letopis 0.16.0 → 0.18.1

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.
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Генерация TS-типов из таблицы Schema:
4
+ * npx tsx scripts/gen-types.mjs --dsn=postgres://… --schema=v1.booking [--out=entity-types.d.ts]
5
+ *
6
+ * Результат: интерфейсы data-полей каждого класса + фасад TypedDb.
7
+ * import type { TypedDb } from './entity-types';
8
+ * const t = db as unknown as TypedDb; // t.Сотрудник(...).rows(): Promise<TypedRow<StaffData>[]>
9
+ */
10
+ //
11
+ // FILE: lib/scripts/gen-types.mjs
12
+ // VERSION: 1.0.0
13
+ // START_MODULE_CONTRACT
14
+ // PURPOSE: CLI-кодген — читает таблицу Schema и печатает типизированный .d.ts-фасад (Data-интерфейсы классов + TypedRow/TypedChain/TypedDb).
15
+ // SCOPE: парсинг аргументов, чтение Schema, конвертация fastest-validator DSL → TS (ts), эмиссия .d.ts.
16
+ // DEPENDS: none
17
+ // LINKS: M-GEN-TYPES, V-M-GEN-TYPES
18
+ // ROLE: SCRIPT
19
+ // MAP_MODE: LOCALS
20
+ // END_MODULE_CONTRACT
21
+ //
22
+ // START_MODULE_MAP
23
+ // ts - fastest-validator DSL → TS-тип ({ t, opt })
24
+ // ident - безопасный идентификатор либо строковый ключ
25
+ // sql/rows - чтение классов из таблицы Schema
26
+ // lines/facade - аккумуляторы генерируемого .d.ts
27
+ // END_MODULE_MAP
28
+ //
29
+ // START_CHANGE_SUMMARY
30
+ // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
31
+ // END_CHANGE_SUMMARY
32
+ import { writeFile } from 'node:fs/promises';
33
+ import postgres from 'postgres';
34
+
35
+ // START_BLOCK_PARSE_ARGS
36
+ const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
37
+ const dsn = args.dsn ?? process.env.ENTITY_DSN;
38
+ const schema = args.schema ?? 'booking';
39
+ const out = args.out ?? 'entity-types.d.ts';
40
+ if (!dsn) { console.error('usage: tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking [--out=…]'); process.exit(1); }
41
+
42
+ // END_BLOCK_PARSE_ARGS
43
+ // START_CONTRACT: ts
44
+ // PURPOSE: Свести attribute-спеку fastest-validator (строка/массив/объект) к TS-типу и признаку optional.
45
+ // INPUTS: { attr: unknown - правило поля }
46
+ // OUTPUTS: { { t: string; opt: boolean } }
47
+ // SIDE_EFFECTS: none (рекурсивно по вложенным props/items)
48
+ // LINKS: M-GEN-TYPES, V-M-GEN-TYPES
49
+ // END_CONTRACT: ts
50
+ // fastest-validator DSL → TS-тип
51
+ function ts(attr) {
52
+ if (typeof attr === 'string') {
53
+ const parts = attr.split('|').map((s) => s.trim());
54
+ const opt = parts.includes('optional');
55
+ const base = { number: 'number', boolean: 'boolean', date: 'string', string: 'string', uuid: 'string', email: 'string', url: 'string', any: 'unknown' }[parts[0]] ?? 'unknown';
56
+ return { t: base, opt };
57
+ }
58
+ if (Array.isArray(attr)) { const first = ts(attr[0] ?? 'any'); return { t: first.t, opt: true }; }
59
+ if (typeof attr === 'object' && attr !== null) {
60
+ const opt = attr.optional === true;
61
+ switch (attr.type) {
62
+ case 'enum': return { t: (attr.values ?? []).map((v) => JSON.stringify(v)).join(' | ') || 'string', opt };
63
+ case 'record': {
64
+ const key = attr.key?.type === 'enum' ? (attr.key.values ?? []).map((v) => JSON.stringify(v)).join(' | ') : 'string';
65
+ return { t: `Partial<Record<${key || 'string'}, number>>`, opt };
66
+ }
67
+ case 'array': {
68
+ const item = ts(attr.items ?? 'any');
69
+ return { t: `(${item.t})[]`, opt };
70
+ }
71
+ case 'number': return { t: 'number', opt };
72
+ case 'boolean': return { t: 'boolean', opt };
73
+ case 'date': return { t: 'string', opt };
74
+ case 'string': case 'uuid': case 'email': case 'url': return { t: 'string', opt };
75
+ case 'object': {
76
+ const props = attr.props ?? attr.properties;
77
+ if (props && typeof props === 'object') {
78
+ const fields = Object.entries(props)
79
+ .map(([k, v]) => { const f = ts(v); return `${ident(k)}${f.opt ? '?' : ''}: ${f.t}`; })
80
+ .join('; ');
81
+ return { t: `{ ${fields} }`, opt };
82
+ }
83
+ return { t: 'Record<string, unknown>', opt };
84
+ }
85
+ default: return { t: 'unknown', opt };
86
+ }
87
+ }
88
+ return { t: 'unknown', opt: true };
89
+ }
90
+
91
+ const ident = (s) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : JSON.stringify(s));
92
+
93
+ // START_BLOCK_READ_SCHEMA
94
+ const sql = postgres(dsn, { max: 1 });
95
+ const rows = await sql.unsafe(
96
+ `SELECT id, alias, category, attributes, meta FROM "${schema.replaceAll('"', '""')}"."Schema" WHERE partition = 'entity' ORDER BY category, "order"`,
97
+ );
98
+ await sql.end();
99
+
100
+ // END_BLOCK_READ_SCHEMA
101
+ // START_BLOCK_EMIT_TYPES
102
+ const lines = [
103
+ '/* Сгенерировано scripts/gen-types.mjs — не редактировать вручную. */',
104
+ `import type { Row, Filter, Path, Cursor } from 'letopis';`,
105
+ '',
106
+ 'export interface TypedRow<D> extends Omit<Row, \'data\'> { data: D }',
107
+ '',
108
+ ];
109
+ const facade = [];
110
+ for (const r of rows) {
111
+ if (r.meta?.abstract === true || r.meta?.abstract === 'true') continue;
112
+ const name = `${r.id}Data`;
113
+ lines.push(`/** ${r.category} ${r.id} · ${r.alias} */`, `export interface ${name} {`);
114
+ for (const [f, attr] of Object.entries(r.attributes)) {
115
+ if (f === 'id') continue;
116
+ const { t, opt } = ts(attr);
117
+ lines.push(` ${ident(f)}${opt ? '?' : ''}: ${t};`);
118
+ }
119
+ lines.push('}', '');
120
+ for (const key of [r.id, r.alias]) {
121
+ facade.push(` ${ident(key)}: (filter?: Filter) => TypedChain<${name}>;`);
122
+ }
123
+ }
124
+ lines.push(
125
+ 'export interface TypedChain<D> {',
126
+ ' execute(): Promise<Path[]>;',
127
+ ' rows(): Promise<TypedRow<D>[]>;',
128
+ ' first(): Promise<TypedRow<D> | null>;',
129
+ ' ids(): Promise<string[]>;',
130
+ ' count(): Promise<number>;',
131
+ ' versions(): Promise<TypedRow<D>[]>;',
132
+ ' set(data?: Partial<D> & { id?: string }): PromiseLike<TypedRow<D>[]> & Record<string, (t: string | { id: string }) => unknown>;',
133
+ ' delete(): Promise<TypedRow<D>[]>;',
134
+ ' anonymize(fields: (keyof D & string)[]): Promise<TypedRow<D>[]>;',
135
+ ' limit(n: number): TypedChain<D>;',
136
+ ' offset(n: number): TypedChain<D>;',
137
+ ' sort(field: string, dir?: \'asc\' | \'desc\' | boolean): TypedChain<D>;',
138
+ ' asOf(t: string | Date): TypedChain<D>;',
139
+ ' after(c: Cursor): TypedChain<D>;',
140
+ ' alias(name: string): TypedChain<D>;',
141
+ ' tags(v: string | string[] | object): TypedChain<D>;',
142
+ ' account(v: string | { id: string }): TypedChain<D>;',
143
+ ' owner(v: string | { id: string }): TypedChain<D>;',
144
+ ' [className: string]: unknown;',
145
+ '}',
146
+ '',
147
+ 'export interface TypedDb {',
148
+ ...facade,
149
+ '}',
150
+ '',
151
+ );
152
+ // END_BLOCK_EMIT_TYPES
153
+ await writeFile(out, lines.join('\n'), 'utf8');
154
+ console.log(`written ${out}: ${rows.length} classes`);
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Миграция классов: diff JSON-файла схемы и таблицы Schema + отчёт совместимости.
4
+ * Ловит ломающие изменения ДО применения: новый валидатор прогоняется по живым
5
+ * latest-строкам каждого изменённого класса (строгая валидация, как в рантайме).
6
+ *
7
+ * node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
8
+ * node scripts/schema-sync.mjs --file=… --dsn=… --schema=v1.booking --apply
9
+ *
10
+ * Без --apply — только отчёт (exit 1, если есть несовместимые строки).
11
+ * Классы, отсутствующие в файле, НЕ удаляются (append-only дух; удаление — вручную).
12
+ * ancestors/descendants пересчитывает триггер schema_lineage.
13
+ */
14
+ //
15
+ // FILE: lib/scripts/schema-sync.mjs
16
+ // VERSION: 1.0.0
17
+ // START_MODULE_CONTRACT
18
+ // PURPOSE: CLI-миграция схемы — diff JSON-файла и таблицы Schema, ревалидация выборки живых строк, upsert классов при --apply.
19
+ // SCOPE: парсинг аргументов, канон-сериализация (stable/linkEnd), diff added/changed/removed, проверка совместимости, apply.
20
+ // DEPENDS: none
21
+ // LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
22
+ // ROLE: SCRIPT
23
+ // MAP_MODE: LOCALS
24
+ // END_MODULE_CONTRACT
25
+ //
26
+ // START_MODULE_MAP
27
+ // stable - стабильная сериализация для сравнения
28
+ // linkEnd/linksCanon - канон элементов Schema.links
29
+ // FIELDS - поля класса для diff/upsert
30
+ // added/changed/removed - результат diff файла и БД
31
+ // breakingRows - живые строки, ломающиеся о новую валидацию
32
+ // END_MODULE_MAP
33
+ //
34
+ // START_CHANGE_SUMMARY
35
+ // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
36
+ // END_CHANGE_SUMMARY
37
+ import { readFile } from 'node:fs/promises';
38
+ import { createRequire } from 'node:module';
39
+ import postgres from 'postgres';
40
+
41
+ const Validator = createRequire(import.meta.url)('fastest-validator');
42
+ const v = new Validator({ useNewCustomCheckerFunction: true });
43
+
44
+ // START_BLOCK_PARSE_ARGS
45
+ const args = Object.fromEntries(
46
+ process.argv.slice(2).map((a) => {
47
+ const m = a.match(/^--([^=]+)(?:=(.*))?$/);
48
+ return m ? [m[1], m[2] ?? true] : [a, true];
49
+ }),
50
+ );
51
+ const { file, schema } = args;
52
+ const dsn = args.dsn ?? process.env.ENTITY_DSN;
53
+ const partition = args.partition ?? 'entity';
54
+ const sample = Number(args.sample ?? 200);
55
+ if (!file || !dsn || !schema || typeof schema !== 'string') {
56
+ console.error('usage: node scripts/schema-sync.mjs --file=schema.json --dsn=… --schema=<pg_schema> [--partition=entity] [--sample=200] [--apply]');
57
+ process.exit(1);
58
+ }
59
+
60
+ // END_BLOCK_PARSE_ARGS
61
+ const ident = `"${schema.replaceAll('"', '""')}"`;
62
+ /** Поля класса, участвующие в diff и upsert (lineage-колонки считает триггер). */
63
+ const FIELDS = ['alias', 'category', 'ancestor', 'attributes', 'links', 'meta', 'order'];
64
+
65
+ // START_CONTRACT: stable
66
+ // PURPOSE: Стабильная сериализация значения (сортировка ключей на всех уровнях) для сравнения diff.
67
+ // INPUTS: { x: unknown }
68
+ // OUTPUTS: { string - канонический вид }
69
+ // SIDE_EFFECTS: none
70
+ // LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
71
+ // END_CONTRACT: stable
72
+ /** Стабильная сериализация для сравнения (сортировка ключей на всех уровнях). */
73
+ function stable(x) {
74
+ if (Array.isArray(x)) return `[${x.map(stable).join(',')}]`;
75
+ if (typeof x === 'object' && x !== null)
76
+ return `{${Object.keys(x).sort().map((k) => `${JSON.stringify(k)}:${stable(x[k])}`).join(',')}}`;
77
+ return JSON.stringify(x ?? null);
78
+ }
79
+
80
+ // START_CONTRACT: linkEnd
81
+ // PURPOSE: Привести элемент links к канону (конец v2: в БД JSON-текст в text[], в файле — объект).
82
+ // INPUTS: { e: string|object }
83
+ // OUTPUTS: { string }
84
+ // SIDE_EFFECTS: none
85
+ // LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
86
+ // END_CONTRACT: linkEnd
87
+ /** Элемент links к канону: в БД конец v2 лежит JSON-текстом в text[], в файле — объектом. */
88
+ function linkEnd(e) {
89
+ if (typeof e === 'string' && e.trimStart().startsWith('{')) return stable(JSON.parse(e));
90
+ return typeof e === 'string' ? JSON.stringify(e) : stable(e);
91
+ }
92
+ const linksCanon = (arr) => `[${(arr ?? []).map(linkEnd).join(',')}]`;
93
+
94
+ const fileClasses = JSON.parse(await readFile(file, 'utf8'));
95
+ const sql = postgres(dsn, { max: 1 });
96
+
97
+ try {
98
+ // START_BLOCK_DIFF
99
+ const dbRows = await sql.unsafe(
100
+ `SELECT id, alias, category, ancestor, attributes, links, meta, "order" FROM ${ident}."Schema" WHERE partition = $1`,
101
+ [partition],
102
+ );
103
+ const inDb = new Map(dbRows.map((r) => [r.id, r]));
104
+ const inFile = new Map(fileClasses.map((c) => [c.id, c]));
105
+
106
+ const added = fileClasses.filter((c) => !inDb.has(c.id));
107
+ const removed = dbRows.filter((r) => !inFile.has(r.id));
108
+ const changed = [];
109
+ for (const c of fileClasses) {
110
+ const db = inDb.get(c.id);
111
+ if (!db) continue;
112
+ const diff = FIELDS.filter((f) =>
113
+ f === 'links'
114
+ ? linksCanon(c.links) !== linksCanon(db.links)
115
+ : stable(c[f] ?? (f === 'order' ? 0 : f === 'ancestor' ? null : {})) !== stable(db[f]));
116
+ if (diff.length) changed.push({ cls: c, fields: diff });
117
+ }
118
+
119
+ // END_BLOCK_DIFF
120
+ console.log(`schema-sync: ${file} ↔ ${schema}.Schema (partition ${partition})`);
121
+ for (const c of added) console.log(` + ${c.id} (${c.category} · ${c.alias}) — новый класс`);
122
+ for (const { cls, fields } of changed) console.log(` ~ ${cls.id} — изменены: ${fields.join(', ')}`);
123
+ for (const r of removed) console.log(` - ${r.id} — в файле отсутствует (НЕ удаляется)`);
124
+ if (!added.length && !changed.length && !removed.length) console.log(' без изменений');
125
+
126
+ // совместимость: живые latest-строки изменённых классов против НОВОЙ строгой валидации
127
+ // START_BLOCK_COMPAT_CHECK
128
+ let breakingRows = 0;
129
+ for (const { cls, fields } of changed) {
130
+ if (!fields.includes('attributes')) continue;
131
+ const check = v.compile({ ...cls.attributes, $$strict: true });
132
+ const rows = await sql.unsafe(
133
+ `SELECT t.id, t.data FROM (
134
+ SELECT DISTINCT ON (e.id) e.id, e.data, e.deleted FROM ${ident}."Entity" e
135
+ WHERE e.partition = $1 AND e.class = $2
136
+ ORDER BY e.id, e.updated DESC
137
+ ) t WHERE t.deleted IS NULL LIMIT $3`,
138
+ [partition, cls.id, sample],
139
+ );
140
+ const bad = [];
141
+ for (const r of rows) {
142
+ const res = check({ ...r.data, id: r.id });
143
+ if (res !== true) bad.push({ id: r.id, issues: res });
144
+ }
145
+ if (bad.length) {
146
+ breakingRows += bad.length;
147
+ console.log(` ! ${cls.id}: ${bad.length}/${rows.length} живых строк НЕ пройдут новую валидацию:`);
148
+ for (const b of bad.slice(0, 3)) {
149
+ console.log(` id=${b.id} → ${b.issues.map((i) => `${i.field} — ${i.message ?? 'invalid'}`).join('; ')}`);
150
+ }
151
+ if (bad.length > 3) console.log(` … и ещё ${bad.length - 3}`);
152
+ } else if (rows.length) {
153
+ console.log(` ✓ ${cls.id}: ${rows.length} живых строк проходят новую валидацию`);
154
+ }
155
+ }
156
+
157
+ // END_BLOCK_COMPAT_CHECK
158
+ // START_BLOCK_APPLY
159
+ if (!args.apply) {
160
+ if (breakingRows) {
161
+ console.log(`ИТОГ: ломающие изменения (${breakingRows} строк). Применение (--apply) сломает запись этих сущностей.`);
162
+ process.exitCode = 1;
163
+ } else {
164
+ console.log(`ИТОГ: ${added.length} новых, ${changed.length} изменённых. Применить: --apply`);
165
+ }
166
+ } else {
167
+ if (breakingRows) console.warn(`ВНИМАНИЕ: применяю НЕсовместимую схему (${breakingRows} строк перестанут проходить валидацию при записи).`);
168
+ for (const c of [...added, ...changed.map((x) => x.cls)]) {
169
+ await sql.unsafe(
170
+ `INSERT INTO ${ident}."Schema" (partition, id, alias, category, ancestor, attributes, links, meta, "order")
171
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
172
+ ON CONFLICT (partition, id) DO UPDATE SET
173
+ alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor,
174
+ attributes = EXCLUDED.attributes, links = EXCLUDED.links, meta = EXCLUDED.meta, "order" = EXCLUDED."order"`,
175
+ // links — jsonb-массив (объекты-концы v2 / legacy-строки); postgres.js сериализует сам
176
+ [partition, c.id, c.alias, c.category, c.ancestor ?? null, c.attributes ?? {},
177
+ c.links ?? [], c.meta ?? {}, c.order ?? 0],
178
+ );
179
+ }
180
+ console.log(`применено: ${added.length} новых, ${changed.length} изменённых классов`);
181
+ }
182
+ // END_BLOCK_APPLY
183
+ } finally {
184
+ await sql.end();
185
+ }
package/sql/ddl.sql CHANGED
@@ -6,14 +6,41 @@
6
6
  -- "<SCHEMA-NAME>" — маркер: letopis.up() / db/apply.mjs подставляет полное имя схемы vN.<имя>.
