@protoyard/blueprint 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +1149 -2
- package/dist/aggregate.d.ts +83 -0
- package/dist/aggregate.js +268 -0
- package/dist/app-model.d.ts +76 -0
- package/dist/app-model.js +287 -0
- package/dist/archetypes/index.d.ts +1 -0
- package/dist/archetypes/index.js +1 -0
- package/dist/archetypes/schema.d.ts +126 -0
- package/dist/archetypes/schema.js +77 -0
- package/dist/audit.d.ts +52 -0
- package/dist/audit.js +149 -0
- package/dist/authoring/deck-lint.d.ts +32 -0
- package/dist/authoring/deck-lint.js +567 -0
- package/dist/authoring/index.d.ts +1 -0
- package/dist/authoring/index.js +1 -0
- package/dist/authoring/select-deck-layouts.d.ts +50 -0
- package/dist/authoring/select-deck-layouts.js +213 -0
- package/dist/authoring-apis.d.ts +13 -0
- package/dist/authoring-apis.js +13 -0
- package/dist/authoring-validation.d.ts +65 -0
- package/dist/authoring-validation.js +267 -0
- package/dist/avatar.d.ts +25 -0
- package/dist/avatar.js +30 -0
- package/dist/brand.d.ts +70 -0
- package/dist/brand.js +294 -0
- package/dist/brief/archetypes.d.ts +28 -0
- package/dist/brief/archetypes.js +798 -0
- package/dist/brief/content-synthesize.d.ts +31 -0
- package/dist/brief/content-synthesize.js +210 -0
- package/dist/brief/deck-outlines.d.ts +96 -0
- package/dist/brief/deck-outlines.js +561 -0
- package/dist/brief/draft-bodies.d.ts +29 -0
- package/dist/brief/draft-bodies.js +224 -0
- package/dist/brief/fields.d.ts +19 -0
- package/dist/brief/fields.js +229 -0
- package/dist/brief/index.d.ts +8 -0
- package/dist/brief/index.js +8 -0
- package/dist/brief/kind-detection.d.ts +69 -0
- package/dist/brief/kind-detection.js +353 -0
- package/dist/brief/limits.d.ts +117 -0
- package/dist/brief/limits.js +217 -0
- package/dist/brief/page-doc-outlines.d.ts +80 -0
- package/dist/brief/page-doc-outlines.js +485 -0
- package/dist/brief/synthesize.d.ts +59 -0
- package/dist/brief/synthesize.js +511 -0
- package/dist/brief/text.d.ts +13 -0
- package/dist/brief/text.js +59 -0
- package/dist/canvas-screen.d.ts +31 -0
- package/dist/canvas-screen.js +33 -0
- package/dist/canvas-seed.d.ts +58 -0
- package/dist/canvas-seed.js +82 -0
- package/dist/computed/evaluate.d.ts +11 -0
- package/dist/computed/evaluate.js +101 -0
- package/dist/computed/index.d.ts +4 -0
- package/dist/computed/index.js +3 -0
- package/dist/computed/public.d.ts +14 -0
- package/dist/computed/public.js +19 -0
- package/dist/computed/schema.d.ts +10 -0
- package/dist/computed/schema.js +140 -0
- package/dist/computed/types.d.ts +70 -0
- package/dist/computed/types.js +1 -0
- package/dist/computed/validate.d.ts +6 -0
- package/dist/computed/validate.js +146 -0
- package/dist/csv-guard.d.ts +15 -0
- package/dist/csv-guard.js +29 -0
- package/dist/csv.d.ts +85 -0
- package/dist/csv.js +288 -0
- package/dist/files.d.ts +35 -0
- package/dist/files.js +246 -0
- package/dist/form-schema.d.ts +124 -0
- package/dist/form-schema.js +300 -0
- package/dist/generator-version.d.ts +11 -0
- package/dist/generator-version.js +51 -0
- package/dist/generator.d.ts +34 -0
- package/dist/generator.js +445 -0
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +91 -0
- package/dist/import/csv-shape.d.ts +40 -0
- package/dist/import/csv-shape.js +265 -0
- package/dist/import/json-shape.d.ts +55 -0
- package/dist/import/json-shape.js +491 -0
- package/dist/import/limits.d.ts +13 -0
- package/dist/import/limits.js +14 -0
- package/dist/import/sqlite-schema.d.ts +36 -0
- package/dist/import/sqlite-schema.js +729 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +58 -0
- package/dist/kinds/anchors.d.ts +18 -0
- package/dist/kinds/anchors.js +46 -0
- package/dist/kinds/bodies.d.ts +6 -0
- package/dist/kinds/bodies.js +32 -0
- package/dist/kinds/brand-runtime.d.ts +19 -0
- package/dist/kinds/brand-runtime.js +35 -0
- package/dist/kinds/deck.d.ts +8 -0
- package/dist/kinds/deck.js +34 -0
- package/dist/kinds/doc.d.ts +19 -0
- package/dist/kinds/doc.js +41 -0
- package/dist/kinds/embed/index.d.ts +39 -0
- package/dist/kinds/embed/index.js +91 -0
- package/dist/kinds/envelope.d.ts +159 -0
- package/dist/kinds/envelope.js +78 -0
- package/dist/kinds/index.d.ts +11 -0
- package/dist/kinds/index.js +11 -0
- package/dist/kinds/kind.d.ts +36 -0
- package/dist/kinds/kind.js +55 -0
- package/dist/kinds/limits.d.ts +64 -0
- package/dist/kinds/limits.js +190 -0
- package/dist/kinds/page-doc-runtime.d.ts +37 -0
- package/dist/kinds/page-doc-runtime.js +55 -0
- package/dist/kinds/page.d.ts +16 -0
- package/dist/kinds/page.js +67 -0
- package/dist/kinds/parse.d.ts +18 -0
- package/dist/kinds/parse.js +27 -0
- package/dist/kinds/runtime.d.ts +128 -0
- package/dist/kinds/runtime.js +206 -0
- package/dist/kinds/services.d.ts +49 -0
- package/dist/kinds/services.js +249 -0
- package/dist/kinds/testing.d.ts +9 -0
- package/dist/kinds/testing.js +16 -0
- package/dist/migrations.d.ts +97 -0
- package/dist/migrations.js +351 -0
- package/dist/motion.d.ts +50 -0
- package/dist/motion.js +52 -0
- package/dist/notifications.d.ts +19 -0
- package/dist/notifications.js +177 -0
- package/dist/patch.d.ts +122 -0
- package/dist/patch.js +322 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +105 -0
- package/dist/runtime-definition.d.ts +39 -0
- package/dist/runtime-definition.js +74 -0
- package/dist/scenarios.d.ts +9 -0
- package/dist/scenarios.js +49 -0
- package/dist/schema/blueprint.schema.json +10191 -0
- package/dist/schema-json.d.ts +6 -0
- package/dist/schema-json.js +36 -0
- package/dist/schema.d.ts +6348 -0
- package/dist/schema.js +1269 -0
- package/dist/search.d.ts +16 -0
- package/dist/search.js +96 -0
- package/dist/services.d.ts +89 -0
- package/dist/services.js +898 -0
- package/dist/share.d.ts +78 -0
- package/dist/share.js +234 -0
- package/dist/sheet-screen.d.ts +49 -0
- package/dist/sheet-screen.js +91 -0
- package/dist/storage.d.ts +43 -0
- package/dist/storage.js +179 -0
- package/dist/stream.d.ts +26 -0
- package/dist/stream.js +173 -0
- package/dist/untrusted.d.ts +63 -0
- package/dist/untrusted.js +502 -0
- package/dist/validation-tasks.d.ts +178 -0
- package/dist/validation-tasks.js +199 -0
- package/dist/validation.d.ts +35 -0
- package/dist/validation.js +312 -0
- package/dist/variants.d.ts +112 -0
- package/dist/variants.js +283 -0
- package/dist/workflows/index.d.ts +3 -0
- package/dist/workflows/index.js +3 -0
- package/dist/workflows/schema.d.ts +50 -0
- package/dist/workflows/schema.js +34 -0
- package/dist/workflows/transition.d.ts +15 -0
- package/dist/workflows/transition.js +25 -0
- package/dist/workflows/validate.d.ts +17 -0
- package/dist/workflows/validate.js +140 -0
- package/package.json +120 -3
package/dist/services.js
ADDED
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
import { createRuntimeId, parseInput, ValidationError, } from '@protoyard/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { blueprintUsesAudit, createAuditWriter } from "./audit.js";
|
|
4
|
+
import { avatarFields, nextAvatar } from "./avatar.js";
|
|
5
|
+
import { mapCsvHeaders, parseCsv, serializeEntityCsv, } from "./csv.js";
|
|
6
|
+
import { createNotificationWriter } from "./notifications.js";
|
|
7
|
+
import { entityTable, } from "./schema.js";
|
|
8
|
+
import { FEATURE_TABLES } from "./storage.js";
|
|
9
|
+
import { deriveInputSchemas, fieldLabel, inputProperty } from "./validation.js";
|
|
10
|
+
/** Thrown when a record does not exist (or was removed meanwhile). */
|
|
11
|
+
export class EntityNotFoundError extends Error {
|
|
12
|
+
code = 'not-found';
|
|
13
|
+
constructor(entity, id) {
|
|
14
|
+
super(`No ${entity} with id "${id}".`);
|
|
15
|
+
this.name = 'EntityNotFoundError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Thrown when `onDelete: 'restrict'` blocks a removal; nothing was deleted. */
|
|
19
|
+
export class EntityInUseError extends Error {
|
|
20
|
+
code = 'in-use';
|
|
21
|
+
constructor(entity, references) {
|
|
22
|
+
const list = Object.entries(references)
|
|
23
|
+
.map(([name, count]) => `${count} ${name}`)
|
|
24
|
+
.join(', ');
|
|
25
|
+
super(`This ${entity} can't be deleted: ${list} still reference it.`);
|
|
26
|
+
this.name = 'EntityInUseError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Thrown when commit is requested and invalid rows remain without `skipInvalid`. */
|
|
30
|
+
export class ImportCsvError extends Error {
|
|
31
|
+
code = 'import-invalid';
|
|
32
|
+
report;
|
|
33
|
+
constructor(report) {
|
|
34
|
+
super(`CSV import has ${report.errors.length} invalid cell(s) across ${report.rowCount} row(s); nothing was written.`);
|
|
35
|
+
this.name = 'ImportCsvError';
|
|
36
|
+
this.report = report;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Writes larger than this go through several `bulkPut` calls inside the same transaction. */
|
|
40
|
+
export const BULK_CHUNK = 1_000;
|
|
41
|
+
export const UNDO_WINDOW_MS = 30_000;
|
|
42
|
+
const UndoPayloadSchema = z.strictObject({ version: z.literal(2), rootId: z.string().min(1) });
|
|
43
|
+
class UndoTokenError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'UndoTokenError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function encodeUndoToken(payload) {
|
|
50
|
+
return `blueprint-undo-v2:${encodeURIComponent(JSON.stringify(payload))}`;
|
|
51
|
+
}
|
|
52
|
+
function decodeUndoToken(token) {
|
|
53
|
+
const prefix = 'blueprint-undo-v2:';
|
|
54
|
+
if (!token.startsWith(prefix))
|
|
55
|
+
throw new UndoTokenError('This undo token is invalid.');
|
|
56
|
+
try {
|
|
57
|
+
const result = UndoPayloadSchema.safeParse(JSON.parse(decodeURIComponent(token.slice(prefix.length))));
|
|
58
|
+
if (!result.success)
|
|
59
|
+
throw new UndoTokenError('This undo token is invalid.');
|
|
60
|
+
return result.data;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof UndoTokenError)
|
|
64
|
+
throw error;
|
|
65
|
+
throw new UndoTokenError('This undo token is invalid.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function putChunked(table, rows) {
|
|
69
|
+
for (let i = 0; i < rows.length; i += BULK_CHUNK) {
|
|
70
|
+
await table.bulkPut(rows.slice(i, i + BULK_CHUNK));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function createEntityServices(runtime, blueprint) {
|
|
74
|
+
const db = runtime.db;
|
|
75
|
+
const { session, environment, clock } = runtime;
|
|
76
|
+
const byName = new Map(blueprint.entities.map((e) => [e.name, e]));
|
|
77
|
+
const tables = blueprint.entities.map(entityTable);
|
|
78
|
+
const filesEnabled = blueprint.entities.some((e) => e.features.files);
|
|
79
|
+
const notificationsEnabled = blueprint.notifications.length > 0 || blueprint.entities.some((e) => e.features.notifications);
|
|
80
|
+
const auditEnabled = blueprintUsesAudit(blueprint);
|
|
81
|
+
const writeTables = [
|
|
82
|
+
...tables,
|
|
83
|
+
...(filesEnabled ? [FEATURE_TABLES.files] : []),
|
|
84
|
+
...(notificationsEnabled ? [FEATURE_TABLES.notifications] : []),
|
|
85
|
+
...(auditEnabled ? [FEATURE_TABLES.audit] : []),
|
|
86
|
+
'py_personas',
|
|
87
|
+
...(blueprint.notifications.some((rule) => rule.notify.startsWith('role:'))
|
|
88
|
+
? ['py_memberships']
|
|
89
|
+
: []),
|
|
90
|
+
];
|
|
91
|
+
const notify = createNotificationWriter(runtime, blueprint);
|
|
92
|
+
const audit = createAuditWriter(runtime, blueprint);
|
|
93
|
+
/** Relation fields (in any entity) that point at `target`. */
|
|
94
|
+
const referencesTo = (target) => blueprint.entities.flatMap((entity) => entity.fields.flatMap((field) => field.kind === 'relation' && field.to === target
|
|
95
|
+
? [{ entity, table: entityTable(entity), field, property: inputProperty(field) }]
|
|
96
|
+
: []));
|
|
97
|
+
const actorId = () => {
|
|
98
|
+
const principal = session.principal();
|
|
99
|
+
if (!principal)
|
|
100
|
+
throw new Error('No active persona. Load a scenario first.');
|
|
101
|
+
return principal.persona.id;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Resolves a relation cell to a stored id: exact id match first, then a unique
|
|
105
|
+
* title-field match on the target entity (soft-deleted targets are ignored).
|
|
106
|
+
*/
|
|
107
|
+
async function resolveRelationCell(field, raw) {
|
|
108
|
+
const trimmed = raw.trim();
|
|
109
|
+
if (!trimmed) {
|
|
110
|
+
throw new ValidationError({
|
|
111
|
+
[inputProperty(field)]: `${fieldLabel(field)} is required`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const target = byName.get(field.to);
|
|
115
|
+
if (!target) {
|
|
116
|
+
throw new ValidationError({
|
|
117
|
+
[inputProperty(field)]: `${fieldLabel(field)} target is unknown`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const targetTable = entityTable(target);
|
|
121
|
+
const byId = await db.table(targetTable).get(trimmed);
|
|
122
|
+
if (byId && !byId.deletedAt)
|
|
123
|
+
return byId.id;
|
|
124
|
+
const titleField = target.fields.find((f) => f.name === target.titleField);
|
|
125
|
+
const titleProp = titleField ? inputProperty(titleField) : 'id';
|
|
126
|
+
const matches = (await db.table(targetTable).toArray()).filter((row) => !row.deletedAt && String(row[titleProp] ?? '') === trimmed);
|
|
127
|
+
if (matches.length === 1)
|
|
128
|
+
return matches[0].id;
|
|
129
|
+
if (matches.length === 0) {
|
|
130
|
+
throw new ValidationError({
|
|
131
|
+
[inputProperty(field)]: `${fieldLabel(field)} not found`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
throw new ValidationError({
|
|
135
|
+
[inputProperty(field)]: `${fieldLabel(field)} matches more than one ${target.name}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async function resolveImportCell(field, raw) {
|
|
139
|
+
if (!field)
|
|
140
|
+
return raw;
|
|
141
|
+
if (field.kind === 'relation') {
|
|
142
|
+
if (raw.trim() === '')
|
|
143
|
+
return null;
|
|
144
|
+
if (field.cardinality === 'many') {
|
|
145
|
+
const parts = raw
|
|
146
|
+
.split(',')
|
|
147
|
+
.map((p) => p.trim())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
const ids = [];
|
|
150
|
+
for (const part of parts)
|
|
151
|
+
ids.push(await resolveRelationCell(field, part));
|
|
152
|
+
return ids;
|
|
153
|
+
}
|
|
154
|
+
return resolveRelationCell(field, raw);
|
|
155
|
+
}
|
|
156
|
+
if (field.kind === 'persona') {
|
|
157
|
+
if (raw.trim() === '')
|
|
158
|
+
return null;
|
|
159
|
+
const trimmed = raw.trim();
|
|
160
|
+
const byId = await db.table('py_personas').get(trimmed);
|
|
161
|
+
if (byId)
|
|
162
|
+
return byId.id;
|
|
163
|
+
const people = await db.table('py_personas').toArray();
|
|
164
|
+
const matches = people.filter((row) => String(row.name ?? '') === trimmed ||
|
|
165
|
+
String(row.displayName ?? '') === trimmed ||
|
|
166
|
+
String(row.email ?? '') === trimmed);
|
|
167
|
+
if (matches.length === 1)
|
|
168
|
+
return matches[0].id;
|
|
169
|
+
if (matches.length === 0) {
|
|
170
|
+
throw new ValidationError({
|
|
171
|
+
[inputProperty(field)]: `${fieldLabel(field)} not found`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
throw new ValidationError({
|
|
175
|
+
[inputProperty(field)]: `${fieldLabel(field)} matches more than one persona`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return raw;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Parse + resolve relations/personas by id or title, then validate each row
|
|
182
|
+
* against the create schema. Does not write.
|
|
183
|
+
*/
|
|
184
|
+
async function previewImportCsv(entity, text, options = {}) {
|
|
185
|
+
const matrix = parseCsv(text, options);
|
|
186
|
+
const [headerRow, ...dataRows] = matrix;
|
|
187
|
+
if (!headerRow) {
|
|
188
|
+
return { headers: [], columns: [], valid: [], errors: [], rowCount: 0 };
|
|
189
|
+
}
|
|
190
|
+
const columns = mapCsvHeaders(headerRow, entity);
|
|
191
|
+
const { create } = deriveInputSchemas(entity);
|
|
192
|
+
const valid = [];
|
|
193
|
+
const errors = [];
|
|
194
|
+
let rowCount = 0;
|
|
195
|
+
for (let index = 0; index < dataRows.length; index += 1) {
|
|
196
|
+
const cells = dataRows[index];
|
|
197
|
+
if (cells.every((c) => c.trim() === ''))
|
|
198
|
+
continue;
|
|
199
|
+
rowCount += 1;
|
|
200
|
+
const rowNumber = index + 1;
|
|
201
|
+
const raw = {};
|
|
202
|
+
try {
|
|
203
|
+
for (let colIndex = 0; colIndex < columns.length; colIndex += 1) {
|
|
204
|
+
const col = columns[colIndex];
|
|
205
|
+
if (!col?.property)
|
|
206
|
+
continue;
|
|
207
|
+
raw[col.property] = await resolveImportCell(col.field, cells[colIndex] ?? '');
|
|
208
|
+
}
|
|
209
|
+
valid.push(parseInput(create, raw));
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error instanceof ValidationError) {
|
|
213
|
+
for (const [field, message] of Object.entries(error.issues)) {
|
|
214
|
+
errors.push({ row: rowNumber, field, message });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
errors.push({
|
|
219
|
+
row: rowNumber,
|
|
220
|
+
field: 'row',
|
|
221
|
+
message: error instanceof Error ? error.message : String(error),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { headers: headerRow, columns, valid, errors, rowCount };
|
|
227
|
+
}
|
|
228
|
+
/** CSV import commits use the same audit writer as live mutations (diff + redaction). */
|
|
229
|
+
async function writeAuditBatch(entity, rows, action) {
|
|
230
|
+
if (!entity.features.audit || rows.length === 0)
|
|
231
|
+
return [];
|
|
232
|
+
const ids = [];
|
|
233
|
+
for (const row of rows) {
|
|
234
|
+
const entry = await audit({
|
|
235
|
+
action,
|
|
236
|
+
entity,
|
|
237
|
+
recordId: row.id,
|
|
238
|
+
before: null,
|
|
239
|
+
after: row,
|
|
240
|
+
});
|
|
241
|
+
if (entry)
|
|
242
|
+
ids.push(entry.id);
|
|
243
|
+
}
|
|
244
|
+
return ids;
|
|
245
|
+
}
|
|
246
|
+
/** Fails with a field-keyed ValidationError when a referenced row is missing. */
|
|
247
|
+
async function assertReferences(entity, values) {
|
|
248
|
+
const issues = {};
|
|
249
|
+
for (const field of entity.fields) {
|
|
250
|
+
const property = inputProperty(field);
|
|
251
|
+
const value = values[property];
|
|
252
|
+
if (value === null || value === undefined)
|
|
253
|
+
continue;
|
|
254
|
+
const table = field.kind === 'relation'
|
|
255
|
+
? entityTable(byName.get(field.to))
|
|
256
|
+
: field.kind === 'persona'
|
|
257
|
+
? 'py_personas'
|
|
258
|
+
: field.kind === 'file' && filesEnabled
|
|
259
|
+
? FEATURE_TABLES.files
|
|
260
|
+
: null;
|
|
261
|
+
if (!table)
|
|
262
|
+
continue;
|
|
263
|
+
const ids = Array.isArray(value) ? value : [value];
|
|
264
|
+
for (const id of ids) {
|
|
265
|
+
const target = await db.table(table).get(id);
|
|
266
|
+
if (!target || (field.kind === 'relation' && Boolean(target.deletedAt))) {
|
|
267
|
+
issues[property] = `${fieldLabel(field)} not found`;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (Object.keys(issues).length > 0)
|
|
273
|
+
throw new ValidationError(issues);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Deletes `ids` of `entity` and applies every `onDelete` rule in the current
|
|
277
|
+
* transaction: cascades are followed first, then restrict blocks and
|
|
278
|
+
* nullify clears references from rows that survive.
|
|
279
|
+
*/
|
|
280
|
+
async function collectDeleteEffects(entity, ids, softDelete = false) {
|
|
281
|
+
const doomed = new Map();
|
|
282
|
+
const queue = [[entity, [...ids]]];
|
|
283
|
+
while (queue.length > 0) {
|
|
284
|
+
const [current, batch] = queue.shift();
|
|
285
|
+
const set = doomed.get(current.name) ?? new Set();
|
|
286
|
+
doomed.set(current.name, set);
|
|
287
|
+
const fresh = batch.filter((id) => !set.has(id));
|
|
288
|
+
for (const id of fresh)
|
|
289
|
+
set.add(id);
|
|
290
|
+
if (fresh.length === 0)
|
|
291
|
+
continue;
|
|
292
|
+
for (const ref of referencesTo(current.name)) {
|
|
293
|
+
if (ref.field.onDelete !== 'cascade')
|
|
294
|
+
continue;
|
|
295
|
+
const rows = (await db.table(ref.table).where(ref.property).anyOf(fresh).toArray()).filter((row) => !softDelete || !row.deletedAt);
|
|
296
|
+
if (rows.length > 0)
|
|
297
|
+
queue.push([ref.entity, rows.map((r) => r.id)]);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const blocked = {};
|
|
301
|
+
const nullified = new Map();
|
|
302
|
+
const originalReferences = new Map();
|
|
303
|
+
for (const [name, set] of doomed) {
|
|
304
|
+
const removed = [...set];
|
|
305
|
+
for (const ref of referencesTo(name)) {
|
|
306
|
+
if (ref.field.onDelete === 'cascade')
|
|
307
|
+
continue;
|
|
308
|
+
const survivors = (await db.table(ref.table).where(ref.property).anyOf(removed).toArray()).filter((row) => !doomed.get(ref.entity.name)?.has(row.id) && (!softDelete || !row.deletedAt));
|
|
309
|
+
if (survivors.length === 0)
|
|
310
|
+
continue;
|
|
311
|
+
if (ref.field.onDelete === 'restrict') {
|
|
312
|
+
blocked[ref.entity.plural] = (blocked[ref.entity.plural] ?? 0) + survivors.length;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const pending = nullified.get(ref.table) ?? new Map();
|
|
316
|
+
nullified.set(ref.table, pending);
|
|
317
|
+
const originals = originalReferences.get(ref.table) ?? new Map();
|
|
318
|
+
originalReferences.set(ref.table, originals);
|
|
319
|
+
for (const row of survivors) {
|
|
320
|
+
const next = { ...(pending.get(row.id) ?? row) };
|
|
321
|
+
const value = next[ref.property];
|
|
322
|
+
const rowOriginals = originals.get(row.id) ?? {};
|
|
323
|
+
if (!(ref.property in rowOriginals)) {
|
|
324
|
+
rowOriginals[ref.property] = Array.isArray(value)
|
|
325
|
+
? [...value]
|
|
326
|
+
: typeof value === 'string'
|
|
327
|
+
? value
|
|
328
|
+
: null;
|
|
329
|
+
}
|
|
330
|
+
originals.set(row.id, rowOriginals);
|
|
331
|
+
next[ref.property] = Array.isArray(value)
|
|
332
|
+
? value.filter((id) => !set.has(id))
|
|
333
|
+
: null;
|
|
334
|
+
next.updatedAt = clock.now().toISOString();
|
|
335
|
+
pending.set(row.id, next);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (Object.keys(blocked).length > 0)
|
|
340
|
+
throw new EntityInUseError(entity.name, blocked);
|
|
341
|
+
return { doomed, nullified, originalReferences, blocked };
|
|
342
|
+
}
|
|
343
|
+
async function deleteWithEffects(entity, ids, softDeleteAt, deletedBy, deleteGroupId) {
|
|
344
|
+
const effects = await collectDeleteEffects(entity, ids, softDeleteAt !== undefined);
|
|
345
|
+
for (const [table, rows] of effects.nullified) {
|
|
346
|
+
const originals = effects.originalReferences.get(table);
|
|
347
|
+
const updated = [...rows.values()].map((row) => {
|
|
348
|
+
if (!softDeleteAt || !deleteGroupId)
|
|
349
|
+
return row;
|
|
350
|
+
const fields = originals?.get(row.id) ?? {};
|
|
351
|
+
const order = row.deleteGroupReferenceOrder && typeof row.deleteGroupReferenceOrder === 'object'
|
|
352
|
+
? { ...row.deleteGroupReferenceOrder }
|
|
353
|
+
: {};
|
|
354
|
+
for (const [property, value] of Object.entries(fields)) {
|
|
355
|
+
if (!(property in order) && Array.isArray(value))
|
|
356
|
+
order[property] = value;
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
...row,
|
|
360
|
+
deleteGroupId,
|
|
361
|
+
deleteGroupReferences: {
|
|
362
|
+
...(row.deleteGroupReferences && typeof row.deleteGroupReferences === 'object'
|
|
363
|
+
? row.deleteGroupReferences
|
|
364
|
+
: {}),
|
|
365
|
+
[deleteGroupId]: fields,
|
|
366
|
+
},
|
|
367
|
+
deleteGroupReferenceOrder: order,
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
await putChunked(db.table(table), updated);
|
|
371
|
+
}
|
|
372
|
+
let count = 0;
|
|
373
|
+
const undoRows = [];
|
|
374
|
+
if (softDeleteAt !== undefined) {
|
|
375
|
+
for (const [name, set] of effects.doomed) {
|
|
376
|
+
const idsToDelete = [...set];
|
|
377
|
+
const target = byName.get(name);
|
|
378
|
+
const targetTable = db.table(entityTable(target));
|
|
379
|
+
const updated = [];
|
|
380
|
+
for (const id of idsToDelete) {
|
|
381
|
+
const row = await targetTable.get(id);
|
|
382
|
+
if (!row || row.deletedAt)
|
|
383
|
+
continue;
|
|
384
|
+
updated.push({
|
|
385
|
+
...row,
|
|
386
|
+
deletedAt: softDeleteAt,
|
|
387
|
+
deletedBy,
|
|
388
|
+
...(deleteGroupId ? { deleteGroupId } : {}),
|
|
389
|
+
updatedAt: softDeleteAt,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (updated.length > 0) {
|
|
393
|
+
await putChunked(targetTable, updated);
|
|
394
|
+
undoRows.push({ entity: name, ids: updated.map((row) => row.id) });
|
|
395
|
+
count += updated.length;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const nullified = [...effects.originalReferences.entries()].flatMap(([targetTable, rows]) => {
|
|
399
|
+
const tableEntity = blueprint.entities.find((item) => entityTable(item) === targetTable);
|
|
400
|
+
if (!tableEntity)
|
|
401
|
+
return [];
|
|
402
|
+
return [...rows.entries()].map(([id, fields]) => ({
|
|
403
|
+
entity: tableEntity.name,
|
|
404
|
+
id,
|
|
405
|
+
fields,
|
|
406
|
+
}));
|
|
407
|
+
});
|
|
408
|
+
return { rows: undoRows, nullified, count };
|
|
409
|
+
}
|
|
410
|
+
for (const [name, set] of effects.doomed) {
|
|
411
|
+
count += set.size;
|
|
412
|
+
const target = byName.get(name);
|
|
413
|
+
const targetTable = db.table(entityTable(target));
|
|
414
|
+
const doomedRows = await Promise.all([...set].map((id) => targetTable.get(id)));
|
|
415
|
+
if (filesEnabled) {
|
|
416
|
+
const fileIds = target.fields
|
|
417
|
+
.filter((field) => field.kind === 'file')
|
|
418
|
+
.flatMap((field) => doomedRows.flatMap((row) => {
|
|
419
|
+
const value = row?.[inputProperty(field)];
|
|
420
|
+
return Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
|
421
|
+
}));
|
|
422
|
+
if (fileIds.length > 0)
|
|
423
|
+
await db.table(FEATURE_TABLES.files).bulkDelete(fileIds);
|
|
424
|
+
}
|
|
425
|
+
await targetTable.bulkDelete([...set]);
|
|
426
|
+
}
|
|
427
|
+
return count;
|
|
428
|
+
}
|
|
429
|
+
function serviceFor(entity) {
|
|
430
|
+
const table = entityTable(entity);
|
|
431
|
+
const cap = (action) => `${entity.name}:${action}`;
|
|
432
|
+
const op = (name) => `${entity.name}.${name}`;
|
|
433
|
+
const schemas = () => deriveInputSchemas(entity);
|
|
434
|
+
const ownerField = entity.fields.find((f) => f.name === entity.ownerField);
|
|
435
|
+
const ownerProperty = ownerField ? inputProperty(ownerField) : undefined;
|
|
436
|
+
const sort = entity.defaultSort ?? { field: 'createdAt', direction: 'asc' };
|
|
437
|
+
const sortField = entity.fields.find((f) => f.name === sort.field);
|
|
438
|
+
const sortProperty = sortField ? inputProperty(sortField) : sort.field;
|
|
439
|
+
const sortRows = (rows) => {
|
|
440
|
+
const dir = sort.direction === 'desc' ? -1 : 1;
|
|
441
|
+
return rows.sort((a, b) => {
|
|
442
|
+
const x = a[sortProperty];
|
|
443
|
+
const y = b[sortProperty];
|
|
444
|
+
if (x === y)
|
|
445
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
446
|
+
if (x === null || x === undefined)
|
|
447
|
+
return 1;
|
|
448
|
+
if (y === null || y === undefined)
|
|
449
|
+
return -1;
|
|
450
|
+
return (x < y ? -1 : 1) * dir;
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
async function restoreGroup(root, checkExpiry) {
|
|
454
|
+
const groupId = typeof root.deleteGroupId === 'string' ? root.deleteGroupId : '';
|
|
455
|
+
const expiresAt = root.deleteGroupExpiresAt;
|
|
456
|
+
if (!groupId || !Array.isArray(root.deleteGroupRows)) {
|
|
457
|
+
throw new UndoTokenError('This undo set is no longer available.');
|
|
458
|
+
}
|
|
459
|
+
if (checkExpiry && (typeof expiresAt !== 'number' || clock.nowMs() > expiresAt)) {
|
|
460
|
+
throw new UndoTokenError('The undo window has expired.');
|
|
461
|
+
}
|
|
462
|
+
const restored = [];
|
|
463
|
+
for (const item of root.deleteGroupRows) {
|
|
464
|
+
const target = byName.get(item.entity);
|
|
465
|
+
if (!target || !Array.isArray(item.ids))
|
|
466
|
+
throw new UndoTokenError('This undo set is invalid.');
|
|
467
|
+
const targetTable = db.table(entityTable(target));
|
|
468
|
+
const rows = await Promise.all(item.ids.map((id) => targetTable.get(id)));
|
|
469
|
+
if (rows.some((row) => !row || row.deleteGroupId !== groupId || !row.deletedAt)) {
|
|
470
|
+
throw new UndoTokenError('The rows in this undo set are no longer available.');
|
|
471
|
+
}
|
|
472
|
+
restored.push({
|
|
473
|
+
entity: target,
|
|
474
|
+
rows: rows.map((row) => ({
|
|
475
|
+
...row,
|
|
476
|
+
deletedAt: null,
|
|
477
|
+
deletedBy: null,
|
|
478
|
+
deleteGroupId: null,
|
|
479
|
+
updatedAt: clock.now().toISOString(),
|
|
480
|
+
})),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
// Restore the deleted targets before restoring survivor references.
|
|
484
|
+
let count = 0;
|
|
485
|
+
for (const group of restored) {
|
|
486
|
+
await putChunked(db.table(entityTable(group.entity)), group.rows);
|
|
487
|
+
count += group.rows.length;
|
|
488
|
+
}
|
|
489
|
+
const nullified = Array.isArray(root.deleteGroupNullified)
|
|
490
|
+
? root.deleteGroupNullified
|
|
491
|
+
: [];
|
|
492
|
+
const changed = new Map();
|
|
493
|
+
for (const item of nullified) {
|
|
494
|
+
const target = byName.get(item.entity);
|
|
495
|
+
if (!target)
|
|
496
|
+
continue;
|
|
497
|
+
const targetTable = entityTable(target);
|
|
498
|
+
const row = await db.table(targetTable).get(item.id);
|
|
499
|
+
if (!row || row.deletedAt)
|
|
500
|
+
continue;
|
|
501
|
+
const groupReferences = row.deleteGroupReferences;
|
|
502
|
+
if (!groupReferences ||
|
|
503
|
+
typeof groupReferences !== 'object' ||
|
|
504
|
+
Array.isArray(groupReferences))
|
|
505
|
+
continue;
|
|
506
|
+
const fields = groupReferences[groupId];
|
|
507
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields))
|
|
508
|
+
continue;
|
|
509
|
+
const next = { ...row };
|
|
510
|
+
const restoreFields = {};
|
|
511
|
+
for (const [property, original] of Object.entries(fields)) {
|
|
512
|
+
const current = next[property];
|
|
513
|
+
if (Array.isArray(original)) {
|
|
514
|
+
const values = Array.isArray(current) ? [...current] : [];
|
|
515
|
+
const storedOrder = row.deleteGroupReferenceOrder;
|
|
516
|
+
const order = storedOrder &&
|
|
517
|
+
typeof storedOrder === 'object' &&
|
|
518
|
+
!Array.isArray(storedOrder) &&
|
|
519
|
+
Array.isArray(storedOrder[property])
|
|
520
|
+
? storedOrder[property]
|
|
521
|
+
: original;
|
|
522
|
+
const field = target.fields.find((candidate) => inputProperty(candidate) === property);
|
|
523
|
+
const eligible = [];
|
|
524
|
+
for (const id of order) {
|
|
525
|
+
if (typeof id !== 'string')
|
|
526
|
+
continue;
|
|
527
|
+
if (field?.kind === 'relation') {
|
|
528
|
+
const targetEntity = byName.get(field.to);
|
|
529
|
+
const targetRow = targetEntity
|
|
530
|
+
? await db.table(entityTable(targetEntity)).get(id)
|
|
531
|
+
: undefined;
|
|
532
|
+
if (!targetRow || targetRow.deletedAt)
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
eligible.push(id);
|
|
536
|
+
}
|
|
537
|
+
const restoredValues = [...eligible, ...values.filter((id) => !order.includes(id))];
|
|
538
|
+
next[property] = restoredValues;
|
|
539
|
+
restoreFields[property] = restoredValues;
|
|
540
|
+
}
|
|
541
|
+
else if (current === null || current === undefined) {
|
|
542
|
+
next[property] = original;
|
|
543
|
+
restoreFields[property] = original;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
await assertReferences(target, restoreFields);
|
|
547
|
+
const remaining = { ...groupReferences };
|
|
548
|
+
delete remaining[groupId];
|
|
549
|
+
next.deleteGroupReferences = Object.keys(remaining).length > 0 ? remaining : null;
|
|
550
|
+
next.deleteGroupReferenceOrder =
|
|
551
|
+
Object.keys(remaining).length > 0 ? row.deleteGroupReferenceOrder : null;
|
|
552
|
+
if (Object.keys(remaining).length === 0)
|
|
553
|
+
next.deleteGroupId = null;
|
|
554
|
+
next.updatedAt = clock.now().toISOString();
|
|
555
|
+
const rows = changed.get(targetTable) ?? new Map();
|
|
556
|
+
rows.set(item.id, next);
|
|
557
|
+
changed.set(targetTable, rows);
|
|
558
|
+
}
|
|
559
|
+
for (const [targetTable, rows] of changed)
|
|
560
|
+
await putChunked(db.table(targetTable), [...rows.values()]);
|
|
561
|
+
const rootAfter = await db.table(table).get(root.id);
|
|
562
|
+
if (rootAfter) {
|
|
563
|
+
const after = {
|
|
564
|
+
...rootAfter,
|
|
565
|
+
deleteGroupId: null,
|
|
566
|
+
deleteGroupRows: null,
|
|
567
|
+
deleteGroupNullified: null,
|
|
568
|
+
deleteGroupExpiresAt: null,
|
|
569
|
+
updatedAt: clock.now().toISOString(),
|
|
570
|
+
};
|
|
571
|
+
await db.table(table).put(after);
|
|
572
|
+
await audit({ action: 'restore', entity, recordId: root.id, before: root, after });
|
|
573
|
+
}
|
|
574
|
+
return count;
|
|
575
|
+
}
|
|
576
|
+
/** `:own` actions fall back to the record rule; denial names the full capability. */
|
|
577
|
+
const assertOn = (action, row) => {
|
|
578
|
+
if (session.can(cap(action)) || session.can(cap(`${action}:own`), row))
|
|
579
|
+
return;
|
|
580
|
+
session.assert(cap(action), row);
|
|
581
|
+
};
|
|
582
|
+
const load = async (id) => {
|
|
583
|
+
const row = await db.table(table).get(id);
|
|
584
|
+
if (!row || row.deletedAt)
|
|
585
|
+
throw new EntityNotFoundError(entity.name, id);
|
|
586
|
+
return row;
|
|
587
|
+
};
|
|
588
|
+
const visible = (row) => !row.deletedAt;
|
|
589
|
+
const readVisibleRows = async () => {
|
|
590
|
+
session.assert(cap('read'));
|
|
591
|
+
return sortRows((await db.table(table).toArray()).filter(visible));
|
|
592
|
+
};
|
|
593
|
+
return {
|
|
594
|
+
entity,
|
|
595
|
+
table,
|
|
596
|
+
async list() {
|
|
597
|
+
return readVisibleRows();
|
|
598
|
+
},
|
|
599
|
+
async trash() {
|
|
600
|
+
session.assert(cap('read'));
|
|
601
|
+
if (!entity.features.softDelete)
|
|
602
|
+
return [];
|
|
603
|
+
const rows = await db.table(table).where('deletedAt').above('').toArray();
|
|
604
|
+
return sortRows(rows);
|
|
605
|
+
},
|
|
606
|
+
async get(id) {
|
|
607
|
+
session.assert(cap('read'));
|
|
608
|
+
const row = await db.table(table).get(id);
|
|
609
|
+
return row && visible(row) ? row : undefined;
|
|
610
|
+
},
|
|
611
|
+
async create(input) {
|
|
612
|
+
session.assert(cap('create'));
|
|
613
|
+
const values = parseInput(schemas().create, input);
|
|
614
|
+
if (ownerProperty &&
|
|
615
|
+
(values[ownerProperty] === null || values[ownerProperty] === undefined)) {
|
|
616
|
+
values[ownerProperty] = actorId();
|
|
617
|
+
}
|
|
618
|
+
return environment.run(op('create'), () => db.transaction('rw', writeTables, async () => {
|
|
619
|
+
await assertReferences(entity, values);
|
|
620
|
+
const now = clock.now().toISOString();
|
|
621
|
+
const row = {
|
|
622
|
+
...values,
|
|
623
|
+
id: createRuntimeId(entity.name.replace(/-/g, '_')),
|
|
624
|
+
createdAt: now,
|
|
625
|
+
updatedAt: now,
|
|
626
|
+
};
|
|
627
|
+
await db.table(table).put(row);
|
|
628
|
+
await audit({ action: 'create', entity, recordId: row.id, before: null, after: row });
|
|
629
|
+
await notify({ on: 'create', entity, row });
|
|
630
|
+
return row;
|
|
631
|
+
}));
|
|
632
|
+
},
|
|
633
|
+
async update(id, patch) {
|
|
634
|
+
const existing = await load(id);
|
|
635
|
+
assertOn('update', existing);
|
|
636
|
+
const values = parseInput(schemas().update, patch);
|
|
637
|
+
return environment.run(op('update'), () => db.transaction('rw', writeTables, async () => {
|
|
638
|
+
const current = await load(id);
|
|
639
|
+
await assertReferences(entity, values);
|
|
640
|
+
const row = { ...current, ...values, id, updatedAt: clock.now().toISOString() };
|
|
641
|
+
await db.table(table).put(row);
|
|
642
|
+
await audit({
|
|
643
|
+
action: 'update',
|
|
644
|
+
entity,
|
|
645
|
+
recordId: id,
|
|
646
|
+
before: current,
|
|
647
|
+
after: row,
|
|
648
|
+
});
|
|
649
|
+
await notify({ on: 'update', entity, row, previous: current });
|
|
650
|
+
return row;
|
|
651
|
+
}));
|
|
652
|
+
},
|
|
653
|
+
async remove(id) {
|
|
654
|
+
const existing = await load(id);
|
|
655
|
+
assertOn('delete', existing);
|
|
656
|
+
if (entity.features.softDelete) {
|
|
657
|
+
return environment.run(op('remove'), () => db.transaction('rw', writeTables, async () => {
|
|
658
|
+
const current = await load(id);
|
|
659
|
+
const deletedAt = clock.now().toISOString();
|
|
660
|
+
const deleteGroupId = createRuntimeId('delete_group');
|
|
661
|
+
const result = await deleteWithEffects(entity, [id], deletedAt, actorId(), deleteGroupId);
|
|
662
|
+
if (typeof result === 'number')
|
|
663
|
+
throw new Error('Soft delete did not create an undo set.');
|
|
664
|
+
const storedRoot = await db.table(table).get(id);
|
|
665
|
+
if (storedRoot) {
|
|
666
|
+
await db.table(table).put({
|
|
667
|
+
...storedRoot,
|
|
668
|
+
deleteGroupRows: result.rows,
|
|
669
|
+
deleteGroupNullified: result.nullified.map(({ entity: name, id: rowId }) => ({
|
|
670
|
+
entity: name,
|
|
671
|
+
id: rowId,
|
|
672
|
+
})),
|
|
673
|
+
deleteGroupExpiresAt: clock.nowMs() + UNDO_WINDOW_MS,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
await audit({
|
|
677
|
+
action: 'delete',
|
|
678
|
+
entity,
|
|
679
|
+
recordId: id,
|
|
680
|
+
before: current,
|
|
681
|
+
after: { ...current, deletedAt, deletedBy: actorId(), deleteGroupId },
|
|
682
|
+
});
|
|
683
|
+
const payload = { version: 2, rootId: id };
|
|
684
|
+
return encodeUndoToken(payload);
|
|
685
|
+
}));
|
|
686
|
+
}
|
|
687
|
+
await environment.run(op('remove'), () => db.transaction('rw', writeTables, async () => {
|
|
688
|
+
const current = await load(id);
|
|
689
|
+
await deleteWithEffects(entity, [id]);
|
|
690
|
+
await audit({
|
|
691
|
+
action: 'delete',
|
|
692
|
+
entity,
|
|
693
|
+
recordId: id,
|
|
694
|
+
before: current,
|
|
695
|
+
after: null,
|
|
696
|
+
});
|
|
697
|
+
}));
|
|
698
|
+
return undefined;
|
|
699
|
+
},
|
|
700
|
+
async restore(undoToken) {
|
|
701
|
+
const payload = decodeUndoToken(undoToken);
|
|
702
|
+
const root = await db.table(table).get(payload.rootId);
|
|
703
|
+
if (!root?.deletedAt || typeof root.deleteGroupId !== 'string')
|
|
704
|
+
throw new EntityNotFoundError(entity.name, payload.rootId);
|
|
705
|
+
assertOn('delete', root);
|
|
706
|
+
return environment.run(op('restore'), () => db.transaction('rw', writeTables, async () => {
|
|
707
|
+
const current = await db.table(table).get(payload.rootId);
|
|
708
|
+
if (!current?.deletedAt)
|
|
709
|
+
throw new EntityNotFoundError(entity.name, payload.rootId);
|
|
710
|
+
assertOn('delete', current);
|
|
711
|
+
return restoreGroup(current, true);
|
|
712
|
+
}));
|
|
713
|
+
},
|
|
714
|
+
async restoreTrashed(id) {
|
|
715
|
+
const recordId = parseInput(z.string().min(1), id);
|
|
716
|
+
if (!entity.features.softDelete)
|
|
717
|
+
throw new EntityNotFoundError(entity.name, recordId);
|
|
718
|
+
const existing = await db.table(table).get(recordId);
|
|
719
|
+
if (!existing?.deletedAt)
|
|
720
|
+
throw new EntityNotFoundError(entity.name, recordId);
|
|
721
|
+
assertOn('delete', existing);
|
|
722
|
+
return environment.run(op('restore'), () => db.transaction('rw', writeTables, async () => {
|
|
723
|
+
const root = await db.table(table).get(recordId);
|
|
724
|
+
if (!root?.deletedAt)
|
|
725
|
+
throw new EntityNotFoundError(entity.name, recordId);
|
|
726
|
+
assertOn('delete', root);
|
|
727
|
+
return restoreGroup(root, false);
|
|
728
|
+
}));
|
|
729
|
+
},
|
|
730
|
+
async purge(id) {
|
|
731
|
+
const existing = await db.table(table).get(id);
|
|
732
|
+
if (!existing?.deletedAt)
|
|
733
|
+
throw new EntityNotFoundError(entity.name, id);
|
|
734
|
+
session.assert(cap('delete'), existing);
|
|
735
|
+
return environment.run(op('purge'), () => db.transaction('rw', writeTables, async () => {
|
|
736
|
+
const current = await db.table(table).get(id);
|
|
737
|
+
if (!current?.deletedAt)
|
|
738
|
+
throw new EntityNotFoundError(entity.name, id);
|
|
739
|
+
const deleted = await deleteWithEffects(entity, [id]);
|
|
740
|
+
if (typeof deleted !== 'number')
|
|
741
|
+
throw new Error('Purge did not hard delete rows.');
|
|
742
|
+
await audit({
|
|
743
|
+
action: 'purge',
|
|
744
|
+
entity,
|
|
745
|
+
recordId: id,
|
|
746
|
+
before: current,
|
|
747
|
+
after: null,
|
|
748
|
+
});
|
|
749
|
+
return deleted;
|
|
750
|
+
}));
|
|
751
|
+
},
|
|
752
|
+
async shuffleAvatar(id, field) {
|
|
753
|
+
const existing = await load(id);
|
|
754
|
+
assertOn('update', existing);
|
|
755
|
+
const fields = avatarFields(entity);
|
|
756
|
+
const avatarField = field === undefined ? fields[0] : fields.find((candidate) => candidate.name === field);
|
|
757
|
+
if (!avatarField) {
|
|
758
|
+
throw new ValidationError({
|
|
759
|
+
field: fields.length === 0
|
|
760
|
+
? `${entity.label ?? entity.name} has no avatar field`
|
|
761
|
+
: `Pick one of: ${fields.map((f) => f.name).join(', ')}`,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
const property = inputProperty(avatarField);
|
|
765
|
+
const key = `${entity.name}:${id}:${avatarField.name}`;
|
|
766
|
+
const values = parseInput(schemas().update, {
|
|
767
|
+
[property]: nextAvatar(existing[property], key),
|
|
768
|
+
});
|
|
769
|
+
return environment.run(op('shuffleAvatar'), () => db.transaction('rw', writeTables, async () => {
|
|
770
|
+
const current = await load(id);
|
|
771
|
+
const row = { ...current, ...values, id, updatedAt: clock.now().toISOString() };
|
|
772
|
+
await db.table(table).put(row);
|
|
773
|
+
await audit({ action: 'update', entity, recordId: id, before: current, after: row });
|
|
774
|
+
await notify({ on: 'update', entity, row, previous: current });
|
|
775
|
+
return row;
|
|
776
|
+
}));
|
|
777
|
+
},
|
|
778
|
+
async bulkUpdate(ids, patch) {
|
|
779
|
+
const unique = [...new Set(ids)];
|
|
780
|
+
const existing = await Promise.all(unique.map(load));
|
|
781
|
+
for (const row of existing)
|
|
782
|
+
assertOn('update', row);
|
|
783
|
+
const values = parseInput(schemas().update, patch);
|
|
784
|
+
return environment.run(op('bulkUpdate'), () => db.transaction('rw', writeTables, async () => {
|
|
785
|
+
await assertReferences(entity, values);
|
|
786
|
+
const now = clock.now().toISOString();
|
|
787
|
+
const current = await Promise.all(unique.map(load));
|
|
788
|
+
const rows = current.map((row) => ({ ...row, ...values, id: row.id, updatedAt: now }));
|
|
789
|
+
await putChunked(db.table(table), rows);
|
|
790
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
791
|
+
const previous = current[i];
|
|
792
|
+
const next = rows[i];
|
|
793
|
+
await audit({
|
|
794
|
+
action: 'update',
|
|
795
|
+
entity,
|
|
796
|
+
recordId: next.id,
|
|
797
|
+
before: previous,
|
|
798
|
+
after: next,
|
|
799
|
+
});
|
|
800
|
+
await notify({ on: 'update', entity, row: next, previous });
|
|
801
|
+
}
|
|
802
|
+
return rows;
|
|
803
|
+
}));
|
|
804
|
+
},
|
|
805
|
+
async bulkRemove(ids) {
|
|
806
|
+
const unique = [...new Set(ids)];
|
|
807
|
+
const existing = await Promise.all(unique.map(load));
|
|
808
|
+
for (const row of existing)
|
|
809
|
+
assertOn('delete', row);
|
|
810
|
+
return environment.run(op('bulkRemove'), () => db.transaction('rw', writeTables, async () => {
|
|
811
|
+
const current = await Promise.all(unique.map(load));
|
|
812
|
+
const result = await deleteWithEffects(entity, unique);
|
|
813
|
+
for (const row of current) {
|
|
814
|
+
await audit({
|
|
815
|
+
action: 'delete',
|
|
816
|
+
entity,
|
|
817
|
+
recordId: row.id,
|
|
818
|
+
before: row,
|
|
819
|
+
after: null,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return typeof result === 'number' ? result : result.count;
|
|
823
|
+
}));
|
|
824
|
+
},
|
|
825
|
+
async exportCsv(filter, options) {
|
|
826
|
+
session.assert(cap('export'));
|
|
827
|
+
let rows = await readVisibleRows();
|
|
828
|
+
if (filter?.ids && filter.ids.length > 0) {
|
|
829
|
+
const want = new Set(filter.ids);
|
|
830
|
+
rows = rows.filter((row) => want.has(row.id));
|
|
831
|
+
}
|
|
832
|
+
if (filter?.where)
|
|
833
|
+
rows = rows.filter(filter.where);
|
|
834
|
+
const records = rows.map((row) => {
|
|
835
|
+
const input = {};
|
|
836
|
+
for (const field of entity.fields) {
|
|
837
|
+
const property = inputProperty(field);
|
|
838
|
+
if (property in row)
|
|
839
|
+
input[property] = row[property];
|
|
840
|
+
}
|
|
841
|
+
return input;
|
|
842
|
+
});
|
|
843
|
+
return serializeEntityCsv(entity, records, options);
|
|
844
|
+
},
|
|
845
|
+
async importCsv(text, options = {}) {
|
|
846
|
+
session.assert(cap('import'));
|
|
847
|
+
const mode = options.mode ?? 'preview';
|
|
848
|
+
const skipInvalid = options.skipInvalid === true;
|
|
849
|
+
const preview = await previewImportCsv(entity, text, options);
|
|
850
|
+
const base = {
|
|
851
|
+
...preview,
|
|
852
|
+
mode,
|
|
853
|
+
created: [],
|
|
854
|
+
auditIds: [],
|
|
855
|
+
};
|
|
856
|
+
if (mode === 'preview')
|
|
857
|
+
return base;
|
|
858
|
+
if (preview.errors.length > 0 && !skipInvalid) {
|
|
859
|
+
throw new ImportCsvError(base);
|
|
860
|
+
}
|
|
861
|
+
if (preview.valid.length === 0) {
|
|
862
|
+
return base;
|
|
863
|
+
}
|
|
864
|
+
return environment.run(op('import'), () => db.transaction('rw', writeTables, async () => {
|
|
865
|
+
const actor = actorId();
|
|
866
|
+
const now = clock.now().toISOString();
|
|
867
|
+
const created = [];
|
|
868
|
+
for (const values of preview.valid) {
|
|
869
|
+
await assertReferences(entity, values);
|
|
870
|
+
const row = {
|
|
871
|
+
...values,
|
|
872
|
+
id: createRuntimeId(entity.name.replace(/-/g, '_')),
|
|
873
|
+
createdAt: now,
|
|
874
|
+
updatedAt: now,
|
|
875
|
+
};
|
|
876
|
+
if (ownerProperty &&
|
|
877
|
+
(row[ownerProperty] === null || row[ownerProperty] === undefined)) {
|
|
878
|
+
row[ownerProperty] = actor;
|
|
879
|
+
}
|
|
880
|
+
created.push(row);
|
|
881
|
+
}
|
|
882
|
+
await putChunked(db.table(table), created);
|
|
883
|
+
const auditIds = await writeAuditBatch(entity, created, 'import');
|
|
884
|
+
for (const row of created) {
|
|
885
|
+
await notify({ on: 'create', entity, row });
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
...base,
|
|
889
|
+
mode: 'commit',
|
|
890
|
+
created,
|
|
891
|
+
auditIds,
|
|
892
|
+
};
|
|
893
|
+
}));
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
return Object.fromEntries(blueprint.entities.map((entity) => [entity.name, serviceFor(entity)]));
|
|
898
|
+
}
|