letopis 1.0.1 → 1.2.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.
Files changed (60) hide show
  1. package/AGENT-CHEATSHEET.en.md +117 -10
  2. package/AGENT-CHEATSHEET.md +110 -8
  3. package/CHANGELOG.md +739 -0
  4. package/README.en.md +873 -68
  5. package/README.md +830 -66
  6. package/dist/admin.d.ts +24 -4
  7. package/dist/admin.js +39 -9
  8. package/dist/cache.d.ts +33 -9
  9. package/dist/cache.js +81 -32
  10. package/dist/chain.d.ts +29 -7
  11. package/dist/chain.js +61 -12
  12. package/dist/cli.js +59 -7
  13. package/dist/errors.js +4 -2
  14. package/dist/import.d.ts +6 -0
  15. package/dist/import.js +196 -175
  16. package/dist/index.d.ts +27 -5
  17. package/dist/index.js +191 -29
  18. package/dist/internal.d.ts +16 -0
  19. package/dist/internal.js +14 -0
  20. package/dist/load.d.ts +63 -0
  21. package/dist/load.js +331 -44
  22. package/dist/mcp.d.ts +28 -0
  23. package/dist/mcp.js +810 -0
  24. package/dist/model.d.ts +34 -0
  25. package/dist/model.js +17 -0
  26. package/dist/project.d.ts +309 -0
  27. package/dist/project.js +1097 -0
  28. package/dist/registry.d.ts +17 -0
  29. package/dist/registry.js +13 -0
  30. package/dist/signals.d.ts +73 -0
  31. package/dist/signals.js +169 -0
  32. package/dist/sql.d.ts +1 -1
  33. package/dist/sql.js +26 -7
  34. package/dist/tx.d.ts +9 -7
  35. package/dist/tx.js +15 -5
  36. package/dist/typed.d.ts +21 -6
  37. package/dist/types.d.ts +77 -4
  38. package/dist/types.js +8 -3
  39. package/dist/up.d.ts +16 -2
  40. package/dist/up.js +34 -4
  41. package/dist/uuid.js +19 -8
  42. package/dist/watch.d.ts +23 -2
  43. package/dist/watch.js +44 -7
  44. package/dist/write.d.ts +11 -2
  45. package/dist/write.js +57 -10
  46. package/llms.txt +9 -2
  47. package/package.json +13 -2
  48. package/sql/10-core.sql +13 -2
  49. package/sql/15-errors.sql +4 -1
  50. package/sql/40-class.sql +57 -2
  51. package/sql/60-write.sql +611 -82
  52. package/sql/70-read.sql +33 -8
  53. package/sql/75-history.sql +358 -0
  54. package/sql/80-auth.sql +7 -7
  55. package/sql/88-derived.sql +1154 -0
  56. package/sql/90-time.sql +134 -21
  57. package/sql/95-seed.system.sql +24 -6
  58. package/sql/99-revision.sql +1 -1
  59. package/sql/README.md +5 -1
  60. package/sql/migrate/0006-changed-paths.sql +3 -0
