letopis 0.18.1 → 0.20.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 +254 -0
- package/LICENSE +21 -0
- package/README.md +492 -103
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +3 -2
- package/dist/chain.d.ts +46 -0
- package/dist/chain.js +38 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +50 -19
- package/dist/schema.js +48 -6
- package/dist/sql.d.ts +26 -1
- package/dist/sql.js +130 -38
- package/dist/tables.d.ts +24 -0
- package/dist/tables.js +64 -1
- package/dist/types.d.ts +34 -9
- package/dist/types.js +40 -3
- package/dist/up.js +15 -4
- package/dist/write.d.ts +9 -0
- package/dist/write.js +46 -6
- package/package.json +9 -3
- package/scripts/check-docs.mjs +338 -0
- package/scripts/gen-api-contract.mjs +221 -0
- package/scripts/gen-types.mjs +238 -42
- package/sql/ddl.sql +114 -2
- package/sql/seed.booking.sql +5 -4
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Машиночитаемый контракт публичного API → docs/api-contract.json:
|
|
4
|
+
* node scripts/gen-api-contract.mjs [--out=../docs/api-contract.json] [--check]
|
|
5
|
+
*
|
|
6
|
+
* Всё ВЫВОДИТСЯ из исходников (src/index.ts, src/chain.ts, src/types.ts) — файл не
|
|
7
|
+
* набивается руками, иначе он стал бы очередным источником дрейфа. --check не пишет,
|
|
8
|
+
* а сравнивает с закоммиченным и выходит с кодом 1 при расхождении (используется в CI).
|
|
9
|
+
*
|
|
10
|
+
* Назначение: дать ИИ-агенту разбираемый список того, ЧТО есть и чего УЖЕ НЕТ,
|
|
11
|
+
* не заставляя читать 3500 строк README.
|
|
12
|
+
*/
|
|
13
|
+
//
|
|
14
|
+
// FILE: lib/scripts/gen-api-contract.mjs
|
|
15
|
+
// VERSION: 1.0.0
|
|
16
|
+
// START_MODULE_CONTRACT
|
|
17
|
+
// PURPOSE: Сгенерировать docs/api-contract.json из исходников — экспорты, поверхность цепочки, снесённые имена, зарезервированные имена классов, каталог ошибок.
|
|
18
|
+
// SCOPE: парсинг src/index.ts (barrel), src/chain.ts (case-метки Proxy + ChainCore), src/types.ts (RESERVED_CLASS_NAMES), сбор throw-сообщений; запись/сверка JSON.
|
|
19
|
+
// DEPENDS: none
|
|
20
|
+
// LINKS: M-GEN-API-CONTRACT, V-M-GEN-API-CONTRACT
|
|
21
|
+
// ROLE: SCRIPT
|
|
22
|
+
// MAP_MODE: LOCALS
|
|
23
|
+
// END_MODULE_CONTRACT
|
|
24
|
+
//
|
|
25
|
+
// START_MODULE_MAP
|
|
26
|
+
// read - прочитать файл пакета относительно lib/
|
|
27
|
+
// exportsOf - публичные значения и типы из barrel src/index.ts
|
|
28
|
+
// chainSurface - case-метки Proxy цепочки/db + сигнатуры ChainCore
|
|
29
|
+
// removedNames - снесённые имена с версией и заменой (из migration-охранников)
|
|
30
|
+
// errorCatalog - все throw-сообщения letopis: с указанием файла
|
|
31
|
+
// main - сборка контракта, запись либо --check
|
|
32
|
+
// END_MODULE_MAP
|
|
33
|
+
//
|
|
34
|
+
// START_CHANGE_SUMMARY
|
|
35
|
+
// LAST_CHANGE: [v1.0.0 - Новый модуль: генерируемый машиночитаемый контракт API для агентов]
|
|
36
|
+
// END_CHANGE_SUMMARY
|
|
37
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
|
|
41
|
+
const LIB = fileURLToPath(new URL('..', import.meta.url));
|
|
42
|
+
const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
|
|
43
|
+
const out = args.out ?? join(LIB, '..', 'docs', 'api-contract.json');
|
|
44
|
+
const check = 'check' in args;
|
|
45
|
+
|
|
46
|
+
const read = (p) => readFile(join(LIB, p), 'utf8');
|
|
47
|
+
|
|
48
|
+
// START_CONTRACT: exportsOf
|
|
49
|
+
// PURPOSE: Собрать публичную поверхность из barrel src/index.ts (значения и типы).
|
|
50
|
+
// INPUTS: { src: string - текст src/index.ts }
|
|
51
|
+
// OUTPUTS: { { values: string[]; types: string[] } }
|
|
52
|
+
// SIDE_EFFECTS: none
|
|
53
|
+
// LINKS: M-GEN-API-CONTRACT, M-CONNECT
|
|
54
|
+
// END_CONTRACT: exportsOf
|
|
55
|
+
function exportsOf(src) {
|
|
56
|
+
const values = new Set();
|
|
57
|
+
const types = new Set();
|
|
58
|
+
// export function foo / export const foo / export class Foo
|
|
59
|
+
for (const m of src.matchAll(/^export\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z_$][\w$]*)/gm)) {
|
|
60
|
+
values.add(m[1]);
|
|
61
|
+
}
|
|
62
|
+
// export { a, b } from '…' / export type { A, B } from '…'
|
|
63
|
+
for (const m of src.matchAll(/^export\s+(type\s+)?\{([^}]+)\}/gm)) {
|
|
64
|
+
const bucket = m[1] ? types : values;
|
|
65
|
+
for (const raw of m[2].split(',')) {
|
|
66
|
+
const name = raw.trim().split(/\s+as\s+/).pop().trim();
|
|
67
|
+
if (name) bucket.add(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { values: [...values].sort(), types: [...types].sort() };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// START_CONTRACT: chainSurface
|
|
74
|
+
// PURPOSE: Извлечь имена, которые Proxy разбирает до резолва класса, и сигнатуры ChainCore.
|
|
75
|
+
// INPUTS: { chain: string - текст src/chain.ts }
|
|
76
|
+
// OUTPUTS: { { chainNames: string[]; dbNames: string[]; chainCore: {name,signature}[] } }
|
|
77
|
+
// SIDE_EFFECTS: none
|
|
78
|
+
// LINKS: M-GEN-API-CONTRACT, M-CHAIN
|
|
79
|
+
// END_CONTRACT: chainSurface
|
|
80
|
+
function chainSurface(chain) {
|
|
81
|
+
// ТРИ handler-switch'а, каждый разбирает имена до ctx.registry.has(prop):
|
|
82
|
+
// makeChain (цепочка) → makeBatch (батч) → makeDb (корень)
|
|
83
|
+
const batchAt = chain.indexOf('function makeBatch');
|
|
84
|
+
const dbAt = chain.indexOf('export function makeDb');
|
|
85
|
+
// перехват бывает ДВУХ форм: case-метка switch'а И ранний `if (prop === '…')`
|
|
86
|
+
// (так разбираются фасады db.accounts/auth/acl и гашение 'then') — ловим обе
|
|
87
|
+
const names = (text) =>
|
|
88
|
+
[
|
|
89
|
+
...new Set([
|
|
90
|
+
...[...text.matchAll(/case '([A-Za-z_$][\w$]*)':/g)].map((m) => m[1]),
|
|
91
|
+
...[...text.matchAll(/(?<!typeof )prop === '([A-Za-z_$][\w$]*)'/g)].map((m) => m[1]),
|
|
92
|
+
]),
|
|
93
|
+
].sort();
|
|
94
|
+
const chainNames = names(chain.slice(0, batchAt));
|
|
95
|
+
const batchNames = names(chain.slice(batchAt, dbAt)).filter((n) => !chainNames.includes(n));
|
|
96
|
+
const dbNames = names(chain.slice(dbAt)).filter((n) => !chainNames.includes(n) && !batchNames.includes(n));
|
|
97
|
+
|
|
98
|
+
// ChainCore — источник истины поверхности цепочки для типов и кодогена
|
|
99
|
+
const body = chain.slice(chain.indexOf('export interface ChainCore {'));
|
|
100
|
+
const end = body.indexOf('\n}');
|
|
101
|
+
const chainCore = [...body.slice(0, end).matchAll(/^\s{2}([A-Za-z_$][\w$]*)\((.*?)\):\s*(.+?);$/gm)]
|
|
102
|
+
.map((m) => ({ name: m[1], signature: `${m[1]}(${m[2]}): ${m[3]}` }));
|
|
103
|
+
return { chainNames, batchNames, dbNames, chainCore };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// START_CONTRACT: removedNames
|
|
107
|
+
// PURPOSE: Собрать снесённые имена API с версией удаления и заменой (из migration-охранников chain.ts).
|
|
108
|
+
// INPUTS: { chain: string - текст src/chain.ts }
|
|
109
|
+
// OUTPUTS: { { name, removedIn, message }[] }
|
|
110
|
+
// SIDE_EFFECTS: none
|
|
111
|
+
// LINKS: M-GEN-API-CONTRACT, M-CHAIN
|
|
112
|
+
// END_CONTRACT: removedNames
|
|
113
|
+
function removedNames(chain) {
|
|
114
|
+
const byMsg = new Map();
|
|
115
|
+
// все migration-охранники: renamed / removed / split into — с версией в скобках
|
|
116
|
+
for (const m of chain.matchAll(/letopis: ([^'`\n]*?(?:renamed to|removed|split into)[^'`\n]*)/g)) {
|
|
117
|
+
const text = m[1].replace(/\$\{[^}]*\}/g, '<…>').replace(/\s+/g, ' ').trim();
|
|
118
|
+
const ver = text.match(/\((\d+\.\d+\.\d+)\)/);
|
|
119
|
+
// что именно снесено: 'batch.execute()' / '.link()' / 'slot .delete()' / 'set()'
|
|
120
|
+
const what = text.match(/^((?:[A-Za-z_$][\w$]*\s+)?\.?[\w$.]*\(\))/);
|
|
121
|
+
const key = text.slice(0, 60);
|
|
122
|
+
if (byMsg.has(key)) continue;
|
|
123
|
+
byMsg.set(key, {
|
|
124
|
+
removed: what ? what[1] : null,
|
|
125
|
+
removedIn: ver ? ver[1] : null,
|
|
126
|
+
replacement: text.includes('—') ? text.split('—').slice(1).join('—').trim() : null,
|
|
127
|
+
message: `letopis: ${text}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return [...byMsg.values()].sort((a, b) => String(a.removed).localeCompare(String(b.removed)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// START_CONTRACT: errorCatalog
|
|
134
|
+
// PURPOSE: Собрать все throw-сообщения letopis по модулям — greppable каталог для агента.
|
|
135
|
+
// INPUTS: { files: Map<string,string> - имя модуля → текст }
|
|
136
|
+
// OUTPUTS: { { module, message }[] - шаблоны сообщений с ${…} как <…> }
|
|
137
|
+
// SIDE_EFFECTS: none
|
|
138
|
+
// LINKS: M-GEN-API-CONTRACT
|
|
139
|
+
// END_CONTRACT: errorCatalog
|
|
140
|
+
function errorCatalog(files) {
|
|
141
|
+
const rows = [];
|
|
142
|
+
for (const [mod, text] of files) {
|
|
143
|
+
for (const m of text.matchAll(/letopis(?:\.up|\.purge)?:\s?([^'`\n]{4,160})/g)) {
|
|
144
|
+
const msg = m[0].replace(/\$\{[^}]*\}/g, '<…>').replace(/\s+/g, ' ').trim();
|
|
145
|
+
if (!rows.some((r) => r.message === msg)) rows.push({ module: mod, message: msg });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return rows.sort((a, b) => a.message.localeCompare(b.message));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// START_BLOCK_MAIN
|
|
152
|
+
const index = await read('src/index.ts');
|
|
153
|
+
const chain = await read('src/chain.ts');
|
|
154
|
+
const types = await read('src/types.ts');
|
|
155
|
+
const pkg = JSON.parse(await read('package.json'));
|
|
156
|
+
|
|
157
|
+
const { chainNames, batchNames, dbNames, chainCore } = chainSurface(chain);
|
|
158
|
+
// начинаем ПОСЛЕ '= [' — иначе первый '[' попадёт на 'readonly string[]'
|
|
159
|
+
const RES_MARK = 'RESERVED_CLASS_NAMES: readonly string[] = [';
|
|
160
|
+
const resStart = types.indexOf(RES_MARK) + RES_MARK.length;
|
|
161
|
+
// комментарии выбрасываем ДО извлечения строк: апостроф или пример в кавычках внутри
|
|
162
|
+
// комментария иначе попадёт в список
|
|
163
|
+
const reserved = [
|
|
164
|
+
...types
|
|
165
|
+
.slice(resStart, types.indexOf('];', resStart))
|
|
166
|
+
.replace(/\/\/[^\n]*/g, '')
|
|
167
|
+
.matchAll(/'([^']+)'/g),
|
|
168
|
+
].map((m) => m[1]);
|
|
169
|
+
if (!reserved.length) throw new Error('gen-api-contract: не разобрал RESERVED_CLASS_NAMES из src/types.ts');
|
|
170
|
+
|
|
171
|
+
const files = new Map([
|
|
172
|
+
['chain', chain], ['sql', await read('src/sql.ts')], ['write', await read('src/write.ts')],
|
|
173
|
+
['schema', types && (await read('src/schema.ts'))], ['tables', await read('src/tables.ts')],
|
|
174
|
+
['auth', await read('src/auth.ts')], ['acl', await read('src/acl.ts')],
|
|
175
|
+
['tx', await read('src/tx.ts')], ['up', await read('src/up.ts')], ['index', index],
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const contract = {
|
|
179
|
+
$comment:
|
|
180
|
+
'СГЕНЕРИРОВАНО lib/scripts/gen-api-contract.mjs из исходников — не редактировать руками. ' +
|
|
181
|
+
'Регенерация: cd lib && npm run api:contract. CI сверяет через npm run check:docs.',
|
|
182
|
+
package: { name: pkg.name, type: pkg.type, engines: pkg.engines, entry: pkg.main, types: pkg.types },
|
|
183
|
+
note: {
|
|
184
|
+
versionSource: 'lib/package.json + верхняя запись lib/CHANGELOG.md',
|
|
185
|
+
schemaOption:
|
|
186
|
+
'connect({schema}) требует ПОЛНОГО имени PG-схемы С версией движка ("v1.booking"); ' +
|
|
187
|
+
'up({schema,version}) берёт БАЗОВОЕ имя без точек и строит "v<version>.<schema>"',
|
|
188
|
+
classStep: 'шаг цепочки принимает id ИЛИ alias класса из таблицы Schema (db.Org ≡ db.Организация)',
|
|
189
|
+
writeVerbs: 'create/update/delete/purge/anonymize возвращают ЦЕПОЧКУ (звено плана); исполняет терминал',
|
|
190
|
+
},
|
|
191
|
+
exports: exportsOf(index),
|
|
192
|
+
chain: {
|
|
193
|
+
core: chainCore,
|
|
194
|
+
interceptedByChainProxy: chainNames,
|
|
195
|
+
interceptedByBatchProxy: batchNames,
|
|
196
|
+
interceptedByDbProxy: dbNames,
|
|
197
|
+
reservedClassNames: reserved,
|
|
198
|
+
reservedClassNamesNote:
|
|
199
|
+
'класс с таким id/alias недостижим как шаг ПОД ЭТИМ именем; schema.define() отказывает, loadRegistry предупреждает',
|
|
200
|
+
},
|
|
201
|
+
removed: removedNames(chain),
|
|
202
|
+
errors: errorCatalog(files),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const json = `${JSON.stringify(contract, null, 2)}\n`;
|
|
206
|
+
if (check) {
|
|
207
|
+
let current = null;
|
|
208
|
+
try { current = await readFile(out, 'utf8'); } catch { /* нет файла — расхождение */ }
|
|
209
|
+
if (current !== json) {
|
|
210
|
+
console.error(`api-contract: ${out} устарел — перегенерируйте: cd lib && npm run api:contract`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
console.log('api-contract: актуален');
|
|
214
|
+
} else {
|
|
215
|
+
await writeFile(out, json, 'utf8');
|
|
216
|
+
console.log(
|
|
217
|
+
`written ${out}: ${contract.exports.values.length} values, ${contract.exports.types.length} types, ` +
|
|
218
|
+
`${chainCore.length} chain methods, ${contract.removed.length} removed, ${contract.errors.length} errors`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
// END_BLOCK_MAIN
|
package/scripts/gen-types.mjs
CHANGED
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
* Генерация TS-типов из таблицы Schema:
|
|
4
4
|
* npx tsx scripts/gen-types.mjs --dsn=postgres://… --schema=v1.booking [--out=entity-types.d.ts]
|
|
5
5
|
*
|
|
6
|
-
* Результат: интерфейсы data-полей каждого класса
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Результат: интерфейсы data-полей каждого класса (с УЧЁТОМ наследования attributes),
|
|
7
|
+
* типизированные цепочки на класс, слоты связей из Schema.links и два фасада:
|
|
8
|
+
*
|
|
9
|
+
* import type { TypedDb, TypedFullDb } from './entity-types';
|
|
10
|
+
* const t = db as unknown as TypedDb; // строго: только шаги по классам, опечатка = ошибка tsc
|
|
11
|
+
* const t = db as unknown as TypedFullDb; // удобно: + begin/batch/auth/acl/tables (опечатки НЕ ловятся)
|
|
12
|
+
* await t.Мастер(и).навык().Услуга().rows() // data типизирована по классу шага
|
|
13
|
+
* await t.Мастер(и).запись().create({ notes: 'x' }).Услуга.set(s).rows()
|
|
9
14
|
*/
|
|
10
15
|
//
|
|
11
16
|
// FILE: lib/scripts/gen-types.mjs
|
|
12
|
-
// VERSION: 1.
|
|
17
|
+
// VERSION: 1.1.0
|
|
13
18
|
// START_MODULE_CONTRACT
|
|
14
|
-
// PURPOSE: CLI-кодген — читает таблицу Schema и печатает типизированный .d.ts-фасад (Data-интерфейсы классов +
|
|
15
|
-
// SCOPE: парсинг аргументов, чтение Schema, конвертация fastest-validator DSL → TS (ts), эмиссия .d.ts.
|
|
19
|
+
// PURPOSE: CLI-кодген — читает таблицу Schema и печатает типизированный .d.ts-фасад (Data-интерфейсы классов с наследованием + ChainOps/Steps/слоты + TypedDb/TypedFullDb).
|
|
20
|
+
// SCOPE: парсинг аргументов, чтение Schema, слияние attributes по ancestors, конвертация fastest-validator DSL → TS (ts), разбор Schema.links в слоты, эмиссия .d.ts.
|
|
16
21
|
// DEPENDS: none
|
|
17
22
|
// LINKS: M-GEN-TYPES, V-M-GEN-TYPES
|
|
18
23
|
// ROLE: SCRIPT
|
|
@@ -20,14 +25,21 @@
|
|
|
20
25
|
// END_MODULE_CONTRACT
|
|
21
26
|
//
|
|
22
27
|
// START_MODULE_MAP
|
|
28
|
+
// RESERVED - имена, которые chain/db-Proxy перехватывает ДО резолва класса (шаг недостижим под этим именем)
|
|
23
29
|
// ts - fastest-validator DSL → TS-тип ({ t, opt })
|
|
24
30
|
// ident - безопасный идентификатор либо строковый ключ
|
|
31
|
+
// typeName - имя генерируемого типа из id класса (без коллизий со служебными)
|
|
32
|
+
// mergedAttrs - attributes класса со слиянием по ancestors (предок → потомок поверх)
|
|
33
|
+
// endsOf - Schema.links → список концов { classes, optional }
|
|
25
34
|
// sql/rows - чтение классов из таблицы Schema
|
|
26
|
-
// lines/
|
|
35
|
+
// lines/steps - аккумуляторы генерируемого .d.ts
|
|
27
36
|
// END_MODULE_MAP
|
|
28
37
|
//
|
|
29
38
|
// START_CHANGE_SUMMARY
|
|
30
|
-
// LAST_CHANGE: [v1.
|
|
39
|
+
// LAST_CHANGE: [v1.1.0 - Фасад приведён к текущему API: убраны снесённые execute()/set(data) и глушащая
|
|
40
|
+
// индекс-сигнатура; добавлены run/create/update/delete/purge/withDeleted/deep/агрегации и слоты
|
|
41
|
+
// .Класс.set()/.unset() из Schema.links; attributes теперь наследуются по ancestors; шаги по классам
|
|
42
|
+
// типизированы (опечатка = ошибка компиляции); зарезервированные имена исключены из шагов]
|
|
31
43
|
// END_CHANGE_SUMMARY
|
|
32
44
|
import { writeFile } from 'node:fs/promises';
|
|
33
45
|
import postgres from 'postgres';
|
|
@@ -40,6 +52,30 @@ const out = args.out ?? 'entity-types.d.ts';
|
|
|
40
52
|
if (!dsn) { console.error('usage: tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking [--out=…]'); process.exit(1); }
|
|
41
53
|
|
|
42
54
|
// END_BLOCK_PARSE_ARGS
|
|
55
|
+
// START_BLOCK_RESERVED_NAMES
|
|
56
|
+
/**
|
|
57
|
+
* Proxy проверяет эти имена ДО ctx.registry.has(prop) (см. lib/src/chain.ts: switch в
|
|
58
|
+
* makeChain-handler и в makeDb-handler), поэтому класс с таким id/alias недостижим как шаг
|
|
59
|
+
* под этим именем. Здесь они ИСКЛЮЧАЮТСЯ из типизированных шагов — иначе типы обещали бы
|
|
60
|
+
* вызов, который в рантайме уходит в модификатор/терминал или в migration-ошибку.
|
|
61
|
+
* Список сверяется с case-метками chain.ts в scripts/check-docs.mjs.
|
|
62
|
+
*/
|
|
63
|
+
const RESERVED = new Set([
|
|
64
|
+
// все три handler'а
|
|
65
|
+
'then',
|
|
66
|
+
// chain-уровень
|
|
67
|
+
'entity', 'run', 'execute', 'rows', 'first', 'ids', 'count', 'limit', 'offset', 'sort',
|
|
68
|
+
'asOf', 'withDeleted', 'deep', 'exact', 'sum', 'avg', 'min', 'max', 'countBy', 'after', 'versions',
|
|
69
|
+
'create', 'update', 'set', 'delete', 'anonymize', 'purge', 'alias', 'tags', 'account',
|
|
70
|
+
'owner',
|
|
71
|
+
// batch-уровень
|
|
72
|
+
'discard', 'size',
|
|
73
|
+
// db-уровень: switch + фасады, разбираемые if'ами до switch
|
|
74
|
+
'as', 'begin', 'commit', 'rollback', 'lock', 'batch', 'watch', 'close', 'reloadSchema',
|
|
75
|
+
'registry', 'sql', 'accounts', 'credentials', 'resources', 'rules', 'schema', 'auth', 'acl',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// END_BLOCK_RESERVED_NAMES
|
|
43
79
|
// START_CONTRACT: ts
|
|
44
80
|
// PURPOSE: Свести attribute-спеку fastest-validator (строка/массив/объект) к TS-типу и признаку optional.
|
|
45
81
|
// INPUTS: { attr: unknown - правило поля }
|
|
@@ -90,65 +126,225 @@ function ts(attr) {
|
|
|
90
126
|
|
|
91
127
|
const ident = (s) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : JSON.stringify(s));
|
|
92
128
|
|
|
129
|
+
// служебные имена генерируемого файла — класс с таким id получит суффикс, чтобы не затенять
|
|
130
|
+
const OWN_TYPES = new Set(['TypedRow', 'ChainOps', 'Steps', 'AnyChain', 'TypedDb', 'TypedFullDb', 'SlotTarget']);
|
|
131
|
+
// START_CONTRACT: typeName
|
|
132
|
+
// PURPOSE: Построить безопасное имя генерируемого типа из id класса.
|
|
133
|
+
// INPUTS: { id: string - id класса; suffix: string - 'Data' | 'Chain' | 'Slots' }
|
|
134
|
+
// OUTPUTS: { string - имя типа, не конфликтующее со служебными }
|
|
135
|
+
// SIDE_EFFECTS: none
|
|
136
|
+
// LINKS: M-GEN-TYPES, V-M-GEN-TYPES
|
|
137
|
+
// END_CONTRACT: typeName
|
|
138
|
+
function typeName(id, suffix) {
|
|
139
|
+
const base = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(id) ? id : id.replace(/[^A-Za-z0-9_$]/g, '_');
|
|
140
|
+
const name = `${base}${suffix}`;
|
|
141
|
+
return OWN_TYPES.has(name) ? `${name}_` : name;
|
|
142
|
+
}
|
|
143
|
+
|
|
93
144
|
// START_BLOCK_READ_SCHEMA
|
|
94
145
|
const sql = postgres(dsn, { max: 1 });
|
|
95
146
|
const rows = await sql.unsafe(
|
|
96
|
-
`SELECT id, alias, category, attributes, meta FROM "${schema.replaceAll('"', '""')}"."Schema" WHERE partition = 'entity' ORDER BY category, "order"`,
|
|
147
|
+
`SELECT id, alias, category, ancestor, ancestors, attributes, links, meta FROM "${schema.replaceAll('"', '""')}"."Schema" WHERE partition = 'entity' ORDER BY category, "order"`,
|
|
97
148
|
);
|
|
98
149
|
await sql.end();
|
|
150
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
99
151
|
|
|
100
152
|
// END_BLOCK_READ_SCHEMA
|
|
101
|
-
//
|
|
153
|
+
// START_CONTRACT: mergedAttrs
|
|
154
|
+
// PURPOSE: Слить attributes класса по цепочке ancestors (предок → потомок поверх), как это делает M-SCHEMA.buildDef.
|
|
155
|
+
// INPUTS: { r: object - строка Schema }
|
|
156
|
+
// OUTPUTS: { Record<string, unknown> - слитые attributes }
|
|
157
|
+
// SIDE_EFFECTS: none
|
|
158
|
+
// LINKS: M-GEN-TYPES, M-SCHEMA, V-M-GEN-TYPES
|
|
159
|
+
// END_CONTRACT: mergedAttrs
|
|
160
|
+
function mergedAttrs(r) {
|
|
161
|
+
// attributes НАСЛЕДУЮТСЯ (lib/src/schema.ts: buildDef); links — НЕТ
|
|
162
|
+
let chain = Array.isArray(r.ancestors) && r.ancestors.length ? r.ancestors : null;
|
|
163
|
+
if (!chain) {
|
|
164
|
+
chain = [r.id];
|
|
165
|
+
let cur = r.ancestor;
|
|
166
|
+
const seen = new Set([r.id]);
|
|
167
|
+
while (cur && !seen.has(cur)) { chain.push(cur); seen.add(cur); cur = byId.get(cur)?.ancestor ?? null; }
|
|
168
|
+
}
|
|
169
|
+
const merged = {};
|
|
170
|
+
for (let i = chain.length - 1; i >= 0; i--) Object.assign(merged, byId.get(chain[i])?.attributes ?? {});
|
|
171
|
+
return merged;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// START_CONTRACT: endsOf
|
|
175
|
+
// PURPOSE: Разобрать Schema.links класса в список концов (как M-SCHEMA.parseEnd, без валидации).
|
|
176
|
+
// INPUTS: { r: object - строка Schema }
|
|
177
|
+
// OUTPUTS: { { classes: string[]; optional: boolean }[] }
|
|
178
|
+
// SIDE_EFFECTS: none
|
|
179
|
+
// LINKS: M-GEN-TYPES, M-SCHEMA, V-M-GEN-TYPES
|
|
180
|
+
// END_CONTRACT: endsOf
|
|
181
|
+
function endsOf(r) {
|
|
182
|
+
let raw = r.links ?? [];
|
|
183
|
+
if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { return []; } }
|
|
184
|
+
if (!Array.isArray(raw)) return [];
|
|
185
|
+
return raw.map((e) => {
|
|
186
|
+
if (typeof e === 'string') {
|
|
187
|
+
if (!e.trimStart().startsWith('{')) return { classes: [e], optional: false };
|
|
188
|
+
try { e = JSON.parse(e); } catch { return null; }
|
|
189
|
+
}
|
|
190
|
+
const classes = e.classes ?? (e.class ? [e.class] : []);
|
|
191
|
+
return classes.length ? { classes, optional: e.optional === true } : null;
|
|
192
|
+
}).filter(Boolean);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// START_BLOCK_EMIT_HEADER
|
|
102
196
|
const lines = [
|
|
103
197
|
'/* Сгенерировано scripts/gen-types.mjs — не редактировать вручную. */',
|
|
104
|
-
|
|
198
|
+
`/* Схема: ${schema} · классов: ${rows.length} */`,
|
|
199
|
+
'',
|
|
200
|
+
`import type { Row, Path, Filter, Cursor, Chain, EntityDb } from 'letopis';`,
|
|
105
201
|
'',
|
|
106
202
|
'export interface TypedRow<D> extends Omit<Row, \'data\'> { data: D }',
|
|
107
203
|
'',
|
|
204
|
+
'/** Цель слота связи: id | Row/сущность с id | вложенная цепочка (та же транзакция). */',
|
|
205
|
+
'export type SlotTarget = string | { id: string } | Chain;',
|
|
206
|
+
'',
|
|
207
|
+
'/**',
|
|
208
|
+
' * Терминалы, модификаторы и глаголы записи цепочки. Self — тип конкретной цепочки-класса,',
|
|
209
|
+
' * поэтому модификаторы и глаголы записи сохраняют типизацию data.',
|
|
210
|
+
' * ВНИМАНИЕ: create/update/delete/purge/anonymize возвращают ЦЕПОЧКУ (звено плана),',
|
|
211
|
+
' * исполняет её терминал (.rows()/.run()/…), а не сам глагол.',
|
|
212
|
+
' */',
|
|
213
|
+
'export interface ChainOps<D, Self> {',
|
|
214
|
+
' /** Пути: [{шаг1: Row, шаг2: Row, …}, …]. */',
|
|
215
|
+
' run(): Promise<Path[]>;',
|
|
216
|
+
' /** Уникальные сущности последнего шага. */',
|
|
217
|
+
' rows(): Promise<TypedRow<D>[]>;',
|
|
218
|
+
' first(): Promise<TypedRow<D> | null>;',
|
|
219
|
+
' ids(): Promise<string[]>;',
|
|
220
|
+
' /** Число ПУТЕЙ. */',
|
|
221
|
+
' count(): Promise<number>;',
|
|
222
|
+
' /** ВСЕ версии сущностей последнего шага, по возрастанию updated. */',
|
|
223
|
+
' versions(): Promise<TypedRow<D>[]>;',
|
|
224
|
+
' sum(field: string): Promise<number | null>;',
|
|
225
|
+
' avg(field: string): Promise<number | null>;',
|
|
226
|
+
' min(field: string): Promise<unknown>;',
|
|
227
|
+
' max(field: string): Promise<unknown>;',
|
|
228
|
+
' countBy(field: string): Promise<Record<string, number>>;',
|
|
229
|
+
' limit(n: number): Self;',
|
|
230
|
+
' offset(n: number): Self;',
|
|
231
|
+
' sort(field: string, dir?: \'asc\' | \'desc\' | boolean): Self;',
|
|
232
|
+
' /** Чтение «как было на момент T». */',
|
|
233
|
+
' asOf(t: string | Date): Self;',
|
|
234
|
+
' /** Keyset-пагинация; требует .sort(). */',
|
|
235
|
+
' after(cursor: Cursor): Self;',
|
|
236
|
+
' /** Включить удалённые (tombstone) в выдачу последнего шага. */',
|
|
237
|
+
' withDeleted(): Self;',
|
|
238
|
+
' /** Рекурсивный self-обход: дети любой глубины (тот же класс). */',
|
|
239
|
+
' deep(max?: number): Self;',
|
|
240
|
+
' /** Снять полиморфизм шага: только свой класс, без классов-потомков. */',
|
|
241
|
+
' exact(): Self;',
|
|
242
|
+
' alias(name: string): Self;',
|
|
243
|
+
' tags(v: string | string[] | object): Self;',
|
|
244
|
+
' account(v: string | { id: string }): Self;',
|
|
245
|
+
' owner(v: string | { id: string }): Self;',
|
|
246
|
+
' /** Вклейка узла/паттерна — тип шага не выводится, отсюда AnyChain. */',
|
|
247
|
+
' entity(x: Chain | Row): AnyChain;',
|
|
248
|
+
' /** Звено: вставить сущность. id — по Schema (v4/v7/v5); фильтр-объект перед create() — ошибка. */',
|
|
249
|
+
' create(data?: Partial<D> & { id?: string }): Self;',
|
|
250
|
+
' /** Звено: новая версия каждой найденной путём (deep-merge листьев data). */',
|
|
251
|
+
' update(data?: Partial<D>): Self;',
|
|
252
|
+
' /** Звено: без confirm — превью (БД не тронута); { confirm: true } — tombstone + каскад. */',
|
|
253
|
+
' delete(opts?: { confirm?: boolean }): Self;',
|
|
254
|
+
' /** Звено: ФИЗИЧЕСКИЙ hard-erase уже удалённых (tombstone). Живую сущность не трогает — вернёт пусто. */',
|
|
255
|
+
' purge(opts?: { confirm?: boolean }): Self;',
|
|
256
|
+
' /** Звено: затереть string-поля + тег anonymized. */',
|
|
257
|
+
' anonymize(fields: (keyof D & string)[]): Self;',
|
|
258
|
+
'}',
|
|
259
|
+
'',
|
|
108
260
|
];
|
|
109
|
-
|
|
261
|
+
|
|
262
|
+
// END_BLOCK_EMIT_HEADER
|
|
263
|
+
// START_BLOCK_EMIT_CLASSES
|
|
264
|
+
const steps = [];
|
|
265
|
+
const skipped = [];
|
|
110
266
|
for (const r of rows) {
|
|
111
267
|
if (r.meta?.abstract === true || r.meta?.abstract === 'true') continue;
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
268
|
+
const dataT = typeName(r.id, 'Data');
|
|
269
|
+
const chainT = typeName(r.id, 'Chain');
|
|
270
|
+
const slotsT = typeName(r.id, 'Slots');
|
|
271
|
+
|
|
272
|
+
lines.push(`/** ${r.category} ${r.id} · ${r.alias} */`, `export interface ${dataT} {`);
|
|
273
|
+
for (const [f, attr] of Object.entries(mergedAttrs(r))) {
|
|
274
|
+
if (f === 'id' || f.startsWith('$$')) continue;
|
|
116
275
|
const { t, opt } = ts(attr);
|
|
117
276
|
lines.push(` ${ident(f)}${opt ? '?' : ''}: ${t};`);
|
|
118
277
|
}
|
|
119
278
|
lines.push('}', '');
|
|
120
|
-
|
|
121
|
-
|
|
279
|
+
|
|
280
|
+
// слоты: концы Schema.links владельца; союз задаётся ЛЮБЫМ своим классом; unset() — только optional
|
|
281
|
+
const ends = endsOf(r);
|
|
282
|
+
const slotKeys = [];
|
|
283
|
+
for (const end of ends) {
|
|
284
|
+
for (const c of end.classes) {
|
|
285
|
+
const target = byId.get(c);
|
|
286
|
+
for (const key of target ? [target.id, target.alias] : [c]) {
|
|
287
|
+
if (slotKeys.some((s) => s.key === key)) continue;
|
|
288
|
+
slotKeys.push({ key, optional: end.optional });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (slotKeys.length) {
|
|
293
|
+
lines.push(`/** Концы связей ${r.id} (Schema.links): .Класс.set(target) / .unset(). */`, `export interface ${slotsT} {`);
|
|
294
|
+
for (const { key, optional } of slotKeys) {
|
|
295
|
+
const body = optional
|
|
296
|
+
? `{ set(target: SlotTarget): ${chainT}; unset(): ${chainT} }`
|
|
297
|
+
: `{ set(target: SlotTarget): ${chainT} }`;
|
|
298
|
+
lines.push(` ${ident(key)}: ${body};`);
|
|
299
|
+
}
|
|
300
|
+
lines.push('}', '');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
lines.push(
|
|
304
|
+
`export type ${chainT} = ChainOps<${dataT}, ${chainT}> & Steps${slotKeys.length ? ` & ${slotsT}` : ''};`,
|
|
305
|
+
'',
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const names = [r.id, r.alias].filter((n, i, a) => a.indexOf(n) === i);
|
|
309
|
+
const usable = names.filter((n) => !RESERVED.has(n));
|
|
310
|
+
if (!usable.length) { skipped.push(`${r.id}·${r.alias}`); continue; }
|
|
311
|
+
for (const n of names) {
|
|
312
|
+
if (RESERVED.has(n)) {
|
|
313
|
+
steps.push(` /** ⚠ "${n}" зарезервировано цепочкой — шаг доступен как ${usable.map((u) => `\`${u}\``).join(' / ')}. */`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
steps.push(` ${ident(n)}(filter?: Filter): ${chainT};`);
|
|
122
317
|
}
|
|
123
318
|
}
|
|
319
|
+
|
|
320
|
+
// END_BLOCK_EMIT_CLASSES
|
|
321
|
+
// START_BLOCK_EMIT_FACADE
|
|
124
322
|
lines.push(
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
|
|
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;',
|
|
323
|
+
'/**',
|
|
324
|
+
' * Шаги по классам — доступны с любой цепочки и с корня db. ЗАКОННОСТЬ перехода',
|
|
325
|
+
' * (HUB↔LINK, pivot, «no path X → Y») проверяет letopis синхронно при построении цепочки;',
|
|
326
|
+
' * типы этого не гарантируют — они ловят опечатки в именах и типизируют data шага.',
|
|
327
|
+
' */',
|
|
328
|
+
'export interface Steps {',
|
|
329
|
+
...steps,
|
|
145
330
|
'}',
|
|
146
331
|
'',
|
|
147
|
-
'
|
|
148
|
-
|
|
149
|
-
'
|
|
332
|
+
'/** Цепочка с невыведенным классом шага (результат entity()). */',
|
|
333
|
+
'export type AnyChain = ChainOps<Record<string, unknown>, AnyChain> & Steps;',
|
|
334
|
+
'',
|
|
335
|
+
'/** Строгий фасад: только шаги по классам — опечатка в имени класса = ошибка компиляции. */',
|
|
336
|
+
'export type TypedDb = Steps;',
|
|
337
|
+
'',
|
|
338
|
+
'/** Полный фасад: шаги + begin/batch/watch/auth/acl/служебные таблицы.',
|
|
339
|
+
' * Индекс-сигнатура EntityDb означает, что опечатки здесь НЕ ловятся. */',
|
|
340
|
+
'export type TypedFullDb = Steps & EntityDb;',
|
|
150
341
|
'',
|
|
151
342
|
);
|
|
152
|
-
|
|
343
|
+
if (skipped.length) {
|
|
344
|
+
lines.push(`/* Без типизированного шага (все имена зарезервированы цепочкой): ${skipped.join(', ')} */`, '');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// END_BLOCK_EMIT_FACADE
|
|
153
348
|
await writeFile(out, lines.join('\n'), 'utf8');
|
|
154
349
|
console.log(`written ${out}: ${rows.length} classes`);
|
|
350
|
+
if (skipped.length) console.warn(`gen-types: пропущены шаги для ${skipped.join(', ')} — имена зарезервированы цепочкой`);
|