letopis 0.13.0 → 0.18.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.
package/docker/Dockerfile CHANGED
@@ -4,6 +4,28 @@
4
4
  # docker run -d --name letopis-timescale -p 15432:5432 -p 16379:6379 \
5
5
  # -v letopis-pgdata:/var/lib/postgresql/data \
6
6
  # -e POSTGRES_PASSWORD=test -e POSTGRES_DB=letopis letopis-db
7
+ # FILE: lib/docker/Dockerfile
8
+ # VERSION: 1.0.0
9
+ # START_MODULE_CONTRACT
10
+ # PURPOSE: Dev-образ БД — TimescaleDB pg17 + Redis в одном контейнере (для letopis.up()).
11
+ # SCOPE: базовый образ, установка redis, копирование start.sh, EXPOSE 5432/6379, entrypoint.
12
+ # DEPENDS: none
13
+ # LINKS: M-DOCKER, V-M-DOCKER
14
+ # ROLE: CONFIG
15
+ # MAP_MODE: SUMMARY
16
+ # END_MODULE_CONTRACT
17
+ #
18
+ # START_MODULE_MAP
19
+ # FROM timescale/timescaledb:latest-pg17 - база (PostgreSQL 17 + TimescaleDB)
20
+ # apk add redis - Redis в тот же образ
21
+ # ENTRYPOINT start-letopis.sh - запуск redis + postgres
22
+ # END_MODULE_MAP
23
+ #
24
+ # START_CHANGE_SUMMARY
25
+ # LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
26
+ # END_CHANGE_SUMMARY
27
+
28
+ # START_BLOCK_IMAGE_BUILD
7
29
  FROM timescale/timescaledb:latest-pg17
8
30
 
9
31
  RUN apk add --no-cache redis
@@ -15,3 +37,4 @@ EXPOSE 5432 6379
15
37
 
16
38
  ENTRYPOINT ["/usr/local/bin/start-letopis.sh"]
17
39
  CMD ["postgres"]
40
+ # END_BLOCK_IMAGE_BUILD
package/docker/start.sh CHANGED
@@ -1,4 +1,18 @@
1
1
  #!/bin/sh
2
+ # FILE: lib/docker/start.sh
3
+ # VERSION: 1.0.0
4
+ # START_MODULE_CONTRACT
5
+ # PURPOSE: Entrypoint dev-контейнера — поднять Redis фоном, затем штатный postgres docker-entrypoint.
6
+ # SCOPE: redis-server (daemonize) + exec docker-entrypoint.sh.
7
+ # DEPENDS: none
8
+ # LINKS: M-DOCKER, V-M-DOCKER
9
+ # ROLE: SCRIPT
10
+ # MAP_MODE: NONE
11
+ # END_MODULE_CONTRACT
12
+ #
13
+ # START_CHANGE_SUMMARY
14
+ # LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
15
+ # END_CHANGE_SUMMARY
2
16
  # Redis фоном (dev: без пароля и персиста — сессии эфемерны), затем штатный entrypoint postgres.
3
17
  redis-server --port 6379 --bind 0.0.0.0 --protected-mode no --save '' --appendonly no --daemonize yes
4
18
  exec docker-entrypoint.sh "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "0.13.0",
3
+ "version": "0.18.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",
package/sql/ddl.sql CHANGED
@@ -6,14 +6,41 @@
6
6
  -- "<SCHEMA-NAME>" — маркер: letopis.up() / db/apply.mjs подставляет полное имя схемы vN.<имя>.
7
7
  -- =============================================================================
8
8
 
9
+ -- FILE: lib/sql/ddl.sql
10
+ -- VERSION: 1.0.0
11
+ -- START_MODULE_CONTRACT
12
+ -- PURPOSE: Движок-хранилище PostgreSQL/TimescaleDB — таблицы, Entity-hypertable, индексы и триггеры целостности (валидация, версионирование, каскад-tombstone, lineage, notify).
13
+ -- SCOPE: таблицы Schema/Entity/Account/Credential/Resource/Rule + триггеры schema_lineage/entity_check/entity_update/entity_delete/entity_notify.
14
+ -- DEPENDS: none
15
+ -- LINKS: M-DDL, V-M-DDL
16
+ -- ROLE: RUNTIME
17
+ -- MAP_MODE: SUMMARY
18
+ -- END_MODULE_CONTRACT
19
+ --
20
+ -- START_MODULE_MAP
21
+ -- Schema - определения классов (HUB/LINK), наследование, attributes/links
22
+ -- Entity - append-only hypertable версий сущностей и связей
23
+ -- Account/Credential/Resource/Rule - служебные таблицы auth/ACL
24
+ -- schema_lineage - пересчёт ancestors/descendants (statement-level)
25
+ -- entity_check - валидация класса и концов LINK при INSERT
26
+ -- entity_update - UPDATE → вставка новой версии
27
+ -- entity_delete - DELETE → tombstone + рекурсивный каскад (advisory-lock)
28
+ -- entity_notify - pg_notify о новой версии (канал = имя схемы)
29
+ -- END_MODULE_MAP
30
+ --
31
+ -- START_CHANGE_SUMMARY
32
+ -- LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
33
+ -- END_CHANGE_SUMMARY
34
+
9
35
  CREATE EXTENSION IF NOT EXISTS timescaledb;
