@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 +32 -0
- package/bin/direct-db.mjs +80 -0
- package/bin/emit.mjs +355 -0
- package/bin/generate.mjs +196 -0
- package/bin/matrx-data.mjs +47 -0
- package/dist/db.cjs +240 -19
- package/dist/db.cjs.map +1 -1
- package/dist/db.d.cts +220 -3
- package/dist/db.d.ts +220 -3
- package/dist/db.js +236 -15
- package/dist/db.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +128 -0
- package/dist/types.d.ts +128 -0
- package/package.json +6 -2
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
|
+
}
|
package/bin/generate.mjs
ADDED
|
@@ -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
|
+
}
|