package/dist/chain.js CHANGED
@@ -102,7 +102,7 @@ async function exec(ctx, q, mode, steps, mods = {}) {
102
102
  if (hit)
103
103
  return hit;
104
104
  const tag = sessionTag(who.token);
105
- const tok = store.token(classes, tag);
105
+ const tok = store.token(classes, tag, who.tenant);
106
106
  const t0 = performance.now();
107
107
  const S = `"${ctx.schema.replace(/"/g, '""')}"`;
108
108
  const [res, exp] = await ctx.exec.run((tx) => Promise.all([
@@ -139,7 +139,7 @@ async function read(ctx, steps, mods, mode, agg) {
139
139
  async function plan(ctx, steps, mods, mode, agg) {
140
140
  precheckPlan(ctx.reg, steps);
141
141
  const t0 = performance.now();
142
- const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, steps, mods, mode, agg));
142
+ const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, steps, mods, mode, agg));
143
143
  if (r.kind === 'read') {
144
144
  report(ctx, mode === 'countPaths' ? 'count' : mode, steps, t0, r.res.length);
145
145
  if (r.q === null) {
@@ -196,6 +196,28 @@ async function lockedRead(ctx, steps, mods, mode) {
196
196
  report(ctx, mode, steps, t0, res.length);
197
197
  return { q: rowsQ, res: mode === 'ids' ? res.map((r) => ({ id: r.row.id })) : res };
198
198
  }
199
+ /** Причины для плана: по умолчанию у глаголов (у батча — своя) и та, что стоит в транзакции. */
200
+ const reasons = (ctx) => ({ reason: ctx.reason ?? ctx.txReason ?? '', txReason: ctx.txReason ?? '' });
201
+ /** Опция reason глагола (план 1.1, §2.1): проверяет precheckPlan. */
202
+ const reasonOf = (o) => (o?.reason !== undefined ? { reason: o.reason } : {});
203
+ /** diff({ from, to }): номер версии (целое ≥ 1) или момент (ISO-строка, Date). */
204
+ function versionRef(v, what, moments = true) {
205
+ if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 2147483647)
206
+ return v;
207
+ if (moments && v instanceof Date && !Number.isNaN(+v))
208
+ return v.toISOString();
209
+ if (moments && typeof v === 'string' && !Number.isNaN(Date.parse(v)))
210
+ return v;
211
+ throw new LetopisError('invalid_query', `${what}: номер версии (целое ≥ 1)${moments ? ' или момент (ISO-строка, Date)' : ''}`);
212
+ }
213
+ /** blame({ at }): момент (ISO-строка, Date). */
214
+ function momentOf(v, what) {
215
+ if (v instanceof Date && !Number.isNaN(+v))
216
+ return v.toISOString();
217
+ if (typeof v === 'string' && !Number.isNaN(Date.parse(v)))
218
+ return v;
219
+ throw new LetopisError('invalid_query', `${what}: момент — ISO-строка или Date`);
220
+ }
199
221
  /** Строка ответа: из чтения — to_jsonb узла, из плана — готовая строка записи. */
200
222
  const rowOf = (r) => (r.done ? r.row : toRow(r.row));
201
223
  /** Значение слота: id, список id, строка ответа или ленивая цепочка. */
@@ -283,6 +305,10 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
283
305
  const last = steps[steps.length - 1];
284
306
  if (!last)
285
307
  throw new LetopisError('invalid_query', `${op.kind}() — нужен шаг класса: db.Класс(…).${op.kind}(…)`);
308
+ // батч: причина db.batch(имя, { reason }) — при постановке в очередь, а не при run(): очередь общая по имени, и
309
+ // run() может позвать фасад без причины или с другой (ревью №1 точки А плана 1.1)
310
+ if (ctx.batch && op.reason === undefined && ctx.reason !== undefined)
311
+ op = { ...op, reason: ctx.reason };
286
312
  const next = last.op
287
313
  ? [...steps, { name: last.name, cls: last.cls, role: last.role, aliasKey: last.aliasKey, self: true, op, opMods: mods, nodeKey: nextNodeKey() }]
288
314
  : [...steps.slice(0, -1), { ...last, op, opMods: mods }];
@@ -364,6 +390,29 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
364
390
  const m = opts?.follow ? { ...mods, follow: true } : mods;
365
391
  return (await read(ctx, steps, m, 'versions')).res.map((r) => toRow(r.row));
366
392
  };
393
+ case 'diff':
394
+ return async (opts = {}) => {
395
+ warnNoHistory(steps, 'diff');
396
+ const hist = {
397
+ ...(opts.from !== undefined ? { from: versionRef(opts.from, 'diff({ from })') } : {}),
398
+ ...(opts.to !== undefined ? { to: versionRef(opts.to, 'diff({ to })') } : {}),
399
+ ...(opts.follow ? { follow: true } : {}),
400
+ };
401
+ return (await read(ctx, steps, { ...mods, hist }, 'diff')).res.map((r) => r.row);
402
+ };
403
+ case 'blame':
404
+ return async (opts = {}) => {
405
+ warnNoHistory(steps, 'blame');
406
+ if (opts.limit !== undefined)
407
+ versionRef(opts.limit, 'blame({ limit })', false);
408
+ const hist = {
409
+ ...(opts.at !== undefined ? { at: momentOf(opts.at, 'blame({ at })') } : {}),
410
+ ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
411
+ };
412
+ // null — объекта на момент at не было (или он не виден)
413
+ return (await read(ctx, steps, { ...mods, hist }, 'blame')).res
414
+ .map((r) => r.row).filter((x) => x !== null);
415
+ };
367
416
  case 'sum':
368
417
  case 'avg':
369
418
  case 'min':
@@ -404,17 +453,17 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
404
453
  throw removed('account()', 'арендатор сессии и auth.switch() (этап 5)');
405
454
  // глаголы записи — звенья плана (этап 4)
406
455
  case 'create':
407
- return (data = {}) => withOp({ kind: 'create', data });
456
+ return (data = {}, opts) => withOp({ kind: 'create', data, ...reasonOf(opts) });
408
457
  case 'update':
409
- return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}) });
458
+ return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}), ...reasonOf(opts) });
410
459
  case 'upsert':