10
36
 
11
37
  CREATE SCHEMA IF NOT EXISTS "<SCHEMA-NAME>";
12
38
 
13
39
  -- -----------------------------------------------------------------------------
14
40
  -- Schema: классы предметной области (HUB — сущности, LINK — связи).
15
- -- Сидится из schema.booking.v2.json (lib/sql/seed.booking.sql).
41
+ -- Сидится демо-доменом booking: lib/sql/seed.booking.sql (источник правды).
16
42
  -- -----------------------------------------------------------------------------
43
+ -- START_BLOCK_TABLE_SCHEMA
17
44
  CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
18
45
  partition text NOT NULL DEFAULT 'entity',
19
46
  id text NOT NULL, -- англ. id класса: 'Staff', 'skill'
@@ -21,13 +48,14 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
21
48
  category text NOT NULL CHECK (category IN ('HUB', 'LINK')),
22
49
  ancestor text, -- прямой родитель (наследование)
23
50
  attributes jsonb NOT NULL DEFAULT '{}', -- fastest-validator DSL (валидирует либа)
24
- links text[] NOT NULL DEFAULT '{}', -- HUB: встраиваемые цели; LINK: обязательные концы ('Entity' = полиморфный)
51
+ links jsonb NOT NULL DEFAULT '[]', -- МАССИВ ОБЪЕКТОВ-концов: {"class":…}/{"classes":[…],"optional":…,"cardinality":…} (союзы ролей, матчинг по порядку); legacy-элемент-строка "Org" ("Entity" = полиморфный)
25
52
  meta jsonb NOT NULL DEFAULT '{}', -- {abstract?, description?}
26
53
  "order" int NOT NULL DEFAULT 0,
27
54
  ancestors text[] NOT NULL DEFAULT '{}', -- [self, parent, …, root] — считает триггер schema_lineage
28
55
  descendants text[] NOT NULL DEFAULT '{}', -- все потомки (транзитивно) — считает триггер schema_lineage
29
56
  PRIMARY KEY (partition, id)
30
57
  );
58
+ -- END_BLOCK_TABLE_SCHEMA
31
59
 
32
60
  -- -----------------------------------------------------------------------------
33
61
  -- Триггер lineage: ancestors + descendants пересчитываются автоматически
@@ -35,6 +63,13 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
35
63
  -- Рекурсивные CTE вверх и вниз; защита от циклов (depth ≤ 32) и от
36
64
  -- саморекурсии триггера (pg_trigger_depth).
37
65
  -- -----------------------------------------------------------------------------
66
+ -- START_CONTRACT: schema_lineage
67
+ -- PURPOSE: Пересчитать ancestors + descendants всех классов при изменении Schema (statement-level; защита от циклов depth≤32 и саморекурсии).
68
+ -- INPUTS: { trigger: AFTER INSERT/UPDATE(ancestor,id)/DELETE ON Schema }
69
+ -- OUTPUTS: { NULL - обновляет колонки ancestors/descendants }
70
+ -- SIDE_EFFECTS: UPDATE Schema.ancestors/descendants
71
+ -- LINKS: M-DDL, V-M-DDL
72
+ -- END_CONTRACT: schema_lineage
38
73
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".schema_lineage() RETURNS trigger
39
74
  LANGUAGE plpgsql AS $$
40
75
  BEGIN
@@ -86,6 +121,7 @@ CREATE TRIGGER schema_lineage
86
121
  -- Версия = строка; актуальная версия = max(updated); удаление = tombstone (deleted).
87
122
  -- links = {"Класс": "id", ...} — произвольное число ссылок в одной строке.
88
123
  -- -----------------------------------------------------------------------------
124
+ -- START_BLOCK_ENTITY_STORAGE
89
125
  CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Entity" (
90
126
  partition text NOT NULL,