7
7
  -- =============================================================================
8
8
 
9
+ -- FILE: lib/sql/ddl.sql
10
+ -- VERSION: 1.0.0
11
+ -- START_MODULE_CONTRACT
12
+ -- PURPOSE: Движок-хранилище PostgreSQL/TimescaleDB — таблицы, Entity-hypertable, индексы и триггеры целостности (валидация, версионирование, каскад-tombstone, lineage, notify).
13
+ -- SCOPE: таблицы Schema/Entity/Account/Credential/Resource/Rule + триггеры schema_lineage/entity_check/entity_update/entity_delete/entity_notify.
14
+ -- DEPENDS: none
15
+ -- LINKS: M-DDL, V-M-DDL
16
+ -- ROLE: RUNTIME
17
+ -- MAP_MODE: SUMMARY
18
+ -- END_MODULE_CONTRACT
19
+ --
20
+ -- START_MODULE_MAP
21
+ -- Schema - определения классов (HUB/LINK), наследование, attributes/links
22
+ -- Entity - append-only hypertable версий сущностей и связей
23
+ -- Account/Credential/Resource/Rule - служебные таблицы auth/ACL
24
+ -- schema_lineage - пересчёт ancestors/descendants (statement-level)
25
+ -- entity_check - валидация класса и концов LINK при INSERT
26
+ -- entity_update - UPDATE → вставка новой версии
27
+ -- entity_delete - DELETE → tombstone + рекурсивный каскад (advisory-lock)
28
+ -- entity_notify - pg_notify о новой версии (канал = имя схемы)
29
+ -- END_MODULE_MAP
30
+ --
31
+ -- START_CHANGE_SUMMARY
32
+ -- LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
33
+ -- END_CHANGE_SUMMARY
34
+
9
35
  CREATE EXTENSION IF NOT EXISTS timescaledb;
