agent-cms 0.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/PROMPT.md +55 -0
- package/README.md +220 -0
- package/dist/handler-ClOW1ldA.mjs +5703 -0
- package/dist/http-transport-DbFCI6Cs.mjs +993 -0
- package/dist/index.d.mts +213 -0
- package/dist/index.mjs +1170 -0
- package/dist/structured-text-service-B4xSlUg_.mjs +1952 -0
- package/dist/token-service-BDjccMmz.mjs +3820 -0
- package/migrations/0000_genesis.sql +118 -0
- package/package.json +98 -0
|
@@ -0,0 +1,3820 @@
|
|
|
1
|
+
import { A as isSearchable, B as encodeJson, C as findUniqueConstraintViolations, D as getLinksTargets, E as getLinkTargets, F as parseMediaGalleryReferences, G as ReferenceConflictError, H as getFieldTypeDef, I as decodeJsonIfString, J as ValidationError, L as decodeJsonRecordStringOr, M as supportsUniqueValidation, O as getSlugSource, P as parseMediaFieldReference, R as decodeJsonString, S as computeIsValid, T as getBlocksOnly, U as DuplicateError, W as NotFoundError, a as materializeStructuredTextValue, b as isContentRow, c as FIELD_TYPES, i as materializeRecordStructuredTextFields, j as isUnique, k as isRequired, l as isFieldType, n as deleteBlocksForField, q as UnauthorizedError, r as getStructuredTextStorageKey, s as writeStructuredText, t as deleteBlockSubtrees, v as pruneBlockNodes, w as getBlockWhitelist, x as parseFieldValidators, y as StructuredTextWriteInput } from "./structured-text-service-B4xSlUg_.mjs";
|
|
2
|
+
import { Context, Data, Effect, Option, Schema } from "effect";
|
|
3
|
+
import { SqlClient } from "@effect/sql";
|
|
4
|
+
import { customAlphabet } from "nanoid";
|
|
5
|
+
import slugify from "slugify";
|
|
6
|
+
const generateId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 10);
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/schema-engine/sql-ddl.ts
|
|
9
|
+
/** Map CMS field type to SQLite column type */
|
|
10
|
+
function fieldTypeToSQLite(fieldType) {
|
|
11
|
+
return getFieldTypeDef(fieldType).sqliteType;
|
|
12
|
+
}
|
|
13
|
+
/** System columns for content tables */
|
|
14
|
+
const CONTENT_SYSTEM_COLUMNS = [
|
|
15
|
+
`"id" TEXT PRIMARY KEY`,
|
|
16
|
+
`"_status" TEXT NOT NULL DEFAULT 'draft'`,
|
|
17
|
+
`"_published_at" TEXT`,
|
|
18
|
+
`"_first_published_at" TEXT`,
|
|
19
|
+
`"_published_snapshot" TEXT`,
|
|
20
|
+
`"_created_at" TEXT NOT NULL`,
|
|
21
|
+
`"_updated_at" TEXT NOT NULL`,
|
|
22
|
+
`"_created_by" TEXT`,
|
|
23
|
+
`"_updated_by" TEXT`,
|
|
24
|
+
`"_published_by" TEXT`,
|
|
25
|
+
`"_scheduled_publish_at" TEXT`,
|
|
26
|
+
`"_scheduled_unpublish_at" TEXT`
|
|
27
|
+
];
|
|
28
|
+
/** System columns for block tables */
|
|
29
|
+
const BLOCK_SYSTEM_COLUMNS = [
|
|
30
|
+
`"id" TEXT PRIMARY KEY`,
|
|
31
|
+
`"_root_record_id" TEXT NOT NULL`,
|
|
32
|
+
`"_root_field_api_key" TEXT NOT NULL`,
|
|
33
|
+
`"_parent_container_model_api_key" TEXT NOT NULL`,
|
|
34
|
+
`"_parent_block_id" TEXT`,
|
|
35
|
+
`"_parent_field_api_key" TEXT NOT NULL`,
|
|
36
|
+
`"_depth" INTEGER NOT NULL DEFAULT 0`
|
|
37
|
+
];
|
|
38
|
+
function blockLookupIndexName(blockApiKey) {
|
|
39
|
+
return `idx_block_${blockApiKey}_lookup`;
|
|
40
|
+
}
|
|
41
|
+
function ensureBlockLookupIndex(blockApiKey) {
|
|
42
|
+
return Effect.gen(function* () {
|
|
43
|
+
const sql = yield* SqlClient.SqlClient;
|
|
44
|
+
const tableName = `block_${blockApiKey}`;
|
|
45
|
+
const indexName = blockLookupIndexName(blockApiKey);
|
|
46
|
+
yield* sql.unsafe(`CREATE INDEX IF NOT EXISTS "${indexName}"
|
|
47
|
+
ON "${tableName}" (
|
|
48
|
+
"_root_record_id",
|
|
49
|
+
"_root_field_api_key",
|
|
50
|
+
"_parent_container_model_api_key",
|
|
51
|
+
"_parent_field_api_key",
|
|
52
|
+
"_parent_block_id"
|
|
53
|
+
)`);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function shouldIndexField(fieldType) {
|
|
57
|
+
return fieldType === "slug" || fieldType === "link" || fieldType === "date" || fieldType === "date_time" || fieldType === "integer";
|
|
58
|
+
}
|
|
59
|
+
function contentFieldIndexName(modelApiKey, fieldApiKey) {
|
|
60
|
+
return `idx_content_${modelApiKey}_${fieldApiKey}`;
|
|
61
|
+
}
|
|
62
|
+
function contentCompositeIndexName(modelApiKey, leftFieldApiKey, rightFieldApiKey) {
|
|
63
|
+
return `idx_content_${modelApiKey}_${leftFieldApiKey}_${rightFieldApiKey}`;
|
|
64
|
+
}
|
|
65
|
+
function ensureContentFieldIndexes(modelApiKey, fields) {
|
|
66
|
+
return Effect.gen(function* () {
|
|
67
|
+
const sql = yield* SqlClient.SqlClient;
|
|
68
|
+
const tableName = `content_${modelApiKey}`;
|
|
69
|
+
for (const field of fields) {
|
|
70
|
+
if (!shouldIndexField(field.fieldType)) continue;
|
|
71
|
+
yield* sql.unsafe(`CREATE INDEX IF NOT EXISTS "${contentFieldIndexName(modelApiKey, field.apiKey)}"
|
|
72
|
+
ON "${tableName}" ("${field.apiKey}")`);
|
|
73
|
+
}
|
|
74
|
+
const linkFields = fields.filter((field) => field.fieldType === "link");
|
|
75
|
+
const temporalFields = fields.filter((field) => field.fieldType === "date" || field.fieldType === "date_time");
|
|
76
|
+
for (const linkField of linkFields) for (const temporalField of temporalFields) yield* sql.unsafe(`CREATE INDEX IF NOT EXISTS "${contentCompositeIndexName(modelApiKey, linkField.apiKey, temporalField.apiKey)}"
|
|
77
|
+
ON "${tableName}" ("${linkField.apiKey}", "${temporalField.apiKey}" DESC)`);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Create a content table for a model using @effect/sql.
|
|
82
|
+
*/
|
|
83
|
+
function createContentTable(modelApiKey, fields, options) {
|
|
84
|
+
return Effect.gen(function* () {
|
|
85
|
+
const sql = yield* SqlClient.SqlClient;
|
|
86
|
+
const tableName = `content_${modelApiKey}`;
|
|
87
|
+
const fieldCols = fields.map((f) => `"${f.apiKey}" ${fieldTypeToSQLite(f.fieldType)}`);
|
|
88
|
+
const systemCols = [...CONTENT_SYSTEM_COLUMNS];
|
|
89
|
+
if (options?.sortable || options?.tree) systemCols.push(`"_position" INTEGER NOT NULL DEFAULT 0`);
|
|
90
|
+
if (options?.tree) systemCols.push(`"_parent_id" TEXT`);
|
|
91
|
+
const allCols = [...systemCols, ...fieldCols].join(", ");
|
|
92
|
+
yield* sql.unsafe(`CREATE TABLE IF NOT EXISTS "${tableName}" (${allCols})`);
|
|
93
|
+
yield* ensureContentFieldIndexes(modelApiKey, fields);
|
|
94
|
+
return tableName;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Create a block table for a block type using @effect/sql.
|
|
99
|
+
*/
|
|
100
|
+
function createBlockTable(blockApiKey, fields) {
|
|
101
|
+
return Effect.gen(function* () {
|
|
102
|
+
const sql = yield* SqlClient.SqlClient;
|
|
103
|
+
const tableName = `block_${blockApiKey}`;
|
|
104
|
+
const fieldCols = fields.map((f) => `"${f.apiKey}" ${fieldTypeToSQLite(f.fieldType)}`);
|
|
105
|
+
const allCols = [...BLOCK_SYSTEM_COLUMNS, ...fieldCols].join(", ");
|
|
106
|
+
yield* sql.unsafe(`CREATE TABLE IF NOT EXISTS "${tableName}" (${allCols})`);
|
|
107
|
+
yield* ensureBlockLookupIndex(blockApiKey);
|
|
108
|
+
return tableName;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Add a column to a dynamic table.
|
|
113
|
+
*/
|
|
114
|
+
function addColumn(tableName, apiKey, fieldType) {
|
|
115
|
+
return Effect.gen(function* () {
|
|
116
|
+
const sql = yield* SqlClient.SqlClient;
|
|
117
|
+
const colType = fieldTypeToSQLite(fieldType);
|
|
118
|
+
yield* sql.unsafe(`ALTER TABLE "${tableName}" ADD COLUMN "${apiKey}" ${colType}`);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Drop a column from a dynamic table.
|
|
123
|
+
*/
|
|
124
|
+
function dropColumn(tableName, apiKey) {
|
|
125
|
+
return Effect.gen(function* () {
|
|
126
|
+
yield* (yield* SqlClient.SqlClient).unsafe(`ALTER TABLE "${tableName}" DROP COLUMN "${apiKey}"`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Drop an entire table.
|
|
131
|
+
*/
|
|
132
|
+
function dropTableSql(tableName) {
|
|
133
|
+
return Effect.gen(function* () {
|
|
134
|
+
yield* (yield* SqlClient.SqlClient).unsafe(`DROP TABLE IF EXISTS "${tableName}"`);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Check if a table exists.
|
|
139
|
+
*/
|
|
140
|
+
function tableExists(tableName) {
|
|
141
|
+
return Effect.gen(function* () {
|
|
142
|
+
return (yield* (yield* SqlClient.SqlClient).unsafe(`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`)).length > 0;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Get existing column names for a table.
|
|
147
|
+
*/
|
|
148
|
+
function getTableColumns(tableName) {
|
|
149
|
+
return Effect.gen(function* () {
|
|
150
|
+
return (yield* (yield* SqlClient.SqlClient).unsafe(`PRAGMA table_info("${tableName}")`)).map((r) => ({
|
|
151
|
+
name: r.name,
|
|
152
|
+
type: r.type
|
|
153
|
+
}));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Migrate a dynamic table: create if missing, add/drop columns as needed.
|
|
158
|
+
*/
|
|
159
|
+
function migrateContentTable(modelApiKey, isBlock, fields, options) {
|
|
160
|
+
return Effect.gen(function* () {
|
|
161
|
+
const tableName = isBlock ? `block_${modelApiKey}` : `content_${modelApiKey}`;
|
|
162
|
+
if (!(yield* tableExists(tableName))) {
|
|
163
|
+
if (isBlock) yield* createBlockTable(modelApiKey, fields);
|
|
164
|
+
else yield* createContentTable(modelApiKey, fields, options);
|
|
165
|
+
return {
|
|
166
|
+
created: true,
|
|
167
|
+
columnsAdded: [],
|
|
168
|
+
columnsDropped: []
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const existingCols = yield* getTableColumns(tableName);
|
|
172
|
+
const existingColNames = new Set(existingCols.map((c) => c.name));
|
|
173
|
+
const systemColNames = isBlock ? new Set([
|
|
174
|
+
"id",
|
|
175
|
+
"_root_record_id",
|
|
176
|
+
"_root_field_api_key",
|
|
177
|
+
"_parent_container_model_api_key",
|
|
178
|
+
"_parent_block_id",
|
|
179
|
+
"_parent_field_api_key",
|
|
180
|
+
"_depth"
|
|
181
|
+
]) : new Set([
|
|
182
|
+
"id",
|
|
183
|
+
"_status",
|
|
184
|
+
"_published_at",
|
|
185
|
+
"_first_published_at",
|
|
186
|
+
"_published_snapshot",
|
|
187
|
+
"_created_at",
|
|
188
|
+
"_updated_at",
|
|
189
|
+
"_created_by",
|
|
190
|
+
"_updated_by",
|
|
191
|
+
"_published_by",
|
|
192
|
+
"_scheduled_publish_at",
|
|
193
|
+
"_scheduled_unpublish_at",
|
|
194
|
+
"_position",
|
|
195
|
+
"_parent_id"
|
|
196
|
+
]);
|
|
197
|
+
const desiredFieldNames = new Set(fields.map((f) => f.apiKey));
|
|
198
|
+
const columnsAdded = [];
|
|
199
|
+
const columnsDropped = [];
|
|
200
|
+
for (const field of fields) if (!existingColNames.has(field.apiKey)) {
|
|
201
|
+
yield* addColumn(tableName, field.apiKey, field.fieldType);
|
|
202
|
+
columnsAdded.push(field.apiKey);
|
|
203
|
+
}
|
|
204
|
+
for (const col of existingCols) if (!systemColNames.has(col.name) && !desiredFieldNames.has(col.name)) {
|
|
205
|
+
yield* dropColumn(tableName, col.name);
|
|
206
|
+
columnsDropped.push(col.name);
|
|
207
|
+
}
|
|
208
|
+
if (isBlock) yield* ensureBlockLookupIndex(modelApiKey);
|
|
209
|
+
else yield* ensureContentFieldIndexes(modelApiKey, fields);
|
|
210
|
+
return {
|
|
211
|
+
created: false,
|
|
212
|
+
columnsAdded,
|
|
213
|
+
columnsDropped
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/search/extract-text.ts
|
|
219
|
+
/**
|
|
220
|
+
* Extract plain text from a DAST document.
|
|
221
|
+
* Walks the tree collecting span.value strings.
|
|
222
|
+
*/
|
|
223
|
+
function extractDastText(dast) {
|
|
224
|
+
if (!isRecord(dast)) return "";
|
|
225
|
+
const children = getArray(isRecord(dast.document) ? dast.document : dast, "children");
|
|
226
|
+
if (!children) return "";
|
|
227
|
+
const parts = [];
|
|
228
|
+
collectText(children, parts);
|
|
229
|
+
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Extract searchable text from all fields of a record.
|
|
233
|
+
* Returns title (for higher BM25 weight) and body (concatenated text).
|
|
234
|
+
*/
|
|
235
|
+
function extractRecordText(record, fields) {
|
|
236
|
+
let title = "";
|
|
237
|
+
const bodyParts = [];
|
|
238
|
+
const titleField = fields.find((f) => f.api_key === "title") ?? fields.find((f) => f.api_key === "name") ?? fields.find((f) => f.field_type === "string");
|
|
239
|
+
for (const field of fields) {
|
|
240
|
+
if (!isSearchable(field.validators)) continue;
|
|
241
|
+
const value = record[field.api_key];
|
|
242
|
+
if (value === void 0 || value === null) continue;
|
|
243
|
+
const texts = extractFieldText(field, value);
|
|
244
|
+
if (texts.length === 0) continue;
|
|
245
|
+
const joined = texts.join(" ");
|
|
246
|
+
if (titleField && field.api_key === titleField.api_key) title = joined;
|
|
247
|
+
else bodyParts.push(joined);
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
title: title.replace(/\s+/g, " ").trim(),
|
|
251
|
+
body: bodyParts.join(" ").replace(/\s+/g, " ").trim()
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function extractFieldText(field, value) {
|
|
255
|
+
if (field.localized && isRecord(value)) {
|
|
256
|
+
const texts = [];
|
|
257
|
+
for (const localeValue of Object.values(value)) texts.push(...extractFieldText({
|
|
258
|
+
...field,
|
|
259
|
+
localized: 0
|
|
260
|
+
}, localeValue));
|
|
261
|
+
return texts;
|
|
262
|
+
}
|
|
263
|
+
switch (field.field_type) {
|
|
264
|
+
case "structured_text": {
|
|
265
|
+
const parsed = typeof value === "string" ? safeParse(value) : value;
|
|
266
|
+
if (!isRecord(parsed)) return [];
|
|
267
|
+
const dast = isRecord(parsed.value) ? parsed.value : parsed;
|
|
268
|
+
const parts = [];
|
|
269
|
+
const text = extractDastText(dast);
|
|
270
|
+
if (text) parts.push(text);
|
|
271
|
+
if (isRecord(parsed.blocks)) parts.push(...extractGenericText(parsed.blocks));
|
|
272
|
+
return parts;
|
|
273
|
+
}
|
|
274
|
+
case "seo": {
|
|
275
|
+
const parsed = typeof value === "string" ? safeParse(value) : value;
|
|
276
|
+
if (!isRecord(parsed)) return [];
|
|
277
|
+
const parts = [];
|
|
278
|
+
if (typeof parsed.title === "string") parts.push(parsed.title);
|
|
279
|
+
if (typeof parsed.description === "string") parts.push(parsed.description);
|
|
280
|
+
return parts;
|
|
281
|
+
}
|
|
282
|
+
default: return extractGenericText(value);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Extract text from any value — strings directly, JSON objects recursively. */
|
|
286
|
+
function extractGenericText(value) {
|
|
287
|
+
if (typeof value === "string") {
|
|
288
|
+
if (/^[0-9a-z]{10}$/.test(value)) return [];
|
|
289
|
+
if (/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(value)) return [];
|
|
290
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) return [];
|
|
291
|
+
return value.length > 0 ? [value] : [];
|
|
292
|
+
}
|
|
293
|
+
if (typeof value === "number" || typeof value === "boolean") return [];
|
|
294
|
+
if (Array.isArray(value)) {
|
|
295
|
+
const texts = [];
|
|
296
|
+
for (const item of value) texts.push(...extractGenericText(item));
|
|
297
|
+
return texts;
|
|
298
|
+
}
|
|
299
|
+
if (isRecord(value)) {
|
|
300
|
+
const texts = [];
|
|
301
|
+
for (const [key, v] of Object.entries(value)) {
|
|
302
|
+
if (key.startsWith("_")) continue;
|
|
303
|
+
texts.push(...extractGenericText(v));
|
|
304
|
+
}
|
|
305
|
+
return texts;
|
|
306
|
+
}
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
function isRecord(v) {
|
|
310
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
311
|
+
}
|
|
312
|
+
function getArray(obj, key) {
|
|
313
|
+
const v = obj[key];
|
|
314
|
+
return Array.isArray(v) ? v : void 0;
|
|
315
|
+
}
|
|
316
|
+
function collectText(nodes, parts) {
|
|
317
|
+
for (const node of nodes) {
|
|
318
|
+
if (!isRecord(node)) continue;
|
|
319
|
+
if (node.type === "span" && typeof node.value === "string") parts.push(node.value);
|
|
320
|
+
if (node.type === "code" && typeof node.code === "string") parts.push(node.code);
|
|
321
|
+
const children = getArray(node, "children");
|
|
322
|
+
if (children) collectText(children, parts);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function safeParse(json) {
|
|
326
|
+
try {
|
|
327
|
+
return JSON.parse(json);
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/search/fts5.ts
|
|
334
|
+
/**
|
|
335
|
+
* Create FTS5 virtual table for a model.
|
|
336
|
+
*/
|
|
337
|
+
function createFtsTable$1(modelApiKey) {
|
|
338
|
+
return Effect.gen(function* () {
|
|
339
|
+
yield* (yield* SqlClient.SqlClient).unsafe(`CREATE VIRTUAL TABLE IF NOT EXISTS "fts_${modelApiKey}" USING fts5(record_id UNINDEXED, title, body)`);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Drop FTS5 virtual table for a model.
|
|
344
|
+
*/
|
|
345
|
+
function dropFtsTable(modelApiKey) {
|
|
346
|
+
return Effect.gen(function* () {
|
|
347
|
+
yield* (yield* SqlClient.SqlClient).unsafe(`DROP TABLE IF EXISTS "fts_${modelApiKey}"`);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Index a single record into the FTS5 table.
|
|
352
|
+
*/
|
|
353
|
+
function ftsIndex(modelApiKey, recordId, title, body) {
|
|
354
|
+
return Effect.gen(function* () {
|
|
355
|
+
yield* (yield* SqlClient.SqlClient).unsafe(`INSERT INTO "fts_${modelApiKey}"(record_id, title, body) VALUES (?, ?, ?)`, [
|
|
356
|
+
recordId,
|
|
357
|
+
title,
|
|
358
|
+
body
|
|
359
|
+
]);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Remove a record from the FTS5 index.
|
|
364
|
+
*/
|
|
365
|
+
function ftsDeindex(modelApiKey, recordId) {
|
|
366
|
+
return Effect.gen(function* () {
|
|
367
|
+
yield* (yield* SqlClient.SqlClient).unsafe(`DELETE FROM "fts_${modelApiKey}" WHERE record_id = ?`, [recordId]);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Query FTS5 with BM25 ranking and snippets.
|
|
372
|
+
* When modelApiKey is not specified, searches across all FTS5 tables.
|
|
373
|
+
*/
|
|
374
|
+
function ftsSearch(query, options) {
|
|
375
|
+
return Effect.gen(function* () {
|
|
376
|
+
const sql = yield* SqlClient.SqlClient;
|
|
377
|
+
const limit = Math.min(options.first ?? 10, 100);
|
|
378
|
+
const offset = options.skip ?? 0;
|
|
379
|
+
if (options.modelApiKey) {
|
|
380
|
+
const modelApiKey = options.modelApiKey;
|
|
381
|
+
return (yield* sql.unsafe(`SELECT record_id, title, rank, snippet("fts_${modelApiKey}", 2, '<mark>', '</mark>', '...', 32) as snippet
|
|
382
|
+
FROM "fts_${modelApiKey}"
|
|
383
|
+
WHERE "fts_${modelApiKey}" MATCH ?
|
|
384
|
+
ORDER BY rank
|
|
385
|
+
LIMIT ? OFFSET ?`, [
|
|
386
|
+
query,
|
|
387
|
+
limit,
|
|
388
|
+
offset
|
|
389
|
+
])).map((r) => ({
|
|
390
|
+
recordId: r.record_id,
|
|
391
|
+
modelApiKey,
|
|
392
|
+
rank: r.rank,
|
|
393
|
+
title: r.title,
|
|
394
|
+
snippet: r.snippet
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
const tables = yield* sql.unsafe(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'fts_%' AND sql LIKE 'CREATE VIRTUAL TABLE%'`);
|
|
398
|
+
if (tables.length === 0) return [];
|
|
399
|
+
const unionQuery = tables.map((t) => {
|
|
400
|
+
return `SELECT record_id, title, '${t.name.replace(/^fts_/, "")}' as model_api_key, rank, snippet("${t.name}", 2, '<mark>', '</mark>', '...', 32) as snippet FROM "${t.name}" WHERE "${t.name}" MATCH ?`;
|
|
401
|
+
}).join(" UNION ALL ") + " ORDER BY rank LIMIT ? OFFSET ?";
|
|
402
|
+
const params = [
|
|
403
|
+
...tables.map(() => query),
|
|
404
|
+
limit,
|
|
405
|
+
offset
|
|
406
|
+
];
|
|
407
|
+
return (yield* sql.unsafe(unionQuery, params)).map((r) => ({
|
|
408
|
+
recordId: r.record_id,
|
|
409
|
+
modelApiKey: r.model_api_key,
|
|
410
|
+
rank: r.rank,
|
|
411
|
+
title: r.title,
|
|
412
|
+
snippet: r.snippet
|
|
413
|
+
}));
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/search/vectorize.ts
|
|
418
|
+
/**
|
|
419
|
+
* Vectorize semantic search using Cloudflare Workers AI + Vectorize.
|
|
420
|
+
* All functions return Effect — async boundaries use Effect.tryPromise.
|
|
421
|
+
*/
|
|
422
|
+
var VectorizeError = class extends Data.TaggedError("VectorizeError") {};
|
|
423
|
+
function describeUnknown(error) {
|
|
424
|
+
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
|
425
|
+
if (typeof error === "string") return error;
|
|
426
|
+
return JSON.stringify(error);
|
|
427
|
+
}
|
|
428
|
+
const EMBED_MODEL = "@cf/baai/bge-small-en-v1.5";
|
|
429
|
+
/**
|
|
430
|
+
* Generate embeddings for text chunks using Workers AI.
|
|
431
|
+
*/
|
|
432
|
+
function embedTexts(ai, texts) {
|
|
433
|
+
if (texts.length === 0) return Effect.succeed([]);
|
|
434
|
+
return Effect.tryPromise({
|
|
435
|
+
try: () => ai.run(EMBED_MODEL, { text: texts }),
|
|
436
|
+
catch: (error) => new VectorizeError({ message: `Embedding failed: ${describeUnknown(error)}` })
|
|
437
|
+
}).pipe(Effect.map((result) => result.data));
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Index a record into Vectorize.
|
|
441
|
+
*/
|
|
442
|
+
function vectorizeIndex(ai, vectorize, modelApiKey, recordId, title, body) {
|
|
443
|
+
const text = [title, body].filter(Boolean).join(" — ");
|
|
444
|
+
if (!text) return Effect.void;
|
|
445
|
+
return Effect.gen(function* () {
|
|
446
|
+
const embeddings = yield* embedTexts(ai, [text]);
|
|
447
|
+
yield* Effect.tryPromise({
|
|
448
|
+
try: () => vectorize.upsert([{
|
|
449
|
+
id: `${modelApiKey}:${recordId}`,
|
|
450
|
+
values: embeddings[0],
|
|
451
|
+
metadata: {
|
|
452
|
+
recordId,
|
|
453
|
+
modelApiKey
|
|
454
|
+
}
|
|
455
|
+
}]),
|
|
456
|
+
catch: (error) => new VectorizeError({ message: `Upsert failed: ${describeUnknown(error)}` })
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Remove a record from Vectorize.
|
|
462
|
+
*/
|
|
463
|
+
function vectorizeDeindex(vectorize, modelApiKey, recordId) {
|
|
464
|
+
return Effect.tryPromise({
|
|
465
|
+
try: () => vectorize.deleteByIds([`${modelApiKey}:${recordId}`]),
|
|
466
|
+
catch: (error) => new VectorizeError({ message: `Deindex failed: ${describeUnknown(error)}` })
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Semantic search via Vectorize.
|
|
471
|
+
*/
|
|
472
|
+
function vectorizeSearch(ai, vectorize, query, topK = 20) {
|
|
473
|
+
return Effect.gen(function* () {
|
|
474
|
+
const embeddings = yield* embedTexts(ai, [query]);
|
|
475
|
+
return (yield* Effect.tryPromise({
|
|
476
|
+
try: () => vectorize.query(embeddings[0], {
|
|
477
|
+
topK,
|
|
478
|
+
returnMetadata: "all"
|
|
479
|
+
}),
|
|
480
|
+
catch: (error) => new VectorizeError({ message: `Search failed: ${describeUnknown(error)}` })
|
|
481
|
+
})).matches.map((m) => ({
|
|
482
|
+
recordId: m.metadata?.recordId ?? m.id.split(":")[1],
|
|
483
|
+
modelApiKey: m.metadata?.modelApiKey ?? m.id.split(":")[0],
|
|
484
|
+
score: m.score
|
|
485
|
+
}));
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Reciprocal Rank Fusion — merge FTS5 and Vectorize results.
|
|
490
|
+
* Records appearing in both result sets get boosted.
|
|
491
|
+
*/
|
|
492
|
+
function reciprocalRankFusion(ftsResults, vectorResults, k = 60) {
|
|
493
|
+
const scores = /* @__PURE__ */ new Map();
|
|
494
|
+
for (let i = 0; i < ftsResults.length; i++) {
|
|
495
|
+
const r = ftsResults[i];
|
|
496
|
+
const key = `${r.modelApiKey}:${r.recordId}`;
|
|
497
|
+
const rrf = 1 / (k + i + 1);
|
|
498
|
+
const existing = scores.get(key);
|
|
499
|
+
if (existing) existing.score += rrf;
|
|
500
|
+
else scores.set(key, {
|
|
501
|
+
recordId: r.recordId,
|
|
502
|
+
modelApiKey: r.modelApiKey,
|
|
503
|
+
score: rrf
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
for (let i = 0; i < vectorResults.length; i++) {
|
|
507
|
+
const r = vectorResults[i];
|
|
508
|
+
const key = `${r.modelApiKey}:${r.recordId}`;
|
|
509
|
+
const rrf = 1 / (k + i + 1);
|
|
510
|
+
const existing = scores.get(key);
|
|
511
|
+
if (existing) existing.score += rrf;
|
|
512
|
+
else scores.set(key, {
|
|
513
|
+
recordId: r.recordId,
|
|
514
|
+
modelApiKey: r.modelApiKey,
|
|
515
|
+
score: rrf
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return [...scores.values()].sort((a, b) => b.score - a.score);
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/search/vectorize-context.ts
|
|
522
|
+
var VectorizeContext = class extends Context.Tag("VectorizeContext")() {};
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region src/search/search-service.ts
|
|
525
|
+
/**
|
|
526
|
+
* Index a record after creation.
|
|
527
|
+
*/
|
|
528
|
+
function indexRecord(modelApiKey, recordId, data, fields) {
|
|
529
|
+
return Effect.gen(function* () {
|
|
530
|
+
const { title, body } = extractRecordText(yield* materializeRecordStructuredTextFields({
|
|
531
|
+
modelApiKey,
|
|
532
|
+
record: data,
|
|
533
|
+
fields
|
|
534
|
+
}), fields);
|
|
535
|
+
if (!title && !body) return;
|
|
536
|
+
yield* ftsIndex(modelApiKey, recordId, title, body);
|
|
537
|
+
const bindings = yield* VectorizeContext;
|
|
538
|
+
if (Option.isSome(bindings)) yield* vectorizeIndex(bindings.value.ai, bindings.value.vectorize, modelApiKey, recordId, title, body).pipe(Effect.ignore);
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Reindex a record after update: deindex old, then fetch fresh data and index.
|
|
543
|
+
*/
|
|
544
|
+
function reindexRecord(modelApiKey, recordId, fields) {
|
|
545
|
+
return Effect.gen(function* () {
|
|
546
|
+
const sql = yield* SqlClient.SqlClient;
|
|
547
|
+
yield* ftsDeindex(modelApiKey, recordId);
|
|
548
|
+
const rows = yield* sql.unsafe(`SELECT * FROM "content_${modelApiKey}" WHERE id = ?`, [recordId]);
|
|
549
|
+
if (rows.length === 0) return;
|
|
550
|
+
const { title, body } = extractRecordText(yield* materializeRecordStructuredTextFields({
|
|
551
|
+
modelApiKey,
|
|
552
|
+
record: rows[0],
|
|
553
|
+
fields
|
|
554
|
+
}), fields);
|
|
555
|
+
if (!title && !body) return;
|
|
556
|
+
yield* ftsIndex(modelApiKey, recordId, title, body);
|
|
557
|
+
const bindings = yield* VectorizeContext;
|
|
558
|
+
if (Option.isSome(bindings)) yield* vectorizeIndex(bindings.value.ai, bindings.value.vectorize, modelApiKey, recordId, title, body).pipe(Effect.ignore);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Remove a record from the index.
|
|
563
|
+
*/
|
|
564
|
+
function deindexRecord(modelApiKey, recordId) {
|
|
565
|
+
return Effect.gen(function* () {
|
|
566
|
+
yield* ftsDeindex(modelApiKey, recordId);
|
|
567
|
+
const bindings = yield* VectorizeContext;
|
|
568
|
+
if (Option.isSome(bindings)) yield* vectorizeDeindex(bindings.value.vectorize, modelApiKey, recordId).pipe(Effect.ignore);
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Rebuild the entire FTS5 index for a model.
|
|
573
|
+
*/
|
|
574
|
+
function rebuildIndex(modelApiKey) {
|
|
575
|
+
return Effect.gen(function* () {
|
|
576
|
+
const sql = yield* SqlClient.SqlClient;
|
|
577
|
+
yield* dropFtsTable(modelApiKey);
|
|
578
|
+
yield* createFtsTable$1(modelApiKey);
|
|
579
|
+
const models = yield* sql.unsafe("SELECT id FROM models WHERE api_key = ?", [modelApiKey]);
|
|
580
|
+
if (models.length === 0) return;
|
|
581
|
+
const fields = (yield* sql.unsafe("SELECT * FROM fields WHERE model_id = ? ORDER BY position", [models[0].id])).map(parseFieldValidators);
|
|
582
|
+
const records = yield* sql.unsafe(`SELECT * FROM "content_${modelApiKey}"`);
|
|
583
|
+
const bindings = yield* VectorizeContext;
|
|
584
|
+
for (const record of records) {
|
|
585
|
+
const { title, body } = extractRecordText(yield* materializeRecordStructuredTextFields({
|
|
586
|
+
modelApiKey,
|
|
587
|
+
record,
|
|
588
|
+
fields
|
|
589
|
+
}), fields);
|
|
590
|
+
if (title || body) {
|
|
591
|
+
yield* ftsIndex(modelApiKey, String(record.id), title, body);
|
|
592
|
+
if (Option.isSome(bindings)) yield* vectorizeIndex(bindings.value.ai, bindings.value.vectorize, modelApiKey, String(record.id), title, body).pipe(Effect.ignore);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Create the FTS5 table for a model.
|
|
599
|
+
*/
|
|
600
|
+
function createFtsTable(modelApiKey) {
|
|
601
|
+
return createFtsTable$1(modelApiKey);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Drop the FTS5 index for a model.
|
|
605
|
+
*/
|
|
606
|
+
function dropIndex(modelApiKey) {
|
|
607
|
+
return dropFtsTable(modelApiKey);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Rebuild search indexes for all content models (or a specific one).
|
|
611
|
+
*/
|
|
612
|
+
function reindexAll(modelApiKey) {
|
|
613
|
+
return Effect.gen(function* () {
|
|
614
|
+
const sql = yield* SqlClient.SqlClient;
|
|
615
|
+
let modelRows;
|
|
616
|
+
if (modelApiKey) {
|
|
617
|
+
modelRows = yield* sql.unsafe("SELECT id, api_key FROM models WHERE api_key = ? AND is_block = 0", [modelApiKey]);
|
|
618
|
+
if (modelRows.length === 0) return yield* new ValidationError({ message: `Model '${modelApiKey}' not found or is a block type` });
|
|
619
|
+
} else modelRows = yield* sql.unsafe("SELECT id, api_key FROM models WHERE is_block = 0");
|
|
620
|
+
const bindings = yield* VectorizeContext;
|
|
621
|
+
let totalRecords = 0;
|
|
622
|
+
let totalIndexed = 0;
|
|
623
|
+
for (const model of modelRows) {
|
|
624
|
+
yield* dropFtsTable(model.api_key);
|
|
625
|
+
yield* createFtsTable$1(model.api_key);
|
|
626
|
+
const fields = (yield* sql.unsafe("SELECT * FROM fields WHERE model_id = ? ORDER BY position", [model.id])).map(parseFieldValidators);
|
|
627
|
+
const records = yield* sql.unsafe(`SELECT * FROM "content_${model.api_key}"`);
|
|
628
|
+
totalRecords += records.length;
|
|
629
|
+
for (const record of records) {
|
|
630
|
+
const { title, body } = extractRecordText(yield* materializeRecordStructuredTextFields({
|
|
631
|
+
modelApiKey: model.api_key,
|
|
632
|
+
record,
|
|
633
|
+
fields
|
|
634
|
+
}), fields);
|
|
635
|
+
if (title || body) {
|
|
636
|
+
yield* ftsIndex(model.api_key, String(record.id), title, body);
|
|
637
|
+
if (Option.isSome(bindings)) yield* vectorizeIndex(bindings.value.ai, bindings.value.vectorize, model.api_key, String(record.id), title, body).pipe(Effect.ignore);
|
|
638
|
+
totalIndexed++;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
models: modelRows.length,
|
|
644
|
+
records: totalRecords,
|
|
645
|
+
indexed: totalIndexed,
|
|
646
|
+
vectorize: Option.isSome(bindings)
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
function lookupIndexedTitle(modelApiKey, recordId) {
|
|
651
|
+
return Effect.gen(function* () {
|
|
652
|
+
return (yield* (yield* SqlClient.SqlClient).unsafe(`SELECT title FROM "fts_${modelApiKey}" WHERE record_id = ? LIMIT 1`, [recordId]).pipe(Effect.catchAll(() => Effect.succeed([]))))[0]?.title ?? null;
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Search content records.
|
|
657
|
+
*/
|
|
658
|
+
function search(params) {
|
|
659
|
+
return Effect.gen(function* () {
|
|
660
|
+
if (!params.query || params.query.trim().length === 0) return yield* new ValidationError({ message: "Search query is required" });
|
|
661
|
+
const bindings = yield* VectorizeContext;
|
|
662
|
+
const hasVector = Option.isSome(bindings);
|
|
663
|
+
const limit = Math.min(params.first ?? 10, 100);
|
|
664
|
+
const mode = params.mode ?? (hasVector ? "hybrid" : "keyword");
|
|
665
|
+
const useVector = (mode === "semantic" || mode === "hybrid") && hasVector;
|
|
666
|
+
let ftsResults = [];
|
|
667
|
+
if (mode !== "semantic") ftsResults = yield* ftsSearch(params.query, {
|
|
668
|
+
modelApiKey: params.modelApiKey,
|
|
669
|
+
first: limit,
|
|
670
|
+
skip: params.skip
|
|
671
|
+
}).pipe(Effect.catchAll(() => Effect.succeed([])));
|
|
672
|
+
let vectorResults = [];
|
|
673
|
+
if (useVector && Option.isSome(bindings)) {
|
|
674
|
+
vectorResults = yield* vectorizeSearch(bindings.value.ai, bindings.value.vectorize, params.query, limit * 2).pipe(Effect.catchAll(() => Effect.succeed([])));
|
|
675
|
+
if (params.modelApiKey) vectorResults = vectorResults.filter((r) => r.modelApiKey === params.modelApiKey);
|
|
676
|
+
}
|
|
677
|
+
if (mode === "hybrid" && ftsResults.length > 0 && vectorResults.length > 0) {
|
|
678
|
+
const paged = reciprocalRankFusion(ftsResults, vectorResults).slice(params.skip ?? 0, (params.skip ?? 0) + limit);
|
|
679
|
+
const ftsMetaMap = new Map(ftsResults.map((r) => [`${r.modelApiKey}:${r.recordId}`, {
|
|
680
|
+
title: r.title,
|
|
681
|
+
snippet: r.snippet
|
|
682
|
+
}]));
|
|
683
|
+
const results = paged.map((r) => ({
|
|
684
|
+
recordId: r.recordId,
|
|
685
|
+
modelApiKey: r.modelApiKey,
|
|
686
|
+
rank: r.score,
|
|
687
|
+
title: ftsMetaMap.get(`${r.modelApiKey}:${r.recordId}`)?.title ?? null,
|
|
688
|
+
snippet: ftsMetaMap.get(`${r.modelApiKey}:${r.recordId}`)?.snippet ?? ""
|
|
689
|
+
}));
|
|
690
|
+
return {
|
|
691
|
+
results,
|
|
692
|
+
meta: {
|
|
693
|
+
count: results.length,
|
|
694
|
+
mode: "hybrid"
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
if (mode === "semantic" && vectorResults.length > 0) {
|
|
699
|
+
const paged = vectorResults.slice(params.skip ?? 0, (params.skip ?? 0) + limit);
|
|
700
|
+
const results = yield* Effect.forEach(paged, (r) => Effect.gen(function* () {
|
|
701
|
+
const title = yield* lookupIndexedTitle(r.modelApiKey, r.recordId);
|
|
702
|
+
return {
|
|
703
|
+
recordId: r.recordId,
|
|
704
|
+
modelApiKey: r.modelApiKey,
|
|
705
|
+
rank: r.score,
|
|
706
|
+
title,
|
|
707
|
+
snippet: ""
|
|
708
|
+
};
|
|
709
|
+
}));
|
|
710
|
+
return {
|
|
711
|
+
results,
|
|
712
|
+
meta: {
|
|
713
|
+
count: results.length,
|
|
714
|
+
mode: "semantic"
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
results: ftsResults,
|
|
720
|
+
meta: {
|
|
721
|
+
count: ftsResults.length,
|
|
722
|
+
mode: hasVector ? mode : "keyword"
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region src/services/model-service.ts
|
|
729
|
+
function listModels() {
|
|
730
|
+
return Effect.gen(function* () {
|
|
731
|
+
return yield* (yield* SqlClient.SqlClient).unsafe("SELECT * FROM models ORDER BY created_at");
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function getModel(id) {
|
|
735
|
+
return Effect.gen(function* () {
|
|
736
|
+
const sql = yield* SqlClient.SqlClient;
|
|
737
|
+
const models = yield* sql.unsafe("SELECT * FROM models WHERE id = ?", [id]);
|
|
738
|
+
if (models.length === 0) return yield* new NotFoundError({
|
|
739
|
+
entity: "Model",
|
|
740
|
+
id
|
|
741
|
+
});
|
|
742
|
+
const fields = yield* sql.unsafe("SELECT * FROM fields WHERE model_id = ? ORDER BY position", [id]);
|
|
743
|
+
return {
|
|
744
|
+
...models[0],
|
|
745
|
+
fields: fields.map(parseFieldValidators)
|
|
746
|
+
};
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
function createModel(body) {
|
|
750
|
+
return Effect.gen(function* () {
|
|
751
|
+
const sql = yield* SqlClient.SqlClient;
|
|
752
|
+
if (!/^[a-z][a-z0-9_]*$/.test(body.apiKey)) return yield* new ValidationError({ message: "apiKey must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" });
|
|
753
|
+
if ((yield* sql.unsafe("SELECT id FROM models WHERE api_key = ?", [body.apiKey])).length > 0) return yield* new DuplicateError({ message: `Model with apiKey '${body.apiKey}' already exists` });
|
|
754
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
755
|
+
const id = generateId();
|
|
756
|
+
yield* sql.unsafe(`INSERT INTO models (id, name, api_key, is_block, singleton, sortable, tree, has_draft, all_locales_required, ordering, created_at, updated_at)
|
|
757
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
758
|
+
id,
|
|
759
|
+
body.name,
|
|
760
|
+
body.apiKey,
|
|
761
|
+
body.isBlock ? 1 : 0,
|
|
762
|
+
body.singleton ? 1 : 0,
|
|
763
|
+
body.sortable ? 1 : 0,
|
|
764
|
+
body.tree ? 1 : 0,
|
|
765
|
+
body.hasDraft ? 1 : 0,
|
|
766
|
+
body.allLocalesRequired ? 1 : 0,
|
|
767
|
+
body.ordering ?? null,
|
|
768
|
+
now,
|
|
769
|
+
now
|
|
770
|
+
]);
|
|
771
|
+
yield* migrateContentTable(body.apiKey, body.isBlock, [], {
|
|
772
|
+
sortable: body.sortable,
|
|
773
|
+
tree: body.tree
|
|
774
|
+
});
|
|
775
|
+
if (!body.isBlock) yield* createFtsTable(body.apiKey).pipe(Effect.ignore);
|
|
776
|
+
return {
|
|
777
|
+
id,
|
|
778
|
+
name: body.name,
|
|
779
|
+
apiKey: body.apiKey,
|
|
780
|
+
isBlock: body.isBlock,
|
|
781
|
+
singleton: body.singleton,
|
|
782
|
+
sortable: body.sortable,
|
|
783
|
+
tree: body.tree,
|
|
784
|
+
hasDraft: body.hasDraft,
|
|
785
|
+
allLocalesRequired: body.allLocalesRequired,
|
|
786
|
+
ordering: body.ordering ?? null,
|
|
787
|
+
createdAt: now,
|
|
788
|
+
updatedAt: now
|
|
789
|
+
};
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
function updateModel(id, body) {
|
|
793
|
+
return Effect.gen(function* () {
|
|
794
|
+
const sql = yield* SqlClient.SqlClient;
|
|
795
|
+
const existing = yield* sql.unsafe("SELECT * FROM models WHERE id = ?", [id]);
|
|
796
|
+
if (existing.length === 0) return yield* new NotFoundError({
|
|
797
|
+
entity: "Model",
|
|
798
|
+
id
|
|
799
|
+
});
|
|
800
|
+
const model = existing[0];
|
|
801
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
802
|
+
const sets = ["updated_at = ?"];
|
|
803
|
+
const values = [now];
|
|
804
|
+
if (body.name !== void 0) {
|
|
805
|
+
sets.push("name = ?");
|
|
806
|
+
values.push(body.name);
|
|
807
|
+
}
|
|
808
|
+
if (body.singleton !== void 0) {
|
|
809
|
+
sets.push("singleton = ?");
|
|
810
|
+
values.push(body.singleton ? 1 : 0);
|
|
811
|
+
}
|
|
812
|
+
if (body.sortable !== void 0) {
|
|
813
|
+
sets.push("sortable = ?");
|
|
814
|
+
values.push(body.sortable ? 1 : 0);
|
|
815
|
+
}
|
|
816
|
+
if (body.hasDraft !== void 0) {
|
|
817
|
+
sets.push("has_draft = ?");
|
|
818
|
+
values.push(body.hasDraft ? 1 : 0);
|
|
819
|
+
}
|
|
820
|
+
if (body.allLocalesRequired !== void 0) {
|
|
821
|
+
sets.push("all_locales_required = ?");
|
|
822
|
+
values.push(body.allLocalesRequired ? 1 : 0);
|
|
823
|
+
}
|
|
824
|
+
if (body.ordering !== void 0) {
|
|
825
|
+
sets.push("ordering = ?");
|
|
826
|
+
values.push(body.ordering);
|
|
827
|
+
}
|
|
828
|
+
if (body.apiKey !== void 0 && body.apiKey !== model.api_key) {
|
|
829
|
+
const newApiKey = body.apiKey;
|
|
830
|
+
if (!/^[a-z][a-z0-9_]*$/.test(newApiKey)) return yield* new ValidationError({ message: "apiKey must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" });
|
|
831
|
+
if ((yield* sql.unsafe("SELECT id FROM models WHERE api_key = ? AND id != ?", [newApiKey, id])).length > 0) return yield* new DuplicateError({ message: `Model with apiKey '${newApiKey}' already exists` });
|
|
832
|
+
const oldPrefix = model.is_block ? "block_" : "content_";
|
|
833
|
+
const oldTableName = `${oldPrefix}${model.api_key}`;
|
|
834
|
+
const newTableName = `${oldPrefix}${newApiKey}`;
|
|
835
|
+
yield* sql.unsafe(`ALTER TABLE "${oldTableName}" RENAME TO "${newTableName}"`);
|
|
836
|
+
if (!model.is_block) {
|
|
837
|
+
yield* dropIndex(model.api_key).pipe(Effect.ignore);
|
|
838
|
+
yield* createFtsTable(newApiKey).pipe(Effect.ignore);
|
|
839
|
+
}
|
|
840
|
+
const allFields = yield* sql.unsafe("SELECT * FROM fields WHERE field_type IN ('link', 'links', 'structured_text')");
|
|
841
|
+
for (const f of allFields) {
|
|
842
|
+
const validators = decodeJsonRecordStringOr(f.validators || "{}", {});
|
|
843
|
+
let changed = false;
|
|
844
|
+
for (const key of ["item_item_type", "items_item_type"]) if (Array.isArray(validators[key])) {
|
|
845
|
+
const idx = validators[key].indexOf(model.api_key);
|
|
846
|
+
if (idx !== -1) {
|
|
847
|
+
validators[key][idx] = newApiKey;
|
|
848
|
+
changed = true;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (Array.isArray(validators.structured_text_blocks)) {
|
|
852
|
+
const idx = validators.structured_text_blocks.indexOf(model.api_key);
|
|
853
|
+
if (idx !== -1) {
|
|
854
|
+
validators.structured_text_blocks[idx] = newApiKey;
|
|
855
|
+
changed = true;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (changed) yield* sql.unsafe("UPDATE fields SET validators = ? WHERE id = ?", [encodeJson(validators), f.id]);
|
|
859
|
+
}
|
|
860
|
+
sets.push("api_key = ?");
|
|
861
|
+
values.push(newApiKey);
|
|
862
|
+
}
|
|
863
|
+
yield* sql.unsafe(`UPDATE models SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
|
|
864
|
+
if (typeof body.apiKey === "string" && body.apiKey !== model.api_key && !model.is_block) yield* rebuildIndex(body.apiKey).pipe(Effect.ignore);
|
|
865
|
+
return (yield* sql.unsafe("SELECT * FROM models WHERE id = ?", [id]))[0];
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
function deleteModel(id) {
|
|
869
|
+
return Effect.gen(function* () {
|
|
870
|
+
const sql = yield* SqlClient.SqlClient;
|
|
871
|
+
const models = yield* sql.unsafe("SELECT * FROM models WHERE id = ?", [id]);
|
|
872
|
+
if (models.length === 0) return yield* new NotFoundError({
|
|
873
|
+
entity: "Model",
|
|
874
|
+
id
|
|
875
|
+
});
|
|
876
|
+
const model = models[0];
|
|
877
|
+
const allFields = yield* sql.unsafe("SELECT * FROM fields");
|
|
878
|
+
if (!model.is_block) {
|
|
879
|
+
const referencingFields = allFields.filter((f) => {
|
|
880
|
+
if (f.field_type !== "link" && f.field_type !== "links") return false;
|
|
881
|
+
if (f.model_id === id) return false;
|
|
882
|
+
const validators = decodeJsonRecordStringOr(f.validators || "{}", {});
|
|
883
|
+
const allowedTypes = validators.items_item_type ?? validators.item_item_type;
|
|
884
|
+
return Array.isArray(allowedTypes) && allowedTypes.includes(model.api_key);
|
|
885
|
+
});
|
|
886
|
+
if (referencingFields.length > 0) {
|
|
887
|
+
const refs = [];
|
|
888
|
+
for (const f of referencingFields) {
|
|
889
|
+
const refModels = yield* sql.unsafe("SELECT api_key FROM models WHERE id = ?", [f.model_id]);
|
|
890
|
+
refs.push(`${refModels[0]?.api_key ?? "unknown"}.${f.api_key}`);
|
|
891
|
+
}
|
|
892
|
+
return yield* new ReferenceConflictError({
|
|
893
|
+
message: `Cannot delete model '${model.api_key}': referenced by fields: ${refs.join(", ")}`,
|
|
894
|
+
references: refs
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
} else {
|
|
898
|
+
const referencingFields = allFields.filter((f) => {
|
|
899
|
+
if (f.field_type !== "structured_text") return false;
|
|
900
|
+
const whitelist = decodeJsonRecordStringOr(f.validators || "{}", {}).block_whitelist;
|
|
901
|
+
return Array.isArray(whitelist) && whitelist.includes(model.api_key);
|
|
902
|
+
});
|
|
903
|
+
if (referencingFields.length > 0) {
|
|
904
|
+
const refs = [];
|
|
905
|
+
for (const f of referencingFields) {
|
|
906
|
+
const refModels = yield* sql.unsafe("SELECT api_key FROM models WHERE id = ?", [f.model_id]);
|
|
907
|
+
refs.push(`${refModels[0]?.api_key ?? "unknown"}.${f.api_key}`);
|
|
908
|
+
}
|
|
909
|
+
return yield* new ReferenceConflictError({
|
|
910
|
+
message: `Cannot delete block type '${model.api_key}': referenced by structured_text fields: ${refs.join(", ")}. Use remove_block to clean up DAST references first.`,
|
|
911
|
+
references: refs
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const tableName = model.is_block ? `block_${model.api_key}` : `content_${model.api_key}`;
|
|
916
|
+
const recordsDestroyed = (yield* sql.unsafe(`SELECT COUNT(*) as c FROM "${tableName}"`))[0]?.c ?? 0;
|
|
917
|
+
yield* sql.unsafe("DELETE FROM fields WHERE model_id = ?", [id]);
|
|
918
|
+
yield* dropTableSql(tableName);
|
|
919
|
+
yield* dropIndex(model.api_key).pipe(Effect.ignore);
|
|
920
|
+
yield* sql.unsafe("DELETE FROM models WHERE id = ?", [id]);
|
|
921
|
+
return {
|
|
922
|
+
deleted: true,
|
|
923
|
+
recordsDestroyed
|
|
924
|
+
};
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region src/services/field-service.ts
|
|
929
|
+
const ALLOWED_FIELD_VALIDATOR_KEYS = new Set([
|
|
930
|
+
"required",
|
|
931
|
+
"unique",
|
|
932
|
+
"enum",
|
|
933
|
+
"length",
|
|
934
|
+
"number_range",
|
|
935
|
+
"format",
|
|
936
|
+
"date_range",
|
|
937
|
+
"slug_source",
|
|
938
|
+
"item_item_type",
|
|
939
|
+
"items_item_type",
|
|
940
|
+
"structured_text_blocks",
|
|
941
|
+
"blocks_only",
|
|
942
|
+
"searchable"
|
|
943
|
+
]);
|
|
944
|
+
function validateFieldValidators(fieldType, apiKey, validators) {
|
|
945
|
+
return Effect.gen(function* () {
|
|
946
|
+
const unknownKeys = Object.keys(validators).filter((key) => !ALLOWED_FIELD_VALIDATOR_KEYS.has(key));
|
|
947
|
+
if (unknownKeys.length > 0) return yield* new ValidationError({
|
|
948
|
+
message: `Unknown validator key(s): ${unknownKeys.join(", ")}`,
|
|
949
|
+
field: apiKey
|
|
950
|
+
});
|
|
951
|
+
if (validators.required !== void 0 && typeof validators.required !== "boolean") return yield* new ValidationError({
|
|
952
|
+
message: `required validator must be a boolean`,
|
|
953
|
+
field: apiKey
|
|
954
|
+
});
|
|
955
|
+
if (isUnique(validators) && !supportsUniqueValidation(fieldType)) return yield* new ValidationError({
|
|
956
|
+
message: `unique validator is not supported for field type '${fieldType}'`,
|
|
957
|
+
field: apiKey
|
|
958
|
+
});
|
|
959
|
+
if (validators.unique !== void 0 && typeof validators.unique !== "boolean") return yield* new ValidationError({
|
|
960
|
+
message: `unique validator must be a boolean`,
|
|
961
|
+
field: apiKey
|
|
962
|
+
});
|
|
963
|
+
if (validators.searchable !== void 0 && typeof validators.searchable !== "boolean") return yield* new ValidationError({
|
|
964
|
+
message: `searchable validator must be a boolean`,
|
|
965
|
+
field: apiKey
|
|
966
|
+
});
|
|
967
|
+
const enumValues = validators.enum;
|
|
968
|
+
if (enumValues !== void 0) {
|
|
969
|
+
if (![
|
|
970
|
+
"string",
|
|
971
|
+
"text",
|
|
972
|
+
"slug"
|
|
973
|
+
].includes(fieldType)) return yield* new ValidationError({
|
|
974
|
+
message: `enum validator is only supported for string, text, and slug fields`,
|
|
975
|
+
field: apiKey
|
|
976
|
+
});
|
|
977
|
+
if (!Array.isArray(enumValues) || !enumValues.every((value) => typeof value === "string")) return yield* new ValidationError({
|
|
978
|
+
message: `enum validator must be an array of strings`,
|
|
979
|
+
field: apiKey
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
const length = validators.length;
|
|
983
|
+
if (length !== void 0) {
|
|
984
|
+
if (![
|
|
985
|
+
"string",
|
|
986
|
+
"text",
|
|
987
|
+
"slug"
|
|
988
|
+
].includes(fieldType)) return yield* new ValidationError({
|
|
989
|
+
message: `length validator is only supported for string, text, and slug fields`,
|
|
990
|
+
field: apiKey
|
|
991
|
+
});
|
|
992
|
+
if (typeof length !== "object" || length === null || Array.isArray(length)) return yield* new ValidationError({
|
|
993
|
+
message: `length validator must be an object`,
|
|
994
|
+
field: apiKey
|
|
995
|
+
});
|
|
996
|
+
const lengthConfig = length;
|
|
997
|
+
if (lengthConfig.min !== void 0 && (typeof lengthConfig.min !== "number" || lengthConfig.min < 0)) return yield* new ValidationError({
|
|
998
|
+
message: `length.min must be a non-negative number`,
|
|
999
|
+
field: apiKey
|
|
1000
|
+
});
|
|
1001
|
+
if (lengthConfig.max !== void 0 && (typeof lengthConfig.max !== "number" || lengthConfig.max < 0)) return yield* new ValidationError({
|
|
1002
|
+
message: `length.max must be a non-negative number`,
|
|
1003
|
+
field: apiKey
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
const slugSource = validators.slug_source;
|
|
1007
|
+
if (slugSource !== void 0) {
|
|
1008
|
+
if (fieldType !== "slug") return yield* new ValidationError({
|
|
1009
|
+
message: `slug_source validator is only supported for slug fields`,
|
|
1010
|
+
field: apiKey
|
|
1011
|
+
});
|
|
1012
|
+
if (typeof slugSource !== "string" || slugSource.length === 0) return yield* new ValidationError({
|
|
1013
|
+
message: `slug_source validator must be a non-empty string`,
|
|
1014
|
+
field: apiKey
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
const itemItemType = validators.item_item_type;
|
|
1018
|
+
if (itemItemType !== void 0) {
|
|
1019
|
+
if (fieldType !== "link") return yield* new ValidationError({
|
|
1020
|
+
message: `item_item_type validator is only supported for link fields`,
|
|
1021
|
+
field: apiKey
|
|
1022
|
+
});
|
|
1023
|
+
if (!Array.isArray(itemItemType) || !itemItemType.every((value) => typeof value === "string")) return yield* new ValidationError({
|
|
1024
|
+
message: `item_item_type validator must be an array of strings`,
|
|
1025
|
+
field: apiKey
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
const itemsItemType = validators.items_item_type;
|
|
1029
|
+
if (itemsItemType !== void 0) {
|
|
1030
|
+
if (fieldType !== "links") return yield* new ValidationError({
|
|
1031
|
+
message: `items_item_type validator is only supported for links fields`,
|
|
1032
|
+
field: apiKey
|
|
1033
|
+
});
|
|
1034
|
+
if (!Array.isArray(itemsItemType) || !itemsItemType.every((value) => typeof value === "string")) return yield* new ValidationError({
|
|
1035
|
+
message: `items_item_type validator must be an array of strings`,
|
|
1036
|
+
field: apiKey
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
const blocksOnly = validators.blocks_only;
|
|
1040
|
+
if (blocksOnly !== void 0) {
|
|
1041
|
+
if (fieldType !== "structured_text") return yield* new ValidationError({
|
|
1042
|
+
message: `blocks_only validator is only supported for structured_text fields`,
|
|
1043
|
+
field: apiKey
|
|
1044
|
+
});
|
|
1045
|
+
if (typeof blocksOnly !== "boolean") return yield* new ValidationError({
|
|
1046
|
+
message: `blocks_only validator must be a boolean`,
|
|
1047
|
+
field: apiKey
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
const structuredTextBlocks = validators.structured_text_blocks;
|
|
1051
|
+
if (structuredTextBlocks !== void 0) {
|
|
1052
|
+
if (fieldType !== "structured_text") return yield* new ValidationError({
|
|
1053
|
+
message: `structured_text_blocks validator is only supported for structured_text fields`,
|
|
1054
|
+
field: apiKey
|
|
1055
|
+
});
|
|
1056
|
+
if (!Array.isArray(structuredTextBlocks) || !structuredTextBlocks.every((value) => typeof value === "string")) return yield* new ValidationError({
|
|
1057
|
+
message: `structured_text_blocks validator must be an array of strings`,
|
|
1058
|
+
field: apiKey
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
const numberRange = validators.number_range;
|
|
1062
|
+
if (numberRange !== void 0) {
|
|
1063
|
+
if (!["integer", "float"].includes(fieldType)) return yield* new ValidationError({
|
|
1064
|
+
message: `number_range validator is only supported for integer and float fields`,
|
|
1065
|
+
field: apiKey
|
|
1066
|
+
});
|
|
1067
|
+
if (typeof numberRange !== "object" || numberRange === null || Array.isArray(numberRange)) return yield* new ValidationError({
|
|
1068
|
+
message: `number_range validator must be an object`,
|
|
1069
|
+
field: apiKey
|
|
1070
|
+
});
|
|
1071
|
+
const rangeConfig = numberRange;
|
|
1072
|
+
if (rangeConfig.min !== void 0 && typeof rangeConfig.min !== "number") return yield* new ValidationError({
|
|
1073
|
+
message: `number_range.min must be a number`,
|
|
1074
|
+
field: apiKey
|
|
1075
|
+
});
|
|
1076
|
+
if (rangeConfig.max !== void 0 && typeof rangeConfig.max !== "number") return yield* new ValidationError({
|
|
1077
|
+
message: `number_range.max must be a number`,
|
|
1078
|
+
field: apiKey
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
const format = validators.format;
|
|
1082
|
+
if (format !== void 0) {
|
|
1083
|
+
if (![
|
|
1084
|
+
"string",
|
|
1085
|
+
"text",
|
|
1086
|
+
"slug"
|
|
1087
|
+
].includes(fieldType)) return yield* new ValidationError({
|
|
1088
|
+
message: `format validator is only supported for string, text, and slug fields`,
|
|
1089
|
+
field: apiKey
|
|
1090
|
+
});
|
|
1091
|
+
const isPreset = format === "email" || format === "url";
|
|
1092
|
+
const isCustom = typeof format === "object" && format !== null && !Array.isArray(format) && typeof format.custom_pattern === "string";
|
|
1093
|
+
if (!isPreset && !isCustom) return yield* new ValidationError({
|
|
1094
|
+
message: `format validator must be 'email', 'url', or { custom_pattern: string }`,
|
|
1095
|
+
field: apiKey
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
const dateRange = validators.date_range;
|
|
1099
|
+
if (dateRange !== void 0) {
|
|
1100
|
+
if (!["date", "date_time"].includes(fieldType)) return yield* new ValidationError({
|
|
1101
|
+
message: `date_range validator is only supported for date and date_time fields`,
|
|
1102
|
+
field: apiKey
|
|
1103
|
+
});
|
|
1104
|
+
if (typeof dateRange !== "object" || dateRange === null || Array.isArray(dateRange)) return yield* new ValidationError({
|
|
1105
|
+
message: `date_range validator must be an object`,
|
|
1106
|
+
field: apiKey
|
|
1107
|
+
});
|
|
1108
|
+
const dateRangeConfig = dateRange;
|
|
1109
|
+
for (const key of ["min", "max"]) {
|
|
1110
|
+
const boundary = dateRangeConfig[key];
|
|
1111
|
+
if (boundary !== void 0 && boundary !== "now" && typeof boundary !== "string") return yield* new ValidationError({
|
|
1112
|
+
message: `date_range.${key} must be an ISO datetime string or 'now'`,
|
|
1113
|
+
field: apiKey
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
function serializeDefaultValueForFieldMetadata(value) {
|
|
1120
|
+
if (value === void 0) return null;
|
|
1121
|
+
if (typeof value === "string") return value;
|
|
1122
|
+
return encodeJson(value);
|
|
1123
|
+
}
|
|
1124
|
+
function serializeDefaultValueForRecordColumn(value) {
|
|
1125
|
+
if (value === void 0) return null;
|
|
1126
|
+
if (typeof value === "object" && value !== null) return encodeJson(value);
|
|
1127
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
1128
|
+
return value;
|
|
1129
|
+
}
|
|
1130
|
+
function syncTable(modelId) {
|
|
1131
|
+
return Effect.gen(function* () {
|
|
1132
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1133
|
+
const models = yield* sql.unsafe("SELECT api_key, is_block FROM models WHERE id = ?", [modelId]);
|
|
1134
|
+
if (models.length === 0) return;
|
|
1135
|
+
const model = models[0];
|
|
1136
|
+
const fields = yield* sql.unsafe("SELECT api_key, field_type FROM fields WHERE model_id = ? ORDER BY position", [modelId]);
|
|
1137
|
+
yield* migrateContentTable(model.api_key, !!model.is_block, fields.map((f) => ({
|
|
1138
|
+
apiKey: f.api_key,
|
|
1139
|
+
fieldType: isFieldType(f.field_type) ? f.field_type : "string"
|
|
1140
|
+
})));
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
function listFields(modelId) {
|
|
1144
|
+
return Effect.gen(function* () {
|
|
1145
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1146
|
+
if ((yield* sql.unsafe("SELECT id FROM models WHERE id = ?", [modelId])).length === 0) return yield* new NotFoundError({
|
|
1147
|
+
entity: "Model",
|
|
1148
|
+
id: modelId
|
|
1149
|
+
});
|
|
1150
|
+
return (yield* sql.unsafe("SELECT * FROM fields WHERE model_id = ? ORDER BY position", [modelId])).map(parseFieldValidators);
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
function createField(modelId, body) {
|
|
1154
|
+
return Effect.gen(function* () {
|
|
1155
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1156
|
+
if ((yield* sql.unsafe("SELECT id FROM models WHERE id = ?", [modelId])).length === 0) return yield* new NotFoundError({
|
|
1157
|
+
entity: "Model",
|
|
1158
|
+
id: modelId
|
|
1159
|
+
});
|
|
1160
|
+
if (!/^[a-z][a-z0-9_]*$/.test(body.apiKey)) return yield* new ValidationError({ message: "apiKey must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" });
|
|
1161
|
+
if (!isFieldType(body.fieldType)) return yield* new ValidationError({ message: `fieldType must be one of: ${FIELD_TYPES.join(", ")}` });
|
|
1162
|
+
if ((yield* sql.unsafe("SELECT id FROM fields WHERE model_id = ? AND api_key = ?", [modelId, body.apiKey])).length > 0) return yield* new DuplicateError({ message: `Field with apiKey '${body.apiKey}' already exists on this model` });
|
|
1163
|
+
const allFields = yield* sql.unsafe("SELECT id FROM fields WHERE model_id = ?", [modelId]);
|
|
1164
|
+
const position = body.position ?? allFields.length;
|
|
1165
|
+
const parsedValidators = body.validators;
|
|
1166
|
+
yield* validateFieldValidators(body.fieldType, body.apiKey, parsedValidators);
|
|
1167
|
+
if (parsedValidators.required) {
|
|
1168
|
+
const modelInfo = yield* sql.unsafe("SELECT api_key, is_block FROM models WHERE id = ?", [modelId]);
|
|
1169
|
+
if (modelInfo.length > 0) {
|
|
1170
|
+
const tableName = modelInfo[0].is_block ? `block_${modelInfo[0].api_key}` : `content_${modelInfo[0].api_key}`;
|
|
1171
|
+
const recordCount = yield* sql.unsafe(`SELECT COUNT(*) as c FROM "${tableName}"`);
|
|
1172
|
+
if (recordCount[0]?.c > 0 && body.defaultValue === void 0) return yield* new ValidationError({
|
|
1173
|
+
message: `Cannot add required field '${body.apiKey}' to model with ${recordCount[0].c} existing record(s) without a default_value. Provide a default_value.`,
|
|
1174
|
+
field: body.apiKey
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1179
|
+
const id = generateId();
|
|
1180
|
+
const validators = encodeJson(body.validators);
|
|
1181
|
+
yield* sql.unsafe(`INSERT INTO fields (id, model_id, label, api_key, field_type, position, localized, validators, default_value, appearance, hint, fieldset_id, created_at, updated_at)
|
|
1182
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1183
|
+
id,
|
|
1184
|
+
modelId,
|
|
1185
|
+
body.label,
|
|
1186
|
+
body.apiKey,
|
|
1187
|
+
body.fieldType,
|
|
1188
|
+
position,
|
|
1189
|
+
body.localized ? 1 : 0,
|
|
1190
|
+
validators,
|
|
1191
|
+
serializeDefaultValueForFieldMetadata(body.defaultValue),
|
|
1192
|
+
body.appearance ? encodeJson(body.appearance) : null,
|
|
1193
|
+
body.hint ?? null,
|
|
1194
|
+
body.fieldsetId ?? null,
|
|
1195
|
+
now,
|
|
1196
|
+
now
|
|
1197
|
+
]);
|
|
1198
|
+
yield* syncTable(modelId);
|
|
1199
|
+
if (parsedValidators.required && body.defaultValue !== void 0) {
|
|
1200
|
+
const modelInfo = yield* sql.unsafe("SELECT api_key, is_block FROM models WHERE id = ?", [modelId]);
|
|
1201
|
+
if (modelInfo.length > 0) {
|
|
1202
|
+
const tableName = modelInfo[0].is_block ? `block_${modelInfo[0].api_key}` : `content_${modelInfo[0].api_key}`;
|
|
1203
|
+
const serialized = serializeDefaultValueForRecordColumn(body.defaultValue);
|
|
1204
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET "${body.apiKey}" = ? WHERE "${body.apiKey}" IS NULL`, [serialized]);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return {
|
|
1208
|
+
id,
|
|
1209
|
+
modelId,
|
|
1210
|
+
label: body.label,
|
|
1211
|
+
apiKey: body.apiKey,
|
|
1212
|
+
fieldType: body.fieldType,
|
|
1213
|
+
position,
|
|
1214
|
+
localized: body.localized,
|
|
1215
|
+
validators: body.validators,
|
|
1216
|
+
defaultValue: body.defaultValue ?? null,
|
|
1217
|
+
appearance: body.appearance ?? null,
|
|
1218
|
+
hint: body.hint ?? null,
|
|
1219
|
+
fieldsetId: body.fieldsetId ?? null,
|
|
1220
|
+
createdAt: now,
|
|
1221
|
+
updatedAt: now
|
|
1222
|
+
};
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
function updateField(fieldId, body) {
|
|
1226
|
+
return Effect.gen(function* () {
|
|
1227
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1228
|
+
const fields = yield* sql.unsafe("SELECT * FROM fields WHERE id = ?", [fieldId]);
|
|
1229
|
+
if (fields.length === 0) return yield* new NotFoundError({
|
|
1230
|
+
entity: "Field",
|
|
1231
|
+
id: fieldId
|
|
1232
|
+
});
|
|
1233
|
+
const field = fields[0];
|
|
1234
|
+
const nextFieldType = body.fieldType ?? field.field_type;
|
|
1235
|
+
const nextValidators = body.validators ?? parseFieldValidators(field).validators;
|
|
1236
|
+
yield* validateFieldValidators(nextFieldType, field.api_key, nextValidators);
|
|
1237
|
+
if (body.fieldType !== void 0 && body.fieldType !== field.field_type) {
|
|
1238
|
+
const model = yield* sql.unsafe("SELECT api_key, is_block FROM models WHERE id = ?", [field.model_id]);
|
|
1239
|
+
if (model.length > 0) {
|
|
1240
|
+
const tableName = model[0].is_block ? `block_${model[0].api_key}` : `content_${model[0].api_key}`;
|
|
1241
|
+
const rows = yield* sql.unsafe(`SELECT COUNT(*) as c FROM "${tableName}" WHERE "${field.api_key}" IS NOT NULL`);
|
|
1242
|
+
if (rows[0]?.c > 0) return yield* new ValidationError({
|
|
1243
|
+
message: `Cannot change field type of '${field.api_key}' from '${field.field_type}' to '${body.fieldType}': field has data in ${rows[0].c} record(s). Clear the field data first.`,
|
|
1244
|
+
field: field.api_key
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1249
|
+
const sets = ["updated_at = ?"];
|
|
1250
|
+
const values = [now];
|
|
1251
|
+
if (body.label !== void 0) {
|
|
1252
|
+
sets.push("label = ?");
|
|
1253
|
+
values.push(body.label);
|
|
1254
|
+
}
|
|
1255
|
+
if (body.position !== void 0) {
|
|
1256
|
+
sets.push("position = ?");
|
|
1257
|
+
values.push(body.position);
|
|
1258
|
+
}
|
|
1259
|
+
if (body.localized !== void 0) {
|
|
1260
|
+
sets.push("localized = ?");
|
|
1261
|
+
values.push(body.localized ? 1 : 0);
|
|
1262
|
+
}
|
|
1263
|
+
if (body.validators !== void 0) {
|
|
1264
|
+
sets.push("validators = ?");
|
|
1265
|
+
values.push(encodeJson(body.validators));
|
|
1266
|
+
}
|
|
1267
|
+
if (body.hint !== void 0) {
|
|
1268
|
+
sets.push("hint = ?");
|
|
1269
|
+
values.push(body.hint);
|
|
1270
|
+
}
|
|
1271
|
+
if (body.appearance !== void 0) {
|
|
1272
|
+
sets.push("appearance = ?");
|
|
1273
|
+
values.push(encodeJson(body.appearance));
|
|
1274
|
+
}
|
|
1275
|
+
if (body.apiKey !== void 0 && body.apiKey !== field.api_key) {
|
|
1276
|
+
const newApiKey = body.apiKey;
|
|
1277
|
+
if (!/^[a-z][a-z0-9_]*$/.test(newApiKey)) return yield* new ValidationError({ message: "apiKey must start with a lowercase letter and contain only lowercase letters, numbers, and underscores" });
|
|
1278
|
+
if ((yield* sql.unsafe("SELECT id FROM fields WHERE model_id = ? AND api_key = ? AND id != ?", [
|
|
1279
|
+
field.model_id,
|
|
1280
|
+
newApiKey,
|
|
1281
|
+
fieldId
|
|
1282
|
+
])).length > 0) return yield* new DuplicateError({ message: `Field with apiKey '${newApiKey}' already exists on this model` });
|
|
1283
|
+
const modelInfo = yield* sql.unsafe("SELECT api_key, is_block FROM models WHERE id = ?", [field.model_id]);
|
|
1284
|
+
if (modelInfo.length > 0) {
|
|
1285
|
+
const tableName = modelInfo[0].is_block ? `block_${modelInfo[0].api_key}` : `content_${modelInfo[0].api_key}`;
|
|
1286
|
+
yield* sql.unsafe(`ALTER TABLE "${tableName}" RENAME COLUMN "${field.api_key}" TO "${newApiKey}"`);
|
|
1287
|
+
}
|
|
1288
|
+
if (field.field_type === "structured_text") {
|
|
1289
|
+
const blockModels = yield* sql.unsafe("SELECT api_key FROM models WHERE is_block = 1");
|
|
1290
|
+
for (const bm of blockModels) if (modelInfo[0].is_block) yield* sql.unsafe(`UPDATE "block_${bm.api_key}"
|
|
1291
|
+
SET _parent_field_api_key = ?
|
|
1292
|
+
WHERE _parent_container_model_api_key = ? AND _parent_field_api_key = ?`, [
|
|
1293
|
+
newApiKey,
|
|
1294
|
+
modelInfo[0].api_key,
|
|
1295
|
+
field.api_key
|
|
1296
|
+
]);
|
|
1297
|
+
else {
|
|
1298
|
+
yield* sql.unsafe(`UPDATE "block_${bm.api_key}"
|
|
1299
|
+
SET _root_field_api_key = ?
|
|
1300
|
+
WHERE _root_field_api_key = ?`, [newApiKey, field.api_key]);
|
|
1301
|
+
yield* sql.unsafe(`UPDATE "block_${bm.api_key}"
|
|
1302
|
+
SET _parent_field_api_key = ?
|
|
1303
|
+
WHERE _parent_container_model_api_key = ? AND _parent_block_id IS NULL AND _parent_field_api_key = ?`, [
|
|
1304
|
+
newApiKey,
|
|
1305
|
+
modelInfo[0].api_key,
|
|
1306
|
+
field.api_key
|
|
1307
|
+
]);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
sets.push("api_key = ?");
|
|
1311
|
+
values.push(newApiKey);
|
|
1312
|
+
}
|
|
1313
|
+
yield* sql.unsafe(`UPDATE fields SET ${sets.join(", ")} WHERE id = ?`, [...values, fieldId]);
|
|
1314
|
+
return parseFieldValidators((yield* sql.unsafe("SELECT * FROM fields WHERE id = ?", [fieldId]))[0]);
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
function deleteField(fieldId) {
|
|
1318
|
+
return Effect.gen(function* () {
|
|
1319
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1320
|
+
const fields = yield* sql.unsafe("SELECT * FROM fields WHERE id = ?", [fieldId]);
|
|
1321
|
+
if (fields.length === 0) return yield* new NotFoundError({
|
|
1322
|
+
entity: "Field",
|
|
1323
|
+
id: fieldId
|
|
1324
|
+
});
|
|
1325
|
+
const field = fields[0];
|
|
1326
|
+
const modelId = field.model_id;
|
|
1327
|
+
const siblingFields = yield* sql.unsafe("SELECT * FROM fields WHERE model_id = ? AND field_type = 'slug' AND id != ?", [modelId, fieldId]);
|
|
1328
|
+
for (const slugField of siblingFields) if (decodeJsonRecordStringOr(slugField.validators || "{}", {}).slug_source === field.api_key) return yield* new ReferenceConflictError({
|
|
1329
|
+
message: `Cannot delete field '${field.api_key}': slug field '${slugField.api_key}' depends on it via slug_source`,
|
|
1330
|
+
references: [`${slugField.api_key}.slug_source`]
|
|
1331
|
+
});
|
|
1332
|
+
const modelInfo = yield* sql.unsafe("SELECT * FROM models WHERE id = ?", [modelId]);
|
|
1333
|
+
if (modelInfo.length > 0) {
|
|
1334
|
+
const tableName = modelInfo[0].is_block ? `block_${modelInfo[0].api_key}` : `content_${modelInfo[0].api_key}`;
|
|
1335
|
+
const publishedRecords = yield* sql.unsafe(`SELECT id, _published_snapshot FROM "${tableName}" WHERE _published_snapshot IS NOT NULL`);
|
|
1336
|
+
for (const record of publishedRecords) {
|
|
1337
|
+
let snapshot;
|
|
1338
|
+
snapshot = decodeJsonRecordStringOr(record._published_snapshot, {});
|
|
1339
|
+
if (Object.keys(snapshot).length === 0) continue;
|
|
1340
|
+
if (field.api_key in snapshot) {
|
|
1341
|
+
delete snapshot[field.api_key];
|
|
1342
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _published_snapshot = ? WHERE id = ?`, [encodeJson(snapshot), record.id]);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (field.field_type === "structured_text") {
|
|
1346
|
+
const blockModels = yield* sql.unsafe("SELECT api_key FROM models WHERE is_block = 1");
|
|
1347
|
+
if (modelInfo[0].is_block) {
|
|
1348
|
+
const directChildIds = [];
|
|
1349
|
+
for (const bm of blockModels) {
|
|
1350
|
+
const rows = yield* sql.unsafe(`SELECT id FROM "block_${bm.api_key}"
|
|
1351
|
+
WHERE _parent_container_model_api_key = ?
|
|
1352
|
+
AND _parent_field_api_key = ?
|
|
1353
|
+
AND _parent_block_id IN (SELECT id FROM "${tableName}")`, [modelInfo[0].api_key, field.api_key]);
|
|
1354
|
+
directChildIds.push(...rows.map((r) => r.id));
|
|
1355
|
+
}
|
|
1356
|
+
yield* deleteBlockSubtrees({ blockIds: directChildIds });
|
|
1357
|
+
} else for (const bm of blockModels) yield* sql.unsafe(`DELETE FROM "block_${bm.api_key}" WHERE _root_field_api_key = ? AND _root_record_id IN (SELECT id FROM "${tableName}")`, [field.api_key]);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
yield* sql.unsafe("DELETE FROM fields WHERE id = ?", [fieldId]);
|
|
1361
|
+
yield* syncTable(modelId);
|
|
1362
|
+
return { deleted: true };
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
//#endregion
|
|
1366
|
+
//#region src/slug.ts
|
|
1367
|
+
/**
|
|
1368
|
+
* Generate a URL-safe slug from a string.
|
|
1369
|
+
* Django-parity: NFKD decomposition + charmap for non-decomposable chars.
|
|
1370
|
+
*/
|
|
1371
|
+
function generateSlug(input) {
|
|
1372
|
+
return slugify(input, {
|
|
1373
|
+
lower: true,
|
|
1374
|
+
strict: true,
|
|
1375
|
+
locale: "en"
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
//#endregion
|
|
1379
|
+
//#region src/schema-engine/sql-records.ts
|
|
1380
|
+
/**
|
|
1381
|
+
* Insert a record into a dynamic content table.
|
|
1382
|
+
*/
|
|
1383
|
+
function insertRecord(tableName, record) {
|
|
1384
|
+
return Effect.gen(function* () {
|
|
1385
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1386
|
+
const columns = Object.keys(record);
|
|
1387
|
+
const colList = columns.map((c) => `"${c}"`).join(", ");
|
|
1388
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
1389
|
+
const values = columns.map((c) => record[c]);
|
|
1390
|
+
yield* sql.unsafe(`INSERT INTO "${tableName}" (${colList}) VALUES (${placeholders})`, values.map(serializeValue));
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Select all records from a dynamic table.
|
|
1395
|
+
*/
|
|
1396
|
+
function selectAll(tableName) {
|
|
1397
|
+
return Effect.gen(function* () {
|
|
1398
|
+
return (yield* (yield* SqlClient.SqlClient).unsafe(`SELECT * FROM "${tableName}"`)).map(deserializeRow);
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Select a single record by ID.
|
|
1403
|
+
*/
|
|
1404
|
+
function selectById(tableName, id) {
|
|
1405
|
+
return Effect.gen(function* () {
|
|
1406
|
+
const rows = yield* (yield* SqlClient.SqlClient).unsafe(`SELECT * FROM "${tableName}" WHERE "id" = ?`, [id]);
|
|
1407
|
+
return rows.length > 0 ? deserializeRow(rows[0]) : null;
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Update a record by ID.
|
|
1412
|
+
*/
|
|
1413
|
+
function updateRecord(tableName, id, updates) {
|
|
1414
|
+
return Effect.gen(function* () {
|
|
1415
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1416
|
+
const columns = Object.keys(updates);
|
|
1417
|
+
if (columns.length === 0) return;
|
|
1418
|
+
const setClauses = columns.map((c) => `"${c}" = ?`).join(", ");
|
|
1419
|
+
const values = [...columns.map((c) => serializeValue(updates[c])), id];
|
|
1420
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET ${setClauses} WHERE "id" = ?`, values);
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Delete a record by ID.
|
|
1425
|
+
*/
|
|
1426
|
+
function deleteRecord(tableName, id) {
|
|
1427
|
+
return Effect.gen(function* () {
|
|
1428
|
+
yield* (yield* SqlClient.SqlClient).unsafe(`DELETE FROM "${tableName}" WHERE "id" = ?`, [id]);
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
/** Serialize a JS value for SQLite storage */
|
|
1432
|
+
function serializeValue(value) {
|
|
1433
|
+
if (value === void 0 || value === null) return null;
|
|
1434
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
1435
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1436
|
+
return value;
|
|
1437
|
+
}
|
|
1438
|
+
/** Deserialize a row from SQLite — parse JSON columns */
|
|
1439
|
+
function deserializeRow(row) {
|
|
1440
|
+
const result = {};
|
|
1441
|
+
for (const [key, value] of Object.entries(row)) if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) try {
|
|
1442
|
+
result[key] = JSON.parse(value);
|
|
1443
|
+
} catch {
|
|
1444
|
+
result[key] = value;
|
|
1445
|
+
}
|
|
1446
|
+
else result[key] = value;
|
|
1447
|
+
return result;
|
|
1448
|
+
}
|
|
1449
|
+
//#endregion
|
|
1450
|
+
//#region src/hooks.ts
|
|
1451
|
+
/**
|
|
1452
|
+
* Lifecycle hooks for content events.
|
|
1453
|
+
* Passed to createCMSHandler, fired in the service layer.
|
|
1454
|
+
*/
|
|
1455
|
+
var HooksContext = class extends Context.Tag("HooksContext")() {};
|
|
1456
|
+
var HookExecutionError = class extends Data.TaggedError("HookExecutionError") {};
|
|
1457
|
+
/**
|
|
1458
|
+
* Fire a lifecycle hook if configured. Non-blocking — errors are logged, not propagated.
|
|
1459
|
+
*/
|
|
1460
|
+
function fireHook(hookName, event) {
|
|
1461
|
+
return Effect.gen(function* () {
|
|
1462
|
+
const hooks = yield* HooksContext;
|
|
1463
|
+
if (Option.isNone(hooks)) return;
|
|
1464
|
+
const fn = hooks.value[hookName];
|
|
1465
|
+
if (!fn) return;
|
|
1466
|
+
yield* Effect.tryPromise({
|
|
1467
|
+
try: () => Promise.resolve(fn(event)),
|
|
1468
|
+
catch: (cause) => new HookExecutionError({ cause })
|
|
1469
|
+
}).pipe(Effect.ignore);
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region src/services/version-service.ts
|
|
1474
|
+
/**
|
|
1475
|
+
* Create a version snapshot for a record.
|
|
1476
|
+
* Called internally by publish and auto-republish flows.
|
|
1477
|
+
*/
|
|
1478
|
+
function createVersion(modelApiKey, recordId, snapshot, attribution) {
|
|
1479
|
+
return Effect.gen(function* () {
|
|
1480
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1481
|
+
const nextVersion = ((yield* sql.unsafe(`SELECT MAX(version_number) as max_v FROM record_versions WHERE model_api_key = ? AND record_id = ?`, [modelApiKey, recordId]))[0]?.max_v ?? 0) + 1;
|
|
1482
|
+
const id = generateId();
|
|
1483
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1484
|
+
const actor = attribution?.actor;
|
|
1485
|
+
yield* sql.unsafe(`INSERT INTO record_versions (id, model_api_key, record_id, version_number, snapshot, action, actor_type, actor_label, actor_token_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1486
|
+
id,
|
|
1487
|
+
modelApiKey,
|
|
1488
|
+
recordId,
|
|
1489
|
+
nextVersion,
|
|
1490
|
+
snapshot,
|
|
1491
|
+
attribution?.action ?? "publish",
|
|
1492
|
+
actor?.type ?? null,
|
|
1493
|
+
actor?.label ?? null,
|
|
1494
|
+
actor?.tokenId ?? null,
|
|
1495
|
+
now
|
|
1496
|
+
]);
|
|
1497
|
+
return {
|
|
1498
|
+
id,
|
|
1499
|
+
model_api_key: modelApiKey,
|
|
1500
|
+
record_id: recordId,
|
|
1501
|
+
version_number: nextVersion,
|
|
1502
|
+
action: attribution?.action ?? "publish",
|
|
1503
|
+
actor_type: actor?.type ?? null,
|
|
1504
|
+
actor_label: actor?.label ?? null,
|
|
1505
|
+
actor_token_id: actor?.tokenId ?? null,
|
|
1506
|
+
created_at: now
|
|
1507
|
+
};
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* List all versions for a record, newest first.
|
|
1512
|
+
*/
|
|
1513
|
+
function listVersions(modelApiKey, recordId) {
|
|
1514
|
+
return Effect.gen(function* () {
|
|
1515
|
+
return (yield* (yield* SqlClient.SqlClient).unsafe(`SELECT * FROM record_versions WHERE model_api_key = ? AND record_id = ? ORDER BY version_number DESC`, [modelApiKey, recordId])).map((r) => ({
|
|
1516
|
+
...r,
|
|
1517
|
+
snapshot: decodeJsonString(r.snapshot)
|
|
1518
|
+
}));
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Get a single version by ID.
|
|
1523
|
+
*/
|
|
1524
|
+
function getVersion(versionId) {
|
|
1525
|
+
return Effect.gen(function* () {
|
|
1526
|
+
const rows = yield* (yield* SqlClient.SqlClient).unsafe(`SELECT * FROM record_versions WHERE id = ?`, [versionId]);
|
|
1527
|
+
if (rows.length === 0) return yield* new NotFoundError({
|
|
1528
|
+
entity: "Version",
|
|
1529
|
+
id: versionId
|
|
1530
|
+
});
|
|
1531
|
+
const row = rows[0];
|
|
1532
|
+
return {
|
|
1533
|
+
...row,
|
|
1534
|
+
snapshot: decodeJsonString(row.snapshot)
|
|
1535
|
+
};
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Restore a record to a previous version.
|
|
1540
|
+
* Versions the current state first (so restore is reversible), then writes
|
|
1541
|
+
* the version's field values back to the content table.
|
|
1542
|
+
*/
|
|
1543
|
+
function restoreVersion(modelApiKey, recordId, versionId, actor) {
|
|
1544
|
+
return Effect.gen(function* () {
|
|
1545
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1546
|
+
const versionRows = yield* sql.unsafe(`SELECT * FROM record_versions WHERE id = ?`, [versionId]);
|
|
1547
|
+
if (versionRows.length === 0) return yield* new NotFoundError({
|
|
1548
|
+
entity: "Version",
|
|
1549
|
+
id: versionId
|
|
1550
|
+
});
|
|
1551
|
+
const version = versionRows[0];
|
|
1552
|
+
const models = yield* sql.unsafe(`SELECT * FROM models WHERE api_key = ?`, [modelApiKey]);
|
|
1553
|
+
if (models.length === 0) return yield* new NotFoundError({
|
|
1554
|
+
entity: "Model",
|
|
1555
|
+
id: modelApiKey
|
|
1556
|
+
});
|
|
1557
|
+
const model = models[0];
|
|
1558
|
+
const tableName = `content_${model.api_key}`;
|
|
1559
|
+
const current = yield* selectById(tableName, recordId);
|
|
1560
|
+
if (!current) return yield* new NotFoundError({
|
|
1561
|
+
entity: "Record",
|
|
1562
|
+
id: recordId
|
|
1563
|
+
});
|
|
1564
|
+
const currentSnap = {};
|
|
1565
|
+
for (const [key, value] of Object.entries(current)) if (!key.startsWith("_") && key !== "id") currentSnap[key] = value;
|
|
1566
|
+
yield* createVersion(modelApiKey, recordId, encodeJson(currentSnap), {
|
|
1567
|
+
action: "restore",
|
|
1568
|
+
actor
|
|
1569
|
+
});
|
|
1570
|
+
const fieldRows = yield* sql.unsafe(`SELECT * FROM fields WHERE model_id = ? ORDER BY position`, [model.id]);
|
|
1571
|
+
const existingFieldKeys = new Set(fieldRows.map((f) => f.api_key));
|
|
1572
|
+
const versionSnapshot = decodeJsonString(version.snapshot);
|
|
1573
|
+
const updates = {};
|
|
1574
|
+
for (const [key, value] of Object.entries(versionSnapshot)) if (existingFieldKeys.has(key)) updates[key] = value;
|
|
1575
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1576
|
+
updates._updated_at = now;
|
|
1577
|
+
updates._updated_by = actor?.label ?? null;
|
|
1578
|
+
if (model.has_draft) updates._status = "draft";
|
|
1579
|
+
else {
|
|
1580
|
+
const parsedFields = fieldRows.map(parseFieldValidators);
|
|
1581
|
+
const tempRecord = { ...current };
|
|
1582
|
+
for (const [key, value] of Object.entries(updates)) tempRecord[key] = value;
|
|
1583
|
+
const materialized = yield* materializeRecordStructuredTextFields({
|
|
1584
|
+
modelApiKey,
|
|
1585
|
+
record: tempRecord,
|
|
1586
|
+
fields: parsedFields
|
|
1587
|
+
});
|
|
1588
|
+
const snap = {};
|
|
1589
|
+
for (const [key, value] of Object.entries(materialized)) if (!key.startsWith("_") && key !== "id") snap[key] = value;
|
|
1590
|
+
updates._published_snapshot = encodeJson(snap);
|
|
1591
|
+
updates._published_at = now;
|
|
1592
|
+
updates._published_by = actor?.label ?? null;
|
|
1593
|
+
updates._status = "published";
|
|
1594
|
+
}
|
|
1595
|
+
const setCols = Object.keys(updates);
|
|
1596
|
+
const setClauses = setCols.map((c) => `"${c}" = ?`).join(", ");
|
|
1597
|
+
const values = setCols.map((c) => {
|
|
1598
|
+
const v = updates[c];
|
|
1599
|
+
if (v === void 0 || v === null) return null;
|
|
1600
|
+
if (typeof v === "object") return encodeJson(v);
|
|
1601
|
+
return v;
|
|
1602
|
+
});
|
|
1603
|
+
values.push(recordId);
|
|
1604
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET ${setClauses} WHERE id = ?`, values);
|
|
1605
|
+
yield* fireHook("onRecordUpdate", {
|
|
1606
|
+
modelApiKey,
|
|
1607
|
+
recordId
|
|
1608
|
+
});
|
|
1609
|
+
return yield* selectById(tableName, recordId);
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
function deleteVersionsForRecord(modelApiKey, recordId) {
|
|
1613
|
+
return Effect.gen(function* () {
|
|
1614
|
+
yield* (yield* SqlClient.SqlClient).unsafe(`DELETE FROM record_versions WHERE model_api_key = ? AND record_id = ?`, [modelApiKey, recordId]);
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
//#endregion
|
|
1618
|
+
//#region src/services/record-service.ts
|
|
1619
|
+
function validateRequestedId(id) {
|
|
1620
|
+
if (id === void 0) return null;
|
|
1621
|
+
return id.trim().length > 0 ? id : null;
|
|
1622
|
+
}
|
|
1623
|
+
function applyRecordOverrides(target, overrides) {
|
|
1624
|
+
if (!overrides) return;
|
|
1625
|
+
if (overrides.createdAt !== void 0) target._created_at = overrides.createdAt;
|
|
1626
|
+
if (overrides.updatedAt !== void 0) target._updated_at = overrides.updatedAt;
|
|
1627
|
+
if (overrides.publishedAt !== void 0) target._published_at = overrides.publishedAt;
|
|
1628
|
+
if (overrides.firstPublishedAt !== void 0) target._first_published_at = overrides.firstPublishedAt;
|
|
1629
|
+
}
|
|
1630
|
+
function applyActorColumns(target, actor, options) {
|
|
1631
|
+
if (!actor) return;
|
|
1632
|
+
if (options?.created) target._created_by = actor.label;
|
|
1633
|
+
if (options?.updated) target._updated_by = actor.label;
|
|
1634
|
+
if (options?.published) target._published_by = actor.label;
|
|
1635
|
+
}
|
|
1636
|
+
function getModelByApiKey(apiKey) {
|
|
1637
|
+
return Effect.gen(function* () {
|
|
1638
|
+
const models = yield* (yield* SqlClient.SqlClient).unsafe("SELECT * FROM models WHERE api_key = ?", [apiKey]);
|
|
1639
|
+
return models.length > 0 ? models[0] : null;
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
function getModelFields(modelId) {
|
|
1643
|
+
return Effect.gen(function* () {
|
|
1644
|
+
return (yield* (yield* SqlClient.SqlClient).unsafe("SELECT * FROM fields WHERE model_id = ? ORDER BY position", [modelId])).map(parseFieldValidators);
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
function decodeLocalizedStructuredTextMap(field, rawValue) {
|
|
1648
|
+
return Schema.decodeUnknown(Schema.Record({
|
|
1649
|
+
key: Schema.String,
|
|
1650
|
+
value: Schema.NullOr(Schema.Unknown)
|
|
1651
|
+
}))(rawValue).pipe(Effect.mapError((e) => new ValidationError({
|
|
1652
|
+
message: `Invalid localized StructuredText for field '${field.api_key}': ${e.message}`,
|
|
1653
|
+
field: field.api_key
|
|
1654
|
+
})));
|
|
1655
|
+
}
|
|
1656
|
+
function decodeLocalizedFieldMap(field, rawValue) {
|
|
1657
|
+
return Schema.decodeUnknown(Schema.Record({
|
|
1658
|
+
key: Schema.String,
|
|
1659
|
+
value: Schema.Unknown
|
|
1660
|
+
}))(rawValue).pipe(Effect.map((localeMap) => sanitizeLocaleMap(localeMap)), Effect.mapError((e) => new ValidationError({
|
|
1661
|
+
message: `Invalid localized value for field '${field.api_key}': ${e.message}`,
|
|
1662
|
+
field: field.api_key
|
|
1663
|
+
})));
|
|
1664
|
+
}
|
|
1665
|
+
function parseExistingLocaleMap(rawValue) {
|
|
1666
|
+
if (rawValue === null || rawValue === void 0) return {};
|
|
1667
|
+
const parsed = decodeJsonIfString(rawValue);
|
|
1668
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
1669
|
+
return sanitizeLocaleMap(parsed);
|
|
1670
|
+
}
|
|
1671
|
+
function sanitizeLocaleMap(localeMap) {
|
|
1672
|
+
return Object.fromEntries(Object.entries(localeMap).filter(([key]) => isLocaleKey(key)));
|
|
1673
|
+
}
|
|
1674
|
+
function isLocaleKey(key) {
|
|
1675
|
+
return /^[a-z]{2,3}(?:[_-][A-Za-z0-9]{2,8})*$/.test(key);
|
|
1676
|
+
}
|
|
1677
|
+
function isLocalizedValueMap(value) {
|
|
1678
|
+
return isJsonRecord(value) && Object.keys(value).length > 0 && Object.keys(value).every(isLocaleKey);
|
|
1679
|
+
}
|
|
1680
|
+
function isJsonRecord(value) {
|
|
1681
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1682
|
+
}
|
|
1683
|
+
function isMistakenMediaObject(value) {
|
|
1684
|
+
return isJsonRecord(value) && typeof value.id === "string" && value.id.length > 0 && value.upload_id === void 0;
|
|
1685
|
+
}
|
|
1686
|
+
function isMistakenLinkObject(value) {
|
|
1687
|
+
return isJsonRecord(value) && typeof value.id === "string" && value.id.length > 0;
|
|
1688
|
+
}
|
|
1689
|
+
function hasMistakenLinkObject(value) {
|
|
1690
|
+
return Array.isArray(value) && value.some((entry) => isMistakenLinkObject(entry));
|
|
1691
|
+
}
|
|
1692
|
+
function toSlugSourceString(value) {
|
|
1693
|
+
if (typeof value === "string") return value;
|
|
1694
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
function normalizeBooleanValue(field, value) {
|
|
1698
|
+
if (field.field_type !== "boolean") return value;
|
|
1699
|
+
if (value === 1) return true;
|
|
1700
|
+
if (value === 0) return false;
|
|
1701
|
+
if (field.localized && isJsonRecord(value)) return Object.fromEntries(Object.entries(value).map(([locale, localeValue]) => [locale, localeValue === 1 ? true : localeValue === 0 ? false : localeValue]));
|
|
1702
|
+
return value;
|
|
1703
|
+
}
|
|
1704
|
+
function normalizeBooleanFields(record, fields) {
|
|
1705
|
+
const normalized = { ...record };
|
|
1706
|
+
for (const field of fields) if (field.api_key in normalized) normalized[field.api_key] = normalizeBooleanValue(field, normalized[field.api_key]);
|
|
1707
|
+
return normalized;
|
|
1708
|
+
}
|
|
1709
|
+
function scopeStructuredTextIds(value, scope) {
|
|
1710
|
+
if (!value || typeof value !== "object") return value;
|
|
1711
|
+
const clone = structuredClone(value);
|
|
1712
|
+
if (!isJsonRecord(clone)) return clone;
|
|
1713
|
+
const mutableClone = clone;
|
|
1714
|
+
const blocks = isJsonRecord(mutableClone.blocks) ? mutableClone.blocks : void 0;
|
|
1715
|
+
const originalIds = Object.keys(blocks ?? {});
|
|
1716
|
+
if (originalIds.length === 0) return clone;
|
|
1717
|
+
const idMap = new Map(originalIds.map((id) => [id, `${scope}:${id}`]));
|
|
1718
|
+
const rewriteNode = (node) => {
|
|
1719
|
+
if (!node || typeof node !== "object") return node;
|
|
1720
|
+
if (Array.isArray(node)) return node.map(rewriteNode);
|
|
1721
|
+
const next = {};
|
|
1722
|
+
for (const [key, child] of Object.entries(node)) next[key] = rewriteNode(child);
|
|
1723
|
+
if ((next.type === "block" || next.type === "inlineBlock") && typeof next.item === "string") next.item = idMap.get(next.item) ?? next.item;
|
|
1724
|
+
return next;
|
|
1725
|
+
};
|
|
1726
|
+
if ("value" in mutableClone) mutableClone.value = rewriteNode(mutableClone.value);
|
|
1727
|
+
mutableClone.blocks = Object.fromEntries(Object.entries(blocks ?? {}).map(([blockId, blockValue]) => [idMap.get(blockId) ?? blockId, blockValue]));
|
|
1728
|
+
return clone;
|
|
1729
|
+
}
|
|
1730
|
+
function createFieldErrorMessage(prefix, message) {
|
|
1731
|
+
return prefix ? `${prefix}: ${message}` : message;
|
|
1732
|
+
}
|
|
1733
|
+
function getReferenceIds(fieldType, value) {
|
|
1734
|
+
if (fieldType === "link") return typeof value === "string" && value.length > 0 ? [value] : [];
|
|
1735
|
+
if (fieldType === "links") return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.length > 0) : [];
|
|
1736
|
+
return [];
|
|
1737
|
+
}
|
|
1738
|
+
function getAssetIds(fieldType, value) {
|
|
1739
|
+
if (fieldType === "media") {
|
|
1740
|
+
const ref = parseMediaFieldReference(value);
|
|
1741
|
+
return ref ? [ref.uploadId] : [];
|
|
1742
|
+
}
|
|
1743
|
+
if (fieldType === "media_gallery") return parseMediaGalleryReferences(value).map((ref) => ref.uploadId);
|
|
1744
|
+
if (fieldType === "seo" && isJsonRecord(value) && typeof value.image === "string" && value.image.length > 0) return [value.image];
|
|
1745
|
+
return [];
|
|
1746
|
+
}
|
|
1747
|
+
function validateAssetFieldValue(sql, field, value, errorPrefix) {
|
|
1748
|
+
return Effect.gen(function* () {
|
|
1749
|
+
const assetIds = getAssetIds(field.field_type, value);
|
|
1750
|
+
if (assetIds.length === 0) return;
|
|
1751
|
+
const placeholders = assetIds.map(() => "?").join(", ");
|
|
1752
|
+
const found = yield* sql.unsafe(`SELECT id FROM assets WHERE id IN (${placeholders})`, assetIds);
|
|
1753
|
+
const foundIds = new Set(found.map((row) => row.id));
|
|
1754
|
+
const missing = assetIds.filter((id) => !foundIds.has(id));
|
|
1755
|
+
if (missing.length > 0) return yield* new ValidationError({
|
|
1756
|
+
message: createFieldErrorMessage(errorPrefix, `Asset(s) not found for field '${field.api_key}': ${missing.join(", ")}`),
|
|
1757
|
+
field: field.api_key
|
|
1758
|
+
});
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
function validateReferenceFieldValue(sql, field, value, errorPrefix) {
|
|
1762
|
+
return Effect.gen(function* () {
|
|
1763
|
+
const targetModelApiKeys = field.field_type === "link" ? getLinkTargets(field.validators) : field.field_type === "links" ? getLinksTargets(field.validators) : void 0;
|
|
1764
|
+
const referenceIds = getReferenceIds(field.field_type, value);
|
|
1765
|
+
if (!targetModelApiKeys || referenceIds.length === 0) return;
|
|
1766
|
+
const placeholders = targetModelApiKeys.map(() => "?").join(", ");
|
|
1767
|
+
const targetModels = yield* sql.unsafe(`SELECT * FROM models WHERE api_key IN (${placeholders})`, targetModelApiKeys);
|
|
1768
|
+
const foundIds = /* @__PURE__ */ new Set();
|
|
1769
|
+
for (const model of targetModels) {
|
|
1770
|
+
const idPlaceholders = referenceIds.map(() => "?").join(", ");
|
|
1771
|
+
const rows = yield* sql.unsafe(`SELECT id FROM "content_${model.api_key}" WHERE id IN (${idPlaceholders})`, referenceIds);
|
|
1772
|
+
for (const row of rows) foundIds.add(row.id);
|
|
1773
|
+
}
|
|
1774
|
+
const missingIds = referenceIds.filter((id) => !foundIds.has(id));
|
|
1775
|
+
if (missingIds.length > 0) return yield* new ValidationError({
|
|
1776
|
+
message: createFieldErrorMessage(errorPrefix, `Linked record(s) not found for field '${field.api_key}': ${missingIds.join(", ")}`),
|
|
1777
|
+
field: field.api_key
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
function processCreateLikeRecordFields({ modelApiKey, tableName, recordId, data, record, modelFields, errorPrefix }) {
|
|
1782
|
+
return Effect.gen(function* () {
|
|
1783
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1784
|
+
for (const field of modelFields) {
|
|
1785
|
+
if (field.field_type === "structured_text" && data[field.api_key] !== void 0 && data[field.api_key] !== null) {
|
|
1786
|
+
if (field.localized) {
|
|
1787
|
+
const localeMap = yield* decodeLocalizedStructuredTextMap(field, data[field.api_key]).pipe(Effect.mapError((error) => new ValidationError({
|
|
1788
|
+
message: createFieldErrorMessage(errorPrefix, error.message),
|
|
1789
|
+
field: error.field
|
|
1790
|
+
})));
|
|
1791
|
+
const localizedDast = {};
|
|
1792
|
+
for (const [localeCode, localeValue] of Object.entries(localeMap)) {
|
|
1793
|
+
if (localeValue === null) {
|
|
1794
|
+
localizedDast[localeCode] = null;
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
const stInput = yield* Schema.decodeUnknown(StructuredTextWriteInput)(scopeStructuredTextIds(localeValue, `${field.api_key}:${localeCode}`)).pipe(Effect.mapError((e) => new ValidationError({
|
|
1798
|
+
message: createFieldErrorMessage(errorPrefix, `Invalid StructuredText for field '${field.api_key}' locale '${localeCode}': ${e.message}`),
|
|
1799
|
+
field: field.api_key
|
|
1800
|
+
})));
|
|
1801
|
+
const allowedBlockTypes = getBlockWhitelist(field.validators);
|
|
1802
|
+
const blocksOnly = getBlocksOnly(field.validators);
|
|
1803
|
+
localizedDast[localeCode] = yield* writeStructuredText({
|
|
1804
|
+
rootModelApiKey: modelApiKey,
|
|
1805
|
+
fieldApiKey: field.api_key,
|
|
1806
|
+
rootFieldStorageKey: getStructuredTextStorageKey(field.api_key, localeCode),
|
|
1807
|
+
rootRecordId: recordId,
|
|
1808
|
+
value: stInput.value,
|
|
1809
|
+
blocks: stInput.blocks,
|
|
1810
|
+
allowedBlockTypes: allowedBlockTypes ?? [],
|
|
1811
|
+
blocksOnly
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
data[field.api_key] = localizedDast;
|
|
1815
|
+
record[field.api_key] = localizedDast;
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
const stInput = yield* Schema.decodeUnknown(StructuredTextWriteInput)(data[field.api_key]).pipe(Effect.mapError((e) => new ValidationError({
|
|
1819
|
+
message: createFieldErrorMessage(errorPrefix, `Invalid StructuredText for field '${field.api_key}': ${e.message}`),
|
|
1820
|
+
field: field.api_key
|
|
1821
|
+
})));
|
|
1822
|
+
const allowedBlockTypes = getBlockWhitelist(field.validators);
|
|
1823
|
+
const blocksOnly = getBlocksOnly(field.validators);
|
|
1824
|
+
const dast = yield* writeStructuredText({
|
|
1825
|
+
rootModelApiKey: modelApiKey,
|
|
1826
|
+
fieldApiKey: field.api_key,
|
|
1827
|
+
rootRecordId: recordId,
|
|
1828
|
+
value: stInput.value,
|
|
1829
|
+
blocks: stInput.blocks,
|
|
1830
|
+
allowedBlockTypes: allowedBlockTypes ?? [],
|
|
1831
|
+
blocksOnly
|
|
1832
|
+
});
|
|
1833
|
+
data[field.api_key] = dast;
|
|
1834
|
+
}
|
|
1835
|
+
if (field.field_type === "slug") {
|
|
1836
|
+
const sourceFieldKey = getSlugSource(field.validators);
|
|
1837
|
+
const sourceValue = sourceFieldKey ? toSlugSourceString(data[sourceFieldKey]) : null;
|
|
1838
|
+
const currentValue = toSlugSourceString(data[field.api_key]);
|
|
1839
|
+
if (!data[field.api_key] && sourceValue) data[field.api_key] = generateSlug(sourceValue);
|
|
1840
|
+
else if (currentValue) data[field.api_key] = generateSlug(currentValue);
|
|
1841
|
+
if (data[field.api_key]) {
|
|
1842
|
+
let slug = String(data[field.api_key]);
|
|
1843
|
+
const baseSlug = slug;
|
|
1844
|
+
let suffix = 1;
|
|
1845
|
+
for (;;) {
|
|
1846
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE "${field.api_key}" = ?`, [slug])).length === 0) break;
|
|
1847
|
+
suffix++;
|
|
1848
|
+
slug = `${baseSlug}-${suffix}`;
|
|
1849
|
+
}
|
|
1850
|
+
data[field.api_key] = slug;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
if (isFieldType(field.field_type) && data[field.api_key] !== void 0 && data[field.api_key] !== null) {
|
|
1854
|
+
const fieldDef = getFieldTypeDef(field.field_type);
|
|
1855
|
+
if (field.field_type === "media" && isMistakenMediaObject(data[field.api_key])) return yield* new ValidationError({
|
|
1856
|
+
message: createFieldErrorMessage(errorPrefix, `Invalid media for field '${field.api_key}': use an asset ID string or {"upload_id":"<asset_id>"}, not {"id":"..."}`),
|
|
1857
|
+
field: field.api_key
|
|
1858
|
+
});
|
|
1859
|
+
if (field.field_type === "link" && isMistakenLinkObject(data[field.api_key])) return yield* new ValidationError({
|
|
1860
|
+
message: createFieldErrorMessage(errorPrefix, `Invalid link for field '${field.api_key}': use a record ID string, not {"id":"..."}`),
|
|
1861
|
+
field: field.api_key
|
|
1862
|
+
});
|
|
1863
|
+
if (field.field_type === "links" && hasMistakenLinkObject(data[field.api_key])) return yield* new ValidationError({
|
|
1864
|
+
message: createFieldErrorMessage(errorPrefix, `Invalid links for field '${field.api_key}': use an array of record ID strings, not objects like {"id":"..."}`),
|
|
1865
|
+
field: field.api_key
|
|
1866
|
+
});
|
|
1867
|
+
if (!field.localized && isLocalizedValueMap(data[field.api_key])) return yield* new ValidationError({
|
|
1868
|
+
message: createFieldErrorMessage(errorPrefix, `Field '${field.api_key}' is not localized and cannot accept locale-keyed values`),
|
|
1869
|
+
field: field.api_key
|
|
1870
|
+
});
|
|
1871
|
+
if (fieldDef.inputSchema) if (field.localized) {
|
|
1872
|
+
const localeMap = yield* decodeLocalizedFieldMap(field, data[field.api_key]).pipe(Effect.mapError((error) => new ValidationError({
|
|
1873
|
+
message: createFieldErrorMessage(errorPrefix, error.message),
|
|
1874
|
+
field: error.field
|
|
1875
|
+
})));
|
|
1876
|
+
for (const [localeCode, localeValue] of Object.entries(localeMap)) {
|
|
1877
|
+
if (localeValue === null) continue;
|
|
1878
|
+
yield* Schema.decodeUnknown(fieldDef.inputSchema)(localeValue).pipe(Effect.mapError((e) => new ValidationError({
|
|
1879
|
+
message: createFieldErrorMessage(errorPrefix, `Invalid ${field.field_type} for field '${field.api_key}' locale '${localeCode}': ${e.message}`),
|
|
1880
|
+
field: field.api_key
|
|
1881
|
+
})));
|
|
1882
|
+
}
|
|
1883
|
+
} else yield* Schema.decodeUnknown(fieldDef.inputSchema)(data[field.api_key]).pipe(Effect.mapError((e) => new ValidationError({
|
|
1884
|
+
message: createFieldErrorMessage(errorPrefix, `Invalid ${field.field_type} for field '${field.api_key}': ${e.message}`),
|
|
1885
|
+
field: field.api_key
|
|
1886
|
+
})));
|
|
1887
|
+
}
|
|
1888
|
+
if ((field.field_type === "link" || field.field_type === "links") && data[field.api_key] !== void 0 && data[field.api_key] !== null) if (field.localized) {
|
|
1889
|
+
const localeMap = yield* decodeLocalizedFieldMap(field, data[field.api_key]).pipe(Effect.mapError((error) => new ValidationError({
|
|
1890
|
+
message: createFieldErrorMessage(errorPrefix, error.message),
|
|
1891
|
+
field: error.field
|
|
1892
|
+
})));
|
|
1893
|
+
for (const localeValue of Object.values(localeMap)) {
|
|
1894
|
+
if (localeValue === null) continue;
|
|
1895
|
+
yield* validateReferenceFieldValue(sql, field, localeValue, errorPrefix);
|
|
1896
|
+
}
|
|
1897
|
+
} else yield* validateReferenceFieldValue(sql, field, data[field.api_key], errorPrefix);
|
|
1898
|
+
if ((field.field_type === "media" || field.field_type === "media_gallery" || field.field_type === "seo") && data[field.api_key] !== void 0 && data[field.api_key] !== null) if (field.localized) {
|
|
1899
|
+
const localeMap = yield* decodeLocalizedFieldMap(field, data[field.api_key]).pipe(Effect.mapError((error) => new ValidationError({
|
|
1900
|
+
message: createFieldErrorMessage(errorPrefix, error.message),
|
|
1901
|
+
field: error.field
|
|
1902
|
+
})));
|
|
1903
|
+
for (const localeValue of Object.values(localeMap)) {
|
|
1904
|
+
if (localeValue === null) continue;
|
|
1905
|
+
yield* validateAssetFieldValue(sql, field, localeValue, errorPrefix);
|
|
1906
|
+
}
|
|
1907
|
+
} else yield* validateAssetFieldValue(sql, field, data[field.api_key], errorPrefix);
|
|
1908
|
+
if (data[field.api_key] !== void 0) record[field.api_key] = data[field.api_key];
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
function createRecord(body, actor) {
|
|
1913
|
+
return Effect.gen(function* () {
|
|
1914
|
+
const model = yield* getModelByApiKey(body.modelApiKey);
|
|
1915
|
+
if (!model) return yield* new NotFoundError({
|
|
1916
|
+
entity: "Model",
|
|
1917
|
+
id: body.modelApiKey
|
|
1918
|
+
});
|
|
1919
|
+
if (model.is_block) return yield* new ValidationError({ message: "Cannot create records for block types directly" });
|
|
1920
|
+
const tableName = `content_${model.api_key}`;
|
|
1921
|
+
if (model.singleton) {
|
|
1922
|
+
if ((yield* selectAll(tableName)).length > 0) return yield* new DuplicateError({ message: `Model '${model.api_key}' is a singleton and already has a record` });
|
|
1923
|
+
}
|
|
1924
|
+
const modelFields = yield* getModelFields(model.id);
|
|
1925
|
+
const data = { ...body.data };
|
|
1926
|
+
if (!model.has_draft) {
|
|
1927
|
+
for (const field of modelFields) if (isRequired(field.validators) && (data[field.api_key] === void 0 || data[field.api_key] === null || data[field.api_key] === "")) return yield* new ValidationError({
|
|
1928
|
+
message: `Field '${field.api_key}' is required`,
|
|
1929
|
+
field: field.api_key
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1933
|
+
const id = validateRequestedId(body.id) ?? generateId();
|
|
1934
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1935
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE id = ?`, [id])).length > 0) return yield* new DuplicateError({ message: `Record with id '${id}' already exists on model '${body.modelApiKey}'` });
|
|
1936
|
+
const record = {
|
|
1937
|
+
id,
|
|
1938
|
+
_status: model.has_draft ? "draft" : "published",
|
|
1939
|
+
_created_at: now,
|
|
1940
|
+
_updated_at: now,
|
|
1941
|
+
...!model.has_draft ? {
|
|
1942
|
+
_published_at: now,
|
|
1943
|
+
_first_published_at: now
|
|
1944
|
+
} : {}
|
|
1945
|
+
};
|
|
1946
|
+
applyActorColumns(record, actor, {
|
|
1947
|
+
created: true,
|
|
1948
|
+
updated: true,
|
|
1949
|
+
published: !model.has_draft
|
|
1950
|
+
});
|
|
1951
|
+
applyRecordOverrides(record, body.overrides);
|
|
1952
|
+
if (model.sortable || model.tree) record._position = ((yield* sql.unsafe(`SELECT MAX("_position") as max_pos FROM "${tableName}"`))[0]?.max_pos ?? -1) + 1;
|
|
1953
|
+
if (model.tree && data._parent_id !== void 0) {
|
|
1954
|
+
record._parent_id = data._parent_id;
|
|
1955
|
+
delete data._parent_id;
|
|
1956
|
+
}
|
|
1957
|
+
yield* processCreateLikeRecordFields({
|
|
1958
|
+
modelApiKey: model.api_key,
|
|
1959
|
+
tableName,
|
|
1960
|
+
recordId: id,
|
|
1961
|
+
data,
|
|
1962
|
+
record,
|
|
1963
|
+
modelFields
|
|
1964
|
+
});
|
|
1965
|
+
const createUniqueViolations = yield* findUniqueConstraintViolations({
|
|
1966
|
+
tableName,
|
|
1967
|
+
record,
|
|
1968
|
+
fields: modelFields,
|
|
1969
|
+
onlyFieldApiKeys: new Set(modelFields.filter((field) => isUnique(field.validators) && data[field.api_key] !== void 0).map((field) => field.api_key))
|
|
1970
|
+
});
|
|
1971
|
+
if (createUniqueViolations.length > 0) return yield* new ValidationError({
|
|
1972
|
+
message: `Unique constraint violation for field(s): ${createUniqueViolations.join(", ")}`,
|
|
1973
|
+
field: createUniqueViolations[0]
|
|
1974
|
+
});
|
|
1975
|
+
yield* insertRecord(tableName, record);
|
|
1976
|
+
if (!model.has_draft) {
|
|
1977
|
+
const snap = {};
|
|
1978
|
+
for (const [key, value] of Object.entries(record)) if (!key.startsWith("_") && key !== "id") snap[key] = value;
|
|
1979
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _published_snapshot = ? WHERE id = ?`, [encodeJson(snap), id]);
|
|
1980
|
+
}
|
|
1981
|
+
yield* indexRecord(body.modelApiKey, id, record, modelFields).pipe(Effect.ignore);
|
|
1982
|
+
yield* fireHook("onRecordCreate", {
|
|
1983
|
+
modelApiKey: body.modelApiKey,
|
|
1984
|
+
recordId: id
|
|
1985
|
+
});
|
|
1986
|
+
return normalizeBooleanFields({
|
|
1987
|
+
id,
|
|
1988
|
+
...record
|
|
1989
|
+
}, modelFields);
|
|
1990
|
+
}).pipe(Effect.withSpan("record.create"), Effect.annotateSpans({
|
|
1991
|
+
modelApiKey: body.modelApiKey,
|
|
1992
|
+
actorType: actor?.type ?? "anonymous"
|
|
1993
|
+
}));
|
|
1994
|
+
}
|
|
1995
|
+
function listRecords(modelApiKey) {
|
|
1996
|
+
return Effect.gen(function* () {
|
|
1997
|
+
if (!modelApiKey) return yield* new ValidationError({ message: "modelApiKey query parameter is required" });
|
|
1998
|
+
const model = yield* getModelByApiKey(modelApiKey);
|
|
1999
|
+
if (!model) return yield* new NotFoundError({
|
|
2000
|
+
entity: "Model",
|
|
2001
|
+
id: modelApiKey
|
|
2002
|
+
});
|
|
2003
|
+
const records = yield* selectAll(`content_${model.api_key}`);
|
|
2004
|
+
const fields = yield* getModelFields(model.id);
|
|
2005
|
+
return yield* Effect.all(records.map((record) => materializeRecordStructuredTextFields({
|
|
2006
|
+
modelApiKey: model.api_key,
|
|
2007
|
+
record: normalizeBooleanFields(record, fields),
|
|
2008
|
+
fields
|
|
2009
|
+
})), { concurrency: "unbounded" });
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
function getRecord(modelApiKey, id) {
|
|
2013
|
+
return Effect.gen(function* () {
|
|
2014
|
+
if (!modelApiKey) return yield* new ValidationError({ message: "modelApiKey query parameter is required" });
|
|
2015
|
+
const model = yield* getModelByApiKey(modelApiKey);
|
|
2016
|
+
if (!model) return yield* new NotFoundError({
|
|
2017
|
+
entity: "Model",
|
|
2018
|
+
id: modelApiKey
|
|
2019
|
+
});
|
|
2020
|
+
const record = yield* selectById(`content_${model.api_key}`, id);
|
|
2021
|
+
if (!record) return yield* new NotFoundError({
|
|
2022
|
+
entity: "Record",
|
|
2023
|
+
id
|
|
2024
|
+
});
|
|
2025
|
+
return normalizeBooleanFields(record, yield* getModelFields(model.id));
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
function updateSingletonRecord(modelApiKey, data, actor) {
|
|
2029
|
+
return Effect.gen(function* () {
|
|
2030
|
+
const model = yield* getModelByApiKey(modelApiKey);
|
|
2031
|
+
if (!model) return yield* new NotFoundError({
|
|
2032
|
+
entity: "Model",
|
|
2033
|
+
id: modelApiKey
|
|
2034
|
+
});
|
|
2035
|
+
if (!model.singleton) return yield* new ValidationError({ message: `Model '${modelApiKey}' is not a singleton` });
|
|
2036
|
+
const records = yield* selectAll(`content_${model.api_key}`);
|
|
2037
|
+
if (records.length === 0) return yield* new NotFoundError({
|
|
2038
|
+
entity: "Record",
|
|
2039
|
+
id: `${modelApiKey} singleton`
|
|
2040
|
+
});
|
|
2041
|
+
const record = records[0];
|
|
2042
|
+
if (!isContentRow(record)) return yield* new ValidationError({ message: `Singleton record for model '${modelApiKey}' is invalid` });
|
|
2043
|
+
return yield* patchRecord(record.id, {
|
|
2044
|
+
modelApiKey,
|
|
2045
|
+
data
|
|
2046
|
+
}, actor);
|
|
2047
|
+
}).pipe(Effect.withSpan("record.update_singleton"), Effect.annotateSpans({
|
|
2048
|
+
modelApiKey,
|
|
2049
|
+
actorType: actor?.type ?? "anonymous"
|
|
2050
|
+
}));
|
|
2051
|
+
}
|
|
2052
|
+
function patchRecord(id, body, actor) {
|
|
2053
|
+
return Effect.gen(function* () {
|
|
2054
|
+
const model = yield* getModelByApiKey(body.modelApiKey);
|
|
2055
|
+
if (!model) return yield* new NotFoundError({
|
|
2056
|
+
entity: "Model",
|
|
2057
|
+
id: body.modelApiKey
|
|
2058
|
+
});
|
|
2059
|
+
const tableName = `content_${model.api_key}`;
|
|
2060
|
+
const existing = yield* selectById(tableName, id);
|
|
2061
|
+
if (!existing) return yield* new NotFoundError({
|
|
2062
|
+
entity: "Record",
|
|
2063
|
+
id
|
|
2064
|
+
});
|
|
2065
|
+
const modelFields = yield* getModelFields(model.id);
|
|
2066
|
+
const data = { ...body.data };
|
|
2067
|
+
const updates = { _updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2068
|
+
applyActorColumns(updates, actor, { updated: true });
|
|
2069
|
+
const hasExplicitDataUpdates = Object.keys(data).length > 0;
|
|
2070
|
+
if (hasExplicitDataUpdates && isContentRow(existing) && existing._status === "published") {
|
|
2071
|
+
if (model.has_draft) updates._status = "updated";
|
|
2072
|
+
else if (existing._published_snapshot) {
|
|
2073
|
+
const prevSnapshot = typeof existing._published_snapshot === "string" ? existing._published_snapshot : encodeJson(existing._published_snapshot);
|
|
2074
|
+
yield* createVersion(body.modelApiKey, id, prevSnapshot, {
|
|
2075
|
+
action: "auto_republish",
|
|
2076
|
+
actor
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
applyRecordOverrides(updates, body.overrides);
|
|
2081
|
+
if (model.tree && data._parent_id !== void 0) {
|
|
2082
|
+
updates._parent_id = data._parent_id;
|
|
2083
|
+
delete data._parent_id;
|
|
2084
|
+
}
|
|
2085
|
+
if ((model.sortable || model.tree) && data._position !== void 0) {
|
|
2086
|
+
updates._position = data._position;
|
|
2087
|
+
delete data._position;
|
|
2088
|
+
}
|
|
2089
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2090
|
+
for (const field of modelFields) {
|
|
2091
|
+
if (field.field_type === "structured_text" && data[field.api_key] !== void 0) if (data[field.api_key] === null) yield* deleteBlocksForField({
|
|
2092
|
+
rootRecordId: id,
|
|
2093
|
+
fieldApiKey: field.api_key,
|
|
2094
|
+
includeLocalizedVariants: field.localized === 1
|
|
2095
|
+
});
|
|
2096
|
+
else {
|
|
2097
|
+
if (field.localized) {
|
|
2098
|
+
const localeMap = yield* decodeLocalizedStructuredTextMap(field, data[field.api_key]);
|
|
2099
|
+
const nextLocaleMap = { ...parseExistingLocaleMap(existing[field.api_key]) };
|
|
2100
|
+
for (const [localeCode, localeValue] of Object.entries(localeMap)) {
|
|
2101
|
+
yield* deleteBlocksForField({
|
|
2102
|
+
rootRecordId: id,
|
|
2103
|
+
fieldApiKey: getStructuredTextStorageKey(field.api_key, localeCode)
|
|
2104
|
+
});
|
|
2105
|
+
if (localeValue === null) {
|
|
2106
|
+
nextLocaleMap[localeCode] = null;
|
|
2107
|
+
continue;
|
|
2108
|
+
}
|
|
2109
|
+
const stInput = yield* Schema.decodeUnknown(StructuredTextWriteInput)(scopeStructuredTextIds(localeValue, `${field.api_key}:${localeCode}`)).pipe(Effect.mapError((e) => new ValidationError({
|
|
2110
|
+
message: `Invalid StructuredText for field '${field.api_key}' locale '${localeCode}': ${e.message}`,
|
|
2111
|
+
field: field.api_key
|
|
2112
|
+
})));
|
|
2113
|
+
const allowedBlockTypes = getBlockWhitelist(field.validators);
|
|
2114
|
+
const blocksOnly = getBlocksOnly(field.validators);
|
|
2115
|
+
nextLocaleMap[localeCode] = yield* writeStructuredText({
|
|
2116
|
+
rootModelApiKey: model.api_key,
|
|
2117
|
+
fieldApiKey: field.api_key,
|
|
2118
|
+
rootFieldStorageKey: getStructuredTextStorageKey(field.api_key, localeCode),
|
|
2119
|
+
rootRecordId: id,
|
|
2120
|
+
value: stInput.value,
|
|
2121
|
+
blocks: stInput.blocks,
|
|
2122
|
+
allowedBlockTypes: allowedBlockTypes ?? [],
|
|
2123
|
+
blocksOnly
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
data[field.api_key] = nextLocaleMap;
|
|
2127
|
+
updates[field.api_key] = nextLocaleMap;
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
const stInput = yield* Schema.decodeUnknown(StructuredTextWriteInput)(data[field.api_key]).pipe(Effect.mapError((e) => new ValidationError({
|
|
2131
|
+
message: `Invalid StructuredText for field '${field.api_key}': ${e.message}`,
|
|
2132
|
+
field: field.api_key
|
|
2133
|
+
})));
|
|
2134
|
+
yield* deleteBlocksForField({
|
|
2135
|
+
rootRecordId: id,
|
|
2136
|
+
fieldApiKey: field.api_key
|
|
2137
|
+
});
|
|
2138
|
+
const allowedBlockTypes = getBlockWhitelist(field.validators);
|
|
2139
|
+
const blocksOnly = getBlocksOnly(field.validators);
|
|
2140
|
+
const dast = yield* writeStructuredText({
|
|
2141
|
+
rootModelApiKey: model.api_key,
|
|
2142
|
+
fieldApiKey: field.api_key,
|
|
2143
|
+
rootRecordId: id,
|
|
2144
|
+
value: stInput.value,
|
|
2145
|
+
blocks: stInput.blocks,
|
|
2146
|
+
allowedBlockTypes: allowedBlockTypes ?? [],
|
|
2147
|
+
blocksOnly
|
|
2148
|
+
});
|
|
2149
|
+
data[field.api_key] = dast;
|
|
2150
|
+
}
|
|
2151
|
+
if (field.field_type === "slug" && data[field.api_key] !== void 0 && data[field.api_key] !== null) {
|
|
2152
|
+
const sourceFieldKey = getSlugSource(field.validators);
|
|
2153
|
+
const sourceValue = sourceFieldKey ? toSlugSourceString(data[sourceFieldKey]) : null;
|
|
2154
|
+
const currentValue = toSlugSourceString(data[field.api_key]);
|
|
2155
|
+
if (sourceValue && !currentValue) data[field.api_key] = generateSlug(sourceValue);
|
|
2156
|
+
else if (currentValue) data[field.api_key] = generateSlug(currentValue);
|
|
2157
|
+
let slug = String(data[field.api_key]);
|
|
2158
|
+
const baseSlug = slug;
|
|
2159
|
+
let suffix = 1;
|
|
2160
|
+
for (;;) {
|
|
2161
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE "${field.api_key}" = ? AND id != ?`, [slug, id])).length === 0) break;
|
|
2162
|
+
suffix++;
|
|
2163
|
+
slug = `${baseSlug}-${suffix}`;
|
|
2164
|
+
}
|
|
2165
|
+
data[field.api_key] = slug;
|
|
2166
|
+
}
|
|
2167
|
+
if (isFieldType(field.field_type) && data[field.api_key] !== void 0 && data[field.api_key] !== null) {
|
|
2168
|
+
const fieldDef = getFieldTypeDef(field.field_type);
|
|
2169
|
+
if (field.field_type === "media" && isMistakenMediaObject(data[field.api_key])) return yield* new ValidationError({
|
|
2170
|
+
message: `Invalid media for field '${field.api_key}': use an asset ID string or {"upload_id":"<asset_id>"}, not {"id":"..."}`,
|
|
2171
|
+
field: field.api_key
|
|
2172
|
+
});
|
|
2173
|
+
if (field.field_type === "link" && isMistakenLinkObject(data[field.api_key])) return yield* new ValidationError({
|
|
2174
|
+
message: `Invalid link for field '${field.api_key}': use a record ID string, not {"id":"..."}`,
|
|
2175
|
+
field: field.api_key
|
|
2176
|
+
});
|
|
2177
|
+
if (field.field_type === "links" && hasMistakenLinkObject(data[field.api_key])) return yield* new ValidationError({
|
|
2178
|
+
message: `Invalid links for field '${field.api_key}': use an array of record ID strings, not objects like {"id":"..."}`,
|
|
2179
|
+
field: field.api_key
|
|
2180
|
+
});
|
|
2181
|
+
if (!field.localized && isLocalizedValueMap(data[field.api_key])) return yield* new ValidationError({
|
|
2182
|
+
message: `Field '${field.api_key}' is not localized and cannot accept locale-keyed values`,
|
|
2183
|
+
field: field.api_key
|
|
2184
|
+
});
|
|
2185
|
+
if (fieldDef.inputSchema) if (field.localized) {
|
|
2186
|
+
const localeMap = yield* decodeLocalizedFieldMap(field, data[field.api_key]);
|
|
2187
|
+
const nextLocaleMap = {
|
|
2188
|
+
...parseExistingLocaleMap(existing[field.api_key]),
|
|
2189
|
+
...localeMap
|
|
2190
|
+
};
|
|
2191
|
+
for (const [localeCode, localeValue] of Object.entries(localeMap)) {
|
|
2192
|
+
if (localeValue === null) continue;
|
|
2193
|
+
yield* Schema.decodeUnknown(fieldDef.inputSchema)(localeValue).pipe(Effect.mapError((e) => new ValidationError({
|
|
2194
|
+
message: `Invalid ${field.field_type} for field '${field.api_key}' locale '${localeCode}': ${e.message}`,
|
|
2195
|
+
field: field.api_key
|
|
2196
|
+
})));
|
|
2197
|
+
}
|
|
2198
|
+
data[field.api_key] = nextLocaleMap;
|
|
2199
|
+
} else yield* Schema.decodeUnknown(fieldDef.inputSchema)(data[field.api_key]).pipe(Effect.mapError((e) => new ValidationError({
|
|
2200
|
+
message: `Invalid ${field.field_type} for field '${field.api_key}': ${e.message}`,
|
|
2201
|
+
field: field.api_key
|
|
2202
|
+
})));
|
|
2203
|
+
}
|
|
2204
|
+
if ((field.field_type === "media" || field.field_type === "media_gallery" || field.field_type === "seo") && data[field.api_key] !== void 0 && data[field.api_key] !== null) if (field.localized) {
|
|
2205
|
+
const localeMap = yield* decodeLocalizedFieldMap(field, data[field.api_key]);
|
|
2206
|
+
for (const localeValue of Object.values(localeMap)) {
|
|
2207
|
+
if (localeValue === null) continue;
|
|
2208
|
+
yield* validateAssetFieldValue(sql, field, localeValue);
|
|
2209
|
+
}
|
|
2210
|
+
} else yield* validateAssetFieldValue(sql, field, data[field.api_key]);
|
|
2211
|
+
if (field.localized && field.field_type !== "structured_text" && data[field.api_key] !== void 0) {
|
|
2212
|
+
const localeMap = yield* decodeLocalizedFieldMap(field, data[field.api_key]);
|
|
2213
|
+
const existingLocaleMap = parseExistingLocaleMap(existing[field.api_key]);
|
|
2214
|
+
data[field.api_key] = {
|
|
2215
|
+
...existingLocaleMap,
|
|
2216
|
+
...localeMap
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
if (data[field.api_key] !== void 0) updates[field.api_key] = data[field.api_key];
|
|
2220
|
+
}
|
|
2221
|
+
const uniqueFieldsTouched = new Set(modelFields.filter((field) => isUnique(field.validators) && data[field.api_key] !== void 0).map((field) => field.api_key));
|
|
2222
|
+
if (uniqueFieldsTouched.size > 0) {
|
|
2223
|
+
const patchUniqueViolations = yield* findUniqueConstraintViolations({
|
|
2224
|
+
tableName,
|
|
2225
|
+
record: {
|
|
2226
|
+
...existing,
|
|
2227
|
+
...updates
|
|
2228
|
+
},
|
|
2229
|
+
fields: modelFields,
|
|
2230
|
+
excludeId: id,
|
|
2231
|
+
onlyFieldApiKeys: uniqueFieldsTouched
|
|
2232
|
+
});
|
|
2233
|
+
if (patchUniqueViolations.length > 0) return yield* new ValidationError({
|
|
2234
|
+
message: `Unique constraint violation for field(s): ${patchUniqueViolations.join(", ")}`,
|
|
2235
|
+
field: patchUniqueViolations[0]
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
yield* updateRecord(tableName, id, updates);
|
|
2239
|
+
if (!model.has_draft && hasExplicitDataUpdates) {
|
|
2240
|
+
const updated = yield* selectById(tableName, id);
|
|
2241
|
+
if (updated) {
|
|
2242
|
+
const snap = {};
|
|
2243
|
+
for (const [key, value] of Object.entries(updated)) if (!key.startsWith("_") && key !== "id") snap[key] = value;
|
|
2244
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _published_snapshot = ?, _published_at = ?, _published_by = ?, _status = 'published' WHERE id = ?`, [
|
|
2245
|
+
encodeJson(snap),
|
|
2246
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
2247
|
+
actor?.label ?? null,
|
|
2248
|
+
id
|
|
2249
|
+
]);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
yield* reindexRecord(body.modelApiKey, id, modelFields).pipe(Effect.ignore);
|
|
2253
|
+
yield* fireHook("onRecordUpdate", {
|
|
2254
|
+
modelApiKey: body.modelApiKey,
|
|
2255
|
+
recordId: id
|
|
2256
|
+
});
|
|
2257
|
+
const updated = yield* selectById(tableName, id);
|
|
2258
|
+
return updated ? normalizeBooleanFields(updated, modelFields) : null;
|
|
2259
|
+
}).pipe(Effect.withSpan("record.patch"), Effect.annotateSpans({
|
|
2260
|
+
modelApiKey: body.modelApiKey,
|
|
2261
|
+
recordId: id,
|
|
2262
|
+
actorType: actor?.type ?? "anonymous"
|
|
2263
|
+
}));
|
|
2264
|
+
}
|
|
2265
|
+
function removeRecord(modelApiKey, id) {
|
|
2266
|
+
return Effect.gen(function* () {
|
|
2267
|
+
if (!modelApiKey) return yield* new ValidationError({ message: "modelApiKey query parameter is required" });
|
|
2268
|
+
const model = yield* getModelByApiKey(modelApiKey);
|
|
2269
|
+
if (!model) return yield* new NotFoundError({
|
|
2270
|
+
entity: "Model",
|
|
2271
|
+
id: modelApiKey
|
|
2272
|
+
});
|
|
2273
|
+
const tableName = `content_${model.api_key}`;
|
|
2274
|
+
if (!(yield* selectById(tableName, id))) return yield* new NotFoundError({
|
|
2275
|
+
entity: "Record",
|
|
2276
|
+
id
|
|
2277
|
+
});
|
|
2278
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2279
|
+
const blockModels = yield* sql.unsafe("SELECT api_key FROM models WHERE is_block = 1");
|
|
2280
|
+
for (const bm of blockModels) yield* sql.unsafe(`DELETE FROM "block_${bm.api_key}" WHERE _root_record_id = ?`, [id]);
|
|
2281
|
+
yield* deleteRecord(tableName, id);
|
|
2282
|
+
yield* deleteVersionsForRecord(modelApiKey, id).pipe(Effect.ignore);
|
|
2283
|
+
yield* deindexRecord(modelApiKey, id).pipe(Effect.ignore);
|
|
2284
|
+
yield* fireHook("onRecordDelete", {
|
|
2285
|
+
modelApiKey,
|
|
2286
|
+
recordId: id
|
|
2287
|
+
});
|
|
2288
|
+
return { deleted: true };
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Bulk create records in a single operation.
|
|
2293
|
+
* All records must belong to the same model. Runs in a logical batch
|
|
2294
|
+
* (individual inserts, but avoids per-record overhead of schema lookups).
|
|
2295
|
+
*/
|
|
2296
|
+
function bulkCreateRecords({ modelApiKey, records }, actor) {
|
|
2297
|
+
return Effect.gen(function* () {
|
|
2298
|
+
if (records.length === 0) return yield* new ValidationError({ message: "records must be a non-empty array" });
|
|
2299
|
+
if (records.length > 1e3) return yield* new ValidationError({ message: "Maximum 1000 records per bulk operation" });
|
|
2300
|
+
const model = yield* getModelByApiKey(modelApiKey);
|
|
2301
|
+
if (!model) return yield* new NotFoundError({
|
|
2302
|
+
entity: "Model",
|
|
2303
|
+
id: modelApiKey
|
|
2304
|
+
});
|
|
2305
|
+
if (model.is_block) return yield* new ValidationError({ message: "Cannot create records for block types directly" });
|
|
2306
|
+
if (model.singleton) return yield* new ValidationError({ message: "Cannot bulk create on singleton models" });
|
|
2307
|
+
const tableName = `content_${model.api_key}`;
|
|
2308
|
+
const modelFields = yield* getModelFields(model.id);
|
|
2309
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2310
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2311
|
+
const initialStatus = model.has_draft ? "draft" : "published";
|
|
2312
|
+
const created = [];
|
|
2313
|
+
let nextPosition = 0;
|
|
2314
|
+
if (model.sortable || model.tree) nextPosition = ((yield* sql.unsafe(`SELECT MAX("_position") as max_pos FROM "${tableName}"`))[0]?.max_pos ?? -1) + 1;
|
|
2315
|
+
for (let idx = 0; idx < records.length; idx++) {
|
|
2316
|
+
const data = { ...records[idx] };
|
|
2317
|
+
if (!model.has_draft) {
|
|
2318
|
+
for (const field of modelFields) if (isRequired(field.validators) && (data[field.api_key] === void 0 || data[field.api_key] === null || data[field.api_key] === "")) return yield* new ValidationError({
|
|
2319
|
+
message: `Record ${idx}: field '${field.api_key}' is required`,
|
|
2320
|
+
field: field.api_key
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
const requestedId = typeof data.id === "string" && data.id.trim().length > 0 ? data.id : void 0;
|
|
2324
|
+
if (requestedId) delete data.id;
|
|
2325
|
+
const id = requestedId ?? generateId();
|
|
2326
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE id = ?`, [id])).length > 0) return yield* new DuplicateError({ message: `Record ${idx}: id '${id}' already exists on model '${modelApiKey}'` });
|
|
2327
|
+
const record = {
|
|
2328
|
+
id,
|
|
2329
|
+
_status: initialStatus,
|
|
2330
|
+
_created_at: now,
|
|
2331
|
+
_updated_at: now,
|
|
2332
|
+
...!model.has_draft ? {
|
|
2333
|
+
_published_at: now,
|
|
2334
|
+
_first_published_at: now
|
|
2335
|
+
} : {}
|
|
2336
|
+
};
|
|
2337
|
+
applyActorColumns(record, actor, {
|
|
2338
|
+
created: true,
|
|
2339
|
+
updated: true,
|
|
2340
|
+
published: !model.has_draft
|
|
2341
|
+
});
|
|
2342
|
+
applyRecordOverrides(record, void 0);
|
|
2343
|
+
if (model.sortable || model.tree) record._position = nextPosition++;
|
|
2344
|
+
yield* processCreateLikeRecordFields({
|
|
2345
|
+
modelApiKey: model.api_key,
|
|
2346
|
+
tableName,
|
|
2347
|
+
recordId: id,
|
|
2348
|
+
data,
|
|
2349
|
+
record,
|
|
2350
|
+
modelFields,
|
|
2351
|
+
errorPrefix: `Record ${idx}`
|
|
2352
|
+
});
|
|
2353
|
+
yield* insertRecord(tableName, record);
|
|
2354
|
+
yield* indexRecord(modelApiKey, id, record, modelFields).pipe(Effect.ignore);
|
|
2355
|
+
yield* fireHook("onRecordCreate", {
|
|
2356
|
+
modelApiKey,
|
|
2357
|
+
recordId: id
|
|
2358
|
+
});
|
|
2359
|
+
created.push({ id });
|
|
2360
|
+
}
|
|
2361
|
+
return {
|
|
2362
|
+
created: created.length,
|
|
2363
|
+
records: created
|
|
2364
|
+
};
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
function isStructuredTextEnvelopeLike(value) {
|
|
2368
|
+
return isJsonRecord(value) && "value" in value && isJsonRecord(value.blocks);
|
|
2369
|
+
}
|
|
2370
|
+
function getPrunableDast(value) {
|
|
2371
|
+
if (!isJsonRecord(value)) return null;
|
|
2372
|
+
if (typeof value.schema !== "string") return null;
|
|
2373
|
+
if (!isJsonRecord(value.document)) return null;
|
|
2374
|
+
if (typeof value.document.type !== "string") return null;
|
|
2375
|
+
if (!Array.isArray(value.document.children)) return null;
|
|
2376
|
+
return {
|
|
2377
|
+
schema: value.schema,
|
|
2378
|
+
document: {
|
|
2379
|
+
type: value.document.type,
|
|
2380
|
+
children: value.document.children
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
function applyPatchToNestedStructuredText(target, blockId, patchValue) {
|
|
2385
|
+
let matches = 0;
|
|
2386
|
+
const visitObject = (value) => {
|
|
2387
|
+
for (const nestedValue of Object.values(value)) {
|
|
2388
|
+
if (isStructuredTextEnvelopeLike(nestedValue)) {
|
|
2389
|
+
const blocks = nestedValue.blocks;
|
|
2390
|
+
if (Object.hasOwn(blocks, blockId)) {
|
|
2391
|
+
matches++;
|
|
2392
|
+
if (patchValue === null) {
|
|
2393
|
+
delete blocks[blockId];
|
|
2394
|
+
const dast = getPrunableDast(nestedValue.value);
|
|
2395
|
+
if (dast) nestedValue.value = pruneBlockNodes(dast, new Set([blockId]));
|
|
2396
|
+
} else if (typeof patchValue === "string") {} else if (isJsonRecord(patchValue)) {
|
|
2397
|
+
const existingBlock = blocks[blockId];
|
|
2398
|
+
if (isJsonRecord(existingBlock)) blocks[blockId] = {
|
|
2399
|
+
...existingBlock,
|
|
2400
|
+
...patchValue
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
for (const childBlock of Object.values(blocks)) if (isJsonRecord(childBlock)) visitObject(childBlock);
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2407
|
+
if (isJsonRecord(nestedValue)) visitObject(nestedValue);
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
visitObject(target);
|
|
2411
|
+
return {
|
|
2412
|
+
applied: matches > 0,
|
|
2413
|
+
ambiguous: matches > 1
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Partial block update for a structured text field.
|
|
2418
|
+
*
|
|
2419
|
+
* Patch map semantics:
|
|
2420
|
+
* - Key with string value (equal to the block ID) → keep block unchanged
|
|
2421
|
+
* - Key with object value → partial merge into existing block (only specified fields updated)
|
|
2422
|
+
* - Key with null → delete block and prune from DAST
|
|
2423
|
+
* - Key absent from patch → keep block unchanged
|
|
2424
|
+
*
|
|
2425
|
+
* Optionally accepts a new DAST `value`. If omitted, keeps existing DAST
|
|
2426
|
+
* (with deleted blocks auto-pruned).
|
|
2427
|
+
*/
|
|
2428
|
+
function patchBlocksForField(body, actor) {
|
|
2429
|
+
return Effect.gen(function* () {
|
|
2430
|
+
const model = yield* getModelByApiKey(body.modelApiKey);
|
|
2431
|
+
if (!model) return yield* new NotFoundError({
|
|
2432
|
+
entity: "Model",
|
|
2433
|
+
id: body.modelApiKey
|
|
2434
|
+
});
|
|
2435
|
+
const tableName = `content_${model.api_key}`;
|
|
2436
|
+
const existing = yield* selectById(tableName, body.recordId);
|
|
2437
|
+
if (!existing) return yield* new NotFoundError({
|
|
2438
|
+
entity: "Record",
|
|
2439
|
+
id: body.recordId
|
|
2440
|
+
});
|
|
2441
|
+
const modelFields = yield* getModelFields(model.id);
|
|
2442
|
+
const field = modelFields.find((f) => f.api_key === body.fieldApiKey);
|
|
2443
|
+
if (!field) return yield* new NotFoundError({
|
|
2444
|
+
entity: "Field",
|
|
2445
|
+
id: body.fieldApiKey
|
|
2446
|
+
});
|
|
2447
|
+
if (field.field_type !== "structured_text") return yield* new ValidationError({
|
|
2448
|
+
message: `Field '${body.fieldApiKey}' is not a structured_text field`,
|
|
2449
|
+
field: body.fieldApiKey
|
|
2450
|
+
});
|
|
2451
|
+
const existingEnvelope = yield* materializeStructuredTextValue({
|
|
2452
|
+
allowedBlockApiKeys: getBlockWhitelist(field.validators) ?? [],
|
|
2453
|
+
parentContainerModelApiKey: model.api_key,
|
|
2454
|
+
parentBlockId: null,
|
|
2455
|
+
parentFieldApiKey: field.api_key,
|
|
2456
|
+
rootRecordId: body.recordId,
|
|
2457
|
+
rootFieldApiKey: field.api_key,
|
|
2458
|
+
rawValue: existing[field.api_key]
|
|
2459
|
+
});
|
|
2460
|
+
if (!existingEnvelope) return yield* new ValidationError({
|
|
2461
|
+
message: `Field '${body.fieldApiKey}' has no structured text content to patch`,
|
|
2462
|
+
field: body.fieldApiKey
|
|
2463
|
+
});
|
|
2464
|
+
const existingBlocks = existingEnvelope.blocks;
|
|
2465
|
+
const blockIdsToDelete = /* @__PURE__ */ new Set();
|
|
2466
|
+
const mergedBlocks = {};
|
|
2467
|
+
for (const [blockId, blockData] of Object.entries(existingBlocks)) mergedBlocks[blockId] = blockData;
|
|
2468
|
+
for (const [blockId, patchValue] of Object.entries(body.blocks)) if (patchValue === null) {
|
|
2469
|
+
if (Object.hasOwn(existingBlocks, blockId)) {
|
|
2470
|
+
blockIdsToDelete.add(blockId);
|
|
2471
|
+
delete mergedBlocks[blockId];
|
|
2472
|
+
continue;
|
|
2473
|
+
}
|
|
2474
|
+
let nestedMatched = false;
|
|
2475
|
+
for (const topLevelBlock of Object.values(mergedBlocks)) {
|
|
2476
|
+
const result = applyPatchToNestedStructuredText(topLevelBlock, blockId, patchValue);
|
|
2477
|
+
if (result.ambiguous) return yield* new ValidationError({
|
|
2478
|
+
message: `Block '${blockId}' matched multiple nested structured_text locations in field '${body.fieldApiKey}'. Patch the parent block explicitly instead.`,
|
|
2479
|
+
field: body.fieldApiKey
|
|
2480
|
+
});
|
|
2481
|
+
nestedMatched = nestedMatched || result.applied;
|
|
2482
|
+
}
|
|
2483
|
+
if (!nestedMatched) return yield* new ValidationError({
|
|
2484
|
+
message: `Block '${blockId}' does not exist in field '${body.fieldApiKey}'.`,
|
|
2485
|
+
field: body.fieldApiKey
|
|
2486
|
+
});
|
|
2487
|
+
} else if (typeof patchValue === "string") {
|
|
2488
|
+
if (!Object.hasOwn(existingBlocks, blockId)) {
|
|
2489
|
+
let nestedMatched = false;
|
|
2490
|
+
for (const topLevelBlock of Object.values(mergedBlocks)) {
|
|
2491
|
+
const result = applyPatchToNestedStructuredText(topLevelBlock, blockId, patchValue);
|
|
2492
|
+
if (result.ambiguous) return yield* new ValidationError({
|
|
2493
|
+
message: `Block '${blockId}' matched multiple nested structured_text locations in field '${body.fieldApiKey}'. Patch the parent block explicitly instead.`,
|
|
2494
|
+
field: body.fieldApiKey
|
|
2495
|
+
});
|
|
2496
|
+
nestedMatched = nestedMatched || result.applied;
|
|
2497
|
+
}
|
|
2498
|
+
if (!nestedMatched) return yield* new ValidationError({
|
|
2499
|
+
message: `Block '${blockId}' does not exist in field '${body.fieldApiKey}'.`,
|
|
2500
|
+
field: body.fieldApiKey
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
} else if (typeof patchValue === "object" && !Array.isArray(patchValue)) {
|
|
2504
|
+
if (!Object.hasOwn(existingBlocks, blockId)) {
|
|
2505
|
+
let nestedMatched = false;
|
|
2506
|
+
for (const topLevelBlock of Object.values(mergedBlocks)) {
|
|
2507
|
+
const result = applyPatchToNestedStructuredText(topLevelBlock, blockId, patchValue);
|
|
2508
|
+
if (result.ambiguous) return yield* new ValidationError({
|
|
2509
|
+
message: `Block '${blockId}' matched multiple nested structured_text locations in field '${body.fieldApiKey}'. Patch the parent block explicitly instead.`,
|
|
2510
|
+
field: body.fieldApiKey
|
|
2511
|
+
});
|
|
2512
|
+
nestedMatched = nestedMatched || result.applied;
|
|
2513
|
+
}
|
|
2514
|
+
if (!nestedMatched) return yield* new ValidationError({
|
|
2515
|
+
message: `Block '${blockId}' does not exist in field '${body.fieldApiKey}'.`,
|
|
2516
|
+
field: body.fieldApiKey
|
|
2517
|
+
});
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
const existingBlock = existingBlocks[blockId];
|
|
2521
|
+
if (!isJsonRecord(existingBlock)) return yield* new ValidationError({
|
|
2522
|
+
message: `Block '${blockId}' has invalid stored data and cannot be patched.`,
|
|
2523
|
+
field: body.fieldApiKey
|
|
2524
|
+
});
|
|
2525
|
+
mergedBlocks[blockId] = {
|
|
2526
|
+
...existingBlock,
|
|
2527
|
+
...patchValue
|
|
2528
|
+
};
|
|
2529
|
+
} else return yield* new ValidationError({
|
|
2530
|
+
message: `Invalid patch value for block '${blockId}': expected string, object, or null`,
|
|
2531
|
+
field: body.fieldApiKey
|
|
2532
|
+
});
|
|
2533
|
+
let finalDastValue;
|
|
2534
|
+
if (body.value !== void 0) finalDastValue = body.value;
|
|
2535
|
+
else if (blockIdsToDelete.size > 0) {
|
|
2536
|
+
const existingDast = existingEnvelope.value;
|
|
2537
|
+
finalDastValue = pruneBlockNodes(existingDast, blockIdsToDelete);
|
|
2538
|
+
} else finalDastValue = existingEnvelope.value;
|
|
2539
|
+
yield* deleteBlocksForField({
|
|
2540
|
+
rootRecordId: body.recordId,
|
|
2541
|
+
fieldApiKey: field.api_key
|
|
2542
|
+
});
|
|
2543
|
+
const allowedBlockTypes = getBlockWhitelist(field.validators);
|
|
2544
|
+
const blocksOnly = getBlocksOnly(field.validators);
|
|
2545
|
+
const dast = yield* writeStructuredText({
|
|
2546
|
+
rootModelApiKey: model.api_key,
|
|
2547
|
+
fieldApiKey: field.api_key,
|
|
2548
|
+
rootRecordId: body.recordId,
|
|
2549
|
+
value: finalDastValue,
|
|
2550
|
+
blocks: mergedBlocks,
|
|
2551
|
+
allowedBlockTypes: allowedBlockTypes ?? [],
|
|
2552
|
+
blocksOnly
|
|
2553
|
+
});
|
|
2554
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2555
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2556
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET "${field.api_key}" = ?, _updated_at = ?, _updated_by = ? WHERE id = ?`, [
|
|
2557
|
+
encodeJson(dast),
|
|
2558
|
+
now,
|
|
2559
|
+
actor?.label ?? null,
|
|
2560
|
+
body.recordId
|
|
2561
|
+
]);
|
|
2562
|
+
if (isContentRow(existing) && existing._status === "published" && model.has_draft) yield* sql.unsafe(`UPDATE "${tableName}" SET _status = 'updated' WHERE id = ?`, [body.recordId]);
|
|
2563
|
+
if (!model.has_draft) {
|
|
2564
|
+
if (existing._published_snapshot) {
|
|
2565
|
+
const prevSnapshot = typeof existing._published_snapshot === "string" ? existing._published_snapshot : encodeJson(existing._published_snapshot);
|
|
2566
|
+
yield* createVersion(body.modelApiKey, body.recordId, prevSnapshot, {
|
|
2567
|
+
action: "auto_republish",
|
|
2568
|
+
actor
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
const updated = yield* selectById(tableName, body.recordId);
|
|
2572
|
+
if (updated) {
|
|
2573
|
+
const snap = {};
|
|
2574
|
+
for (const [key, value] of Object.entries(updated)) if (!key.startsWith("_") && key !== "id") snap[key] = value;
|
|
2575
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _published_snapshot = ?, _published_at = ?, _published_by = ?, _status = 'published' WHERE id = ?`, [
|
|
2576
|
+
encodeJson(snap),
|
|
2577
|
+
now,
|
|
2578
|
+
actor?.label ?? null,
|
|
2579
|
+
body.recordId
|
|
2580
|
+
]);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
yield* reindexRecord(body.modelApiKey, body.recordId, modelFields).pipe(Effect.ignore);
|
|
2584
|
+
yield* fireHook("onRecordUpdate", {
|
|
2585
|
+
modelApiKey: body.modelApiKey,
|
|
2586
|
+
recordId: body.recordId
|
|
2587
|
+
});
|
|
2588
|
+
const updatedRecord = yield* selectById(tableName, body.recordId);
|
|
2589
|
+
if (!updatedRecord) return null;
|
|
2590
|
+
return yield* materializeRecordStructuredTextFields({
|
|
2591
|
+
modelApiKey: model.api_key,
|
|
2592
|
+
record: normalizeBooleanFields(updatedRecord, modelFields),
|
|
2593
|
+
fields: modelFields
|
|
2594
|
+
});
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* Reorder records for a sortable/tree model.
|
|
2599
|
+
* Accepts an ordered array of record IDs — sets _position = index.
|
|
2600
|
+
*/
|
|
2601
|
+
function reorderRecords(modelApiKey, recordIds, actor) {
|
|
2602
|
+
return Effect.gen(function* () {
|
|
2603
|
+
const model = yield* getModelByApiKey(modelApiKey);
|
|
2604
|
+
if (!model) return yield* new NotFoundError({
|
|
2605
|
+
entity: "Model",
|
|
2606
|
+
id: modelApiKey
|
|
2607
|
+
});
|
|
2608
|
+
if (!model.sortable && !model.tree) return yield* new ValidationError({ message: `Model '${modelApiKey}' is not sortable` });
|
|
2609
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2610
|
+
const tableName = `content_${model.api_key}`;
|
|
2611
|
+
for (let i = 0; i < recordIds.length; i++) yield* sql.unsafe(`UPDATE "${tableName}" SET "_position" = ?, "_updated_at" = ?, "_updated_by" = ? WHERE id = ?`, [
|
|
2612
|
+
i,
|
|
2613
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
2614
|
+
actor?.label ?? null,
|
|
2615
|
+
recordIds[i]
|
|
2616
|
+
]);
|
|
2617
|
+
return { reordered: recordIds.length };
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
//#endregion
|
|
2621
|
+
//#region src/services/publish-service.ts
|
|
2622
|
+
function publishRecord(modelApiKey, recordId, actor) {
|
|
2623
|
+
return Effect.gen(function* () {
|
|
2624
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2625
|
+
const models = yield* sql.unsafe("SELECT * FROM models WHERE api_key = ? AND is_block = 0", [modelApiKey]);
|
|
2626
|
+
if (models.length === 0) return yield* new NotFoundError({
|
|
2627
|
+
entity: "Model",
|
|
2628
|
+
id: modelApiKey
|
|
2629
|
+
});
|
|
2630
|
+
const model = models[0];
|
|
2631
|
+
const tableName = `content_${model.api_key}`;
|
|
2632
|
+
const record = yield* selectById(tableName, recordId);
|
|
2633
|
+
if (!record) return yield* new NotFoundError({
|
|
2634
|
+
entity: "Record",
|
|
2635
|
+
id: recordId
|
|
2636
|
+
});
|
|
2637
|
+
const parsedFields = (yield* sql.unsafe("SELECT * FROM fields WHERE model_id = ? ORDER BY position", [model.id])).map(parseFieldValidators);
|
|
2638
|
+
const localeRows = yield* sql.unsafe("SELECT code FROM locales ORDER BY position", []);
|
|
2639
|
+
const { valid, missingFields } = computeIsValid(record, parsedFields, localeRows.length > 0 ? localeRows[0].code : null, model.all_locales_required && localeRows.length > 0 ? localeRows.map((l) => l.code) : void 0);
|
|
2640
|
+
const uniqueViolations = yield* findUniqueConstraintViolations({
|
|
2641
|
+
tableName,
|
|
2642
|
+
record,
|
|
2643
|
+
fields: parsedFields,
|
|
2644
|
+
excludeId: recordId
|
|
2645
|
+
});
|
|
2646
|
+
if (!valid || uniqueViolations.length > 0) return yield* new ValidationError({ message: `Cannot publish: invalid fields: ${[...missingFields.map((field) => `${field} (required)`), ...uniqueViolations.map((field) => `${field} (unique)`)].join(", ")}` });
|
|
2647
|
+
const materialized = yield* materializeRecordStructuredTextFields({
|
|
2648
|
+
modelApiKey,
|
|
2649
|
+
record,
|
|
2650
|
+
fields: parsedFields
|
|
2651
|
+
});
|
|
2652
|
+
if (record._published_snapshot) yield* createVersion(modelApiKey, recordId, typeof record._published_snapshot === "string" ? record._published_snapshot : encodeJson(record._published_snapshot), {
|
|
2653
|
+
action: "publish",
|
|
2654
|
+
actor
|
|
2655
|
+
});
|
|
2656
|
+
const snapshot = {};
|
|
2657
|
+
for (const [key, value] of Object.entries(materialized)) if (!key.startsWith("_") && key !== "id") snapshot[key] = value;
|
|
2658
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2659
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _status = 'published', _published_at = ?, _first_published_at = COALESCE(_first_published_at, ?), _published_snapshot = ?, _updated_at = ?, _updated_by = ?, _published_by = ?, _scheduled_publish_at = NULL WHERE id = ?`, [
|
|
2660
|
+
now,
|
|
2661
|
+
now,
|
|
2662
|
+
encodeJson(snapshot),
|
|
2663
|
+
now,
|
|
2664
|
+
actor?.label ?? null,
|
|
2665
|
+
actor?.label ?? null,
|
|
2666
|
+
recordId
|
|
2667
|
+
]);
|
|
2668
|
+
yield* fireHook("onPublish", {
|
|
2669
|
+
modelApiKey,
|
|
2670
|
+
recordId
|
|
2671
|
+
});
|
|
2672
|
+
return yield* getRecord(modelApiKey, recordId);
|
|
2673
|
+
}).pipe(Effect.withSpan("record.publish"), Effect.annotateSpans({
|
|
2674
|
+
modelApiKey,
|
|
2675
|
+
recordId,
|
|
2676
|
+
actorType: actor?.type ?? "anonymous"
|
|
2677
|
+
}));
|
|
2678
|
+
}
|
|
2679
|
+
function bulkPublishRecords(modelApiKey, recordIds, actor) {
|
|
2680
|
+
return Effect.all(recordIds.map((recordId) => publishRecord(modelApiKey, recordId, actor)), { concurrency: "unbounded" }).pipe(Effect.withSpan("record.bulk_publish"), Effect.annotateSpans({
|
|
2681
|
+
modelApiKey,
|
|
2682
|
+
recordCount: String(recordIds.length),
|
|
2683
|
+
actorType: actor?.type ?? "anonymous"
|
|
2684
|
+
}));
|
|
2685
|
+
}
|
|
2686
|
+
function bulkUnpublishRecords(modelApiKey, recordIds, actor) {
|
|
2687
|
+
return Effect.all(recordIds.map((recordId) => unpublishRecord(modelApiKey, recordId, actor)), { concurrency: "unbounded" }).pipe(Effect.withSpan("record.bulk_unpublish"), Effect.annotateSpans({
|
|
2688
|
+
modelApiKey,
|
|
2689
|
+
recordCount: String(recordIds.length),
|
|
2690
|
+
actorType: actor?.type ?? "anonymous"
|
|
2691
|
+
}));
|
|
2692
|
+
}
|
|
2693
|
+
function unpublishRecord(modelApiKey, recordId, actor) {
|
|
2694
|
+
return Effect.gen(function* () {
|
|
2695
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2696
|
+
const models = yield* sql.unsafe("SELECT * FROM models WHERE api_key = ? AND is_block = 0", [modelApiKey]);
|
|
2697
|
+
if (models.length === 0) return yield* new NotFoundError({
|
|
2698
|
+
entity: "Model",
|
|
2699
|
+
id: modelApiKey
|
|
2700
|
+
});
|
|
2701
|
+
const tableName = `content_${models[0].api_key}`;
|
|
2702
|
+
if (!(yield* selectById(tableName, recordId))) return yield* new NotFoundError({
|
|
2703
|
+
entity: "Record",
|
|
2704
|
+
id: recordId
|
|
2705
|
+
});
|
|
2706
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2707
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _status = 'draft', _published_snapshot = NULL, _updated_at = ?, _updated_by = ?, _scheduled_unpublish_at = NULL WHERE id = ?`, [
|
|
2708
|
+
now,
|
|
2709
|
+
actor?.label ?? null,
|
|
2710
|
+
recordId
|
|
2711
|
+
]);
|
|
2712
|
+
yield* fireHook("onUnpublish", {
|
|
2713
|
+
modelApiKey,
|
|
2714
|
+
recordId
|
|
2715
|
+
});
|
|
2716
|
+
return yield* getRecord(modelApiKey, recordId);
|
|
2717
|
+
}).pipe(Effect.withSpan("record.unpublish"), Effect.annotateSpans({
|
|
2718
|
+
modelApiKey,
|
|
2719
|
+
recordId,
|
|
2720
|
+
actorType: actor?.type ?? "anonymous"
|
|
2721
|
+
}));
|
|
2722
|
+
}
|
|
2723
|
+
//#endregion
|
|
2724
|
+
//#region src/services/asset-service.ts
|
|
2725
|
+
var AssetImportContext = class extends Context.Tag("AssetImportContext")() {};
|
|
2726
|
+
const MAX_REMOTE_ASSET_BYTES = 25 * 1024 * 1024;
|
|
2727
|
+
const MAX_REMOTE_ASSET_REDIRECTS = 5;
|
|
2728
|
+
function getAssetBasename(filename) {
|
|
2729
|
+
const lastDot = filename.lastIndexOf(".");
|
|
2730
|
+
return lastDot > 0 ? filename.slice(0, lastDot) : filename;
|
|
2731
|
+
}
|
|
2732
|
+
function getAssetFormat(filename, mimeType) {
|
|
2733
|
+
const lastDot = filename.lastIndexOf(".");
|
|
2734
|
+
if (lastDot > 0 && lastDot < filename.length - 1) return filename.slice(lastDot + 1).toLowerCase();
|
|
2735
|
+
return mimeType.split("/")[1]?.toLowerCase() ?? "bin";
|
|
2736
|
+
}
|
|
2737
|
+
function normalizeHostname(hostname) {
|
|
2738
|
+
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
2739
|
+
}
|
|
2740
|
+
function isPrivateIpv4(hostname) {
|
|
2741
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname)) return false;
|
|
2742
|
+
const octets = hostname.split(".").map(Number);
|
|
2743
|
+
if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) return false;
|
|
2744
|
+
return octets[0] === 10 || octets[0] === 127 || octets[0] === 169 && octets[1] === 254 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31 || octets[0] === 192 && octets[1] === 168;
|
|
2745
|
+
}
|
|
2746
|
+
function isPrivateIpv6(hostname) {
|
|
2747
|
+
const normalized = normalizeHostname(hostname);
|
|
2748
|
+
return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:");
|
|
2749
|
+
}
|
|
2750
|
+
function isBlockedHostname(hostname) {
|
|
2751
|
+
const normalized = normalizeHostname(hostname);
|
|
2752
|
+
return normalized === "localhost" || normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal") || isPrivateIpv4(normalized) || isPrivateIpv6(normalized);
|
|
2753
|
+
}
|
|
2754
|
+
function validateRemoteAssetUrl(input) {
|
|
2755
|
+
return Effect.try({
|
|
2756
|
+
try: () => new URL(input),
|
|
2757
|
+
catch: () => new ValidationError({ message: "Asset URL must be a valid http:// or https:// URL" })
|
|
2758
|
+
}).pipe(Effect.flatMap((url) => {
|
|
2759
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return new ValidationError({ message: "Asset URL must use http:// or https://" });
|
|
2760
|
+
if (!url.hostname || isBlockedHostname(url.hostname)) return new ValidationError({ message: `Asset URL host is not allowed: ${url.hostname || "<empty>"}` });
|
|
2761
|
+
if (url.username || url.password) return new ValidationError({ message: "Asset URL must not contain embedded credentials" });
|
|
2762
|
+
return Effect.succeed(url);
|
|
2763
|
+
}));
|
|
2764
|
+
}
|
|
2765
|
+
function parseContentLength(header) {
|
|
2766
|
+
if (!header) return null;
|
|
2767
|
+
const parsed = Number(header);
|
|
2768
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
2769
|
+
}
|
|
2770
|
+
function isRedirectStatus(status) {
|
|
2771
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
2772
|
+
}
|
|
2773
|
+
function fetchRemoteAsset(url, fetchFn) {
|
|
2774
|
+
return Effect.gen(function* () {
|
|
2775
|
+
let currentUrl = url;
|
|
2776
|
+
for (let redirectCount = 0; redirectCount <= MAX_REMOTE_ASSET_REDIRECTS; redirectCount += 1) {
|
|
2777
|
+
const response = yield* Effect.tryPromise({
|
|
2778
|
+
try: () => fetchFn(currentUrl, { redirect: "manual" }),
|
|
2779
|
+
catch: () => new ValidationError({ message: `Failed to fetch asset URL: ${currentUrl}` })
|
|
2780
|
+
});
|
|
2781
|
+
if (!isRedirectStatus(response.status)) return {
|
|
2782
|
+
response,
|
|
2783
|
+
resolvedUrl: currentUrl
|
|
2784
|
+
};
|
|
2785
|
+
const location = response.headers.get("location");
|
|
2786
|
+
if (!location) return yield* new ValidationError({ message: `Asset URL redirect is missing a Location header: ${currentUrl}` });
|
|
2787
|
+
if (redirectCount === MAX_REMOTE_ASSET_REDIRECTS) return yield* new ValidationError({ message: `Asset URL redirected too many times (>${MAX_REMOTE_ASSET_REDIRECTS}): ${url}` });
|
|
2788
|
+
currentUrl = yield* validateRemoteAssetUrl(new URL(location, currentUrl).toString());
|
|
2789
|
+
}
|
|
2790
|
+
return yield* new ValidationError({ message: `Failed to resolve asset URL: ${url}` });
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
function readResponseBytes(response, url) {
|
|
2794
|
+
return Effect.gen(function* () {
|
|
2795
|
+
const contentLength = parseContentLength(response.headers.get("content-length"));
|
|
2796
|
+
if (contentLength !== null && contentLength > MAX_REMOTE_ASSET_BYTES) return yield* new ValidationError({ message: `Asset URL is too large to import (${contentLength} bytes > ${MAX_REMOTE_ASSET_BYTES} byte limit)` });
|
|
2797
|
+
if (!response.body) return new Uint8Array();
|
|
2798
|
+
const reader = response.body.getReader();
|
|
2799
|
+
const chunks = [];
|
|
2800
|
+
let total = 0;
|
|
2801
|
+
let done = false;
|
|
2802
|
+
while (!done) {
|
|
2803
|
+
const chunk = yield* Effect.tryPromise({
|
|
2804
|
+
try: () => reader.read(),
|
|
2805
|
+
catch: () => new ValidationError({ message: `Failed to read asset bytes from: ${url}` })
|
|
2806
|
+
});
|
|
2807
|
+
if (chunk.done) {
|
|
2808
|
+
done = true;
|
|
2809
|
+
continue;
|
|
2810
|
+
}
|
|
2811
|
+
const value = chunk.value;
|
|
2812
|
+
total += value.byteLength;
|
|
2813
|
+
if (total > MAX_REMOTE_ASSET_BYTES) return yield* new ValidationError({ message: `Asset URL is too large to import (${total} bytes > ${MAX_REMOTE_ASSET_BYTES} byte limit)` });
|
|
2814
|
+
chunks.push(value);
|
|
2815
|
+
}
|
|
2816
|
+
const bytes = new Uint8Array(total);
|
|
2817
|
+
let offset = 0;
|
|
2818
|
+
for (const chunk of chunks) {
|
|
2819
|
+
bytes.set(chunk, offset);
|
|
2820
|
+
offset += chunk.byteLength;
|
|
2821
|
+
}
|
|
2822
|
+
return bytes;
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
function inferFilename(input) {
|
|
2826
|
+
if (input.filename && input.filename.length > 0) return input.filename;
|
|
2827
|
+
const candidate = new URL(input.url).pathname.split("/").filter(Boolean).at(-1);
|
|
2828
|
+
if (candidate && candidate.length > 0) return decodeURIComponent(candidate);
|
|
2829
|
+
return input.mimeType?.startsWith("image/") ? `asset.${input.mimeType.slice(6)}` : "asset.bin";
|
|
2830
|
+
}
|
|
2831
|
+
function createAsset(body, actor) {
|
|
2832
|
+
return Effect.gen(function* () {
|
|
2833
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2834
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2835
|
+
const id = body.id ?? generateId();
|
|
2836
|
+
if ((yield* sql.unsafe("SELECT id FROM assets WHERE id = ?", [id])).length > 0) return yield* new ValidationError({ message: `Asset with id '${id}' already exists` });
|
|
2837
|
+
yield* sql.unsafe(`INSERT INTO assets (id, filename, basename, format, mime_type, size, width, height, alt, title, r2_key, blurhash, colors, focal_point, tags, created_at, updated_at, created_by, updated_by)
|
|
2838
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
2839
|
+
id,
|
|
2840
|
+
body.filename,
|
|
2841
|
+
getAssetBasename(body.filename),
|
|
2842
|
+
getAssetFormat(body.filename, body.mimeType),
|
|
2843
|
+
body.mimeType,
|
|
2844
|
+
body.size,
|
|
2845
|
+
body.width ?? null,
|
|
2846
|
+
body.height ?? null,
|
|
2847
|
+
body.alt ?? null,
|
|
2848
|
+
body.title ?? null,
|
|
2849
|
+
body.r2Key ?? `uploads/${id}/${body.filename}`,
|
|
2850
|
+
body.blurhash ?? null,
|
|
2851
|
+
body.colors ? encodeJson(body.colors) : null,
|
|
2852
|
+
body.focalPoint ? encodeJson(body.focalPoint) : null,
|
|
2853
|
+
encodeJson(body.tags),
|
|
2854
|
+
now,
|
|
2855
|
+
now,
|
|
2856
|
+
actor?.label ?? null,
|
|
2857
|
+
actor?.label ?? null
|
|
2858
|
+
]);
|
|
2859
|
+
return {
|
|
2860
|
+
id,
|
|
2861
|
+
filename: body.filename,
|
|
2862
|
+
mimeType: body.mimeType,
|
|
2863
|
+
size: body.size,
|
|
2864
|
+
width: body.width,
|
|
2865
|
+
height: body.height,
|
|
2866
|
+
alt: body.alt,
|
|
2867
|
+
title: body.title,
|
|
2868
|
+
r2Key: body.r2Key ?? `uploads/${id}/${body.filename}`,
|
|
2869
|
+
createdAt: now,
|
|
2870
|
+
updatedAt: now,
|
|
2871
|
+
createdBy: actor?.label ?? null,
|
|
2872
|
+
updatedBy: actor?.label ?? null
|
|
2873
|
+
};
|
|
2874
|
+
}).pipe(Effect.withSpan("asset.create"), Effect.annotateSpans({
|
|
2875
|
+
assetId: body.id ?? "",
|
|
2876
|
+
filename: body.filename,
|
|
2877
|
+
actorType: actor?.type ?? "anonymous"
|
|
2878
|
+
}));
|
|
2879
|
+
}
|
|
2880
|
+
function listAssets() {
|
|
2881
|
+
return Effect.gen(function* () {
|
|
2882
|
+
return yield* (yield* SqlClient.SqlClient).unsafe("SELECT * FROM assets ORDER BY created_at DESC");
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
function getAsset(id) {
|
|
2886
|
+
return Effect.gen(function* () {
|
|
2887
|
+
const rows = yield* (yield* SqlClient.SqlClient).unsafe("SELECT * FROM assets WHERE id = ?", [id]);
|
|
2888
|
+
if (rows.length === 0) return yield* new NotFoundError({
|
|
2889
|
+
entity: "Asset",
|
|
2890
|
+
id
|
|
2891
|
+
});
|
|
2892
|
+
return rows[0];
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
/**
|
|
2896
|
+
* Replace an asset's file while keeping the same ID and URL.
|
|
2897
|
+
* Updates metadata (filename, mimeType, size, dimensions, r2Key) but the asset ID
|
|
2898
|
+
* and all content references remain stable. DatoCMS can't do this (imgix regenerates URLs).
|
|
2899
|
+
*/
|
|
2900
|
+
function replaceAsset(id, body, actor) {
|
|
2901
|
+
return Effect.gen(function* () {
|
|
2902
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2903
|
+
const rows = yield* sql.unsafe("SELECT * FROM assets WHERE id = ?", [id]);
|
|
2904
|
+
if (rows.length === 0) return yield* new NotFoundError({
|
|
2905
|
+
entity: "Asset",
|
|
2906
|
+
id
|
|
2907
|
+
});
|
|
2908
|
+
const r2Key = body.r2Key ?? `uploads/${id}/${body.filename}`;
|
|
2909
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2910
|
+
yield* sql.unsafe(`UPDATE assets SET filename = ?, basename = ?, format = ?, mime_type = ?, size = ?, width = ?, height = ?,
|
|
2911
|
+
alt = ?, title = ?, r2_key = ?, blurhash = ?, colors = ?, focal_point = ?, tags = ?, updated_at = ?, updated_by = ?
|
|
2912
|
+
WHERE id = ?`, [
|
|
2913
|
+
body.filename,
|
|
2914
|
+
getAssetBasename(body.filename),
|
|
2915
|
+
getAssetFormat(body.filename, body.mimeType),
|
|
2916
|
+
body.mimeType,
|
|
2917
|
+
body.size,
|
|
2918
|
+
body.width ?? null,
|
|
2919
|
+
body.height ?? null,
|
|
2920
|
+
body.alt ?? rows[0].alt,
|
|
2921
|
+
body.title ?? rows[0].title,
|
|
2922
|
+
r2Key,
|
|
2923
|
+
body.blurhash ?? null,
|
|
2924
|
+
body.colors ? encodeJson(body.colors) : null,
|
|
2925
|
+
body.focalPoint ? encodeJson(body.focalPoint) : null,
|
|
2926
|
+
encodeJson(body.tags),
|
|
2927
|
+
now,
|
|
2928
|
+
actor?.label ?? null,
|
|
2929
|
+
id
|
|
2930
|
+
]);
|
|
2931
|
+
return {
|
|
2932
|
+
id,
|
|
2933
|
+
filename: body.filename,
|
|
2934
|
+
mimeType: body.mimeType,
|
|
2935
|
+
size: body.size,
|
|
2936
|
+
width: body.width,
|
|
2937
|
+
height: body.height,
|
|
2938
|
+
alt: body.alt ?? rows[0].alt,
|
|
2939
|
+
title: body.title ?? rows[0].title,
|
|
2940
|
+
r2Key,
|
|
2941
|
+
replaced: true,
|
|
2942
|
+
updatedAt: now,
|
|
2943
|
+
updatedBy: actor?.label ?? null
|
|
2944
|
+
};
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
function searchAssets(opts) {
|
|
2948
|
+
return Effect.gen(function* () {
|
|
2949
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2950
|
+
const { query, limit, offset } = opts;
|
|
2951
|
+
if (query) {
|
|
2952
|
+
const pattern = `%${query}%`;
|
|
2953
|
+
const assets = yield* sql.unsafe(`SELECT * FROM assets WHERE filename LIKE ? OR alt LIKE ? OR title LIKE ?
|
|
2954
|
+
ORDER BY created_at DESC LIMIT ? OFFSET ?`, [
|
|
2955
|
+
pattern,
|
|
2956
|
+
pattern,
|
|
2957
|
+
pattern,
|
|
2958
|
+
limit,
|
|
2959
|
+
offset
|
|
2960
|
+
]);
|
|
2961
|
+
const countRows = yield* sql.unsafe(`SELECT COUNT(*) as total FROM assets WHERE filename LIKE ? OR alt LIKE ? OR title LIKE ?`, [
|
|
2962
|
+
pattern,
|
|
2963
|
+
pattern,
|
|
2964
|
+
pattern
|
|
2965
|
+
]);
|
|
2966
|
+
return {
|
|
2967
|
+
assets: Array.from(assets),
|
|
2968
|
+
total: countRows[0]?.total ?? 0
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
const assets = yield* sql.unsafe("SELECT * FROM assets ORDER BY created_at DESC LIMIT ? OFFSET ?", [limit, offset]);
|
|
2972
|
+
const countRows = yield* sql.unsafe("SELECT COUNT(*) as total FROM assets");
|
|
2973
|
+
return {
|
|
2974
|
+
assets: Array.from(assets),
|
|
2975
|
+
total: countRows[0]?.total ?? 0
|
|
2976
|
+
};
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
function updateAssetMetadata(id, body, actor) {
|
|
2980
|
+
return Effect.gen(function* () {
|
|
2981
|
+
const sql = yield* SqlClient.SqlClient;
|
|
2982
|
+
const rows = yield* sql.unsafe("SELECT * FROM assets WHERE id = ?", [id]);
|
|
2983
|
+
if (rows.length === 0) return yield* new NotFoundError({
|
|
2984
|
+
entity: "Asset",
|
|
2985
|
+
id
|
|
2986
|
+
});
|
|
2987
|
+
const alt = body.alt !== void 0 ? body.alt : rows[0].alt;
|
|
2988
|
+
const title = body.title !== void 0 ? body.title : rows[0].title;
|
|
2989
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2990
|
+
yield* sql.unsafe("UPDATE assets SET alt = ?, title = ?, updated_at = ?, updated_by = ? WHERE id = ?", [
|
|
2991
|
+
alt,
|
|
2992
|
+
title,
|
|
2993
|
+
now,
|
|
2994
|
+
actor?.label ?? null,
|
|
2995
|
+
id
|
|
2996
|
+
]);
|
|
2997
|
+
return {
|
|
2998
|
+
id,
|
|
2999
|
+
alt,
|
|
3000
|
+
title,
|
|
3001
|
+
updatedAt: now,
|
|
3002
|
+
updatedBy: actor?.label ?? null
|
|
3003
|
+
};
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
3006
|
+
function deleteAsset(id) {
|
|
3007
|
+
return Effect.gen(function* () {
|
|
3008
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3009
|
+
if ((yield* sql.unsafe("SELECT id FROM assets WHERE id = ?", [id])).length === 0) return yield* new NotFoundError({
|
|
3010
|
+
entity: "Asset",
|
|
3011
|
+
id
|
|
3012
|
+
});
|
|
3013
|
+
yield* sql.unsafe("DELETE FROM assets WHERE id = ?", [id]);
|
|
3014
|
+
return { deleted: true };
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
function importAssetFromUrl(input, actor) {
|
|
3018
|
+
return Effect.gen(function* () {
|
|
3019
|
+
const { r2Bucket, fetch } = yield* AssetImportContext;
|
|
3020
|
+
if (!r2Bucket) return yield* new ValidationError({ message: "Asset import requires an R2 bucket binding" });
|
|
3021
|
+
const { response, resolvedUrl } = yield* fetchRemoteAsset(yield* validateRemoteAssetUrl(input.url), fetch);
|
|
3022
|
+
const filename = inferFilename({
|
|
3023
|
+
...input,
|
|
3024
|
+
url: resolvedUrl.toString()
|
|
3025
|
+
});
|
|
3026
|
+
const id = generateId();
|
|
3027
|
+
if (!response.ok) return yield* new ValidationError({ message: `Failed to fetch asset URL: ${resolvedUrl} (${response.status})` });
|
|
3028
|
+
const mimeType = input.mimeType ?? response.headers.get("content-type")?.split(";")[0] ?? "application/octet-stream";
|
|
3029
|
+
const bytes = yield* readResponseBytes(response, resolvedUrl.toString());
|
|
3030
|
+
const r2Key = `uploads/${id}/${filename}`;
|
|
3031
|
+
yield* Effect.tryPromise({
|
|
3032
|
+
try: () => r2Bucket.put(r2Key, bytes, { httpMetadata: { contentType: mimeType } }),
|
|
3033
|
+
catch: () => new ValidationError({ message: `Failed to store asset in R2: ${filename}` })
|
|
3034
|
+
});
|
|
3035
|
+
return yield* createAsset({
|
|
3036
|
+
id,
|
|
3037
|
+
filename,
|
|
3038
|
+
mimeType,
|
|
3039
|
+
size: bytes.byteLength,
|
|
3040
|
+
alt: input.alt,
|
|
3041
|
+
title: input.title,
|
|
3042
|
+
tags: input.tags,
|
|
3043
|
+
r2Key
|
|
3044
|
+
}, actor);
|
|
3045
|
+
}).pipe(Effect.withSpan("asset.import_from_url"), Effect.annotateSpans({
|
|
3046
|
+
url: input.url,
|
|
3047
|
+
actorType: actor?.type ?? "anonymous"
|
|
3048
|
+
}));
|
|
3049
|
+
}
|
|
3050
|
+
//#endregion
|
|
3051
|
+
//#region src/services/schema-lifecycle.ts
|
|
3052
|
+
/**
|
|
3053
|
+
* Schema lifecycle operations for complex cascading changes.
|
|
3054
|
+
* These go beyond simple CRUD and handle content-level cascades.
|
|
3055
|
+
*/
|
|
3056
|
+
/**
|
|
3057
|
+
* P4.4: Remove a block type — scans all StructuredText fields,
|
|
3058
|
+
* cleans DAST trees, deletes block rows, drops the block table.
|
|
3059
|
+
*/
|
|
3060
|
+
function removeBlockType(blockApiKey) {
|
|
3061
|
+
return Effect.gen(function* () {
|
|
3062
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3063
|
+
const blockModels = yield* sql.unsafe("SELECT * FROM models WHERE api_key = ? AND is_block = 1", [blockApiKey]);
|
|
3064
|
+
if (blockModels.length === 0) return yield* new NotFoundError({
|
|
3065
|
+
entity: "Block type",
|
|
3066
|
+
id: blockApiKey
|
|
3067
|
+
});
|
|
3068
|
+
const blockModel = blockModels[0];
|
|
3069
|
+
const affectedFields = (yield* sql.unsafe("SELECT * FROM fields WHERE field_type = 'structured_text'")).filter((f) => {
|
|
3070
|
+
const allowedBlocks = decodeJsonRecordStringOr(f.validators || "{}", {}).structured_text_blocks;
|
|
3071
|
+
return Array.isArray(allowedBlocks) && allowedBlocks.includes(blockApiKey);
|
|
3072
|
+
});
|
|
3073
|
+
for (const field of affectedFields) {
|
|
3074
|
+
const model = yield* sql.unsafe("SELECT * FROM models WHERE id = ?", [field.model_id]);
|
|
3075
|
+
if (model.length === 0) continue;
|
|
3076
|
+
const tableName = model[0].is_block ? `block_${model[0].api_key}` : `content_${model[0].api_key}`;
|
|
3077
|
+
const records = yield* sql.unsafe(`SELECT id, "${field.api_key}", _published_snapshot FROM "${tableName}" WHERE "${field.api_key}" IS NOT NULL OR _published_snapshot IS NOT NULL`);
|
|
3078
|
+
for (const record of records) {
|
|
3079
|
+
const blockIds = yield* sql.unsafe(`SELECT id FROM "block_${blockApiKey}" WHERE _root_record_id = ? AND _root_field_api_key = ?`, [record.id, field.api_key]);
|
|
3080
|
+
const blockIdSet = new Set(blockIds.map((b) => b.id));
|
|
3081
|
+
if (blockIdSet.size > 0) {
|
|
3082
|
+
const dast = decodeJsonIfString(record[field.api_key]);
|
|
3083
|
+
if (dast?.document?.children) {
|
|
3084
|
+
const cleaned = removeBlockNodesFromDast(dast, blockIdSet);
|
|
3085
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET "${field.api_key}" = ? WHERE id = ?`, [encodeJson(cleaned), record.id]);
|
|
3086
|
+
}
|
|
3087
|
+
yield* cleanPublishedSnapshot(sql, tableName, String(record.id), field.api_key, blockIdSet);
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
const validators = decodeJsonRecordStringOr(field.validators || "{}", {});
|
|
3091
|
+
validators.structured_text_blocks = (Array.isArray(validators.structured_text_blocks) ? validators.structured_text_blocks.filter((b) => typeof b === "string") : []).filter((b) => b !== blockApiKey);
|
|
3092
|
+
yield* sql.unsafe("UPDATE fields SET validators = ? WHERE id = ?", [encodeJson(validators), field.id]);
|
|
3093
|
+
}
|
|
3094
|
+
yield* sql.unsafe(`DELETE FROM "block_${blockApiKey}"`);
|
|
3095
|
+
yield* dropTableSql(`block_${blockApiKey}`);
|
|
3096
|
+
yield* sql.unsafe("DELETE FROM fields WHERE model_id = ?", [blockModel.id]);
|
|
3097
|
+
yield* sql.unsafe("DELETE FROM models WHERE id = ?", [blockModel.id]);
|
|
3098
|
+
return {
|
|
3099
|
+
deleted: true,
|
|
3100
|
+
affectedFields: affectedFields.length
|
|
3101
|
+
};
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
/**
|
|
3105
|
+
* P4.5: Remove a block type from a field's whitelist (without deleting the type).
|
|
3106
|
+
* Cleans affected DAST trees by removing block nodes of that type.
|
|
3107
|
+
*/
|
|
3108
|
+
function removeBlockFromWhitelist(params) {
|
|
3109
|
+
return Effect.gen(function* () {
|
|
3110
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3111
|
+
const { fieldId, blockApiKey } = params;
|
|
3112
|
+
const fields = yield* sql.unsafe("SELECT * FROM fields WHERE id = ?", [fieldId]);
|
|
3113
|
+
if (fields.length === 0) return yield* new NotFoundError({
|
|
3114
|
+
entity: "Field",
|
|
3115
|
+
id: fieldId
|
|
3116
|
+
});
|
|
3117
|
+
const field = fields[0];
|
|
3118
|
+
if (field.field_type !== "structured_text") return yield* new ValidationError({ message: "Field is not a structured_text field" });
|
|
3119
|
+
const validators = decodeJsonRecordStringOr(field.validators || "{}", {});
|
|
3120
|
+
const whitelist = Array.isArray(validators.structured_text_blocks) ? validators.structured_text_blocks.filter((b) => typeof b === "string") : [];
|
|
3121
|
+
if (!whitelist.includes(blockApiKey)) return yield* new ValidationError({ message: `Block type '${blockApiKey}' is not in this field's whitelist` });
|
|
3122
|
+
const model = yield* sql.unsafe("SELECT * FROM models WHERE id = ?", [field.model_id]);
|
|
3123
|
+
if (model.length === 0) return yield* new NotFoundError({
|
|
3124
|
+
entity: "Model",
|
|
3125
|
+
id: field.model_id
|
|
3126
|
+
});
|
|
3127
|
+
const tableName = model[0].is_block ? `block_${model[0].api_key}` : `content_${model[0].api_key}`;
|
|
3128
|
+
const blockIds = yield* sql.unsafe(`SELECT id FROM "block_${blockApiKey}" WHERE _root_field_api_key = ? AND _root_record_id IN (SELECT id FROM "${tableName}")`, [field.api_key]);
|
|
3129
|
+
const blockIdSet = new Set(blockIds.map((b) => b.id));
|
|
3130
|
+
let cleanedRecords = 0;
|
|
3131
|
+
if (blockIdSet.size > 0) {
|
|
3132
|
+
const records = yield* sql.unsafe(`SELECT id, "${field.api_key}", _published_snapshot FROM "${tableName}" WHERE "${field.api_key}" IS NOT NULL OR _published_snapshot IS NOT NULL`);
|
|
3133
|
+
for (const record of records) {
|
|
3134
|
+
const dast = decodeJsonIfString(record[field.api_key]);
|
|
3135
|
+
if (dast?.document?.children) {
|
|
3136
|
+
const cleaned = removeBlockNodesFromDast(dast, blockIdSet);
|
|
3137
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET "${field.api_key}" = ? WHERE id = ?`, [encodeJson(cleaned), record.id]);
|
|
3138
|
+
cleanedRecords++;
|
|
3139
|
+
}
|
|
3140
|
+
yield* cleanPublishedSnapshot(sql, tableName, String(record.id), field.api_key, blockIdSet);
|
|
3141
|
+
}
|
|
3142
|
+
yield* sql.unsafe(`DELETE FROM "block_${blockApiKey}" WHERE _root_field_api_key = ? AND _root_record_id IN (SELECT id FROM "${tableName}")`, [field.api_key]);
|
|
3143
|
+
}
|
|
3144
|
+
validators.structured_text_blocks = whitelist.filter((b) => b !== blockApiKey);
|
|
3145
|
+
yield* sql.unsafe("UPDATE fields SET validators = ? WHERE id = ?", [encodeJson(validators), fieldId]);
|
|
3146
|
+
return {
|
|
3147
|
+
removed: blockApiKey,
|
|
3148
|
+
cleanedRecords,
|
|
3149
|
+
blocksDeleted: blockIdSet.size
|
|
3150
|
+
};
|
|
3151
|
+
});
|
|
3152
|
+
}
|
|
3153
|
+
/**
|
|
3154
|
+
* P4.6: Remove a locale — strips the locale key from all localized field
|
|
3155
|
+
* values across all models.
|
|
3156
|
+
*/
|
|
3157
|
+
function removeLocale(localeId) {
|
|
3158
|
+
return Effect.gen(function* () {
|
|
3159
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3160
|
+
const locales = yield* sql.unsafe("SELECT id, code FROM locales WHERE id = ?", [localeId]);
|
|
3161
|
+
if (locales.length === 0) return yield* new NotFoundError({
|
|
3162
|
+
entity: "Locale",
|
|
3163
|
+
id: localeId
|
|
3164
|
+
});
|
|
3165
|
+
const localeCode = locales[0].code;
|
|
3166
|
+
const localizedFields = yield* sql.unsafe("SELECT * FROM fields WHERE localized = 1");
|
|
3167
|
+
let updatedRecords = 0;
|
|
3168
|
+
for (const field of localizedFields) {
|
|
3169
|
+
const model = yield* sql.unsafe("SELECT * FROM models WHERE id = ?", [field.model_id]);
|
|
3170
|
+
if (model.length === 0) continue;
|
|
3171
|
+
const tableName = model[0].is_block ? `block_${model[0].api_key}` : `content_${model[0].api_key}`;
|
|
3172
|
+
const records = yield* sql.unsafe(`SELECT id, "${field.api_key}" FROM "${tableName}" WHERE "${field.api_key}" IS NOT NULL`);
|
|
3173
|
+
for (const record of records) {
|
|
3174
|
+
const parsed = decodeJsonIfString(record[field.api_key]);
|
|
3175
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
|
|
3176
|
+
const localeMap = parsed;
|
|
3177
|
+
if (localeCode in localeMap) {
|
|
3178
|
+
delete localeMap[localeCode];
|
|
3179
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET "${field.api_key}" = ? WHERE id = ?`, [encodeJson(localeMap), record.id]);
|
|
3180
|
+
updatedRecords++;
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
yield* sql.unsafe("DELETE FROM locales WHERE id = ?", [localeId]);
|
|
3185
|
+
return {
|
|
3186
|
+
deleted: localeCode,
|
|
3187
|
+
updatedRecords,
|
|
3188
|
+
fieldsScanned: localizedFields.length
|
|
3189
|
+
};
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
/** Clean DAST inside a record's _published_snapshot for a given field */
|
|
3193
|
+
function cleanPublishedSnapshot(sql, tableName, recordId, fieldApiKey, blockIds) {
|
|
3194
|
+
return Effect.gen(function* () {
|
|
3195
|
+
const rows = yield* sql.unsafe(`SELECT _published_snapshot FROM "${tableName}" WHERE id = ?`, [recordId]);
|
|
3196
|
+
if (!rows[0]?._published_snapshot) return;
|
|
3197
|
+
const snapshot = decodeJsonRecordStringOr(rows[0]._published_snapshot, {});
|
|
3198
|
+
if (Object.keys(snapshot).length === 0) return;
|
|
3199
|
+
snapshot[fieldApiKey] = removeBlockNodesFromStructuredTextValue(decodeJsonIfString(snapshot[fieldApiKey]), blockIds);
|
|
3200
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _published_snapshot = ? WHERE id = ?`, [encodeJson(snapshot), recordId]);
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
/** Remove block/inlineBlock nodes whose item IDs are in the given set */
|
|
3204
|
+
function removeBlockNodesFromDast(dast, blockIds) {
|
|
3205
|
+
if (!dast.document?.children) return dast;
|
|
3206
|
+
return {
|
|
3207
|
+
...dast,
|
|
3208
|
+
document: {
|
|
3209
|
+
...dast.document,
|
|
3210
|
+
children: filterNodes(dast.document.children, blockIds)
|
|
3211
|
+
}
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
function removeBlockNodesFromStructuredTextValue(value, blockIds) {
|
|
3215
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
3216
|
+
const obj = value;
|
|
3217
|
+
if (obj.value && typeof obj.value === "object" && obj.blocks && typeof obj.blocks === "object") {
|
|
3218
|
+
const cleanedBlocks = { ...obj.blocks };
|
|
3219
|
+
for (const blockId of blockIds) delete cleanedBlocks[blockId];
|
|
3220
|
+
return {
|
|
3221
|
+
...obj,
|
|
3222
|
+
value: removeBlockNodesFromDast(obj.value, blockIds),
|
|
3223
|
+
blocks: cleanedBlocks
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
if (obj.document?.children) return removeBlockNodesFromDast(obj, blockIds);
|
|
3227
|
+
return value;
|
|
3228
|
+
}
|
|
3229
|
+
function filterNodes(nodes, blockIds) {
|
|
3230
|
+
return nodes.filter((node) => {
|
|
3231
|
+
if ((node.type === "block" || node.type === "inlineBlock") && typeof node.item === "string" && blockIds.has(node.item)) return false;
|
|
3232
|
+
return true;
|
|
3233
|
+
}).map((node) => {
|
|
3234
|
+
if (Array.isArray(node.children)) return {
|
|
3235
|
+
...node,
|
|
3236
|
+
children: filterNodes(node.children, blockIds)
|
|
3237
|
+
};
|
|
3238
|
+
return node;
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
//#endregion
|
|
3242
|
+
//#region src/services/locale-service.ts
|
|
3243
|
+
function listLocales() {
|
|
3244
|
+
return Effect.gen(function* () {
|
|
3245
|
+
return yield* (yield* SqlClient.SqlClient).unsafe("SELECT * FROM locales ORDER BY position");
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
function createLocale(body) {
|
|
3249
|
+
return Effect.gen(function* () {
|
|
3250
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3251
|
+
if ((yield* sql.unsafe("SELECT id FROM locales WHERE code = ?", [body.code])).length > 0) return yield* new DuplicateError({ message: `Locale '${body.code}' already exists` });
|
|
3252
|
+
const allLocales = yield* sql.unsafe("SELECT id FROM locales");
|
|
3253
|
+
const id = generateId();
|
|
3254
|
+
const position = body.position ?? allLocales.length;
|
|
3255
|
+
yield* sql.unsafe("INSERT INTO locales (id, code, position, fallback_locale_id) VALUES (?, ?, ?, ?)", [
|
|
3256
|
+
id,
|
|
3257
|
+
body.code,
|
|
3258
|
+
position,
|
|
3259
|
+
body.fallbackLocaleId ?? null
|
|
3260
|
+
]);
|
|
3261
|
+
return {
|
|
3262
|
+
id,
|
|
3263
|
+
code: body.code,
|
|
3264
|
+
position,
|
|
3265
|
+
fallbackLocaleId: body.fallbackLocaleId ?? null
|
|
3266
|
+
};
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3269
|
+
function deleteLocale(id) {
|
|
3270
|
+
return removeLocale(id);
|
|
3271
|
+
}
|
|
3272
|
+
//#endregion
|
|
3273
|
+
//#region src/services/schedule-service.ts
|
|
3274
|
+
function getContentModel(modelApiKey) {
|
|
3275
|
+
return Effect.gen(function* () {
|
|
3276
|
+
const models = yield* (yield* SqlClient.SqlClient).unsafe("SELECT * FROM models WHERE api_key = ? AND is_block = 0", [modelApiKey]);
|
|
3277
|
+
if (models.length === 0) return yield* new NotFoundError({
|
|
3278
|
+
entity: "Model",
|
|
3279
|
+
id: modelApiKey
|
|
3280
|
+
});
|
|
3281
|
+
return models[0];
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
function validateScheduleAt(at) {
|
|
3285
|
+
return Effect.gen(function* () {
|
|
3286
|
+
if (at === null) return null;
|
|
3287
|
+
if (Number.isNaN(Date.parse(at))) return yield* new ValidationError({ message: "Schedule time must be a valid ISO datetime string" });
|
|
3288
|
+
return at;
|
|
3289
|
+
});
|
|
3290
|
+
}
|
|
3291
|
+
function schedulePublish(modelApiKey, recordId, at, actor) {
|
|
3292
|
+
return Effect.gen(function* () {
|
|
3293
|
+
const model = yield* getContentModel(modelApiKey);
|
|
3294
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3295
|
+
const tableName = `content_${model.api_key}`;
|
|
3296
|
+
const scheduleAt = yield* validateScheduleAt(at);
|
|
3297
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE id = ?`, [recordId])).length === 0) return yield* new NotFoundError({
|
|
3298
|
+
entity: "Record",
|
|
3299
|
+
id: recordId
|
|
3300
|
+
});
|
|
3301
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3302
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _scheduled_publish_at = ?, _updated_at = ?, _updated_by = ? WHERE id = ?`, [
|
|
3303
|
+
scheduleAt,
|
|
3304
|
+
now,
|
|
3305
|
+
actor?.label ?? null,
|
|
3306
|
+
recordId
|
|
3307
|
+
]);
|
|
3308
|
+
return yield* selectById(tableName, recordId);
|
|
3309
|
+
});
|
|
3310
|
+
}
|
|
3311
|
+
function scheduleUnpublish(modelApiKey, recordId, at, actor) {
|
|
3312
|
+
return Effect.gen(function* () {
|
|
3313
|
+
const model = yield* getContentModel(modelApiKey);
|
|
3314
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3315
|
+
const tableName = `content_${model.api_key}`;
|
|
3316
|
+
const scheduleAt = yield* validateScheduleAt(at);
|
|
3317
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE id = ?`, [recordId])).length === 0) return yield* new NotFoundError({
|
|
3318
|
+
entity: "Record",
|
|
3319
|
+
id: recordId
|
|
3320
|
+
});
|
|
3321
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3322
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _scheduled_unpublish_at = ?, _updated_at = ?, _updated_by = ? WHERE id = ?`, [
|
|
3323
|
+
scheduleAt,
|
|
3324
|
+
now,
|
|
3325
|
+
actor?.label ?? null,
|
|
3326
|
+
recordId
|
|
3327
|
+
]);
|
|
3328
|
+
return yield* selectById(tableName, recordId);
|
|
3329
|
+
});
|
|
3330
|
+
}
|
|
3331
|
+
function clearSchedule(modelApiKey, recordId, actor) {
|
|
3332
|
+
return Effect.gen(function* () {
|
|
3333
|
+
const model = yield* getContentModel(modelApiKey);
|
|
3334
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3335
|
+
const tableName = `content_${model.api_key}`;
|
|
3336
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE id = ?`, [recordId])).length === 0) return yield* new NotFoundError({
|
|
3337
|
+
entity: "Record",
|
|
3338
|
+
id: recordId
|
|
3339
|
+
});
|
|
3340
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3341
|
+
yield* sql.unsafe(`UPDATE "${tableName}" SET _scheduled_publish_at = NULL, _scheduled_unpublish_at = NULL, _updated_at = ?, _updated_by = ? WHERE id = ?`, [
|
|
3342
|
+
now,
|
|
3343
|
+
actor?.label ?? null,
|
|
3344
|
+
recordId
|
|
3345
|
+
]);
|
|
3346
|
+
return yield* selectById(tableName, recordId);
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
function runScheduledTransitions(now = /* @__PURE__ */ new Date(), actor = {
|
|
3350
|
+
type: "admin",
|
|
3351
|
+
label: "scheduler"
|
|
3352
|
+
}) {
|
|
3353
|
+
return Effect.gen(function* () {
|
|
3354
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3355
|
+
const nowIso = now.toISOString();
|
|
3356
|
+
const models = yield* sql.unsafe("SELECT api_key FROM models WHERE is_block = 0 ORDER BY created_at");
|
|
3357
|
+
const published = [];
|
|
3358
|
+
const unpublished = [];
|
|
3359
|
+
for (const model of models) {
|
|
3360
|
+
const tableName = `content_${model.api_key}`;
|
|
3361
|
+
const duePublish = yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE _scheduled_publish_at IS NOT NULL AND _scheduled_publish_at <= ? ORDER BY _scheduled_publish_at ASC`, [nowIso]);
|
|
3362
|
+
for (const row of duePublish) {
|
|
3363
|
+
yield* publishRecord(model.api_key, row.id, actor);
|
|
3364
|
+
published.push({
|
|
3365
|
+
modelApiKey: model.api_key,
|
|
3366
|
+
recordId: row.id
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
for (const model of models) {
|
|
3371
|
+
const tableName = `content_${model.api_key}`;
|
|
3372
|
+
const dueUnpublish = yield* sql.unsafe(`SELECT id FROM "${tableName}" WHERE _scheduled_unpublish_at IS NOT NULL AND _scheduled_unpublish_at <= ? AND _status IN ('published', 'updated') ORDER BY _scheduled_unpublish_at ASC`, [nowIso]);
|
|
3373
|
+
for (const row of dueUnpublish) {
|
|
3374
|
+
yield* unpublishRecord(model.api_key, row.id, actor);
|
|
3375
|
+
unpublished.push({
|
|
3376
|
+
modelApiKey: model.api_key,
|
|
3377
|
+
recordId: row.id
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
return {
|
|
3382
|
+
now: nowIso,
|
|
3383
|
+
published,
|
|
3384
|
+
unpublished
|
|
3385
|
+
};
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
//#endregion
|
|
3389
|
+
//#region src/services/input-schemas.ts
|
|
3390
|
+
/**
|
|
3391
|
+
* Effect Schema definitions for REST API request body validation.
|
|
3392
|
+
* Replaces manual type guard functions with runtime-verified decoding.
|
|
3393
|
+
*/
|
|
3394
|
+
const Int = Schema.Number.pipe(Schema.int());
|
|
3395
|
+
function finiteNumber(message) {
|
|
3396
|
+
return Schema.Number.pipe(Schema.filter((value) => Number.isFinite(value), { message: () => message }));
|
|
3397
|
+
}
|
|
3398
|
+
function positiveInt(label) {
|
|
3399
|
+
return Int.pipe(Schema.filter((value) => Number.isFinite(value) && value > 0, { message: () => `${label} must be a positive integer` }));
|
|
3400
|
+
}
|
|
3401
|
+
function nonNegativeFiniteNumber(label) {
|
|
3402
|
+
return finiteNumber(`${label} must be a finite number`).pipe(Schema.filter((value) => value >= 0, { message: () => `${label} must be >= 0` }));
|
|
3403
|
+
}
|
|
3404
|
+
const UnitIntervalNumber = finiteNumber("Expected a finite number").pipe(Schema.filter((value) => value >= 0 && value <= 1, { message: () => "Expected a number between 0 and 1" }));
|
|
3405
|
+
const HttpUrlString = Schema.String.pipe(Schema.filter((value) => {
|
|
3406
|
+
try {
|
|
3407
|
+
const url = new URL(value);
|
|
3408
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
3409
|
+
} catch {
|
|
3410
|
+
return false;
|
|
3411
|
+
}
|
|
3412
|
+
}, { message: () => "Expected a valid http:// or https:// URL" }));
|
|
3413
|
+
const CreateModelInput = Schema.Struct({
|
|
3414
|
+
name: Schema.NonEmptyString,
|
|
3415
|
+
apiKey: Schema.NonEmptyString,
|
|
3416
|
+
isBlock: Schema.optionalWith(Schema.Boolean, { default: () => false }),
|
|
3417
|
+
singleton: Schema.optionalWith(Schema.Boolean, { default: () => false }),
|
|
3418
|
+
sortable: Schema.optionalWith(Schema.Boolean, { default: () => false }),
|
|
3419
|
+
tree: Schema.optionalWith(Schema.Boolean, { default: () => false }),
|
|
3420
|
+
hasDraft: Schema.optionalWith(Schema.Boolean, { default: () => true }),
|
|
3421
|
+
allLocalesRequired: Schema.optionalWith(Schema.Boolean, { default: () => false }),
|
|
3422
|
+
ordering: Schema.optional(Schema.String)
|
|
3423
|
+
});
|
|
3424
|
+
const CreateFieldInput = Schema.Struct({
|
|
3425
|
+
label: Schema.NonEmptyString,
|
|
3426
|
+
apiKey: Schema.NonEmptyString,
|
|
3427
|
+
fieldType: Schema.NonEmptyString,
|
|
3428
|
+
position: Schema.optional(Int.pipe(Schema.filter((value) => value >= 0, { message: () => "position must be >= 0" }))),
|
|
3429
|
+
localized: Schema.optionalWith(Schema.Boolean, { default: () => false }),
|
|
3430
|
+
validators: Schema.optionalWith(Schema.Record({
|
|
3431
|
+
key: Schema.String,
|
|
3432
|
+
value: Schema.Unknown
|
|
3433
|
+
}), { default: () => ({}) }),
|
|
3434
|
+
defaultValue: Schema.optional(Schema.Unknown),
|
|
3435
|
+
appearance: Schema.optional(Schema.Unknown),
|
|
3436
|
+
hint: Schema.optional(Schema.String),
|
|
3437
|
+
fieldsetId: Schema.optional(Schema.String)
|
|
3438
|
+
});
|
|
3439
|
+
const CreateRecordInput = Schema.Struct({
|
|
3440
|
+
id: Schema.optional(Schema.String),
|
|
3441
|
+
modelApiKey: Schema.NonEmptyString,
|
|
3442
|
+
data: Schema.optionalWith(Schema.Record({
|
|
3443
|
+
key: Schema.String,
|
|
3444
|
+
value: Schema.Unknown
|
|
3445
|
+
}), { default: () => ({}) }),
|
|
3446
|
+
overrides: Schema.optional(Schema.Struct({
|
|
3447
|
+
createdAt: Schema.optional(Schema.String),
|
|
3448
|
+
updatedAt: Schema.optional(Schema.String),
|
|
3449
|
+
publishedAt: Schema.optional(Schema.String),
|
|
3450
|
+
firstPublishedAt: Schema.optional(Schema.String)
|
|
3451
|
+
}))
|
|
3452
|
+
});
|
|
3453
|
+
const PatchRecordInput = Schema.Struct({
|
|
3454
|
+
modelApiKey: Schema.NonEmptyString,
|
|
3455
|
+
data: Schema.optionalWith(Schema.Record({
|
|
3456
|
+
key: Schema.String,
|
|
3457
|
+
value: Schema.Unknown
|
|
3458
|
+
}), { default: () => ({}) }),
|
|
3459
|
+
overrides: Schema.optional(Schema.Struct({
|
|
3460
|
+
createdAt: Schema.optional(Schema.String),
|
|
3461
|
+
updatedAt: Schema.optional(Schema.String),
|
|
3462
|
+
publishedAt: Schema.optional(Schema.String),
|
|
3463
|
+
firstPublishedAt: Schema.optional(Schema.String)
|
|
3464
|
+
}))
|
|
3465
|
+
});
|
|
3466
|
+
const CreateAssetInput = Schema.Struct({
|
|
3467
|
+
id: Schema.optional(Schema.String),
|
|
3468
|
+
filename: Schema.NonEmptyString,
|
|
3469
|
+
mimeType: Schema.NonEmptyString,
|
|
3470
|
+
size: Schema.optionalWith(nonNegativeFiniteNumber("size"), { default: () => 0 }),
|
|
3471
|
+
width: Schema.optional(nonNegativeFiniteNumber("width")),
|
|
3472
|
+
height: Schema.optional(nonNegativeFiniteNumber("height")),
|
|
3473
|
+
alt: Schema.optional(Schema.String),
|
|
3474
|
+
title: Schema.optional(Schema.String),
|
|
3475
|
+
r2Key: Schema.optional(Schema.String),
|
|
3476
|
+
blurhash: Schema.optional(Schema.String),
|
|
3477
|
+
colors: Schema.optional(Schema.Array(Schema.String).pipe(Schema.filter((value) => value.length <= 16, { message: () => "colors must contain at most 16 entries" }))),
|
|
3478
|
+
focalPoint: Schema.optional(Schema.Struct({
|
|
3479
|
+
x: UnitIntervalNumber,
|
|
3480
|
+
y: UnitIntervalNumber
|
|
3481
|
+
})),
|
|
3482
|
+
tags: Schema.optionalWith(Schema.Array(Schema.String).pipe(Schema.filter((value) => value.length <= 50, { message: () => "tags must contain at most 50 entries" })), { default: () => [] })
|
|
3483
|
+
});
|
|
3484
|
+
const ImportAssetFromUrlInput = Schema.Struct({
|
|
3485
|
+
url: HttpUrlString,
|
|
3486
|
+
filename: Schema.optional(Schema.String),
|
|
3487
|
+
mimeType: Schema.optional(Schema.String),
|
|
3488
|
+
alt: Schema.optional(Schema.String),
|
|
3489
|
+
title: Schema.optional(Schema.String),
|
|
3490
|
+
tags: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] })
|
|
3491
|
+
});
|
|
3492
|
+
const SearchAssetsInput = Schema.Struct({
|
|
3493
|
+
query: Schema.optional(Schema.String),
|
|
3494
|
+
limit: Schema.optionalWith(positiveInt("limit"), { default: () => 24 }),
|
|
3495
|
+
offset: Schema.optionalWith(Int.pipe(Schema.filter((value) => value >= 0, { message: () => "offset must be >= 0" })), { default: () => 0 })
|
|
3496
|
+
});
|
|
3497
|
+
const UpdateAssetMetadataInput = Schema.Struct({
|
|
3498
|
+
alt: Schema.optional(Schema.String),
|
|
3499
|
+
title: Schema.optional(Schema.String)
|
|
3500
|
+
});
|
|
3501
|
+
const ReorderInput = Schema.Struct({
|
|
3502
|
+
modelApiKey: Schema.NonEmptyString,
|
|
3503
|
+
recordIds: Schema.Array(Schema.String).pipe(Schema.filter((value) => value.length <= 1e3, { message: () => "recordIds must contain at most 1000 entries" }))
|
|
3504
|
+
});
|
|
3505
|
+
Schema.Struct({
|
|
3506
|
+
modelApiKey: Schema.NonEmptyString,
|
|
3507
|
+
recordIds: Schema.Array(Schema.String).pipe(Schema.filter((value) => value.length >= 1, { message: () => "recordIds must contain at least 1 entry" }), Schema.filter((value) => value.length <= 1e3, { message: () => "recordIds must contain at most 1000 entries" }))
|
|
3508
|
+
});
|
|
3509
|
+
const ScheduleRecordInput = Schema.Struct({
|
|
3510
|
+
modelApiKey: Schema.NonEmptyString,
|
|
3511
|
+
at: Schema.NullOr(Schema.String.pipe(Schema.filter((value) => !Number.isNaN(Date.parse(value)), { message: () => "at must be a valid ISO datetime string or null" })))
|
|
3512
|
+
});
|
|
3513
|
+
const SearchInput = Schema.Struct({
|
|
3514
|
+
query: Schema.String,
|
|
3515
|
+
modelApiKey: Schema.optional(Schema.String),
|
|
3516
|
+
first: Schema.optional(positiveInt("first")),
|
|
3517
|
+
skip: Schema.optional(Int.pipe(Schema.filter((value) => value >= 0, { message: () => "skip must be >= 0" }))),
|
|
3518
|
+
mode: Schema.optional(Schema.Literal("keyword", "semantic", "hybrid"))
|
|
3519
|
+
});
|
|
3520
|
+
const ReindexSearchInput = Schema.Struct({ modelApiKey: Schema.optional(Schema.String) });
|
|
3521
|
+
const CreateLocaleInput = Schema.Struct({
|
|
3522
|
+
code: Schema.NonEmptyString,
|
|
3523
|
+
position: Schema.optional(Int.pipe(Schema.filter((value) => value >= 0, { message: () => "position must be >= 0" }))),
|
|
3524
|
+
fallbackLocaleId: Schema.optional(Schema.String)
|
|
3525
|
+
});
|
|
3526
|
+
const UpdateModelInput = Schema.Struct({
|
|
3527
|
+
name: Schema.optional(Schema.NonEmptyString),
|
|
3528
|
+
apiKey: Schema.optional(Schema.NonEmptyString),
|
|
3529
|
+
singleton: Schema.optional(Schema.Boolean),
|
|
3530
|
+
sortable: Schema.optional(Schema.Boolean),
|
|
3531
|
+
hasDraft: Schema.optional(Schema.Boolean),
|
|
3532
|
+
allLocalesRequired: Schema.optional(Schema.Boolean),
|
|
3533
|
+
ordering: Schema.optional(Schema.NullOr(Schema.String))
|
|
3534
|
+
});
|
|
3535
|
+
const UpdateFieldInput = Schema.Struct({
|
|
3536
|
+
label: Schema.optional(Schema.NonEmptyString),
|
|
3537
|
+
apiKey: Schema.optional(Schema.NonEmptyString),
|
|
3538
|
+
fieldType: Schema.optional(Schema.NonEmptyString),
|
|
3539
|
+
position: Schema.optional(Int.pipe(Schema.filter((value) => value >= 0, { message: () => "position must be >= 0" }))),
|
|
3540
|
+
localized: Schema.optional(Schema.Boolean),
|
|
3541
|
+
validators: Schema.optional(Schema.Record({
|
|
3542
|
+
key: Schema.String,
|
|
3543
|
+
value: Schema.Unknown
|
|
3544
|
+
})),
|
|
3545
|
+
hint: Schema.optional(Schema.String),
|
|
3546
|
+
appearance: Schema.optional(Schema.Unknown)
|
|
3547
|
+
});
|
|
3548
|
+
const BulkCreateRecordsInput = Schema.Struct({
|
|
3549
|
+
modelApiKey: Schema.NonEmptyString,
|
|
3550
|
+
records: Schema.Array(Schema.Record({
|
|
3551
|
+
key: Schema.String,
|
|
3552
|
+
value: Schema.Unknown
|
|
3553
|
+
}))
|
|
3554
|
+
});
|
|
3555
|
+
const SchemaExportFieldSchema = Schema.Struct({
|
|
3556
|
+
label: Schema.String,
|
|
3557
|
+
apiKey: Schema.String,
|
|
3558
|
+
fieldType: Schema.String,
|
|
3559
|
+
position: Schema.Number,
|
|
3560
|
+
localized: Schema.Boolean,
|
|
3561
|
+
validators: Schema.Record({
|
|
3562
|
+
key: Schema.String,
|
|
3563
|
+
value: Schema.Unknown
|
|
3564
|
+
}),
|
|
3565
|
+
hint: Schema.NullOr(Schema.String)
|
|
3566
|
+
});
|
|
3567
|
+
const SchemaExportModelSchema = Schema.Struct({
|
|
3568
|
+
name: Schema.String,
|
|
3569
|
+
apiKey: Schema.String,
|
|
3570
|
+
isBlock: Schema.Boolean,
|
|
3571
|
+
singleton: Schema.Boolean,
|
|
3572
|
+
sortable: Schema.Boolean,
|
|
3573
|
+
tree: Schema.Boolean,
|
|
3574
|
+
hasDraft: Schema.Boolean,
|
|
3575
|
+
ordering: Schema.optionalWith(Schema.NullOr(Schema.String), { default: () => null }),
|
|
3576
|
+
fields: Schema.Array(SchemaExportFieldSchema)
|
|
3577
|
+
});
|
|
3578
|
+
const SchemaExportLocaleSchema = Schema.Struct({
|
|
3579
|
+
code: Schema.String,
|
|
3580
|
+
position: Schema.Number,
|
|
3581
|
+
fallbackLocale: Schema.NullOr(Schema.String)
|
|
3582
|
+
});
|
|
3583
|
+
const PatchBlocksInput = Schema.Struct({
|
|
3584
|
+
recordId: Schema.NonEmptyString,
|
|
3585
|
+
modelApiKey: Schema.NonEmptyString,
|
|
3586
|
+
fieldApiKey: Schema.NonEmptyString,
|
|
3587
|
+
value: Schema.optional(Schema.Unknown),
|
|
3588
|
+
blocks: Schema.Record({
|
|
3589
|
+
key: Schema.String,
|
|
3590
|
+
value: Schema.NullOr(Schema.Unknown)
|
|
3591
|
+
})
|
|
3592
|
+
});
|
|
3593
|
+
const ImportSchemaInput = Schema.Struct({
|
|
3594
|
+
version: Schema.Literal(1),
|
|
3595
|
+
locales: Schema.optionalWith(Schema.Array(SchemaExportLocaleSchema), { default: () => [] }),
|
|
3596
|
+
models: Schema.Array(SchemaExportModelSchema)
|
|
3597
|
+
});
|
|
3598
|
+
const CreateUploadUrlInput = Schema.Struct({
|
|
3599
|
+
filename: Schema.NonEmptyString,
|
|
3600
|
+
mimeType: Schema.NonEmptyString
|
|
3601
|
+
});
|
|
3602
|
+
const CreateEditorTokenInput = Schema.Struct({
|
|
3603
|
+
name: Schema.NonEmptyString,
|
|
3604
|
+
expiresIn: Schema.optional(Int.pipe(Schema.positive(), Schema.filter((value) => value <= 3600 * 24 * 365, { message: () => "expiresIn must be <= 31536000 seconds" })))
|
|
3605
|
+
});
|
|
3606
|
+
//#endregion
|
|
3607
|
+
//#region src/services/schema-io.ts
|
|
3608
|
+
/**
|
|
3609
|
+
* Schema import/export — portable JSON format for CMS schemas.
|
|
3610
|
+
*
|
|
3611
|
+
* Export produces a self-contained JSON with no IDs (references by api_key).
|
|
3612
|
+
* Import creates all locales, models, and fields in dependency order.
|
|
3613
|
+
*/
|
|
3614
|
+
function exportSchema() {
|
|
3615
|
+
return Effect.gen(function* () {
|
|
3616
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3617
|
+
const localeRows = yield* sql.unsafe("SELECT * FROM locales ORDER BY position");
|
|
3618
|
+
const modelRows = yield* sql.unsafe("SELECT * FROM models ORDER BY is_block, created_at");
|
|
3619
|
+
const fieldRows = yield* sql.unsafe("SELECT * FROM fields ORDER BY model_id, position");
|
|
3620
|
+
const localeIdToCode = /* @__PURE__ */ new Map();
|
|
3621
|
+
for (const l of localeRows) localeIdToCode.set(l.id, l.code);
|
|
3622
|
+
const locales = localeRows.map((l) => ({
|
|
3623
|
+
code: l.code,
|
|
3624
|
+
position: l.position,
|
|
3625
|
+
fallbackLocale: l.fallback_locale_id ? localeIdToCode.get(l.fallback_locale_id) ?? null : null
|
|
3626
|
+
}));
|
|
3627
|
+
const fieldsByModelId = /* @__PURE__ */ new Map();
|
|
3628
|
+
for (const f of fieldRows) {
|
|
3629
|
+
const list = fieldsByModelId.get(f.model_id) ?? [];
|
|
3630
|
+
list.push(f);
|
|
3631
|
+
fieldsByModelId.set(f.model_id, list);
|
|
3632
|
+
}
|
|
3633
|
+
return {
|
|
3634
|
+
version: 1,
|
|
3635
|
+
locales,
|
|
3636
|
+
models: modelRows.map((m) => ({
|
|
3637
|
+
name: m.name,
|
|
3638
|
+
apiKey: m.api_key,
|
|
3639
|
+
isBlock: !!m.is_block,
|
|
3640
|
+
singleton: !!m.singleton,
|
|
3641
|
+
sortable: !!m.sortable,
|
|
3642
|
+
tree: !!m.tree,
|
|
3643
|
+
hasDraft: !!m.has_draft,
|
|
3644
|
+
ordering: m.ordering,
|
|
3645
|
+
fields: (fieldsByModelId.get(m.id) ?? []).map((f) => ({
|
|
3646
|
+
label: f.label,
|
|
3647
|
+
apiKey: f.api_key,
|
|
3648
|
+
fieldType: f.field_type,
|
|
3649
|
+
position: f.position,
|
|
3650
|
+
localized: !!f.localized,
|
|
3651
|
+
validators: decodeJsonRecordStringOr(f.validators || "{}", {}),
|
|
3652
|
+
hint: f.hint
|
|
3653
|
+
}))
|
|
3654
|
+
}))
|
|
3655
|
+
};
|
|
3656
|
+
});
|
|
3657
|
+
}
|
|
3658
|
+
function importSchema(s) {
|
|
3659
|
+
return Effect.gen(function* () {
|
|
3660
|
+
const stats = {
|
|
3661
|
+
locales: 0,
|
|
3662
|
+
models: 0,
|
|
3663
|
+
fields: 0
|
|
3664
|
+
};
|
|
3665
|
+
const localeCodeToId = /* @__PURE__ */ new Map();
|
|
3666
|
+
if (s.locales.length > 0) {
|
|
3667
|
+
const sortedLocales = [...s.locales].sort((a, b) => a.position - b.position);
|
|
3668
|
+
for (const locale of sortedLocales) {
|
|
3669
|
+
const result = yield* createLocale({
|
|
3670
|
+
code: locale.code,
|
|
3671
|
+
position: locale.position
|
|
3672
|
+
});
|
|
3673
|
+
localeCodeToId.set(locale.code, result.id);
|
|
3674
|
+
stats.locales++;
|
|
3675
|
+
}
|
|
3676
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3677
|
+
for (const locale of sortedLocales) if (locale.fallbackLocale) {
|
|
3678
|
+
const fallbackId = localeCodeToId.get(locale.fallbackLocale);
|
|
3679
|
+
if (fallbackId) {
|
|
3680
|
+
const localeId = localeCodeToId.get(locale.code);
|
|
3681
|
+
yield* sql.unsafe("UPDATE locales SET fallback_locale_id = ? WHERE id = ?", [fallbackId, localeId]);
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
const modelApiKeyToId = /* @__PURE__ */ new Map();
|
|
3686
|
+
for (const model of s.models) {
|
|
3687
|
+
const result = yield* createModel({
|
|
3688
|
+
name: model.name,
|
|
3689
|
+
apiKey: model.apiKey,
|
|
3690
|
+
isBlock: model.isBlock,
|
|
3691
|
+
singleton: model.singleton,
|
|
3692
|
+
sortable: model.sortable,
|
|
3693
|
+
tree: model.tree,
|
|
3694
|
+
hasDraft: model.hasDraft,
|
|
3695
|
+
allLocalesRequired: false,
|
|
3696
|
+
ordering: model.ordering ?? void 0
|
|
3697
|
+
});
|
|
3698
|
+
modelApiKeyToId.set(model.apiKey, result.id);
|
|
3699
|
+
stats.models++;
|
|
3700
|
+
}
|
|
3701
|
+
for (const model of s.models) {
|
|
3702
|
+
const modelId = modelApiKeyToId.get(model.apiKey);
|
|
3703
|
+
if (modelId === void 0) return yield* new ValidationError({ message: `Model "${model.apiKey}" was not created — cannot attach fields` });
|
|
3704
|
+
for (const field of model.fields) {
|
|
3705
|
+
yield* createField(modelId, {
|
|
3706
|
+
label: field.label,
|
|
3707
|
+
apiKey: field.apiKey,
|
|
3708
|
+
fieldType: field.fieldType,
|
|
3709
|
+
position: field.position,
|
|
3710
|
+
localized: field.localized,
|
|
3711
|
+
validators: field.validators,
|
|
3712
|
+
hint: field.hint ?? void 0
|
|
3713
|
+
});
|
|
3714
|
+
stats.fields++;
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
return stats;
|
|
3718
|
+
});
|
|
3719
|
+
}
|
|
3720
|
+
//#endregion
|
|
3721
|
+
//#region src/services/token-service.ts
|
|
3722
|
+
function generateToken() {
|
|
3723
|
+
const bytes = new Uint8Array(24);
|
|
3724
|
+
crypto.getRandomValues(bytes);
|
|
3725
|
+
return `etk_${Array.from(bytes, (b) => b.toString(36).padStart(2, "0")).join("")}`;
|
|
3726
|
+
}
|
|
3727
|
+
function generateTokenId() {
|
|
3728
|
+
return `etid_${crypto.randomUUID()}`;
|
|
3729
|
+
}
|
|
3730
|
+
function getTokenPrefix(token) {
|
|
3731
|
+
return token.slice(0, 12);
|
|
3732
|
+
}
|
|
3733
|
+
function isLegacyStoredToken(row) {
|
|
3734
|
+
return row.secret_hash === null;
|
|
3735
|
+
}
|
|
3736
|
+
function hashToken(token) {
|
|
3737
|
+
return Effect.tryPromise({
|
|
3738
|
+
try: async () => {
|
|
3739
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
3740
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
3741
|
+
},
|
|
3742
|
+
catch: (cause) => new ValidationError({ message: `Failed to hash editor token: ${cause instanceof Error ? cause.message : String(cause)}` })
|
|
3743
|
+
});
|
|
3744
|
+
}
|
|
3745
|
+
function createEditorToken(input) {
|
|
3746
|
+
return Effect.gen(function* () {
|
|
3747
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3748
|
+
const token = generateToken();
|
|
3749
|
+
const id = generateTokenId();
|
|
3750
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3751
|
+
const expiresIn = input.expiresIn;
|
|
3752
|
+
if (expiresIn !== void 0 && expiresIn <= 0) return yield* new ValidationError({ message: "expiresIn must be a positive integer" });
|
|
3753
|
+
const expiresAt = expiresIn !== void 0 ? new Date(Date.now() + expiresIn * 1e3).toISOString() : null;
|
|
3754
|
+
const tokenPrefix = getTokenPrefix(token);
|
|
3755
|
+
const secretHash = yield* hashToken(token);
|
|
3756
|
+
yield* sql.unsafe(`INSERT INTO editor_tokens (id, name, token_prefix, secret_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
3757
|
+
id,
|
|
3758
|
+
input.name,
|
|
3759
|
+
tokenPrefix,
|
|
3760
|
+
secretHash,
|
|
3761
|
+
now,
|
|
3762
|
+
expiresAt
|
|
3763
|
+
]);
|
|
3764
|
+
return {
|
|
3765
|
+
id,
|
|
3766
|
+
token,
|
|
3767
|
+
tokenPrefix,
|
|
3768
|
+
name: input.name,
|
|
3769
|
+
createdAt: now,
|
|
3770
|
+
expiresAt
|
|
3771
|
+
};
|
|
3772
|
+
});
|
|
3773
|
+
}
|
|
3774
|
+
function listEditorTokens() {
|
|
3775
|
+
return Effect.gen(function* () {
|
|
3776
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3777
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3778
|
+
return yield* sql.unsafe(`SELECT id, name, token_prefix, created_at, last_used_at, expires_at
|
|
3779
|
+
FROM editor_tokens
|
|
3780
|
+
WHERE expires_at IS NULL OR expires_at > ?
|
|
3781
|
+
ORDER BY created_at DESC`, [now]);
|
|
3782
|
+
});
|
|
3783
|
+
}
|
|
3784
|
+
function revokeEditorToken(id) {
|
|
3785
|
+
return Effect.gen(function* () {
|
|
3786
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3787
|
+
if ((yield* sql.unsafe("SELECT id FROM editor_tokens WHERE id = ?", [id])).length === 0) return yield* new NotFoundError({
|
|
3788
|
+
entity: "EditorToken",
|
|
3789
|
+
id
|
|
3790
|
+
});
|
|
3791
|
+
yield* sql.unsafe("DELETE FROM editor_tokens WHERE id = ?", [id]);
|
|
3792
|
+
return { ok: true };
|
|
3793
|
+
});
|
|
3794
|
+
}
|
|
3795
|
+
function validateEditorToken(token) {
|
|
3796
|
+
return Effect.gen(function* () {
|
|
3797
|
+
const sql = yield* SqlClient.SqlClient;
|
|
3798
|
+
const secretHash = yield* hashToken(token);
|
|
3799
|
+
const hashedRows = yield* sql.unsafe("SELECT * FROM editor_tokens WHERE secret_hash = ?", [secretHash]);
|
|
3800
|
+
const legacyRows = hashedRows.length === 0 ? yield* sql.unsafe("SELECT * FROM editor_tokens WHERE id = ?", [token]) : [];
|
|
3801
|
+
if (hashedRows.length === 0 && legacyRows.length === 0) return yield* new UnauthorizedError({ message: "Invalid editor token" });
|
|
3802
|
+
const row = hashedRows[0] ?? legacyRows[0];
|
|
3803
|
+
if (row.expires_at && new Date(row.expires_at) < /* @__PURE__ */ new Date()) return yield* new UnauthorizedError({ message: "Editor token has expired" });
|
|
3804
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3805
|
+
yield* sql.unsafe("UPDATE editor_tokens SET last_used_at = ? WHERE id = ?", [now, row.id]);
|
|
3806
|
+
if (isLegacyStoredToken(row)) return {
|
|
3807
|
+
id: row.id,
|
|
3808
|
+
name: row.name,
|
|
3809
|
+
token_prefix: getTokenPrefix(token),
|
|
3810
|
+
created_at: row.created_at,
|
|
3811
|
+
last_used_at: now,
|
|
3812
|
+
expires_at: row.expires_at
|
|
3813
|
+
};
|
|
3814
|
+
return row;
|
|
3815
|
+
});
|
|
3816
|
+
}
|
|
3817
|
+
//#endregion
|
|
3818
|
+
export { listRecords as $, scheduleUnpublish as A, getAsset as B, SearchInput as C, clearSchedule as D, UpdateModelInput as E, removeBlockType as F, updateAssetMetadata as G, listAssets as H, removeLocale as I, publishRecord as J, bulkPublishRecords as K, AssetImportContext as L, deleteLocale as M, listLocales as N, runScheduledTransitions as O, removeBlockFromWhitelist as P, getRecord as Q, createAsset as R, SearchAssetsInput as S, UpdateFieldInput as T, replaceAsset as U, importAssetFromUrl as V, searchAssets as W, bulkCreateRecords as X, unpublishRecord as Y, createRecord as Z, PatchBlocksInput as _, updateModel as _t, exportSchema as a, getVersion as at, ReorderInput as b, VectorizeContext as bt, CreateAssetInput as c, HooksContext as ct, CreateLocaleInput as d, listFields as dt, patchBlocksForField as et, CreateModelInput as f, updateField as ft, ImportSchemaInput as g, listModels as gt, ImportAssetFromUrlInput as h, getModel as ht, validateEditorToken as i, updateSingletonRecord as it, createLocale as j, schedulePublish as k, CreateEditorTokenInput as l, createField as lt, CreateUploadUrlInput as m, deleteModel as mt, listEditorTokens as n, removeRecord as nt, importSchema as o, listVersions as ot, CreateRecordInput as p, createModel as pt, bulkUnpublishRecords as q, revokeEditorToken as r, reorderRecords as rt, BulkCreateRecordsInput as s, restoreVersion as st, createEditorToken as t, patchRecord as tt, CreateFieldInput as u, deleteField as ut, PatchRecordInput as v, reindexAll as vt, UpdateAssetMetadataInput as w, ScheduleRecordInput as x, ReindexSearchInput as y, search as yt, deleteAsset as z };
|
|
3819
|
+
|
|
3820
|
+
//# sourceMappingURL=token-service-BDjccMmz.mjs.map
|