letopis 0.5.0 → 0.16.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.16.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,404 @@
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 jsonb NOT NULL DEFAULT '[]', -- МАССИВ ОБЪЕКТОВ-концов: {"class":…}/{"classes":[…],"optional":…,"cardinality":…} (союзы ролей, матчинг по порядку); legacy-элемент-строка "Org" ("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
+ -- Формат v2 (элемент — JSON-объект '{"class":…}' / '{"classes":[…]}'):
137
+ -- жадный матчинг по порядку объявления, optional-концы могут отсутствовать,
138
+ -- связи вне объявленных концов — ошибка.
139
+ -- Legacy (элемент — имя класса): конец обязателен по имени, 'Entity' —
140
+ -- полиморфный (его резолвит либа), лишние связи не проверяются.
141
+ -- Tombstone-версии (deleted IS NOT NULL) не валидируются.
142
+ -- -----------------------------------------------------------------------------
143
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_check() RETURNS trigger
144
+ LANGUAGE plpgsql AS $$
145
+ DECLARE
146
+ s RECORD;
147
+ bad text;
148
+ k text;
149
+ used text[];
150
+ ej jsonb;
151
+ ecs text[];
152
+ hit text;
153
+ c2 text;
154
+ is_v2 boolean;
155
+ BEGIN
156
+ IF NEW.deleted IS NOT NULL THEN
157
+ RETURN NEW;
158
+ END IF;
159
+
160
+ SELECT * INTO s
161
+ FROM "<SCHEMA-NAME>"."Schema"
162
+ WHERE partition = NEW.partition AND id = NEW.class;
163
+
164
+ IF NOT FOUND THEN
165
+ RAISE EXCEPTION 'ENTITY_CHECK: unknown class "%" (partition "%")', NEW.class, NEW.partition;
166
+ END IF;
167
+
168
+ IF s.meta->>'abstract' = 'true' THEN
169
+ RAISE EXCEPTION 'ENTITY_CHECK: class "%" is abstract', NEW.class;
170
+ END IF;
171
+
172
+ -- ключи links — существующие классы, значения — строки
173
+ SELECT e.key INTO bad
174
+ FROM jsonb_each(COALESCE(NEW.links, '{}'::jsonb)) e
175
+ WHERE jsonb_typeof(e.value) <> 'string'
176
+ OR NOT EXISTS (
177
+ SELECT 1 FROM "<SCHEMA-NAME>"."Schema" c
178
+ WHERE c.partition = NEW.partition AND c.id = e.key)
179
+ LIMIT 1;
180
+
181
+ IF bad IS NOT NULL THEN
182
+ RAISE EXCEPTION 'ENTITY_CHECK: links key "%" is not a class or its value is not an id string', bad;
183
+ END IF;
184
+
185
+ -- LINK: концы по Schema.links — jsonb-массив; элемент-объект = v2 (строгий жадный
186
+ -- матчинг по порядку), элемент-строка = legacy-имя класса
187
+ IF s.category = 'LINK' THEN
188
+ is_v2 := EXISTS (
189
+ SELECT 1 FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le
190
+ WHERE jsonb_typeof(le.value) = 'object');
191
+ IF is_v2 THEN
192
+ used := ARRAY[]::text[];
193
+ FOR ej IN SELECT le.value FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le LOOP
194
+ ecs := COALESCE(
195
+ (SELECT array_agg(x) FROM jsonb_array_elements_text(ej->'classes') x),
196
+ ARRAY[ej->>'class']);
197
+ hit := NULL;
198
+ FOREACH c2 IN ARRAY ecs LOOP
199
+ IF (COALESCE(NEW.links, '{}'::jsonb) ? c2) AND NOT (c2 = ANY(used)) THEN
200
+ hit := c2;
201
+ EXIT;
202
+ END IF;
203
+ END LOOP;
204
+ IF hit IS NOT NULL THEN
205
+ used := used || hit;
206
+ ELSIF NOT COALESCE((ej->>'optional')::boolean, false) THEN
207
+ RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, array_to_string(ecs, '|');
208
+ END IF;
209
+ END LOOP;
210
+ SELECT e.key INTO bad
211
+ FROM jsonb_each(COALESCE(NEW.links, '{}'::jsonb)) e
212
+ WHERE NOT (e.key = ANY(used))
213
+ LIMIT 1;
214
+ IF bad IS NOT NULL THEN
215
+ RAISE EXCEPTION 'ENTITY_CHECK: link "%" has stray link "%" — not an end in Schema.links', NEW.class, bad;
216
+ END IF;
217
+ ELSE
218
+ FOR k IN SELECT le.value #>> '{}' FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le LOOP
219
+ IF k <> 'Entity' AND NOT (COALESCE(NEW.links, '{}'::jsonb) ? k) THEN
220
+ RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, k;
221
+ END IF;
222
+ END LOOP;
223
+ END IF;
224
+ END IF;
225
+
226
+ RETURN NEW;
227
+ END $$;
228
+
229
+ DROP TRIGGER IF EXISTS entity_check ON "<SCHEMA-NAME>"."Entity";
230
+ CREATE TRIGGER entity_check
231
+ BEFORE INSERT ON "<SCHEMA-NAME>"."Entity"
232
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_check();
233
+
234
+ -- -----------------------------------------------------------------------------
235
+ -- Триггер 2: UPDATE = новая версия. Физического UPDATE не происходит:
236
+ -- * бьёт только АКТУАЛЬНУЮ строку (не-latest молча игнорируются, история неизменна);
237
+ -- * вместо апдейта вставляется новая версия с монотонным updated;
238
+ -- * вставка проходит entity_check (валидация класса/links).
239
+ -- Работает и для голого SQL: UPDATE "<SCHEMA-NAME>"."Entity" SET data=… WHERE …
240
+ -- -----------------------------------------------------------------------------
241
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_update() RETURNS trigger
242
+ LANGUAGE plpgsql AS $$
243
+ DECLARE latest timestamptz;
244
+ BEGIN
245
+ SELECT max(updated) INTO latest FROM "<SCHEMA-NAME>"."Entity"
246
+ WHERE partition = OLD.partition AND class = OLD.class AND id = OLD.id;
247
+ IF OLD.updated <> latest THEN
248
+ RETURN NULL; -- историческая версия неприкосновенна
249
+ END IF;
250
+
251
+ INSERT INTO "<SCHEMA-NAME>"."Entity"
252
+ (partition, id, class, data, links, tags, account, owner, updated, deleted)
253
+ VALUES
254
+ (NEW.partition, NEW.id, NEW.class, NEW.data, NEW.links, NEW.tags, NEW.account, NEW.owner,
255
+ GREATEST(clock_timestamp(), OLD.updated + interval '1 microsecond'), NEW.deleted);
256
+ RETURN NULL; -- физический UPDATE отменён
257
+ END $$;
258
+
259
+ DROP TRIGGER IF EXISTS entity_no_update ON "<SCHEMA-NAME>"."Entity";
260
+ DROP TRIGGER IF EXISTS entity_update ON "<SCHEMA-NAME>"."Entity";
261
+ CREATE TRIGGER entity_update
262
+ BEFORE UPDATE ON "<SCHEMA-NAME>"."Entity"
263
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_update();
264
+
265
+ -- -----------------------------------------------------------------------------
266
+ -- Триггер 3: DELETE = tombstone + РЕКУРСИВНЫЙ каскад по links — целиком в БД.
267
+ -- * бьёт только актуальную ЖИВУЮ строку (история и tombstone неприкосновенны);
268
+ -- * advisory-lock сериализует параллельные удаления сущности;
269
+ -- * каскад: DELETE живых зависимых (links ⊃ {класс: id}) → рекурсия этого же триггера;
270
+ -- * физического удаления не происходит никогда (очистка истории — retention-политики).
271
+ -- Работает и для голого SQL: DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE partition=… AND class=… AND id=…
272
+ -- -----------------------------------------------------------------------------
273
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_delete() RETURNS trigger
274
+ LANGUAGE plpgsql AS $$
275
+ DECLARE latest timestamptz;
276
+ BEGIN
277
+ PERFORM pg_advisory_xact_lock(
278
+ hashtextextended(OLD.partition || '|' || OLD.class || '|' || OLD.id, 0));
279
+
280
+ SELECT max(updated) INTO latest FROM "<SCHEMA-NAME>"."Entity"
281
+ WHERE partition = OLD.partition AND class = OLD.class AND id = OLD.id;
282
+ IF OLD.updated <> latest OR OLD.deleted IS NOT NULL THEN
283
+ RETURN NULL; -- не актуальная или уже tombstone: игнор
284
+ END IF;
285
+
286
+ -- tombstone-версия
287
+ INSERT INTO "<SCHEMA-NAME>"."Entity"
288
+ (partition, id, class, data, links, tags, account, owner, updated, deleted)
289
+ VALUES
290
+ (OLD.partition, OLD.id, OLD.class, OLD.data, OLD.links, OLD.tags, OLD.account, OLD.owner,
291
+ GREATEST(clock_timestamp(), OLD.updated + interval '1 microsecond'), clock_timestamp());
292
+
293
+ -- каскад: живые зависимые; их DELETE рекурсивно повторит этот триггер
294
+ DELETE FROM "<SCHEMA-NAME>"."Entity" e
295
+ WHERE e.partition = OLD.partition
296
+ AND e.deleted IS NULL
297
+ AND e.links @> jsonb_build_object(OLD.class, OLD.id);
298
+
299
+ RETURN NULL; -- физическое удаление отменено
300
+ END $$;
301
+
302
+ DROP TRIGGER IF EXISTS entity_delete ON "<SCHEMA-NAME>"."Entity";
303
+ CREATE TRIGGER entity_delete
304
+ BEFORE DELETE ON "<SCHEMA-NAME>"."Entity"
305
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_delete();
306
+
307
+ -- =============================================================================
308
+ -- Служебные таблицы (auth/ACL) — перенесены из legacy-дампа 1:1.
309
+ -- Обычные таблицы: UPDATE/DELETE разрешены, версии не ведутся.
310
+ -- =============================================================================
311
+
312
+ -- Аккаунты: пользователи и сервисы. Категории: System | User | Anonymous | Shadow…
313
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Account" (
314
+ id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
315
+ categories character varying[] DEFAULT ARRAY['User'::text] NOT NULL,
316
+ data jsonb DEFAULT '{}'::jsonb NOT NULL,
317
+ meta jsonb DEFAULT '{}'::jsonb NOT NULL,
318
+ avatar character varying(255) DEFAULT ('https://i.pravatar.cc/128?img='::text || (floor(((70)::double precision * random())) + (1)::double precision)) NOT NULL,
319
+ enabled boolean DEFAULT true NOT NULL,
320
+ created timestamptz DEFAULT now() NOT NULL,
321
+ updated timestamptz DEFAULT now() NOT NULL
322
+ );
323
+
324
+ -- Ссылочная целостность Entity → Account. ТОЛЬКО RESTRICT:
325
+ -- CASCADE/SET NULL невозможны — Entity append-only (физические UPDATE/DELETE запрещены триггером).
326
+ -- Аккаунт со строками Entity (включая историю/tombstone) не удаляется — вместо этого enabled = false.
327
+ ALTER TABLE "<SCHEMA-NAME>"."Entity" DROP CONSTRAINT IF EXISTS entity_account_fk;
328
+ ALTER TABLE "<SCHEMA-NAME>"."Entity" ADD CONSTRAINT entity_account_fk
329
+ FOREIGN KEY (account) REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
330
+ ALTER TABLE "<SCHEMA-NAME>"."Entity" DROP CONSTRAINT IF EXISTS entity_owner_fk;
331
+ ALTER TABLE "<SCHEMA-NAME>"."Entity" ADD CONSTRAINT entity_owner_fk
332
+ FOREIGN KEY (owner) REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
333
+
334
+ -- Способы входа аккаунта: EMAILPASSWORD | APIKEY | KEYSECRET | TELEGRAM …
335
+ -- Мягкое удаление колонкой deleted; уникальность способа на аккаунт.
336
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Credential" (
337
+ id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
338
+ account uuid NOT NULL REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE CASCADE ON DELETE CASCADE,
339
+ category character varying(255) DEFAULT 'EMAIL'::character varying NOT NULL,
340
+ identifier character varying(255) NOT NULL,
341
+ meta jsonb DEFAULT '{}'::jsonb NOT NULL,
342
+ confirmed boolean DEFAULT true NOT NULL,
343
+ created timestamptz DEFAULT now() NOT NULL,
344
+ updated timestamptz DEFAULT now() NOT NULL,
345
+ deleted timestamptz,
346
+ CONSTRAINT credential_unique UNIQUE (account, category, identifier)
347
+ );
348
+ CREATE INDEX IF NOT EXISTS credential_account_idx ON "<SCHEMA-NAME>"."Credential" (account) WHERE deleted IS NULL;
349
+ -- Вход по identifier должен быть однозначен: один живой кред на (category, identifier) во всей схеме.
350
+ CREATE UNIQUE INDEX IF NOT EXISTS credential_identity_udx ON "<SCHEMA-NAME>"."Credential" (category, identifier) WHERE deleted IS NULL;
351
+
352
+ -- Именованные ресурсы ACL: субъекты ('authenticated:ACCOUNT') и объекты ('payment:API').
353
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Resource" (
354
+ alias character varying(255) NOT NULL PRIMARY KEY,
355
+ category character varying(255) NOT NULL,
356
+ pattern jsonb,
357
+ meta jsonb
358
+ );
359
+
360
+ -- Правила ACL: субъект → объект → allow|deny (+вес). Оба конца — Resource.alias (как в дампе).
361
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Rule" (
362
+ account character varying(255) NOT NULL REFERENCES "<SCHEMA-NAME>"."Resource"(alias) ON UPDATE CASCADE ON DELETE CASCADE,
363
+ resource character varying(255) NOT NULL REFERENCES "<SCHEMA-NAME>"."Resource"(alias) ON UPDATE CASCADE ON DELETE CASCADE,
364
+ permission character varying(255) NOT NULL,
365
+ weight integer,
366
+ meta jsonb,
367
+ enabled boolean DEFAULT true NOT NULL,
368
+ PRIMARY KEY (account, resource)
369
+ );
370
+
371
+ -- -----------------------------------------------------------------------------
372
+ -- Триггер 4: realtime — факт каждой новой версии в pg_notify (канал = имя схемы).
373
+ -- Payload лёгкий (без data): подписчик дочитывает нужное сам. Потребитель: db.watch().
374
+ -- -----------------------------------------------------------------------------
375
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_notify() RETURNS trigger
376
+ LANGUAGE plpgsql AS $$
377
+ BEGIN
378
+ -- канал = имя схемы ЛИТЕРАЛОМ: триггер исполняется на чанке hypertable,
379
+ -- где TG_TABLE_SCHEMA = '_timescaledb_internal' (apply.mjs заменяет литерал)
380
+ PERFORM pg_notify('<SCHEMA-NAME>', json_build_object(
381
+ 'partition', NEW.partition, 'class', NEW.class, 'id', NEW.id,
382
+ 'updated', NEW.updated, 'deleted', NEW.deleted IS NOT NULL)::text);
383
+ RETURN NULL;
384
+ END $$;
385
+
386
+ DROP TRIGGER IF EXISTS entity_notify ON "<SCHEMA-NAME>"."Entity";
387
+ CREATE TRIGGER entity_notify
388
+ AFTER INSERT ON "<SCHEMA-NAME>"."Entity"
389
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_notify();
390
+
391
+ -- -----------------------------------------------------------------------------
392
+ -- Опционально (включать по мере роста данных):
393
+ --
394
+ -- Сжатие старых чанков (экономия на версиях занятости/записей):
395
+ -- ALTER TABLE "<SCHEMA-NAME>"."Entity" SET (
396
+ -- timescaledb.compress,
397
+ -- timescaledb.compress_segmentby = 'partition, class, id',
398
+ -- timescaledb.compress_orderby = 'updated DESC'
399
+ -- );
400
+ -- SELECT add_compression_policy('"<SCHEMA-NAME>"."Entity"', INTERVAL '30 days');
401
+ --
402
+ -- Retention (если старые версии не нужны вечно):
403
+ -- SELECT add_retention_policy('"<SCHEMA-NAME>"."Entity"', INTERVAL '2 years');
404
+ -- -----------------------------------------------------------------------------
@@ -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,69 @@
1
+ -- =============================================================================
2
+ -- Сид таблицы Schema из schema.booking.v2.json (источник правды — JSON).
3
+ -- Файл сгенерирован: node lib/scripts/gen-seed.mjs — не редактировать вручную.
4
+ -- Идемпотентен (ON CONFLICT). Концы связей — Schema.links v2 (JSON-объекты в text[]).
5
+ -- =============================================================================
6
+
7
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
8
+ ('entity', 'Entity', 'Сущность', 'HUB', NULL, '{"id":{"type":"uuid","generate":7}}'::jsonb, '{"abstract":true,"description":"Абстрактный корень всех сущностей"}'::jsonb, '[]'::jsonb, 100, ARRAY['Entity']::text[])
9
+ 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;
10
+
11
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
12
+ ('entity', 'Org', 'Организация', 'HUB', 'Entity', '{"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, '[]'::jsonb, 200, ARRAY['Org', 'Entity']::text[])
13
+ 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;
14
+
15
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
16
+ ('entity', 'Staff', 'Сотрудник', 'HUB', 'Entity', '{"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, '[{"class":"Org","cardinality":1}]'::jsonb, 300, ARRAY['Staff', 'Entity']::text[])
17
+ 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;
18
+
19
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
20
+ ('entity', 'Folder', 'Папка', 'HUB', 'Entity', '{"name":"string","kind":{"type":"enum","values":["services","masters"],"default":"services"}}'::jsonb, '{"abstract":false,"description":"Папка-категория; вложенность — ссылкой на родительскую Папку в links"}'::jsonb, '[{"class":"Org","cardinality":1},{"class":"Folder","optional":true,"cardinality":1}]'::jsonb, 400, ARRAY['Folder', 'Entity']::text[])
21
+ 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;
22
+
23
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
24
+ ('entity', 'Service', 'Услуга', 'HUB', 'Entity', '{"id":{"type":"uuid","generate":5,"from":["Org","name"]},"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, '[{"class":"Org","cardinality":1},{"class":"Folder","optional":true,"cardinality":1}]'::jsonb, 500, ARRAY['Service', 'Entity']::text[])
25
+ 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;
26
+
27
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
28
+ ('entity', 'Complex', 'Комплекс', 'HUB', 'Entity', '{"id":{"type":"uuid","generate":5,"from":["Org","name"]},"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, '[{"class":"Org","cardinality":1},{"class":"Folder","optional":true,"cardinality":1}]'::jsonb, 600, ARRAY['Complex', 'Entity']::text[])
29
+ 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;
30
+
31
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
32
+ ('entity', 'Customer', 'Клиент', 'HUB', 'Entity', '{"name":"string|optional","contact":"string|optional","language":{"type":"enum","values":["ru","en"],"default":"ru"},"active":{"type":"boolean","default":true}}'::jsonb, '{"abstract":false,"description":"Клиент — записывается на услуги"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 700, ARRAY['Customer', 'Entity']::text[])
33
+ 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;
34
+
35
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
36
+ ('entity', 'Schedule', 'Расписание', 'HUB', 'Entity', '{"start":{"type":"date","convert":true},"end":{"type":"date","convert":true},"active":{"type":"boolean","default":true}}'::jsonb, '{"abstract":false,"description":"Расписание — период организации, содержит непересекающиеся Окна (инвариант проверяет приложение)"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 800, ARRAY['Schedule', 'Entity']::text[])
37
+ 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;
38
+
39
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
40
+ ('entity', 'Slot', 'Окно', 'HUB', 'Entity', '{"id":{"type":"uuid","generate":5,"from":["Schedule","start"]},"start":{"type":"date","convert":true},"end":{"type":"date","convert":true}}'::jsonb, '{"abstract":false,"description":"Окно — общий таймлайн произвольной длины внутри Расписания; статуса нет — занятость мастера хранит LINK занятость"}'::jsonb, '[{"class":"Schedule","cardinality":1}]'::jsonb, 900, ARRAY['Slot', 'Entity']::text[])
41
+ 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;
42
+
43
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
44
+ ('entity', 'Booking', 'Запись', 'HUB', 'Entity', '{"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, '[{"class":"Customer","optional":true,"cardinality":1}]'::jsonb, 1000, ARRAY['Booking', 'Entity']::text[])
45
+ 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;
46
+
47
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
48
+ ('entity', 'link', 'связь', 'LINK', NULL, '{"id":{"type":"uuid","generate":7}}'::jsonb, '{"abstract":true,"description":"Абстрактная связь; концы объявляются потомками в links (Schema.links v2: объекты-концы)"}'::jsonb, '[]'::jsonb, 100, ARRAY['link']::text[])
49
+ 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;
50
+
51
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
52
+ ('entity', 'item', 'позиция', 'LINK', 'link', '{"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":"Позиция заказа: Запись × предмет [Услуга|Комплекс] × Сотрудник?. Снимок цены в price. Состав комплекса — отдельный LINK «состав» (compo)"}'::jsonb, '[{"classes":["Booking"],"cardinality":1},{"classes":["Service","Complex"],"cardinality":1},{"class":"Staff","optional":true,"cardinality":1}]'::jsonb, 200, ARRAY['item', 'link']::text[])
53
+ 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;
54
+
55
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
56
+ ('entity', 'compo', 'состав', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Complex","Service"]},"qty":"number|integer|min:1|default:1"}'::jsonb, '{"abstract":false,"description":"Состав комплекса: Комплекс ← Услуга (+qty). Раньше жил на позиции — вынесен отдельным классом"}'::jsonb, '[{"class":"Complex","cardinality":1},{"class":"Service","cardinality":1}]'::jsonb, 250, ARRAY['compo', 'link']::text[])
57
+ 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;
58
+
59
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
60
+ ('entity', 'skill', 'навык', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Staff","Service|Complex"]}}'::jsonb, '{"abstract":false,"description":"Навык: Сотрудник умеет (Услуга|Комплекс); при записи проверяется"}'::jsonb, '[{"class":"Staff","cardinality":1},{"classes":["Service","Complex"],"cardinality":1}]'::jsonb, 300, ARRAY['skill', 'link']::text[])
61
+ 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;
62
+
63
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
64
+ ('entity', 'shift', 'смена', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Schedule","Staff"]}}'::jsonb, '{"abstract":false,"description":"Ростер: сотрудник работает в расписании ⇒ по умолчанию доступен во всех его окнах"}'::jsonb, '[{"class":"Schedule","cardinality":1},{"class":"Staff","cardinality":1}]'::jsonb, 400, ARRAY['shift', 'link']::text[])
65
+ 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;
66
+
67
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
68
+ ('entity', 'busy', 'занятость', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Slot","Staff"]},"kind":{"type":"enum","values":["off","hold","booking"],"default":"booking"},"reason":"string|optional"}'::jsonb, '{"abstract":false,"description":"Негативная доступность: свободно = НЕТ живой занятости. off — мастер убран из окна; hold — оформление; booking — бронь (+ключ Запись в links). id = uuidv5(Окно, Сотрудник) — считает схема (attributes.id) — детерминирован против двойной брони"}'::jsonb, '[{"class":"Staff","cardinality":1},{"class":"Slot","cardinality":1},{"class":"Booking","optional":true,"cardinality":1}]'::jsonb, 500, ARRAY['busy', 'link']::text[])
69
+ 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;