letopis 0.20.0 → 0.20.1
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 +86 -0
- package/README.md +228 -170
- package/dist/index.js +68 -13
- package/dist/types.d.ts +20 -0
- package/dist/types.js +25 -0
- package/dist/up.d.ts +8 -0
- package/dist/up.js +22 -7
- package/package.json +1 -1
- package/scripts/check-docs.mjs +39 -2
- package/scripts/gen-api-contract.mjs +7 -2
- package/sql/ddl.sql +15 -1
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* await t.Мастер({ name: 'Вася' }).навык().Услуга().run()
|
|
11
11
|
*/
|
|
12
12
|
// FILE: lib/src/index.ts
|
|
13
|
-
// VERSION: 1.
|
|
13
|
+
// VERSION: 1.3.0
|
|
14
14
|
// START_MODULE_CONTRACT
|
|
15
15
|
// PURPOSE: Публичная точка входа пакета — открыть соединение (реестр, опциональный ACL, сборка db) и ре-экспорт публичной поверхности; хелпер курсора.
|
|
16
16
|
// SCOPE: connect, cursorOf + barrel-реэкспорты
|
|
@@ -21,22 +21,24 @@
|
|
|
21
21
|
// END_MODULE_CONTRACT
|
|
22
22
|
//
|
|
23
23
|
// START_MODULE_MAP
|
|
24
|
-
// connect - открывает pg-соединение, грузит реестр, опц. включает ACL, собирает EntityDb
|
|
24
|
+
// connect - открывает pg-соединение, грузит реестр, опц. включает ACL (снимок Resource/Rule на подключение), собирает EntityDb
|
|
25
25
|
// cursorOf - курсор keyset-пагинации из последней строки страницы
|
|
26
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
27
|
// END_MODULE_MAP
|
|
28
28
|
//
|
|
29
29
|
// START_CHANGE_SUMMARY
|
|
30
|
-
// LAST_CHANGE: [v1.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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()]
|
|
34
35
|
// END_CHANGE_SUMMARY
|
|
35
36
|
import postgres from 'postgres';
|
|
36
37
|
import { loadRegistry } from './schema.js';
|
|
37
38
|
import { makeDb } from './chain.js';
|
|
38
39
|
import { makeTables } from './tables.js';
|
|
39
40
|
import { compileEnforcer } from './acl.js';
|
|
41
|
+
import { DDL_REVISION, parseDdlRevision } from './types.js';
|
|
40
42
|
// START_CONTRACT: connect
|
|
41
43
|
// PURPOSE: Открыть БЕЗЛИЧНОЕ соединение: postgres-пул, реестр схемы, System-аккаунт, хук компиляции ACL — и собрать EntityDb (арендатора вызова называет db.as()).
|
|
42
44
|
// INPUTS: { opts: ConnectOpts { dsn, schema, partition?='entity', max?=10, enforceAcl?, enforceAccount?=true, onQuery?, slowMs? } }
|
|
@@ -46,6 +48,36 @@ import { compileEnforcer } from './acl.js';
|
|
|
46
48
|
// END_CONTRACT: connect
|
|
47
49
|
/** Предупреждение об отключённом enforceAcl печатается один раз на процесс. */
|
|
48
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');
|
|
80
|
+
}
|
|
49
81
|
export async function connect(opts) {
|
|
50
82
|
// timestamptz — строкой (JS Date режет микросекунды → ломал бы asOf/cursorOf по updated)
|
|
51
83
|
const sql = postgres(opts.dsn, {
|
|
@@ -57,6 +89,13 @@ export async function connect(opts) {
|
|
|
57
89
|
// Entity.account NOT NULL: fallback — System-аккаунт (семантика legacy-движка)
|
|
58
90
|
const ident = `"${opts.schema.replace(/"/g, '""')}"`;
|
|
59
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
|
|
60
99
|
const ctx = {
|
|
61
100
|
sql,
|
|
62
101
|
registry,
|
|
@@ -75,20 +114,34 @@ export async function connect(opts) {
|
|
|
75
114
|
// enforceAcl: правила и категории субъекта компилятся в резолвер ПОД КОНКРЕТНЫЙ scope.
|
|
76
115
|
// Функция живёт на ctx, потому что зовут её двое: db.as() (новый субъект) и reload
|
|
77
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;
|
|
135
|
+
};
|
|
78
136
|
ctx.compileAcl = async (c) => {
|
|
79
137
|
if (!opts.enforceAcl)
|
|
80
138
|
return;
|
|
81
139
|
if (!c.account)
|
|
82
140
|
throw new Error('letopis: enforceAcl is on — call db.as(account) to name the subject');
|
|
83
|
-
const
|
|
84
|
-
const [account, resources, rules] = await Promise.all([
|
|
85
|
-
tables.accounts.get(c.account),
|
|
86
|
-
tables.resources.find(),
|
|
87
|
-
tables.rules.find({ enabled: true }),
|
|
88
|
-
]);
|
|
141
|
+
const [account, dict] = await Promise.all([makeTables(c).accounts.get(c.account), aclSource(c)]);
|
|
89
142
|
if (!account)
|
|
90
143
|
throw new Error(`letopis: enforceAcl — account "${c.account}" not found`);
|
|
91
|
-
c.aclDecide = compileEnforcer(
|
|
144
|
+
c.aclDecide = compileEnforcer(dict, account.id, account.categories, c.registry);
|
|
92
145
|
};
|
|
93
146
|
// enforceAcl остаётся OPT-IN: включить его по умолчанию нельзя (он работает
|
|
94
147
|
// deny-by-default, т.е. без настроенных Resource/Rule выдача стала бы пустой).
|
|
@@ -100,9 +153,11 @@ export async function connect(opts) {
|
|
|
100
153
|
'authorization unconditionally. For production use connect({ enforceAcl: true }) + db.as(account).');
|
|
101
154
|
}
|
|
102
155
|
// db.reloadSchema(): перечитать определения классов из таблицы Schema без реконнекта.
|
|
156
|
+
// Заодно сбрасывается снимок Resource/Rule: правки словаря ACL подхватываются здесь.
|
|
103
157
|
// На корневом хендле обновляется только реестр (субъекта тут нет); scoped-хендлы
|
|
104
158
|
// подхватывают его и перекомпилируют свой энфорсер сами (chain.ts, case 'as').
|
|
105
159
|
ctx.reload = async () => {
|
|
160
|
+
aclDict = null;
|
|
106
161
|
ctx.registry = await loadRegistry(sql, opts.schema, partition);
|
|
107
162
|
};
|
|
108
163
|
// END_BLOCK_ENFORCE_ACL
|
package/dist/types.d.ts
CHANGED
|
@@ -268,3 +268,23 @@ export declare function reservedNamesOf(def: {
|
|
|
268
268
|
id: string;
|
|
269
269
|
alias?: string;
|
|
270
270
|
}): string[];
|
|
271
|
+
/**
|
|
272
|
+
* Ревизия движка (`lib/sql/ddl.sql`), которую ожидает ЭТА версия либы.
|
|
273
|
+
*
|
|
274
|
+
* Зачем: `ddl.sql` растёт аддитивно внутри одной версии движка (в 0.19.0, например,
|
|
275
|
+
* добавились функции purge/purge_closure/purge_account). Схема, накатанная раньше, их
|
|
276
|
+
* НЕ получает — приложение обновляет пакет и падает сырым `PostgresError: function
|
|
277
|
+
* "v1.x".purge(...) does not exist` вместо внятного объяснения. `connect()` сравнивает
|
|
278
|
+
* ожидаемую ревизию с меткой в схеме (`COMMENT ON SCHEMA` — её ставит последняя строка
|
|
279
|
+
* ddl.sql) и один раз на процесс предупреждает, что и как обновить.
|
|
280
|
+
*
|
|
281
|
+
* Файл идемпотентен, поэтому аддитивные правки доезжают повторным накатом:
|
|
282
|
+
* `up({ upgrade: true })` либо `node db/apply.mjs --upgrade` — данные целы.
|
|
283
|
+
* Несовместимая правка СТРУКТУРЫ таблиц — это смена version в имени схемы (v1 → v2).
|
|
284
|
+
*
|
|
285
|
+
* Совпадение константы с маркером `-- DDL_REVISION:` в ddl.sql проверяет
|
|
286
|
+
* `scripts/check-docs.mjs` — иначе одно уедет без другого.
|
|
287
|
+
*/
|
|
288
|
+
export declare const DDL_REVISION = 1;
|
|
289
|
+
/** Метка ревизии в комментарии схемы: 'letopis ddl_revision=N' → N; иначе null. */
|
|
290
|
+
export declare function parseDdlRevision(comment: string | null | undefined): number | null;
|
package/dist/types.js
CHANGED
|
@@ -86,3 +86,28 @@ export function reservedNamesOf(def) {
|
|
|
86
86
|
return [def.id, def.alias].filter((n) => !!n && set.has(n));
|
|
87
87
|
}
|
|
88
88
|
// END_BLOCK_RESERVED_NAMES
|
|
89
|
+
// START_BLOCK_DDL_REVISION
|
|
90
|
+
/**
|
|
91
|
+
* Ревизия движка (`lib/sql/ddl.sql`), которую ожидает ЭТА версия либы.
|
|
92
|
+
*
|
|
93
|
+
* Зачем: `ddl.sql` растёт аддитивно внутри одной версии движка (в 0.19.0, например,
|
|
94
|
+
* добавились функции purge/purge_closure/purge_account). Схема, накатанная раньше, их
|
|
95
|
+
* НЕ получает — приложение обновляет пакет и падает сырым `PostgresError: function
|
|
96
|
+
* "v1.x".purge(...) does not exist` вместо внятного объяснения. `connect()` сравнивает
|
|
97
|
+
* ожидаемую ревизию с меткой в схеме (`COMMENT ON SCHEMA` — её ставит последняя строка
|
|
98
|
+
* ddl.sql) и один раз на процесс предупреждает, что и как обновить.
|
|
99
|
+
*
|
|
100
|
+
* Файл идемпотентен, поэтому аддитивные правки доезжают повторным накатом:
|
|
101
|
+
* `up({ upgrade: true })` либо `node db/apply.mjs --upgrade` — данные целы.
|
|
102
|
+
* Несовместимая правка СТРУКТУРЫ таблиц — это смена version в имени схемы (v1 → v2).
|
|
103
|
+
*
|
|
104
|
+
* Совпадение константы с маркером `-- DDL_REVISION:` в ddl.sql проверяет
|
|
105
|
+
* `scripts/check-docs.mjs` — иначе одно уедет без другого.
|
|
106
|
+
*/
|
|
107
|
+
export const DDL_REVISION = 1;
|
|
108
|
+
/** Метка ревизии в комментарии схемы: 'letopis ddl_revision=N' → N; иначе null. */
|
|
109
|
+
export function parseDdlRevision(comment) {
|
|
110
|
+
const m = /letopis ddl_revision=(\d+)/.exec(comment ?? '');
|
|
111
|
+
return m ? Number(m[1]) : null;
|
|
112
|
+
}
|
|
113
|
+
// END_BLOCK_DDL_REVISION
|
package/dist/up.d.ts
CHANGED
|
@@ -28,6 +28,14 @@ export interface UpOpts extends Omit<ConnectOpts, 'dsn' | 'schema'> {
|
|
|
28
28
|
seeds?: string[] | false;
|
|
29
29
|
/** Дропнуть схему и накатить заново. ДАННЫЕ СХЕМЫ ТЕРЯЮТСЯ. */
|
|
30
30
|
fresh?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Перекатить `ddl.sql` на СУЩЕСТВУЮЩУЮ схему (сиды не трогаются, данные целы).
|
|
33
|
+
* Так доезжают аддитивные правки движка: схема, накатанная старой либой, не имеет новых
|
|
34
|
+
* функций/триггеров (например `purge`/`purge_account` из 0.19.0) и падает сырым
|
|
35
|
+
* «function … does not exist». О расхождении предупреждает `connect()` (см. `DDL_REVISION`).
|
|
36
|
+
* Идемпотентно; на несуществующей схеме — обычный первый накат.
|
|
37
|
+
*/
|
|
38
|
+
upgrade?: boolean;
|
|
31
39
|
/** Без console.log-прогресса. */
|
|
32
40
|
quiet?: boolean;
|
|
33
41
|
/** Максимум ожидания готовности, мс. Default 120 000 (первый запуск: pull образа + initdb). */
|
package/dist/up.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// END_MODULE_CONTRACT
|
|
18
18
|
//
|
|
19
19
|
// START_MODULE_MAP
|
|
20
|
-
// UpOpts - опции up(): dsn, schema, version, контейнер/образ, dataDir, seeds, fresh, quiet, таймаут
|
|
20
|
+
// UpOpts - опции up(): dsn, schema, version, контейнер/образ, dataDir, seeds, fresh, upgrade, quiet, таймаут
|
|
21
21
|
// dockerRunArgs - чистая сборка argv для `docker run` (bind/volume, trust/пароль)
|
|
22
22
|
// RunCfg - конфиг запуска контейнера (порты, креды, база, dataDir)
|
|
23
23
|
// up - точка входа: контейнер -> база -> схема -> готовность -> connect (возвращает EntityDb)
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// pgPing - (local) проба postgres: ok | no-server | no-database | starting | fatal
|
|
27
27
|
// ensureDatabase - (local) создать базу, если её нет (гонка 42P04 глушится)
|
|
28
28
|
// tcpAlive - (local) TCP-проба хоста:порта (для redis)
|
|
29
|
-
// applySchema - (local) drop (fresh) -> накат ddl+сидов с подстановкой <SCHEMA-NAME> -> ANALYZE Entity
|
|
29
|
+
// applySchema - (local) drop (fresh) | перекат ddl (upgrade) -> накат ddl+сидов с подстановкой <SCHEMA-NAME> -> ANALYZE Entity
|
|
30
30
|
// END_MODULE_MAP
|
|
31
31
|
//
|
|
32
32
|
// START_CHANGE_SUMMARY
|
|
@@ -184,12 +184,12 @@ function tcpAlive(host, port) {
|
|
|
184
184
|
}
|
|
185
185
|
// START_CONTRACT: applySchema
|
|
186
186
|
// PURPOSE: Применяет DDL-схему и сиды — при fresh дропает схему; если схемы нет, накатывает ddl.sql (+ дефолтные booking/auth или свои сиды), подставляя <SCHEMA-NAME>.
|
|
187
|
-
// INPUTS: { dsn: string; full: string - полное имя схемы v<N>.<schema>; seeds: string[] | false | undefined - свои пути / false (без сидов) / undefined (дефолт); fresh: boolean; log: (m: string) => void }
|
|
187
|
+
// INPUTS: { dsn: string; full: string - полное имя схемы v<N>.<schema>; seeds: string[] | false | undefined - свои пути / false (без сидов) / undefined (дефолт); fresh: boolean; upgrade: boolean - перекатить ddl на существующую схему; log: (m: string) => void }
|
|
188
188
|
// OUTPUTS: { Promise<void> }
|
|
189
189
|
// SIDE_EFFECTS: читает sql-файлы; выполняет DROP/накат схемы в postgres; закрывает соединение
|
|
190
190
|
// LINKS: M-UP, V-M-UP
|
|
191
191
|
// END_CONTRACT: applySchema
|
|
192
|
-
async function applySchema(dsn, full, seeds, fresh, log) {
|
|
192
|
+
async function applySchema(dsn, full, seeds, fresh, upgrade, log) {
|
|
193
193
|
const sql = postgres(dsn, { max: 1, onnotice: () => { } });
|
|
194
194
|
try {
|
|
195
195
|
if (fresh) {
|
|
@@ -198,6 +198,21 @@ async function applySchema(dsn, full, seeds, fresh, log) {
|
|
|
198
198
|
}
|
|
199
199
|
const have = await sql `SELECT 1 FROM information_schema.schemata WHERE schema_name = ${full}`;
|
|
200
200
|
if (have.length > 0) {
|
|
201
|
+
// START_BLOCK_UPGRADE_DDL
|
|
202
|
+
// upgrade: перекатить ТОЛЬКО ddl.sql на существующую схему. Сиды не трогаем — они
|
|
203
|
+
// вставляют данные, повторный прогон дал бы дубли смысла. Файл идемпотентен
|
|
204
|
+
// (CREATE OR REPLACE у функций, DROP IF EXISTS + CREATE у триггеров, IF NOT EXISTS
|
|
205
|
+
// у таблиц/индексов), поэтому данные целы, а аддитивные правки движка доезжают.
|
|
206
|
+
// Так закрывается разрыв: схема, накатанная старой либой, не имела новых функций
|
|
207
|
+
// (purge/purge_account из 0.19.0) и падала сырым «function … does not exist».
|
|
208
|
+
if (upgrade) {
|
|
209
|
+
const text = (await readFile(pkgPath('../sql/ddl.sql'), 'utf8')).replaceAll('<SCHEMA-NAME>', full);
|
|
210
|
+
await sql.unsafe(text);
|
|
211
|
+
await sql.unsafe(`ANALYZE ${qi(full)}."Entity"`);
|
|
212
|
+
log(`schema "${full}" upgraded (ddl re-applied, data intact)`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// END_BLOCK_UPGRADE_DDL
|
|
201
216
|
log(`schema "${full}" already exists — apply skipped`);
|
|
202
217
|
return;
|
|
203
218
|
}
|
|
@@ -226,13 +241,13 @@ async function applySchema(dsn, full, seeds, fresh, log) {
|
|
|
226
241
|
}
|
|
227
242
|
// START_CONTRACT: up
|
|
228
243
|
// PURPOSE: Единая точка входа dev-bootstrap — валидирует схему/версию, при живом postgres пропускает docker (иначе поднимает контейнер и ждёт готовности PG+Redis), применяет схему и подключается.
|
|
229
|
-
// INPUTS: { opts: UpOpts - dsn, schema, version, контейнер/образ, dataDir, redisPort, seeds, fresh, quiet, waitTimeoutMs + прочие ConnectOpts }
|
|
244
|
+
// INPUTS: { opts: UpOpts - dsn, schema, version, контейнер/образ, dataDir, redisPort, seeds, fresh, upgrade, quiet, waitTimeoutMs + прочие ConnectOpts }
|
|
230
245
|
// OUTPUTS: { Promise<EntityDb> - подключённый фасад на схеме "v<version>.<schema>" }
|
|
231
246
|
// SIDE_EFFECTS: shell docker через M-UP.ensureContainer; TCP-пинги; чтение sql и накат схемы; console.log с префиксом [letopis.up]; connect (M-CONNECT); бросает при плохом имени схемы/версии и таймауте готовности
|
|
232
247
|
// LINKS: M-UP, V-M-UP
|
|
233
248
|
// END_CONTRACT: up
|
|
234
249
|
export async function up(opts) {
|
|
235
|
-
const { dsn = DEFAULT_DSN, schema, version, container = 'letopis-timescale', image = 'letopis-db', dataDir, redisPort = 16379, seeds, fresh, quiet, waitTimeoutMs = 120_000, ...connectRest } = opts;
|
|
250
|
+
const { dsn = DEFAULT_DSN, schema, version, container = 'letopis-timescale', image = 'letopis-db', dataDir, redisPort = 16379, seeds, fresh, upgrade, quiet, waitTimeoutMs = 120_000, ...connectRest } = opts;
|
|
236
251
|
if (!/^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(schema))
|
|
237
252
|
throw new Error(`letopis.up: bad schema name "${schema}" (базовое имя без версии и точек; версию задаёт version)`);
|
|
238
253
|
if (!Number.isInteger(version) || version < 1)
|
|
@@ -295,7 +310,7 @@ export async function up(opts) {
|
|
|
295
310
|
// END_BLOCK_WAIT_READY
|
|
296
311
|
}
|
|
297
312
|
// START_BLOCK_APPLY_SCHEMA
|
|
298
|
-
await applySchema(dsn, full, seeds, fresh ?? false, log);
|
|
313
|
+
await applySchema(dsn, full, seeds, fresh ?? false, upgrade ?? false, log);
|
|
299
314
|
const db = await connect({ ...connectRest, dsn, schema: full });
|
|
300
315
|
log(`connected (schema "${full}")`);
|
|
301
316
|
// END_BLOCK_APPLY_SCHEMA
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
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/scripts/check-docs.mjs
CHANGED
|
@@ -260,8 +260,9 @@ async function checkReserved() {
|
|
|
260
260
|
const счётчики = [
|
|
261
261
|
['lib/README.md', readme, new RegExp(`Всего ${n} им`)],
|
|
262
262
|
['AGENT-CHEATSHEET.md', await rd('AGENT-CHEATSHEET.md'), new RegExp(`\\*\\*Зарезервированные имена классов\\*\\*: ${n} им`)],
|
|
263
|
-
//
|
|
264
|
-
|
|
263
|
+
// CHANGELOG целиком: число называет та запись, которая его изменила, — следующие релизы
|
|
264
|
+
// его не повторяют. Меняется число — релиз обязан это записать, иначе проверка встанет.
|
|
265
|
+
['lib/CHANGELOG.md', await rd('lib/CHANGELOG.md'), new RegExp(`\\*\\*${n} им`)],
|
|
265
266
|
];
|
|
266
267
|
for (const [file, text, re] of счётчики) {
|
|
267
268
|
if (!re.test(text)) fail('reserved', `${file} не называет актуальное число зарезервированных имён (${n})`);
|
|
@@ -317,6 +318,41 @@ async function checkDemos() {
|
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
// END_BLOCK_CHECK_DEMOS
|
|
321
|
+
// START_BLOCK_CHECK_DDL_REVISION
|
|
322
|
+
/**
|
|
323
|
+
* Класс дефекта: ревизия движка живёт в ДВУХ местах — маркер `-- DDL_REVISION:` в ddl.sql
|
|
324
|
+
* (штампуется в COMMENT ON SCHEMA последней строкой файла) и константа DDL_REVISION в
|
|
325
|
+
* types.ts (её сверяет connect()). Разъедутся — либа начнёт предупреждать про отставание
|
|
326
|
+
* на свежей схеме либо промолчит на отставшей. Плюс проверяем, что штамп в ddl.sql вообще
|
|
327
|
+
* есть и стоит ПОСЛЕДНИМ выражением: иначе метка появится при частично применённом файле.
|
|
328
|
+
*/
|
|
329
|
+
async function checkDdlRevision() {
|
|
330
|
+
const ddl = await rd('lib/sql/ddl.sql');
|
|
331
|
+
const types = await rd('lib/src/types.ts');
|
|
332
|
+
|
|
333
|
+
const marker = /^--\s*DDL_REVISION:\s*(\d+)/m.exec(ddl);
|
|
334
|
+
const konst = /export const DDL_REVISION = (\d+)/.exec(types);
|
|
335
|
+
const stamp = /COMMENT ON SCHEMA "<SCHEMA-NAME>" IS 'letopis ddl_revision=(\d+)';/.exec(ddl);
|
|
336
|
+
|
|
337
|
+
if (!marker) return fail('ddl-revision', 'в lib/sql/ddl.sql нет маркера "-- DDL_REVISION: N"');
|
|
338
|
+
if (!konst) return fail('ddl-revision', 'в lib/src/types.ts нет "export const DDL_REVISION = N"');
|
|
339
|
+
if (!stamp) return fail('ddl-revision', 'ddl.sql не штампует ревизию: нет COMMENT ON SCHEMA … ddl_revision=N');
|
|
340
|
+
|
|
341
|
+
const [m, k, s] = [marker[1], konst[1], stamp[1]];
|
|
342
|
+
if (!(m === k && k === s)) {
|
|
343
|
+
fail('ddl-revision', `ревизии разошлись: маркер ddl.sql ${m}, штамп COMMENT ${s}, DDL_REVISION в types.ts ${k}`);
|
|
344
|
+
}
|
|
345
|
+
// штамп обязан быть последним ВЫРАЖЕНИЕМ файла (комментарии после — можно)
|
|
346
|
+
const tail = ddl.slice(ddl.indexOf(stamp[0]) + stamp[0].length);
|
|
347
|
+
if (/^\s*[^-\s]/m.test(tail.replace(/^\s*--.*$/gm, ''))) {
|
|
348
|
+
fail('ddl-revision', 'после COMMENT ON SCHEMA в ddl.sql есть ещё выражения — метка встанет на неполном накате');
|
|
349
|
+
}
|
|
350
|
+
if (!problems.some((p) => p.startsWith('ddl-revision:'))) {
|
|
351
|
+
ok('ddl-revision', `ревизия движка ${k}: маркер ddl.sql ↔ штамп COMMENT ↔ DDL_REVISION в types.ts совпадают`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// END_BLOCK_CHECK_DDL_REVISION
|
|
320
356
|
// START_BLOCK_MAIN
|
|
321
357
|
await checkVersion();
|
|
322
358
|
await checkLinks();
|
|
@@ -325,6 +361,7 @@ await checkSeedCounts();
|
|
|
325
361
|
await checkCodescribe();
|
|
326
362
|
await checkApiContract();
|
|
327
363
|
await checkReserved();
|
|
364
|
+
await checkDdlRevision();
|
|
328
365
|
await checkDemos();
|
|
329
366
|
|
|
330
367
|
for (const p of passed) console.log(` ok ${p}`);
|
|
@@ -38,6 +38,9 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|
|
38
38
|
import { fileURLToPath } from 'node:url';
|
|
39
39
|
import { join } from 'node:path';
|
|
40
40
|
|
|
41
|
+
/** Детерминированный порядок: кодовые единицы UTF-16, одинаково на любой ОС и локали. */
|
|
42
|
+
const byCode = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
43
|
+
|
|
41
44
|
const LIB = fileURLToPath(new URL('..', import.meta.url));
|
|
42
45
|
const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
|
|
43
46
|
const out = args.out ?? join(LIB, '..', 'docs', 'api-contract.json');
|
|
@@ -127,7 +130,9 @@ function removedNames(chain) {
|
|
|
127
130
|
message: `letopis: ${text}`,
|
|
128
131
|
});
|
|
129
132
|
}
|
|
130
|
-
|
|
133
|
+
// ВАЖНО: сравнение по кодовым единицам, а не localeCompare — тот зависит от локали и версии
|
|
134
|
+
// ICU, поэтому Windows и linux-раннер давали РАЗНЫЙ порядок, и контракт числился устаревшим.
|
|
135
|
+
return [...byMsg.values()].sort((a, b) => byCode(String(a.removed), String(b.removed)));
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
// START_CONTRACT: errorCatalog
|
|
@@ -145,7 +150,7 @@ function errorCatalog(files) {
|
|
|
145
150
|
if (!rows.some((r) => r.message === msg)) rows.push({ module: mod, message: msg });
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
|
-
return rows.sort((a, b) => a.message
|
|
153
|
+
return rows.sort((a, b) => byCode(a.message, b.message));
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
// START_BLOCK_MAIN
|
package/sql/ddl.sql
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
-- =============================================================================
|
|
8
8
|
|
|
9
9
|
-- FILE: lib/sql/ddl.sql
|
|
10
|
-
-- VERSION: 1.
|
|
10
|
+
-- VERSION: 1.1.0
|
|
11
|
+
-- DDL_REVISION: 1 (последняя строка файла штампует её в COMMENT ON SCHEMA; см. там же)
|
|
11
12
|
-- START_MODULE_CONTRACT
|
|
12
13
|
-- PURPOSE: Движок-хранилище PostgreSQL/TimescaleDB — таблицы, Entity-hypertable, индексы и триггеры целостности (валидация, версионирование, каскад-tombstone, lineage, notify).
|
|
13
14
|
-- SCOPE: таблицы Schema/Entity/Account/Credential/Resource/Rule + триггеры schema_lineage/entity_check/entity_update/entity_delete/entity_notify.
|
|
@@ -570,6 +571,19 @@ CREATE TRIGGER entity_notify
|
|
|
570
571
|
AFTER INSERT ON "<SCHEMA-NAME>"."Entity"
|
|
571
572
|
FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_notify();
|
|
572
573
|
|
|
574
|
+
-- -----------------------------------------------------------------------------
|
|
575
|
+
-- Метка ревизии движка — ПОСЛЕДНЕЙ строкой: комментарий появляется только если весь
|
|
576
|
+
-- файл применился. Её читает connect() и предупреждает, если схема отстала от либы
|
|
577
|
+
-- (DDL_REVISION в lib/src/types.ts; совпадение сверяет scripts/check-docs.mjs).
|
|
578
|
+
--
|
|
579
|
+
-- Ревизия — про АДДИТИВНЫЕ правки внутри одной версии движка (новые функции/триггеры/
|
|
580
|
+
-- индексы): файл идемпотентен, поэтому такие правки доезжают повторным накатом
|
|
581
|
+
-- (up({ upgrade: true }) / node db/apply.mjs --upgrade), данные при этом целы.
|
|
582
|
+
-- Несовместимая правка СТРУКТУРЫ таблиц — это смена version в имени схемы (v1 → v2),
|
|
583
|
+
-- а не ревизия.
|
|
584
|
+
-- -----------------------------------------------------------------------------
|
|
585
|
+
COMMENT ON SCHEMA "<SCHEMA-NAME>" IS 'letopis ddl_revision=1';
|
|
586
|
+
|
|
573
587
|
-- -----------------------------------------------------------------------------
|
|
574
588
|
-- Опционально (включать по мере роста данных):
|
|
575
589
|
--
|