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