letopis 0.20.3 → 1.0.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/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI letopis (план, этап 2, п. 7; этап 4, п. 11; этап 6, п. 4, 8, 12): `letopis sync`, `types`, `load`,
|
|
4
|
+
* `verify`, `maintain`, `describe`; этап 7 — `import` (из 0.21).
|
|
5
|
+
*
|
|
6
|
+
* letopis sync <модуль моделей> --dsn … --schema v2.x (--token … | --api-key …) [--apply]
|
|
7
|
+
* letopis types --dsn … --schema v2.x (--token … | --api-key …) [--out types.d.ts]
|
|
8
|
+
* letopis load <файлы NDJSON…> --dsn … --schema v2.x (--token … | --api-key …) [--class C]
|
|
9
|
+
* [--duplicates error|first|last] [--existing error|skip|update] [--commit-every N] [--verify]
|
|
10
|
+
* letopis verify --dsn … --schema v2.x [--token … | --api-key …] [--data] — код 1 при находках
|
|
11
|
+
* letopis maintain --dsn … --schema v2.x [--token … | --api-key …] [--batches N]
|
|
12
|
+
* letopis describe --dsn … --schema v2.x (--token … | --api-key …) [--format json|text] [--out файл]
|
|
13
|
+
* letopis import --from <строка 0.21> --from-schema v1.x --dsn … --schema v2.x [--tenant <имя>] [--keyless C]…
|
|
14
|
+
* [--rename Старое=Новое]… [--update] [--commit-every N] [--partition p] — код 1, если verify нашёл расхождения
|
|
15
|
+
*/
|
|
16
|
+
import { parseArgs } from 'node:util';
|
|
17
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
import { resolve } from 'node:path';
|
|
20
|
+
import { connect, describeText } from './index.js';
|
|
21
|
+
import { syncModels, generateTypes } from './sync.js';
|
|
22
|
+
import { import021 } from './import.js';
|
|
23
|
+
const USAGE = `letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
|
|
24
|
+
letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
|
|
25
|
+
letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
|
|
26
|
+
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify]
|
|
27
|
+
letopis verify --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--data]
|
|
28
|
+
letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
|
|
29
|
+
letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
|
|
30
|
+
letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
|
|
31
|
+
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>]`;
|
|
32
|
+
const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
|
|
33
|
+
export async function main(argv) {
|
|
34
|
+
const { values, positionals } = parseArgs({
|
|
35
|
+
args: argv,
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
options: {
|
|
38
|
+
dsn: { type: 'string' }, schema: { type: 'string' }, token: { type: 'string' }, 'api-key': { type: 'string' },
|
|
39
|
+
apply: { type: 'boolean' }, out: { type: 'string' }, help: { type: 'boolean' },
|
|
40
|
+
class: { type: 'string' }, duplicates: { type: 'string' }, existing: { type: 'string' }, 'commit-every': { type: 'string' },
|
|
41
|
+
verify: { type: 'boolean' }, trust: { type: 'boolean' }, data: { type: 'boolean' }, batches: { type: 'string' },
|
|
42
|
+
format: { type: 'string' },
|
|
43
|
+
from: { type: 'string' }, 'from-schema': { type: 'string' }, tenant: { type: 'string' }, partition: { type: 'string' },
|
|
44
|
+
keyless: { type: 'string', multiple: true }, rename: { type: 'string', multiple: true }, update: { type: 'boolean' },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const [cmd, arg] = positionals;
|
|
48
|
+
if (values.help || !cmd || !values.dsn || !values.schema) {
|
|
49
|
+
console.log(USAGE);
|
|
50
|
+
return cmd ? 2 : 0;
|
|
51
|
+
}
|
|
52
|
+
if (cmd === 'import') {
|
|
53
|
+
// импорт сам открывает администраторские подключения к 0.21 и к 1.0 (загрузчик, функции владельца)
|
|
54
|
+
if (!values.from || !values['from-schema']) {
|
|
55
|
+
console.log(USAGE);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
const rename = {};
|
|
59
|
+
for (const x of values.rename ?? []) {
|
|
60
|
+
const [from, to] = x.split('=');
|
|
61
|
+
if (!from || !to) {
|
|
62
|
+
console.log(USAGE);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
rename[from] = to;
|
|
66
|
+
}
|
|
67
|
+
const r = await import021({
|
|
68
|
+
from: values.from, fromSchema: values['from-schema'], dsn: values.dsn, schema: values.schema, tenant: values.tenant,
|
|
69
|
+
partition: values.partition, keyless: values.keyless, rename, update: values.update,
|
|
70
|
+
commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined,
|
|
71
|
+
});
|
|
72
|
+
console.log(JSON.stringify(r, null, 2));
|
|
73
|
+
return r.verify.ok ? 0 : 1;
|
|
74
|
+
}
|
|
75
|
+
// загрузчику нужно администраторское подключение (§2.6, п. 1)
|
|
76
|
+
const db = await connect({ dsn: values.dsn, schema: values.schema, token: values.token, apiKey: values['api-key'], max: 2,
|
|
77
|
+
// verify и maintain работают и на администраторском подключении без сессии
|
|
78
|
+
...(cmd === 'load' || cmd === 'verify' || cmd === 'maintain' ? { allowBypassRls: true } : {}) });
|
|
79
|
+
try {
|
|
80
|
+
if (cmd === 'sync') {
|
|
81
|
+
if (!arg) {
|
|
82
|
+
console.log(USAGE);
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
const mod = await import(pathToFileURL(resolve(arg)).href);
|
|
86
|
+
const models = [...new Set(Object.values(mod).flatMap((v) => (Array.isArray(v) ? v : [v])).filter(isModel))];
|
|
87
|
+
const r = await syncModels(db, models, { apply: values.apply });
|
|
88
|
+
console.log(`${r.applied ? 'применено' : 'сухой прогон'}: создать ${r.created.length}, изменить ${r.updated.length}, без изменений ${r.unchanged.length}, системных ${r.skipped.length}`);
|
|
89
|
+
for (const [k, list] of [['создать', r.created], ['изменить', r.updated], ['системные (меняет установка)', r.skipped]]) {
|
|
90
|
+
if (list.length)
|
|
91
|
+
console.log(` ${k}: ${list.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
for (const l of r.lost)
|
|
94
|
+
console.log(` правило ${l.kind} в ${l.path} проверяется только в библиотеке — в базе его нет`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
if (cmd === 'load') {
|
|
98
|
+
const files = positionals.slice(1);
|
|
99
|
+
if (!files.length) {
|
|
100
|
+
console.log(USAGE);
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
if (values.trust)
|
|
104
|
+
throw new Error('letopis: removed: --trust снят в 1.0.0 — запрос по целям вне загрузки стоит 0,1–1,3 % её времени');
|
|
105
|
+
// несколько файлов — одна загрузка и одна транзакция; в файле — объект на строку
|
|
106
|
+
const rows = [];
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
for (const line of (await readFile(f, 'utf8')).split(/\r?\n/))
|
|
109
|
+
if (line.trim())
|
|
110
|
+
rows.push(JSON.parse(line));
|
|
111
|
+
}
|
|
112
|
+
const r = await db.load(rows, {
|
|
113
|
+
class: values.class,
|
|
114
|
+
duplicates: values.duplicates,
|
|
115
|
+
existing: values.existing,
|
|
116
|
+
commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined,
|
|
117
|
+
verify: values.verify,
|
|
118
|
+
});
|
|
119
|
+
console.log(JSON.stringify(r));
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (cmd === 'verify') {
|
|
123
|
+
const r = await db.verify({ data: values.data });
|
|
124
|
+
console.log(JSON.stringify(r, null, 2));
|
|
125
|
+
return r.ok ? 0 : 1;
|
|
126
|
+
}
|
|
127
|
+
if (cmd === 'maintain') {
|
|
128
|
+
const r = await db.maintain({ batches: values.batches ? Number(values.batches) : undefined });
|
|
129
|
+
console.log(JSON.stringify(r));
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
if (cmd === 'describe') {
|
|
133
|
+
const d = db.describe();
|
|
134
|
+
const text = values.format === 'text' ? describeText(d) : JSON.stringify(d, null, 2);
|
|
135
|
+
if (values.out)
|
|
136
|
+
await writeFile(values.out, text);
|
|
137
|
+
else
|
|
138
|
+
console.log(text);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
if (cmd === 'types') {
|
|
142
|
+
const text = generateTypes(db.registry);
|
|
143
|
+
if (values.out)
|
|
144
|
+
await writeFile(values.out, text);
|
|
145
|
+
else
|
|
146
|
+
console.log(text);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
console.log(USAGE);
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await db.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
157
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
|
|
158
|
+
console.error(e.message);
|
|
159
|
+
const rows = e.detail?.rows;
|
|
160
|
+
if (rows)
|
|
161
|
+
console.error(JSON.stringify(rows, null, 2));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { type AnyModel } from '../model.js';
|
|
2
|
+
export declare const Org: import("../model.js").Model<"Org", {
|
|
3
|
+
name: string;
|
|
4
|
+
timezone: string;
|
|
5
|
+
active: boolean;
|
|
6
|
+
address?: string | undefined;
|
|
7
|
+
phone?: string | undefined;
|
|
8
|
+
settings?: {
|
|
9
|
+
booking?: {
|
|
10
|
+
autoconfirm: boolean;
|
|
11
|
+
deposit?: {
|
|
12
|
+
amount: number;
|
|
13
|
+
currency: "RUB" | "USD" | "EUR" | "AED";
|
|
14
|
+
} | undefined;
|
|
15
|
+
} | undefined;
|
|
16
|
+
} | undefined;
|
|
17
|
+
}, {} & {}, "Организация">;
|
|
18
|
+
export declare const Person: import("../model.js").Model<"Person", {
|
|
19
|
+
name: string;
|
|
20
|
+
phone: string;
|
|
21
|
+
}, {
|
|
22
|
+
readonly Org: string;
|
|
23
|
+
} & {}, "Контрагент">;
|
|
24
|
+
export declare const Staff: import("../model.js").Model<"Staff", Omit<{
|
|
25
|
+
name: string;
|
|
26
|
+
phone: string;
|
|
27
|
+
}, "description" | "photo" | "specialization"> & {
|
|
28
|
+
photo?: string | undefined;
|
|
29
|
+
description?: string | undefined;
|
|
30
|
+
specialization?: string | undefined;
|
|
31
|
+
}, Omit<{
|
|
32
|
+
readonly Org: string;
|
|
33
|
+
} & {}, never> & {} & {}, "Мастер">;
|
|
34
|
+
export declare const Customer: import("../model.js").Model<"Customer", Omit<{
|
|
35
|
+
name: string;
|
|
36
|
+
phone: string;
|
|
37
|
+
}, "notes" | "preferred_contact"> & {
|
|
38
|
+
preferred_contact: "email" | "phone" | "messenger";
|
|
39
|
+
notes?: string | undefined;
|
|
40
|
+
}, Omit<{
|
|
41
|
+
readonly Org: string;
|
|
42
|
+
} & {}, never> & {} & {}, "Клиент">;
|
|
43
|
+
export declare const Location: import("../model.js").Model<"Location", {
|
|
44
|
+
address: string;
|
|
45
|
+
coordinates?: {
|
|
46
|
+
lat: number;
|
|
47
|
+
lng: number;
|
|
48
|
+
} | undefined;
|
|
49
|
+
description?: string | undefined;
|
|
50
|
+
}, {
|
|
51
|
+
readonly Org: string;
|
|
52
|
+
} & {}, "Локация">;
|
|
53
|
+
export declare const Element: import("../model.js").Model<"Element", {
|
|
54
|
+
name: string;
|
|
55
|
+
description?: string | undefined;
|
|
56
|
+
}, {
|
|
57
|
+
readonly Org: string;
|
|
58
|
+
} & {}, "Элемент">;
|
|
59
|
+
export declare const Service: import("../model.js").Model<"Service", Omit<{
|
|
60
|
+
name: string;
|
|
61
|
+
description?: string | undefined;
|
|
62
|
+
}, "duration"> & {
|
|
63
|
+
duration?: number | undefined;
|
|
64
|
+
}, Omit<{
|
|
65
|
+
readonly Org: string;
|
|
66
|
+
} & {}, never> & {} & {}, "Услуга">;
|
|
67
|
+
export declare const Product: import("../model.js").Model<"Product", Omit<{
|
|
68
|
+
name: string;
|
|
69
|
+
description?: string | undefined;
|
|
70
|
+
}, "sku" | "unit"> & {
|
|
71
|
+
unit: string;
|
|
72
|
+
sku?: string | undefined;
|
|
73
|
+
}, Omit<{
|
|
74
|
+
readonly Org: string;
|
|
75
|
+
} & {}, never> & {} & {}, "Товар">;
|
|
76
|
+
export declare const Complex: import("../model.js").Model<"Complex", Omit<{
|
|
77
|
+
name: string;
|
|
78
|
+
description?: string | undefined;
|
|
79
|
+
}, "duration" | "cost"> & {
|
|
80
|
+
cost: number;
|
|
81
|
+
duration: number;
|
|
82
|
+
}, Omit<{
|
|
83
|
+
readonly Org: string;
|
|
84
|
+
} & {}, never> & {} & {}, "Комплекс">;
|
|
85
|
+
export declare const Schedule: import("../model.js").Model<"Schedule", {
|
|
86
|
+
name: string;
|
|
87
|
+
year: number;
|
|
88
|
+
month: number;
|
|
89
|
+
}, {
|
|
90
|
+
readonly Org: string;
|
|
91
|
+
} & {}, "Расписание">;
|
|
92
|
+
export declare const Folder: import("../model.js").Model<"Folder", {
|
|
93
|
+
name: string;
|
|
94
|
+
}, {
|
|
95
|
+
readonly Org: string;
|
|
96
|
+
} & {
|
|
97
|
+
readonly Parent?: string | undefined;
|
|
98
|
+
}, "Папка">;
|
|
99
|
+
export declare const address: import("../model.js").Model<"address", {
|
|
100
|
+
default: boolean;
|
|
101
|
+
schedule_info?: string | undefined;
|
|
102
|
+
}, {
|
|
103
|
+
readonly Staff: string;
|
|
104
|
+
readonly Location: string;
|
|
105
|
+
} & {}, "адрес">;
|
|
106
|
+
export declare const slot: import("../model.js").Model<"slot", {
|
|
107
|
+
start_datetime: string;
|
|
108
|
+
end_datetime: string;
|
|
109
|
+
}, {
|
|
110
|
+
readonly Staff: string;
|
|
111
|
+
readonly Location: string;
|
|
112
|
+
readonly Schedule: string;
|
|
113
|
+
} & {}, "окно">;
|
|
114
|
+
export declare const booking: import("../model.js").Model<"booking", Omit<{
|
|
115
|
+
start_datetime: string;
|
|
116
|
+
end_datetime: string;
|
|
117
|
+
}, "notes"> & {
|
|
118
|
+
notes?: string | undefined;
|
|
119
|
+
}, Omit<{
|
|
120
|
+
readonly Staff: string;
|
|
121
|
+
readonly Location: string;
|
|
122
|
+
readonly Schedule: string;
|
|
123
|
+
} & {}, "Customer" | "Item"> & {
|
|
124
|
+
readonly Item: string;
|
|
125
|
+
} & {
|
|
126
|
+
readonly Customer?: string | undefined;
|
|
127
|
+
}, "запись">;
|
|
128
|
+
export declare const content: import("../model.js").Model<"content", {
|
|
129
|
+
order: number;
|
|
130
|
+
}, {
|
|
131
|
+
readonly Folder: string;
|
|
132
|
+
readonly Item: string;
|
|
133
|
+
} & {}, "содержимое">;
|
|
134
|
+
export declare const compo: import("../model.js").Model<"compo", {
|
|
135
|
+
order: number;
|
|
136
|
+
quantity: number;
|
|
137
|
+
}, {
|
|
138
|
+
readonly Complex: string;
|
|
139
|
+
readonly Part: string;
|
|
140
|
+
} & {}, "состав">;
|
|
141
|
+
export declare const skill: import("../model.js").Model<"skill", {
|
|
142
|
+
level: "basic" | "intermediate" | "advanced" | "expert";
|
|
143
|
+
}, {
|
|
144
|
+
readonly Staff: string;
|
|
145
|
+
readonly Service: string;
|
|
146
|
+
} & {}, "навык">;
|
|
147
|
+
export declare const price: import("../model.js").Model<"price", {
|
|
148
|
+
note: string;
|
|
149
|
+
amounts: Partial<Record<"RUB" | "USD" | "EUR" | "AED", number>>;
|
|
150
|
+
}, {
|
|
151
|
+
readonly Item: string;
|
|
152
|
+
} & {}, "цена">;
|
|
153
|
+
/** Все классы в порядке «предок раньше потомка». */
|
|
154
|
+
export declare const BOOKING: readonly [import("../model.js").Model<"Org", {
|
|
155
|
+
name: string;
|
|
156
|
+
timezone: string;
|
|
157
|
+
active: boolean;
|
|
158
|
+
address?: string | undefined;
|
|
159
|
+
phone?: string | undefined;
|
|
160
|
+
settings?: {
|
|
161
|
+
booking?: {
|
|
162
|
+
autoconfirm: boolean;
|
|
163
|
+
deposit?: {
|
|
164
|
+
amount: number;
|
|
165
|
+
currency: "RUB" | "USD" | "EUR" | "AED";
|
|
166
|
+
} | undefined;
|
|
167
|
+
} | undefined;
|
|
168
|
+
} | undefined;
|
|
169
|
+
}, {} & {}, "Организация">, import("../model.js").Model<"Person", {
|
|
170
|
+
name: string;
|
|
171
|
+
phone: string;
|
|
172
|
+
}, {
|
|
173
|
+
readonly Org: string;
|
|
174
|
+
} & {}, "Контрагент">, import("../model.js").Model<"Staff", Omit<{
|
|
175
|
+
name: string;
|
|
176
|
+
phone: string;
|
|
177
|
+
}, "description" | "photo" | "specialization"> & {
|
|
178
|
+
photo?: string | undefined;
|
|
179
|
+
description?: string | undefined;
|
|
180
|
+
specialization?: string | undefined;
|
|
181
|
+
}, Omit<{
|
|
182
|
+
readonly Org: string;
|
|
183
|
+
} & {}, never> & {} & {}, "Мастер">, import("../model.js").Model<"Customer", Omit<{
|
|
184
|
+
name: string;
|
|
185
|
+
phone: string;
|
|
186
|
+
}, "notes" | "preferred_contact"> & {
|
|
187
|
+
preferred_contact: "email" | "phone" | "messenger";
|
|
188
|
+
notes?: string | undefined;
|
|
189
|
+
}, Omit<{
|
|
190
|
+
readonly Org: string;
|
|
191
|
+
} & {}, never> & {} & {}, "Клиент">, import("../model.js").Model<"Location", {
|
|
192
|
+
address: string;
|
|
193
|
+
coordinates?: {
|
|
194
|
+
lat: number;
|
|
195
|
+
lng: number;
|
|
196
|
+
} | undefined;
|
|
197
|
+
description?: string | undefined;
|
|
198
|
+
}, {
|
|
199
|
+
readonly Org: string;
|
|
200
|
+
} & {}, "Локация">, import("../model.js").Model<"Element", {
|
|
201
|
+
name: string;
|
|
202
|
+
description?: string | undefined;
|
|
203
|
+
}, {
|
|
204
|
+
readonly Org: string;
|
|
205
|
+
} & {}, "Элемент">, import("../model.js").Model<"Service", Omit<{
|
|
206
|
+
name: string;
|
|
207
|
+
description?: string | undefined;
|
|
208
|
+
}, "duration"> & {
|
|
209
|
+
duration?: number | undefined;
|
|
210
|
+
}, Omit<{
|
|
211
|
+
readonly Org: string;
|
|
212
|
+
} & {}, never> & {} & {}, "Услуга">, import("../model.js").Model<"Product", Omit<{
|
|
213
|
+
name: string;
|
|
214
|
+
description?: string | undefined;
|
|
215
|
+
}, "sku" | "unit"> & {
|
|
216
|
+
unit: string;
|
|
217
|
+
sku?: string | undefined;
|
|
218
|
+
}, Omit<{
|
|
219
|
+
readonly Org: string;
|
|
220
|
+
} & {}, never> & {} & {}, "Товар">, import("../model.js").Model<"Complex", Omit<{
|
|
221
|
+
name: string;
|
|
222
|
+
description?: string | undefined;
|
|
223
|
+
}, "duration" | "cost"> & {
|
|
224
|
+
cost: number;
|
|
225
|
+
duration: number;
|
|
226
|
+
}, Omit<{
|
|
227
|
+
readonly Org: string;
|
|
228
|
+
} & {}, never> & {} & {}, "Комплекс">, import("../model.js").Model<"Schedule", {
|
|
229
|
+
name: string;
|
|
230
|
+
year: number;
|
|
231
|
+
month: number;
|
|
232
|
+
}, {
|
|
233
|
+
readonly Org: string;
|
|
234
|
+
} & {}, "Расписание">, import("../model.js").Model<"Folder", {
|
|
235
|
+
name: string;
|
|
236
|
+
}, {
|
|
237
|
+
readonly Org: string;
|
|
238
|
+
} & {
|
|
239
|
+
readonly Parent?: string | undefined;
|
|
240
|
+
}, "Папка">, import("../model.js").Model<"address", {
|
|
241
|
+
default: boolean;
|
|
242
|
+
schedule_info?: string | undefined;
|
|
243
|
+
}, {
|
|
244
|
+
readonly Staff: string;
|
|
245
|
+
readonly Location: string;
|
|
246
|
+
} & {}, "адрес">, import("../model.js").Model<"slot", {
|
|
247
|
+
start_datetime: string;
|
|
248
|
+
end_datetime: string;
|
|
249
|
+
}, {
|
|
250
|
+
readonly Staff: string;
|
|
251
|
+
readonly Location: string;
|
|
252
|
+
readonly Schedule: string;
|
|
253
|
+
} & {}, "окно">, import("../model.js").Model<"booking", Omit<{
|
|
254
|
+
start_datetime: string;
|
|
255
|
+
end_datetime: string;
|
|
256
|
+
}, "notes"> & {
|
|
257
|
+
notes?: string | undefined;
|
|
258
|
+
}, Omit<{
|
|
259
|
+
readonly Staff: string;
|
|
260
|
+
readonly Location: string;
|
|
261
|
+
readonly Schedule: string;
|
|
262
|
+
} & {}, "Customer" | "Item"> & {
|
|
263
|
+
readonly Item: string;
|
|
264
|
+
} & {
|
|
265
|
+
readonly Customer?: string | undefined;
|
|
266
|
+
}, "запись">, import("../model.js").Model<"content", {
|
|
267
|
+
order: number;
|
|
268
|
+
}, {
|
|
269
|
+
readonly Folder: string;
|
|
270
|
+
readonly Item: string;
|
|
271
|
+
} & {}, "содержимое">, import("../model.js").Model<"compo", {
|
|
272
|
+
order: number;
|
|
273
|
+
quantity: number;
|
|
274
|
+
}, {
|
|
275
|
+
readonly Complex: string;
|
|
276
|
+
readonly Part: string;
|
|
277
|
+
} & {}, "состав">, import("../model.js").Model<"skill", {
|
|
278
|
+
level: "basic" | "intermediate" | "advanced" | "expert";
|
|
279
|
+
}, {
|
|
280
|
+
readonly Staff: string;
|
|
281
|
+
readonly Service: string;
|
|
282
|
+
} & {}, "навык">, import("../model.js").Model<"price", {
|
|
283
|
+
note: string;
|
|
284
|
+
amounts: Partial<Record<"RUB" | "USD" | "EUR" | "AED", number>>;
|
|
285
|
+
}, {
|
|
286
|
+
readonly Item: string;
|
|
287
|
+
} & {}, "цена">];
|
|
288
|
+
/** Текст lib/sql/seed.booking.sql: строки Class системного арендатора, записанные владельцем. */
|
|
289
|
+
export declare function bookingSeedSql(models?: readonly AnyModel[]): string;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Демо-домен booking на Zod (план, этап 2, п. 8): 18 классов 0.21 (`lib/sql/seed.booking.sql` тега
|
|
3
|
+
* v0.21.0) без корней `Entity` и `link` — они несли только правило id v7.
|
|
4
|
+
* - Ключи взяты из правил id 0.21: классы с v5 сохраняют поля ключа, классы с v4 и v7 — без ключа.
|
|
5
|
+
* - Концы получили имена ролей. Союз всех потомков Element стал полиморфным концом `Item: one(Element)`;
|
|
6
|
+
* союз Service|Product в составе комплекса — `Part`; самоссылка Folder — `Parent`.
|
|
7
|
+
* - description, appearance и order 0.21 перешли в meta (§2.13).
|
|
8
|
+
* Из моделей генерируется lib/sql/seed.booking.sql (`npm run seed:booking`).
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { hub, link, one } from '../model.js';
|
|
12
|
+
const CURRENCY = z.enum(['RUB', 'USD', 'EUR', 'AED']);
|
|
13
|
+
const meta = (description, icon, color, order) => ({ description, icon, color, order });
|
|
14
|
+
export const Org = hub('Org', {
|
|
15
|
+
alias: 'Организация',
|
|
16
|
+
fields: z.object({
|
|
17
|
+
name: z.string(),
|
|
18
|
+
timezone: z.string().default('Europe/Moscow'),
|
|
19
|
+
address: z.string().optional(),
|
|
20
|
+
phone: z.string().optional(),
|
|
21
|
+
active: z.boolean().default(true),
|
|
22
|
+
settings: z.strictObject({
|
|
23
|
+
booking: z.strictObject({
|
|
24
|
+
deposit: z.strictObject({ amount: z.number().int().min(0).default(0), currency: CURRENCY.default('RUB') }).optional(),
|
|
25
|
+
autoconfirm: z.boolean().default(false),
|
|
26
|
+
}).optional(),
|
|
27
|
+
}).optional(),
|
|
28
|
+
}),
|
|
29
|
+
meta: meta('Организация — арендатор: владелец каталога, локаций и расписаний; settings — вложенные настройки (глубина 4)', 'mdi:domain', 'text-blue-600', 200),
|
|
30
|
+
});
|
|
31
|
+
export const Person = hub('Person', {
|
|
32
|
+
alias: 'Контрагент',
|
|
33
|
+
abstract: true,
|
|
34
|
+
ends: { Org },
|
|
35
|
+
fields: z.object({ name: z.string(), phone: z.string() }),
|
|
36
|
+
meta: meta('Абстрактный контрагент: имя и телефон; наследуют Мастер и Клиент', 'mdi:account-outline', 'text-blue-500', 300),
|
|
37
|
+
});
|
|
38
|
+
export const Staff = hub('Staff', {
|
|
39
|
+
alias: 'Мастер',
|
|
40
|
+
extends: Person,
|
|
41
|
+
fields: z.object({ photo: z.string().optional(), description: z.string().optional(), specialization: z.string().optional() }),
|
|
42
|
+
meta: meta('Мастер — бронируемый исполнитель; локации — связь адрес, доступность — связь окно', 'mdi:account-star', 'text-indigo-500', 400),
|
|
43
|
+
});
|
|
44
|
+
export const Customer = hub('Customer', {
|
|
45
|
+
alias: 'Клиент',
|
|
46
|
+
extends: Person,
|
|
47
|
+
fields: z.object({ notes: z.string().optional(), preferred_contact: z.enum(['phone', 'email', 'messenger']).default('phone') }),
|
|
48
|
+
meta: meta('Клиент — записывается на услуги', 'mdi:account', 'text-green-500', 500),
|
|
49
|
+
});
|
|
50
|
+
export const Location = hub('Location', {
|
|
51
|
+
alias: 'Локация',
|
|
52
|
+
ends: { Org },
|
|
53
|
+
fields: z.object({ address: z.string(), coordinates: z.object({ lat: z.number(), lng: z.number() }).optional(), description: z.string().optional() }),
|
|
54
|
+
meta: meta('Локация — точка обслуживания организации', 'mdi:map-marker', 'text-red-500', 600),
|
|
55
|
+
});
|
|
56
|
+
export const Element = hub('Element', {
|
|
57
|
+
alias: 'Элемент',
|
|
58
|
+
abstract: true,
|
|
59
|
+
ends: { Org },
|
|
60
|
+
fields: z.object({ name: z.string(), description: z.string().optional() }),
|
|
61
|
+
key: ['Org', 'name'],
|
|
62
|
+
meta: meta('Абстрактная каталожная единица (Услуга, Товар, Комплекс): имя уникально в организации; цена — связь цена', 'mdi:view-list', 'text-teal-500', 700),
|
|
63
|
+
});
|
|
64
|
+
export const Service = hub('Service', {
|
|
65
|
+
alias: 'Услуга',
|
|
66
|
+
extends: Element,
|
|
67
|
+
fields: z.object({ duration: z.number().min(0).optional() }),
|
|
68
|
+
meta: meta('Услуга: длительность в минутах', 'mdi:spa', 'text-purple-500', 800),
|
|
69
|
+
});
|
|
70
|
+
export const Product = hub('Product', {
|
|
71
|
+
alias: 'Товар',
|
|
72
|
+
extends: Element,
|
|
73
|
+
fields: z.object({ sku: z.string().optional(), unit: z.string().default('шт') }),
|
|
74
|
+
meta: meta('Товар: артикул и единица измерения', 'mdi:package', 'text-blue-400', 900),
|
|
75
|
+
});
|
|
76
|
+
export const Complex = hub('Complex', {
|
|
77
|
+
alias: 'Комплекс',
|
|
78
|
+
extends: Element,
|
|
79
|
+
fields: z.object({ cost: z.number().min(0), duration: z.number().min(0) }),
|
|
80
|
+
meta: meta('Комплекс: собственные стоимость и длительность; состав — связь состав', 'mdi:package-variant-closed', 'text-orange-500', 1000),
|
|
81
|
+
});
|
|
82
|
+
export const Schedule = hub('Schedule', {
|
|
83
|
+
alias: 'Расписание',
|
|
84
|
+
ends: { Org },
|
|
85
|
+
fields: z.object({ name: z.string(), year: z.number().int(), month: z.number().int().min(1).max(12) }),
|
|
86
|
+
meta: meta('Месячное расписание организации; наполнение — связь окно', 'mdi:calendar', 'text-cyan-500', 1100),
|
|
87
|
+
});
|
|
88
|
+
export const Folder = hub('Folder', {
|
|
89
|
+
alias: 'Папка',
|
|
90
|
+
ends: { Org, Parent: one('Folder').optional() },
|
|
91
|
+
fields: z.object({ name: z.string() }),
|
|
92
|
+
meta: meta('Папка-категория; вложенность — конец Parent на родительскую папку; наполнение — связь содержимое', 'mdi:folder', 'text-yellow-500', 1200),
|
|
93
|
+
});
|
|
94
|
+
export const address = link('address', {
|
|
95
|
+
alias: 'адрес',
|
|
96
|
+
ends: { Staff, Location },
|
|
97
|
+
fields: z.object({ default: z.boolean().default(false), schedule_info: z.string().optional() }),
|
|
98
|
+
key: ['Staff', 'Location'],
|
|
99
|
+
meta: meta('Адрес: Мастер работает в Локации; default — основная точка мастера', 'mdi:link', 'text-sky-500', 200),
|
|
100
|
+
});
|
|
101
|
+
export const slot = link('slot', {
|
|
102
|
+
alias: 'окно',
|
|
103
|
+
ends: { Staff, Location, Schedule },
|
|
104
|
+
fields: z.object({ start_datetime: z.iso.datetime({ precision: 3 }), end_datetime: z.iso.datetime({ precision: 3 }) }),
|
|
105
|
+
key: ['Staff', 'start_datetime'],
|
|
106
|
+
meta: meta('Окно доступности: Мастер × Локация × Расписание и интервал; мастер не задваивает время', 'mdi:clock-outline', 'text-amber-500', 300),
|
|
107
|
+
});
|
|
108
|
+
export const booking = link('booking', {
|
|
109
|
+
alias: 'запись',
|
|
110
|
+
extends: slot,
|
|
111
|
+
ends: { Item: one(Element), Customer: one(Customer).optional() },
|
|
112
|
+
onDelete: { Customer: 'unset' },
|
|
113
|
+
fields: z.object({ notes: z.string().optional() }),
|
|
114
|
+
meta: meta('Запись — наследник окна: бронь мастера на элемент каталога для клиента; ручная бронь — без клиента', 'mdi:calendar-check', 'text-emerald-500', 400),
|
|
115
|
+
});
|
|
116
|
+
export const content = link('content', {
|
|
117
|
+
alias: 'содержимое',
|
|
118
|
+
ends: { Folder, Item: one(Element) },
|
|
119
|
+
fields: z.object({ order: z.number().int().default(0) }),
|
|
120
|
+
key: ['Folder', 'Item'],
|
|
121
|
+
meta: meta('Содержимое папки: элемент может лежать в нескольких папках', 'mdi:link', 'text-sky-500', 500),
|
|
122
|
+
});
|
|
123
|
+
export const compo = link('compo', {
|
|
124
|
+
alias: 'состав',
|
|
125
|
+
ends: { Complex, Part: one(Service, Product) },
|
|
126
|
+
fields: z.object({ order: z.number().int().default(0), quantity: z.number().int().min(1).default(1) }),
|
|
127
|
+
key: ['Complex', 'Part'],
|
|
128
|
+
meta: meta('Состав комплекса: услуги и товары с порядком и количеством', 'mdi:link', 'text-sky-500', 600),
|
|
129
|
+
});
|
|
130
|
+
export const skill = link('skill', {
|
|
131
|
+
alias: 'навык',
|
|
132
|
+
ends: { Staff, Service },
|
|
133
|
+
fields: z.object({ level: z.enum(['basic', 'intermediate', 'advanced', 'expert']).default('intermediate') }),
|
|
134
|
+
key: ['Staff', 'Service'],
|
|
135
|
+
meta: meta('Навык: Мастер умеет Услугу; умение комплекса вычислимо по составу', 'mdi:link', 'text-sky-500', 700),
|
|
136
|
+
});
|
|
137
|
+
export const price = link('price', {
|
|
138
|
+
alias: 'цена',
|
|
139
|
+
ends: { Item: one(Element) },
|
|
140
|
+
fields: z.object({ note: z.string().default('базовая'), amounts: z.partialRecord(CURRENCY, z.number().int().min(0)) }),
|
|
141
|
+
key: ['Item', 'note'],
|
|
142
|
+
meta: meta('Вариант цены элемента: мультивалютные суммы и описание варианта', 'mdi:cash-multiple', 'text-green-600', 800),
|
|
143
|
+
});
|
|
144
|
+
/** Все классы в порядке «предок раньше потомка». */
|
|
145
|
+
export const BOOKING = [Org, Person, Staff, Customer, Location, Element, Service, Product, Complex, Schedule, Folder,
|
|
146
|
+
address, slot, booking, content, compo, skill, price];
|
|
147
|
+
/** Текст lib/sql/seed.booking.sql: строки Class системного арендатора, записанные владельцем. */
|
|
148
|
+
export function bookingSeedSql(models = BOOKING) {
|
|
149
|
+
const lines = [
|
|
150
|
+
'-- letopis 1.0 — демо-домен booking (план, этап 2, п. 8). Сгенерировано из lib/src/demo/booking.ts',
|
|
151
|
+
'-- командой `npm run seed:booking`; руками не править. Классы — системного арендатора, пишет владелец.',
|
|
152
|
+
'',
|
|
153
|
+
];
|
|
154
|
+
for (const m of models) {
|
|
155
|
+
const json = JSON.stringify(m.describe());
|
|
156
|
+
lines.push(`select @SCHEMA@.sys_create(@SCHEMA@.v5_id('@SYSTEM@'::uuid, 'Class', array['${m.name}']), 'Class', '@SYSTEM@'::uuid, '{}',\n $json$${json}$json$::jsonb);`);
|
|
157
|
+
}
|
|
158
|
+
return lines.join('\n') + '\n';
|
|
159
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ошибки letopis 1.0 (план, §7.1, этап 2, п. 10). Текст — «letopis: <код>: <сообщение>», как у ошибок
|
|
3
|
+
* базы; код — имя из каталога (`error_catalog()` в lib/sql/15-errors.sql) или код самой библиотеки.
|
|
4
|
+
*/
|
|
5
|
+
/** Нарушение правила данных: путь, ключевое слово JSON Schema, сообщение. Один формат для Zod, валидатора и базы. */
|
|
6
|
+
export interface Issue {
|
|
7
|
+
path: (string | number)[];
|
|
8
|
+
keyword: string;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class LetopisError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
readonly detail: unknown;
|
|
14
|
+
constructor(code: string, message: string, detail?: unknown);
|
|
15
|
+
}
|
|
16
|
+
/** Охранник снесённого API (план, этап 3, п. 11): понятная ошибка с версией и заменой. */
|
|
17
|
+
export declare function removed(name: string, replacement: string): LetopisError;
|
|
18
|
+
/** Метод зарезервирован, но появится на следующем этапе плана. */
|
|
19
|
+
export declare function notYet(name: string, stage: number): LetopisError;
|
|
20
|
+
/** Данные не проходят описание класса (`invalid_data`). */
|
|
21
|
+
export declare class ValidationError extends LetopisError {
|
|
22
|
+
readonly issues: Issue[];
|
|
23
|
+
constructor(cls: string, issues: Issue[]);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL
|
|
27
|
+
* (JSON) переносятся в detail; прочие ошибки возвращаются как есть.
|
|
28
|
+
*/
|
|
29
|
+
export declare function fromDbError(e: unknown): unknown;
|