letopis 0.20.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
package/dist/index.js CHANGED
@@ -1,188 +1,314 @@
1
1
  /**
2
- * letopis: dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
2
+ * letopis 1.0 — основная точка входа пакета. Руководство — README.md пакета, машинный каталог API —
3
+ * docs/api-contract.json репозитория; решения и их причины — reports/PLAN-LETOPIS-1.0.md (этапы ниже).
3
4
  *
4
- * schema — ПОЛНОЕ имя PG-схемы С версией движка ("v1.booking"); префикс либа НЕ достраивает
5
- * (создаёт up({ schema: 'booking', version: 1 }) либо db/apply.mjs). Шаг цепочки — id ИЛИ
6
- * alias класса из таблицы Schema (db.Staff ≡ db.Мастер).
7
- *
8
- * const db = await connect({ dsn, schema: 'v1.booking' }) // безличный пул
9
- * const t = await db.as(accountId) // арендатор ВЫЗОВА
10
- * await t.Мастер({ name: 'Вася' }).навык().Услуга().run()
5
+ * Этап 2: connect() — подключение под сессией, реестр классов, отложенный сигнал, db.sql.
6
+ * Этап 3: цепочки чтения t.Класс(…).Связь().rows(); этап 4 — запись цепочками и загрузчик.
7
+ * Этап 5: db.auth (креды, вход, сессии), db.acl, db.as(аккаунт, { tenant }) — имперсонация.
8
+ * Этап 6: история (restore, purge, rekey, trimHistory), verify, maintain, reset, watch, кэш результатов.
11
9
  */
12
- // FILE: lib/src/index.ts
13
- // VERSION: 1.3.0
14
- // START_MODULE_CONTRACT
15
- // PURPOSE: Публичная точка входа пакета — открыть соединение (реестр, опциональный ACL, сборка db) и ре-экспорт публичной поверхности; хелпер курсора.
16
- // SCOPE: connect, cursorOf + barrel-реэкспорты
17
- // DEPENDS: M-SCHEMA, M-CHAIN, M-TABLES, M-ACL, M-SQL, M-TYPES, M-UP
18
- // LINKS: M-CONNECT, V-M-CONNECT
19
- // ROLE: RUNTIME
20
- // MAP_MODE: EXPORTS
21
- // END_MODULE_CONTRACT
22
- //
23
- // START_MODULE_MAP
24
- // connect - открывает pg-соединение, грузит реестр, опц. включает ACL (снимок Resource/Rule на подключение), собирает EntityDb
25
- // cursorOf - курсор keyset-пагинации из последней строки страницы
26
- // NOTE (re-exports) - up/UpOpts←M-UP; uuidv5/uuidv7/LETOPIS_NS←M-UUID; ops←M-OPS; totpCode/AuthApi/AuthResult←M-AUTH; AclApi←M-ACL; ValidationError←M-WRITE; Registry←M-SCHEMA; Session-типы←M-SESSIONS; базовые типы←M-TYPES; типы chain/tables
27
- // END_MODULE_MAP
28
- //
29
- // START_CHANGE_SUMMARY
30
- // LAST_CHANGE: [v1.3.0 - Словарь Resource/Rule снимается ОДИН раз на подключение и
31
- // переиспользуется всеми db.as(): scope-per-request под enforceAcl платил два лишних
32
- // SELECT на каждый HTTP-запрос. Per-scope остались чтение своего аккаунта и компиляция
33
- // энфорсера (один энфорсер = один субъект). Снимок сбрасывает reloadSchema().
34
- // Ранее: BREAKING — opts.account/opts.owner сняты, арендатора называет db.as()]
35
- // END_CHANGE_SUMMARY
36
10
  import postgres from 'postgres';
