@thebes/cadmus 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/astro/index.cjs +149 -0
- package/dist/astro/index.cjs.map +1 -0
- package/dist/astro/index.d.cts +101 -0
- package/dist/astro/index.d.cts.map +1 -0
- package/dist/astro/index.d.ts +101 -0
- package/dist/astro/index.d.ts.map +1 -0
- package/dist/astro/index.js +146 -0
- package/dist/astro/index.js.map +1 -0
- package/dist/auth/index.cjs +59 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +14 -0
- package/dist/auth/index.d.cts.map +1 -0
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +54 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/cache/index.cjs +18 -0
- package/dist/cache/index.cjs.map +1 -0
- package/dist/cache/index.d.cts +10 -0
- package/dist/cache/index.d.cts.map +1 -0
- package/dist/cache/index.d.ts +10 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +17 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cms/index.cjs +763 -0
- package/dist/cms/index.cjs.map +1 -0
- package/dist/cms/index.d.cts +2 -0
- package/dist/cms/index.d.ts +2 -0
- package/dist/cms/index.js +743 -0
- package/dist/cms/index.js.map +1 -0
- package/dist/db/index.cjs +10 -0
- package/dist/db/index.cjs.map +1 -0
- package/dist/db/index.d.cts +7 -0
- package/dist/db/index.d.cts.map +1 -0
- package/dist/db/index.d.ts +7 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +9 -0
- package/dist/db/index.js.map +1 -0
- package/dist/email/index.cjs +25 -0
- package/dist/email/index.cjs.map +1 -0
- package/dist/email/index.d.cts +12 -0
- package/dist/email/index.d.cts.map +1 -0
- package/dist/email/index.d.ts +12 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +24 -0
- package/dist/email/index.js.map +1 -0
- package/dist/errors-CW6Lz0AQ.cjs +196 -0
- package/dist/errors-CW6Lz0AQ.cjs.map +1 -0
- package/dist/errors-mZIqZJO4.js +125 -0
- package/dist/errors-mZIqZJO4.js.map +1 -0
- package/dist/hono/index.cjs +132 -0
- package/dist/hono/index.cjs.map +1 -0
- package/dist/hono/index.d.cts +59 -0
- package/dist/hono/index.d.cts.map +1 -0
- package/dist/hono/index.d.ts +59 -0
- package/dist/hono/index.d.ts.map +1 -0
- package/dist/hono/index.js +130 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/index-BUrCSGVb.d.cts +616 -0
- package/dist/index-BUrCSGVb.d.cts.map +1 -0
- package/dist/index-BUrCSGVb.d.ts +616 -0
- package/dist/index-BUrCSGVb.d.ts.map +1 -0
- package/dist/index.cjs +60 -0
- package/dist/index.d.cts +107 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/queues/index.cjs +31 -0
- package/dist/queues/index.cjs.map +1 -0
- package/dist/queues/index.d.cts +22 -0
- package/dist/queues/index.d.cts.map +1 -0
- package/dist/queues/index.d.ts +22 -0
- package/dist/queues/index.d.ts.map +1 -0
- package/dist/queues/index.js +29 -0
- package/dist/queues/index.js.map +1 -0
- package/dist/rate-limit/index.cjs +38 -0
- package/dist/rate-limit/index.cjs.map +1 -0
- package/dist/rate-limit/index.d.cts +14 -0
- package/dist/rate-limit/index.d.cts.map +1 -0
- package/dist/rate-limit/index.d.ts +14 -0
- package/dist/rate-limit/index.d.ts.map +1 -0
- package/dist/rate-limit/index.js +37 -0
- package/dist/rate-limit/index.js.map +1 -0
- package/dist/session/index.cjs +48 -0
- package/dist/session/index.cjs.map +1 -0
- package/dist/session/index.d.cts +14 -0
- package/dist/session/index.d.cts.map +1 -0
- package/dist/session/index.d.ts +14 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +45 -0
- package/dist/session/index.js.map +1 -0
- package/dist/storage/index.cjs +29 -0
- package/dist/storage/index.cjs.map +1 -0
- package/dist/storage/index.d.cts +38 -0
- package/dist/storage/index.d.cts.map +1 -0
- package/dist/storage/index.d.ts +38 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +26 -0
- package/dist/storage/index.js.map +1 -0
- package/package.json +115 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_errors = require("../errors-CW6Lz0AQ.cjs");
|
|
3
|
+
const require_queues_index = require("../queues/index.cjs");
|
|
4
|
+
let drizzle_orm_sqlite_core = require("drizzle-orm/sqlite-core");
|
|
5
|
+
let drizzle_orm = require("drizzle-orm");
|
|
6
|
+
//#region src/cms/types.ts
|
|
7
|
+
/**
|
|
8
|
+
* Expands every `group` field in `fields` into its flattened equivalents
|
|
9
|
+
* (`<key>_<subKey>`, recursively — a group nested inside a group flattens
|
|
10
|
+
* all the way down), and passes every other field through unchanged. This
|
|
11
|
+
* is the single canonicalization step codegen, schema-gen, and the Local
|
|
12
|
+
* API's field-shape validation (`validateRequiredFields`/
|
|
13
|
+
* `rejectUnknownFields`) all run before touching `group` fields, so none of
|
|
14
|
+
* them need their own group-aware branch — see localApi.ts's `flattenDoc`/
|
|
15
|
+
* `nestDoc` for the matching document-level transform.
|
|
16
|
+
*
|
|
17
|
+
* Known limitation: a flattened key can collide if two different group
|
|
18
|
+
* nestings produce the same combined name (e.g. a group `a_b` containing
|
|
19
|
+
* field `c` collides with group `a` containing field `b_c`) — not guarded
|
|
20
|
+
* against, since no current collection nests groups deeply enough to hit
|
|
21
|
+
* it.
|
|
22
|
+
*/
|
|
23
|
+
function flattenFields(fields) {
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const [key, field] of Object.entries(fields)) if (field.type === "group") for (const [subKey, subField] of Object.entries(flattenFields(field.fields))) result[`${key}_${subKey}`] = subField;
|
|
26
|
+
else result[key] = field;
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The document-level counterpart to `flattenFields` — turns a `group`
|
|
31
|
+
* field's nested object value (`{ shippingAddress: { city: "..." } }`) into
|
|
32
|
+
* its flattened equivalent (`{ shippingAddress_city: "..." }`) for writing
|
|
33
|
+
* to the DB, recursively. Fields not present in `doc` are simply omitted
|
|
34
|
+
* from the result (lets `update()`'s partial inputs flatten correctly —
|
|
35
|
+
* an absent group means every one of its flattened keys is absent too, not
|
|
36
|
+
* `undefined`-valued). See `nestDoc` for the inverse, used on read.
|
|
37
|
+
*/
|
|
38
|
+
function flattenDoc(fields, doc) {
|
|
39
|
+
const result = {};
|
|
40
|
+
for (const [key, field] of Object.entries(fields)) if (field.type === "group") {
|
|
41
|
+
if (!(key in doc)) continue;
|
|
42
|
+
const nested = doc[key] ?? {};
|
|
43
|
+
for (const [subKey, subValue] of Object.entries(flattenDoc(field.fields, nested))) result[`${key}_${subKey}`] = subValue;
|
|
44
|
+
} else if (key in doc) result[key] = doc[key];
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The inverse of `flattenDoc` — re-nests a flat DB row's `<key>_<subKey>`
|
|
49
|
+
* columns back into `{ key: { subKey: ... } }` for everything the Local
|
|
50
|
+
* API returns to a caller, so a `group` field's document shape always
|
|
51
|
+
* matches its config shape regardless of how it's actually stored.
|
|
52
|
+
*/
|
|
53
|
+
function nestDoc(fields, flatRow) {
|
|
54
|
+
const result = {};
|
|
55
|
+
for (const [key, field] of Object.entries(fields)) if (field.type === "group") {
|
|
56
|
+
const prefix = `${key}_`;
|
|
57
|
+
const nestedFlat = {};
|
|
58
|
+
for (const [flatKey, value] of Object.entries(flatRow)) if (flatKey.startsWith(prefix)) nestedFlat[flatKey.slice(prefix.length)] = value;
|
|
59
|
+
result[key] = nestDoc(field.fields, nestedFlat);
|
|
60
|
+
} else if (key in flatRow) result[key] = flatRow[key];
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/cms/codegen.ts
|
|
65
|
+
function toSnakeCase$1(value) {
|
|
66
|
+
return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
67
|
+
}
|
|
68
|
+
function fieldToColumn(key, field) {
|
|
69
|
+
const columnName = field.name ?? toSnakeCase$1(key);
|
|
70
|
+
switch (field.type) {
|
|
71
|
+
case "text":
|
|
72
|
+
case "upload": {
|
|
73
|
+
let column = (0, drizzle_orm_sqlite_core.text)(columnName);
|
|
74
|
+
if (field.required) column = column.notNull();
|
|
75
|
+
if (field.unique) column = column.unique();
|
|
76
|
+
if (field.defaultValue !== void 0) column = column.default(field.defaultValue);
|
|
77
|
+
return column;
|
|
78
|
+
}
|
|
79
|
+
case "richText":
|
|
80
|
+
case "array":
|
|
81
|
+
case "json": {
|
|
82
|
+
let column = (0, drizzle_orm_sqlite_core.text)(columnName, { mode: "json" }).$type();
|
|
83
|
+
if (field.required) column = column.notNull();
|
|
84
|
+
if (field.defaultValue !== void 0) column = column.default(field.defaultValue);
|
|
85
|
+
return column;
|
|
86
|
+
}
|
|
87
|
+
case "relationship": {
|
|
88
|
+
let column = (0, drizzle_orm_sqlite_core.integer)(columnName);
|
|
89
|
+
if (field.required) column = column.notNull();
|
|
90
|
+
return column;
|
|
91
|
+
}
|
|
92
|
+
case "select": {
|
|
93
|
+
let column = (0, drizzle_orm_sqlite_core.text)(columnName, { enum: field.options });
|
|
94
|
+
if (field.required) column = column.notNull();
|
|
95
|
+
if (field.defaultValue !== void 0) column = column.default(field.defaultValue);
|
|
96
|
+
return column;
|
|
97
|
+
}
|
|
98
|
+
case "number": {
|
|
99
|
+
if (field.autoIncrement) return (0, drizzle_orm_sqlite_core.integer)(columnName).primaryKey({ autoIncrement: true });
|
|
100
|
+
let column = (0, drizzle_orm_sqlite_core.real)(columnName);
|
|
101
|
+
if (field.required) column = column.notNull();
|
|
102
|
+
if (field.defaultValue !== void 0) column = column.default(field.defaultValue);
|
|
103
|
+
return column;
|
|
104
|
+
}
|
|
105
|
+
case "date": {
|
|
106
|
+
let column = field.mode === "timestamp_ms" ? (0, drizzle_orm_sqlite_core.integer)(columnName, { mode: "timestamp_ms" }) : (0, drizzle_orm_sqlite_core.integer)(columnName, { mode: "timestamp" });
|
|
107
|
+
if (field.required) column = column.notNull();
|
|
108
|
+
if (field.defaultValue === "now") column = column.$defaultFn(() => /* @__PURE__ */ new Date());
|
|
109
|
+
else if (field.defaultValue instanceof Date) {
|
|
110
|
+
const defaultDate = field.defaultValue;
|
|
111
|
+
column = column.$defaultFn(() => defaultDate);
|
|
112
|
+
}
|
|
113
|
+
return column;
|
|
114
|
+
}
|
|
115
|
+
case "checkbox": {
|
|
116
|
+
let column = (0, drizzle_orm_sqlite_core.integer)(columnName, { mode: "boolean" });
|
|
117
|
+
if (field.required) column = column.notNull();
|
|
118
|
+
if (field.defaultValue !== void 0) column = column.default(field.defaultValue);
|
|
119
|
+
return column;
|
|
120
|
+
}
|
|
121
|
+
default: throw new require_errors.CadmusCmsError(`Field type "${field.type}" is not yet supported by cadmus/cms codegen`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function collectionToTable(config) {
|
|
125
|
+
const columns = {};
|
|
126
|
+
for (const [key, field] of Object.entries(flattenFields(config.fields))) {
|
|
127
|
+
if (field.type === "relationship" && field.hasMany) continue;
|
|
128
|
+
columns[key] = fieldToColumn(key, field);
|
|
129
|
+
}
|
|
130
|
+
if (config.versions?.drafts) columns.publishedVersionId = (0, drizzle_orm_sqlite_core.integer)("published_version_id");
|
|
131
|
+
return (0, drizzle_orm_sqlite_core.sqliteTable)(config.slug, columns);
|
|
132
|
+
}
|
|
133
|
+
function collectionVersionsTable(config) {
|
|
134
|
+
return (0, drizzle_orm_sqlite_core.sqliteTable)(`${config.slug}_versions`, {
|
|
135
|
+
id: (0, drizzle_orm_sqlite_core.integer)("id").primaryKey({ autoIncrement: true }),
|
|
136
|
+
parentId: (0, drizzle_orm_sqlite_core.integer)("parent_id").notNull(),
|
|
137
|
+
versionData: (0, drizzle_orm_sqlite_core.text)("version_data", { mode: "json" }).$type().notNull(),
|
|
138
|
+
status: (0, drizzle_orm_sqlite_core.text)("status", { enum: ["draft", "published"] }).notNull(),
|
|
139
|
+
createdAt: (0, drizzle_orm_sqlite_core.integer)("created_at", { mode: "timestamp" }).$defaultFn(() => /* @__PURE__ */ new Date())
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function relationshipJoinTables(config) {
|
|
143
|
+
const joinTables = {};
|
|
144
|
+
for (const [key, field] of Object.entries(config.fields)) {
|
|
145
|
+
if (field.type !== "relationship" || !field.hasMany) continue;
|
|
146
|
+
const tableName = `${config.slug}_${key}`;
|
|
147
|
+
const ownColumn = `${config.slug}_id`;
|
|
148
|
+
const relatedColumn = `${field.relationTo}_id`;
|
|
149
|
+
const columns = {};
|
|
150
|
+
columns[ownColumn] = (0, drizzle_orm_sqlite_core.integer)(ownColumn).notNull();
|
|
151
|
+
columns[relatedColumn] = (0, drizzle_orm_sqlite_core.integer)(relatedColumn).notNull();
|
|
152
|
+
joinTables[tableName] = (0, drizzle_orm_sqlite_core.sqliteTable)(tableName, columns);
|
|
153
|
+
}
|
|
154
|
+
return joinTables;
|
|
155
|
+
}
|
|
156
|
+
function collectionSearchTableName(config) {
|
|
157
|
+
return `${config.slug}_fts`;
|
|
158
|
+
}
|
|
159
|
+
function collectionSearchTableSQL(config) {
|
|
160
|
+
const fields = config.search?.fields ?? [];
|
|
161
|
+
if (fields.length === 0) return "";
|
|
162
|
+
const columns = fields.map((key) => `"${key}"`).join(", ");
|
|
163
|
+
return `CREATE VIRTUAL TABLE IF NOT EXISTS "${collectionSearchTableName(config)}" USING fts5(${columns});`;
|
|
164
|
+
}
|
|
165
|
+
function flattenRichText(value) {
|
|
166
|
+
if (value === null || value === void 0) return "";
|
|
167
|
+
if (typeof value === "string") return value;
|
|
168
|
+
if (typeof value !== "object") return String(value);
|
|
169
|
+
if (Array.isArray(value)) return value.map(flattenRichText).filter(Boolean).join(" ");
|
|
170
|
+
const node = value;
|
|
171
|
+
const parts = [];
|
|
172
|
+
if (typeof node.text === "string") parts.push(node.text);
|
|
173
|
+
if (Array.isArray(node.content)) parts.push(flattenRichText(node.content));
|
|
174
|
+
return parts.filter(Boolean).join(" ");
|
|
175
|
+
}
|
|
176
|
+
function extractSearchText(config, doc) {
|
|
177
|
+
return (config.search?.fields ?? []).map((key) => {
|
|
178
|
+
const field = config.fields[key];
|
|
179
|
+
const raw = doc[key];
|
|
180
|
+
if (field?.type === "richText") return flattenRichText(raw);
|
|
181
|
+
return typeof raw === "string" ? raw : "";
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function cmsConfigToSchema(config) {
|
|
185
|
+
const schema = {};
|
|
186
|
+
for (const collection of config.collections) {
|
|
187
|
+
schema[collection.slug] = collectionToTable(collection);
|
|
188
|
+
Object.assign(schema, relationshipJoinTables(collection));
|
|
189
|
+
if (collection.versions?.drafts) schema[`${collection.slug}_versions`] = collectionVersionsTable(collection);
|
|
190
|
+
}
|
|
191
|
+
return schema;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/cms/defineCollection.ts
|
|
195
|
+
const KNOWN_FIELD_TYPES = new Set([
|
|
196
|
+
"text",
|
|
197
|
+
"select",
|
|
198
|
+
"number",
|
|
199
|
+
"date",
|
|
200
|
+
"richText",
|
|
201
|
+
"checkbox",
|
|
202
|
+
"relationship",
|
|
203
|
+
"array",
|
|
204
|
+
"upload",
|
|
205
|
+
"json",
|
|
206
|
+
"group"
|
|
207
|
+
]);
|
|
208
|
+
function validateField(slug, key, field) {
|
|
209
|
+
if (!KNOWN_FIELD_TYPES.has(field.type)) throw new require_errors.CadmusCmsError(`Collection "${slug}" field "${key}" has unrecognized type "${field.type}"`);
|
|
210
|
+
if (field.type === "relationship" && !field.relationTo) throw new require_errors.CadmusCmsError(`Collection "${slug}" field "${key}" is a relationship field and requires "relationTo"`);
|
|
211
|
+
if (field.type === "array" && Object.keys(field.fields ?? {}).length === 0) throw new require_errors.CadmusCmsError(`Collection "${slug}" field "${key}" is an array field and must define at least one nested field`);
|
|
212
|
+
if (field.type === "group") {
|
|
213
|
+
const nestedEntries = Object.entries(field.fields ?? {});
|
|
214
|
+
if (nestedEntries.length === 0) throw new require_errors.CadmusCmsError(`Collection "${slug}" field "${key}" is a group field and must define at least one nested field`);
|
|
215
|
+
for (const [nestedKey, nestedField] of nestedEntries) validateField(slug, `${key}.${nestedKey}`, nestedField);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function validateCollectionConfig(config) {
|
|
219
|
+
if (!config.slug || config.slug.trim().length === 0) throw new require_errors.CadmusCmsError("Collection config requires a non-empty slug");
|
|
220
|
+
const fieldEntries = Object.entries(config.fields ?? {});
|
|
221
|
+
if (fieldEntries.length === 0) throw new require_errors.CadmusCmsError(`Collection "${config.slug}" must define at least one field`);
|
|
222
|
+
for (const [key, field] of fieldEntries) validateField(config.slug, key, field);
|
|
223
|
+
const SEARCHABLE_FIELD_TYPES = new Set([
|
|
224
|
+
"text",
|
|
225
|
+
"richText",
|
|
226
|
+
"upload"
|
|
227
|
+
]);
|
|
228
|
+
for (const key of config.search?.fields ?? []) {
|
|
229
|
+
const field = config.fields[key];
|
|
230
|
+
if (!field) throw new require_errors.CadmusCmsError(`Collection "${config.slug}" search.fields references unknown field "${key}"`);
|
|
231
|
+
if (!SEARCHABLE_FIELD_TYPES.has(field.type)) throw new require_errors.CadmusCmsError(`Collection "${config.slug}" search.fields field "${key}" has type "${field.type}" — only "text", "richText", and "upload" fields can be indexed`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function validateUniqueSlugs(collections) {
|
|
235
|
+
const seen = /* @__PURE__ */ new Set();
|
|
236
|
+
for (const collection of collections) {
|
|
237
|
+
if (seen.has(collection.slug)) throw new require_errors.CadmusCmsError(`Duplicate collection slug "${collection.slug}" — collection slugs must be unique`);
|
|
238
|
+
seen.add(collection.slug);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function defineCollection(config) {
|
|
242
|
+
validateCollectionConfig(config);
|
|
243
|
+
return config;
|
|
244
|
+
}
|
|
245
|
+
function defineCmsConfig(config) {
|
|
246
|
+
let resolved = config;
|
|
247
|
+
for (const plugin of config.plugins ?? []) resolved = plugin(resolved);
|
|
248
|
+
for (const collection of resolved.collections) validateCollectionConfig(collection);
|
|
249
|
+
validateUniqueSlugs(resolved.collections);
|
|
250
|
+
return resolved;
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/cms/localApi.ts
|
|
254
|
+
function validateRequiredFields(config, input) {
|
|
255
|
+
for (const [key, field] of Object.entries(flattenFields(config.fields))) {
|
|
256
|
+
const hasDefault = field.defaultValue !== void 0;
|
|
257
|
+
if (field.required && !hasDefault && input[key] === void 0) throw new require_errors.CadmusCmsError(`Missing required field "${key}" for collection "${config.slug}"`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function rejectUnknownFields(config, input) {
|
|
261
|
+
const flatFields = flattenFields(config.fields);
|
|
262
|
+
for (const key of Object.keys(input)) if (!(key in flatFields)) throw new require_errors.CadmusCmsError(`Unknown field "${key}" for collection "${config.slug}"`);
|
|
263
|
+
}
|
|
264
|
+
function wrapWriteError(config, error) {
|
|
265
|
+
if (error instanceof require_errors.CadmusCmsError) throw error;
|
|
266
|
+
if ((error instanceof Error ? error.message : String(error)).includes("UNIQUE constraint failed")) throw new require_errors.CadmusCmsError(`Unique constraint violated for collection "${config.slug}"`, error);
|
|
267
|
+
throw new require_errors.CadmusCmsError(`Write failed for collection "${config.slug}"`, error);
|
|
268
|
+
}
|
|
269
|
+
function notFound(config, id) {
|
|
270
|
+
throw new require_errors.CadmusCmsError(`No "${config.slug}" document found with id ${id}`);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Reads collection `slug`'s `LocalApi` out of `registry.apis` — the
|
|
274
|
+
* accessor hook factories should use (see `CmsRegistry`'s doc comment for
|
|
275
|
+
* the late-binding pattern this assumes) instead of indexing
|
|
276
|
+
* `registry.apis` directly, so every caller gets the same clear error if
|
|
277
|
+
* the registry wasn't built/populated correctly. `TContext` is a type-only
|
|
278
|
+
* parameter (the registry itself is stored with `never` to stay variance-
|
|
279
|
+
* safe across collections with different context shapes) — callers assert
|
|
280
|
+
* the context type they expect, the same way `resolveRelationships`'s own
|
|
281
|
+
* registry lookups do.
|
|
282
|
+
*/
|
|
283
|
+
function getRegisteredApi(registry, slug) {
|
|
284
|
+
const api = registry?.apis?.[slug];
|
|
285
|
+
if (!api) throw new require_errors.CadmusCmsError(`No LocalApi registered for collection "${slug}" — pass a CmsRegistry whose "apis" map has been populated with every collection a hook needs to reach (see CmsRegistry's doc comment for the late-binding build order)`);
|
|
286
|
+
return api;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Batch-resolves this collection's `hasMany: false` relationship fields
|
|
290
|
+
* for an already-fetched page of `rows`, one query per relationship field
|
|
291
|
+
* (not one query per row — the N+1 the `depth: 1` design note in types.ts
|
|
292
|
+
* calls out avoiding). The related collection's `read` access fn is run
|
|
293
|
+
* once per field against `context`, not once per row: there's a single
|
|
294
|
+
* yes/no for "can this context read collection X", not a row-by-row
|
|
295
|
+
* filter. When it rejects, the field is left as the bare id rather than
|
|
296
|
+
* throwing — a denied relationship is an omission, not a failed request.
|
|
297
|
+
* `hasMany: true` relationship fields are untouched (no column on this
|
|
298
|
+
* table to resolve from — they live in a join table, out of scope here).
|
|
299
|
+
*/
|
|
300
|
+
async function resolveRelationships(db, config, rows, context, registry) {
|
|
301
|
+
const relationshipFields = Object.entries(config.fields).filter(([, field]) => field.type === "relationship" && !field.hasMany);
|
|
302
|
+
if (relationshipFields.length === 0) return rows;
|
|
303
|
+
if (!registry) throw new require_errors.CadmusCmsError(`Collection "${config.slug}" requested depth: 1 but createLocalApi was not given a registry to resolve relationship fields against`);
|
|
304
|
+
let result = rows;
|
|
305
|
+
for (const [key, field] of relationshipFields) {
|
|
306
|
+
const relationTo = field.relationTo;
|
|
307
|
+
const relatedConfig = registry.configs[relationTo];
|
|
308
|
+
const relatedTable = registry.tables[relationTo];
|
|
309
|
+
if (!relatedConfig || !relatedTable) throw new require_errors.CadmusCmsError(`Collection "${config.slug}" field "${key}" relates to unknown collection "${relationTo}" — not present in the registry`);
|
|
310
|
+
const readFn = relatedConfig.access?.read;
|
|
311
|
+
if (!(readFn ? await readFn(context) : true)) continue;
|
|
312
|
+
const ids = [...new Set(result.map((row) => row[key]).filter((id) => typeof id === "number"))];
|
|
313
|
+
if (ids.length === 0) continue;
|
|
314
|
+
const relatedRows = await db.select().from(relatedTable).where((0, drizzle_orm.inArray)(relatedTable.id, ids));
|
|
315
|
+
const byId = new Map(relatedRows.map((row) => [row.id, row]));
|
|
316
|
+
result = result.map((row) => {
|
|
317
|
+
const id = row[key];
|
|
318
|
+
const related = typeof id === "number" ? byId.get(id) : void 0;
|
|
319
|
+
return related ? {
|
|
320
|
+
...row,
|
|
321
|
+
[key]: related
|
|
322
|
+
} : row;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Non-throwing counterpart to `checkAccess` below, for UI code that wants
|
|
329
|
+
* to hide/disable an action a context can't perform rather than let it
|
|
330
|
+
* fail server-side after a click (see Phase 6 / issue #26's
|
|
331
|
+
* `getPageCapabilities`). `checkAccess` calls through this same function
|
|
332
|
+
* rather than duplicating the "no access fn = allowed" logic, so `can()`'s
|
|
333
|
+
* answer and the real operation's enforcement can never disagree.
|
|
334
|
+
*/
|
|
335
|
+
async function can(config, operation, context) {
|
|
336
|
+
const fn = config.access?.[operation];
|
|
337
|
+
if (!fn) return true;
|
|
338
|
+
return await fn(context);
|
|
339
|
+
}
|
|
340
|
+
async function checkAccess(config, operation, context) {
|
|
341
|
+
if (await can(config, operation, context)) return;
|
|
342
|
+
throw new require_errors.CadmusAccessDeniedError(`Access denied for "${operation}" on collection "${config.slug}"`);
|
|
343
|
+
}
|
|
344
|
+
async function runBeforeChange(config, data) {
|
|
345
|
+
let result = data;
|
|
346
|
+
for (const hook of config.hooks?.beforeChange ?? []) result = await hook({ data: result });
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
async function runAfterChange(config, doc, operation) {
|
|
350
|
+
for (const hook of config.hooks?.afterChange ?? []) await hook({
|
|
351
|
+
doc,
|
|
352
|
+
operation
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
async function runReadHooks(config, doc) {
|
|
356
|
+
let result = doc;
|
|
357
|
+
for (const hook of config.hooks?.beforeRead ?? []) result = await hook({ doc: result });
|
|
358
|
+
for (const hook of config.hooks?.afterRead ?? []) result = await hook({ doc: result });
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
function hasReadHooks(config) {
|
|
362
|
+
return Boolean(config.hooks?.beforeRead?.length || config.hooks?.afterRead?.length);
|
|
363
|
+
}
|
|
364
|
+
async function runBeforeDelete(config, id) {
|
|
365
|
+
for (const hook of config.hooks?.beforeDelete ?? []) await hook({ id });
|
|
366
|
+
}
|
|
367
|
+
async function runAfterDelete(config, id) {
|
|
368
|
+
for (const hook of config.hooks?.afterDelete ?? []) await hook({ id });
|
|
369
|
+
}
|
|
370
|
+
async function syncSearchIndex(db, config, doc) {
|
|
371
|
+
const fields = config.search?.fields;
|
|
372
|
+
if (!fields?.length) return;
|
|
373
|
+
const id = doc.id;
|
|
374
|
+
if (typeof id !== "number") return;
|
|
375
|
+
const fts = drizzle_orm.sql.identifier(collectionSearchTableName(config));
|
|
376
|
+
const columnList = drizzle_orm.sql.join(fields.map((key) => drizzle_orm.sql.identifier(key)), drizzle_orm.sql.raw(", "));
|
|
377
|
+
const values = extractSearchText(config, doc);
|
|
378
|
+
const valueList = drizzle_orm.sql.join(values.map((value) => drizzle_orm.sql`${value}`), drizzle_orm.sql.raw(", "));
|
|
379
|
+
await db.run(drizzle_orm.sql`DELETE FROM ${fts} WHERE rowid = ${id}`);
|
|
380
|
+
await db.run(drizzle_orm.sql`INSERT INTO ${fts} (rowid, ${columnList}) VALUES (${id}, ${valueList})`);
|
|
381
|
+
}
|
|
382
|
+
async function removeFromSearchIndex(db, config, id) {
|
|
383
|
+
if (!config.search?.fields.length) return;
|
|
384
|
+
const fts = drizzle_orm.sql.identifier(collectionSearchTableName(config));
|
|
385
|
+
await db.run(drizzle_orm.sql`DELETE FROM ${fts} WHERE rowid = ${id}`);
|
|
386
|
+
}
|
|
387
|
+
function createLocalApi(db, table, config, registry) {
|
|
388
|
+
const idColumn = table.id;
|
|
389
|
+
const hasGroupFields = Object.values(config.fields).some((field) => field.type === "group");
|
|
390
|
+
const toFlatDoc = (doc) => hasGroupFields ? flattenDoc(config.fields, doc) : doc;
|
|
391
|
+
const toNestedDoc = (row) => hasGroupFields ? nestDoc(config.fields, row) : row;
|
|
392
|
+
return {
|
|
393
|
+
async find(context, options) {
|
|
394
|
+
await checkAccess(config, "read", context);
|
|
395
|
+
if (options?.depth !== void 0 && options.depth !== 0 && options.depth !== 1) throw new require_errors.CadmusCmsError(`Relationship resolution depth ${options.depth} is not supported for collection "${config.slug}" (only 0 and 1 are)`);
|
|
396
|
+
let query = db.select().from(table).where(options?.where).$dynamic();
|
|
397
|
+
if (options?.orderBy !== void 0) query = query.orderBy(...Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]);
|
|
398
|
+
if (options?.limit !== void 0) query = query.limit(options.limit);
|
|
399
|
+
if (options?.offset !== void 0) query = query.offset(options.offset);
|
|
400
|
+
const nestedRows = (await query).map((row) => toNestedDoc(row));
|
|
401
|
+
const afterHooks = hasReadHooks(config) ? await Promise.all(nestedRows.map((row) => runReadHooks(config, row))) : nestedRows;
|
|
402
|
+
return options?.depth === 1 ? await resolveRelationships(db, config, afterHooks, context, registry) : afterHooks;
|
|
403
|
+
},
|
|
404
|
+
async count(context, options) {
|
|
405
|
+
await checkAccess(config, "read", context);
|
|
406
|
+
const [row] = await db.select({ value: (0, drizzle_orm.count)() }).from(table).where(options?.where);
|
|
407
|
+
return row?.value ?? 0;
|
|
408
|
+
},
|
|
409
|
+
async findByID(context, id, options) {
|
|
410
|
+
await checkAccess(config, "read", context);
|
|
411
|
+
if (options?.depth !== void 0 && options.depth !== 0 && options.depth !== 1) throw new require_errors.CadmusCmsError(`Relationship resolution depth ${options.depth} is not supported for collection "${config.slug}" (only 0 and 1 are)`);
|
|
412
|
+
const [row] = await db.select().from(table).where((0, drizzle_orm.eq)(idColumn, id));
|
|
413
|
+
if (!row) notFound(config, id);
|
|
414
|
+
const nestedRow = toNestedDoc(row);
|
|
415
|
+
const afterHooks = hasReadHooks(config) ? await runReadHooks(config, nestedRow) : nestedRow;
|
|
416
|
+
return options?.depth === 1 ? (await resolveRelationships(db, config, [afterHooks], context, registry))[0] : afterHooks;
|
|
417
|
+
},
|
|
418
|
+
async search(context, query, options) {
|
|
419
|
+
await checkAccess(config, "read", context);
|
|
420
|
+
if (!config.search?.fields.length) throw new require_errors.CadmusCmsError(`Collection "${config.slug}" has no "search" config — cannot run search()`);
|
|
421
|
+
const fts = drizzle_orm.sql.identifier(collectionSearchTableName(config));
|
|
422
|
+
const limit = options?.limit ?? 20;
|
|
423
|
+
return (await db.all(drizzle_orm.sql`
|
|
424
|
+
SELECT ${table}.* FROM ${fts}
|
|
425
|
+
JOIN ${table} ON ${idColumn} = ${fts}.rowid
|
|
426
|
+
WHERE ${fts} MATCH ${query}
|
|
427
|
+
ORDER BY rank
|
|
428
|
+
LIMIT ${limit}
|
|
429
|
+
`)).map((row) => toNestedDoc(row));
|
|
430
|
+
},
|
|
431
|
+
async create(context, input) {
|
|
432
|
+
await checkAccess(config, "create", context);
|
|
433
|
+
const flatData = toFlatDoc(await runBeforeChange(config, input));
|
|
434
|
+
validateRequiredFields(config, flatData);
|
|
435
|
+
rejectUnknownFields(config, flatData);
|
|
436
|
+
let row;
|
|
437
|
+
try {
|
|
438
|
+
const [inserted] = await db.insert(table).values(flatData).returning();
|
|
439
|
+
row = inserted;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
wrapWriteError(config, error);
|
|
442
|
+
}
|
|
443
|
+
const doc = toNestedDoc(row);
|
|
444
|
+
await syncSearchIndex(db, config, doc);
|
|
445
|
+
await runAfterChange(config, doc, "create");
|
|
446
|
+
return doc;
|
|
447
|
+
},
|
|
448
|
+
async update(context, id, input) {
|
|
449
|
+
await checkAccess(config, "update", context);
|
|
450
|
+
const flatData = toFlatDoc(await runBeforeChange(config, input));
|
|
451
|
+
rejectUnknownFields(config, flatData);
|
|
452
|
+
let row;
|
|
453
|
+
try {
|
|
454
|
+
const [updated] = await db.update(table).set(flatData).where((0, drizzle_orm.eq)(idColumn, id)).returning();
|
|
455
|
+
if (!updated) notFound(config, id);
|
|
456
|
+
row = updated;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
wrapWriteError(config, error);
|
|
459
|
+
}
|
|
460
|
+
const doc = toNestedDoc(row);
|
|
461
|
+
await syncSearchIndex(db, config, doc);
|
|
462
|
+
await runAfterChange(config, doc, "update");
|
|
463
|
+
return doc;
|
|
464
|
+
},
|
|
465
|
+
async deleteByID(context, id) {
|
|
466
|
+
await checkAccess(config, "delete", context);
|
|
467
|
+
await runBeforeDelete(config, id);
|
|
468
|
+
const [rawRow] = await db.delete(table).where((0, drizzle_orm.eq)(idColumn, id)).returning();
|
|
469
|
+
if (!rawRow) notFound(config, id);
|
|
470
|
+
const row = toNestedDoc(rawRow);
|
|
471
|
+
await removeFromSearchIndex(db, config, id);
|
|
472
|
+
await runAfterDelete(config, id);
|
|
473
|
+
return row;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function notFoundVersion(config, id) {
|
|
478
|
+
throw new require_errors.CadmusCmsError(`No "${config.slug}" version found with id ${id}`);
|
|
479
|
+
}
|
|
480
|
+
function createVersionedLocalApi(db, table, versionsTable, config, registry) {
|
|
481
|
+
const base = createLocalApi(db, table, config, registry);
|
|
482
|
+
const idColumn = table.id;
|
|
483
|
+
const versionsIdColumn = versionsTable.id;
|
|
484
|
+
const versionsParentIdColumn = versionsTable.parentId;
|
|
485
|
+
return {
|
|
486
|
+
...base,
|
|
487
|
+
async findVersions(context, parentId) {
|
|
488
|
+
await checkAccess(config, "read", context);
|
|
489
|
+
return await db.select().from(versionsTable).where((0, drizzle_orm.eq)(versionsParentIdColumn, parentId)).orderBy((0, drizzle_orm.desc)(versionsIdColumn));
|
|
490
|
+
},
|
|
491
|
+
async saveDraft(context, id, input) {
|
|
492
|
+
await checkAccess(config, "update", context);
|
|
493
|
+
const [parent] = await db.select().from(table).where((0, drizzle_orm.eq)(idColumn, id));
|
|
494
|
+
if (!parent) notFound(config, id);
|
|
495
|
+
const data = await runBeforeChange(config, input);
|
|
496
|
+
rejectUnknownFields(config, data);
|
|
497
|
+
const insertValues = {
|
|
498
|
+
parentId: id,
|
|
499
|
+
versionData: data,
|
|
500
|
+
status: "draft"
|
|
501
|
+
};
|
|
502
|
+
const [row] = await db.insert(versionsTable).values(insertValues).returning();
|
|
503
|
+
return row;
|
|
504
|
+
},
|
|
505
|
+
async publish(context, versionId) {
|
|
506
|
+
await checkAccess(config, "publish", context);
|
|
507
|
+
const [version] = await db.select().from(versionsTable).where((0, drizzle_orm.eq)(versionsIdColumn, versionId));
|
|
508
|
+
if (!version) notFoundVersion(config, versionId);
|
|
509
|
+
const versionRecord = version;
|
|
510
|
+
const data = await runBeforeChange(config, versionRecord.versionData);
|
|
511
|
+
validateRequiredFields(config, data);
|
|
512
|
+
rejectUnknownFields(config, data);
|
|
513
|
+
const parentId = versionRecord.parentId;
|
|
514
|
+
let doc;
|
|
515
|
+
try {
|
|
516
|
+
const [row] = await db.update(table).set({
|
|
517
|
+
...data,
|
|
518
|
+
publishedVersionId: versionId
|
|
519
|
+
}).where((0, drizzle_orm.eq)(idColumn, parentId)).returning();
|
|
520
|
+
if (!row) notFound(config, parentId);
|
|
521
|
+
doc = row;
|
|
522
|
+
} catch (error) {
|
|
523
|
+
wrapWriteError(config, error);
|
|
524
|
+
}
|
|
525
|
+
await db.update(versionsTable).set({ status: "published" }).where((0, drizzle_orm.eq)(versionsIdColumn, versionId));
|
|
526
|
+
await syncSearchIndex(db, config, doc);
|
|
527
|
+
await runAfterChange(config, doc, "update");
|
|
528
|
+
return doc;
|
|
529
|
+
},
|
|
530
|
+
async unpublish(context, id) {
|
|
531
|
+
await checkAccess(config, "publish", context);
|
|
532
|
+
const [row] = await db.update(table).set({ publishedVersionId: null }).where((0, drizzle_orm.eq)(idColumn, id)).returning();
|
|
533
|
+
if (!row) notFound(config, id);
|
|
534
|
+
return row;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region src/cms/meta.ts
|
|
540
|
+
function getCollectionsMeta(config) {
|
|
541
|
+
return config.collections.map((collection) => ({
|
|
542
|
+
slug: collection.slug,
|
|
543
|
+
fields: collection.fields,
|
|
544
|
+
searchable: Boolean(collection.search?.fields.length)
|
|
545
|
+
}));
|
|
546
|
+
}
|
|
547
|
+
//#endregion
|
|
548
|
+
//#region src/cms/schema-gen.ts
|
|
549
|
+
function toSnakeCase(value) {
|
|
550
|
+
return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
551
|
+
}
|
|
552
|
+
function quote(value) {
|
|
553
|
+
return JSON.stringify(value);
|
|
554
|
+
}
|
|
555
|
+
function fieldToColumnSource(key, field, usedBuilders) {
|
|
556
|
+
const columnName = field.name ?? toSnakeCase(key);
|
|
557
|
+
switch (field.type) {
|
|
558
|
+
case "text":
|
|
559
|
+
case "upload": {
|
|
560
|
+
usedBuilders.add("text");
|
|
561
|
+
let source = `text(${quote(columnName)})`;
|
|
562
|
+
if (field.required) source += ".notNull()";
|
|
563
|
+
if (field.unique) source += ".unique()";
|
|
564
|
+
if (field.defaultValue !== void 0) source += `.default(${quote(field.defaultValue)})`;
|
|
565
|
+
return source;
|
|
566
|
+
}
|
|
567
|
+
case "richText":
|
|
568
|
+
case "array":
|
|
569
|
+
case "json": {
|
|
570
|
+
usedBuilders.add("text");
|
|
571
|
+
let source = `text(${quote(columnName)}, { mode: "json" }).$type<JsonValue>()`;
|
|
572
|
+
if (field.required) source += ".notNull()";
|
|
573
|
+
if (field.defaultValue !== void 0) source += `.default(${JSON.stringify(field.defaultValue)})`;
|
|
574
|
+
return source;
|
|
575
|
+
}
|
|
576
|
+
case "relationship": {
|
|
577
|
+
usedBuilders.add("integer");
|
|
578
|
+
let source = `integer(${quote(columnName)})`;
|
|
579
|
+
if (field.required) source += ".notNull()";
|
|
580
|
+
return source;
|
|
581
|
+
}
|
|
582
|
+
case "select": {
|
|
583
|
+
usedBuilders.add("text");
|
|
584
|
+
const options = field.options.map(quote).join(", ");
|
|
585
|
+
let source = `text(${quote(columnName)}, { enum: [${options}] })`;
|
|
586
|
+
if (field.required) source += ".notNull()";
|
|
587
|
+
if (field.defaultValue !== void 0) source += `.default(${quote(field.defaultValue)})`;
|
|
588
|
+
return source;
|
|
589
|
+
}
|
|
590
|
+
case "number": {
|
|
591
|
+
if (field.autoIncrement) {
|
|
592
|
+
usedBuilders.add("integer");
|
|
593
|
+
return `integer(${quote(columnName)}).primaryKey({ autoIncrement: true })`;
|
|
594
|
+
}
|
|
595
|
+
usedBuilders.add("real");
|
|
596
|
+
let source = `real(${quote(columnName)})`;
|
|
597
|
+
if (field.required) source += ".notNull()";
|
|
598
|
+
if (field.defaultValue !== void 0) source += `.default(${field.defaultValue})`;
|
|
599
|
+
return source;
|
|
600
|
+
}
|
|
601
|
+
case "date": {
|
|
602
|
+
usedBuilders.add("integer");
|
|
603
|
+
const mode = field.mode === "timestamp_ms" ? "timestamp_ms" : "timestamp";
|
|
604
|
+
let source = `integer(${quote(columnName)}, { mode: ${quote(mode)} })`;
|
|
605
|
+
if (field.required) source += ".notNull()";
|
|
606
|
+
if (field.defaultValue === "now") source += ".$defaultFn(() => new Date())";
|
|
607
|
+
else if (field.defaultValue instanceof Date) source += `.$defaultFn(() => new Date(${field.defaultValue.getTime()}))`;
|
|
608
|
+
return source;
|
|
609
|
+
}
|
|
610
|
+
default: throw new require_errors.CadmusCmsError(`Field type "${field.type}" is not yet supported by cadmus/cms schema-gen`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function collectionToTableSource(config, usedBuilders) {
|
|
614
|
+
const fieldLines = Object.entries(flattenFields(config.fields)).filter(([, field]) => !(field.type === "relationship" && field.hasMany)).map(([key, field]) => ` ${key}: ${fieldToColumnSource(key, field, usedBuilders)},`);
|
|
615
|
+
if (config.versions?.drafts) {
|
|
616
|
+
usedBuilders.add("integer");
|
|
617
|
+
fieldLines.push(" publishedVersionId: integer(\"published_version_id\"),");
|
|
618
|
+
}
|
|
619
|
+
return `export const ${config.slug} = sqliteTable(${quote(config.slug)}, {\n${fieldLines.join("\n")}\n});`;
|
|
620
|
+
}
|
|
621
|
+
function versionsTableSource(config, usedBuilders) {
|
|
622
|
+
usedBuilders.add("integer");
|
|
623
|
+
usedBuilders.add("text");
|
|
624
|
+
const tableName = `${config.slug}_versions`;
|
|
625
|
+
return `export const ${tableName} = sqliteTable(${quote(tableName)}, {\n id: integer("id").primaryKey({ autoIncrement: true }),
|
|
626
|
+
parentId: integer("parent_id").notNull(),
|
|
627
|
+
versionData: text("version_data", { mode: "json" }).\$type<JsonValue>().notNull(),
|
|
628
|
+
status: text("status", { enum: ["draft", "published"] }).notNull(),
|
|
629
|
+
createdAt: integer("created_at", { mode: "timestamp" }).\$defaultFn(() => new Date()),
|
|
630
|
+
});`;
|
|
631
|
+
}
|
|
632
|
+
function relationshipJoinTableSources(config, usedBuilders) {
|
|
633
|
+
const blocks = [];
|
|
634
|
+
for (const [key, field] of Object.entries(config.fields)) {
|
|
635
|
+
if (field.type !== "relationship" || !field.hasMany) continue;
|
|
636
|
+
usedBuilders.add("integer");
|
|
637
|
+
const tableName = `${config.slug}_${key}`;
|
|
638
|
+
const ownColumn = `${config.slug}_id`;
|
|
639
|
+
const relatedColumn = `${field.relationTo}_id`;
|
|
640
|
+
blocks.push(`export const ${tableName} = sqliteTable(${quote(tableName)}, {\n ${ownColumn}: integer(${quote(ownColumn)}).notNull(),\n ${relatedColumn}: integer(${quote(relatedColumn)}).notNull(),\n});`);
|
|
641
|
+
}
|
|
642
|
+
return blocks;
|
|
643
|
+
}
|
|
644
|
+
function generateSchemaSource(config) {
|
|
645
|
+
const usedBuilders = new Set(["sqliteTable"]);
|
|
646
|
+
const blocks = config.collections.flatMap((collection) => [
|
|
647
|
+
collectionToTableSource(collection, usedBuilders),
|
|
648
|
+
...relationshipJoinTableSources(collection, usedBuilders),
|
|
649
|
+
...collection.versions?.drafts ? [versionsTableSource(collection, usedBuilders)] : []
|
|
650
|
+
]);
|
|
651
|
+
const importList = [...usedBuilders].sort().join(", ");
|
|
652
|
+
return [
|
|
653
|
+
"// Generated by @thebes/cadmus/cms — do not hand-edit.",
|
|
654
|
+
"// Source: this app's CmsConfig (see defineCmsConfig).",
|
|
655
|
+
...blocks.some((block) => block.includes(".$type<JsonValue>()")) ? ["import type { JsonValue } from \"@thebes/cadmus/cms\";"] : [],
|
|
656
|
+
`import { ${importList} } from "drizzle-orm/sqlite-core";`,
|
|
657
|
+
"",
|
|
658
|
+
blocks.join("\n\n"),
|
|
659
|
+
""
|
|
660
|
+
].join("\n");
|
|
661
|
+
}
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/cms/webhooks.ts
|
|
664
|
+
/**
|
|
665
|
+
* Builds an `afterChange` hook that enqueues a `WebhookMessage` for every
|
|
666
|
+
* matching write — append the result to a collection's
|
|
667
|
+
* `hooks.afterChange` array. `queue` is whatever `Queue<WebhookMessage>`
|
|
668
|
+
* binding the caller's Worker has configured for webhook dispatch (see
|
|
669
|
+
* wrangler.jsonc's webhook queue producer binding).
|
|
670
|
+
*/
|
|
671
|
+
function createWebhookHook(queue, config) {
|
|
672
|
+
return async ({ doc, operation }) => {
|
|
673
|
+
if (config.events && !config.events.includes(operation)) return;
|
|
674
|
+
await require_queues_index.enqueue(queue, {
|
|
675
|
+
url: config.url,
|
|
676
|
+
secret: config.secret,
|
|
677
|
+
event: operation,
|
|
678
|
+
doc,
|
|
679
|
+
timestamp: Date.now()
|
|
680
|
+
});
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const BLOCKED_HOSTNAME_PATTERNS = [
|
|
684
|
+
/^localhost$/i,
|
|
685
|
+
/^127\./,
|
|
686
|
+
/^0\.0\.0\.0$/,
|
|
687
|
+
/^169\.254\./,
|
|
688
|
+
/^10\./,
|
|
689
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
690
|
+
/^192\.168\./,
|
|
691
|
+
/^\[?::1\]?$/,
|
|
692
|
+
/^\[?fc/i,
|
|
693
|
+
/^\[?fd/i,
|
|
694
|
+
/^\[?fe80/i
|
|
695
|
+
];
|
|
696
|
+
function isAllowedWebhookUrl(url) {
|
|
697
|
+
let parsed;
|
|
698
|
+
try {
|
|
699
|
+
parsed = new URL(url);
|
|
700
|
+
} catch {
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
|
704
|
+
return !BLOCKED_HOSTNAME_PATTERNS.some((pattern) => pattern.test(parsed.hostname));
|
|
705
|
+
}
|
|
706
|
+
async function hmacSha256Hex(payload, secret) {
|
|
707
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
708
|
+
name: "HMAC",
|
|
709
|
+
hash: "SHA-256"
|
|
710
|
+
}, false, ["sign"]);
|
|
711
|
+
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
|
712
|
+
return Array.from(new Uint8Array(signature), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Delivers a single `WebhookMessage` via `fetch()`. Throws
|
|
716
|
+
* `CadmusQueueError` on any non-2xx response or network failure — meant
|
|
717
|
+
* to be called from inside `processBatch`'s handler, where a thrown error
|
|
718
|
+
* becomes a `message.retry()`.
|
|
719
|
+
*/
|
|
720
|
+
async function deliverWebhookMessage(message) {
|
|
721
|
+
if (!isAllowedWebhookUrl(message.url)) throw new require_errors.CadmusQueueError(`Webhook URL "${message.url}" is not allowed (must be http(s) and not target a private/reserved/loopback address)`);
|
|
722
|
+
const body = JSON.stringify({
|
|
723
|
+
event: message.event,
|
|
724
|
+
doc: message.doc,
|
|
725
|
+
timestamp: message.timestamp
|
|
726
|
+
});
|
|
727
|
+
const headers = { "Content-Type": "application/json" };
|
|
728
|
+
if (message.secret) headers["X-Cadmus-Signature"] = await hmacSha256Hex(body, message.secret);
|
|
729
|
+
let response;
|
|
730
|
+
try {
|
|
731
|
+
response = await fetch(message.url, {
|
|
732
|
+
method: "POST",
|
|
733
|
+
headers,
|
|
734
|
+
body
|
|
735
|
+
});
|
|
736
|
+
} catch (cause) {
|
|
737
|
+
throw new require_errors.CadmusQueueError(`Webhook delivery to "${message.url}" failed`, cause);
|
|
738
|
+
}
|
|
739
|
+
if (!response.ok) throw new require_errors.CadmusQueueError(`Webhook delivery to "${message.url}" returned status ${response.status}`);
|
|
740
|
+
}
|
|
741
|
+
//#endregion
|
|
742
|
+
exports.can = can;
|
|
743
|
+
exports.cmsConfigToSchema = cmsConfigToSchema;
|
|
744
|
+
exports.collectionSearchTableName = collectionSearchTableName;
|
|
745
|
+
exports.collectionSearchTableSQL = collectionSearchTableSQL;
|
|
746
|
+
exports.collectionToTable = collectionToTable;
|
|
747
|
+
exports.collectionVersionsTable = collectionVersionsTable;
|
|
748
|
+
exports.createLocalApi = createLocalApi;
|
|
749
|
+
exports.createVersionedLocalApi = createVersionedLocalApi;
|
|
750
|
+
exports.createWebhookHook = createWebhookHook;
|
|
751
|
+
exports.defineCmsConfig = defineCmsConfig;
|
|
752
|
+
exports.defineCollection = defineCollection;
|
|
753
|
+
exports.deliverWebhookMessage = deliverWebhookMessage;
|
|
754
|
+
exports.extractSearchText = extractSearchText;
|
|
755
|
+
exports.flattenDoc = flattenDoc;
|
|
756
|
+
exports.flattenFields = flattenFields;
|
|
757
|
+
exports.generateSchemaSource = generateSchemaSource;
|
|
758
|
+
exports.getCollectionsMeta = getCollectionsMeta;
|
|
759
|
+
exports.getRegisteredApi = getRegisteredApi;
|
|
760
|
+
exports.nestDoc = nestDoc;
|
|
761
|
+
exports.relationshipJoinTables = relationshipJoinTables;
|
|
762
|
+
|
|
763
|
+
//# sourceMappingURL=index.cjs.map
|