@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.
- package/CHANGELOG.md +46 -0
- package/README.md +34 -0
- package/bin/direct-db.mjs +80 -0
- package/bin/emit.mjs +380 -0
- package/bin/generate.mjs +225 -0
- package/bin/matrx-data.mjs +49 -0
- package/description/admin.json.gz +0 -0
- package/description/agent.json.gz +0 -0
- package/description/ai.json.gz +0 -0
- package/description/api.json.gz +0 -0
- package/description/app.json.gz +0 -0
- package/description/assignment.json.gz +0 -0
- package/description/audit.json.gz +0 -0
- package/description/batch.json.gz +0 -0
- package/description/billing.json.gz +0 -0
- package/description/browser.json.gz +0 -0
- package/description/canvas.json.gz +0 -0
- package/description/chat.json.gz +0 -0
- package/description/code.json.gz +0 -0
- package/description/commerce.json.gz +0 -0
- package/description/communication.json.gz +0 -0
- package/description/content.json.gz +0 -0
- package/description/content_ir.json.gz +0 -0
- package/description/context.json.gz +0 -0
- package/description/crm.json.gz +0 -0
- package/description/custom.json.gz +0 -0
- package/description/dictionary.json.gz +0 -0
- package/description/docproc.json.gz +0 -0
- package/description/education.json.gz +0 -0
- package/description/esign.json.gz +0 -0
- package/description/extend.json.gz +0 -0
- package/description/files.json.gz +0 -0
- package/description/growth.json.gz +0 -0
- package/description/hindsight.json.gz +0 -0
- package/description/history.json.gz +0 -0
- package/description/iam.json.gz +0 -0
- package/description/index.json.gz +0 -0
- package/description/interview.json.gz +0 -0
- package/description/knowledge.json.gz +0 -0
- package/description/legal.json.gz +0 -0
- package/description/mandate.json.gz +0 -0
- package/description/marketing.json.gz +0 -0
- package/description/media.json.gz +0 -0
- package/description/meta.json.gz +0 -0
- package/description/ops.json.gz +0 -0
- package/description/pdf.json.gz +0 -0
- package/description/plan.json.gz +0 -0
- package/description/platform.json.gz +0 -0
- package/description/podcast.json.gz +0 -0
- package/description/projects.json.gz +0 -0
- package/description/public.json.gz +0 -0
- package/description/rag.json.gz +0 -0
- package/description/research.json.gz +0 -0
- package/description/runtime.json.gz +0 -0
- package/description/scheduler.json.gz +0 -0
- package/description/scraper.json.gz +0 -0
- package/description/seo.json.gz +0 -0
- package/description/skill.json.gz +0 -0
- package/description/social.json.gz +0 -0
- package/description/tool.json.gz +0 -0
- package/description/transcripts.json.gz +0 -0
- package/description/ui.json.gz +0 -0
- package/description/users.json.gz +0 -0
- package/description/web.json.gz +0 -0
- package/description/work.json.gz +0 -0
- package/description/workbench.json.gz +0 -0
- package/description/workflow.json.gz +0 -0
- package/dist/db.cjs +274 -19
- package/dist/db.cjs.map +1 -1
- package/dist/db.d.cts +270 -3
- package/dist/db.d.ts +270 -3
- package/dist/db.js +270 -15
- package/dist/db.js.map +1 -1
- 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
|
+
}
|