91
127
  id text NOT NULL,
@@ -126,22 +162,41 @@ CREATE INDEX IF NOT EXISTS entity_data_idx ON "<SCHEMA-NAME>"."Entity" USING
126
162
  CREATE INDEX IF NOT EXISTS entity_tags_idx ON "<SCHEMA-NAME>"."Entity" USING gin (tags);
127
163
  CREATE INDEX IF NOT EXISTS entity_account_idx ON "<SCHEMA-NAME>"."Entity" (account);
128
164
  CREATE INDEX IF NOT EXISTS entity_owner_idx ON "<SCHEMA-NAME>"."Entity" (owner);
165
+ -- END_BLOCK_ENTITY_STORAGE
129
166
 
130
167
  -- -----------------------------------------------------------------------------
131
168
  -- Триггер 1: целостность при INSERT (только проверки, никакой логики приложения).
132
169
  -- * класс существует в Schema той же partition;
133
170
  -- * класс не абстрактный;
134
171
  -- * каждый ключ links — существующий класс, значение — строка (id);
135
- -- * LINK содержит все обязательные концы из Schema.links
136
- -- ('Entity' — полиморфный конец, его резолвит либа).
172
+ -- * LINK: концы из Schema.links.
173
+ -- Формат v2 (элемент — JSON-объект '{"class":…}' / '{"classes":[…]}'):
174
+ -- жадный матчинг по порядку объявления, optional-концы могут отсутствовать,
175
+ -- связи вне объявленных концов — ошибка.
176
+ -- Legacy (элемент — имя класса): конец обязателен по имени, 'Entity' —
177
+ -- полиморфный (его резолвит либа), лишние связи не проверяются.
137
178
  -- Tombstone-версии (deleted IS NOT NULL) не валидируются.
138
179
  -- -----------------------------------------------------------------------------
180
+ -- START_CONTRACT: entity_check
181
+ -- PURPOSE: Проверить при INSERT — класс существует и не абстрактный; ключи links — существующие классы со строковыми id; концы LINK (v2 жадный матчинг / legacy).
182
+ -- INPUTS: { trigger: BEFORE INSERT ON Entity (NEW) }
183
+ -- OUTPUTS: { NEW - либо EXCEPTION }
184
+ -- SIDE_EFFECTS: none (валидация; tombstone-версии пропускаются)
185
+ -- ERRORS: unknown class; class is abstract; links key not a class; link requires end; stray link
186
+ -- LINKS: M-DDL, V-M-DDL, M-WRITE
187
+ -- END_CONTRACT: entity_check
139
188
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_check() RETURNS trigger
140
189
  LANGUAGE plpgsql AS $$
141
190
  DECLARE
142
- s RECORD;
143
- bad text;
144
- k text;
191
+ s RECORD;
192
+ bad text;
193
+ k text;
194
+ used text[];
195
+ ej jsonb;
196
+ ecs text[];
197
+ hit text;
198
+ c2 text;
199
+ is_v2 boolean;
145
200
  BEGIN
146
201
  IF NEW.deleted IS NOT NULL THEN
147
202
  RETURN NEW;
@@ -172,15 +227,49 @@ BEGIN
172
227
  RAISE EXCEPTION 'ENTITY_CHECK: links key "%" is not a class or its value is not an id string', bad;
173
228
  END IF;
174
229
 
175
- -- LINK: все объявленные концы обязаны присутствовать
230
+ -- LINK: концы по Schema.links — jsonb-массив; элемент-объект = v2 (строгий жадный
231
+ -- матчинг по порядку), элемент-строка = legacy-имя класса
232
+ -- START_BLOCK_ENTITY_CHECK_LINK
176
233
  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;
234
+ is_v2 := EXISTS (
235
+ SELECT 1 FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le
236
+ WHERE jsonb_typeof(le.value) = 'object');
237
+ IF is_v2 THEN
238
+ used := ARRAY[]::text[];
239
+ FOR ej IN SELECT le.value FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le LOOP
240
+ ecs := COALESCE(
241
+ (SELECT array_agg(x) FROM jsonb_array_elements_text(ej->'classes') x),
242
+ ARRAY[ej->>'class']);
243
+ hit := NULL;
244
+ FOREACH c2 IN ARRAY ecs LOOP
245
+ IF (COALESCE(NEW.links, '{}'::jsonb) ? c2) AND NOT (c2 = ANY(used)) THEN
246
+ hit := c2;
247
+ EXIT;
248
+ END IF;
249
+ END LOOP;
250
+ IF hit IS NOT NULL THEN
251
+ used := used || hit;
252
+ ELSIF NOT COALESCE((ej->>'optional')::boolean, false) THEN
253
+ RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, array_to_string(ecs, '|');
254
+ END IF;
255
+ END LOOP;
256
+ SELECT e.key INTO bad
257
+ FROM jsonb_each(COALESCE(NEW.links, '{}'::jsonb)) e
258
+ WHERE NOT (e.key = ANY(used))
259
+ LIMIT 1;
260
+ IF bad IS NOT NULL THEN
261
+ RAISE EXCEPTION 'ENTITY_CHECK: link "%" has stray link "%" — not an end in Schema.links', NEW.class, bad;
180
262
  END IF;
