letopis 0.18.1 → 0.19.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 CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
4
4
 
5
+ ## [0.19.0] — 2026-07-14
6
+
7
+ ### Added — физический purge (hard-erase) + живой подхват правок схемы
8
+
9
+ - **`.purge({ confirm })`** — необратимый физический снос уже логически удалённой (tombstone)
10
+ сущности + всего поддерева по `links` (все версии). Двухфазно: живую не трогает (сначала
11
+ `.delete()`); без `confirm` — dry-превью замыкания. Внутри — серверная `purge()`:
12
+ `SET LOCAL letopis.purge='on'` отключает append-only-триггер через `WHEN`-условие → плоский
13
+ `DELETE` (детерминированно, без `TM_SelfModified`).
14
+ - **`.withDeleted()`** — включить удалённые (tombstone) в выдачу последнего шага; снимает ТОЛЬКО
15
+ фильтр `deleted`, изоляция арендатора (`enforceAccount`) и ACL (`enforceAcl`) действуют.
16
+ - **`db.accounts.purge(id)`** — полный офбординг тенанта: физ. снос всех Entity (`account|owner`)
17
+ + сам Account (Credential — FK-каскад). Гарды: только Owner/System, не последний Owner, не свой аккаунт.
18
+ - **`db.reloadSchema()` / `db.schema.define(def)`** — правка определений классов простым SQL в
19
+ таблице `Schema` подхватывается без реконнекта (пересборка registry + ACL-резолвера).
20
+
21
+ ### Changed — DDL (требует наката `ddl.sql` на существующие схемы)
22
+
23
+ - `entity_delete` получил `WHEN (current_setting('letopis.purge', true) IS DISTINCT FROM 'on')` —
24
+ обычный путь (tombstone + каскад) неизменен; под флагом триггер пропускается для физ-сноса.
25
+ - Новые серверные функции: `purge_closure(partition,class,ids[])`,
26
+ `purge(partition,class,id[,dry]) RETURNS SETOF Entity`, `purge_account(id)`.
27
+
28
+ ### Notes
29
+
30
+ - Голый повторный `DELETE` надгробия БЕЗ флага остаётся no-op (safety-инвариант жив).
31
+ - Тесты: новый `test/wave5.test.ts` (13 кейсов P1/P2) — весь набор 181/181 зелёный.
32
+
5
33
  ## [0.18.1] — 2026-07-13
6
34
 
7
35
  ### Fixed — упаковка: CLI-скрипты в npm-пакете
package/README.md CHANGED
@@ -781,6 +781,29 @@ await db.Мастер(m).окно().delete({ confirm: true }).rows() // кон
781
781
  (+advisory-lock). История неприкосновенна; повторный delete → `[]`; `create()` с тем же
782
782
  id — воскрешение.
783
783
 
784
+ ### `.purge({ confirm })` — ФИЗИЧЕСКИЙ hard-erase (необратимо)
785
+
786
+ ```ts
787
+ await db.Клиент(cid).delete({ confirm: true }).rows() // 1) мягко: tombstone (обратимо)
788
+ await db.Клиент(cid).purge().rows() // превью замыкания (что сотрётся), БД цела
789
+ await db.Клиент(cid).purge({ confirm: true }).rows() // 2) физически: tombstone + всё поддерево (все версии)
790
+ ```
791
+
792
+ **Двухфазно:** `.purge()` работает ТОЛЬКО по уже логически удалённому (tombstone) — живую сущность
793
+ не трогает (сначала `.delete()`). Историю, в отличие от `.delete()`, НЕ сохраняет. Внутри — серверная
794
+ `purge(partition,class,id)`: `SET LOCAL letopis.purge='on'` отключает append-only-триггер (через `WHEN`)
795
+ → плоский физический `DELETE` замыкания (детерминированно, без вложенного DML). Голый SQL:
796
+ `SELECT * FROM "v1.notify".purge('entity','Клиент','…')` (или `…,true)` — dry-превью).
797
+
798
+ ### `.withDeleted()` — включить удалённые (tombstone) в выдачу
799
+
800
+ ```ts
801
+ await db.Клиент(cid).rows() // только живые
802
+ await db.Клиент(cid).withDeleted().rows() // + tombstone (последний шаг)
803
+ ```
804
+
805
+ Снимает **только** фильтр `deleted IS NULL`; изоляция арендатора (`enforceAccount`) и ACL (`enforceAcl`) действуют.
806
+
784
807
  ### Откат плана
785
808
 
786
809
  ```ts
@@ -1144,9 +1167,11 @@ await db.Клиент(id).anonymize(['name', 'phone']).rows()
1144
1167
  // новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
1145
1168
  ```
1146
1169
 
1147
- Физического стирания НЕТ — история священна: старые версии хранят PII до retention-политики
1148
- (§ 10.8). Полное «право на забвение» = `anonymize()` сейчас + настроенный retention потом.
1149
- Не-string поле в списке — ошибка (типы сверяются по Schema).
1170
+ Физического стирания `anonymize()` НЕ делает — история священна: старые версии хранят PII до
1171
+ retention-политики (§ 10.8). Три уровня «права на забвение»: `anonymize()` (затереть PII, запись
1172
+ живёт) → retention (снос по времени) → **`.purge({ confirm })`** — немедленный физический hard-erase
1173
+ удалённого (tombstone) + всего поддерева, а `db.accounts.purge(id)` — целого тенанта (см. § 11).
1174
+ Не-string поле в списке `anonymize` — ошибка (типы сверяются по Schema).
1150
1175
 
1151
1176
  ### 10.8 Политики хранения: `db/policies.mjs`
