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.
- package/CHANGELOG.md +171 -0
- package/README.md +2280 -211
- package/dist/acl.d.ts +48 -0
- package/dist/acl.js +208 -0
- package/dist/auth.d.ts +116 -0
- package/dist/auth.js +263 -0
- package/dist/chain.d.ts +51 -26
- package/dist/chain.js +143 -74
- package/dist/index.d.ts +9 -3
- package/dist/index.js +24 -3
- package/dist/schema.js +11 -0
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +23 -1
- package/dist/sql.js +167 -55
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +44 -1
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/write.d.ts +18 -12
- package/dist/write.js +247 -36
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +362 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +64 -0
|
@@ -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"]
|
package/docker/start.sh
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "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": [
|
|
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;
|