letopis 0.20.0 → 0.20.3

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/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.2.0
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.2.0 - BREAKING: opts.account/opts.owner сняты — подключение безличное,
31
- // арендатора и субъекта ACL называет db.as(account, { owner }). Компиляция энфорсера
32
- // вынесена в ctx.compileAcl(ctx-scope), ctx.reload обновляет только реестр.
33
- // Ранее: enforceAccount по умолчанию true; предупреждение при выключенном enforceAcl]
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 tables = makeTables(c);
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({ resources, rules }, account.id, account.categories, c.registry);
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.0",
3
+ "version": "0.20.3",
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",
@@ -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
- // только ТЕКУЩАЯ запись CHANGELOG: прошлые версии описывают своё время и неприкосновенны
264
- ['lib/CHANGELOG.md', (await rd('lib/CHANGELOG.md')).split(/^## \[/m)[1] ?? '', new RegExp(`\\*\\*${n} им`)],
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
- return [...byMsg.values()].sort((a, b) => String(a.removed).localeCompare(String(b.removed)));
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.localeCompare(b.message));
153
+ return rows.sort((a, b) => byCode(a.message, b.message));
149
154
  }
150
155
 
151
156
  // START_BLOCK_MAIN
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Печатает в stdout секцию CHANGELOG для ТЕКУЩЕЙ версии пакета — тело релизного PR.
4
+ *
5
+ * cd lib && node scripts/release-notes.mjs # посмотреть
6
+ * gh pr create --base main --head dev --title "Release: …" \
7
+ * --body "$(cd lib && node scripts/release-notes.mjs)" # так и создавать PR
8
+ *
9
+ * Зачем: описание PR было единственным местом контура, где текст писался заново, хотя тот же
10
+ * текст уже есть в lib/CHANGELOG.md — источнике истины версии (его сверяет check-docs.mjs с
11
+ * package.json). Цепочка становится без дублирования:
12
+ *
13
+ * lib/CHANGELOG.md (руками) → описание PR → тело релиза (release.yml берёт из PR) → npm
14
+ *
15
+ * Версия НЕ передаётся аргументом намеренно: берётся из package.json, чтобы нельзя было
16
+ * выпустить заметки одной версии под тегом другой.
17
+ */
18
+ //
19
+ // FILE: lib/scripts/release-notes.mjs
20
+ // VERSION: 1.0.0
21
+ // START_MODULE_CONTRACT
22
+ // PURPOSE: Вырезать из lib/CHANGELOG.md секцию текущей версии пакета и напечатать её — тело релизного PR, из которого release.yml берёт заметки релиза.
23
+ // SCOPE: чтение package.json + CHANGELOG, извлечение секции, печать в stdout.
24
+ // DEPENDS: none
25
+ // LINKS: M-RELEASE-NOTES, V-M-RELEASE-NOTES
26
+ // ROLE: SCRIPT
27
+ // MAP_MODE: LOCALS
28
+ // END_MODULE_CONTRACT
29
+ //
30
+ // START_MODULE_MAP
31
+ // section - (local) секция CHANGELOG по версии: от "## [x.y.z]" до следующей "## ["
32
+ // END_MODULE_MAP
33
+ //
34
+ // START_CHANGE_SUMMARY
35
+ // LAST_CHANGE: [v1.0.0 - Новый скрипт: тело релизного PR берётся из CHANGELOG, а не пишется заново]
36
+ // END_CHANGE_SUMMARY
37
+ import { readFile } from 'node:fs/promises';
38
+ import { fileURLToPath } from 'node:url';
39
+ import { join } from 'node:path';
40
+
41
+ const LIB = fileURLToPath(new URL('..', import.meta.url));
42
+
43
+ // START_CONTRACT: section
44
+ // PURPOSE: Достать из текста CHANGELOG секцию конкретной версии (без её заголовка).
45
+ // INPUTS: { changelog: string - текст lib/CHANGELOG.md; version: string - x.y.z }
46
+ // OUTPUTS: { string - тело секции; пусто, если версии в файле нет }
47
+ // SIDE_EFFECTS: none
48
+ // LINKS: M-RELEASE-NOTES, V-M-RELEASE-NOTES
49
+ // END_CONTRACT: section
50
+ function section(changelog, version) {
51
+ const start = changelog.indexOf(`## [${version}]`);
52
+ if (start < 0) return '';
53
+ const after = changelog.indexOf('\n## [', start + 1);
54
+ const body = changelog.slice(changelog.indexOf('\n', start) + 1, after < 0 ? undefined : after);
55
+ return body.trim();
56
+ }
57
+
58
+ const [pkgRaw, changelog] = await Promise.all([
59
+ readFile(join(LIB, 'package.json'), 'utf8'),
60
+ readFile(join(LIB, 'CHANGELOG.md'), 'utf8'),
61
+ ]);
62
+ const { version } = JSON.parse(pkgRaw);
63
+ const body = section(changelog, version);
64
+
65
+ if (!body) {
66
+ console.error(
67
+ `release-notes: в lib/CHANGELOG.md нет записи "## [${version}]" — подними версию в ` +
68
+ 'package.json или добавь запись (то же самое проверяет npm run check:docs)',
69
+ );
70
+ process.exit(1);
71
+ }
72
+
73
+ // Ссылку на полный changelog добавляем всегда: релиз читают из GitHub, а не из репозитория.
74
+ process.stdout.write(
75
+ `${body}\n\n---\n\nПолный changelog — [lib/CHANGELOG.md](https://github.com/alepri51/letopis/blob/main/lib/CHANGELOG.md).\n`,
76
+ );
package/sql/ddl.sql CHANGED
@@ -7,7 +7,8 @@
7
7
  -- =============================================================================
8
8
 
9
9
  -- FILE: lib/sql/ddl.sql
10
- -- VERSION: 1.0.0
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
  --