Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ OPENAI_API_KEY=
# Every setting below ALSO has a config-file equivalent in repos.toml's
# [db]/[agent]/[server] sections; the env var wins when both are set. See the
# README "Configuration" matrix. Uncomment only to override the default shown.
# TIMEPLUS_DATABASE=tpk # [db].database database all tpk streams live under
# TPK_DB_BACKEND=timeplusd # [db].backend timeplusd | proton
# TPK_STREAM_PREFIX= # [db].stream_prefix namespace all streams
# TPK_STREAM_PREFIX= # [db].stream_prefix namespace streams within the database
# TPK_DB_WAIT_SECONDS=60 # [db].wait_seconds serve: DB connect retry budget
# TPK_SESSION_TTL=86400 # [server].session_ttl login session lifetime (s)
# TPK_CHAT_AUDIT=1 # [server].chat_audit false/0 disables chat auditing
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export the matching env var — whichever suits your deployment. Secrets are
|---|---|---|---|
| `TIMEPLUS_HOST` | `[db].host` | `localhost` | DB host |
| `TIMEPLUS_USER` | `[db].user` | `default` | DB user |
| `TIMEPLUS_DATABASE` | `[db].database` | `tpk` | Database all tpk streams live under |
| `TPK_DB_BACKEND` | `[db].backend` | `timeplusd` | Stream-semantics mode (`timeplusd`\|`proton`) |
| `TPK_STREAM_PREFIX` | `[db].stream_prefix` | `` | Namespace prefix for all streams |
| `TPK_DB_WAIT_SECONDS` | `[db].wait_seconds` | `60` | `serve`: DB connect retry budget |
Expand All @@ -83,6 +84,22 @@ Secrets — **env-only**, never in the file: `TIMEPLUS_PASSWORD`,
`ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` / `*_MODEL` endpoint variables the LLM
clients read directly).

### The `tpk` database

All tpk streams (`kg_nodes`, `kg_users`, `chat_audit_log`, …) live under a
dedicated database — **`tpk`** by default, set via `TIMEPLUS_DATABASE` /
`[db].database`. tpk creates it on startup (`CREATE DATABASE IF NOT EXISTS`) and
qualifies every stream as `tpk.<name>`, so its objects don't clutter the server's
`default` database and can be granted or dropped as a unit. This matters most
when pointing tpk at a **shared** Timeplus Enterprise (the k8s app-only mode):
the connecting user needs `CREATE DATABASE` (first run) plus read/write on `tpk`.

**Upgrading an existing deployment:** streams created before this change live in
`default` and are **not** moved automatically — after upgrading, tpk looks in
`tpk` and finds an empty graph. Either re-ingest (`tpk ingest`), or carry data
over with `tpk export` (old version) → `tpk import` (new version). Point
`TIMEPLUS_DATABASE=default` to keep using the old location instead.

## Docker compose (quickest start)

Two deployment modes are provided:
Expand Down
1 change: 1 addition & 0 deletions deploy/docker/repos.container.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ token_budget = 16000
[db]
# host = "localhost" # env: TIMEPLUS_HOST (DB+App image sets this to "db")
# user = "default" # env: TIMEPLUS_USER (images set this to "tpk")
# database = "tpk" # env: TIMEPLUS_DATABASE (all tpk streams live here)
# backend = "timeplusd" # env: TPK_DB_BACKEND (all-in-one image bakes "proton")
# stream_prefix = "" # env: TPK_STREAM_PREFIX
# wait_seconds = 60 # env: TPK_DB_WAIT_SECONDS
Expand Down
9 changes: 7 additions & 2 deletions docker-compose.allinone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ services:
# CANNOT_LOAD_CONFIG), so the two deployments must not share a data
# volume. Same compose project name would otherwise collide on `tpk-data`.
- tpk-proton-data:/var/lib/proton
- tpk-checkouts:/opt/tpk/.checkouts
# Distinct checkout cache from the DB+App file's `tpk-checkouts`. This
# container runs as UID 101 (proton), while the DB+App `app` container
# runs as root -- a shared cache leaves git checkouts owned by one user
# that the other can't operate on ("git ... detected dubious ownership").
# Same compose project name would otherwise collide on `tpk-checkouts`.
- tpk-proton-checkouts:/opt/tpk/.checkouts
environment:
# TIMEPLUS_HOST defaults to localhost and TPK_DB_BACKEND=proton is baked
# into the image (serve talks to the proton running in this container).
Expand All @@ -57,4 +62,4 @@ services:

