@ai-matrx/data 0.22.32 → 0.23.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0 — the doors: one way to talk to the database
4
+
5
+ - **`matrx-data` CLI** (`bin`): `matrx-data emit [--out db-contract] [--check]` reads the LIVE database
6
+ (read-only, every statement in `begin transaction read only`) into the ONE language-neutral database
7
+ description — per exposed schema: tables/views with columns, keys, grants per role, the table-registry
8
+ flags, lifecycle columns, the organization rule (entity binding · parent via `inherit_org_from_parent`),
9
+ realtime publications; every function any client role may execute (overloads kept apart, register
10
+ metadata or `registered: false`); enums; `index.json` with the contract hash. `--check` exits 1 on drift.
11
+ `matrx-data generate --from <description> --out <dir> [--schemas a,b] [--flags-index] [--check]` writes
12
+ typed descriptors INSIDE the app (one self-contained file per schema; one top-level `const` per table
13
+ and function), so a new column never waits on a package release.
14
+ - **`@ai-matrx/data/db` doors** (additive): `createDb({ client, organization })` wraps the app's existing
15
+ Supabase client (no second auth client or socket) and hands the session to `@ai-matrx/api` as its
16
+ `user` credential (`db.credentials`); `createServiceDb` (server only, throws in a browser);
17
+ `read`, `listAll` (complete past 1,000 rows), `page`, `count`, `insert` (entity → named or active
18
+ organization; child → never the active organization, the parent's comes from the database),
19
+ `update` (`{ expectedVersion }` guarded or `{ overwrite: true }` on versioned tables), `remove`
20
+ (soft delete; archive-only tables refuse), `archive` / `restore`, `call` (functions), `tableByName`
21
+ (runtime-chosen tables over the flags index), `changesOf` (published tables only); the door set by
22
+ registry type refused before the wire (`DoorRefusedError`).
23
+
24
+ Consumer action: none required (additive; nothing existing changed). To adopt: add
25
+ `"db:generate": "matrx-data generate --from <aidream>/db-contract --out src/db/generated --schemas <yours>"`,
26
+ then `createDb({ client: <your existing client>, organization: () => activeOrgId })` and call the doors.
27
+ Migration of existing call sites starts at the data layer design's step 5.
28
+
29
+ ## 0.22.33
30
+
31
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
32
+ `git diff npm/data/v0.22.32..npm/data/v0.22.33 -- apps/shared/data`).
33
+ No source changes intended and no consumer action required.
34
+
3
35
  ## 0.22.32
4
36
 