181
- END LOOP;
263
+ ELSE
264
+ FOR k IN SELECT le.value #>> '{}' FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le LOOP
265
+ IF k <> 'Entity' AND NOT (COALESCE(NEW.links, '{}'::jsonb) ? k) THEN
266
+ RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, k;
267
+ END IF;
268
+ END LOOP;
269
+ END IF;
182
270
  END IF;
183
271
 
272
+ -- END_BLOCK_ENTITY_CHECK_LINK
184
273
  RETURN NEW;
185
274
  END $$;
186
275
 
@@ -196,6 +285,13 @@ CREATE TRIGGER entity_check
196
285
  -- * вставка проходит entity_check (валидация класса/links).
197
286
  -- Работает и для голого SQL: UPDATE "<SCHEMA-NAME>"."Entity" SET data=… WHERE …
198
287
  -- -----------------------------------------------------------------------------
288
+ -- START_CONTRACT: entity_update
289
+ -- PURPOSE: Превратить UPDATE в INSERT новой версии — бьёт только актуальную строку, историю не трогает, updated монотонно растёт.
290
+ -- INPUTS: { trigger: BEFORE UPDATE ON Entity (OLD/NEW) }
291
+ -- OUTPUTS: { NULL - физический UPDATE отменён }
292
+ -- SIDE_EFFECTS: INSERT новой версии (проходит entity_check)
293
+ -- LINKS: M-DDL, V-M-DDL
294
+ -- END_CONTRACT: entity_update
199
295
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_update() RETURNS trigger
200
296
  LANGUAGE plpgsql AS $$
201
297
  DECLARE latest timestamptz;
@@ -228,6 +324,13 @@ CREATE TRIGGER entity_update
228
324
  -- * физического удаления не происходит никогда (очистка истории — retention-политики).
229
325
  -- Работает и для голого SQL: DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE partition=… AND class=… AND id=…
230
326
  -- -----------------------------------------------------------------------------
327
+ -- START_CONTRACT: entity_delete
328
+ -- PURPOSE: Превратить DELETE в tombstone + рекурсивный каскад по links; advisory-lock сериализует параллельные удаления; физического удаления нет.
329
+ -- INPUTS: { trigger: BEFORE DELETE ON Entity (OLD) }
330
+ -- OUTPUTS: { NULL - физическое удаление отменено }
331
+ -- SIDE_EFFECTS: INSERT tombstone; DELETE живых зависимых (рекурсия триггера); pg_advisory_xact_lock
332
+ -- LINKS: M-DDL, V-M-DDL, M-WRITE
333
+ -- END_CONTRACT: entity_delete
231
334
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_delete() RETURNS trigger
232
335
  LANGUAGE plpgsql AS $$
233
336
  DECLARE latest timestamptz;
@@ -268,6 +371,7 @@ CREATE TRIGGER entity_delete
268
371
  -- =============================================================================
269
372
 
270
373
  -- Аккаунты: пользователи и сервисы. Категории: System | User | Anonymous | Shadow…
374
+ -- START_BLOCK_AUTH_TABLES
271
375
  CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Account" (
272
376
  id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
273
377
  categories character varying[] DEFAULT ARRAY['User'::text] NOT NULL,
@@ -325,11 +429,19 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Rule" (
325
429
  enabled boolean DEFAULT true NOT NULL,
326
430
  PRIMARY KEY (account, resource)
327
431
  );
432
+ -- END_BLOCK_AUTH_TABLES
328
433
 
329
434
  -- -----------------------------------------------------------------------------
330
435
  -- Триггер 4: realtime — факт каждой новой версии в pg_notify (канал = имя схемы).
331
436
  -- Payload лёгкий (без data): подписчик дочитывает нужное сам. Потребитель: db.watch().
332
437
  -- -----------------------------------------------------------------------------
