letopis 0.5.0 → 0.13.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.
@@ -0,0 +1,17 @@
1
+ # Dev-контейнер letopis: TimescaleDB + Redis в одном контейнере (решение проекта).
2
+ # Обычный путь — letopis.up() собирает и запускает сам. Руками:
3
+ # docker build -t letopis-db lib/docker
4
+ # docker run -d --name letopis-timescale -p 15432:5432 -p 16379:6379 \
5
+ # -v letopis-pgdata:/var/lib/postgresql/data \
6
+ # -e POSTGRES_PASSWORD=test -e POSTGRES_DB=letopis letopis-db
7
+ FROM timescale/timescaledb:latest-pg17
8
+
9
+ RUN apk add --no-cache redis
10
+
11
+ COPY start.sh /usr/local/bin/start-letopis.sh
12
+ RUN chmod +x /usr/local/bin/start-letopis.sh
13
+
14
+ EXPOSE 5432 6379
15
+
16
+ ENTRYPOINT ["/usr/local/bin/start-letopis.sh"]
17
+ CMD ["postgres"]
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ # Redis фоном (dev: без пароля и персиста — сессии эфемерны), затем штатный entrypoint postgres.
3
+ redis-server --port 6379 --bind 0.0.0.0 --protected-mode no --save '' --appendonly no --daemonize yes
4
+ exec docker-entrypoint.sh "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "0.5.0",
3
+ "version": "0.13.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",
@@ -17,6 +17,13 @@
17
17
  "jsonb"
18
18
  ],
19
19
  "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/alepri51/letopis.git",
23
+ "directory": "lib"
24
+ },
25
+ "homepage": "https://github.com/alepri51/letopis/tree/main/lib#readme",
26
+ "bugs": "https://github.com/alepri51/letopis/issues",
20
27
  "type": "module",
21
28
  "main": "dist/index.js",
22
29
  "types": "dist/index.d.ts",
@@ -26,7 +33,13 @@
26
33
  "import": "./dist/index.js"
27
34
  }
28
35
  },
29
- "files": ["dist", "README.md", "CHANGELOG.md"],
36
+ "files": [
37
+ "dist",
38
+ "sql",
39
+ "docker",
40
+ "README.md",
41
+ "CHANGELOG.md"
42
+ ],
30
43
  "scripts": {
31
44
  "build": "tsc",
32
45
  "typecheck": "tsc --noEmit",
@@ -40,6 +53,7 @@
40
53
  },
41
54
  "devDependencies": {
42
55
  "@types/node": "^22.10.0",
56
+ "ioredis": "^5.11.1",
43
57
  "tsx": "^4.19.0",
44
58
  "typescript": "^5.7.0"
45
59
  }