5
37
  Automatic changed-only republish (docs/metadata drift since the last tag — see
@@ -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,355 @@
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
+ const doors = readJson(`
141
+ select coalesce(json_agg(json_build_object(
142
+ 'schema', d.schema_name, 'name', d.function_name, 'identity', d.identity_args,
143
+ 'gatePredicate', d.gate_predicate, 'anonymousCallers', d.anonymous_callers, 'signedInCallers', d.signed_in_callers,
144
+ 'nonClientLane', d.non_client_lane, 'argumentRules', d.argument_rules, 'refusalOnly', d.refusal_only
145
+ )), '[]') from platform.client_callable_door d`);
146
+
147
+ const enums = readJson(`
148
+ select coalesce(json_object_agg(n.nspname || '.' || t.typname,
149
+ (select json_agg(e.enumlabel order by e.enumsortorder) from pg_enum e where e.enumtypid = t.oid)), '{}')
150
+ from pg_type t join pg_namespace n on n.oid = t.typnamespace where t.typtype = 'e'`);
151
+
152
+ // Every published table, in every schema (a client subscribes by name; the messages
153
+ // publication's daily partitions collapse to their parent so the description does not drift each day).
154
+ const published = readJson(`
155
+ select coalesce(json_agg(json_build_object('pub', pubname, 'table', schemaname || '.' || tablename)), '[]') from pg_publication_tables`);
156
+
157
+ return { relations, registry, functions, doors, enums, published };
158
+ }
159
+
160
+ const col = (c) => {
161
+ const out = { name: c.name, type: c.t.type };
162
+ if (c.t.enum) out.enum = c.t.enum;
163
+ if (c.array) out.array = true;
164
+ if (c.nullable) out.nullable = true;
165
+ if (c.default) out.default = true;
166
+ if (c.generated) out.generated = true;
167
+ if (c.identity) out.identity = true;
168
+ return out;
169
+ };
170
+
171
+ function describeTable(rel, reg) {
172
+ const names = new Set((rel.columns ?? []).map((c) => c.name));
173
+ const guards = rel.guards ?? [];
174
+ const inherit = guards.find((g) => g.function === "inherit_org_from_parent");
175
+ let org;
176
+ if (inherit) {
177
+ const [ps, pt, fk] = inherit.args;
178
+ org = { rule: "parent", parent: `${ps}.${pt}`, via: fk };
179
+ } else if (names.has("organization_id")) {
180
+ org = { rule: rel.kind === "table" ? "binding" : "column" };
181
+ } else org = { rule: "none" };
182
+
183
+ const lifecycle = Object.fromEntries(LIFECYCLE.filter((c) => names.has(c)).map((c) => [c, true]));
184
+ const out = {
185
+ name: rel.name,
186
+ kind: rel.kind,
187
+ columns: (rel.columns ?? []).map(col),
188
+ primaryKey: rel.pk ?? [],
189
+ foreignKeys: (rel.fks ?? []).map((f) => ({ name: f.name, columns: f.columns, references: f.references, referencedColumns: f.referencedColumns })),
190
+ rls: rel.rls,
191
+ grants: rel.grants,
192
+ lifecycle,
193
+ org,
194
+ guards: [...new Set(guards.map((g) => g.function))].sort(),
195
+ realtime: rel.publications ?? [],
196
+ };
197
+ if (reg) {
198
+ out.registry = {
199
+ token: reg.token,
200
+ type: reg.type ?? null,
201
+ versioned: !!reg.versioned,
202
+ softDelete: !!reg.softDelete,
203
+ component: !!reg.component,
204
+ active: reg.active !== false,
205
+ clientReadOnly: !!reg.clientReadOnly,
206
+ clientDeletesRefused: !!reg.clientDeletesRefused,
207
+ clientExcludedColumns: reg.clientExcludedColumns ?? [],
208
+ clientReadOnlyColumns: reg.clientReadOnlyColumns ?? [],
209
+ governedColumns: reg.governedColumns ?? [],
210
+ listScope: reg.listScope ?? null,
211
+ titleColumn: reg.titleColumn ?? null,
212
+ };
213
+ } else out.registry = null;
214
+ return out;
215
+ }
216
+
217
+ function describeFunction(fn, door) {
218
+ const args = (fn.args ?? []).map((a) => {
219
+ const o = { name: a.name, mode: { i: "in", o: "out", b: "inout", v: "variadic", t: "table" }[a.mode] ?? a.mode, type: a.t.type };
220
+ if (a.t.enum) o.enum = a.t.enum;
221
+ if (a.array) o.array = true;
222
+ return o;
223
+ });
224
+ const inputs = args.filter((a) => a.mode === "in" || a.mode === "inout" || a.mode === "variadic");
225
+ inputs.slice(inputs.length - fn.argDefaults).forEach((a) => {
226
+ a.optional = true;
227
+ });
228
+ const out = {
229
+ name: fn.name,
230
+ identity: fn.identity,
231
+ args,
232
+ returns: {
233
+ type: fn.result.t.type,
234
+ ...(fn.result.t.enum ? { enum: fn.result.t.enum } : {}),
235
+ ...(fn.result.array ? { array: true } : {}),
236
+ ...(fn.returnsSet ? { set: true } : {}),
237
+ },
238
+ volatility: { i: "immutable", s: "stable", v: "volatile" }[fn.volatility],
239
+ security: fn.definer ? "definer" : "invoker",
240
+ roles: fn.roles.filter(Boolean),
241
+ registered: !!door,
242
+ };
243
+ if (door) {
244
+ out.door = {
245
+ gatePredicate: door.gatePredicate ?? null,
246
+ anonymousCallers: !!door.anonymousCallers,
247
+ signedInCallers: door.signedInCallers !== false,
248
+ nonClientLane: door.nonClientLane ?? null,
249
+ argumentRules: door.argumentRules ?? null,
250
+ refusalOnly: !!door.refusalOnly,
251
+ };
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /** Build every file of the description in memory: { "index.json": text, "<schema>.json": text, … } */
257
+ export function buildDescription() {
258
+ const schemas = exposedSchemas();
259
+ const { relations, registry, functions, doors, enums, published } = collect(schemas);
260
+ const regByOid = new Map(registry.filter((r) => r.oid).map((r) => [r.oid, r]));
261
+ const doorKey = (s, n, i) => `${s}.${n}(${i})`;
262
+ const doorsByKey = new Map(doors.map((d) => [doorKey(d.schema, d.name, d.identity), d]));
263
+
264
+ const files = {};
265
+ const index = { format: FORMAT, schemas: {}, publications: {}, report: { tablesWithoutRegistryRow: [], untypedRegistryRows: [], registeredDoorsOutsideDescription: 0 } };
266
+ const publications = {};
267
+ for (const { pub, table } of published) {
268
+ const name = table.replace(/^realtime\.messages_\d{4}_\d{2}_\d{2}$/, "realtime.messages");
269
+ const list = (publications[pub] ??= []);
270
+ if (!list.includes(name)) list.push(name);
271
+ }
272
+ let matchedDoors = 0;
273
+
274
+ for (const schema of schemas) {
275
+ const rels = relations.filter((r) => r.schema === schema);
276
+ const tables = {};
277
+ const views = {};
278
+ const used = new Set();
279
+ for (const rel of rels) {
280
+ const reg = regByOid.get(rel.oid) ?? null;
281
+ const d = describeTable(rel, reg);
282
+ d.columns.forEach((c) => c.enum && used.add(c.enum));
283
+ if (rel.kind === "table") {
284
+ tables[rel.name] = d;
285
+ if (!reg) index.report.tablesWithoutRegistryRow.push(`${schema}.${rel.name}`);
286
+ else if (!reg.type) index.report.untypedRegistryRows.push(`${schema}.${rel.name}`);
287
+ } else {
288
+ delete d.registry;
289
+ views[rel.name] = d;
290
+ }
291
+ }
292
+ const fns = {};
293
+ for (const fn of functions.filter((f) => f.schema === schema)) {
294
+ const door = doorsByKey.get(doorKey(schema, fn.name, fn.identity));
295
+ if (door) matchedDoors += 1;
296
+ const d = describeFunction(fn, door);
297
+ d.args.forEach((a) => a.enum && used.add(a.enum));
298
+ if (d.returns.enum) used.add(d.returns.enum);
299
+ (fns[fn.name] ??= []).push(d);
300
+ }
301
+ for (const list of Object.values(fns)) list.sort((a, b) => a.identity.localeCompare(b.identity));
302
+ const schemaEnums = Object.fromEntries([...used].sort().map((e) => [e, enums[e] ?? []]));
303
+ const text = canonical({ format: FORMAT, schema, tables, views, functions: fns, enums: schemaEnums });
304
+ files[`${schema}.json`] = text;
305
+ const overloads = Object.values(fns).filter((l) => l.length > 1).length;
306
+ index.schemas[schema] = {
307
+ sha256: sha256(text),
308
+ tables: Object.keys(tables).length,
309
+ views: Object.keys(views).length,
310
+ functions: Object.values(fns).reduce((n, l) => n + l.length, 0),
311
+ overloadedNames: overloads,
312
+ };
313
+ }
314
+ for (const k of Object.keys(publications)) publications[k].sort();
315
+ index.publications = publications;
316
+ // Register rows whose function is outside the exposed schemas, not executable by any client role,
317
+ // or whose identity no longer matches a live signature.
318
+ index.report.registeredDoorsOutsideDescription = doors.length - matchedDoors;
319
+ index.report.tablesWithoutRegistryRow.sort();
320
+ index.report.untypedRegistryRows.sort();
321
+ index.contractSha256 = sha256(Object.keys(index.schemas).sort().map((s) => `${s}:${index.schemas[s].sha256}`).join("\n"));
322
+ files["index.json"] = canonical(index);
323
+ return files;
324
+ }
325
+
326
+ export function emit({ out, check }) {
327
+ const files = buildDescription();
328
+ if (check) {
329
+ const drift = [];
330
+ for (const [name, text] of Object.entries(files)) {
331
+ const p = path.join(out, name);
332
+ if (!existsSync(p)) drift.push(`${name}: missing on disk`);
333
+ else if (readFileSync(p, "utf8") !== text) drift.push(`${name}: differs from the live database`);
334
+ }
335
+ if (existsSync(out))
336
+ for (const name of readdirSync(out)) if (name.endsWith(".json") && !(name in files)) drift.push(`${name}: on disk, no longer in the live database`);
337
+ if (drift.length) {
338
+ console.error(`matrx-data emit --check: the committed description drifted from the live database (${drift.length}):`);
339
+ for (const d of drift) console.error(` ${d}`);
340
+ console.error("Run `matrx-data emit` and commit the result.");
341
+ return 1;
342
+ }
343
+ const idx = JSON.parse(files["index.json"]);
344
+ console.log(`matrx-data emit --check: in step with the live database (contract ${idx.contractSha256.slice(0, 12)})`);
345
+ return 0;
346
+ }
347
+ mkdirSync(out, { recursive: true });
348
+ for (const [name, text] of Object.entries(files)) writeFileSync(path.join(out, name), text);
349
+ const idx = JSON.parse(files["index.json"]);
350
+ const sum = (k) => Object.values(idx.schemas).reduce((n, s) => n + s[k], 0);
351
+ console.log(
352
+ `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)})`,
353
+ );
354
+ return 0;
355
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * generate.mjs — `matrx-data generate`: the database description → typed doors,
3
+ * written INSIDE the app that uses them (Prisma's model: the package ships the
4
+ * runtime and this generator, never the generated tables, so a new column never
5
+ * waits on a package release).
6
+ *
7
+ * One file per schema (`<schema>.ts`), each self-contained (no file imports
8
+ * another), so importing one schema loads no other; every table and function is
9
+ * its own top-level `const` (flags + names only, no row data), so a bundler keeps
10
+ * exactly the descriptors used. Types: Row (client-excluded columns removed),
11
+ * Insert (defaulted / nullable / org-filled columns optional; generated and
12
+ * client-read-only columns removed), Update (Partial of Insert). Functions: one
13
+ * descriptor per name; overloads become a union of named-argument shapes.
14
+ *
15
+ * matrx-data generate --from <description dir> --out <dir> [--schemas a,b] [--check]
16
+ * [--flags-index] also write flags-index.ts for tableByName()
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import path from "node:path";
20
+
21
+ const RESERVED = new Set(
22
+ "break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await".split(
23
+ " ",
24
+ ),
25
+ );
26
+
27
+ const camel = (s) => s.replace(/^_+/, "").replace(/_+([a-z0-9])/g, (_, c) => c.toUpperCase());
28
+ const pascal = (s) => {
29
+ const c = camel(s);
30
+ return c.charAt(0).toUpperCase() + c.slice(1);
31
+ };
32
+ const ident = (s) => {
33
+ let c = camel(s) || "_";
34
+ if (/^[0-9]/.test(c)) c = `_${c}`;
35
+ return RESERVED.has(c) ? `${c}Table` : c;
36
+ };
37
+ const key = (s) => (/^[A-Za-z_$][\w$]*$/.test(s) ? s : JSON.stringify(s));
38
+
39
+ const NUMBER = new Set(["smallint", "integer", "bigint", "real", "double precision", "numeric", "oid", "int2", "int4", "int8", "float4", "float8"]);
40
+ const STRING_PREFIX = /^(?:text|uuid|character|char|varchar|citext|name|date|time|timestamp|interval|inet|cidr|macaddr|tsvector|tsquery|bytea|money|xml|bit|ltree|regclass|regproc|regtype|point|line|box|path|polygon|circle|int4range|int8range|tsrange|tstzrange|daterange|numrange|halfvec|vector|sparsevec)/;
41
+
42
+ function tsType(t, enums) {
43
+ let base;
44
+ if (t.enum) base = (enums[t.enum] ?? []).map((v) => JSON.stringify(v)).join(" | ") || "string";
45
+ else if (NUMBER.has(t.type)) base = "number";
46
+ else if (t.type === "boolean") base = "boolean";
47
+ else if (t.type === "json" || t.type === "jsonb") base = "Json";
48
+ else if (t.type === "void") base = "undefined";
49
+ else if (STRING_PREFIX.test(t.type)) base = "string";
50
+ else base = "unknown";
51
+ return t.array ? (base.includes("|") ? `(${base})[]` : `${base}[]`) : base;
52
+ }
53
+
54
+ function tableTypes(P, t, enums) {
55
+ const reg = t.registry ?? null;
56
+ const excluded = new Set(reg?.clientExcludedColumns ?? []);
57
+ const readOnly = new Set(reg?.clientReadOnlyColumns ?? []);
58
+ const rowLines = [];
59
+ const insLines = [];
60
+ for (const c of t.columns) {
61
+ const ty = tsType(c, enums);
62
+ if (!excluded.has(c.name)) rowLines.push(` ${key(c.name)}: ${ty}${c.nullable ? " | null" : ""};`);
63
+ if (t.kind !== "table" || c.generated || readOnly.has(c.name) || excluded.has(c.name)) continue;
64
+ const orgFilled = c.name === "organization_id" && (t.org.rule === "binding" || t.org.rule === "parent");
65
+ const optional = c.nullable || c.default || c.identity || orgFilled;
66
+ insLines.push(` ${key(c.name)}${optional ? "?" : ""}: ${ty}${c.nullable ? " | null" : ""};`);
67
+ }
68
+ let out = `export interface ${P}Row {\n${rowLines.join("\n")}\n}\n`;
69
+ if (t.kind === "table") {
70
+ out += `export interface ${P}Insert {\n${insLines.join("\n")}\n}\nexport type ${P}Update = Partial<${P}Insert>;\n`;
71
+ } else {
72
+ out += `export type ${P}Insert = never;\nexport type ${P}Update = never;\n`;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function tableDescriptor(schema, name, t) {
78
+ const reg = t.registry ?? null;
79
+ const archive = t.lifecycle.archived_at ? "archived_at" : t.lifecycle.is_archived ? "is_archived" : null;
80
+ const flags = {
81
+ schema,
82
+ name,
83
+ kind: t.kind,
84
+ type: reg?.type ?? null,
85
+ pk: t.primaryKey,
86
+ org: t.org,
87
+ versioned: !!(reg ? reg.versioned && t.lifecycle.version : t.lifecycle.version),
88
+ softDelete: !!(reg ? reg.softDelete && t.lifecycle.deleted_at : t.lifecycle.deleted_at),
89
+ archive,
90
+ readOnly: !!reg?.clientReadOnly,
91
+ deletesRefused: !!reg?.clientDeletesRefused,
92
+ readOnlyColumns: reg?.clientReadOnlyColumns ?? [],
93
+ listScope: reg?.listScope ?? null,
94
+ realtime: (t.realtime ?? []).includes("supabase_realtime"),
95
+ };
96
+ return flags;
97
+ }
98
+
99
+ function fnTypes(P, overloads, enums) {
100
+ const shapes = [];
101
+ let result = "unknown";
102
+ for (const f of overloads) {
103
+ const inputs = f.args.filter((a) => a.mode === "in" || a.mode === "inout" || a.mode === "variadic");
104
+ if (inputs.some((a) => !a.name)) {
105
+ shapes.push("never /* positional arguments: PostgREST calls by name */");
106
+ continue;
107
+ }
108
+ shapes.push(inputs.length ? `{ ${inputs.map((a) => `${key(a.name)}${a.optional ? "?" : ""}: ${tsType(a, enums)}`).join("; ")} }` : "Record<string, never>");
109
+ const outs = f.args.filter((a) => a.mode === "table" || a.mode === "out" || a.mode === "inout");
110
+ let r;
111
+ if (outs.length && (f.returns.type === "record" || f.args.some((a) => a.mode === "table"))) {
112
+ r = `{ ${outs.map((a) => `${key(a.name || "value")}: ${tsType(a, enums)} | null`).join("; ")} }`;
113
+ } else r = tsType(f.returns, enums);
114
+ if (f.returns.set) r = r.startsWith("{") || !r.includes("|") ? `${r}[]` : `(${r})[]`;
115
+ result = r;
116
+ }
117
+ const args = [...new Set(shapes)];
118
+ return `export type ${P}Args = ${args.join(" | ")};\nexport type ${P}Result = ${result};\n`;
119
+ }
120
+
121
+ const HEADER = (schema, src, importFrom) => `/**
122
+ * GENERATED by \`matrx-data generate\` from the database description — never edit by hand.
123
+ * schema: ${schema} · description ${src.slice(0, 12)}
124
+ * Regenerate: matrx-data generate (see @ai-matrx/data README).
125
+ */
126
+ /* eslint-disable */
127
+ import type { FunctionDescriptor, TableDescriptor } from "${importFrom}";
128
+
129
+ type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
130
+ `;
131
+
132
+ export function renderSchema(desc, schemaSha, importFrom) {
133
+ const { schema, tables, views, functions, enums } = desc;
134
+ const used = new Set();
135
+ const take = (base) => {
136
+ let n = base;
137
+ while (used.has(n)) n = `${n}_`;
138
+ used.add(n);
139
+ return n;
140
+ };
141
+ let out = HEADER(schema, schemaSha, importFrom);
142
+ const rels = [...Object.entries(tables), ...Object.entries(views)].sort(([a], [b]) => a.localeCompare(b));
143
+ for (const [name, t] of rels) {
144
+ const id = take(ident(name));
145
+ const P = pascal(id);
146
+ out += `\n${tableTypes(P, t, enums)}export const ${id} = ${JSON.stringify(tableDescriptor(schema, name, t))} as unknown as TableDescriptor<${P}Row, ${P}Insert, ${P}Update>;\n`;
147
+ }
148
+ for (const [name, overloads] of Object.entries(functions).sort(([a], [b]) => a.localeCompare(b))) {
149
+ let id = ident(name);
150
+ if (used.has(id)) id = `${id}Fn`;
151
+ id = take(id);
152
+ const P = pascal(id);
153
+ const roles = [...new Set(overloads.flatMap((f) => f.roles))].sort();
154
+ const fd = { schema, name, roles, registered: overloads.some((f) => f.registered) };
155
+ out += `\n${fnTypes(P, overloads, enums)}export const ${id} = ${JSON.stringify(fd)} as unknown as FunctionDescriptor<${P}Args, ${P}Result>;\n`;
156
+ }
157
+ return out;
158
+ }
159
+
160
+ export function renderFlagsIndex(descs, importFrom) {
161
+ const index = {};
162
+ for (const d of descs) for (const [name, t] of Object.entries({ ...d.tables, ...d.views })) index[`${d.schema}.${name}`] = tableDescriptor(d.schema, name, t);
163
+ return `/** GENERATED by \`matrx-data generate --flags-index\` — the flags of every table, for tableByName(). Never edit by hand. */
164
+ /* eslint-disable */
165
+ import type { FlagsIndex } from "${importFrom}";
166
+
167
+ export const flagsIndex: FlagsIndex = ${JSON.stringify(index)} as unknown as FlagsIndex;
168
+ `;
169
+ }
170
+
171
+ export function generate({ from, out, schemas, check, importFrom, flagsIndex = process.argv.includes("--flags-index") }) {
172
+ const index = JSON.parse(readFileSync(path.join(from, "index.json"), "utf8"));
173
+ const names = schemas?.length ? schemas : Object.keys(index.schemas).sort();
174
+ const files = {};
175
+ const descs = [];
176
+ for (const s of names) {
177
+ if (!index.schemas[s]) throw new Error(`schema "${s}" is not in the description (${Object.keys(index.schemas).length} schemas)`);
178
+ const desc = JSON.parse(readFileSync(path.join(from, `${s}.json`), "utf8"));
179
+ descs.push(desc);
180
+ files[`${s}.ts`] = renderSchema(desc, index.schemas[s].sha256, importFrom);
181
+ }
182
+ if (flagsIndex) files["flags-index.ts"] = renderFlagsIndex(descs, importFrom);
183
+ if (check) {
184
+ const stale = Object.entries(files).filter(([n, t]) => !existsSync(path.join(out, n)) || readFileSync(path.join(out, n), "utf8") !== t).map(([n]) => n);
185
+ if (stale.length) {
186
+ console.error(`matrx-data generate --check: stale against the description: ${stale.join(", ")}`);
187
+ return 1;
188
+ }
189
+ console.log(`matrx-data generate --check: ${Object.keys(files).length} file(s) current (contract ${index.contractSha256.slice(0, 12)})`);
190
+ return 0;
191
+ }
192
+ mkdirSync(out, { recursive: true });
193
+ for (const [n, t] of Object.entries(files)) writeFileSync(path.join(out, n), t);
194
+ console.log(`matrx-data generate: ${Object.keys(files).join(", ")} → ${out} (contract ${index.contractSha256.slice(0, 12)})`);
195
+ return 0;
196
+ }