438
+ -- START_CONTRACT: entity_notify
439
+ -- PURPOSE: Опубликовать факт новой версии в pg_notify (канал = имя схемы), лёгкий payload без data.
440
+ -- INPUTS: { trigger: AFTER INSERT ON Entity (NEW) }
441
+ -- OUTPUTS: { NULL }
442
+ -- SIDE_EFFECTS: pg_notify(канал = имя схемы)
443
+ -- LINKS: M-DDL, V-M-DDL, M-CHAIN
444
+ -- END_CONTRACT: entity_notify
333
445
  CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_notify() RETURNS trigger
334
446
  LANGUAGE plpgsql AS $$
335
447
  BEGIN
package/sql/seed.auth.sql CHANGED
@@ -4,11 +4,35 @@
4
4
  -- Идемпотентен (ON CONFLICT).
5
5
  -- =============================================================================
6
6
 
7
+ -- FILE: lib/sql/seed.auth.sql
8
+ -- VERSION: 1.0.0
9
+ -- START_MODULE_CONTRACT
10
+ -- PURPOSE: Идемпотентный сид System-аккаунта и дефолтного набора ACL Resource/Rule для auth-эндпоинтов.
11
+ -- SCOPE: 1 Account (System), 16 Resource (subjects+API), 12 Rule (allow/deny с весами).
12
+ -- DEPENDS: none
13
+ -- LINKS: M-SEED-AUTH, V-M-SEED-AUTH
14
+ -- ROLE: CONFIG
15
+ -- MAP_MODE: SUMMARY
16
+ -- END_MODULE_CONTRACT
17
+ --
18
+ -- START_MODULE_MAP
19
+ -- Account - один System-аккаунт (фикс. id 2eba6d0a-…)
20
+ -- Resource - 16 ресурсов ACL (anonymous/authenticated/any, auth.*, payment/shop/billing/geo)
21
+ -- Rule - 12 правил allow/deny, вес 0..105
22
+ -- END_MODULE_MAP
23
+ --
24
+ -- START_CHANGE_SUMMARY
25
+ -- LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
26
+ -- END_CHANGE_SUMMARY
27
+
28
+ -- START_BLOCK_SEED_ACCOUNT
7
29
  -- Системный аккаунт (фиксированный id из дампа)
8
30
  INSERT INTO "<SCHEMA-NAME>"."Account" (id, categories, data, meta, avatar, enabled) VALUES
9
31
  ('2eba6d0a-1edd-4bb6-a85a-a703dab49035', '{System}', '{"id": "System", "name": "System"}', '{}', 'https://i.pravatar.cc/128?img=28', true)
10
32
  ON CONFLICT (id) DO NOTHING;
11
33
 
34
+ -- END_BLOCK_SEED_ACCOUNT
35
+ -- START_BLOCK_SEED_RESOURCES
12
36
  -- Ресурсы: субъекты (категории аккаунтов) и объекты (API-эндпоинты)
13
37
  INSERT INTO "<SCHEMA-NAME>"."Resource" (alias, category, pattern, meta) VALUES
14
38
  ('anonymous:ACCOUNT', 'ACCOUNT', '{"categories": "{Anonymous,Shadow}"}', '{"description": "Unauthenticated users (includes Anonymous and Shadow categories)"}'),
@@ -29,6 +53,8 @@ INSERT INTO "<SCHEMA-NAME>"."Resource" (alias, category, pattern, meta) VALUES
29
53
  ('geo:API', 'API', '{"endpoint": "v2.geo.*"}', NULL)
30
54
  ON CONFLICT (alias) DO UPDATE SET category = EXCLUDED.category, pattern = EXCLUDED.pattern, meta = EXCLUDED.meta;
31
55
 
56
+ -- END_BLOCK_SEED_RESOURCES
57
+ -- START_BLOCK_SEED_RULES
32
58
  -- Правила: субъект → объект → allow|deny (weight — приоритет)
33
59
  INSERT INTO "<SCHEMA-NAME>"."Rule" (account, resource, permission, weight, meta, enabled) VALUES
34
60
  ('authenticated:ACCOUNT', 'auth.user.password:API', 'allow', 90, '{"description": "Users can change their own password"}', true),
