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 +28 -0
- package/README.md +63 -4
- package/dist/chain.d.ts +21 -0
- package/dist/chain.js +10 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +12 -4
- package/dist/sql.d.ts +8 -0
- package/dist/sql.js +19 -2
- package/dist/tables.d.ts +24 -0
- package/dist/tables.js +56 -1
- package/dist/types.d.ts +4 -2
- package/dist/write.d.ts +9 -0
- package/dist/write.js +39 -2
- package/package.json +1 -1
- package/sql/ddl.sql +114 -2
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
|
-
Физического стирания
|
|
1148
|
-
(§ 10.8).
|
|
1149
|
-
|
|
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: правила и категории субъекта
|
|
64
|
-
|
|
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: последний шаг включает и удалённые
|
|
521
|
-
|
|
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
|
-
|
|
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 —
|
|
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'
|
|
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.
|
|
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
|
-
-- * физического удаления не происходит никогда
|
|
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
|
|
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().
|