letopis 0.21.0 → 1.0.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/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +313 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3469
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +362 -563
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -252
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -352
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -685
- package/sql/seed.auth.sql +0 -73
package/scripts/gen-types.mjs
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
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-полей каждого класса (с УЧЁТОМ наследования 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()
|
|
14
|
-
*/
|
|
15
|
-
//
|
|
16
|
-
// FILE: lib/scripts/gen-types.mjs
|
|
17
|
-
// VERSION: 1.1.0
|
|
18
|
-
// START_MODULE_CONTRACT
|
|
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.
|
|
21
|
-
// DEPENDS: none
|
|
22
|
-
// LINKS: M-GEN-TYPES, V-M-GEN-TYPES
|
|
23
|
-
// ROLE: SCRIPT
|
|
24
|
-
// MAP_MODE: LOCALS
|
|
25
|
-
// END_MODULE_CONTRACT
|
|
26
|
-
//
|
|
27
|
-
// START_MODULE_MAP
|
|
28
|
-
// RESERVED - имена, которые chain/db-Proxy перехватывает ДО резолва класса (шаг недостижим под этим именем)
|
|
29
|
-
// ts - fastest-validator DSL → TS-тип ({ t, opt })
|
|
30
|
-
// ident - безопасный идентификатор либо строковый ключ
|
|
31
|
-
// typeName - имя генерируемого типа из id класса (без коллизий со служебными)
|
|
32
|
-
// mergedAttrs - attributes класса со слиянием по ancestors (предок → потомок поверх)
|
|
33
|
-
// endsOf - Schema.links → список концов { classes, optional }
|
|
34
|
-
// sql/rows - чтение классов из таблицы Schema
|
|
35
|
-
// lines/steps - аккумуляторы генерируемого .d.ts
|
|
36
|
-
// END_MODULE_MAP
|
|
37
|
-
//
|
|
38
|
-
// START_CHANGE_SUMMARY
|
|
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
|
-
// типизированы (опечатка = ошибка компиляции); зарезервированные имена исключены из шагов]
|
|
43
|
-
// END_CHANGE_SUMMARY
|
|
44
|
-
import { writeFile } from 'node:fs/promises';
|
|
45
|
-
import postgres from 'postgres';
|
|
46
|
-
|
|
47
|
-
// START_BLOCK_PARSE_ARGS
|
|
48
|
-
const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
|
|
49
|
-
const dsn = args.dsn ?? process.env.ENTITY_DSN;
|
|
50
|
-
const schema = args.schema ?? 'booking';
|
|
51
|
-
const out = args.out ?? 'entity-types.d.ts';
|
|
52
|
-
if (!dsn) { console.error('usage: tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking [--out=…]'); process.exit(1); }
|
|
53
|
-
|
|
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
|
|
79
|
-
// START_CONTRACT: ts
|
|
80
|
-
// PURPOSE: Свести attribute-спеку fastest-validator (строка/массив/объект) к TS-типу и признаку optional.
|
|
81
|
-
// INPUTS: { attr: unknown - правило поля }
|
|
82
|
-
// OUTPUTS: { { t: string; opt: boolean } }
|
|
83
|
-
// SIDE_EFFECTS: none (рекурсивно по вложенным props/items)
|
|
84
|
-
// LINKS: M-GEN-TYPES, V-M-GEN-TYPES
|
|
85
|
-
// END_CONTRACT: ts
|
|
86
|
-
// fastest-validator DSL → TS-тип
|
|
87
|
-
function ts(attr) {
|
|
88
|
-
if (typeof attr === 'string') {
|
|
89
|
-
const parts = attr.split('|').map((s) => s.trim());
|
|
90
|
-
const opt = parts.includes('optional');
|
|
91
|
-
const base = { number: 'number', boolean: 'boolean', date: 'string', string: 'string', uuid: 'string', email: 'string', url: 'string', any: 'unknown' }[parts[0]] ?? 'unknown';
|
|
92
|
-
return { t: base, opt };
|
|
93
|
-
}
|
|
94
|
-
if (Array.isArray(attr)) { const first = ts(attr[0] ?? 'any'); return { t: first.t, opt: true }; }
|
|
95
|
-
if (typeof attr === 'object' && attr !== null) {
|
|
96
|
-
const opt = attr.optional === true;
|
|
97
|
-
switch (attr.type) {
|
|
98
|
-
case 'enum': return { t: (attr.values ?? []).map((v) => JSON.stringify(v)).join(' | ') || 'string', opt };
|
|
99
|
-
case 'record': {
|
|
100
|
-
const key = attr.key?.type === 'enum' ? (attr.key.values ?? []).map((v) => JSON.stringify(v)).join(' | ') : 'string';
|
|
101
|
-
return { t: `Partial<Record<${key || 'string'}, number>>`, opt };
|
|
102
|
-
}
|
|
103
|
-
case 'array': {
|
|
104
|
-
const item = ts(attr.items ?? 'any');
|
|
105
|
-
return { t: `(${item.t})[]`, opt };
|
|
106
|
-
}
|
|
107
|
-
case 'number': return { t: 'number', opt };
|
|
108
|
-
case 'boolean': return { t: 'boolean', opt };
|
|
109
|
-
case 'date': return { t: 'string', opt };
|
|
110
|
-
case 'string': case 'uuid': case 'email': case 'url': return { t: 'string', opt };
|
|
111
|
-
case 'object': {
|
|
112
|
-
const props = attr.props ?? attr.properties;
|
|
113
|
-
if (props && typeof props === 'object') {
|
|
114
|
-
const fields = Object.entries(props)
|
|
115
|
-
.map(([k, v]) => { const f = ts(v); return `${ident(k)}${f.opt ? '?' : ''}: ${f.t}`; })
|
|
116
|
-
.join('; ');
|
|
117
|
-
return { t: `{ ${fields} }`, opt };
|
|
118
|
-
}
|
|
119
|
-
return { t: 'Record<string, unknown>', opt };
|
|
120
|
-
}
|
|
121
|
-
default: return { t: 'unknown', opt };
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return { t: 'unknown', opt: true };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const ident = (s) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : JSON.stringify(s));
|
|
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
|
-
|
|
144
|
-
// START_BLOCK_READ_SCHEMA
|
|
145
|
-
const sql = postgres(dsn, { max: 1 });
|
|
146
|
-
const rows = await sql.unsafe(
|
|
147
|
-
`SELECT id, alias, category, ancestor, ancestors, attributes, links, meta FROM "${schema.replaceAll('"', '""')}"."Schema" WHERE partition = 'entity' ORDER BY category, "order"`,
|
|
148
|
-
);
|
|
149
|
-
await sql.end();
|
|
150
|
-
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
151
|
-
|
|
152
|
-
// END_BLOCK_READ_SCHEMA
|
|
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
|
|
196
|
-
const lines = [
|
|
197
|
-
'/* Сгенерировано scripts/gen-types.mjs — не редактировать вручную. */',
|
|
198
|
-
`/* Схема: ${schema} · классов: ${rows.length} */`,
|
|
199
|
-
'',
|
|
200
|
-
`import type { Row, Path, Filter, Cursor, Chain, EntityDb } from 'letopis';`,
|
|
201
|
-
'',
|
|
202
|
-
'export interface TypedRow<D> extends Omit<Row, \'data\'> { data: D }',
|
|
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
|
-
'',
|
|
260
|
-
];
|
|
261
|
-
|
|
262
|
-
// END_BLOCK_EMIT_HEADER
|
|
263
|
-
// START_BLOCK_EMIT_CLASSES
|
|
264
|
-
const steps = [];
|
|
265
|
-
const skipped = [];
|
|
266
|
-
for (const r of rows) {
|
|
267
|
-
if (r.meta?.abstract === true || r.meta?.abstract === 'true') continue;
|
|
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;
|
|
275
|
-
const { t, opt } = ts(attr);
|
|
276
|
-
lines.push(` ${ident(f)}${opt ? '?' : ''}: ${t};`);
|
|
277
|
-
}
|
|
278
|
-
lines.push('}', '');
|
|
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};`);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// END_BLOCK_EMIT_CLASSES
|
|
321
|
-
// START_BLOCK_EMIT_FACADE
|
|
322
|
-
lines.push(
|
|
323
|
-
'/**',
|
|
324
|
-
' * Шаги по классам — доступны с любой цепочки и с корня db. ЗАКОННОСТЬ перехода',
|
|
325
|
-
' * (HUB↔LINK, pivot, «no path X → Y») проверяет letopis синхронно при построении цепочки;',
|
|
326
|
-
' * типы этого не гарантируют — они ловят опечатки в именах и типизируют data шага.',
|
|
327
|
-
' */',
|
|
328
|
-
'export interface Steps {',
|
|
329
|
-
...steps,
|
|
330
|
-
'}',
|
|
331
|
-
'',
|
|
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;',
|
|
341
|
-
'',
|
|
342
|
-
);
|
|
343
|
-
if (skipped.length) {
|
|
344
|
-
lines.push(`/* Без типизированного шага (все имена зарезервированы цепочкой): ${skipped.join(', ')} */`, '');
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// END_BLOCK_EMIT_FACADE
|
|
348
|
-
await writeFile(out, lines.join('\n'), 'utf8');
|
|
349
|
-
console.log(`written ${out}: ${rows.length} classes`);
|
|
350
|
-
if (skipped.length) console.warn(`gen-types: пропущены шаги для ${skipped.join(', ')} — имена зарезервированы цепочкой`);
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Печатает в stdout секцию CHANGELOG для ТЕКУЩЕЙ версии пакета — тело релизного PR.
|
|
4
|
-
*
|
|
5
|
-
* cd lib && node scripts/release-notes.mjs # посмотреть
|
|
6
|
-
* gh pr create --base main --head dev --title "Release: …" \
|
|
7
|
-
* --body "$(cd lib && node scripts/release-notes.mjs)" # так и создавать PR
|
|
8
|
-
*
|
|
9
|
-
* Зачем: описание PR было единственным местом контура, где текст писался заново, хотя тот же
|
|
10
|
-
* текст уже есть в lib/CHANGELOG.md — источнике истины версии (его сверяет check-docs.mjs с
|
|
11
|
-
* package.json). Цепочка становится без дублирования:
|
|
12
|
-
*
|
|
13
|
-
* lib/CHANGELOG.md (руками) → описание PR → тело релиза (release.yml берёт из PR) → npm
|
|
14
|
-
*
|
|
15
|
-
* Версия НЕ передаётся аргументом намеренно: берётся из package.json, чтобы нельзя было
|
|
16
|
-
* выпустить заметки одной версии под тегом другой.
|
|
17
|
-
*/
|
|
18
|
-
//
|
|
19
|
-
// FILE: lib/scripts/release-notes.mjs
|
|
20
|
-
// VERSION: 1.0.0
|
|
21
|
-
// START_MODULE_CONTRACT
|
|
22
|
-
// PURPOSE: Вырезать из lib/CHANGELOG.md секцию текущей версии пакета и напечатать её — тело релизного PR, из которого release.yml берёт заметки релиза.
|
|
23
|
-
// SCOPE: чтение package.json + CHANGELOG, извлечение секции, печать в stdout.
|
|
24
|
-
// DEPENDS: none
|
|
25
|
-
// LINKS: M-RELEASE-NOTES, V-M-RELEASE-NOTES
|
|
26
|
-
// ROLE: SCRIPT
|
|
27
|
-
// MAP_MODE: LOCALS
|
|
28
|
-
// END_MODULE_CONTRACT
|
|
29
|
-
//
|
|
30
|
-
// START_MODULE_MAP
|
|
31
|
-
// section - (local) секция CHANGELOG по версии: от "## [x.y.z]" до следующей "## ["
|
|
32
|
-
// END_MODULE_MAP
|
|
33
|
-
//
|
|
34
|
-
// START_CHANGE_SUMMARY
|
|
35
|
-
// LAST_CHANGE: [v1.0.0 - Новый скрипт: тело релизного PR берётся из CHANGELOG, а не пишется заново]
|
|
36
|
-
// END_CHANGE_SUMMARY
|
|
37
|
-
import { readFile } 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
|
-
|
|
43
|
-
// START_CONTRACT: section
|
|
44
|
-
// PURPOSE: Достать из текста CHANGELOG секцию конкретной версии (без её заголовка).
|
|
45
|
-
// INPUTS: { changelog: string - текст lib/CHANGELOG.md; version: string - x.y.z }
|
|
46
|
-
// OUTPUTS: { string - тело секции; пусто, если версии в файле нет }
|
|
47
|
-
// SIDE_EFFECTS: none
|
|
48
|
-
// LINKS: M-RELEASE-NOTES, V-M-RELEASE-NOTES
|
|
49
|
-
// END_CONTRACT: section
|
|
50
|
-
function section(changelog, version) {
|
|
51
|
-
const start = changelog.indexOf(`## [${version}]`);
|
|
52
|
-
if (start < 0) return '';
|
|
53
|
-
const after = changelog.indexOf('\n## [', start + 1);
|
|
54
|
-
const body = changelog.slice(changelog.indexOf('\n', start) + 1, after < 0 ? undefined : after);
|
|
55
|
-
return body.trim();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const [pkgRaw, changelog] = await Promise.all([
|
|
59
|
-
readFile(join(LIB, 'package.json'), 'utf8'),
|
|
60
|
-
readFile(join(LIB, 'CHANGELOG.md'), 'utf8'),
|
|
61
|
-
]);
|
|
62
|
-
const { version } = JSON.parse(pkgRaw);
|
|
63
|
-
const body = section(changelog, version);
|
|
64
|
-
|
|
65
|
-
if (!body) {
|
|
66
|
-
console.error(
|
|
67
|
-
`release-notes: в lib/CHANGELOG.md нет записи "## [${version}]" — подними версию в ` +
|
|
68
|
-
'package.json или добавь запись (то же самое проверяет npm run check:docs)',
|
|
69
|
-
);
|
|
70
|
-
process.exit(1);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Ссылку на полный changelog добавляем всегда: релиз читают из GitHub, а не из репозитория.
|
|
74
|
-
process.stdout.write(
|
|
75
|
-
`${body}\n\n---\n\nПолный changelog — [lib/CHANGELOG.md](https://github.com/alepri51/letopis/blob/main/lib/CHANGELOG.md).\n`,
|
|
76
|
-
);
|
package/scripts/schema-sync.mjs
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
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
|
-
}
|