@@ -44,3 +70,4 @@ INSERT INTO "<SCHEMA-NAME>"."Rule" (account, resource, permission, weight, meta,
44
70
  ('authenticated:ACCOUNT', 'billing:API', 'allow', 90, '{"description": "Users can access billing (uses ctx.meta.$account.id)"}', true),
45
71
  ('authenticated:ACCOUNT', 'geo:API', 'allow', 90, '{"description": "Users can access geo (uses ctx.meta.$account.id)"}', true)
46
72
  ON CONFLICT (account, resource) DO UPDATE SET permission = EXCLUDED.permission, weight = EXCLUDED.weight, meta = EXCLUDED.meta, enabled = EXCLUDED.enabled;
73
+ -- END_BLOCK_SEED_RULES
@@ -1,64 +1,111 @@
1
1
  -- =============================================================================
2
- -- Сид таблицы Schema из schema.booking.v2.json (источник правды — JSON).
3
- -- Файл сгенерирован, не редактировать вручную. Идемпотентен (ON CONFLICT).
2
+ -- Сид таблицы Schema — ИСТОЧНИК ПРАВДЫ демо-домена booking (19 классов, 0.17).
3
+ -- Редактируется руками; накат: db/apply.mjs / up() (маркер <SCHEMA-NAME>).
4
+ -- Идемпотентен (ON CONFLICT). Концы связей — Schema.links v2 (JSON-объекты в text[]).
4
5
  -- =============================================================================
6
+ --
7
+ -- FILE: lib/sql/seed.booking.sql
8
+ -- VERSION: 1.0.0
9
+ -- START_MODULE_CONTRACT
10
+ -- PURPOSE: Идемпотентный сид таблицы Schema — источник правды демо-домена booking (классы HUB + LINK).
11
+ -- SCOPE: 20 INSERT-ов классов (12 HUB: Entity/Org/Person/Staff/Customer/Location/Element/Service/Product/Complex/Schedule/Folder; 8 LINK: link/address/slot/booking/content/compo/skill/price).
12
+ -- DEPENDS: none
13
+ -- LINKS: M-SEED-BOOKING, V-M-SEED-BOOKING
14
+ -- ROLE: CONFIG
15
+ -- MAP_MODE: SUMMARY
16
+ -- END_MODULE_CONTRACT
17
+ --
18
+ -- START_MODULE_MAP
19
+ -- HUB-классы - сущности домена (наследование от Entity; id v5/v7)
20
+ -- LINK-классы - связи (концы Schema.links v2; id v5 из концов)
21
+ -- NB: фактически 20 классов (12 HUB + 8 LINK); заголовок файла говорит «19» — расхождение
22
+ -- END_MODULE_MAP
23
+ --
24
+ -- START_CHANGE_SUMMARY
25
+ -- LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
26
+ -- END_CHANGE_SUMMARY
27
+ --
28
+ -- START_BLOCK_SEED_HUB_CLASSES
5
29
 
6
30
  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[])
31
+ ('entity', 'Entity', 'Сущность', 'HUB', NULL, '{"id":{"type":"uuid","generate":7}}'::jsonb, '{"abstract":true,"appearance":{"icon":{"name":"mdi:atom","color":"text-violet-500"}},"description":"Абстрактный корень всех сущностей; дефолт id v7 наследуют все классы без своего правила"}'::jsonb, '[]'::jsonb, 100, ARRAY['Entity']::text[])
8
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;
9
33
 
10
34
  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[])
35
+ ('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,"appearance":{"icon":{"name":"mdi:domain","color":"text-blue-600"}},"description":"Организация — арендатор (мультитенантность): владелец каталога, локаций и расписаний; settings — вложенные настройки (пример структуры глубины 4)"}'::jsonb, '[]'::jsonb, 200, ARRAY['Org', 'Entity']::text[])
12
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;
13
37
 
14
38
  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[])
39
+ ('entity', 'Person', 'Контрагент', 'HUB', 'Entity', '{"name":"string","phone":"string"}'::jsonb, '{"abstract":true,"appearance":{"icon":{"name":"mdi:account-outline","color":"text-blue-500"}},"description":"Абстрактный контрагент: имя + телефон; наследуют Мастер и Клиент"}'::jsonb, '[]'::jsonb, 300, ARRAY['Person', 'Entity']::text[])
16
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;
17
41
 
18
42
  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[])
43
+ ('entity', 'Staff', 'Мастер', 'HUB', 'Person', '{"photo":"string|optional","description":"string|optional","specialization":"string|optional"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:account-star","color":"text-indigo-500"}},"description":"Мастер — бронируемый исполнитель; name/phone наследует от Контрагента; локации — LINK адрес, доступность — LINK окно"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 400, ARRAY['Staff', 'Person', 'Entity']::text[])
20
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;
21
45
 
22
46
  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[])