1152
1177
 
@@ -2542,7 +2567,41 @@ await db.accounts.delete(SYS) // [6.0 ms]
2542
2567
  ```
2543
2568
 
2544
2569
  **Кейс:** чистка мусорной регистрации — удалять можно только то, что не оставило следов;
2545
- след есть → `enabled: false` вместо удаления.
2570
+ след есть → `enabled: false` вместо удаления, либо `db.accounts.purge(id)` — полный офбординг ниже.
2571
+
2572
+ #### `db.accounts.purge(id): Promise<boolean>`
2573
+
2574
+ Полный физический **офбординг тенанта** (необратимо): все `Entity` с `account = id` ИЛИ `owner = id`
2575
+ + сам `Account` (`Credential` — FK-каскад). Освобождает `entity_account_fk`/`entity_owner_fk` (RESTRICT),
2576
+ которые блокируют обычный `delete`. Внутри — серверная `purge_account()` (флаг отключает append-only-триггер).
2577
+ Предохранители (до сноса): вызывать может лишь **Owner/System**; нельзя снести **последний enabled Owner**
2578
+ (лок-аут тенанта) и **свой** аккаунт сессии.
2579
+
2580
+ ```ts
2581
+ const dbSys = await connect({ dsn, schema: 'v1.notify', account: SYS })
2582
+ await dbSys.accounts.purge(tenantId) // → true: Entity + Account + Credential снесены, место освобождено
2583
+ ```
2584
+
2585
+ #### `db.schema.define(def)` / `db.reloadSchema()` — живой подхват правок схемы
2586
+
2587
+ Определения классов живут в таблице `Schema`. Меняешь их **простым SQL** (или `db.schema.define(...)`)
2588
+ → `db.reloadSchema()` пересобирает реестр (и ACL-резолвер) **без реконнекта**.
2589
+
2590
+ ```ts
2591
+ // правка простым SQL + перечитывание (напр. новое значение enum)
2592
+ await db.sql.unsafe(`UPDATE "v1.notify"."Schema"
2593
+ SET attributes = jsonb_set(attributes, '{status,values}', '["queued","sent","paused"]')
2594
+ WHERE id = 'Delivery'`)
2595
+ await db.reloadSchema() // запись со status:'paused' теперь проходит валидацию
2596
+
2597
+ // или спец-метод (сам перечитывает)
2598
+ await db.schema.define({ id: 'Coupon', alias: 'Купон', category: 'HUB',
2599
+ attributes: { id: { type: 'uuid', generate: 7 }, code: { type: 'string' } } })
2600
+ ```
2601
+
2602
+ `up()` при существующей схеме seed **не** перезаливает → правка в `Schema` переживает рестарт (клоббер только
2603
+ при явном re-run старого seed через `db/apply.mjs`/`fresh`). Бамп версии (`vN.*`) — **новый пустой** namespace
2604
+ с переливом данных, НЕ инструмент для аддитивной правки определения (новое значение enum/поле/класс).
2546
2605
 
2547
2606
  #### `db.credentials.find(f?): Promise<Credential[]>`
2548
2607
 
package/dist/chain.d.ts CHANGED
@@ -33,6 +33,11 @@ export interface ChainCore {
33
33
  asOf(t: string | Date): Chain;
34
34
  /** Keyset-пагинация: строго после курсора (см. cursorOf). Требует .sort(). */
35
35
  after(cursor: import('./types.js').Cursor): Chain;
36
+ /**
37
+ * Включить в выдачу последнего шага удалённые (tombstone). Снимает ТОЛЬКО фильтр deleted —
38
+ * изоляция арендатора (enforceAccount) и ACL (enforceAcl) остаются в силе.
39
+ */
40
+ withDeleted(): Chain;
36
41
  /** ВСЕ версии сущностей последнего шага (включая tombstone → $deleted), по возрастанию updated. */
37
42
  versions(): Promise<Row[]>;
38
43
  /** Рекурсивный self-обход: дети любой глубины (тот же класс), $depth в Row. */
@@ -89,6 +94,15 @@ export interface ChainCore {
89
94
  delete(opts?: {
90
95
  confirm?: boolean;
91
96
  }): Chain;
97
+ /**
98
+ * ФИЗИЧЕСКИЙ hard-erase (необратимо): сносит УЖЕ логически удалённые (tombstone) цели + всё
99
+ * поддерево по links (все версии). Живую сущность не трогает — сначала .delete(). { confirm: true }
100
+ * — стирает; без confirm — превью замыкания (что сотрётся), БД не тронута. Историю НЕ сохраняет
101
+ * (в отличие от .delete()). Реализуется серверной purge() (SET LOCAL letopis.purge отключает триггер).
102
+ */
103
+ purge(opts?: {
104
+ confirm?: boolean;
105
+ }): Chain;
92
106
  }
93
107
  /**
94
108
  * Свойство-класс на цепочке:
@@ -126,6 +140,11 @@ export interface DbCore {
126
140
  watch(cb: (e: WatchEvent) => void, opts?: WatchOpts): Promise<() => void>;
127
141
  watch(cls: string, cb: (e: WatchEvent) => void, opts?: WatchOpts): Promise<() => void>;
128
142
  close(): Promise<void>;
143
+ /**
144
+ * Перечитать определения классов из таблицы Schema (после правки её простым SQL / db.schema.define):
145
+ * пересобирает registry и, при enforceAcl, ACL-резолвер — без реконнекта. Подхват «сразу».
146
+ */
147
+ reloadSchema(): Promise<void>;
129
148
  registry: Registry;
130
149
  /** Голый postgres-клиент (тесты, EXPLAIN). */
131
150
  sql: Ctx['sql'];
@@ -134,6 +153,8 @@ export interface DbCore {
134
153
  credentials: Tables['credentials'];
135
154
  resources: Tables['resources'];
136
155
  rules: Tables['rules'];
156
+ /** Определения классов: db.schema.define(def) — upsert в таблицу Schema + reloadSchema(). */
157
+ schema: Tables['schema'];
137
158
  /** Вход по кредам (пароль/api-key/key-secret/внешние identity) + сессии в Redis. */
138
159
  auth: AuthApi;
139
160
  /** ACL по Resource/Rule: check(эндпоинт) / checkData(класс, READ|WRITE|DELETE) / reload. */
package/dist/chain.js CHANGED
@@ -354,6 +354,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
354
354
  return (field, dir) => withMods({ order: field, desc: dir === true || dir === 'desc' });
355
355
  case 'asOf':
356
356
  return (t) => withMods({ asOf: t instanceof Date ? t.toISOString() : t });
357
+ case 'withDeleted':
358
+ return () => withMods({ withDeleted: true });
357
359
  case 'deep':
358
360
  return (max = 32) => withLast({ deepMax: max });
359
361
  // END_BLOCK_READ_MODIFIERS
@@ -434,6 +436,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
434
436
  return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
435
437
  case 'anonymize':
436
438
  return (fields) => withOp({ kind: 'anonymize', fields, mods });
439
+ case 'purge':
440
+ return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, mods });
437
441
  // END_BLOCK_WRITE_TERMINALS
438
442
  // START_BLOCK_COLUMN_MODS
439
443
  case 'alias':
@@ -512,7 +516,7 @@ export function makeDb(ctx, root) {
512
516
  if (typeof prop === 'symbol' || prop === 'then')
513
517
  return undefined;
514
518
  // START_BLOCK_TABLES_AUTH_ACL
515
- if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules') {
519
+ if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules' || prop === 'schema') {
516
520
  tables ??= makeTables(ctx);
517
521
  return tables[prop];
518
522
  }
@@ -607,6 +611,11 @@ export function makeDb(ctx, root) {
607
611
  // START_BLOCK_CLOSE_META
608
612
  case 'close':
609
613
  return () => ctx.sql.end();
614
+ case 'reloadSchema':
615
+ return async () => {
616
+ await ctx.reload?.();
617
+ acl = undefined; // db.acl-фасад перестроится на свежем registry при следующем доступе
618
+ };
610
619
  case 'registry':
611
620
  return ctx.registry;
612
621
  case 'sql':
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
15
15
  export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
16
16
  export type { Row, Path, Filter, ChainMods, Cursor, ConnectOpts, QueryEvent, Account, Credential, Resource, Rule, AclOp, AclDecision, } from './types.js';
17
17
  export type { EntityDb, EntityTx, Chain, Batch, WatchEvent, WatchOpts } from './chain.js';
18
- export type { Tables, AccountsApi, CredentialsApi, ResourcesApi, RulesApi } from './tables.js';
18
+ export type { Tables, AccountsApi, CredentialsApi, ResourcesApi, RulesApi, SchemaApi } from './tables.js';
19
19
  export { totpCode } from './auth.js';
20
20
  export type { AuthApi, AuthResult } from './auth.js';
21
21
  export type { AclApi } from './acl.js';
package/dist/index.js CHANGED
@@ -60,8 +60,10 @@ export async function connect(opts) {
60
60
  slowMs: opts.slowMs,
61
61
  };
62
62
  // START_BLOCK_ENFORCE_ACL
63
- // enforceAcl: правила и категории субъекта фиксируются на connect (перечитка — новый connect)
64
- if (opts.enforceAcl) {
63
+ // enforceAcl: правила и категории субъекта компилятся в резолвер (перечитываются reloadSchema/reload)
64
+ const compileAcl = async () => {
65
+ if (!opts.enforceAcl)
66
+ return;
65
67
  if (!opts.account)
66
68
  throw new Error('letopis: enforceAcl requires connect({ account })');
67
69
  const tables = makeTables(ctx);
@@ -72,8 +74,14 @@ export async function connect(opts) {
72
74
  ]);
73
75
  if (!account)
74
76
  throw new Error(`letopis: enforceAcl — account "${opts.account}" not found`);
75
- ctx.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, registry);
76
- }
77
+ ctx.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, ctx.registry);
78
+ };
79
+ await compileAcl();
80
+ // db.reloadSchema(): перечитать определения классов из таблицы Schema (registry + enforcer) без реконнекта
81
+ ctx.reload = async () => {
82
+ ctx.registry = await loadRegistry(sql, opts.schema, partition);
83
+ await compileAcl();
84
+ };
77
85
  // END_BLOCK_ENFORCE_ACL
78
86
  return makeDb(ctx);
79
87
  }
package/dist/sql.d.ts CHANGED
@@ -41,6 +41,8 @@ export interface Ctx {
41
41
  onQuery?: (e: QueryEvent) => void;
42
42
  /** Порог «медленного» запроса, мс (без onQuery — console.warn). */
43
43
  slowMs?: number;
44
+ /** Перечитать определения классов из таблицы Schema: пересобрать registry (+ enforcer при enforceAcl). Ставит connect(). */
45
+ reload?: () => Promise<void>;
44
46
  }
45
47
  /** Транзиентные ошибки PG — гонка, снимаемая повтором: deadlock / serialization failure. */
46
48
  export declare function isTransient(e: unknown): boolean;
@@ -131,4 +133,10 @@ export declare function deleteSql(pgSchema: string, n: number): string;
131
133
  * Рекурсивный CTE; каждый узел — актуальная живая версия. $1 partition, $2 class, $3+ — ids.
132
134
  */
133
135
  export declare function closureSql(pgSchema: string, n: number): string;
136
+ /**
137
+ * Вызов серверной purge(): $4 dry → превью (актуальные версии замыкания, БД цела), иначе двухфазный
138
+ * физический снос корня+поддерева. RETURNS SETOF Entity (по строке на сущность). Вся логика
139
+ * (замыкание, двухфазность, отключение триггера через SET LOCAL) — в БД. $1 partition, $2 class, $3 id, $4 dry.
140
+ */
141
+ export declare function purgeCallSql(pgSchema: string): string;
134
142
  export {};
package/dist/sql.js CHANGED
@@ -517,8 +517,10 @@ export function buildRead(ctx, steps, mods, mode) {
517
517
  const innerWhere = [`e.partition = ${pPart}`, `e.class = ${pCls}`];
518
518
  if (mods.asOf !== undefined)
519
519
  innerWhere.push(`e.updated <= ${p.push(mods.asOf)}::timestamptz`); // «как было на T»
520
- // versions: последний шаг включает и удалённые сущности (история существует после delete)
521
- const outerWhere = [mode === 'versions' && i === steps.length - 1 ? 'TRUE' : 't.deleted IS NULL'];
520
+ // versions/.withDeleted(): последний шаг включает и удалённые (история/tombstone видны после delete).
521
+ // Снимается ТОЛЬКО фильтр deleted — enforceAccount/enforceAcl/контекст-фраги ниже действуют (изоляция цела).
522
+ const keepDeleted = i === steps.length - 1 && (mode === 'versions' || mods.withDeleted === true);
523
+ const outerWhere = [keepDeleted ? 'TRUE' : 't.deleted IS NULL'];
522
524
  const candWhere = f.candFrags.map((fr) => fr('c'));
523
525
  for (const c of f.idConds)
524
526
  innerWhere.push(c('e'));
@@ -726,3 +728,18 @@ export function closureSql(pgSchema, n) {
726
728
  )
727
729
  SELECT DISTINCT ON (class, id) * FROM node ORDER BY class, id, updated DESC`);
