@ai-matrx/data 0.22.33 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +34 -0
  3. package/bin/direct-db.mjs +80 -0
  4. package/bin/emit.mjs +380 -0
  5. package/bin/generate.mjs +225 -0
  6. package/bin/matrx-data.mjs +49 -0
  7. package/description/admin.json.gz +0 -0
  8. package/description/agent.json.gz +0 -0
  9. package/description/ai.json.gz +0 -0
  10. package/description/api.json.gz +0 -0
  11. package/description/app.json.gz +0 -0
  12. package/description/assignment.json.gz +0 -0
  13. package/description/audit.json.gz +0 -0
  14. package/description/batch.json.gz +0 -0
  15. package/description/billing.json.gz +0 -0
  16. package/description/browser.json.gz +0 -0
  17. package/description/canvas.json.gz +0 -0
  18. package/description/chat.json.gz +0 -0
  19. package/description/code.json.gz +0 -0
  20. package/description/commerce.json.gz +0 -0
  21. package/description/communication.json.gz +0 -0
  22. package/description/content.json.gz +0 -0
  23. package/description/content_ir.json.gz +0 -0
  24. package/description/context.json.gz +0 -0
  25. package/description/crm.json.gz +0 -0
  26. package/description/custom.json.gz +0 -0
  27. package/description/dictionary.json.gz +0 -0
  28. package/description/docproc.json.gz +0 -0
  29. package/description/education.json.gz +0 -0
  30. package/description/esign.json.gz +0 -0
  31. package/description/extend.json.gz +0 -0
  32. package/description/files.json.gz +0 -0
  33. package/description/growth.json.gz +0 -0
  34. package/description/hindsight.json.gz +0 -0
  35. package/description/history.json.gz +0 -0
  36. package/description/iam.json.gz +0 -0
  37. package/description/index.json.gz +0 -0
  38. package/description/interview.json.gz +0 -0
  39. package/description/knowledge.json.gz +0 -0
  40. package/description/legal.json.gz +0 -0
  41. package/description/mandate.json.gz +0 -0
  42. package/description/marketing.json.gz +0 -0
  43. package/description/media.json.gz +0 -0
  44. package/description/meta.json.gz +0 -0
  45. package/description/ops.json.gz +0 -0
  46. package/description/pdf.json.gz +0 -0
  47. package/description/plan.json.gz +0 -0
  48. package/description/platform.json.gz +0 -0
  49. package/description/podcast.json.gz +0 -0
  50. package/description/projects.json.gz +0 -0
  51. package/description/public.json.gz +0 -0
  52. package/description/rag.json.gz +0 -0
  53. package/description/research.json.gz +0 -0
  54. package/description/runtime.json.gz +0 -0
  55. package/description/scheduler.json.gz +0 -0
  56. package/description/scraper.json.gz +0 -0
  57. package/description/seo.json.gz +0 -0
  58. package/description/skill.json.gz +0 -0
  59. package/description/social.json.gz +0 -0
  60. package/description/tool.json.gz +0 -0
  61. package/description/transcripts.json.gz +0 -0
  62. package/description/ui.json.gz +0 -0
  63. package/description/users.json.gz +0 -0
  64. package/description/web.json.gz +0 -0
  65. package/description/work.json.gz +0 -0
  66. package/description/workbench.json.gz +0 -0
  67. package/description/workflow.json.gz +0 -0
  68. package/dist/db.cjs +274 -19
  69. package/dist/db.cjs.map +1 -1
  70. package/dist/db.d.cts +270 -3
  71. package/dist/db.d.ts +270 -3
  72. package/dist/db.js +270 -15
  73. package/dist/db.js.map +1 -1
  74. package/package.json +9 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.0 — doors hardened (independent review)