47
+ ('entity', 'Customer', 'Клиент', 'HUB', 'Person', '{"notes":"string|optional","preferred_contact":{"type":"enum","values":["phone","email","messenger"],"default":"phone"}}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:account","color":"text-green-500"}},"description":"Клиент — записывается на услуги; name/phone наследует от Контрагента"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 500, ARRAY['Customer', 'Person', 'Entity']::text[])
24
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;
25
49
 
26
50
  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[])
51
+ ('entity', 'Location', 'Локация', 'HUB', 'Entity', '{"address":"string","coordinates":{"type":"object","optional":true,"props":{"lat":"number","lng":"number"}},"description":"string|optional"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:map-marker","color":"text-red-500"}},"description":"Локация — точка обслуживания организации"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 600, ARRAY['Location', 'Entity']::text[])
28
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;
29
53
 
30
54
  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[])
55
+ ('entity', 'Element', 'Элемент', 'HUB', 'Entity', '{"id":{"type":"uuid","generate":5,"from":["Org","name"]},"name":"string","description":"string|optional"}'::jsonb, '{"abstract":true,"appearance":{"icon":{"name":"mdi:view-list","color":"text-teal-500"}},"description":"Абстрактная каталожная единица (Услуга/Товар/Комплекс). Правило id v5(Org, name) объявлено здесь один раз — наследуется потомками (имя уникально в организации, classId различает классы); links не наследуются — конец Org каждый потомок объявляет сам. Цена — отдельный LINK «цена» (варианты по пожеланиям, мультивалюта)"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 700, ARRAY['Element', 'Entity']::text[])
32
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;
33
57
 
34
58
  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[])
59
+ ('entity', 'Service', 'Услуга', 'HUB', 'Element', '{"duration":"number|min:0|optional"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:spa","color":"text-purple-500"}},"description":"Услуга: длительность (мин); name/id-правило наследует от Элемента; цена — LINK «цена»"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 800, ARRAY['Service', 'Element', 'Entity']::text[])
36
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;
37
61
 
38
62
  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[])
63
+ ('entity', 'Product', 'Товар', 'HUB', 'Element', '{"sku":"string|optional","unit":{"type":"string","default":"шт"}}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:package","color":"text-blue-400"}},"description":"Товар: артикул + единица измерения; name/id-правило наследует от Элемента; цена — LINK «цена»"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 900, ARRAY['Product', 'Element', 'Entity']::text[])
40
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;
41
65
 
42
66
  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[])
67
+ ('entity', 'Complex', 'Комплекс', 'HUB', 'Element', '{"cost":"number|min:0","duration":"number|min:0"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:package-variant-closed","color":"text-orange-500"}},"description":"Комплекс: собственные cost/duration; состав — LINK состав (Комплекс ← Элемент); цена — LINK «цена»"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 1000, ARRAY['Complex', 'Element', 'Entity']::text[])
44
68
  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
69
 
46
70
  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[])
71
+ ('entity', 'Schedule', 'Расписание', 'HUB', 'Entity', '{"name":"string","year":"number|integer","month":"number|integer|min:1|max:12"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:calendar","color":"text-cyan-500"}},"description":"Месячное расписание организации; наполнение — LINK окно"}'::jsonb, '[{"class":"Org","cardinality":1}]'::jsonb, 1100, ARRAY['Schedule', 'Entity']::text[])
48
72
  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
73
 
50
74
  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[])
75
+ ('entity', 'Folder', 'Папка', 'HUB', 'Entity', '{"name":"string"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:folder","color":"text-yellow-500"}},"description":"Папка-категория; вложенность — HUB-конец на родительскую Папку; наполнение — LINK содержимое"}'::jsonb, '[{"class":"Org","cardinality":1},{"class":"Folder","optional":true,"cardinality":1}]'::jsonb, 1200, ARRAY['Folder', 'Entity']::text[])
52
76
  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
77
 
78
+ -- END_BLOCK_SEED_HUB_CLASSES
79
+ -- START_BLOCK_SEED_LINK_CLASSES
54
80
  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[])
81
+ ('entity', 'link', 'связь', 'LINK', NULL, '{"id":{"type":"uuid","generate":7}}'::jsonb, '{"abstract":true,"appearance":{"icon":{"name":"mdi:link","color":"text-sky-500"}},"description":"Абстрактная связь; концы объявляются потомками в links (Schema.links v2: объекты-концы, только HUB-классы)"}'::jsonb, '[]'::jsonb, 100, ARRAY['link']::text[])
56
82
  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
83
 
58
84
  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[])