728
730
  }
731
+ /**
732
+ * Вызов серверной purge(): $4 dry → превью (актуальные версии замыкания, БД цела), иначе двухфазный
733
+ * физический снос корня+поддерева. RETURNS SETOF Entity (по строке на сущность). Вся логика
734
+ * (замыкание, двухфазность, отключение триггера через SET LOCAL) — в БД. $1 partition, $2 class, $3 id, $4 dry.
735
+ */
736
+ // START_CONTRACT: purgeCallSql
737
+ // PURPOSE: SQL-обёртка вызова серверной purge() (снос/превью) — вся логика в БД, JS лишь зовёт.
738
+ // INPUTS: { pgSchema: string }
739
+ // OUTPUTS: { string - SELECT * FROM "<schema>".purge($1,$2,$3,$4) }
740
+ // SIDE_EFFECTS: none
741
+ // LINKS: M-SQL, V-M-SQL, M-DDL, M-WRITE
742
+ // END_CONTRACT: purgeCallSql
743
+ export function purgeCallSql(pgSchema) {
744
+ return `SELECT * FROM ${escId(pgSchema)}.purge($1, $2, $3, $4)`;
745
+ }
package/dist/tables.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface AccountsApi {
17
17
  set(a: Partial<Account>): Promise<Account>;
18
18
  /** Физический DELETE; Credential снесётся FK-каскадом. */
19
19
  delete(id: string): Promise<boolean>;
20
+ /**
21
+ * Полный физический офбординг тенанта (необратимо): все Entity (account|owner=id) + сам Account
22
+ * (Credential — FK-каскад), через серверную purge_account(). Предохранители: только Owner/System-вызов;
23
+ * нельзя снести последний enabled Owner (лок-аут) и свой аккаунт. → true если Account снесён.
24
+ */
25
+ purge(id: string): Promise<boolean>;
20
26
  }