4
+
5
+ - **Every write on a versioned table is guarded.** `remove` (and `archive` / `restore`) take the same
6
+ `{ expectedVersion }` / `{ overwrite: true }` guard as `update`; `expectedVersion` on an UNversioned
7
+ table is refused (it used to be ignored silently). `remove` returns `{ status }` (or a conflict).
8
+ - **Reads exclude soft-deleted rows** by default (`read`, `listAll`, `page`, `count`); `includeDeleted: true` opts in.
9
+ - **`listAll` / `page` order by the full primary key** (stable paging on composite keys).
10
+ - **Types:** ops infer `TableDescriptor<Row, Insert, Update>` directly, so an insert error names the
11
+ table's Insert type; `read`/`listAll`/`page` take `columns: [...]` and type the result to them; the
12
+ `Where` callback receives a typed `Filter<Row>` (column names and value types checked).
13
+ - **Pay-only runtime tables:** `--flags-index` writes `<schema>.flags.ts` per schema plus
14
+ `flags-index.ts` loaders; `loadTableByName(flagLoaders, "schema.table")` loads only that schema.
15
+ - **The description ships in the package** (`description/*.json.gz`, 0.5 MB): `matrx-data generate`
16
+ needs no repository or database access (`--from` still takes a fresher one). README documents it.
17
+ - `matrx-data emit` accounts for every callable-door register row: `index.json` →
18
+ `report.registeredDoorsOutsideDescription` by reason, and the emit fails if any row is unexplained.
19
+
20
+ Consumer action: `remove(db, t, id)` on a versioned table now needs a guard and returns `{ status }`;
21
+ `read(db, t, id, "cols")` becomes `read(db, t, id, { columns: [...] })`. No consumer used 0.23.0's doors yet.
22
+
23
+ ## 0.23.0 — the doors: one way to talk to the database
24
+
25
+ - **`matrx-data` CLI** (`bin`): `matrx-data emit [--out db-contract] [--check]` reads the LIVE database
26
+ (read-only, every statement in `begin transaction read only`) into the ONE language-neutral database
27
+ description — per exposed schema: tables/views with columns, keys, grants per role, the table-registry
28
+ flags, lifecycle columns, the organization rule (entity binding · parent via `inherit_org_from_parent`),
29
+ realtime publications; every function any client role may execute (overloads kept apart, register
30
+ metadata or `registered: false`); enums; `index.json` with the contract hash. `--check` exits 1 on drift.
31
+ `matrx-data generate --from <description> --out <dir> [--schemas a,b] [--flags-index] [--check]` writes
32
+ typed descriptors INSIDE the app (one self-contained file per schema; one top-level `const` per table
33
+ and function), so a new column never waits on a package release.
34
+ - **`@ai-matrx/data/db` doors** (additive): `createDb({ client, organization })` wraps the app's existing
35
+ Supabase client (no second auth client or socket) and hands the session to `@ai-matrx/api` as its
36
+ `user` credential (`db.credentials`); `createServiceDb` (server only, throws in a browser);
37
+ `read`, `listAll` (complete past 1,000 rows), `page`, `count`, `insert` (entity → named or active
38
+ organization; child → never the active organization, the parent's comes from the database),
39
+ `update` (`{ expectedVersion }` guarded or `{ overwrite: true }` on versioned tables), `remove`
40
+ (soft delete; archive-only tables refuse), `archive` / `restore`, `call` (functions), `tableByName`
41
+ (runtime-chosen tables over the flags index), `changesOf` (published tables only); the door set by
42
+ registry type refused before the wire (`DoorRefusedError`).
43
+
44
+ Consumer action: none required (additive; nothing existing changed). To adopt: add
45
+ `"db:generate": "matrx-data generate --from <aidream>/db-contract --out src/db/generated --schemas <yours>"`,
46
+ then `createDb({ client: <your existing client>, organization: () => activeOrgId })` and call the doors.
47
+ Migration of existing call sites starts at the data layer design's step 5.
48
+
3
49
  ## 0.22.33
4
50
 
5
51
  Automatic changed-only republish (docs/metadata drift since the last tag — see
package/README.md CHANGED
@@ -32,6 +32,40 @@ npm install @ai-matrx/data
32
32
  | `createActiveOrgCookie` | ONE browser memory of the organization a person is acting in, shared by every Matrx surface on the apex (`matrx-active-org`, `Domain=.aimatrx.com`): identity-keyed (`<userId>:<orgId>`), domain-wide only on a listed apex, host-only elsewhere. Stores the stored-selection rung of the canonical order; never decides. |
33
33
  | `uniqueChannelTopic` | Realtime channel hygiene: unique per-mount topics so React 19 double-invoked effects and Fast Refresh can never collide with a still-joined channel. |
34
34
 
35
+ ## The doors — one way to talk to the database
36
+
37
+ Every app talks to the database through doors over descriptors it **generates locally** from the
38
+ ONE database description. The description ships inside this package (`description/`, gzipped, the one
39
+ this version was built with), so no repository or database access is needed.
40
+
41
+ ```bash
42
+ # in your app (package.json: "db:generate": "matrx-data generate --out src/db/generated --schemas workbench,chat")
43
+ npx matrx-data generate --out src/db/generated --schemas workbench # one file per schema
44
+ npx matrx-data generate --out src/db/generated --schemas workbench --check # CI: fail when stale
45
+ npx matrx-data generate --out src/db/generated --flags-index # + per-schema flags for tableByName
46
+ # fresher than this package version? With the direct-DB variables (SUPABASE_MATRIX_*):
47
+ npx matrx-data emit --out db-contract && npx matrx-data generate --from db-contract --out src/db/generated
48
+ ```
49
+
50
+ ```ts
51
+ import { createDb, listAll, read, insert, update, remove, call } from "@ai-matrx/data/db";
52
+ import { noteFolders, notes, noteFolderGetOrCreate } from "./db/generated/workbench";
53
+
54
+ const db = createDb({ client: supabase, organization: () => activeOrgId }); // wraps YOUR client
55
+ const pinned = await listAll(db, notes, (q) => q.eq("is_pinned", true)); // complete; typed filter; live rows
56
+ const row = await read(db, notes, id, { columns: ["id", "label"] }); // typed to those columns
57
+ await insert(db, notes, { folder_id, label }); // child: the folder's organization, never the active one
58
+ await update(db, notes, id, { label }, { expectedVersion: row.version }); // or { overwrite: true }
59
+ await remove(db, notes, id, { expectedVersion: v }); // soft delete; archive-only tables refuse
60
+ await call(db, noteFolderGetOrCreate, { p_name: "Clients" });
61
+ // @ai-matrx/api: pass db.credentials as its `credentials` option — one sign-in feeds both lanes
62
+ ```
63
+
64
+ Rules come from the description (the live table registry), never the call site: organization by the
65
+ access ladder; reads never filter by the active organization and exclude soft-deleted rows unless
66
+ `includeDeleted`; `listAll` is complete and ordered by the full primary key; every write on a versioned
67
+ table is guarded; the door set follows the registry type; `createServiceDb` is server-only.
68
+
35
69
  ## `@ai-matrx/data/net`
36
70
 
37
71
  | Export | What it does |
@@ -0,0 +1,80 @@
1
+ /**
2
+ * direct-db.mjs — @ai-matrx/data's BUILD-TIME read of the live catalogue (the
3
+ * `matrx-data emit` step). Never part of a runtime entry point.
4
+ *
5
+ * Connection: the SAME five variables every Matrx tool resolves —
6
+ * SUPABASE_MATRIX_USER / PASSWORD / HOST / PORT / DATABASE_NAME — from the
7
+ * environment, then `.env.local` / `.env` up the tree, then a sibling
8
+ * `aidream/.env`. No second candidate variable, no fallback database.
9
+ * The password rides PGPASSWORD in psql's environment; it is never printed.
10
+ *
11
+ * READ-ONLY, always: every statement runs inside `begin transaction read only`.
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+
17
+ export const DB_VARS = [
18
+ "SUPABASE_MATRIX_USER",
19
+ "SUPABASE_MATRIX_PASSWORD",
20
+ "SUPABASE_MATRIX_HOST",
21
+ "SUPABASE_MATRIX_PORT",
22
+ "SUPABASE_MATRIX_DATABASE_NAME",
23
+ ];
24
+
25
+ function parseEnvFile(file) {
26
+ const out = {};
27
+ for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
28
+ const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
29
+ if (m) out[m[1]] = (m[2] ?? "").replace(/^["']|["']$/g, "");
30
+ }
31
+ return out;
32
+ }
33
+
34
+ const complete = (bag) => DB_VARS.every((k) => bag[k]);
35
+
36
+ export function connection() {
37
+ if (complete(process.env)) return { bag: process.env, from: "the environment" };
38
+ const seen = new Set();
39
+ let dir = process.cwd();
40
+ for (;;) {
41
+ for (const file of [join(dir, ".env.local"), join(dir, ".env"), join(dir, "aidream", ".env")]) {
42
+ if (seen.has(file) || !existsSync(file)) continue;
43
+ seen.add(file);
44
+ const bag = parseEnvFile(file);
45
+ if (complete(bag)) return { bag, from: file };
46
+ }
47
+ const parent = dirname(dir);
48
+ if (parent === dir) return null;
49
+ dir = parent;
50
+ }
51
+ }
52
+
53
+ function psqlBinary() {
54
+ try {
55
+ const prefix = execFileSync("brew", ["--prefix", "libpq"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
56
+ if (existsSync(join(prefix, "bin", "psql"))) return join(prefix, "bin", "psql");
57
+ } catch {
58
+ /* fall through */
59
+ }
60
+ return "psql";
61
+ }
62
+
63
+ /** Run one read-only SELECT that returns a single json value; returns the parsed value. */
64
+ export function readJson(sql) {
65
+ const conn = connection();
66
+ if (!conn) {
67
+ throw new Error(`matrx-data: none of ${DB_VARS.join(", ")} resolvable (environment, .env up the tree, aidream/.env)`);
68
+ }
69
+ const b = conn.bag;
70
+ const out = execFileSync(
71
+ psqlBinary(),
72
+ [
73
+ "-h", b.SUPABASE_MATRIX_HOST, "-p", b.SUPABASE_MATRIX_PORT, "-U", b.SUPABASE_MATRIX_USER,
74
+ "-d", b.SUPABASE_MATRIX_DATABASE_NAME, "-X", "-q", "-At", "-v", "ON_ERROR_STOP=1",
75
+ "-c", "begin transaction read only;", "-c", sql, "-c", "rollback;",
76
+ ],
77
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1 << 30, env: { ...process.env, PGPASSWORD: b.SUPABASE_MATRIX_PASSWORD } },
78
+ );
79
+ return JSON.parse(out.trim() || "null");
80
+ }
package/bin/emit.mjs ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * emit.mjs — `matrx-data emit`: the LIVE database → the database description.
3
+ *
4
+ * The description is the ONE language-neutral contract every client generates
5
+ * from (TypeScript today, Swift/Kotlin and matrx-local's SQLite mirror read the
6
+ * same JSON). It carries what the database itself knows, never a hand-written
7
+ * list:
8
+ *
9
+ * per schema (every schema PostgREST exposes — `pgrst.db_schemas` on `authenticator`):
10
+ * tables + views: columns (type, array, nullable, default, generated, identity, enum),
11
+ * primary key, foreign keys, RLS on/off, per-role grants (anon/authenticated/service_role),
12
+ * the table-registry row (`platform.entity_types`: token, type, versioned, soft delete,
13
+ * component, client_read_only, client_deletes_refused, client_excluded_columns,
14
+ * client_read_only_columns, governed_columns, default_list_scope, title_column),
15
+ * lifecycle columns (deleted_at / archived_at / is_archived / version / organization_id),
16
+ * the organization rule (entity binding · parent via `inherit_org_from_parent` · none),
17
+ * the guard triggers, realtime publications
18
+ * functions: EVERY function anon / authenticated / service_role may execute (overloads kept
19
+ * apart), args with mode/default, result, volatility, definer/invoker, and the
20
+ * callable-door register's metadata where a row exists (`registered: false` otherwise)
21
+ * enums referenced by the schema
22
+ *
23
+ * index.json: format, contract hash (sha256 over every schema file), per-schema hash and
24
+ * counts, the publications, and the report (tables with no registry row, untyped rows).
25
+ *
26
+ * Deterministic: no timestamps, everything sorted — `--check` compares bytes.
27
+ * READ-ONLY: every statement runs inside `begin transaction read only` (direct-db.mjs).
28
+ *
29
+ * matrx-data emit [--out <dir>] [--check]
30
+ */
31
+ import { createHash } from "node:crypto";
32
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
33
+ import path from "node:path";
34
+
35
+ import { readJson } from "./direct-db.mjs";
36
+
37
+ export const FORMAT = "matrx-db-description/1";
38
+ const ROLES = ["anon", "authenticated", "service_role"];
39
+ const LIFECYCLE = ["deleted_at", "archived_at", "is_archived", "version", "organization_id"];
40
+ const GUARD_TRIGGERS = ["inherit_org_from_parent", "refuse_org_foreign_to_parent", "_cascade_soft_delete", "_guard_soft_delete_parent"];
41
+
42
+ const lit = (s) => `'${String(s).replace(/'/g, "''")}'`;
43
+ const arr = (xs) => `array[${xs.map(lit).join(",")}]::text[]`;
44
+
45
+ /** Stable stringify: object keys sorted, 2-space indent. */
46
+ export function canonical(value) {
47
+ const sort = (v) =>
48
+ Array.isArray(v) ? v.map(sort) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])])) : v;
49
+ return `${JSON.stringify(sort(value), null, 1)}\n`;
50
+ }
51
+
52
+ export const sha256 = (text) => createHash("sha256").update(text).digest("hex");
53
+
54
+ function exposedSchemas() {
55
+ const cfg = readJson(
56
+ "select coalesce(json_agg(c), '[]') from pg_roles r, unnest(r.rolconfig) c where r.rolname = 'authenticator' and c like 'pgrst.db_schemas=%'",
57
+ );
58
+ const raw = (cfg[0] ?? "").replace(/^pgrst\.db_schemas=/, "");
59
+ const names = raw.split(",").map((s) => s.trim()).filter(Boolean);
60
+ const present = readJson(`select coalesce(json_agg(nspname order by nspname), '[]') from pg_namespace where nspname = any(${arr(names)})`);
61
+ // graphql_public carries no client tables or functions of ours.
62
+ return present.filter((s) => s !== "graphql_public");
63
+ }
64
+
65
+ function collect(schemas) {
66
+ const S = arr(schemas);
67
+ const TYPE = (oidExpr) => `(
68
+ select json_build_object(
69
+ 'type', format_type(coalesce(nullif(bt.typbasetype, 0), bt.oid), null),
70
+ 'enum', case when coalesce(dt.typtype, bt.typtype) = 'e' then dn.nspname || '.' || dt.typname end
71
+ )
72
+ from pg_type t0
73
+ join pg_type bt on bt.oid = case when t0.typcategory = 'A' and t0.typelem <> 0 then t0.typelem else t0.oid end
74
+ left join pg_type dt on dt.oid = case when bt.typtype = 'd' then bt.typbasetype else bt.oid end
75
+ left join pg_namespace dn on dn.oid = dt.typnamespace
76
+ where t0.oid = ${oidExpr})`;
77
+
78
+ const relations = readJson(`
79
+ select coalesce(json_agg(r order by r.schema, r.name), '[]') from (
80
+ select n.nspname as schema, c.relname as name, c.oid::int as oid,
81
+ case c.relkind when 'v' then 'view' when 'm' then 'materialized_view' else 'table' end as kind,
82
+ c.relrowsecurity as rls,
83
+ (select json_agg(json_build_object(
84
+ 'name', a.attname, 'nullable', not a.attnotnull, 'default', a.atthasdef,
85
+ 'generated', a.attgenerated <> '', 'identity', a.attidentity <> '',
86
+ 'array', t.typcategory = 'A', 't', ${TYPE("a.atttypid")}) order by a.attnum)
87
+ from pg_attribute a join pg_type t on t.oid = a.atttypid
88
+ where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped) as columns,
89
+ (select json_agg(a.attname order by k.ord) from pg_index i
90
+ cross join lateral unnest(i.indkey) with ordinality k(attnum, ord)
91
+ join pg_attribute a on a.attrelid = c.oid and a.attnum = k.attnum
92
+ where i.indrelid = c.oid and i.indisprimary) as pk,
93
+ (select json_agg(json_build_object(
94
+ 'name', co.conname,
95
+ 'columns', (select json_agg(a.attname order by k.ord) from unnest(co.conkey) with ordinality k(n, ord) join pg_attribute a on a.attrelid = co.conrelid and a.attnum = k.n),
96
+ 'references', fn.nspname || '.' || fc.relname,
97
+ 'referencedColumns', (select json_agg(a.attname order by k.ord) from unnest(co.confkey) with ordinality k(n, ord) join pg_attribute a on a.attrelid = co.confrelid and a.attnum = k.n)
98
+ ) order by co.conname)
99
+ from pg_constraint co join pg_class fc on fc.oid = co.confrelid join pg_namespace fn on fn.oid = fc.relnamespace
100
+ where co.conrelid = c.oid and co.contype = 'f') as fks,
101
+ json_build_object(${ROLES.map(
102
+ (r) =>
103
+ `'${r}', json_build_object('select', has_table_privilege('${r}', c.oid, 'SELECT'), 'insert', has_table_privilege('${r}', c.oid, 'INSERT'), 'update', has_table_privilege('${r}', c.oid, 'UPDATE'), 'delete', has_table_privilege('${r}', c.oid, 'DELETE'))`,
104
+ ).join(", ")}) as grants,
105
+ (select json_agg(json_build_object('function', p.proname, 'args', string_to_array(rtrim(encode(t.tgargs, 'escape'), E'\\\\000'), E'\\\\000')) order by p.proname, t.tgname)
106
+ from pg_trigger t join pg_proc p on p.oid = t.tgfoid
107
+ where t.tgrelid = c.oid and not t.tgisinternal and p.proname = any(${arr(GUARD_TRIGGERS)})) as guards,
108
+ (select json_agg(pubname order by pubname) from pg_publication_tables pt where pt.schemaname = n.nspname and pt.tablename = c.relname) as publications
109
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
110
+ where n.nspname = any(${S}) and c.relkind in ('r','p','v','m') and not c.relispartition
111
+ ) r`);
112
+
113
+ const registry = readJson(`
114
+ select coalesce(json_agg(json_build_object(
115
+ 'oid', e.table_ref::oid::int, 'schema', e.schema_name, 'table', e.table_name, 'token', e.token, 'type', e.type,
116
+ 'versioned', e.is_versioned, 'softDelete', e.has_soft_delete, 'component', e.is_component, 'active', e.is_active,
117
+ 'clientReadOnly', e.client_read_only, 'clientDeletesRefused', e.client_deletes_refused,
118
+ 'clientExcludedColumns', e.client_excluded_columns, 'clientReadOnlyColumns', e.client_read_only_columns,
119
+ 'governedColumns', e.governed_columns, 'listScope', e.default_list_scope::text, 'titleColumn', e.title_column
120
+ ) order by e.schema_name, e.table_name), '[]') from platform.entity_types e`);
121
+
122
+ const functions = readJson(`
123
+ select coalesce(json_agg(f order by f.schema, f.name, f.identity), '[]') from (
124
+ select n.nspname as schema, p.proname as name, pg_get_function_identity_arguments(p.oid) as identity,
125
+ p.proretset as "returnsSet", p.provolatile as volatility, p.prosecdef as definer,
126
+ p.pronargdefaults as "argDefaults",
127
+ (select json_agg(json_build_object(
128
+ 'name', coalesce(p.proargnames[k.ord], ''), 'mode', coalesce(p.proargmodes[k.ord], 'i'),
129
+ 'array', (select typcategory = 'A' from pg_type where oid = k.typ), 't', ${TYPE("k.typ")}) order by k.ord)
130
+ from unnest(coalesce(p.proallargtypes, p.proargtypes::oid[])) with ordinality k(typ, ord)) as args,
131
+ (select json_build_object('array', typcategory = 'A', 'pseudo', typtype = 'p', 't', ${TYPE("p.prorettype")}) from pg_type where oid = p.prorettype) as result,
132
+ json_build_array(${ROLES.map((r) => `case when has_function_privilege('${r}', p.oid, 'EXECUTE') then '${r}' end`).join(", ")}) as roles
133
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
134
+ where n.nspname = any(${S}) and p.prokind = 'f'
135
+ and p.prorettype not in ('trigger'::regtype, 'event_trigger'::regtype)
136
+ and not exists (select 1 from pg_depend d where d.classid = 'pg_proc'::regclass and d.objid = p.oid and d.deptype = 'e')
137
+ and (${ROLES.map((r) => `has_function_privilege('${r}', p.oid, 'EXECUTE')`).join(" or ")})
138
+ ) f`);
139
+
140
+ // Every register row, matched to its live function (schema-qualified type names normalized, so
141
+ // "p_row block_states" matches "p_row platform.block_states"), and when it is not in the
142
+ // description, WHY — so no register row is ever unaccounted for.
143
+ const doors = readJson(`
144
+ select coalesce(json_agg(json_build_object(
145
+ 'schema', d.schema_name, 'name', d.function_name, 'identity', coalesce(p.ident, d.identity_args),
146
+ 'gatePredicate', d.gate_predicate, 'anonymousCallers', d.anonymous_callers, 'signedInCallers', d.signed_in_callers,
147
+ 'nonClientLane', d.non_client_lane, 'argumentRules', d.argument_rules, 'refusalOnly', d.refusal_only,
148
+ 'outside', case
149
+ when p.oid is null and not exists (select 1 from pg_proc p2 join pg_namespace n2 on n2.oid = p2.pronamespace
150
+ where n2.nspname = d.schema_name and p2.proname = d.function_name) then 'function no longer exists'
151
+ when p.oid is null then 'no live overload has the registered signature'
152
+ when not (d.schema_name = any(${S})) then 'schema not exposed to PostgREST (server-only)'
153
+ when not (${ROLES.map((r) => `has_function_privilege('${r}', p.oid, 'EXECUTE')`).join(" or ")}) then 'no client role may execute it'
154
+ when p.prorettype in ('trigger'::regtype, 'event_trigger'::regtype) then 'trigger function'
155
+ when exists (select 1 from pg_depend dd where dd.classid = 'pg_proc'::regclass and dd.objid = p.oid and dd.deptype = 'e') then 'extension-owned'
156
+ end
157
+ )), '[]')
158
+ from platform.client_callable_door d
159
+ left join lateral (
160
+ select p.oid, p.prorettype, pg_get_function_identity_arguments(p.oid) as ident
161
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
162
+ where n.nspname = d.schema_name and p.proname = d.function_name
163
+ and regexp_replace(pg_get_function_identity_arguments(p.oid), '\\m[a-z_][a-z0-9_]*\\.', '', 'g')
164
+ = regexp_replace(d.identity_args, '\\m[a-z_][a-z0-9_]*\\.', '', 'g')
165
+ limit 1) p on true`);
166
+
167
+ const enums = readJson(`
168
+ select coalesce(json_object_agg(n.nspname || '.' || t.typname,
169
+ (select json_agg(e.enumlabel order by e.enumsortorder) from pg_enum e where e.enumtypid = t.oid)), '{}')
170
+ from pg_type t join pg_namespace n on n.oid = t.typnamespace where t.typtype = 'e'`);
171
+
172
+ // Every published table, in every schema (a client subscribes by name; the messages
173
+ // publication's daily partitions collapse to their parent so the description does not drift each day).
174
+ const published = readJson(`
175
+ select coalesce(json_agg(json_build_object('pub', pubname, 'table', schemaname || '.' || tablename)), '[]') from pg_publication_tables`);
176
+
177
+ return { relations, registry, functions, doors, enums, published };
178
+ }
179
+
180
+ const col = (c) => {
181
+ const out = { name: c.name, type: c.t.type };
182
+ if (c.t.enum) out.enum = c.t.enum;
183
+ if (c.array) out.array = true;
184
+ if (c.nullable) out.nullable = true;
185
+ if (c.default) out.default = true;
186
+ if (c.generated) out.generated = true;
187
+ if (c.identity) out.identity = true;
188
+ return out;
189
+ };
190
+
191
+ function describeTable(rel, reg) {
192
+ const names = new Set((rel.columns ?? []).map((c) => c.name));
193
+ const guards = rel.guards ?? [];
194
+ const inherit = guards.find((g) => g.function === "inherit_org_from_parent");
195
+ let org;
196
+ if (inherit) {
197
+ const [ps, pt, fk] = inherit.args;
198
+ org = { rule: "parent", parent: `${ps}.${pt}`, via: fk };
199
+ } else if (names.has("organization_id")) {
200
+ org = { rule: rel.kind === "table" ? "binding" : "column" };
201
+ } else org = { rule: "none" };
202
+
203
+ const lifecycle = Object.fromEntries(LIFECYCLE.filter((c) => names.has(c)).map((c) => [c, true]));
204
+ const out = {
205
+ name: rel.name,
206
+ kind: rel.kind,
207
+ columns: (rel.columns ?? []).map(col),
208
+ primaryKey: rel.pk ?? [],
209
+ foreignKeys: (rel.fks ?? []).map((f) => ({ name: f.name, columns: f.columns, references: f.references, referencedColumns: f.referencedColumns })),
210
+ rls: rel.rls,
211
+ grants: rel.grants,
212
+ lifecycle,
213
+ org,
214
+ guards: [...new Set(guards.map((g) => g.function))].sort(),
215
+ realtime: rel.publications ?? [],
216
+ };
217
+ if (reg) {
218
+ out.registry = {
219
+ token: reg.token,
220
+ type: reg.type ?? null,
221
+ versioned: !!reg.versioned,
222
+ softDelete: !!reg.softDelete,
223
+ component: !!reg.component,
224
+ active: reg.active !== false,
225
+ clientReadOnly: !!reg.clientReadOnly,
226
+ clientDeletesRefused: !!reg.clientDeletesRefused,
227
+ clientExcludedColumns: reg.clientExcludedColumns ?? [],
228
+ clientReadOnlyColumns: reg.clientReadOnlyColumns ?? [],
229
+ governedColumns: reg.governedColumns ?? [],
230
+ listScope: reg.listScope ?? null,
231
+ titleColumn: reg.titleColumn ?? null,
232
+ };
233
+ } else out.registry = null;
234
+ return out;
235
+ }
236
+
237
+ function describeFunction(fn, door) {
238
+ const args = (fn.args ?? []).map((a) => {
239
+ const o = { name: a.name, mode: { i: "in", o: "out", b: "inout", v: "variadic", t: "table" }[a.mode] ?? a.mode, type: a.t.type };
240
+ if (a.t.enum) o.enum = a.t.enum;
241
+ if (a.array) o.array = true;
242
+ return o;
243
+ });
244
+ const inputs = args.filter((a) => a.mode === "in" || a.mode === "inout" || a.mode === "variadic");
245
+ inputs.slice(inputs.length - fn.argDefaults).forEach((a) => {
246
+ a.optional = true;
247
+ });
248
+ const out = {
249
+ name: fn.name,
250
+ identity: fn.identity,
251
+ args,
252
+ returns: {
253
+ type: fn.result.t.type,
254
+ ...(fn.result.t.enum ? { enum: fn.result.t.enum } : {}),
255
+ ...(fn.result.array ? { array: true } : {}),
256
+ ...(fn.returnsSet ? { set: true } : {}),
257
+ },
258
+ volatility: { i: "immutable", s: "stable", v: "volatile" }[fn.volatility],
259
+ security: fn.definer ? "definer" : "invoker",
260
+ roles: fn.roles.filter(Boolean),
261
+ registered: !!door,
262
+ };
263
+ if (door) {
264
+ out.door = {
265
+ gatePredicate: door.gatePredicate ?? null,
266
+ anonymousCallers: !!door.anonymousCallers,
267
+ signedInCallers: door.signedInCallers !== false,
268
+ nonClientLane: door.nonClientLane ?? null,
269
+ argumentRules: door.argumentRules ?? null,
270
+ refusalOnly: !!door.refusalOnly,
271
+ };
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /** Build every file of the description in memory: { "index.json": text, "<schema>.json": text, … } */
277
+ export function buildDescription() {
278
+ const schemas = exposedSchemas();
279
+ const { relations, registry, functions, doors, enums, published } = collect(schemas);
280
+ const regByOid = new Map(registry.filter((r) => r.oid).map((r) => [r.oid, r]));
281
+ const doorKey = (s, n, i) => `${s}.${n}(${i})`;
282
+ const doorsByKey = new Map(doors.map((d) => [doorKey(d.schema, d.name, d.identity), d]));
283
+
284
+ const files = {};
285
+ const index = { format: FORMAT, schemas: {}, publications: {}, report: { tablesWithoutRegistryRow: [], untypedRegistryRows: [], registeredDoorsOutsideDescription: {} } };
286
+ const publications = {};
287
+ for (const { pub, table } of published) {
288
+ const name = table.replace(/^realtime\.messages_\d{4}_\d{2}_\d{2}$/, "realtime.messages");
289
+ const list = (publications[pub] ??= []);
290
+ if (!list.includes(name)) list.push(name);
291
+ }
292
+ let matchedDoors = 0;
293
+
294
+ for (const schema of schemas) {
295
+ const rels = relations.filter((r) => r.schema === schema);
296
+ const tables = {};
297
+ const views = {};
298
+ const used = new Set();
299
+ for (const rel of rels) {
300
+ const reg = regByOid.get(rel.oid) ?? null;
301
+ const d = describeTable(rel, reg);
302
+ d.columns.forEach((c) => c.enum && used.add(c.enum));
303
+ if (rel.kind === "table") {
304
+ tables[rel.name] = d;
305
+ if (!reg) index.report.tablesWithoutRegistryRow.push(`${schema}.${rel.name}`);
306
+ else if (!reg.type) index.report.untypedRegistryRows.push(`${schema}.${rel.name}`);
307
+ } else {
308
+ delete d.registry;
309
+ views[rel.name] = d;
310
+ }
311
+ }
312
+ const fns = {};
313
+ for (const fn of functions.filter((f) => f.schema === schema)) {
314
+ const door = doorsByKey.get(doorKey(schema, fn.name, fn.identity));
315
+ if (door) matchedDoors += 1;
316
+ const d = describeFunction(fn, door);
317
+ d.args.forEach((a) => a.enum && used.add(a.enum));
318
+ if (d.returns.enum) used.add(d.returns.enum);
319
+ (fns[fn.name] ??= []).push(d);
320
+ }
321
+ for (const list of Object.values(fns)) list.sort((a, b) => a.identity.localeCompare(b.identity));
322
+ const schemaEnums = Object.fromEntries([...used].sort().map((e) => [e, enums[e] ?? []]));
323
+ const text = canonical({ format: FORMAT, schema, tables, views, functions: fns, enums: schemaEnums });
324
+ files[`${schema}.json`] = text;
325
+ const overloads = Object.values(fns).filter((l) => l.length > 1).length;
326
+ index.schemas[schema] = {
327
+ sha256: sha256(text),
328
+ tables: Object.keys(tables).length,
329
+ views: Object.keys(views).length,
330
+ functions: Object.values(fns).reduce((n, l) => n + l.length, 0),
331
+ overloadedNames: overloads,
332
+ };
333
+ }
334
+ for (const k of Object.keys(publications)) publications[k].sort();
335
+ index.publications = publications;
336
+ // Every register row not in the description, by reason (each named; none unaccounted for).
337
+ const outside = {};
338
+ for (const d of doors) if (d.outside) (outside[d.outside] ??= []).push(`${d.schema}.${d.name}(${d.identity})`);
339
+ for (const k of Object.keys(outside)) outside[k].sort();
340
+ index.report.registeredDoorsOutsideDescription = outside;
341
+ index.report.registeredDoors = { total: doors.length, inDescription: matchedDoors, outside: doors.length - matchedDoors };
342
+ const unexplained = doors.length - matchedDoors - Object.values(outside).reduce((n, l) => n + l.length, 0);
343
+ if (unexplained !== 0) throw new Error(`${unexplained} register row(s) neither in the description nor explained — the emitter's matching is wrong`);
344
+ index.report.tablesWithoutRegistryRow.sort();
345
+ index.report.untypedRegistryRows.sort();
346
+ index.contractSha256 = sha256(Object.keys(index.schemas).sort().map((s) => `${s}:${index.schemas[s].sha256}`).join("\n"));
347
+ files["index.json"] = canonical(index);
348
+ return files;
349
+ }
350
+
351
+ export function emit({ out, check }) {
352
+ const files = buildDescription();
353
+ if (check) {
354
+ const drift = [];
355
+ for (const [name, text] of Object.entries(files)) {
356
+ const p = path.join(out, name);
357
+ if (!existsSync(p)) drift.push(`${name}: missing on disk`);
358
+ else if (readFileSync(p, "utf8") !== text) drift.push(`${name}: differs from the live database`);
359
+ }
360
+ if (existsSync(out))
361
+ for (const name of readdirSync(out)) if (name.endsWith(".json") && !(name in files)) drift.push(`${name}: on disk, no longer in the live database`);
362
+ if (drift.length) {
363
+ console.error(`matrx-data emit --check: the committed description drifted from the live database (${drift.length}):`);
364
+ for (const d of drift) console.error(` ${d}`);
365
+ console.error("Run `matrx-data emit` and commit the result.");
366
+ return 1;
367
+ }
368
+ const idx = JSON.parse(files["index.json"]);
369
+ console.log(`matrx-data emit --check: in step with the live database (contract ${idx.contractSha256.slice(0, 12)})`);
370
+ return 0;
371
+ }
372
+ mkdirSync(out, { recursive: true });
373
+ for (const [name, text] of Object.entries(files)) writeFileSync(path.join(out, name), text);
374
+ const idx = JSON.parse(files["index.json"]);
375
+ const sum = (k) => Object.values(idx.schemas).reduce((n, s) => n + s[k], 0);
376
+ console.log(
377
+ `matrx-data emit: ${Object.keys(idx.schemas).length} schemas · ${sum("tables")} tables · ${sum("views")} views · ${sum("functions")} functions → ${out} (contract ${idx.contractSha256.slice(0, 12)})`,
378
+ );
379
+ return 0;
380
+ }