85
+ ('entity', 'address', 'адрес', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Staff","Location"]},"default":"boolean|default:false","schedule_info":"string|optional"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:link","color":"text-sky-500"}},"description":"Адрес: Мастер работает в Локации; default — основная точка мастера"}'::jsonb, '[{"class":"Staff","cardinality":1},{"class":"Location","cardinality":1}]'::jsonb, 200, ARRAY['address', 'link']::text[])
60
86
  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
87
 
62
88
  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[])
89
+ ('entity', 'slot', 'окно', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Staff","start_datetime"]},"start_datetime":{"type":"date","convert":true},"end_datetime":{"type":"date","convert":true}}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:clock-outline","color":"text-amber-500"}},"description":"Окно доступности: Мастер × Локация × Расписание + интервал. id = v5(Мастер, старт) — мастер не задваивает время ни в одной локации"}'::jsonb, '[{"class":"Staff","cardinality":1},{"class":"Location","cardinality":1},{"class":"Schedule","cardinality":1}]'::jsonb, 300, ARRAY['slot', 'link']::text[])
64
90
  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;
91
+
92
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
93
+ ('entity', 'booking', 'запись', 'LINK', 'slot', '{"notes":"string|optional"}'::jsonb, '{"abstract":false,"validation":"strict","appearance":{"icon":{"name":"mdi:calendar-check","color":"text-emerald-500"}},"description":"Запись — наследник окна (start/end_datetime и правило id v5(Мастер, старт) наследуются): бронь мастера на Элемент для Клиента. Ручная бронь — без Клиента, имя в notes. classId в v5-формуле различает окно и запись; двойная бронь того же времени мертва"}'::jsonb, '[{"class":"Staff","cardinality":1},{"class":"Location","cardinality":1},{"class":"Schedule","cardinality":1},{"classes":["Service","Product","Complex"],"cardinality":1},{"class":"Customer","optional":true,"cardinality":1}]'::jsonb, 400, ARRAY['booking', 'slot', 'link']::text[])
94
+ 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;
95
+
96
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
97
+ ('entity', 'content', 'содержимое', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Folder","Service|Product|Complex"]},"order":"number|integer|default:0"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:link","color":"text-sky-500"}},"description":"Содержимое папки: Папка ← Элемент (+порядок); элемент может лежать в нескольких папках"}'::jsonb, '[{"class":"Folder","cardinality":1},{"classes":["Service","Product","Complex"],"cardinality":1}]'::jsonb, 500, ARRAY['content', 'link']::text[])
98
+ 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;
99
+
100
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
101
+ ('entity', 'compo', 'состав', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Complex","Service|Product"]},"order":"number|integer|default:0","quantity":"number|integer|min:1|default:1"}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:link","color":"text-sky-500"}},"description":"Состав комплекса: Комплекс ← Элемент (+порядок, +количество); в состав входят и услуги, и товары"}'::jsonb, '[{"class":"Complex","cardinality":1},{"classes":["Service","Product"],"cardinality":1}]'::jsonb, 600, ARRAY['compo', 'link']::text[])
102
+ 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;
103
+
104
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
105
+ ('entity', 'skill', 'навык', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Staff","Service"]},"level":{"type":"enum","values":["basic","intermediate","advanced","expert"],"default":"intermediate"}}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:link","color":"text-sky-500"}},"description":"Навык: Мастер умеет Услугу (+уровень); умение комплекса вычислимо по составу"}'::jsonb, '[{"class":"Staff","cardinality":1},{"class":"Service","cardinality":1}]'::jsonb, 700, ARRAY['skill', 'link']::text[])
106
+ 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;
107
+
108
+ INSERT INTO "<SCHEMA-NAME>"."Schema" (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors) VALUES
109
+ ('entity', 'price', 'цена', 'LINK', 'link', '{"id":{"type":"uuid","generate":5,"from":["Service|Product|Complex","note"]},"note":{"type":"string","default":"базовая"},"amounts":{"type":"record","key":{"type":"enum","values":["RUB","USD","EUR","AED"]},"value":"number|integer|min:0"}}'::jsonb, '{"abstract":false,"appearance":{"icon":{"name":"mdi:cash-multiple","color":"text-green-600"}},"description":"Вариант цены каталожного элемента: мультивалютные суммы (amounts) + описание (note). У элемента несколько вариантов по пожеланиям клиента (note различает: базовая/с дизайном/…); id v5(элемент, note) — note не задан ⇒ дефолт «базовая»; правку одной валюты amounts мержит точечно"}'::jsonb, '[{"classes":["Service","Product","Complex"],"cardinality":1}]'::jsonb, 800, ARRAY['price', 'link']::text[])
110
+ 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;
111
+ -- END_BLOCK_SEED_LINK_CLASSES