10
36
 
11
37
  CREATE SCHEMA IF NOT EXISTS "<SCHEMA-NAME>";
12
38
 
13
39
  -- -----------------------------------------------------------------------------
14
40
  -- Schema: классы предметной области (HUB — сущности, LINK — связи).
15
- -- Сидится из schema.booking.v2.json (lib/sql/seed.booking.sql).
41
+ -- Сидится демо-доменом booking: lib/sql/seed.booking.sql (источник правды).
16
42
  -- -----------------------------------------------------------------------------
43
+ -- START_BLOCK_TABLE_SCHEMA
17
44
  CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
18
45
  partition text NOT NULL DEFAULT 'entity',
19
46
  id text NOT NULL, -- англ. id класса: 'Staff', 'skill'
@@ -28,6 +55,7 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
28
55
  descendants text[] NOT NULL DEFAULT '{}', -- все потомки (транзитивно) — считает триггер schema_lineage
29
56
  PRIMARY KEY (partition, id)
30
57
  );
58
+ -- END_BLOCK_TABLE_SCHEMA
31
59
 
32
60
  -- -----------------------------------------------------------------------------
33
61
  -- Триггер lineage: ancestors + descendants пересчитываются автоматически
@@ -35,6 +63,13 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
35
63
  -- Рекурсивные CTE вверх и вниз; защита от циклов (depth ≤ 32) и от