411
- return (data = {}) => withOp({ kind: 'upsert', data });
460
+ return (data = {}, opts) => withOp({ kind: 'upsert', data, ...reasonOf(opts) });
412
461
  case 'delete':
413
- return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true });
462
+ return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, ...reasonOf(opts) });
414
463
  case 'anonymize':
415
- return (fields) => withOp({ kind: 'anonymize', fields });
464
+ return (fields, opts) => withOp({ kind: 'anonymize', fields, ...reasonOf(opts) });
416
465
  case 'reclass':
417
- return (cls, data) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}) });
466
+ return (cls, data, opts) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}), ...reasonOf(opts) });
418
467
  case 'set':
419
468
  case 'unset':
420
469
  case 'add':
@@ -423,11 +472,11 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
423
472
  case 'forUpdate':
424
473
  return () => withMods({ forUpdate: true });
425
474
  case 'restore':
426
- return () => withOp({ kind: 'restore' });
475
+ return (opts) => withOp({ kind: 'restore', ...reasonOf(opts) });
427
476
  case 'purge':
428
- return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true });
477
+ return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, ...reasonOf(opts) });
429
478
  case 'rekey':
430
- return (patch) => withOp({ kind: 'rekey', data: patch ?? {} });
479
+ return (patch, opts) => withOp({ kind: 'rekey', data: patch ?? {}, ...reasonOf(opts) });
431
480
  case 'cache':
432
481
  return (opts) => {
433
482
  if (opts?.ttl !== undefined && !(opts.ttl > 0))
@@ -459,7 +508,7 @@ export function makeBatch(ctx, queue) {
459
508
  precheckPlan(ctx.reg, p);
460
509
  if (!plans.length)
461
510
  return [];
462
- return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, plans));
511
+ return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, plans));
463
512
  };
464
513
  case 'execute':
465
514
  throw removed('batch.execute()', 'batch.run()');
package/dist/cli.js CHANGED
@@ -6,12 +6,15 @@
6
6
  * letopis sync <модуль моделей> --dsn … --schema v2.x (--token … | --api-key …) [--apply]
7
7
  * letopis types --dsn … --schema v2.x (--token … | --api-key …) [--out types.d.ts]
8
8
  * letopis load <файлы NDJSON…> --dsn … --schema v2.x (--token … | --api-key …) [--class C]
9
- * [--duplicates error|first|last] [--existing error|skip|update] [--commit-every N] [--verify]
9
+ * [--duplicates error|first|last] [--existing error|skip|update] [--commit-every N] [--verify] [--initial]
10
10
  * letopis verify --dsn … --schema v2.x [--token … | --api-key …] [--data] — код 1 при находках
11
11
  * letopis maintain --dsn … --schema v2.x [--token … | --api-key …] [--batches N]
12
12
  * letopis describe --dsn … --schema v2.x (--token … | --api-key …) [--format json|text] [--out файл]
13
13
  * letopis import --from <строка 0.21> --from-schema v1.x --dsn … --schema v2.x [--tenant <имя>] [--keyless C]…
14
- * [--rename Старое=Новое]… [--update] [--commit-every N] [--partition p] — код 1, если verify нашёл расхождения
14
+ * [--rename Старое=Новое]… [--update] [--commit-every N] [--partition p] [--initial] — код 1, если verify нашёл расхождения
15
+ * --initial — первая заливка (план 1.1, §2.3): пустая схема, одна транзакция, индексы и триггеры — после загрузки
16
+ * letopis mcp --dsn … --schema v2.x --token … [--write] — сервер MCP по stdio (план 1.1, §2.5); dsn, schema и
17
+ * token — ещё из LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; только токен пользователя: --api-key — отказ
15
18
  */
