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.
- package/CHANGELOG.md +132 -5
- package/README.md +924 -544
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.js +112 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +36 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +73 -0
- package/dist/sessions.js +66 -0
- package/dist/sql.js +90 -1
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.js +36 -0
- package/dist/write.js +148 -2
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +2 -1
- package/scripts/gen-types.mjs +154 -0
- package/scripts/schema-sync.mjs +185 -0
- package/sql/ddl.sql +71 -1
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +60 -18
|
@@ -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
|
-
-- Сидится
|
|
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
|