letopis 0.19.0 → 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.
@@ -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-полей каждого класса + фасад TypedDb.
7
- * import type { TypedDb } from './entity-types';
8
- * const t = db as unknown as TypedDb; // t.Сотрудник(...).rows(): Promise<TypedRow<StaffData>[]>
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.0.0
17
+ // VERSION: 1.1.0
13
18
  // START_MODULE_CONTRACT
14
- // PURPOSE: CLI-кодген — читает таблицу Schema и печатает типизированный .d.ts-фасад (Data-интерфейсы классов + TypedRow/TypedChain/TypedDb).
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/facade - аккумуляторы генерируемого .d.ts
35
+ // lines/steps - аккумуляторы генерируемого .d.ts
27
36
  // END_MODULE_MAP
28
37
  //
29
38
  // START_CHANGE_SUMMARY
30
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
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
- // START_BLOCK_EMIT_TYPES
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
- `import type { Row, Filter, Path, Cursor } from 'letopis';`,
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
- const facade = [];
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 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;
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
- for (const key of [r.id, r.alias]) {
121
- facade.push(` ${ident(key)}: (filter?: Filter) => TypedChain<${name}>;`);
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
- '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;',
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
- 'export interface TypedDb {',
148
- ...facade,
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
- // END_BLOCK_EMIT_TYPES
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(', ')} — имена зарезервированы цепочкой`);
@@ -1,11 +1,11 @@
1
1
  -- =============================================================================
2
- -- Сид таблицы Schema — ИСТОЧНИК ПРАВДЫ демо-домена booking (19 классов, 0.17).
2
+ -- Сид таблицы Schema — ИСТОЧНИК ПРАВДЫ демо-домена booking (20 классов: 12 HUB + 8 LINK).
3
3
  -- Редактируется руками; накат: db/apply.mjs / up() (маркер <SCHEMA-NAME>).
4
4
  -- Идемпотентен (ON CONFLICT). Концы связей — Schema.links v2 (JSON-объекты в text[]).
5
5
  -- =============================================================================
6
6
  --
7
7
  -- FILE: lib/sql/seed.booking.sql
8
- -- VERSION: 1.0.0
8
+ -- VERSION: 1.0.1
9
9
  -- START_MODULE_CONTRACT
10
10
  -- PURPOSE: Идемпотентный сид таблицы Schema — источник правды демо-домена booking (классы HUB + LINK).
11
11
  -- SCOPE: 20 INSERT-ов классов (12 HUB: Entity/Org/Person/Staff/Customer/Location/Element/Service/Product/Complex/Schedule/Folder; 8 LINK: link/address/slot/booking/content/compo/skill/price).
@@ -18,11 +18,12 @@
18
18
  -- START_MODULE_MAP
19
19
  -- HUB-классы - сущности домена (наследование от Entity; id v5/v7)
20
20
  -- LINK-классы - связи (концы Schema.links v2; id v5 из концов)
21
- -- NB: фактически 20 классов (12 HUB + 8 LINK); заголовок файла говорит «19» — расхождение
21
+ -- NB: 20 классов (12 HUB + 8 LINK); число в заголовке файла сверяет scripts/check-docs.mjs
22
22
  -- END_MODULE_MAP
23
23
  --
24
24
  -- START_CHANGE_SUMMARY
25
- -- LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
25
+ -- LAST_CHANGE: [v1.0.1 - Docs-only: заголовок файла приведён к факту (20 классов: 12 HUB + 8 LINK);
26
+ -- число теперь проверяется автоматически. INSERT-ы не менялись]
26
27
  -- END_CHANGE_SUMMARY
27
28
  --
28
29
  -- START_BLOCK_SEED_HUB_CLASSES