21
27
  export interface CredentialsApi {
22
28
  /** deleted IS NULL по умолчанию; withDeleted: true — включая удалённые. */
@@ -72,10 +78,28 @@ export interface RulesApi {
72
78
  }): Promise<Rule>;
73
79
  delete(account: string, resource: string): Promise<boolean>;
74
80
  }
81
+ export interface SchemaApi {
82
+ /**
83
+ * Upsert определения класса в таблицу Schema (ON CONFLICT (partition,id) DO UPDATE) + reloadSchema().
84
+ * Правка «простым SQL», подхватывается сразу без реконнекта. attributes — DSL fastest-validator
85
+ * (валидирует либа при чтении реестра); ancestors/descendants считает триггер schema_lineage.
86
+ */
87
+ define(def: {
88
+ id: string;
89
+ alias: string;
90
+ category: 'HUB' | 'LINK';
91
+ ancestor?: string | null;
92
+ attributes?: Record<string, unknown>;
93
+ meta?: Record<string, unknown>;
94
+ links?: unknown[];
95
+ order?: number;
96
+ }): Promise<void>;
97
+ }
75
98
  export interface Tables {
76
99
  accounts: AccountsApi;
77
100
  credentials: CredentialsApi;
78
101
  resources: ResourcesApi;
79
102
  rules: RulesApi;
103
+ schema: SchemaApi;
80
104
  }
81
105
  export declare function makeTables(ctx: Ctx): Tables;