37
- import { loadRegistry } from './schema.js';
38
- import { makeDb } from './chain.js';
39
- import { makeTables } from './tables.js';
40
- import { compileEnforcer } from './acl.js';
41
- import { DDL_REVISION, parseDdlRevision } from './types.js';
42
- // START_CONTRACT: connect
43
- // PURPOSE: Открыть БЕЗЛИЧНОЕ соединение: postgres-пул, реестр схемы, System-аккаунт, хук компиляции ACL — и собрать EntityDb (арендатора вызова называет db.as()).
44
- // INPUTS: { opts: ConnectOpts { dsn, schema, partition?='entity', max?=10, enforceAcl?, enforceAccount?=true, onQuery?, slowMs? } }
45
- // OUTPUTS: { Promise<EntityDb> - dot-цепочный db без идентичности; db.as(account) даёт scoped-хендл }
46
- // SIDE_EFFECTS: открывает пул postgres (timestamptz строкой), читает реестр и System-аккаунт; ставит ctx.compileAcl (зовётся из db.as: читает resources/rules и компилит aclDecide под субъекта scope; при ненайденном аккаунте — 'letopis: enforceAcl — account "…" not found'); без enforceAcl печатает предупреждение один раз на процесс; NB: ESM-цикл M-CONNECT↔M-UP (up ре-экспортится здесь)
47
- // LINKS: M-CONNECT, V-M-CONNECT, M-SCHEMA, M-CHAIN, M-TABLES, M-ACL, M-SQL
48
- // END_CONTRACT: connect
49
- /** Предупреждение об отключённом enforceAcl печатается один раз на процесс. */
50
- let warnedNoAcl = false;
51
- /** Схемы, про чью ревизию движка уже предупредили (один раз на схему за процесс). */
52
- const warnedDdl = new Set();
53
- // START_CONTRACT: checkDdlRevision
54
- // PURPOSE: Сверить ревизию движка схемы (COMMENT ON SCHEMA) с ожидаемой либой; при отставании предупредить с командой апгрейда.
55
- // INPUTS: { sql: Ctx['sql'] - открытый пул; schema: string - полное имя PG-схемы }
56
- // OUTPUTS: { Promise<void> }
57
- // SIDE_EFFECTS: один SELECT из obj_description; console.warn один раз на схему за процесс (никогда не бросает: правки ddl аддитивны)
58
- // LINKS: M-CONNECT, V-M-CONNECT, M-DDL, M-TYPES
59
- // END_CONTRACT: checkDdlRevision
60
- async function checkDdlRevision(sql, schema) {
61
- // NB: ::regnamespace тут нельзя — имена схем letopis содержат точку ("v1.booking"),
62
- // и приведение разберёт их как «схема v1, объект booking» → invalid name syntax.
63
- const rows = (await sql `
64
- SELECT obj_description(n.oid, 'pg_namespace') AS c FROM pg_namespace n WHERE n.nspname = ${schema}
65
- `);
66
- const have = parseDdlRevision(rows[0]?.c);
67
- if (have === DDL_REVISION)
68
- return;
69
- if (warnedDdl.has(schema))
70
- return;
71
- warnedDdl.add(schema);
72
- const чего = have === null
73
- ? 'ревизия не помечена (схема накатана либой до появления метки)'
74
- : `ревизия схемы ${have}, либа ожидает ${DDL_REVISION}`;
75
- console.warn(`letopis: движок схемы "${schema}" отстал — ${чего}. Схема продолжит работать, но новых ` +
76
- 'функций/триггеров в ней нет (например purge/purge_account из 0.19.0) — вызов упадёт ' +
77
- 'ошибкой PostgreSQL "function … does not exist". ddl.sql идемпотентен, накат повторный ' +
78
- 'и данные целы: up({ …, upgrade: true }) либо node db/apply.mjs --dsn=… --schema=<имя> ' +
79
- '--version=<N> --upgrade');
11
+ import { LetopisError, fromDbError, removed } from './errors.js';
12
+ import { Registry } from './registry.js';
13
+ import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
14
+ import { makeAuth } from './auth.js';
15
+ import { makeAcl } from './acl.js';
16
+ import { idOf as idOfClass } from './uuid.js';
17
+ import { load } from './load.js';
18
+ import { makeBatch, startEntity, startStep } from './chain.js';
19
+ import { DDL_REVISION } from './types.js';
20
+ import { ACL_CLASSES, ResultCache } from './cache.js';
21
+ import { Subscription } from './watch.js';
22
+ import { describe as describeModel, maintain as runMaintain, rekeyClass as runRekeyClass, reset as runReset, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
23
+ export { DDL_REVISION } from './types.js';
24
+ export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
25
+ export { LetopisError, ValidationError, fromDbError } from './errors.js';
26
+ export { Registry } from './registry.js';
27
+ export { compileValidator, applyDefaults } from './validate.js';
28
+ export { syncModels, generateTypes, schemaToTs } from './sync.js';
29
+ export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, isOp } from './ops.js';
30
+ export { cursorOf } from './sql.js';
31
+ export { inc, isInc } from './write.js';
32
+ export { totpCode, hashPassword, verifyHash } from './auth.js';
33
+ export { describeText } from './admin.js';
34
+ export { up, createTenant } from './up.js';
35
+ export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
36
+ /** Снесено в 1.0.0: id v7 больше не выдаются (§2.3). */
37
+ export function uuidv7() {
38
+ throw removed('uuidv7()', 'uuidv5() и idOf(): id объекта вычисляется из ключа');
80
39
  }
81
- export async function connect(opts) {
82
- // timestamptz — строкой (JS Date режет микросекунды → ломал бы asOf/cursorOf по updated)
83
- const sql = postgres(opts.dsn, {
84
- max: opts.max ?? 10,
85
- types: { timestamptz: { to: 1184, from: [1184], serialize: (v) => v, parse: (v) => v } },
86
- });
87
- const partition = opts.partition ?? 'entity';
88
- const registry = await loadRegistry(sql, opts.schema, partition);
89
- // Entity.account NOT NULL: fallback — System-аккаунт (семантика legacy-движка)
90
- const ident = `"${opts.schema.replace(/"/g, '""')}"`;
91
- const sys = (await sql.unsafe(`SELECT id FROM ${ident}."Account" WHERE 'System' = ANY(categories) LIMIT 1`));
92
- // START_BLOCK_CHECK_DDL_REVISION
93
- // Схема, накатанная более старой либой, не получает аддитивных правок ddl.sql (в 0.19.0
94
- // так появились purge/purge_closure/purge_account). Без этой проверки приложение узнаёт
95
- // о расхождении из сырого `PostgresError: function … does not exist`.
96
- // Предупреждение, а не отказ: правки аддитивны — то, что уже работало, работает.
97
- await checkDdlRevision(sql, opts.schema);
98
- // END_BLOCK_CHECK_DDL_REVISION
99
- const ctx = {
100
- sql,
101
- registry,
102
- pgSchema: opts.schema,
103
- partition,
104
- // account/owner тут НЕ задаются: подключение безличное, арендатора называет db.as().
105
- systemAccount: sys[0]?.id,
106
- // Изоляция арендатора ВКЛЮЧЕНА по умолчанию (0.20.0): забыть её было слишком легко,
107
- // а цена забывчивости — чужие строки в выдаче. Без идентичности вызова (корневой
108
- // хендл) чтение/запись при ней запрещены — см. guardScoped в sql.ts.
109
- enforceAccount: opts.enforceAccount ?? true,
110
- onQuery: opts.onQuery,
111
- slowMs: opts.slowMs,
112
- };
113
- // START_BLOCK_ENFORCE_ACL
114
- // enforceAcl: правила и категории субъекта компилятся в резолвер ПОД КОНКРЕТНЫЙ scope.
115
- // Функция живёт на ctx, потому что зовут её двое: db.as() (новый субъект) и reload
116
- // (тот же субъект на свежем реестре). Субъект берётся из c.account, т.е. из scope.
117
- //
118
- // Словарь Resource/Rule ОДИН для всех субъектов — снимается один раз на подключение и
119
- // переиспользуется каждым db.as(). Иначе scope-per-request (штатный режим в вебе) платил
120
- // бы два лишних SELECT на КАЖДЫЙ HTTP-запрос. Per-scope остаётся только чтение своего
121
- // аккаунта (категории субъекта) и сама компиляция: один энфорсер = один субъект, иначе
122
- // memo в acl.ts (ключ «класс + операция», без аккаунта) отдал бы чужое решение.
123
- // Снимок сбрасывает reloadSchema() — как и обещано в README §9.2/§11.10.
124
- let aclDict = null;
125
- const aclSource = (c) => {
126
- aclDict ??= (async () => {
127
- const tables = makeTables(c);
128
- const [resources, rules] = await Promise.all([tables.resources.find(), tables.rules.find({ enabled: true })]);
129
- return { resources, rules };
130
- })().catch((e) => {
131
- aclDict = null; // не кэшируем провал: следующий db.as() попробует снова
132
- throw e;
133
- });
134
- return aclDict;
40
+ /** Снесено в 1.0.0: пространство имён v5 — id арендатора. Любое использование значения — ошибка. */
41
+ export const LETOPIS_NS = new Proxy({}, {
42
+ get(_t, p) {
43
+ if (p === 'then')
44
+ return undefined;
45
+ throw removed('LETOPIS_NS', 'пространство имён v5 — id арендатора: idOf(класс, ключ, { tenant })');
46
+ },
47
+ });
48
+ const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
49
+ const SCHEMA_NAME = /^v\d+\.[a-z_][a-z0-9_]{0,40}$/;
50
+ /** Опции 0.21, снятые в 1.0.0, и их замены (этап 3, п. 11). */
51
+ const REMOVED_OPTIONS = {
52
+ partition: 'отдельные схемы установки (v2.<имя>)',
53
+ enforceAccount: 'изоляция арендаторов включена всегда (RLS)',
54
+ enforceAcl: 'права включены всегда (этап 5)',
55
+ container: 'Docker в up() не нужен',
56
+ image: 'Docker в up() не нужен',
57
+ dataDir: 'Docker в up() не нужен',
58
+ redisPort: 'Redis не нужен: сессии в базе',
59
+ version: 'версия схемы — константа движка',
60
+ };
61
+ const quote = (name) => `"${name.replace(/"/g, '""')}"`;
62
+ /** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
63
+ function makeDb(sh, scope, exec, owner, tx) {
64
+ const txc = scope.txc;
65
+ const chainCtx = {
66
+ reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
67
+ ...(sh.cache ? { cache: { store: sh.cache, who: () => ({ token: txc.token, account: txc.account, tenant: scope.tenant() }) } } : {}),
135
68
  };
136
- ctx.compileAcl = async (c) => {
137
- if (!opts.enforceAcl)
138
- return;
139
- if (!c.account)
140
- throw new Error('letopis: enforceAcl is on — call db.as(account) to name the subject');
141
- const [account, dict] = await Promise.all([makeTables(c).accounts.get(c.account), aclSource(c)]);
142
- if (!account)
143
- throw new Error(`letopis: enforceAcl — account "${c.account}" not found`);
144
- c.aclDecide = compileEnforcer(dict, account.id, account.categories, c.registry);
69
+ // реестр корня меняется после auth.switch — цепочки берут действующий
70
+ Object.defineProperty(chainCtx, 'reg', { get: scope.registry, enumerable: true });
71
+ // очереди батчей — свои у фасада, а не общие на подключение: run() исполняет очередь в контексте фасада
72
+ // (актор, арендатор, владелец, транзакция), значит, в ней только планы, поставленные этим же фасадом
73
+ const batches = new Map();
74
+ const noTx = (what) => {
75
+ throw new LetopisError('tx_required', `${what} — только внутри db.begin()`);
145
76
  };
146
- // enforceAcl остаётся OPT-IN: включить его по умолчанию нельзя (он работает
147
- // deny-by-default, т.е. без настроенных Resource/Rule выдача стала бы пустой).
148
- // Поэтому — предупреждение: молчаливое отсутствие авторизации хуже шумного.
149
- // Один раз на процесс, иначе утонет в логах приложения с пулом подключений.
150
- if (!opts.enforceAcl && !warnedNoAcl) {
151
- warnedNoAcl = true;
152
- console.warn('letopis: enforceAcl is off — Resource/Rule are NOT checked, reads and writes pass ' +
153
- 'authorization unconditionally. For production use connect({ enforceAcl: true }) + db.as(account).');
154
- }
155
- // db.reloadSchema(): перечитать определения классов из таблицы Schema без реконнекта.
156
- // Заодно сбрасывается снимок Resource/Rule: правки словаря ACL подхватываются здесь.
157
- // На корневом хендле обновляется только реестр (субъекта тут нет); scoped-хендлы
158
- // подхватывают его и перекомпилируют свой энфорсер сами (chain.ts, case 'as').
159
- ctx.reload = async () => {
160
- aclDict = null;
161
- ctx.registry = await loadRegistry(sql, opts.schema, partition);
77
+ const auth = makeAuth(sh.S, exec, async () => {
78
+ sh.passes.clear();
79
+ await sh.refresh();
80
+ }, scope.root && !tx);
81
+ const acl = makeAcl(sh.S, exec);
82
+ const adm = { sql: sh.sql, schema: sh.schema, S: sh.S, txc, exec, registry: scope.registry, tenant: scope.tenant };
83
+ const core = {
84
+ schema: sh.schema,
85
+ get token() { return txc.token; },
86
+ get tenant() { return scope.tenant(); },
87
+ get registry() { return scope.registry(); },
88
+ auth,
89
+ acl,
90
+ sql: (strings, ...values) => exec.run((q) => q(strings, ...values), { raw: true }),
91
+ transaction: (fn) => exec.run(fn),
92
+ idOf: (cls, key) => {
93
+ const tenant = scope.tenant();
94
+ if (!tenant)
95
+ throw new LetopisError('no_session', 'idOf: нет арендатора сессии');
96
+ const c = scope.registry().resolve(cls);
97
+ return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
98
+ },
99
+ refresh: sh.refresh,
100
+ flushSignals: () => sh.notifier.flush(),
101
+ verify: (o) => runVerify(adm, o),
102
+ maintain: (o) => runMaintain(adm, o),
103
+ reset: async (o) => {
104
+ const r = await runReset(adm, o);
105
+ sh.passes.clear();
106
+ sh.cache?.clear();
107
+ return r;
108
+ },
109
+ trimHistory: (target, before) => runTrim(adm, target, before),
110
+ rekeyClass: (cls, key) => runRekeyClass(adm, cls, key),
111
+ describe: () => describeModel(adm),
112
+ watch: (o = {}) => {
113
+ if (typeof o === 'function' || typeof o === 'string')
114
+ throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
115
+ const reg = scope.registry();
116
+ const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
117
+ return new Subscription({
118
+ S: sh.S, exec: poolExecutor(txc), classes,
119
+ ...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
120
+ }, o);
121
+ },
122
+ cache: {
123
+ clear: () => sh.cache?.clear(),
124
+ stats: () => sh.cache?.stats() ?? { size: 0, hits: 0, misses: 0, stores: 0, raced: 0, signals: 0, listens: 0, enabled: false },
125
+ },
126
+ entity: (x) => startEntity(chainCtx, x),
127
+ as: async (account, o) => {
128
+ if (tx)
129
+ throw new LetopisError('invalid_query', 'db.as() — от корня подключения, а не внутри db.begin()');
130
+ const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
131
+ const nextOwner = o?.owner ?? owner;
132
+ if (id === undefined && o?.tenant === undefined) {
133
+ return makeDb(sh, scope, poolExecutor(txc), nextOwner);
134
+ }
135
+ // имперсонация и арендатор проверяются базой сразу: ошибка — здесь, а не на первой цепочке
136
+ const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
137
+ const [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
138
+ if (!r?.account)
139
+ throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
140
+ const tenant = r.tenant;
141
+ const registry = await sh.registryFor(tenant, scoped);
142
+ return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
143
+ },
144
+ begin: async () => {
145
+ if (tx)
146
+ throw new LetopisError('invalid_query', 'вложенных транзакций нет: db.begin() внутри db.begin()');
147
+ const t = await ExplicitTx.open(txc);
148
+ return makeDb(sh, scope, t, owner, t);
149
+ },
150
+ load: async (input, o) => {
151
+ if (tx)
152
+ throw new LetopisError('invalid_query', 'load() — своей транзакцией, не внутри db.begin()');
153
+ return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
154
+ },
155
+ batch: (name) => {
156
+ let q = batches.get(name);
157
+ if (!q)
158
+ batches.set(name, (q = []));
159
+ return makeBatch(chainCtx, q);
160
+ },
161
+ lock: async (...keys) => (tx ? tx.lock(...keys) : noTx('lock()')),
162
+ commit: async (tr) => (tr ? tr.commit() : tx ? tx.commit() : noTx('commit()')),
163
+ rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
164
+ close: () => (tx ? tx.rollback() : sh.close()),
162
165
  };
163
- // END_BLOCK_ENFORCE_ACL
164
- return makeDb(ctx);
166
+ return new Proxy(core, {
167
+ get(t, prop, r) {
168
+ if (typeof prop === 'symbol' || prop in t)
169
+ return Reflect.get(t, prop, r);
170
+ switch (prop) {
171
+ case 'then':
172
+ return undefined;
173
+ case 'reloadSchema':
174
+ throw removed('reloadSchema()', 'реестр обновляется сам по сигналу; принудительно — db.refresh()');
175
+ case 'accounts':
176
+ case 'credentials':
177
+ case 'resources':
178
+ case 'rules':
179
+ throw removed(`фасад db.${prop}`, 'системные классы Account, Resource, rule, member и db.auth (accounts.purge — db.auth.purgeAccount)');
180
+ case 'compact':
181
+ throw removed('compact()', 'db.trimHistory(цель, момент), политика хранения history и history: false');
182
+ }
183
+ return startStep(chainCtx, prop);
184
+ },
185
+ });
165
186
  }
166
- /** Курсор keyset-пагинации из последней строки страницы (field как в .sort(), вложенные пути поддержаны). */
167
- // START_CONTRACT: cursorOf
168
- // PURPOSE: Собрать курсор keyset-пагинации из последней строки страницы.
169
- // INPUTS: { row: Row - строка результата; field?: string='updated' - поле сортировки как в .sort() (вложенные пути 'data.*' поддержаны) }
170
- // OUTPUTS: { Cursor - { v, id } для следующей страницы }
171
- // SIDE_EFFECTS: none
172
- // LINKS: M-CONNECT, V-M-CONNECT, M-TYPES
173
- // END_CONTRACT: cursorOf
174
- export function cursorOf(row, field = 'updated') {
175
- const v = field === 'updated'
176
- ? row.updated
177
- : field.slice(5).split('.').reduce((o, k) => o?.[k], row.data);
178
- return { v, id: row.id };
187
+ export async function connect(opts) {
188
+ if (!SCHEMA_NAME.test(opts.schema))
189
+ throw new LetopisError('invalid_data', `connect: схема ${opts.schema} — нужно v<N>.<имя>`);
190
+ if (!opts.sql && !opts.dsn)
191
+ throw new LetopisError('invalid_data', 'connect: нужен dsn или sql');
192
+ for (const k of Object.keys(opts))
193
+ if (k in REMOVED_OPTIONS)
194
+ throw removed(`опция connect({ ${k} })`, REMOVED_OPTIONS[k]);
195
+ const own = !opts.sql;
196
+ // timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
197
+ const sql = (opts.sql ?? postgres(opts.dsn, {
198
+ max: opts.max ?? 10,
199
+ onnotice: () => { },
200
+ types: { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } },
201
+ }));
202
+ const S = quote(opts.schema);
203
+ const owner = opts.owner ?? 'letopis_owner';
204
+ try {
205
+ // роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
206
+ const [role] = await sql `select r.rolsuper, r.rolbypassrls,
207
+ pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
208
+ from pg_roles r where r.rolname = current_user`;
209
+ const bypass = role.rolsuper || role.rolbypassrls || role.inherits || role.can_set;
210
+ if (bypass && !opts.allowBypassRls) {
211
+ throw new LetopisError('bypass_rls', 'роль подключения обходит RLS (суперпользователь, BYPASSRLS или член роли-владельца); администраторское подключение — только с опцией allowBypassRls');
212
+ }
213
+ if (bypass)
214
+ console.warn('letopis: администраторское подключение — RLS не действует, данные всех арендаторов видны');
215
+ let token = opts.token ?? null;
216
+ const apiKey = opts.apiKey ?? opts.service;
217
+ if (!token && apiKey) {
218
+ const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
219
+ throw fromDbError(e);
220
+ });
221
+ token = r.t;
222
+ }
223
+ // ревизия движка: библиотека новее схемы — накат отстал (этап 6, п. 10)
224
+ const [rv] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${opts.schema}`;
225
+ const rev = Number(/ddl_revision=(\d+)/.exec(String(rv?.c ?? ''))?.[1] ?? 0);
226
+ if (!rv)
227
+ throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
228
+ if (rev < DDL_REVISION)
229
+ console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev}, библиотека ждёт ${DDL_REVISION} — обновите: up({ schema: '${opts.schema}', upgrade: true })`);
230
+ const notifier = new Notifier(sql, opts.schema, opts.notifyIntervalMs ?? 100);
231
+ const passes = new AclPasses(S);
232
+ const listen = opts.listen !== false;
233
+ const cache = opts.cache === false ? null : new ResultCache(opts.cache ?? {});
234
+ const signals = new Set();
235
+ // продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
236
+ // транзакции только для чтения) не мешает работе
237
+ let refreshedAt = Date.now();
238
+ const touch = () => {
239
+ if (!token || opts.refreshSession === false || Date.now() - refreshedAt < 60_000)
240
+ return;
241
+ refreshedAt = Date.now();
242
+ void runTx({ sql, token, notifier: null }, (q) => q.unsafe(`select ${S}.auth_refresh()`), { retries: 0 }).catch(() => { });
243
+ };
244
+ const ctx = { sql, token, notifier, passes, after: touch, ...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
245
+ const root = { tenant: null, registry: new Registry() };
246
+ const loadClasses = (txc) => runTx(txc, (q) => q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`), { read: true });
247
+ const others = new Map();
248
+ const refresh = async () => {
249
+ const rows = await runTx(ctx, async (q) => {
250
+ const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
251
+ root.tenant = t?.t ?? null;
252
+ return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
253
+ });
254
+ root.registry.load(rows);
255
+ for (const o of others.values())
256
+ o.registry.load(await loadClasses(o.txc).catch(() => []));
257
+ };
258
+ const registryFor = async (tenant, txc) => {
259
+ if (tenant === root.tenant)
260
+ return root.registry;
261
+ const known = others.get(tenant);
262
+ if (known)
263
+ return known.registry;
264
+ const registry = new Registry();
265
+ registry.load(await loadClasses(txc));
266
+ others.set(tenant, { registry, txc });
267
+ return registry;
268
+ };
269
+ await refresh();
270
+ // описания классов меняются — реестр перечитывается по сигналу; строки прав — пропуска заново;
271
+ // кэш результатов и подписки — по именам классов; (пере)подключение слушателя — кэш целиком
272
+ const onSignal = (cls) => {
273
+ if (ACL_CLASSES.has(cls) || cls === '*')
274
+ passes.clear();
275
+ if (cls === 'Class' || cls === '*')
276
+ void refresh().catch(() => { });
277
+ cache?.signal(cls);
278
+ for (const fn of signals)
279
+ fn(cls);
280
+ };
281
+ const listener = listen
282
+ ? await sql.listen(opts.schema, onSignal, () => {
283
+ cache?.listen();
284
+ for (const fn of signals)
285
+ fn('*');
286
+ })
287
+ : null;
288
+ let stopMaintain = null;
289
+ const shared = {
290
+ sql, schema: opts.schema, S, notifier, passes, root, registryFor, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
291
+ admin: Boolean(bypass && (role.rolsuper || role.can_set)),
292
+ ownerRole: owner,
293
+ cache,
294
+ signals,
295
+ listen,
296
+ async close() {
297
+ stopMaintain?.();
298
+ await notifier.close();
299
+ await listener?.unlisten();
300
+ if (own)
301
+ await sql.end({ timeout: 5 });
302
+ },
303
+ };
304
+ const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
305
+ if (opts.maintain !== undefined)
306
+ stopMaintain = scheduleMaintain(() => db.maintain(), opts.maintain);
307
+ return db;
308
+ }
309
+ catch (e) {
310
+ if (own)
311
+ await sql.end({ timeout: 1 });
312
+ throw e;
313
+ }
179
314
  }
180
- // одна точка входа: контейнер + готовность + схема + connect
181
- export { up } from './up.js';
182
- // генерация id (attributes.id): формула v5 открыта — id считается ДО создания
183
- export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
184
- // операторы фильтров
185
- export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
186
- export { totpCode } from './auth.js';
187
- export { ValidationError } from './write.js';
188
- export { Registry } from './schema.js';
@@ -0,0 +1,22 @@
1
+ type Json = null | boolean | number | string | Json[] | {
2
+ [k: string]: Json;
3
+ };
4
+ export type JsonSchema = {
5
+ [k: string]: Json;
6
+ };
7
+ /** Подмножество, которое понимают база и валидатор. */
8
+ export declare const SUPPORTED: Set<string>;
9
+ /**
10
+ * Перевод шаблона JavaScript в форму, которую одинаково понимают JavaScript (без флага u) и
11
+ * PostgreSQL: обозначения классов заменяются явными наборами, остальное не трогается.
12
+ */
13
+ export declare function translatePattern(p: string, where?: string): string;
14
+ export interface NormalizeOptions {
15
+ /** Имя модели или поля — для сообщений. */
16
+ where?: string;
17
+ /** Проверять, что имена полей верхнего уровня не начинаются с $. */
18
+ topLevel?: boolean;
19
+ }
20
+ /** Нормализует JSON Schema; бросает LetopisError('invalid_class') с названием поля и ключевого слова. */
21
+ export declare function normalizeSchema(input: JsonSchema, opts?: NormalizeOptions): JsonSchema;
22
+ export {};