16
19
  import { parseArgs } from 'node:util';
17
20
  import { readFile, writeFile } from 'node:fs/promises';
@@ -24,12 +27,14 @@ import { import021 } from './import.js';
24
27
  const USAGE = `letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
25
28
  letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
26
29
  letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
27
- [--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify]
30
+ [--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify] [--initial]
28
31
  letopis verify --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--data]
29
32
  letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
30
33
  letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
31
34
  letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
32
- [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>]`;
35
+ [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
36
+ letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
37
+ (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)`;
33
38
  const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
34
39
  export async function main(argv) {
35
40
  const { values, positionals } = parseArgs({
@@ -43,9 +48,16 @@ export async function main(argv) {
43
48
  format: { type: 'string' },
44
49
  from: { type: 'string' }, 'from-schema': { type: 'string' }, tenant: { type: 'string' }, partition: { type: 'string' },
45
50
  keyless: { type: 'string', multiple: true }, rename: { type: 'string', multiple: true }, update: { type: 'boolean' },
51
+ initial: { type: 'boolean' }, write: { type: 'boolean' },
46
52
  },
47
53
  });
48
54
  const [cmd, arg] = positionals;
55
+ // клиенты MCP передают строку подключения и токен окружением (план 1.1, §2.5) — только для этой команды
56
+ if (cmd === 'mcp') {
57
+ values.dsn ??= process.env.LETOPIS_DSN || undefined;
58
+ values.schema ??= process.env.LETOPIS_SCHEMA || undefined;
59
+ values.token ??= process.env.LETOPIS_TOKEN || undefined;
60
+ }
49
61
  if (values.help || !cmd || !values.dsn || !values.schema) {
50
62
  console.log(USAGE);
51
63
  return cmd ? 2 : 0;
@@ -68,15 +80,18 @@ export async function main(argv) {
68
80
  const r = await import021({
69
81
  from: values.from, fromSchema: values['from-schema'], dsn: values.dsn, schema: values.schema, tenant: values.tenant,
70
82
  partition: values.partition, keyless: values.keyless, rename, update: values.update,
71
- commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined,
83
+ commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined, initial: values.initial,
72
84
  });
73
85
  console.log(JSON.stringify(r, null, 2));
74
86
  return r.verify.ok ? 0 : 1;
75
87
  }
88
+ if (cmd === 'mcp')
89
+ return mcp(values);
76
90
  // загрузчику нужно администраторское подключение (§2.6, п. 1)
77
91
  const db = await connect({ dsn: values.dsn, schema: values.schema, token: values.token, apiKey: values['api-key'], max: 2,
78
- // verify и maintain работают и на администраторском подключении без сессии
79
- ...(cmd === 'load' || cmd === 'verify' || cmd === 'maintain' ? { allowBypassRls: true } : {}) });
92
+ // verify и maintain работают и на администраторском подключении без сессии; производные классы (derivedBy)
93
+ // синхронизирует администраторское подключение с сессией арендатора
94
+ ...(cmd === 'load' || cmd === 'verify' || cmd === 'maintain' || cmd === 'sync' ? { allowBypassRls: true } : {}) });
80
95
  try {
81
96
  if (cmd === 'sync') {
82
97
  if (!arg) {
@@ -116,6 +131,7 @@ export async function main(argv) {
116
131
  existing: values.existing,
117
132
  commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined,
118
133
  verify: values.verify,
134
+ initial: values.initial,
119
135
  });
120
136
  console.log(JSON.stringify(r));
121
137
  return 0;
@@ -154,6 +170,42 @@ export async function main(argv) {
154
170
  await db.close();
155
171
  }
156
172
  }
173
+ /**
174
+ * letopis mcp (план 1.1, §2.5): сервер MCP по stdio от имени одного пользователя. Только токен сессии пользователя —
175
+ * ключ сервиса даёт все права auth.* (имперсонация, выпуск сессий) и лежал бы на машине агента. Модуль mcp.js и SDK
176
+ * грузятся только здесь: letopis --help и прочие команды работают без SDK. В stdout — только протокол (предупреждения
177
+ * connect() идут в stderr); процесс живёт, пока клиент не закроет stdin.
178
+ */
179
+ async function mcp(v) {
180
+ if (v['api-key'] !== undefined) {
181
+ console.error('letopis: user_session_required: letopis mcp принимает только токен сессии пользователя (--token или LETOPIS_TOKEN): ключ сервиса даёт все права auth.* и не должен лежать на машине агента');
182
+ return 2;
183
+ }
184
+ if (!v.token) {
185
+ console.error('letopis: user_session_required: letopis mcp — нужен токен сессии пользователя: --token или LETOPIS_TOKEN');
186
+ return 2;
187
+ }
188
+ const { createMcpServer } = await import('./mcp.js');
189
+ const db = await connect({ dsn: v.dsn, schema: v.schema, token: v.token, max: 4 });
190
+ try {
191
+ const opts = { write: v.write === true };
192
+ // учётные данные — до начала протокола: отказ виден кодом выхода и текстом в stderr
193
+ await createMcpServer(db, opts);
194
+ const { serveStdio } = await import('@modelcontextprotocol/server/stdio');
195
+ const closed = new Promise((resolve) => {
196
+ process.stdin.once('end', resolve);
197
+ process.stdin.once('close', resolve);
198
+ });
199
+ // экземпляр сервера — на соединение (и на пробу server/discover): ревизию протокола выбирает клиент
200
+ const handle = serveStdio(() => createMcpServer(db, opts), { onerror: (e) => console.error(`letopis mcp: ${e.message}`) });
201
+ await closed;
202
+ await handle.close();
203
+ return 0;
204
+ }
205
+ finally {
206
+ await db.close();
207
+ }
208
+ }
157
209
  // Запущен ли файл как программа. Сравниваются реальные пути: на Linux и macOS `node_modules/.bin/letopis`
158
210
  // (npx, скрипты package.json) — симлинк, `process.argv[1]` указывает на него, а `import.meta.url` — на сам файл.
159
211
  function isMain() {
package/dist/errors.js CHANGED
@@ -35,7 +35,8 @@ const LT_NAMES = {
35
35
  LT006: 'acl_denied', LT007: 'id_mismatch', LT008: 'id_from_db', LT009: 'immutable_key', LT010: 'invalid_class',
36
36
  LT011: 'use_reclass', LT012: 'reclass_denied', LT013: 'tightening_conflict', LT014: 'isolation_level',
37
37
  LT015: 'recreated_in_statement', LT016: 'cursor_expired', LT017: 'delete_restricted', LT018: 'not_deleted',
38
- LT019: 'reset_disabled', LT020: 'confirm_mismatch',
38
+ LT019: 'reset_disabled', LT020: 'confirm_mismatch', LT021: 'derived_class', LT022: 'aggregate_field',
39
+ LT023: 'projector_pending',
39
40
  };
40
41
  /**
41
42
  * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL
@@ -64,7 +65,8 @@ export function fromDbError(e) {
64
65
  if (name === 'invalid_data' && Array.isArray(issues)) {
65
66
  const cls = /класса (\S+)/.exec(text)?.[1] ?? '?';
66
67
  const v = new ValidationError(cls, issues);
67
- return Object.assign(v, { cause: e });
68
+ // текст базы целиком: в нём бывает больше, чем имя класса (у проектора — арендатор и id строки)
69
+ return Object.assign(v, { cause: e, ...(text ? { message: `letopis: invalid_data: ${text}` } : {}) });
68
70
  }
69
71
  return Object.assign(new LetopisError(name, text, detail), { cause: e });
70
72
  }
package/dist/import.d.ts CHANGED
@@ -46,6 +46,12 @@ export interface ImportOptions {
46
46
  update?: boolean;
47
47
  /** Порции загрузчика (§2.6, п. 8). */
48
48
  commitEvery?: number;
49
+ /**
50
+ * Первая заливка (план 1.1, §2.3): весь импорт — одной транзакцией в пустую схему, индексы и триггеры —
51
+ * после загрузки; несовместимо с update и commitEvery. Строки, уже лежащие в схеме (системный сид:
52
+ * ресурсы, правила), пропускаются, как без флага.
53
+ */
54
+ initial?: boolean;
49
55
  /** Без сообщений в консоль. */
50
56
  quiet?: boolean;
51
57
  }