package/dist/tables.js CHANGED
@@ -85,6 +85,44 @@ export function makeTables(ctx) {
85
85
  const rows = await run(`DELETE FROM ${T(ctx, 'Account')} WHERE id = $1 RETURNING id`, [id]);
86
86
  return rows.length > 0;
87
87
  },
88
+ // START_CONTRACT: accounts.purge
89
+ // PURPOSE: Физический офбординг тенанта через purge_account(); гарды Owner/System, последний Owner, не-себя — до сноса, в одной tx.
90
+ // INPUTS: { id: string - целевой аккаунт }
91
+ // OUTPUTS: { Promise<boolean> - true если Account физически снесён }
92
+ // SIDE_EFFECTS: физ. DELETE всех Entity аккаунта + Account (Credential FK-каскад) через SQL purge_account()
93
+ // ERRORS: нет caller/ACL; вызов не Owner/System; снос последнего Owner; снос своего аккаунта
94
+ // LINKS: M-TABLES, V-M-TABLES, M-DDL
95
+ // END_CONTRACT: accounts.purge
96
+ async purge(id) {
97
+ if (!ctx.account) {
98
+ throw new Error('letopis: accounts.purge requires an authenticated caller — connect({ account })');
99
+ }
100
+ if (id === ctx.account) {
101
+ throw new Error('letopis: accounts.purge cannot purge the calling account itself');
102
+ }
103
+ const schema = `"${ctx.pgSchema.replace(/"/g, '""')}"`;
104
+ return ctx.sql.begin(async (tsql) => {
105
+ const q = (t, p) => tsql.unsafe(t, p);
106
+ // вызывающий обязан быть Owner/System
107
+ const caller = await q(`SELECT categories FROM ${T(ctx, 'Account')} WHERE id = $1`, [ctx.account]);
108
+ const cats = caller[0]?.categories ?? [];
109
+ if (!cats.includes('Owner') && !cats.includes('System')) {
110
+ throw new Error('letopis: accounts.purge is restricted to Owner/System accounts');
111
+ }
112
+ const tgt = await q(`SELECT categories FROM ${T(ctx, 'Account')} WHERE id = $1`, [id]);
113
+ if (!tgt.length)
114
+ return false;
115
+ // нельзя снести последний enabled Owner → тенант без владельца (лок-аут)
116
+ if ((tgt[0].categories ?? []).includes('Owner')) {
117
+ const cnt = await q(`SELECT count(*)::text AS n FROM ${T(ctx, 'Account')} WHERE 'Owner' = ANY(categories) AND enabled`, []);
118
+ if (Number(cnt[0]?.n ?? 0) <= 1) {
119
+ throw new Error('letopis: refusing to purge the last enabled Owner (tenant lock-out)');
120
+ }
121
+ }
122
+ const res = await q(`SELECT ${schema}.purge_account($1::uuid) AS id`, [id]);
123
+ return res[0]?.id != null;
124
+ });
125
+ },
88
126
  };
89
127
  // END_BLOCK_ACCOUNTS_API
90
128
  // START_BLOCK_CREDENTIALS_API
@@ -181,5 +219,22 @@ export function makeTables(ctx) {
181
219
  },
182
220
  };
183
221
  // END_BLOCK_RULES_API
184
- return { accounts, credentials, resources, rules };
222
+ // START_BLOCK_SCHEMA_API
223
+ const schema = {
224
+ async define(def) {
225
+ // jsonb-значения через sql.json() — postgres.js кладёт как jsonb (не PG-массив/строку)
226
+ const j = (v) => ctx.sql.json(v);
227
+ await run(`INSERT INTO ${T(ctx, 'Schema')} (partition, id, alias, category, ancestor, attributes, meta, links, "order")
228
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
229
+ ON CONFLICT (partition, id) DO UPDATE SET
230
+ alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor,
231
+ attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order"`, [
232
+ ctx.partition, def.id, def.alias, def.category, def.ancestor ?? null,
233
+ j(def.attributes ?? {}), j(def.meta ?? {}), j(def.links ?? []), def.order ?? 0,
234
+ ]);
235
+ await ctx.reload?.(); // подхватить новое определение сразу (registry + enforcer)
236
+ },
237
+ };
238
+ // END_BLOCK_SCHEMA_API
239
+ return { accounts, credentials, resources, rules, schema };
185
240
  }