36
64
  -- саморекурсии триггера (pg_trigger_depth).
37
65
  -- -----------------------------------------------------------------------------
66
+ -- START_CONTRACT: schema_lineage
67
+ -- PURPOSE: Пересчитать ancestors + descendants всех классов при изменении Schema (statement-level; защита от циклов depth≤32 и саморекурсии).
68
+ -- INPUTS: { trigger: AFTER INSERT/UPDATE(ancestor,id)/DELETE ON Schema }
69
+ -- OUTPUTS: { NULL - обновляет колонки ancestors/descendants }
70
+ -- SIDE_EFFECTS: UPDATE Schema.ancestors/descendants
71
+ -- LINKS: M-DDL, V-M-DDL
72
+ -- END_CONTRACT: schema_lineage
38
73
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".schema_lineage() RETURNS trigger
39
74
  LANGUAGE plpgsql AS $$
40
75
  BEGIN
@@ -86,6 +121,7 @@ CREATE TRIGGER schema_lineage
86
121
  -- Версия = строка; актуальная версия = max(updated); удаление = tombstone (deleted).
87
122
  -- links = {"Класс": "id", ...} — произвольное число ссылок в одной строке.
88
123
  -- -----------------------------------------------------------------------------
124
+ -- START_BLOCK_ENTITY_STORAGE
89
125
  CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Entity" (
