@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,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