package/dist/types.d.ts CHANGED
@@ -125,12 +125,12 @@ export interface Cursor {
125
125
  * Терминал исполняет план (все операции + финальное чтение) одной транзакцией.
126
126
  */
127
127
  export interface PlanOp {
128
- kind: 'create' | 'update' | 'delete' | 'anonymize';
128
+ kind: 'create' | 'update' | 'delete' | 'anonymize' | 'purge';
129
129
  /** create/update: данные новой версии (deep-merge листьев). */
130
130
  data?: Record<string, unknown>;
131
131
  /** anonymize: string-поля под '[erased]'. */
132
132
  fields?: string[];
133
- /** delete: true — удалить; без confirm — превью (вернуть кандидатов, БД не трогать). */
133
+ /** delete/purge: true — выполнить; без confirm — превью (вернуть кандидатов/замыкание, БД не трогать). */
134
134
  confirm?: boolean;
135
135
  /** Снапшот модификаторов на момент вызова операции (limit/sort для поиска целей). */
136
136
  mods: ChainMods;
@@ -149,6 +149,8 @@ export interface ChainMods {
149
149
  /** Внутреннее: агрегация терминалов .sum/.avg/.min/.max/.countBy. */
150
150
  aggFn?: 'sum' | 'avg' | 'min' | 'max' | 'countBy';
151
151
  aggField?: string;
152
+ /** .withDeleted(): последний шаг включает удалённые (tombstone). Снимает ТОЛЬКО фильтр deleted; enforceAccount/enforceAcl действуют. */
153
+ withDeleted?: boolean;
152
154
  }
153
155
  export interface Account {
154
156
  id: string;
package/dist/write.d.ts CHANGED
@@ -56,6 +56,15 @@ export declare function anonymizeOp(ctx: Ctx, steps: Step[], mods: ChainMods, fi
56
56
  * Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
57
57
  */
58
58
  export declare function delOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
59
+ /**
60
+ * .purge({confirm}): ФИЗИЧЕСКИЙ hard-erase — сносит логически удалённые цели + всё поддерево (все версии).
61
+ * Вся логика — в серверной purge() (двухфазность: живую цель не трогает; замыкание по links; SET LOCAL
62
+ * отключает entity_delete → плоский снос без TM_SelfModified). JS лишь резолвит цели и зовёт функцию.
63
+ * confirm: false → dry-превью (что сотрётся, БД цела); true → снос, возвращает снесённое ($deleted).
64
+ * gate: DELETE-право на класс цели (aclWrite) — поддерево уже было DELETE-авторизовано при мягком удалении.
65
+ * В отличие от .delete() (tombstone, обратимо) — необратимо, историю НЕ сохраняет.
66
+ */
67
+ export declare function purgeOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
59
68
  export type PlanMode = 'rows' | 'ids' | 'count' | 'paths' | 'versions' | 'agg';
60
69
  /**
61
70
  * Исполнить план целиком: все операции + финальное чтение — одна транзакция
package/dist/write.js CHANGED
@@ -40,7 +40,7 @@
40
40
  // END_CHANGE_SUMMARY
41
41
  import { randomUUID } from 'node:crypto';
42
42
  import { uuidv5, uuidv7 } from './uuid.js';
43
- import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, isTransient, retryDelay, RETRIES, } from './sql.js';
43
+ import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, purgeCallSql, isTransient, retryDelay, RETRIES, } from './sql.js';
44
44
  import { aclDenied } from './acl.js';
45
45
  // START_CONTRACT: ValidationError
46
46
  // PURPOSE: Ошибка валидации класса, несущая список проблемных полей (issues).
@@ -581,6 +581,40 @@ export async function delOp(ctx, steps, mods, confirm) {
581
581
  });
582
582
  // END_BLOCK_DELETE_CASCADE
583
583
  }
584
+ /**
585
+ * .purge({confirm}): ФИЗИЧЕСКИЙ hard-erase — сносит логически удалённые цели + всё поддерево (все версии).
586
+ * Вся логика — в серверной purge() (двухфазность: живую цель не трогает; замыкание по links; SET LOCAL
587
+ * отключает entity_delete → плоский снос без TM_SelfModified). JS лишь резолвит цели и зовёт функцию.
588
+ * confirm: false → dry-превью (что сотрётся, БД цела); true → снос, возвращает снесённое ($deleted).
589
+ * gate: DELETE-право на класс цели (aclWrite) — поддерево уже было DELETE-авторизовано при мягком удалении.
590
+ * В отличие от .delete() (tombstone, обратимо) — необратимо, историю НЕ сохраняет.
591
+ */
592
+ // START_CONTRACT: purgeOp
593
+ // PURPOSE: .purge({confirm}) — резолв целей (вкл. tombstone) + вызов серверной purge() (снос при confirm, иначе dry-превью).
594
+ // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; confirm: boolean }
595
+ // OUTPUTS: { Promise<Row[]> - снесённое с $deleted, либо dry-превью; [] если цели не tombstone/не найдены }
596
+ // SIDE_EFFECTS: физический DELETE в Entity через purge() (SET LOCAL letopis.purge отключает триггер)
597
+ // ERRORS: acl denies DELETE (класс цели)
598
+ // LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL, M-SQL
599
+ // END_CONTRACT: purgeOp
600
+ export async function purgeOp(ctx, steps, mods, confirm) {
601
+ const target = steps[steps.length - 1];
602
+ aclWrite(ctx, target, 'DELETE');
603
+ // цели ищем ВКЛЮЧАЯ tombstone — .purge() работает по уже логически удалённым (двухфазность в purge())
604
+ const targets = await readRows(ctx, steps, { ...mods, withDeleted: true });
605
+ if (!targets.length)
606
+ return [];
607
+ return inTransaction(ctx, async (c) => {
608
+ const out = [];
609
+ // вся логика (двухфазность, замыкание, отключение триггера) — в серверной purge(); dry = !confirm
610
+ for (const t of targets) {
611
+ const rows = await runQuery(c, purgeCallSql(c.pgSchema), [c.partition, target.cls.id, t.id, !confirm], 'delete', [target.cls.id]);
612
+ for (const r of rows)
613
+ out.push(confirm ? { ...toRow(r), $deleted: true } : toRow(r));
614
+ }
615
+ return out;
616
+ });
617
+ }
584
618
  /** Разрезать план: сегменты (…шаги + op-шаг) и читающий хвост после последней операции. */
585
619
  function splitPlan(steps) {
586
620
  const segments = [];
@@ -604,6 +638,8 @@ function opCall(c, steps, op) {
604
638
  return anonymizeOp(c, steps, op.mods, op.fields ?? []);
605
639
  case 'delete':
606
640
  return delOp(c, steps, op.mods, op.confirm === true);
641
+ case 'purge':
642
+ return purgeOp(c, steps, op.mods, op.confirm === true);
607
643
  }
608
644
  }
609
645
  /** Виртуальный старт-шаг «от этих строк»: контекст/чтение следующего сегмента. */
@@ -651,7 +687,8 @@ export async function runPlan(ctx, steps, mods, mode) {
651
687
  last.push(...await opCall(c, rowSteps, seg.op));
652
688
  }
653
689
  }
654
- start = seg.op.kind === 'delete' ? last.filter((r) => r.class === targetCls.id) : last;
690
+ start = (seg.op.kind === 'delete' || seg.op.kind === 'purge')
691
+ ? last.filter((r) => r.class === targetCls.id) : last;
655
692
  }
656
693
  // END_BLOCK_RUNPLAN_SEGMENTS
657
694
  const opStep = segments[segments.length - 1].steps.at(-1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)",
5
5
  "keywords": [
6
6
  "timescaledb",
package/sql/ddl.sql CHANGED
@@ -321,7 +321,8 @@ CREATE TRIGGER entity_update
321
321
  -- * бьёт только актуальную ЖИВУЮ строку (история и tombstone неприкосновенны);
322
322
  -- * advisory-lock сериализует параллельные удаления сущности;
323
323
  -- * каскад: DELETE живых зависимых (links ⊃ {класс: id}) → рекурсия этого же триггера;
324
- -- * физического удаления не происходит никогда (очистка истории — retention-политики).
324
+ -- * физического удаления не происходит никогда — КРОМЕ явного purge (см. ниже):
325
+ -- при SET LOCAL letopis.purge='on' триггер целиком пропускается (WHEN) → физический DELETE.
325
326
  -- Работает и для голого SQL: DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE partition=… AND class=… AND id=…
326
327
  -- -----------------------------------------------------------------------------
327
328
  -- START_CONTRACT: entity_delete
@@ -363,7 +364,94 @@ END $$;
363
364
  DROP TRIGGER IF EXISTS entity_delete ON "<SCHEMA-NAME>"."Entity";
364
365
  CREATE TRIGGER entity_delete
365
366
  BEFORE DELETE ON "<SCHEMA-NAME>"."Entity"
366
- FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_delete();
367
+ FOR EACH ROW
368
+ -- purge-режим: при SET LOCAL letopis.purge='on' триггер НЕ срабатывает → физический DELETE.
369
+ -- Обычный путь (флаг не выставлен) видит NULL → IS DISTINCT FROM 'on' = true → триггер работает (tombstone).
370
+ WHEN (current_setting('letopis.purge', true) IS DISTINCT FROM 'on')
371
+ EXECUTE FUNCTION "<SCHEMA-NAME>".entity_delete();
372
+
373
+ -- -----------------------------------------------------------------------------
374
+ -- Физический hard-erase (purge). Вся логика — в БД (две функции); либа лишь зовёт purge():
375
+ -- * purge_closure(partition,class,ids[]) — (class,id) всего поддерева по links (вкл. tombstone);
376
+ -- единый источник замыкания (UNION отсекает циклы/диаманты).
377
+ -- * purge(partition,class,id[,dry]) RETURNS SETOF Entity — двухфазный снос корня + поддерева:
378
+ -- - ДВУХФАЗНОСТЬ: только если сущность уже логически удалена (актуальная версия — tombstone);
379
+ -- живую не трогает (0 строк);
380
+ -- - dry=true → превью (актуальные версии замыкания), БД не трогается;
381
+ -- - dry=false → SET LOCAL letopis.purge='on' отключает entity_delete (см. WHEN выше) → плоский DELETE
382
+ -- замыкания без вложенного DML (без TM_SelfModified, детерминированно), возвращает снесённое;
383
+ -- - флаг транзакционный (is_local) + явный сброс → дальше в той же tx снова tombstone; привилегий не требует.
384
+ -- Голый SQL: SELECT * FROM "<SCHEMA-NAME>".purge('entity','Order','X'); -- снос
385
+ -- SELECT * FROM "<SCHEMA-NAME>".purge('entity','Order','X', true); -- превью
386
+ -- -----------------------------------------------------------------------------
387
+ -- START_CONTRACT: purge_closure
388
+ -- PURPOSE: (class,id)-замыкание поддерева по links (вкл. tombstone) — что снесёт purge(); переиспользуется самой purge() (снос и dry-превью).
389
+ -- INPUTS: { p_partition text; p_class text; p_ids text[] }
390
+ -- OUTPUTS: { SETOF (class text, id text) - узлы замыкания }
391
+ -- SIDE_EFFECTS: none (STABLE)
392
+ -- LINKS: M-DDL, V-M-DDL, M-WRITE, M-SQL
393
+ -- END_CONTRACT: purge_closure
394
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge_closure(p_partition text, p_class text, p_ids text[])
395
+ RETURNS TABLE(class text, id text) LANGUAGE sql STABLE AS $$
396
+ WITH RECURSIVE closure(cls, cid) AS (
397
+ SELECT p_class, x FROM unnest(p_ids) AS x
398
+ UNION -- не ALL → отсекает циклы/диаманты links
399
+ SELECT e.class, e.id
400
+ FROM "<SCHEMA-NAME>"."Entity" e
401
+ JOIN closure c ON e.links @> jsonb_build_object(c.cls, c.cid)
402
+ WHERE e.partition = p_partition
403
+ )
404
+ SELECT cls, cid FROM closure;
405
+ $$;
406
+
407
+ -- START_CONTRACT: purge
408
+ -- PURPOSE: Двухфазно физически стереть логически удалённый корень и всё поддерево (по links, все версии); dry=превью.
409
+ -- INPUTS: { p_partition text; p_class text; p_id text; p_dry boolean = false }
410
+ -- OUTPUTS: { SETOF Entity - снесённые (по одному на сущность, latest) либо превью (dry); пусто если не tombstone }
411
+ -- SIDE_EFFECTS: при dry=false — SET LOCAL letopis.purge (отключает entity_delete на tx) + физический DELETE; advisory-lock
412
+ -- LINKS: M-DDL, V-M-DDL, M-WRITE, M-TABLES
413
+ -- END_CONTRACT: purge
414
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge(
415
+ p_partition text, p_class text, p_id text, p_dry boolean DEFAULT false)
416
+ RETURNS SETOF "<SCHEMA-NAME>"."Entity" LANGUAGE plpgsql AS $$
417
+ BEGIN
418
+ PERFORM pg_advisory_xact_lock(
419
+ hashtextextended(p_partition || '|' || p_class || '|' || p_id, 0));
420
+
421
+ -- двухфазность: hard-purge только ПОСЛЕ логического удаления — актуальная версия обязана быть tombstone.
422
+ -- Живую (не удалённую) сущность purge НЕ трогает (возвращает 0 строк).
423
+ IF NOT EXISTS (
424
+ SELECT 1 FROM "<SCHEMA-NAME>"."Entity" e
425
+ WHERE e.partition = p_partition AND e.class = p_class AND e.id = p_id AND e.deleted IS NOT NULL
426
+ AND e.updated = (SELECT max(updated) FROM "<SCHEMA-NAME>"."Entity"
427
+ WHERE partition = p_partition AND class = p_class AND id = p_id)
428
+ ) THEN
429
+ RETURN;
430
+ END IF;
431
+
432
+ IF p_dry THEN
433
+ -- превью: актуальная версия каждого члена замыкания, БД не трогаем
434
+ RETURN QUERY
435
+ SELECT DISTINCT ON (e.class, e.id) e.* FROM "<SCHEMA-NAME>"."Entity" e
436
+ JOIN "<SCHEMA-NAME>".purge_closure(p_partition, p_class, ARRAY[p_id]) c
437
+ ON e.class = c.class AND e.id = c.id
438
+ WHERE e.partition = p_partition
439
+ ORDER BY e.class, e.id, e.updated DESC;
440
+ RETURN;
441
+ END IF;
442
+
443
+ PERFORM set_config('letopis.purge', 'on', true); -- SET LOCAL: entity_delete отключён на эту tx
444
+ RETURN QUERY
445
+ WITH del AS (
446
+ DELETE FROM "<SCHEMA-NAME>"."Entity" e
447
+ USING "<SCHEMA-NAME>".purge_closure(p_partition, p_class, ARRAY[p_id]) c
448
+ WHERE e.partition = p_partition AND e.class = c.class AND e.id = c.id -- триггер пропущен (WHEN)
449
+ RETURNING e.*
450
+ )
451
+ SELECT DISTINCT ON (class, id) * FROM del ORDER BY class, id, updated DESC;
452
+ PERFORM set_config('letopis.purge', '', true); -- сброс: дальше в той же tx снова tombstone
453
+ RETURN;
454
+ END $$;
367
455
 
368
456
  -- =============================================================================
369
457
  -- Служебные таблицы (auth/ACL) — перенесены из legacy-дампа 1:1.
@@ -431,6 +519,30 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Rule" (
431
519
  );
432
520
  -- END_BLOCK_AUTH_TABLES
433
521
 
522
+ -- -----------------------------------------------------------------------------
523
+ -- purge_account(): полный физический офбординг тенанта — все Entity (account|owner) + сам Account.
524
+ -- Credential уходит FK-каскадом (ON DELETE CASCADE). Флаг letopis.purge отключает entity_delete на
525
+ -- снос Entity (см. WHEN). Права/предохранители (Owner-only, последний Owner, не-себя) — в либе
526
+ -- (accounts.purge, им нужен ctx/ACL). Возвращает id снесённого Account либо NULL.
527
+ -- -----------------------------------------------------------------------------
528
+ -- START_CONTRACT: purge_account
529
+ -- PURPOSE: Физически стереть тенанта: все Entity с account|owner = id + сам Account (Credential — FK-каскад).
530
+ -- INPUTS: { p_id uuid }
531
+ -- OUTPUTS: { uuid - id снесённого Account, либо NULL если не найден }
532
+ -- SIDE_EFFECTS: SET LOCAL letopis.purge (отключает entity_delete); физический DELETE Entity + Account
533
+ -- LINKS: M-DDL, V-M-DDL, M-TABLES
534
+ -- END_CONTRACT: purge_account
535
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge_account(p_id uuid)
536
+ RETURNS uuid LANGUAGE plpgsql AS $$
537
+ DECLARE gone uuid;
538
+ BEGIN
539
+ PERFORM set_config('letopis.purge', 'on', true); -- SET LOCAL: entity_delete отключён (см. WHEN)
540
+ DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE account = p_id OR owner = p_id; -- обе оси (иначе entity_owner_fk RESTRICT)
541
+ PERFORM set_config('letopis.purge', '', true);
542
+ DELETE FROM "<SCHEMA-NAME>"."Account" WHERE id = p_id RETURNING id INTO gone; -- Credential — FK-каскад
543
+ RETURN gone;
544
+ END $$;
545
+
434
546
  -- -----------------------------------------------------------------------------
435
547
  -- Триггер 4: realtime — факт каждой новой версии в pg_notify (канал = имя схемы).
436
548
  -- Payload лёгкий (без data): подписчик дочитывает нужное сам. Потребитель: db.watch().