volumes:
tpk-proton-data:
tpk-checkouts:
tpk-proton-checkouts:
3 changes: 2 additions & 1 deletion repos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ token_budget = 16000
[db]
# host = "localhost" # env: TIMEPLUS_HOST
# user = "default" # env: TIMEPLUS_USER
# database = "tpk" # env: TIMEPLUS_DATABASE (all tpk streams live here)
# backend = "timeplusd" # env: TPK_DB_BACKEND ("timeplusd" | "proton")
# stream_prefix = "" # env: TPK_STREAM_PREFIX (namespaces all streams)
# stream_prefix = "" # env: TPK_STREAM_PREFIX (namespaces streams within the database)
# wait_seconds = 60 # env: TPK_DB_WAIT_SECONDS (serve: DB connect retry budget)

[agent]
Expand Down
4 changes: 2 additions & 2 deletions src/tpk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,13 +263,13 @@ def list_repos(actor: User = Depends(auth.require_cap(auth_mod.CAP_CORPUS_VIEW))
entries = corpus.list_entries(client, prefix=prefix)
counts = dict(
client.query(
f"SELECT repo, count() FROM {db.latest(f'{prefix}kg_nodes')} GROUP BY repo"
f"SELECT repo, count() FROM {db.latest(db.qualified('kg_nodes', prefix))} GROUP BY repo"
).result_rows
)
last: dict[str, tuple] = {}
for repo, sha, status, t in client.query(
f"SELECT repo, arg_max(git_sha, _tp_time), arg_max(status, _tp_time),"
f" max(_tp_time) FROM table({prefix}kg_ingest_log) GROUP BY repo"
f" max(_tp_time) FROM table({db.qualified('kg_ingest_log', prefix)}) GROUP BY repo"
).result_rows:
last[repo] = (sha, status, t)
out = []
Expand Down
11 changes: 7 additions & 4 deletions src/tpk/audit.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Chat support-history audit: one row per chat turn written to the
append-only ``{prefix}chat_audit_log`` stream (see db.ensure_schema).
append-only ``chat_audit_log`` stream (qualified as ``<database>.<prefix>
chat_audit_log`` via db.qualified; see db.ensure_schema).

The sink is deliberately best-effort: auditing must never break or slow the
chat response, so every write is wrapped and any failure is logged and
Expand All @@ -12,6 +13,8 @@
from dataclasses import dataclass, field
from datetime import datetime

from tpk import db

logger = logging.getLogger(__name__)

# Insert column order for {prefix}chat_audit_log. Kept next to AuditRecord.to_row
Expand Down Expand Up @@ -81,8 +84,8 @@ def to_row(self) -> list:


def make_db_sink(prefix: str, client_factory):
"""Return a ``sink(record: AuditRecord) -> None`` that inserts into
``{prefix}chat_audit_log``.
"""Return a ``sink(record: AuditRecord) -> None`` that inserts into the
``chat_audit_log`` stream (qualified via db.qualified).

``client_factory`` is called per write to obtain a Timeplus client -- a
fresh, single-use session avoids timeplus_connect's concurrent-query
Expand All @@ -96,7 +99,7 @@ def sink(record: AuditRecord) -> None:
try:
client = client_factory()
client.insert(
f"{prefix}chat_audit_log",
db.qualified("chat_audit_log", prefix),
[record.to_row()],
column_names=CHAT_AUDIT_COLUMNS,
)
Expand Down
30 changes: 15 additions & 15 deletions src/tpk/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def _now():

def upsert_user(client, user: User, prefix: str = "") -> None:
client.insert(
f"{prefix}kg_users",
db.qualified("kg_users", prefix),
[[user.username, user.password_hash, user.role,
user.must_change_password, user.disabled, _now(), _now()]],
column_names=_USER_COLUMNS,
Expand All @@ -145,7 +145,7 @@ def upsert_user(client, user: User, prefix: str = "") -> None:
def get_user(client, username: str, prefix: str = "") -> User | None:
rows = client.query(
f"SELECT username, password_hash, role, must_change_password, disabled"
f" FROM {db.latest(f'{prefix}kg_users')} WHERE username = %(u)s",
f" FROM {db.latest(db.qualified('kg_users', prefix))} WHERE username = %(u)s",
parameters={"u": username},
).result_rows
if not rows:
Expand All @@ -157,19 +157,19 @@ def get_user(client, username: str, prefix: str = "") -> User | None:
def list_users(client, prefix: str = "") -> list[User]:
rows = client.query(
f"SELECT username, password_hash, role, must_change_password, disabled"
f" FROM {db.latest(f'{prefix}kg_users')} ORDER BY username"
f" FROM {db.latest(db.qualified('kg_users', prefix))} ORDER BY username"
).result_rows
return [User(u, h, r, bool(mc), bool(dis)) for u, h, r, mc, dis in rows]


def delete_user(client, username: str, prefix: str = "") -> None:
db.delete(client, f"{prefix}kg_users", "username = %(u)s",
db.delete(client, db.qualified("kg_users", prefix), "username = %(u)s",
{"u": username}, ("username",))


def admin_count(client, prefix: str = "") -> int:
rows = client.query(
f"SELECT count() FROM {db.latest(f'{prefix}kg_users')}"
f"SELECT count() FROM {db.latest(db.qualified('kg_users', prefix))}"
f" WHERE role = %(r)s AND NOT disabled",
parameters={"r": ROLE_ADMIN},
).result_rows
Expand All @@ -192,7 +192,7 @@ def seed_admin(client, prefix: str = "") -> bool:

def upsert_role(client, role: Role, prefix: str = "") -> None:
client.insert(
f"{prefix}kg_roles",
db.qualified("kg_roles", prefix),
[[role.name, json.dumps(role.entry_keys), json.dumps(role.capabilities),
role.description, _now()]],
column_names=_ROLE_COLUMNS,
Expand All @@ -201,7 +201,7 @@ def upsert_role(client, role: Role, prefix: str = "") -> None:

def get_role(client, name: str, prefix: str = "") -> Role | None:
rows = client.query(
f"SELECT name, entry_keys, capabilities, description FROM {db.latest(f'{prefix}kg_roles')}"
f"SELECT name, entry_keys, capabilities, description FROM {db.latest(db.qualified('kg_roles', prefix))}"
f" WHERE name = %(n)s",
parameters={"n": name},
).result_rows
Expand All @@ -213,20 +213,20 @@ def get_role(client, name: str, prefix: str = "") -> Role | None:

def list_roles(client, prefix: str = "") -> list[Role]:
rows = client.query(
f"SELECT name, entry_keys, capabilities, description FROM {db.latest(f'{prefix}kg_roles')}"
f"SELECT name, entry_keys, capabilities, description FROM {db.latest(db.qualified('kg_roles', prefix))}"
f" ORDER BY name"
).result_rows
return [Role(n, json.loads(k) if k else [], d, _parse_capabilities(c))
for n, k, c, d in rows]


def delete_role(client, name: str, prefix: str = "") -> None:
db.delete(client, f"{prefix}kg_roles", "name = %(n)s", {"n": name}, ("name",))
db.delete(client, db.qualified("kg_roles", prefix), "name = %(n)s", {"n": name}, ("name",))


def usernames_with_role(client, name: str, prefix: str = "") -> list[str]:
rows = client.query(
f"SELECT username FROM {db.latest(f'{prefix}kg_users')} WHERE role = %(r)s"
f"SELECT username FROM {db.latest(db.qualified('kg_users', prefix))} WHERE role = %(r)s"
f" ORDER BY username",
parameters={"r": name},
).result_rows
Expand All @@ -242,7 +242,7 @@ def _token_hash(token: str) -> str:
def create_session(client, username: str, ttl_seconds: int, prefix: str = "") -> str:
token = secrets.token_urlsafe(32)
client.insert(
f"{prefix}kg_sessions",
db.qualified("kg_sessions", prefix),
[[_token_hash(token), username,
_now() + timedelta(seconds=ttl_seconds), _now()]],
column_names=_SESSION_COLUMNS,
Expand All @@ -253,22 +253,22 @@ def create_session(client, username: str, ttl_seconds: int, prefix: str = "") ->
def get_session(client, token: str, prefix: str = "") -> str | None:
h = _token_hash(token)
rows = client.query(
f"SELECT username, expires_at FROM {db.latest(f'{prefix}kg_sessions')}"
f"SELECT username, expires_at FROM {db.latest(db.qualified('kg_sessions', prefix))}"
f" WHERE token_hash = %(h)s",
parameters={"h": h},
).result_rows
if not rows:
return None
username, expires_at = rows[0]
if expires_at.replace(tzinfo=timezone.utc) < _now():
db.delete(client, f"{prefix}kg_sessions", "token_hash = %(h)s",
db.delete(client, db.qualified("kg_sessions", prefix), "token_hash = %(h)s",
{"h": h}, ("token_hash",))
return None
return username


def delete_session(client, token: str, prefix: str = "") -> None:
db.delete(client, f"{prefix}kg_sessions", "token_hash = %(h)s",
db.delete(client, db.qualified("kg_sessions", prefix), "token_hash = %(h)s",
{"h": _token_hash(token)}, ("token_hash",))


Expand All @@ -279,7 +279,7 @@ def delete_user_sessions(client, username: str, prefix: str = "",
if keep_token is not None:
where += " AND token_hash != %(k)s"
params["k"] = _token_hash(keep_token)
db.delete(client, f"{prefix}kg_sessions", where, params, ("token_hash",))
db.delete(client, db.qualified("kg_sessions", prefix), where, params, ("token_hash",))


# -- HTTP layer ------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/tpk/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def status():
rows = client.query(
"SELECT repo, max(_tp_time) AS last_run, arg_max(status, _tp_time) AS status,"
" arg_max(nodes, _tp_time) AS nodes, arg_max(edges, _tp_time) AS edges"
" FROM table(kg_ingest_log) GROUP BY repo ORDER BY repo"
f" FROM table({db.qualified('kg_ingest_log')}) GROUP BY repo ORDER BY repo"
).result_rows
for repo, last_run, status_, nodes, edges in rows:
typer.echo(f"{repo:35s} {status_:7s} {nodes:>8} nodes {edges:>8} edges {last_run}")
Expand Down
14 changes: 14 additions & 0 deletions src/tpk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ def db_backend() -> str:
return backend


def database() -> str:
"""The database all tpk streams live under (env > [db].database > 'tpk').
tpk qualifies every stream as `<database>.<prefix><name>` (see #58) so its
objects don't clutter the server's `default` database and can be granted /
dropped as a unit. Read module-level (like db_backend) so db.py helpers can
qualify names without threading it through every signature."""
name = setting("TIMEPLUS_DATABASE", "db", "database", "tpk")
if not name.replace("_", "").isalnum():
raise ValueError(
f"TIMEPLUS_DATABASE / [db].database must be alphanumeric/underscore, got {name!r}"
)
return name


@dataclass(frozen=True)
class RepoConfig:
name: str
Expand Down
14 changes: 7 additions & 7 deletions src/tpk/corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,20 @@ def _to_cfg(row) -> RepoConfig:


def upsert_entry(client, cfg: RepoConfig, prefix: str = "") -> None:
client.insert(f"{prefix}kg_repos", [_row(cfg)], column_names=_COLUMNS)
client.insert(db.qualified("kg_repos", prefix), [_row(cfg)], column_names=_COLUMNS)


def list_entries(client, prefix: str = "") -> list[RepoConfig]:
rows = client.query(
f"SELECT {', '.join(_COLUMNS)} FROM {db.latest(f'{prefix}kg_repos')}"
f"SELECT {', '.join(_COLUMNS)} FROM {db.latest(db.qualified('kg_repos', prefix))}"
" ORDER BY name, ref"
).result_rows
return [_to_cfg(r) for r in rows]


def find_entry(client, name: str, ref: str, prefix: str = "") -> RepoConfig | None:
rows = client.query(
f"SELECT {', '.join(_COLUMNS)} FROM {db.latest(f'{prefix}kg_repos')}"
f"SELECT {', '.join(_COLUMNS)} FROM {db.latest(db.qualified('kg_repos', prefix))}"
" WHERE name = %(n)s AND ref = %(r)s",
parameters={"n": name, "r": ref},
).result_rows
Expand All @@ -66,19 +66,19 @@ def delete_entry(
cfg = find_entry(client, name, ref, prefix=prefix)
if cfg is None:
return False
db.delete(client, f"{prefix}kg_repos", "name = %(n)s AND ref = %(r)s",
db.delete(client, db.qualified("kg_repos", prefix), "name = %(n)s AND ref = %(r)s",
{"n": name, "r": ref}, ("name", "ref"))
if purge:
key = entry_key(cfg)
db.delete(client, f"{prefix}kg_nodes", "repo = %(k)s", {"k": key}, ("id",))
db.delete(client, f"{prefix}kg_edges", "repo = %(k)s", {"k": key},
db.delete(client, db.qualified("kg_nodes", prefix), "repo = %(k)s", {"k": key}, ("id",))
db.delete(client, db.qualified("kg_edges", prefix), "repo = %(k)s", {"k": key},
("src", "dst", "rel"))
return True


def enabled_keys(client, prefix: str = "") -> list[str]:
rows = client.query(
f"SELECT name, ref FROM {db.latest(f'{prefix}kg_repos')} WHERE enabled"
f"SELECT name, ref FROM {db.latest(db.qualified('kg_repos', prefix))} WHERE enabled"
" ORDER BY name, ref"
).result_rows
return [f"{n}@{r}" if r else n for n, r in rows]
Expand Down
Loading
Loading