db
29 modules
db/assert_row.ts
Assertion helper for INSERT RETURNING results.
db/cell_audit_queries.ts
Audit-log query for the per-cell timeline. Matches rows whose
metadatajsonb names the cell on any of the keys used by cell-domain event envelopes —cell_id(cell mutations + grants),source_id/new_id(clone),source_id/target_id(cell_field events),parent_id/child_id(cell_item events).Each
metadata @> '{...}'::jsonbclause hitsidx_audit_log_metadata(thejsonb_path_opsGIN onaudit_log.metadata, shipped by theaudit_log_metadata_gin_indexmigration); Postgres bitmap-ORs the index scans together.db/cell_ddl.ts
Cell PG schema.
The universal content primitive: a single
celltable whosedataJSONB is interpreted by view shape. Parent→child membership and named relationships live in two sibling tables —cell_item(ordered children, fractional-indexing keyed) andcell_field(named edges).refs text[]carries the fact hashes derived fromdata(string values that are exactly a fact hash, derive_cell_refs) by application code on everydatawrite.Soft delete via
deleted_at. Most indexes are partial ondeleted_at IS NULLso active-cell queries skip tombstones.pathis the global namespace axis — a partial unique index enforces uniqueness across all active rows (PostgreSQL UNIQUE constraints don't support WHERE clauses, so it's expressed as a partial unique index). It additionally filters ondeleted_at IS NULLso a soft-deleted cell doesn't block reuse of its path. Path writes are admin-only at the action layer; user-namespaced paths are a future extension.Ownership columns (
created_by,updated_by) are nullable FKs toactor: NULL = system origin (well-known cells, daemon/agent cells). The non-admin authz path treats NULLcreated_byas admin-only via an explicit equality check (auth/cell_authorize.ts).Timestamp naming (
created_at,updated_at) aligns with fuz_app's_at-everywhere convention used byaccount,actor,audit_log,role_grant, etc.Single-migration shape:
full_cell_schemacreates the canonical cell + cell_grant + cell_field + cell_item layout in one shot from the live exported constants.db/cell_field_queries.ts
Raw queries against the
cell_fieldtable.Named-relation primitive: each row is
(source_id, name) → target_id, a JSON-object-shaped edge from one cell to another.(source_id, name)is unique — one target per name per source. Multiplicity by composition (target a collection cell whoseitems[]are the multi-valued tags), not by allowing duplicate field rows.Reads filter both endpoints by
cell.deleted_at IS NULLso relations dangling off a soft-deleted cell don't surface to the live graph, and the list reads apply the view predicate (db/cell_view_sql.ts) to the far end beforeLIMIT— a page is up tolimitedges whose far end the caller can view.query_cell_field_set upserts on the
(source_id, name)PK so re-pointing a name updatestarget_idin place — JSON-object semantics (obj.foo = baroverwrites whatever was there).db/cell_grant_queries.ts
Raw queries against the
cell_granttable.Resource-side ACL for cells: each row admits a principal at a
level(viewer|editor). Principal is discriminated by which columns are set —actor_id(single actor) xor(role, scope_id?)(any holder of a matching role_grant). Owner is implicit oncell.created_byand never appears in this table.Convention:
deps: QueryDepsfirst, no audit side effects, mutations return the affected row (ornullfor not-found).query_cell_grant_create upserts on the relevant partial unique index so re-granting the same principal updates
levelrather than producing duplicate rows. The two principal shapes use different indexes:- Actor-shaped:
idx_cell_grant_unique_actoron(cell_id, actor_id). - Role-shaped:
idx_cell_grant_unique_role_scopeon(cell_id, role, scope_id)withNULLS NOT DISTINCTso two(role, NULL)grants on the same cell collide.
- Actor-shaped:
db/cell_item_queries.ts
Raw queries against the
cell_itemtable.Ordered-child membership: each row is
(parent_id, position) → child_id.positionis opaque text (fractional-indexing key) — lex ordering is the contract. The PK on(parent_id, position)enforces one cell per slot but allows the samechild_idto appear at multiple positions (the primitive is JSON-array-shaped — ordered multiset, not set; domain dedup rules ride on top).Reads filter both endpoints by
cell.deleted_at IS NULLso items dangling off a soft-deleted cell don't surface, and the list reads apply the view predicate (db/cell_view_sql.ts) to the far end beforeLIMIT— a page is up tolimitedges whose far end the caller can view.query_cell_item_insert returns the inserted row OR throws the underlying
23505(Postgres unique violation) on a `(parent_id, position)` collision. Handlers convert this into thecell_item_position_takenJSON-RPC error so the client retries with a refreshed bracket.db/cell_moderation.ts
The
cell.moderationread rules, in pure and SQL form side by side.moderationis freeTEXTwith no CHECK, so each rule is an allowlist over the literals: a marker this module doesn't name (a typo, a future state) admits nothing.Audience admission — cell_moderation_admits_audience and cell_moderation_admits_audience_sql: unmoderated (
null) or'approved'. A cell's effective audience is its visibility (or grants) and its admission, so the public and grant read arms of can_view_cell require it, as do theeditorgrant arm of can_edit_cell and the matching arms of query_cell_list (general andshared_withshapes). The owner and global-admin arms don't: an author keeps reading and editing their own pending or rejected contribution, and an admin reads everything.Stewardship — cell_moderation_is_stewardable and cell_moderation_is_stewardable_sql: exactly
'pending'or'rejected', the states in which the governing root's manager reads a contribution its audience can't (can_steward_cell). An unknown marker is neither admitted nor stewardable, so only its owner and admins read it.The pure and SQL forms are written once each here, next to each other, over the same literal constants; every predicate and query composes them rather than restating the rule. Twin of the Rust
fuz_cell::cell_moderation.db/cell_queries.ts
Raw queries against the
celltable.Convention:
deps: QueryDepsfirst, no audit side effects, mutations return the affected row (ornullfor not-found).cell.refsis derived fromdataon every create and update via derive_cell_refs (depth-first walk for string values that are exactly a fact hash). Callers never passrefsdirectly — the column is a derived projection ofdata, what fact serving admits a read through and what cell ref admission (auth/cell_ref_admission.ts) gates on a write.Soft delete via
deleted_at. Allget/listqueries exclude tombstones by default;include_deleted: trueopts in for admin / audit views.pathuniqueness is global, enforced byidx_cell_path_unique(partial on active rows). Path reuse after soft delete falls out of the partial index — queries do not need special handling.db/cell_view_sql.ts
can_view_cell in SQL — the view predicate a query applies to a cell row before
LIMIT, so a page holds only rows the caller may view.can_view_cell_sql is the one SQL form of the predicate: query_cell_list embeds it (adding the steward arm beside it), and the relation lists apply it to the cell at the other end of each edge. It binds the caller through a CellViewer (auth/cell_authorize.ts's cell_viewer_from_auth builds one from a request context), and the role-shaped grant arm reads the
caller_role_grantsCTE caller_role_grants_cte defines. Twin of the Rustfuz_cell::cell_view_sql.db/create_db.ts
Database initialization with driver auto-detection.
Selects the appropriate database driver based on
database_url:postgres://orpostgresql://— usespg(PostgreSQL)file://— uses@electric-sql/pglite(file-based)memory://— uses@fuzdev/pglet-wasm(in-memory)
pg,@electric-sql/pgliteand@fuzdev/pglet-wasmare optional peer dependencies, dynamically imported only when needed. For direct driver construction without auto-detection, use db/db_pg.ts, db/db_pglite.ts or db/db_pglet.ts — an in-memory PGlite iscreate_pglite_db(new PGlite()).db/db_checkout_error.ts
DbCheckoutError — a failed pool checkout, kept apart from a failed query.
A request that cannot check a connection out of the pool (the wait timed out, a new connection's connect timed out or was refused, the pool is closed) finds the database unavailable; every request surface answers it with a 503 (
service_unavailable), where a query that fails on a connection already checked out keeps its 500. The pool built by create_pg_pool (db/db_pg.ts) throws one from itsconnect, which per-statementpool.queryand each transaction'spool.connect()both run through. to_thrown_jsonrpc_error (http/jsonrpc_errors.ts) maps it, so the dispatcher, the route catch, and the server'sonErrorall answer it the same way. Twin of the Rust spine'spool.get()failures, which answerfuz_http::db_unavailable/db_unavailable_response.PGlite and pglet have no pool, so neither ever throws one.
db/db_pg.ts
PostgreSQL driver adapter for Db.
Provides
create_pg_pool()to build the pool with a bounded checkout, andcreate_pg_db()to construct a Db backed by it.Checkout timeout
Every checkout from a create_pg_pool pool — the wait for a free client, or a new client's connect — gives up after
checkout_timeout_ms(connectionTimeoutMillis; default DEFAULT_DB_CHECKOUT_TIMEOUT_MS, 5 s) and throws a DbCheckoutError (db/db_checkout_error.ts), which every request surface answers with a 503. A query that fails on a client already checked out throws the driver's error as before (a 500). Per-statementpool.queryand each transaction'spool.connect()both check out through the pool'sconnect, so both are bounded. The wait and the connect are bounded one by one, so a checkout that waits and is then handed a slot to connect in can take about twice the bound.Db.with_checkout_timeoutgives a caller its own bound on the same pool — the audit emitter's pool writes wait DEFAULT_DB_AUDIT_CHECKOUT_TIMEOUT_MS (30 s), so a pool saturated enough to shed requests does not also drop the failure rows recording them. A request that awaits its audit row — a failed login does — can therefore wait up to that bound, not the request's. Twin of the Rustfuz_db::create_pool/PoolOptions.Statically imports only
pgtypes; thepgruntime is dynamically imported where needed — by callers (e.g., create_db) that construct thePool, and by register_pg_type_parsers to reachpg.types. pglite-only consumers never reach either path, sopgstays an optional peer dep.db/db_pglet.ts
pglet driver adapter for Db.
Provides
create_pglet_instance(), which loads@fuzdev/pglet-wasmon demand and creates an in-memory instance with PGLET_COERCION, the value coercion every fuz_app pglet instance is created with, andcreate_pglet_db_driver()to construct a Db backed by one. The package is imported dynamically, so it stays an optional peer.db/db_pglite.ts
db/db.ts
Database wrapper with duck-typed interface.
Accepts any client with a query(text, values) method.
pg.Pool,@electric-sql/pgliteand@fuzdev/pglet-wasmall satisfy this interface.Transaction safety is provided by an injected
transactioncallback — the driver adapters (db/db_pg.ts, db/db_pglite.ts, db/db_pglet.ts) supply the driver-appropriate implementation. Close is handled externally (returned alongside the Db as DbDriverResult), not as a method on this class.db/fact_ddl.ts
Fact + memo + possession PG schema.
Tables:
fact— content-addressed bytes.hash = 'blake3:<hex64>'. Either embedded (bytes) or referenced (external_url); the CHECK constraint enforces exactly one populated. Idempotent: same bytes always produce the same hash, soINSERT … ON CONFLICT DO NOTHINGis the put primitive.fact_ref— declared dependency edges (source fact → target fact).target_hashis intentionally not a foreign key: in federation a reference may target a fact stored on another instance.memo—(fn_id, input_hash) → output_hashfor memoized computations.fact_holder— which actors hold which facts: one row per(actor, fact)the actor has put, the possession record cell ref admission reads.
fact_holder.actor_idreferences the auth schema'sactortable, so the namespace runs afterfuz_auth.The released chain is frozen — every schema change ships as an appended migration. Migrations are tracked by
(namespace, name)with no checksum, so editing a released entry in place never reruns on a database that already recorded it. Never edit, rename, reorder, or re-purpose an entry inFACT_MIGRATIONS; append a new one with idempotent DDL. Each entry is twinned with the Rustfuz_factmigration of the same name and position, statement for statement (the_testing_migration_trackerand schema-parity gates compare the two).db/fact_disk_storage.ts
Filesystem CAS for fact bytes stored outside Postgres — the disk half of PgFactStore, threaded over the injectable
runtime/*Depsrather than rawnode:fs, so it runs unchanged under Node, Deno, and a mock runtime.Large facts (over the embedded threshold) live on disk at the canonical sharded layout
<facts_dir>/<shard>/<rest>—<shard>is the first 2 hex chars of the blake3 digest,<rest>the remaining 62 — with thefactrow carryingexternal_url = file:<shard>/<rest>(disk-root-relative). The layout is single-sourced by fact_disk_path in db/file_fact_url.ts, so the write path here and the URL minted into the row can't drift. The TS twin of the Rustfuz_factdisk CAS.Writes land through
<facts_dir>/.tmp/<rand>.tmp, arefsynced, thenrenamed into the content-addressed final path. Therenameis atomic on POSIX (a *concurrent reader* observing the path sees either the full content or nothing), but atomicity is not durability — thefsyncbefore the rename is what guards against a *host crash* leaving a torn/zero file at a published CAS path, because the serving path streams the hash-named file without re-hashing it (server/serve_fact_route.ts). This twins the Rustfuz_fact§fsync posture: data-sync before the rename; the parent-dir fsync stays deliberately waived (a lost dirent is regenerable under content addressing). The temp is renamed over the final path even when a file is already there: the temp's bytes are the ones just hashed, so the rename is a no-op for a sound file and replaces a corrupt one (a torn write, bit rot) with bytes that match their name — a re-upload heals the CAS..tmp/is a sibling of<shard>/under the samefacts_dirsorenameis always same-filesystem (no EXDEV).db/fact_holder_queries.ts
Raw queries against
fact_holder— the possession record.A
fact_holderrow says an actor has put a fact's bytes. PgFactStore writes one on every put that names a holder (HeldFactPutOptions.holder), deduplicated puts included: identical bytes put again by a second actor make that actor a holder too. Cell ref admission (auth/cell_ref_admission.ts) reads it back — a write may introduce a fact ref only for a fact the acting actor holds.Same discipline as db/fact_queries.ts:
deps: QueryDepsfirst (the caller owns the transaction), no audit side effects, idempotent writes.db/fact_queries.ts
Raw queries against the
factandfact_reftables.Convention:
deps: QueryDepsfirst, no audit side effects, mutations are idempotent (ON CONFLICT DO NOTHING) so the same hash can be written by two callers without the second observing an error.Higher-level lifecycle (verify-on-read, embedded-vs-referenced selection) lives in db/fact_store.ts. Queries here are deliberately mechanical.
db/fact_store_errors.ts
Typed errors thrown by
PgFactStore.put_streamso a file-store route can map them to the canonical wire responses.The Rust twin uses
FactError::PayloadTooLarge/::StorageFull(fuz_fact); these TS classes carry the same two cases so the upload handler can branch identically and return the same status + body shape (413/507).db/fact_store.ts
PG-backed HeldFactStore implementation.
Wraps the raw queries in db/fact_queries.ts with the lifecycle the HeldFactStore interface promises:
- sync hash on
put;put_streamhashes as it streams - idempotent insert (
ON CONFLICT DO NOTHINGin the queries layer) - refs are exactly the caller's
options.refs— the bytes are never scanned for hashes, whatever thecontent_type - verify-on-read for disk-backed content; embedded reads skip verify because PG storage IS the hash table
- mismatched disk bytes return
null+ log warning (treat as unavailable; GC / repair is a separate concern)
Embedded vs disk split: writes route by size. Bytes
<= embedded_thresholdland in the PGbytescolumn; larger bytes go to the disk CAS at<facts_dir>/<shard>/<rest>(db/fact_disk_storage.ts) and the row records afile:<shard>/<rest>external_url.puttakes fully-buffered bytes;put_streamis the bounded-memory streaming twin (hash BLAKE3 as it streams, spill past the threshold, enforcemax_bytes/ENOSPC).putneedsdisk_root+fs(theruntime/*Deps) for the over-threshold path only: without them the store has nowhere to put bytes it cannot embed, and an oversizeputthrows.put_streamneedsfsfor a body of any size — with none it throws before reading the stream — anddisk_rootfor the over-threshold spill: with none, a body that passes the threshold throws FactPayloadTooLargeError.Every
external_urlthe store writes is afile:<shard>/<rest>URL into its own disk CAS, and that CAS is the only placegetreads external bytes from — the store makes no network request. The column is free text (query_put_fact inserts what it is given), sogettreats a row whoseexternal_urlhas any other shape, and any external row on a store with no disk CAS configured, as unavailable: a warning andnull. Afile:URL naming another fact's file (file_fact_url_addresses) is refused the same way, logged as an error, before the file is read.Holders
Every put names its holder (
HeldFactPutOptions.holder): the actor whose request carried the bytes, ornullfor a system put (seeds, server-side imports). A named holder gets afact_holderrow on every put — a deduplicated put included, since bringing identical bytes is possession. Thefactrow, its refs, and the holder row commit in one transaction. Cell ref admission (auth/cell_ref_admission.ts) reads the holder rows. The holder is a spine concept, so PgFactStore implements HeldFactStore — the@fuzdev/fuz_utilFactStorereads plus puts that take HeldFactPutOptions — rather than the backend-agnosticFactStore, whose puts name no actor.- sync hash on
db/file_fact_url.ts
Canonical filesystem-fact URL shape + on-disk layout.
external_urlon thefactrow is free text (string | null), but the only shape PgFactStore writes, reads, or serves is the filesystem-minted one — any other reads as unavailable. Those URLs are exactlyfile:<shard>/<rest>where<shard>is the first 2 hex chars of the blake3 digest and<rest>the remaining 62 — files land at<facts_dir>/<shard>/<rest>after the writer atomically temp+renames them in.Centralizing the regex + the fact_disk_path split keeps the shape in one place: PgFactStore's disk CAS (db/fact_disk_storage.ts), the
serve_fact_routedefense-in-depth checks (shape, then file_fact_url_addresses), and the disk read (read_fact_bytes_from_disk) all derive the layout here, so the write path and the read path can't drift. The TS twin of the Rust fact_disk_path (fuz_fact).Defense-in-depth: a
..segment can't match (.isn't in[0-9a-f]), neither can absolute paths, query strings, or any non-hex character. Used in front ofpath.joinso the resolver never trusts the URL came from a fact row, even though it always does in practice.db/migrate.ts
Identity-tracked database migration runner.
Migrations are named
{name, up}objects in ordered arrays, grouped by namespace. Aschema_versiontable records one row per applied migration —(namespace, name, sequence, applied_at)— and the runner verifies the applied list is a name-prefix of the code's migration array at boot.nameis descriptive identity, not a version ordinal. The runner orders by thesequencecolumn and matchesnameby array position — it never parses the name — sonamecarries *what the migration is* (full_cell_schema,role_grant_offer_and_scoped_role_grants), and the redundant_vNordinal is avoided (it would only duplicatesequence, and acell_v0name tempts editing "v0" in place).nameis half theschema_versionPK (withnamespace), so a rename is an identity change every already-migrated DB sees asname-divergence-at-N. This is also the cross-impl contract: the TS and Rust spines must record byte-identical(namespace, name, sequence)so a consumer can swap backends over one DB.Schema is not stabilized yet — append-only is NOT the rule. While fuz_app is pre-stable, migration bodies, names, and positions can change freely between versions; consumers upgrading across a schema change are expected to drop and re-bootstrap their dev/test databases (production deployments are not yet a supported use case). Once the schema is declared stable a hard append-only-after-publish rule will apply and the cliff will be called out in that release's notes; until then, body edits to a published migration slip past the runner (no content hashing) by design — they're the recommended way to evolve the schema.
Chain-level transactions: All pending migrations in a namespace run in a single transaction. Any failure rolls back every migration in that run — no partial-state recovery. This rules out non-transactional DDL (e.g.,
CREATE INDEX CONCURRENTLY); run those out of band.Chain idempotency, not migration idempotency: the chain-tx wraps every migration replayed in a single boot, so an individual migration may temporarily produce intermediate state that a later migration reverses (e.g. v0's ROLE_GRANT_INDEXES recreates an index that v1 drops; chain-tx hides this from observers). What matters is that the *committed end state* matches; the in-tx steps may not be individually idempotent against an arbitrary mid-chain target.
Forward-only: No down-migrations. Schema changes are additive.
Advisory locking: Per-namespace
pg_advisory_lockreduces contention in multi-instance deployments — best-effort, not load-bearing. The locks are session-scoped, butDb.queryruns against a pool that may check out a different backend per call, so two concurrent boots can both "hold" the lock on different sessions. The real serialization comes from chain- tx atomicity + the(namespace, name)PK onschema_version: the loser's INSERT hits a PK violation, the chain-tx rolls back, and the next boot reads the committed state and proceeds cleanly. Environments withoutpg_advisory_lock(pglet) silently fall through.db/pg_error.ts
PostgreSQL error utilities.
Works with both
pgand@electric-sql/pglite— both set.codeon error objects using standard PostgreSQL error codes.db/query_deps.ts
Shared query dependency type.
All
query_*functions takedeps: QueryDepsas their first argument. Widened per-function when additional capabilities are needed (e.g.,logfor token validation).db/schema_ready.ts
Readiness probe core: live-DB schema-drift detection.
/healthis a dumb liveness probe (no DB)./readyis the deploy gate — it introspects the live database's column set and compares it against a committed expected column map (what a fresh full migration-chain bootstrap produces). A live DB missing an expected column is exactly the failure mode that silently broke login when the auth schema gainedaccount.deleted_atvia an in-place base-DDL edit instead of an appended migration: the deployed code required a column an older bootstrapped DB never got, and aSELECT *+ JSdeleted_at === nullfilter rejected every account. The/readyroute (http/common_routes.ts) turns that drift into a loud503so a deploy poll rolls the release back instead of promoting code that can't authenticate anyone. The discipline that prevents the drift is the frozen append-only migration chain (auth/migrations.ts); this probe is the runtime net for a lapse.The check is intentionally column-presence only — not type / constraint / index parity. Column names are DDL-deterministic and engine-portable, so a map generated against PGlite at gen-time compares exactly against a live Postgres at runtime; finer-grained parity would false-positive across the two engines, and a false positive here means a rolled-back deploy — an outage you caused. Full structural parity stays the dev-time cross-backend schema-snapshot suite's job (testing/schema_introspect.ts). In-place *type* changes (a column kept by name, retyped) are out of scope — they rely on the query-time column-named failures instead.
This module is pure DB introspection + comparison: no HTTP, no filesystem, no fixture-path knowledge. The route factory and the committed-fixture loader live in http/common_routes.ts; the gen-time fixture-regeneration helper lives in testing/schema_ready_fixture.ts.
db/sql_columns.ts
Helpers for the named-column projection consts (ACCOUNT_COLUMNS, CELL_COLUMNS, …) — per-table column-name arrays that every table read projects through so a dropped or leftover column fails loud instead of vanishing under
SELECT *.Each query module exports its table's const and derives every projection it needs from it at the read site — columns_sql for single-table reads, qualify_columns for reads that alias the table, omit_columns for a client-safe subset — rather than spelling a second column list, so the drift guard on the base const (assert_columns_match_live in testing/db.ts) covers every derived projection too. Twin of the Rust spine's
*_COLUMNSconsts +fuz_db::qualify_columns/ omit_columns.A read that needs a column rendered as something other than the bare reference passes a ColumnExpr — the
exproverride, twin of the third argument tofuz_db::qualify_columns. Every timestamp that reaches the wire projects through iso8601_timestamp_column so both spines serialize the same instant to the same bytes; each query module builds its override with iso8601_timestamp_expr over its table const. An override costs one rule — a projectedORDER BYmust qualify its column — stated on qualify_columns.Placement rule: a const lives in its table's query module. When two query modules project the same table (the revoke cascades in auth/role_grant_queries.ts supersede
role_grant_offerrows while the offer module's accept path writesrole_grantrows), it lives in the table's*_ddl.tsinstead — the module both already import — so the query modules stay acyclic.Column arrays are literal consts; the alias passed to qualify_columns is the only input these helpers treat as dynamic (and validate). Each const also carries
satisfies ReadonlyArray<keyof Row>so a column name the row type doesn't know is a compile error.db/sql_identifier.ts
SQL identifier validation for the places an identifier must be interpolated rather than parameterized.
PostgreSQL parameterizes values only — table and column names in DDL (DROP TABLE, TRUNCATE, ALTER) and the alias qualify_columns prefixes onto a projection (db/sql_columns.ts) have to be string-interpolated. This validator ensures such identifiers are safe to interpolate.
db/status.ts
Database status utility for CLI and dev workflows.
Queries migration state and table info without a running server. Returns structured data that consumer scripts can print however they like.
The migration check is name-divergence aware: it name-prefix-verifies the applied migrations against the code's list (mirroring run_migrations), so a divergent history (same count, different names) reports a
divergenceand rendersDIVERGEDrather than a falseup_to_date.