90
126
  partition text NOT NULL,
91
127
  id text NOT NULL,
@@ -126,6 +162,7 @@ CREATE INDEX IF NOT EXISTS entity_data_idx ON "<SCHEMA-NAME>"."Entity" USING
126
162
  CREATE INDEX IF NOT EXISTS entity_tags_idx ON "<SCHEMA-NAME>"."Entity" USING gin (tags);
127
163
  CREATE INDEX IF NOT EXISTS entity_account_idx ON "<SCHEMA-NAME>"."Entity" (account);
128
164
  CREATE INDEX IF NOT EXISTS entity_owner_idx ON "<SCHEMA-NAME>"."Entity" (owner);
165
+ -- END_BLOCK_ENTITY_STORAGE
129
166
 
130
167
  -- -----------------------------------------------------------------------------
131
168
  -- Триггер 1: целостность при INSERT (только проверки, никакой логики приложения).
@@ -140,6 +177,14 @@ CREATE INDEX IF NOT EXISTS entity_owner_idx ON "<SCHEMA-NAME>"."Entity" (owner
140
177
  -- полиморфный (его резолвит либа), лишние связи не проверяются.
141
178
  -- Tombstone-версии (deleted IS NOT NULL) не валидируются.
142
179
  -- -----------------------------------------------------------------------------
180
+ -- START_CONTRACT: entity_check
181
+ -- PURPOSE: Проверить при INSERT — класс существует и не абстрактный; ключи links — существующие классы со строковыми id; концы LINK (v2 жадный матчинг / legacy).
182
+ -- INPUTS: { trigger: BEFORE INSERT ON Entity (NEW) }
183
+ -- OUTPUTS: { NEW - либо EXCEPTION }
184
+ -- SIDE_EFFECTS: none (валидация; tombstone-версии пропускаются)
185
+ -- ERRORS: unknown class; class is abstract; links key not a class; link requires end; stray link
186
+ -- LINKS: M-DDL, V-M-DDL, M-WRITE
187
+ -- END_CONTRACT: entity_check
143
188
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_check() RETURNS trigger
144
189
  LANGUAGE plpgsql AS $$
145
190
  DECLARE
@@ -184,6 +229,7 @@ BEGIN
184
229
 
185
230
  -- LINK: концы по Schema.links — jsonb-массив; элемент-объект = v2 (строгий жадный
186
231
  -- матчинг по порядку), элемент-строка = legacy-имя класса
232
+ -- START_BLOCK_ENTITY_CHECK_LINK
187
233
  IF s.category = 'LINK' THEN
188
234
  is_v2 := EXISTS (
189
235
  SELECT 1 FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le
@@ -223,6 +269,7 @@ BEGIN
223
269
  END IF;
224
270
  END IF;
225
271
 
272
+ -- END_BLOCK_ENTITY_CHECK_LINK
226
273
  RETURN NEW;
227
274
  END $$;
228
275
 
@@ -238,6 +285,13 @@ CREATE TRIGGER entity_check
238
285
  -- * вставка проходит entity_check (валидация класса/links).
239
286
  -- Работает и для голого SQL: UPDATE "<SCHEMA-NAME>"."Entity" SET data=… WHERE …
240
287
  -- -----------------------------------------------------------------------------
288
+ -- START_CONTRACT: entity_update
289
+ -- PURPOSE: Превратить UPDATE в INSERT новой версии — бьёт только актуальную строку, историю не трогает, updated монотонно растёт.
290
+ -- INPUTS: { trigger: BEFORE UPDATE ON Entity (OLD/NEW) }
291
+ -- OUTPUTS: { NULL - физический UPDATE отменён }
292
+ -- SIDE_EFFECTS: INSERT новой версии (проходит entity_check)
293
+ -- LINKS: M-DDL, V-M-DDL
294
+ -- END_CONTRACT: entity_update
241
295
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_update() RETURNS trigger
242
296
  LANGUAGE plpgsql AS $$
243
297
  DECLARE latest timestamptz;
@@ -270,6 +324,13 @@ CREATE TRIGGER entity_update
270
324
  -- * физического удаления не происходит никогда (очистка истории — retention-политики).
271
325
  -- Работает и для голого SQL: DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE partition=… AND class=… AND id=…
272
326
  -- -----------------------------------------------------------------------------
327
+ -- START_CONTRACT: entity_delete
328
+ -- PURPOSE: Превратить DELETE в tombstone + рекурсивный каскад по links; advisory-lock сериализует параллельные удаления; физического удаления нет.
329
+ -- INPUTS: { trigger: BEFORE DELETE ON Entity (OLD) }
330
+ -- OUTPUTS: { NULL - физическое удаление отменено }
331
+ -- SIDE_EFFECTS: INSERT tombstone; DELETE живых зависимых (рекурсия триггера); pg_advisory_xact_lock
332
+ -- LINKS: M-DDL, V-M-DDL, M-WRITE
333
+ -- END_CONTRACT: entity_delete
273
334
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_delete() RETURNS trigger
274
335
  LANGUAGE plpgsql AS $$
275
336
  DECLARE latest timestamptz;
@@ -310,6 +371,7 @@ CREATE TRIGGER entity_delete
310
371
  -- =============================================================================
311
372
 
312
373
  -- Аккаунты: пользователи и сервисы. Категории: System | User | Anonymous | Shadow…
374
+ -- START_BLOCK_AUTH_TABLES
313
375
  CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Account" (
314
376
  id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
315
377
  categories character varying[] DEFAULT ARRAY['User'::text] NOT NULL,
@@ -367,11 +429,19 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Rule" (
367
429
  enabled boolean DEFAULT true NOT NULL,
368
430
  PRIMARY KEY (account, resource)
369
431
  );
432
+ -- END_BLOCK_AUTH_TABLES
370
433
 
371
434
  -- -----------------------------------------------------------------------------
372
435
  -- Триггер 4: realtime — факт каждой новой версии в pg_notify (канал = имя схемы).
373
436
  -- Payload лёгкий (без data): подписчик дочитывает нужное сам. Потребитель: db.watch().
374
437
  -- -----------------------------------------------------------------------------
438
+ -- START_CONTRACT: entity_notify
439
+ -- PURPOSE: Опубликовать факт новой версии в pg_notify (канал = имя схемы), лёгкий payload без data.
440
+ -- INPUTS: { trigger: AFTER INSERT ON Entity (NEW) }
441
+ -- OUTPUTS: { NULL }
442
+ -- SIDE_EFFECTS: pg_notify(канал = имя схемы)
443
+ -- LINKS: M-DDL, V-M-DDL, M-CHAIN
444
+ -- END_CONTRACT: entity_notify
375
445
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_notify() RETURNS trigger
376
446
  LANGUAGE plpgsql AS $$
377
447
  BEGIN
package/sql/seed.auth.sql CHANGED
@@ -4,11 +4,35 @@
4
4
  -- Идемпотентен (ON CONFLICT).
5
5
  -- =============================================================================
6
6
 
7
+ -- FILE: lib/sql/seed.auth.sql
8
+ -- VERSION: 1.0.0
9
+ -- START_MODULE_CONTRACT
10
+ -- PURPOSE: Идемпотентный сид System-аккаунта и дефолтного набора ACL Resource/Rule для auth-эндпоинтов.
11
+ -- SCOPE: 1 Account (System), 16 Resource (subjects+API), 12 Rule (allow/deny с весами).
12
+ -- DEPENDS: none
13
+ -- LINKS: M-SEED-AUTH, V-M-SEED-AUTH
14
+ -- ROLE: CONFIG
15
+ -- MAP_MODE: SUMMARY
16
+ -- END_MODULE_CONTRACT
17
+ --
18
+ -- START_MODULE_MAP
19
+ -- Account - один System-аккаунт (фикс. id 2eba6d0a-…)
20
+ -- Resource - 16 ресурсов ACL (anonymous/authenticated/any, auth.*, payment/shop/billing/geo)
21
+ -- Rule - 12 правил allow/deny, вес 0..105
22
+ -- END_MODULE_MAP
23
+ --
24
+ -- START_CHANGE_SUMMARY
25
+ -- LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
26
+ -- END_CHANGE_SUMMARY
27
+
28
+ -- START_BLOCK_SEED_ACCOUNT
7
29
  -- Системный аккаунт (фиксированный id из дампа)
8
30
  INSERT INTO "<SCHEMA-NAME>"."Account" (id, categories, data, meta, avatar, enabled) VALUES
9
31
  ('2eba6d0a-1edd-4bb6-a85a-a703dab49035', '{System}', '{"id": "System", "name": "System"}', '{}', 'https://i.pravatar.cc/128?img=28', true)
10
32
  ON CONFLICT (id) DO NOTHING;
11
33
 
34
+ -- END_BLOCK_SEED_ACCOUNT
35
+ -- START_BLOCK_SEED_RESOURCES
12
36
  -- Ресурсы: субъекты (категории аккаунтов) и объекты (API-эндпоинты)
13
37
  INSERT INTO "<SCHEMA-NAME>"."Resource" (alias, category, pattern, meta) VALUES
14
38
  ('anonymous:ACCOUNT', 'ACCOUNT', '{"categories": "{Anonymous,Shadow}"}', '{"description": "Unauthenticated users (includes Anonymous and Shadow categories)"}'),
@@ -29,6 +53,8 @@ INSERT INTO "<SCHEMA-NAME>"."Resource" (alias, category, pattern, meta) VALUES
29
53
  ('geo:API', 'API', '{"endpoint": "v2.geo.*"}', NULL)
30
54
  ON CONFLICT (alias) DO UPDATE SET category = EXCLUDED.category, pattern = EXCLUDED.pattern, meta = EXCLUDED.meta;
31
55
 
56
+ -- END_BLOCK_SEED_RESOURCES
57
+ -- START_BLOCK_SEED_RULES
32
58
  -- Правила: субъект → объект → allow|deny (weight — приоритет)
33
59
  INSERT INTO "<SCHEMA-NAME>"."Rule" (account, resource, permission, weight, meta, enabled) VALUES
34
60
  ('authenticated:ACCOUNT', 'auth.user.password:API', 'allow', 90, '{"description": "Users can change their own password"}', true),
@@ -44,3 +70,4 @@ INSERT INTO "<SCHEMA-NAME>"."Rule" (account, resource, permission, weight, meta,
44
70
  ('authenticated:ACCOUNT', 'billing:API', 'allow', 90, '{"description": "Users can access billing (uses ctx.meta.$account.id)"}', true),
45
71
  ('authenticated:ACCOUNT', 'geo:API', 'allow', 90, '{"description": "Users can access geo (uses ctx.meta.$account.id)"}', true)
46
72
  ON CONFLICT (account, resource) DO UPDATE SET permission = EXCLUDED.permission, weight = EXCLUDED.weight, meta = EXCLUDED.meta, enabled = EXCLUDED.enabled;
73
+ -- END_BLOCK_SEED_RULES