package/sql/ddl.sql ADDED
@@ -0,0 +1,362 @@
1
+ -- =============================================================================
2
+ -- Движок-хранилище: TimescaleDB, append-only.
3
+ -- В БД только: структура таблиц + индексы + триггеры ЦЕЛОСТНОСТИ.
4
+ -- Вся логика приложения (версии, каскады, локи, валидация data) — в либе letopis.
5
+ -- Идемпотентен: можно применять повторно.
6
+ -- "<SCHEMA-NAME>" — маркер: letopis.up() / db/apply.mjs подставляет полное имя схемы vN.<имя>.
7
+ -- =============================================================================
8
+
9
+ CREATE EXTENSION IF NOT EXISTS timescaledb;
10
+
11
+ CREATE SCHEMA IF NOT EXISTS "<SCHEMA-NAME>";
12
+
13
+ -- -----------------------------------------------------------------------------
14
+ -- Schema: классы предметной области (HUB — сущности, LINK — связи).
15
+ -- Сидится из schema.booking.v2.json (lib/sql/seed.booking.sql).
16
+ -- -----------------------------------------------------------------------------
17
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
18
+ partition text NOT NULL DEFAULT 'entity',
19
+ id text NOT NULL, -- англ. id класса: 'Staff', 'skill'
20
+ alias text NOT NULL, -- рус. алиас: 'Сотрудник', 'навык'
21
+ category text NOT NULL CHECK (category IN ('HUB', 'LINK')),
22
+ ancestor text, -- прямой родитель (наследование)
23
+ attributes jsonb NOT NULL DEFAULT '{}', -- fastest-validator DSL (валидирует либа)
24
+ links text[] NOT NULL DEFAULT '{}', -- HUB: встраиваемые цели; LINK: обязательные концы ('Entity' = полиморфный)
25
+ meta jsonb NOT NULL DEFAULT '{}', -- {abstract?, description?}
26
+ "order" int NOT NULL DEFAULT 0,
27
+ ancestors text[] NOT NULL DEFAULT '{}', -- [self, parent, …, root] — считает триггер schema_lineage
28
+ descendants text[] NOT NULL DEFAULT '{}', -- все потомки (транзитивно) — считает триггер schema_lineage
29
+ PRIMARY KEY (partition, id)
30
+ );
31
+
32
+ -- -----------------------------------------------------------------------------
33
+ -- Триггер lineage: ancestors + descendants пересчитываются автоматически
34
+ -- при любом INSERT/UPDATE(ancestor,id)/DELETE в Schema (statement-level).
35
+ -- Рекурсивные CTE вверх и вниз; защита от циклов (depth ≤ 32) и от
36
+ -- саморекурсии триггера (pg_trigger_depth).
37
+ -- -----------------------------------------------------------------------------
38
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".schema_lineage() RETURNS trigger
39
+ LANGUAGE plpgsql AS $$
40
+ BEGIN
41
+ IF pg_trigger_depth() > 1 THEN RETURN NULL; END IF;
42
+
43
+ WITH RECURSIVE up AS (
44
+ SELECT s.partition, s.id, s.id AS anc, 0 AS depth FROM "<SCHEMA-NAME>"."Schema" s
45
+ UNION ALL
46
+ SELECT u.partition, u.id, p.ancestor, u.depth + 1
47
+ FROM up u
48
+ JOIN "<SCHEMA-NAME>"."Schema" p ON p.partition = u.partition AND p.id = u.anc
49
+ WHERE p.ancestor IS NOT NULL AND u.depth < 32
50
+ ),
51
+ anc AS (
52
+ SELECT partition, id, array_agg(anc ORDER BY depth) AS ancestors
53
+ FROM up GROUP BY partition, id
54
+ ),
55
+ down AS (
56
+ SELECT s.partition, s.id, s.id AS des, 0 AS depth FROM "<SCHEMA-NAME>"."Schema" s
57
+ UNION ALL
58
+ SELECT d.partition, d.id, c.id, d.depth + 1
59
+ FROM down d
60
+ JOIN "<SCHEMA-NAME>"."Schema" c ON c.partition = d.partition AND c.ancestor = d.des
61
+ WHERE d.depth < 32
62
+ ),
63
+ dsc AS (
64
+ SELECT partition, id,
65
+ COALESCE(array_agg(des ORDER BY depth, des) FILTER (WHERE depth > 0), '{}') AS descendants
66
+ FROM down GROUP BY partition, id
67
+ )
68
+ UPDATE "<SCHEMA-NAME>"."Schema" s
69
+ SET ancestors = anc.ancestors, descendants = dsc.descendants
70
+ FROM anc
71
+ JOIN dsc ON dsc.partition = anc.partition AND dsc.id = anc.id
72
+ WHERE s.partition = anc.partition AND s.id = anc.id
73
+ AND (s.ancestors IS DISTINCT FROM anc.ancestors
74
+ OR s.descendants IS DISTINCT FROM dsc.descendants);
75
+
76
+ RETURN NULL;
77
+ END $$;
78
+
79
+ DROP TRIGGER IF EXISTS schema_lineage ON "<SCHEMA-NAME>"."Schema";
80
+ CREATE TRIGGER schema_lineage
81
+ AFTER INSERT OR UPDATE OF ancestor, id OR DELETE ON "<SCHEMA-NAME>"."Schema"
82
+ FOR EACH STATEMENT EXECUTE FUNCTION "<SCHEMA-NAME>".schema_lineage();
83
+
84
+ -- -----------------------------------------------------------------------------
85
+ -- Entity: единое хранилище сущностей и связей. Append-only hypertable.
86
+ -- Версия = строка; актуальная версия = max(updated); удаление = tombstone (deleted).
87
+ -- links = {"Класс": "id", ...} — произвольное число ссылок в одной строке.
88
+ -- -----------------------------------------------------------------------------
89
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Entity" (
90
+ partition text NOT NULL,
91
+ id text NOT NULL,
92
+ class text NOT NULL,
93
+ data jsonb NOT NULL DEFAULT '{}',
94
+ updated timestamptz NOT NULL DEFAULT clock_timestamp(),
95
+ account uuid NOT NULL, -- арендатор; default подставляет либа (connect.account → System)
96
+ tags text[] NOT NULL DEFAULT '{}',
97
+ owner uuid NOT NULL, -- владелец; default = account
98
+ links jsonb NOT NULL DEFAULT '{}',
99
+ deleted timestamptz,
100
+ PRIMARY KEY (partition, class, id, updated),
101
+ -- ссылочная целостность класса: Entity(partition, class) → Schema(partition, id).
102
+ -- Timescale поддерживает FK hypertable → обычная таблица.
103
+ -- RESTRICT: класс с данными нельзя удалить из Schema (проверка через префикс entity_latest_idx).
104
+ CONSTRAINT entity_class_fk FOREIGN KEY (partition, class)
105
+ REFERENCES "<SCHEMA-NAME>"."Schema"(partition, id)
106
+ ON UPDATE CASCADE ON DELETE RESTRICT
107
+ );
108
+
109
+ -- Партиционирование: ТОЛЬКО время updated (чанки по 30 дней). Hash-размерности
110
+ -- (partition/class/id) убраны по результатам bench/dimensions.bench.mjs (1.1M строк,
111
+ -- год истории): точечный доступ равен PK-btree, простые фильтры −10-20%, зато
112
+ -- цепочки LATERAL ×2…×140 медленнее (93 чанка/год против 14) и планирование дороже.
113
+ -- Локальность версий сущности даёт компрессия segmentby (db/policies.mjs), не hash.
114
+ SELECT create_hypertable(
115
+ '"<SCHEMA-NAME>"."Entity"', 'updated',
116
+ chunk_time_interval => INTERVAL '30 days',
117
+ if_not_exists => TRUE,
118
+ migrate_data => TRUE
119
+ );
120
+
121
+ -- Индексы: «последняя версия», обход графа, фильтры, теги, арендатор/владелец.
122
+ -- account/owner — полные (не partial): нужны и фильтрам $account/$owner, и RI-сканам FK.
123
+ CREATE INDEX IF NOT EXISTS entity_latest_idx ON "<SCHEMA-NAME>"."Entity" (partition, class, id, updated DESC);
124
+ CREATE INDEX IF NOT EXISTS entity_links_idx ON "<SCHEMA-NAME>"."Entity" USING gin (links jsonb_path_ops);
125
+ CREATE INDEX IF NOT EXISTS entity_data_idx ON "<SCHEMA-NAME>"."Entity" USING gin (data jsonb_path_ops);
126
+ CREATE INDEX IF NOT EXISTS entity_tags_idx ON "<SCHEMA-NAME>"."Entity" USING gin (tags);
127
+ CREATE INDEX IF NOT EXISTS entity_account_idx ON "<SCHEMA-NAME>"."Entity" (account);
128
+ CREATE INDEX IF NOT EXISTS entity_owner_idx ON "<SCHEMA-NAME>"."Entity" (owner);
129
+
130
+ -- -----------------------------------------------------------------------------
131
+ -- Триггер 1: целостность при INSERT (только проверки, никакой логики приложения).
132
+ -- * класс существует в Schema той же partition;
133
+ -- * класс не абстрактный;
134
+ -- * каждый ключ links — существующий класс, значение — строка (id);
135
+ -- * LINK содержит все обязательные концы из Schema.links
136
+ -- ('Entity' — полиморфный конец, его резолвит либа).
137
+ -- Tombstone-версии (deleted IS NOT NULL) не валидируются.
138
+ -- -----------------------------------------------------------------------------
139
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_check() RETURNS trigger
140
+ LANGUAGE plpgsql AS $$
141
+ DECLARE
142
+ s RECORD;
143
+ bad text;
144
+ k text;
145
+ BEGIN
146
+ IF NEW.deleted IS NOT NULL THEN
147
+ RETURN NEW;
148
+ END IF;
149
+
150
+ SELECT * INTO s
151
+ FROM "<SCHEMA-NAME>"."Schema"
152
+ WHERE partition = NEW.partition AND id = NEW.class;
153
+
154
+ IF NOT FOUND THEN
155
+ RAISE EXCEPTION 'ENTITY_CHECK: unknown class "%" (partition "%")', NEW.class, NEW.partition;
156
+ END IF;
157
+
158
+ IF s.meta->>'abstract' = 'true' THEN
159
+ RAISE EXCEPTION 'ENTITY_CHECK: class "%" is abstract', NEW.class;
160
+ END IF;
161
+
162
+ -- ключи links — существующие классы, значения — строки
163
+ SELECT e.key INTO bad
164
+ FROM jsonb_each(COALESCE(NEW.links, '{}'::jsonb)) e
165
+ WHERE jsonb_typeof(e.value) <> 'string'
166
+ OR NOT EXISTS (
167
+ SELECT 1 FROM "<SCHEMA-NAME>"."Schema" c
168
+ WHERE c.partition = NEW.partition AND c.id = e.key)
169
+ LIMIT 1;
170
+
171
+ IF bad IS NOT NULL THEN
172
+ RAISE EXCEPTION 'ENTITY_CHECK: links key "%" is not a class or its value is not an id string', bad;
173
+ END IF;
174
+
175
+ -- LINK: все объявленные концы обязаны присутствовать
176
+ IF s.category = 'LINK' THEN
177
+ FOREACH k IN ARRAY COALESCE(s.links, '{}') LOOP
178
+ IF k <> 'Entity' AND NOT (COALESCE(NEW.links, '{}'::jsonb) ? k) THEN
179
+ RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, k;
180
+ END IF;
181
+ END LOOP;
182
+ END IF;
183
+
184
+ RETURN NEW;
185
+ END $$;
186
+
187
+ DROP TRIGGER IF EXISTS entity_check ON "<SCHEMA-NAME>"."Entity";
188
+ CREATE TRIGGER entity_check
189
+ BEFORE INSERT ON "<SCHEMA-NAME>"."Entity"
190
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_check();
191
+
192
+ -- -----------------------------------------------------------------------------
193
+ -- Триггер 2: UPDATE = новая версия. Физического UPDATE не происходит:
194
+ -- * бьёт только АКТУАЛЬНУЮ строку (не-latest молча игнорируются, история неизменна);
195
+ -- * вместо апдейта вставляется новая версия с монотонным updated;
196
+ -- * вставка проходит entity_check (валидация класса/links).
197
+ -- Работает и для голого SQL: UPDATE "<SCHEMA-NAME>"."Entity" SET data=… WHERE …
198
+ -- -----------------------------------------------------------------------------
199
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_update() RETURNS trigger
200
+ LANGUAGE plpgsql AS $$
201
+ DECLARE latest timestamptz;
202
+ BEGIN
203
+ SELECT max(updated) INTO latest FROM "<SCHEMA-NAME>"."Entity"
204
+ WHERE partition = OLD.partition AND class = OLD.class AND id = OLD.id;
205
+ IF OLD.updated <> latest THEN
206
+ RETURN NULL; -- историческая версия неприкосновенна
207
+ END IF;
208
+
209
+ INSERT INTO "<SCHEMA-NAME>"."Entity"
210
+ (partition, id, class, data, links, tags, account, owner, updated, deleted)
211
+ VALUES
212
+ (NEW.partition, NEW.id, NEW.class, NEW.data, NEW.links, NEW.tags, NEW.account, NEW.owner,
213
+ GREATEST(clock_timestamp(), OLD.updated + interval '1 microsecond'), NEW.deleted);
214
+ RETURN NULL; -- физический UPDATE отменён
215
+ END $$;
216
+
217
+ DROP TRIGGER IF EXISTS entity_no_update ON "<SCHEMA-NAME>"."Entity";
218
+ DROP TRIGGER IF EXISTS entity_update ON "<SCHEMA-NAME>"."Entity";
219
+ CREATE TRIGGER entity_update
220
+ BEFORE UPDATE ON "<SCHEMA-NAME>"."Entity"
221
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_update();
222
+
223
+ -- -----------------------------------------------------------------------------
224
+ -- Триггер 3: DELETE = tombstone + РЕКУРСИВНЫЙ каскад по links — целиком в БД.
225
+ -- * бьёт только актуальную ЖИВУЮ строку (история и tombstone неприкосновенны);
226
+ -- * advisory-lock сериализует параллельные удаления сущности;
227
+ -- * каскад: DELETE живых зависимых (links ⊃ {класс: id}) → рекурсия этого же триггера;
228
+ -- * физического удаления не происходит никогда (очистка истории — retention-политики).
229
+ -- Работает и для голого SQL: DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE partition=… AND class=… AND id=…
230
+ -- -----------------------------------------------------------------------------
231
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_delete() RETURNS trigger
232
+ LANGUAGE plpgsql AS $$
233
+ DECLARE latest timestamptz;
234
+ BEGIN
235
+ PERFORM pg_advisory_xact_lock(
236
+ hashtextextended(OLD.partition || '|' || OLD.class || '|' || OLD.id, 0));
237
+
238
+ SELECT max(updated) INTO latest FROM "<SCHEMA-NAME>"."Entity"
239
+ WHERE partition = OLD.partition AND class = OLD.class AND id = OLD.id;
240
+ IF OLD.updated <> latest OR OLD.deleted IS NOT NULL THEN
241
+ RETURN NULL; -- не актуальная или уже tombstone: игнор
242
+ END IF;
243
+
244
+ -- tombstone-версия
245
+ INSERT INTO "<SCHEMA-NAME>"."Entity"
246
+ (partition, id, class, data, links, tags, account, owner, updated, deleted)
247
+ VALUES
248
+ (OLD.partition, OLD.id, OLD.class, OLD.data, OLD.links, OLD.tags, OLD.account, OLD.owner,
249
+ GREATEST(clock_timestamp(), OLD.updated + interval '1 microsecond'), clock_timestamp());
250
+
251
+ -- каскад: живые зависимые; их DELETE рекурсивно повторит этот триггер
252
+ DELETE FROM "<SCHEMA-NAME>"."Entity" e
253
+ WHERE e.partition = OLD.partition
254
+ AND e.deleted IS NULL
255
+ AND e.links @> jsonb_build_object(OLD.class, OLD.id);
256
+
257
+ RETURN NULL; -- физическое удаление отменено
258
+ END $$;
259
+
260
+ DROP TRIGGER IF EXISTS entity_delete ON "<SCHEMA-NAME>"."Entity";
261
+ CREATE TRIGGER entity_delete
262
+ BEFORE DELETE ON "<SCHEMA-NAME>"."Entity"
263
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_delete();
264
+
265
+ -- =============================================================================
266
+ -- Служебные таблицы (auth/ACL) — перенесены из legacy-дампа 1:1.
267
+ -- Обычные таблицы: UPDATE/DELETE разрешены, версии не ведутся.
268
+ -- =============================================================================
269
+
270
+ -- Аккаунты: пользователи и сервисы. Категории: System | User | Anonymous | Shadow…
271
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Account" (
272
+ id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
273
+ categories character varying[] DEFAULT ARRAY['User'::text] NOT NULL,
274
+ data jsonb DEFAULT '{}'::jsonb NOT NULL,
275
+ meta jsonb DEFAULT '{}'::jsonb NOT NULL,
276
+ avatar character varying(255) DEFAULT ('https://i.pravatar.cc/128?img='::text || (floor(((70)::double precision * random())) + (1)::double precision)) NOT NULL,
277
+ enabled boolean DEFAULT true NOT NULL,
278
+ created timestamptz DEFAULT now() NOT NULL,
279
+ updated timestamptz DEFAULT now() NOT NULL
280
+ );
281
+
282
+ -- Ссылочная целостность Entity → Account. ТОЛЬКО RESTRICT:
283
+ -- CASCADE/SET NULL невозможны — Entity append-only (физические UPDATE/DELETE запрещены триггером).
284
+ -- Аккаунт со строками Entity (включая историю/tombstone) не удаляется — вместо этого enabled = false.
285
+ ALTER TABLE "<SCHEMA-NAME>"."Entity" DROP CONSTRAINT IF EXISTS entity_account_fk;
286
+ ALTER TABLE "<SCHEMA-NAME>"."Entity" ADD CONSTRAINT entity_account_fk
287
+ FOREIGN KEY (account) REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
288
+ ALTER TABLE "<SCHEMA-NAME>"."Entity" DROP CONSTRAINT IF EXISTS entity_owner_fk;
289
+ ALTER TABLE "<SCHEMA-NAME>"."Entity" ADD CONSTRAINT entity_owner_fk
290
+ FOREIGN KEY (owner) REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
291
+
292
+ -- Способы входа аккаунта: EMAILPASSWORD | APIKEY | KEYSECRET | TELEGRAM …
293
+ -- Мягкое удаление колонкой deleted; уникальность способа на аккаунт.
294
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Credential" (
295
+ id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
296
+ account uuid NOT NULL REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE CASCADE ON DELETE CASCADE,
297
+ category character varying(255) DEFAULT 'EMAIL'::character varying NOT NULL,
298
+ identifier character varying(255) NOT NULL,
299
+ meta jsonb DEFAULT '{}'::jsonb NOT NULL,
300
+ confirmed boolean DEFAULT true NOT NULL,
301
+ created timestamptz DEFAULT now() NOT NULL,
302
+ updated timestamptz DEFAULT now() NOT NULL,
303
+ deleted timestamptz,
304
+ CONSTRAINT credential_unique UNIQUE (account, category, identifier)
305
+ );
306
+ CREATE INDEX IF NOT EXISTS credential_account_idx ON "<SCHEMA-NAME>"."Credential" (account) WHERE deleted IS NULL;
307
+ -- Вход по identifier должен быть однозначен: один живой кред на (category, identifier) во всей схеме.
308
+ CREATE UNIQUE INDEX IF NOT EXISTS credential_identity_udx ON "<SCHEMA-NAME>"."Credential" (category, identifier) WHERE deleted IS NULL;
309
+
310
+ -- Именованные ресурсы ACL: субъекты ('authenticated:ACCOUNT') и объекты ('payment:API').
311
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Resource" (
312
+ alias character varying(255) NOT NULL PRIMARY KEY,
313
+ category character varying(255) NOT NULL,
314
+ pattern jsonb,
315
+ meta jsonb
316
+ );
317
+
318
+ -- Правила ACL: субъект → объект → allow|deny (+вес). Оба конца — Resource.alias (как в дампе).
319
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Rule" (
320
+ account character varying(255) NOT NULL REFERENCES "<SCHEMA-NAME>"."Resource"(alias) ON UPDATE CASCADE ON DELETE CASCADE,
321
+ resource character varying(255) NOT NULL REFERENCES "<SCHEMA-NAME>"."Resource"(alias) ON UPDATE CASCADE ON DELETE CASCADE,
322
+ permission character varying(255) NOT NULL,
323
+ weight integer,
324
+ meta jsonb,
325
+ enabled boolean DEFAULT true NOT NULL,
326
+ PRIMARY KEY (account, resource)
327
+ );
328
+
329
+ -- -----------------------------------------------------------------------------
330
+ -- Триггер 4: realtime — факт каждой новой версии в pg_notify (канал = имя схемы).
331
+ -- Payload лёгкий (без data): подписчик дочитывает нужное сам. Потребитель: db.watch().
332
+ -- -----------------------------------------------------------------------------
333
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_notify() RETURNS trigger
334
+ LANGUAGE plpgsql AS $$
335
+ BEGIN
336
+ -- канал = имя схемы ЛИТЕРАЛОМ: триггер исполняется на чанке hypertable,
337
+ -- где TG_TABLE_SCHEMA = '_timescaledb_internal' (apply.mjs заменяет литерал)
338
+ PERFORM pg_notify('<SCHEMA-NAME>', json_build_object(
339
+ 'partition', NEW.partition, 'class', NEW.class, 'id', NEW.id,
340
+ 'updated', NEW.updated, 'deleted', NEW.deleted IS NOT NULL)::text);
341
+ RETURN NULL;
342
+ END $$;
343
+
344
+ DROP TRIGGER IF EXISTS entity_notify ON "<SCHEMA-NAME>"."Entity";
345
+ CREATE TRIGGER entity_notify
346
+ AFTER INSERT ON "<SCHEMA-NAME>"."Entity"
347
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_notify();
348
+
349
+ -- -----------------------------------------------------------------------------
350
+ -- Опционально (включать по мере роста данных):
351
+ --
352
+ -- Сжатие старых чанков (экономия на версиях занятости/записей):
353
+ -- ALTER TABLE "<SCHEMA-NAME>"."Entity" SET (
354
+ -- timescaledb.compress,
355
+ -- timescaledb.compress_segmentby = 'partition, class, id',
356
+ -- timescaledb.compress_orderby = 'updated DESC'
357
+ -- );
358
+ -- SELECT add_compression_policy('"<SCHEMA-NAME>"."Entity"', INTERVAL '30 days');
359
+ --
360
+ -- Retention (если старые версии не нужны вечно):
361
+ -- SELECT add_retention_policy('"<SCHEMA-NAME>"."Entity"', INTERVAL '2 years');
362
+ -- -----------------------------------------------------------------------------
@@ -0,0 +1,46 @@
1
+ -- =============================================================================
2
+ -- Сид auth/ACL: System-аккаунт + Resource + Rule (перенос из legacy-дампа).
3
+ -- Тест-пользователи и их креденшелы НЕ переносятся.
4
+ -- Идемпотентен (ON CONFLICT).
5
+ -- =============================================================================
6
+
7
+ -- Системный аккаунт (фиксированный id из дампа)
8
+ INSERT INTO "<SCHEMA-NAME>"."Account" (id, categories, data, meta, avatar, enabled) VALUES
9
+ ('2eba6d0a-1edd-4bb6-a85a-a703dab49035', '{System}', '{"id": "System", "name": "System"}', '{}', 'https://i.pravatar.cc/128?img=28', true)
10
+ ON CONFLICT (id) DO NOTHING;
11
+
12
+ -- Ресурсы: субъекты (категории аккаунтов) и объекты (API-эндпоинты)
13
+ INSERT INTO "<SCHEMA-NAME>"."Resource" (alias, category, pattern, meta) VALUES
14
+ ('anonymous:ACCOUNT', 'ACCOUNT', '{"categories": "{Anonymous,Shadow}"}', '{"description": "Unauthenticated users (includes Anonymous and Shadow categories)"}'),
15
+ ('authenticated:ACCOUNT', 'ACCOUNT', '{"categories": "!{Anonymous,Shadow}"}', '{"description": "Authenticated users (excludes Anonymous and Shadow)"}'),
16
+ ('any:ACCOUNT', 'ACCOUNT', NULL, '{"description": "All account categories (no pattern = match all)"}'),
17
+ ('auth.user.password:API', 'API', '{"endpoint": "v2.auth.password.change"}', '{"endpoints": ["v2.auth.password.change"], "description": "Change own password"}'),
18
+ ('auth.user.session:API', 'API', '{"endpoint": "v2.auth.session.signout"}', '{"endpoints": ["v2.auth.session.signout"], "description": "Logout (revoke session)"}'),
19
+ ('auth.user.apikey:API', 'API', '{"endpoint": "v2.auth.apikey.*"}', '{"endpoints": ["v2.auth.apikey.create", "v2.auth.apikey.list", "v2.auth.apikey.revoke"], "description": "Manage own API keys (auto-filtered by account)"}'),
20
+ ('auth.user.basic:API', 'API', '{"endpoint": "v2.auth.basic.*"}', '{"endpoints": ["v2.auth.basic.create", "v2.auth.basic.list", "v2.auth.basic.revoke"], "description": "Manage own Basic Auth credentials (auto-filtered by account)"}'),
21
+ ('any:API', 'API', NULL, '{"description": "All API endpoints (no pattern = match all, for default deny)"}'),
22
+ ('chrono.user.events:API', 'API', '{"endpoint": "v2.chrono.*"}', '{"endpoint": "v2.chrono.*", "description": "User event management (auto-filtered by account)"}'),
23
+ ('auth.anonymous:API', 'API', '{"endpoint": "v2.auth.password.{signup,signin,reset.*,email.*}"}', '{"endpoints": ["v2.auth.password.signup", "v2.auth.password.signin", "v2.auth.password.reset.request", "v2.auth.password.reset.complete", "v2.auth.password.email.verify", "v2.auth.password.email.resend"], "description": "Anonymous auth endpoints (signup, signin, reset, email verification)"}'),
24
+ ('auth.telegram:API', 'API', '{"endpoint": "v2.auth.telegram.{initiate,poll}"}', '{"endpoints": ["v2.auth.telegram.initiate", "v2.auth.telegram.poll"], "description": "Telegram passwordless login (initiate, long polling)"}'),
25
+ ('payment.callback:API', 'API', '{"endpoint": "v2.payment.callback.*"}', '{"endpoints": ["v2.payment.callback.yookassa", "v2.payment.callback.robokassa"], "description": "Payment provider webhooks (YOOKASSA, ROBOKASSA)"}'),
26
+ ('shop:API', 'API', '{"endpoint": "booking.*"}', '{"description": "Booking service endpoints"}'),
27
+ ('payment:API', 'API', '{"endpoint": "v2.payment.*"}', '{"endpoints": ["v2.payment.create"], "description": "Create payment (authenticated users only)"}'),
28
+ ('billing:API', 'API', '{"endpoint": "v2.billing.*"}', NULL),
29
+ ('geo:API', 'API', '{"endpoint": "v2.geo.*"}', NULL)
30
+ ON CONFLICT (alias) DO UPDATE SET category = EXCLUDED.category, pattern = EXCLUDED.pattern, meta = EXCLUDED.meta;
31
+
32
+ -- Правила: субъект → объект → allow|deny (weight — приоритет)
33
+ INSERT INTO "<SCHEMA-NAME>"."Rule" (account, resource, permission, weight, meta, enabled) VALUES
34
+ ('authenticated:ACCOUNT', 'auth.user.password:API', 'allow', 90, '{"description": "Users can change their own password"}', true),
35
+ ('authenticated:ACCOUNT', 'auth.user.session:API', 'allow', 90, '{"description": "Users can logout (revoke their session)"}', true),
36
+ ('authenticated:ACCOUNT', 'auth.user.apikey:API', 'allow', 90, '{"description": "Users can manage their own API keys (service auto-filters by ctx.meta.$account.id)"}', true),
37
+ ('authenticated:ACCOUNT', 'auth.user.basic:API', 'allow', 90, '{"description": "Users can manage their own Basic Auth (service auto-filters by ctx.meta.$account.id)"}', true),
38
+ ('any:ACCOUNT', 'any:API', 'deny', 0, '{"code": 403, "message": "Access denied - default for any ACCOUNT to any API"}', true),
39
+ ('authenticated:ACCOUNT', 'chrono.user.events:API', 'allow', 90, '{"description": "Users can manage their own scheduled events (filtered by ctx.meta.$account.id)"}', true),
40
+ ('any:ACCOUNT', 'auth.anonymous:API', 'allow', 105, '{"description": "Anonymous users can signup, signin, reset password, use OTP"}', true),
41
+ ('anonymous:ACCOUNT', 'auth.telegram:API', 'allow', 105, '{"description": "Anonymous users can login via Telegram (initiate, poll)"}', true),
42
+ ('authenticated:ACCOUNT', 'payment:API', 'allow', 90, '{"description": "Users can create payments (uses ctx.meta.$account.id)"}', true),
43
+ ('authenticated:ACCOUNT', 'shop:API', 'allow', 90, '{"description": "Users can access booking"}', true),
44
+ ('authenticated:ACCOUNT', 'billing:API', 'allow', 90, '{"description": "Users can access billing (uses ctx.meta.$account.id)"}', true),
45
+ ('authenticated:ACCOUNT', 'geo:API', 'allow', 90, '{"description": "Users can access geo (uses ctx.meta.$account.id)"}', true)
46
+ ON CONFLICT (account, resource) DO UPDATE SET permission = EXCLUDED.permission, weight = EXCLUDED.weight, meta = EXCLUDED.meta, enabled = EXCLUDED.enabled;
@@ -0,0 +1,64 @@
1
+ -- =============================================================================
2
+ -- Сид таблицы Schema из schema.booking.v2.json (источник правды — JSON).
3
+ -- Файл сгенерирован, не редактировать вручную. Идемпотентен (ON CONFLICT).
4
+ -- =============================================================================
5
+
6
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
7
+ ('entity', 'Entity', 'Сущность', 'HUB', NULL, '{"id":"any"}'::jsonb, '{"abstract":true,"description":"Абстрактный корень всех сущностей"}'::jsonb, '{}'::text[], 100, '{"Entity"}'::text[])
8
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
9
+
10
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
11
+ ('entity', 'Org', 'Организация', 'HUB', 'Entity', '{"id":"uuid","name":"string","timezone":{"type":"string","default":"Europe/Moscow"},"address":"string|optional","phone":"string|optional","active":{"type":"boolean","default":true},"settings":{"type":"object","optional":true,"strict":true,"props":{"booking":{"type":"object","optional":true,"strict":true,"props":{"deposit":{"type":"object","optional":true,"strict":true,"props":{"amount":"number|integer|min:0|default:0","currency":{"type":"enum","values":["RUB","USD","EUR","AED"],"default":"RUB"}}},"autoconfirm":"boolean|default:false"}}}}}'::jsonb, '{"abstract":false,"description":"Организация / точка / филиал — владелец каталога и расписаний; settings — вложенные настройки (пример структуры глубины 4)"}'::jsonb, '{}'::text[], 200, '{"Org","Entity"}'::text[])
12
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
13
+
14
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
15
+ ('entity', 'Staff', 'Сотрудник', 'HUB', 'Entity', '{"id":"uuid","name":"string","roles":{"type":"array","items":{"type":"enum","values":["owner","administrator","manager","master"]},"default":["master"]},"active":{"type":"boolean","default":true}}'::jsonb, '{"abstract":false,"description":"Человек: роли совмещаются (owner+master). Бронируемый ⇔ master ∈ roles. Владелец = первый сотрудник с owner."}'::jsonb, '{"Org"}'::text[], 300, '{"Staff","Entity"}'::text[])
16
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
17
+
18
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
19
+ ('entity', 'Folder', 'Папка', 'HUB', 'Entity', '{"id":"uuid","name":"string","kind":{"type":"enum","values":["services","masters"],"default":"services"}}'::jsonb, '{"abstract":false,"description":"Папка-категория; вложенность — ссылкой на родительскую Папку в links"}'::jsonb, '{"Org","Folder"}'::text[], 400, '{"Folder","Entity"}'::text[])
20
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
21
+
22
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
23
+ ('entity', 'Service', 'Услуга', 'HUB', 'Entity', '{"id":"uuid","name":"string","description":"string|optional","duration":"number|integer|positive","price":{"type":"record","key":{"type":"enum","values":["RUB","USD","EUR","AED"]},"value":"number|integer|min:0","default":{"RUB":0}},"active":{"type":"boolean","default":true}}'::jsonb, '{"abstract":false,"description":"Услуга: длительность (мин) + цена; занимает столько подряд окон, сколько покрывает duration"}'::jsonb, '{"Org","Folder"}'::text[], 500, '{"Service","Entity"}'::text[])
24
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
25
+
26
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
27
+ ('entity', 'Complex', 'Комплекс', 'HUB', 'Entity', '{"id":"uuid","name":"string","description":"string|optional","duration":"number|integer|positive","discount":"number|integer|default:0","price":{"type":"record","key":{"type":"enum","values":["RUB","USD","EUR","AED"]},"value":"number|integer|min:0","optional":true}}'::jsonb, '{"abstract":false,"description":"Комплекс: собственный duration; состав — позиция(Комплекс→Услуга); свободный текст цены — в description"}'::jsonb, '{"Org","Folder"}'::text[], 600, '{"Complex","Entity"}'::text[])
28
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
29
+
30
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
31
+ ('entity', 'Customer', 'Клиент', 'HUB', 'Entity', '{"id":"uuid","name":"string|optional","contact":"string|optional","language":{"type":"enum","values":["ru","en"],"default":"ru"},"active":{"type":"boolean","default":true}}'::jsonb, '{"abstract":false,"description":"Клиент — записывается на услуги"}'::jsonb, '{"Org"}'::text[], 700, '{"Customer","Entity"}'::text[])
32
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
33
+
34
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
35
+ ('entity', 'Schedule', 'Расписание', 'HUB', 'Entity', '{"id":"uuid","start":{"type":"date","convert":true},"end":{"type":"date","convert":true},"active":{"type":"boolean","default":true}}'::jsonb, '{"abstract":false,"description":"Расписание — период организации, содержит непересекающиеся Окна (инвариант проверяет приложение)"}'::jsonb, '{"Org"}'::text[], 800, '{"Schedule","Entity"}'::text[])
36
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
37
+
38
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
39
+ ('entity', 'Slot', 'Окно', 'HUB', 'Entity', '{"id":"uuid","start":{"type":"date","convert":true},"end":{"type":"date","convert":true}}'::jsonb, '{"abstract":false,"description":"Окно — общий таймлайн произвольной длины внутри Расписания; статуса нет — занятость мастера хранит LINK занятость"}'::jsonb, '{"Schedule"}'::text[], 900, '{"Slot","Entity"}'::text[])
40
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
41
+
42
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
43
+ ('entity', 'Booking', 'Запись', 'HUB', 'Entity', '{"id":"uuid","status":{"type":"enum","values":["created","confirmed","completed","cancelled","no_show"],"default":"created"},"source":{"type":"enum","values":["online","web","admin","telegram"],"default":"online"},"comment":"string|optional","total":{"type":"record","key":{"type":"enum","values":["RUB","USD","EUR","AED"]},"value":"number|integer|min:0","default":{"RUB":0}}}'::jsonb, '{"abstract":false,"description":"Запись клиента. Без мастера (мульти-мастер): мастера — на занятости/позиции. Ручная бронь по имени: без Customer, имя в comment. Дата приёма = start первого окна брони. history: полный аудит версиями"}'::jsonb, '{"Customer"}'::text[], 1000, '{"Booking","Entity"}'::text[])
44
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
45
+
46
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
47
+ ('entity', 'link', 'связь', 'LINK', NULL, '{"id":"any"}'::jsonb, '{"abstract":true,"description":"Абстрактная связь; фактические концы — ключи Entity.links {Класс: id, …}"}'::jsonb, '{"Entity","Entity"}'::text[], 100, '{"link"}'::text[])
48
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
49
+
50
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
51
+ ('entity', 'item', 'позиция', 'LINK', 'link', '{"id":"any","qty":"number|integer|min:1|default:1","price":{"type":"record","key":{"type":"enum","values":["RUB","USD","EUR","AED"]},"value":"number|integer|min:0","optional":true}}'::jsonb, '{"abstract":false,"description":"Полиморфная позиция: {Запись,(Услуга|Комплекс),Сотрудник} — строка записи со снимком цены и исполнителем; {Комплекс,Услуга} — состав комплекса"}'::jsonb, '{"Entity","Entity"}'::text[], 200, '{"item","link"}'::text[])
52
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
53
+
54
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
55
+ ('entity', 'skill', 'навык', 'LINK', 'link', '{"id":"any"}'::jsonb, '{"abstract":false,"description":"Навык: Сотрудник умеет (Услуга|Комплекс); при записи проверяется"}'::jsonb, '{"Staff","Entity"}'::text[], 300, '{"skill","link"}'::text[])
56
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
57
+
58
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
59
+ ('entity', 'shift', 'смена', 'LINK', 'link', '{"id":"any"}'::jsonb, '{"abstract":false,"description":"Ростер: сотрудник работает в расписании ⇒ по умолчанию доступен во всех его окнах"}'::jsonb, '{"Schedule","Staff"}'::text[], 400, '{"shift","link"}'::text[])
60
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;
61
+
62
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
63
+ ('entity', 'busy', 'занятость', 'LINK', 'link', '{"id":"any","kind":{"type":"enum","values":["off","hold","booking"],"default":"booking"},"reason":"string|optional"}'::jsonb, '{"abstract":false,"description":"Негативная доступность: свободно = НЕТ живой занятости. off — мастер убран из окна; hold — оформление; booking — бронь (+ключ Запись в links). id = uuidv5(Сотрудник, Окно) — детерминирован против двойной брони"}'::jsonb, '{"Staff","Slot"}'::text[], 500, '{"busy","link"}'::text[])
64
+ ON CONFLICT (partition, id) DO UPDATE SET alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor, attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order", ancestors = EXCLUDED.ancestors;