dineway 0.1.3

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 (96) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +89 -0
  3. package/dist/adapters-BlzWJG82.d.mts +106 -0
  4. package/dist/apply-CAPvMfoU.mjs +1339 -0
  5. package/dist/astro/index.d.mts +50 -0
  6. package/dist/astro/index.mjs +1326 -0
  7. package/dist/astro/middleware/auth.d.mts +30 -0
  8. package/dist/astro/middleware/auth.mjs +708 -0
  9. package/dist/astro/middleware/redirect.d.mts +21 -0
  10. package/dist/astro/middleware/redirect.mjs +62 -0
  11. package/dist/astro/middleware/request-context.d.mts +17 -0
  12. package/dist/astro/middleware/request-context.mjs +1371 -0
  13. package/dist/astro/middleware/setup.d.mts +19 -0
  14. package/dist/astro/middleware/setup.mjs +46 -0
  15. package/dist/astro/middleware.d.mts +12 -0
  16. package/dist/astro/middleware.mjs +1716 -0
  17. package/dist/astro/types.d.mts +269 -0
  18. package/dist/astro/types.mjs +1 -0
  19. package/dist/base64-F8-DUraK.mjs +58 -0
  20. package/dist/byline-DeWCMU_i.mjs +234 -0
  21. package/dist/bylines-DyqBV9EQ.mjs +137 -0
  22. package/dist/chunk-ClPoSABd.mjs +21 -0
  23. package/dist/cli/index.d.mts +1 -0
  24. package/dist/cli/index.mjs +3987 -0
  25. package/dist/client/external-auth-headers.d.mts +38 -0
  26. package/dist/client/external-auth-headers.mjs +101 -0
  27. package/dist/client/index.d.mts +397 -0
  28. package/dist/client/index.mjs +345 -0
  29. package/dist/config-Cq8H0SfX.mjs +46 -0
  30. package/dist/connection-C9pxzuag.mjs +52 -0
  31. package/dist/content-zSgdNmnt.mjs +836 -0
  32. package/dist/db/index.d.mts +4 -0
  33. package/dist/db/index.mjs +62 -0
  34. package/dist/db/libsql.d.mts +10 -0
  35. package/dist/db/libsql.mjs +21 -0
  36. package/dist/db/postgres.d.mts +10 -0
  37. package/dist/db/postgres.mjs +29 -0
  38. package/dist/db/sqlite.d.mts +10 -0
  39. package/dist/db/sqlite.mjs +15 -0
  40. package/dist/default-WYlzADZL.mjs +80 -0
  41. package/dist/dialect-helpers-B9uSp2GJ.mjs +89 -0
  42. package/dist/error-DrxtnGPg.mjs +26 -0
  43. package/dist/index-C-jx21qs.d.mts +4771 -0
  44. package/dist/index.d.mts +16 -0
  45. package/dist/index.mjs +30 -0
  46. package/dist/load-C6FCD1FU.mjs +27 -0
  47. package/dist/loader-qKmo0wAY.mjs +446 -0
  48. package/dist/manifest-schema-CTSEyIJ3.mjs +186 -0
  49. package/dist/media/index.d.mts +25 -0
  50. package/dist/media/index.mjs +54 -0
  51. package/dist/media/local-runtime.d.mts +38 -0
  52. package/dist/media/local-runtime.mjs +132 -0
  53. package/dist/media-DMTr80Gv.mjs +199 -0
  54. package/dist/mode-BlyYtIFO.mjs +22 -0
  55. package/dist/page/index.d.mts +148 -0
  56. package/dist/page/index.mjs +419 -0
  57. package/dist/placeholder-B3knXwNc.mjs +267 -0
  58. package/dist/placeholder-bOx1xCTY.d.mts +283 -0
  59. package/dist/plugin-utils.d.mts +57 -0
  60. package/dist/plugin-utils.mjs +77 -0
  61. package/dist/plugins/adapt-sandbox-entry.d.mts +21 -0
  62. package/dist/plugins/adapt-sandbox-entry.mjs +112 -0
  63. package/dist/query-BiaPl_g2.mjs +459 -0
  64. package/dist/redirect-JPqLAbxa.mjs +328 -0
  65. package/dist/registry-DSd1GWB8.mjs +851 -0
  66. package/dist/request-context.d.mts +49 -0
  67. package/dist/request-context.mjs +42 -0
  68. package/dist/runner-B5l1JfOj.d.mts +26 -0
  69. package/dist/runner-BGUGywgG.mjs +1529 -0
  70. package/dist/runtime.d.mts +25 -0
  71. package/dist/runtime.mjs +41 -0
  72. package/dist/search-BNruJHDL.mjs +11054 -0
  73. package/dist/seed/index.d.mts +3 -0
  74. package/dist/seed/index.mjs +15 -0
  75. package/dist/seo/index.d.mts +69 -0
  76. package/dist/seo/index.mjs +69 -0
  77. package/dist/storage/local.d.mts +38 -0
  78. package/dist/storage/local.mjs +165 -0
  79. package/dist/storage/s3.d.mts +31 -0
  80. package/dist/storage/s3.mjs +174 -0
  81. package/dist/tokens-4vgYuXsZ.mjs +170 -0
  82. package/dist/transport-C5FYnid7.mjs +417 -0
  83. package/dist/transport-gIL-e43D.d.mts +41 -0
  84. package/dist/types-BawVha09.mjs +30 -0
  85. package/dist/types-BgQeVaPj.d.mts +192 -0
  86. package/dist/types-CLLdsG3g.d.mts +103 -0
  87. package/dist/types-D38djUXv.d.mts +1196 -0
  88. package/dist/types-DShnjzb6.mjs +15 -0
  89. package/dist/types-DkvMXalq.d.mts +425 -0
  90. package/dist/types-DuNbGKjF.mjs +74 -0
  91. package/dist/types-ju-_ORz7.d.mts +182 -0
  92. package/dist/validate-CXnRKfJK.mjs +327 -0
  93. package/dist/validate-CqRJb_xU.mjs +96 -0
  94. package/dist/validate-DVKJJ-M_.d.mts +377 -0
  95. package/locals.d.ts +47 -0
  96. package/package.json +313 -0
@@ -0,0 +1,851 @@
1
+ import { t as __exportAll } from "./chunk-ClPoSABd.mjs";
2
+ import { a as isSqlite, c as tableExists, n as currentTimestamp, s as listTablesLike } from "./dialect-helpers-B9uSp2GJ.mjs";
3
+ import { t as validateIdentifier } from "./validate-CqRJb_xU.mjs";
4
+ import { i as RESERVED_FIELD_SLUGS, n as FIELD_TYPE_TO_COLUMN, r as RESERVED_COLLECTION_SLUGS } from "./types-DuNbGKjF.mjs";
5
+ import { sql } from "kysely";
6
+ import { ulid } from "ulidx";
7
+
8
+ //#region src/database/transaction.ts
9
+ /**
10
+ * Run a callback inside a transaction if supported, or directly if not.
11
+ *
12
+ * Probes the database once on first call to determine if transactions work.
13
+ * The result is cached for the lifetime of the process/worker.
14
+ */
15
+ let transactionsSupported = null;
16
+ const TRANSACTIONS_NOT_SUPPORTED_RE = /transactions are not supported/i;
17
+ async function withTransaction(db, fn) {
18
+ if (transactionsSupported === true) return db.transaction().execute(fn);
19
+ if (transactionsSupported === false) return fn(db);
20
+ try {
21
+ const result = await db.transaction().execute(fn);
22
+ transactionsSupported = true;
23
+ return result;
24
+ } catch (error) {
25
+ if (error instanceof Error && TRANSACTIONS_NOT_SUPPORTED_RE.test(error.message)) {
26
+ transactionsSupported = false;
27
+ return fn(db);
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ //#endregion
34
+ //#region src/search/fts-manager.ts
35
+ /**
36
+ * FTS5 Manager
37
+ *
38
+ * Handles creation, deletion, and management of FTS5 virtual tables
39
+ * for full-text search on content collections.
40
+ */
41
+ var FTSManager = class {
42
+ constructor(db) {
43
+ this.db = db;
44
+ }
45
+ /**
46
+ * Validate a collection slug and its searchable field names.
47
+ * Must be called before any raw SQL interpolation.
48
+ */
49
+ validateInputs(collectionSlug, searchableFields) {
50
+ validateIdentifier(collectionSlug, "collection slug");
51
+ if (searchableFields) for (const field of searchableFields) validateIdentifier(field, "searchable field name");
52
+ }
53
+ /**
54
+ * Get the FTS table name for a collection
55
+ * Uses _dineway_ prefix to clearly mark as internal/system table
56
+ */
57
+ getFtsTableName(collectionSlug) {
58
+ return `_dineway_fts_${collectionSlug}`;
59
+ }
60
+ /**
61
+ * Get the content table name for a collection
62
+ */
63
+ getContentTableName(collectionSlug) {
64
+ return `ec_${collectionSlug}`;
65
+ }
66
+ /**
67
+ * Check if an FTS table exists for a collection
68
+ */
69
+ async ftsTableExists(collectionSlug) {
70
+ const ftsTable = this.getFtsTableName(collectionSlug);
71
+ return tableExists(this.db, ftsTable);
72
+ }
73
+ /**
74
+ * Create an FTS5 virtual table for a collection.
75
+ * FTS5 is SQLite-only; on other dialects this is a no-op.
76
+ *
77
+ * @param collectionSlug - The collection slug
78
+ * @param searchableFields - Array of field names to index
79
+ * @param weights - Optional field weights for ranking
80
+ */
81
+ async createFtsTable(collectionSlug, searchableFields, _weights) {
82
+ if (!isSqlite(this.db)) return;
83
+ this.validateInputs(collectionSlug, searchableFields);
84
+ const ftsTable = this.getFtsTableName(collectionSlug);
85
+ const contentTable = this.getContentTableName(collectionSlug);
86
+ const columns = [
87
+ "id UNINDEXED",
88
+ "locale UNINDEXED",
89
+ ...searchableFields
90
+ ].join(", ");
91
+ await sql.raw(`
92
+ CREATE VIRTUAL TABLE IF NOT EXISTS "${ftsTable}" USING fts5(
93
+ ${columns},
94
+ content='${contentTable}',
95
+ content_rowid='rowid',
96
+ tokenize='porter unicode61'
97
+ )
98
+ `).execute(this.db);
99
+ await this.createTriggers(collectionSlug, searchableFields);
100
+ }
101
+ /**
102
+ * Create triggers to keep FTS table in sync with content table
103
+ */
104
+ async createTriggers(collectionSlug, searchableFields) {
105
+ const ftsTable = this.getFtsTableName(collectionSlug);
106
+ const contentTable = this.getContentTableName(collectionSlug);
107
+ const fieldList = searchableFields.join(", ");
108
+ const newFieldList = searchableFields.map((f) => `NEW.${f}`).join(", ");
109
+ await sql.raw(`
110
+ CREATE TRIGGER IF NOT EXISTS "${ftsTable}_insert"
111
+ AFTER INSERT ON "${contentTable}"
112
+ BEGIN
113
+ INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
114
+ VALUES (NEW.rowid, NEW.id, NEW.locale, ${newFieldList});
115
+ END
116
+ `).execute(this.db);
117
+ await sql.raw(`
118
+ CREATE TRIGGER IF NOT EXISTS "${ftsTable}_update"
119
+ AFTER UPDATE ON "${contentTable}"
120
+ BEGIN
121
+ DELETE FROM "${ftsTable}" WHERE rowid = OLD.rowid;
122
+ INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
123
+ VALUES (NEW.rowid, NEW.id, NEW.locale, ${newFieldList});
124
+ END
125
+ `).execute(this.db);
126
+ await sql.raw(`
127
+ CREATE TRIGGER IF NOT EXISTS "${ftsTable}_delete"
128
+ AFTER DELETE ON "${contentTable}"
129
+ BEGIN
130
+ DELETE FROM "${ftsTable}" WHERE rowid = OLD.rowid;
131
+ END
132
+ `).execute(this.db);
133
+ }
134
+ /**
135
+ * Drop triggers for a collection
136
+ */
137
+ async dropTriggers(collectionSlug) {
138
+ const ftsTable = this.getFtsTableName(collectionSlug);
139
+ await sql.raw(`DROP TRIGGER IF EXISTS "${ftsTable}_insert"`).execute(this.db);
140
+ await sql.raw(`DROP TRIGGER IF EXISTS "${ftsTable}_update"`).execute(this.db);
141
+ await sql.raw(`DROP TRIGGER IF EXISTS "${ftsTable}_delete"`).execute(this.db);
142
+ }
143
+ /**
144
+ * Drop the FTS table and triggers for a collection
145
+ */
146
+ async dropFtsTable(collectionSlug) {
147
+ if (!isSqlite(this.db)) return;
148
+ this.validateInputs(collectionSlug);
149
+ const ftsTable = this.getFtsTableName(collectionSlug);
150
+ await this.dropTriggers(collectionSlug);
151
+ await sql.raw(`DROP TABLE IF EXISTS "${ftsTable}"`).execute(this.db);
152
+ }
153
+ /**
154
+ * Rebuild the FTS index for a collection
155
+ *
156
+ * This is useful after bulk imports or if the index gets out of sync.
157
+ */
158
+ async rebuildIndex(collectionSlug, searchableFields, weights) {
159
+ if (!isSqlite(this.db)) return;
160
+ await this.dropFtsTable(collectionSlug);
161
+ await this.createFtsTable(collectionSlug, searchableFields, weights);
162
+ await this.populateFromContent(collectionSlug, searchableFields);
163
+ }
164
+ /**
165
+ * Populate the FTS table from existing content
166
+ */
167
+ async populateFromContent(collectionSlug, searchableFields) {
168
+ if (!isSqlite(this.db)) return;
169
+ this.validateInputs(collectionSlug, searchableFields);
170
+ const ftsTable = this.getFtsTableName(collectionSlug);
171
+ const contentTable = this.getContentTableName(collectionSlug);
172
+ const fieldList = searchableFields.join(", ");
173
+ await sql.raw(`
174
+ INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
175
+ SELECT rowid, id, locale, ${fieldList} FROM "${contentTable}"
176
+ WHERE deleted_at IS NULL
177
+ `).execute(this.db);
178
+ }
179
+ /**
180
+ * Get the search configuration for a collection
181
+ */
182
+ async getSearchConfig(collectionSlug) {
183
+ const result = await this.db.selectFrom("_dineway_collections").select("search_config").where("slug", "=", collectionSlug).executeTakeFirst();
184
+ if (!result?.search_config) return null;
185
+ try {
186
+ const parsed = JSON.parse(result.search_config);
187
+ if (typeof parsed !== "object" || parsed === null || !("enabled" in parsed) || typeof parsed.enabled !== "boolean") return null;
188
+ const config = { enabled: parsed.enabled };
189
+ if ("weights" in parsed && typeof parsed.weights === "object" && parsed.weights !== null) {
190
+ const weights = {};
191
+ for (const [k, v] of Object.entries(parsed.weights)) if (typeof v === "number") weights[k] = v;
192
+ config.weights = weights;
193
+ }
194
+ return config;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+ /**
200
+ * Update the search configuration for a collection
201
+ */
202
+ async setSearchConfig(collectionSlug, config) {
203
+ await this.db.updateTable("_dineway_collections").set({ search_config: JSON.stringify(config) }).where("slug", "=", collectionSlug).execute();
204
+ }
205
+ /**
206
+ * Get searchable fields for a collection
207
+ */
208
+ async getSearchableFields(collectionSlug) {
209
+ const collection = await this.db.selectFrom("_dineway_collections").select("id").where("slug", "=", collectionSlug).executeTakeFirst();
210
+ if (!collection) return [];
211
+ return (await this.db.selectFrom("_dineway_fields").select("slug").where("collection_id", "=", collection.id).where("searchable", "=", 1).execute()).map((f) => f.slug);
212
+ }
213
+ /**
214
+ * Enable search for a collection
215
+ *
216
+ * Creates the FTS table and triggers, and populates from existing content.
217
+ */
218
+ async enableSearch(collectionSlug, options) {
219
+ if (!isSqlite(this.db)) throw new Error("Full-text search is only available with SQLite databases");
220
+ const searchableFields = await this.getSearchableFields(collectionSlug);
221
+ if (searchableFields.length === 0) throw new Error(`No searchable fields defined for collection "${collectionSlug}". Mark at least one field as searchable before enabling search.`);
222
+ await this.createFtsTable(collectionSlug, searchableFields, options?.weights);
223
+ await this.populateFromContent(collectionSlug, searchableFields);
224
+ await this.setSearchConfig(collectionSlug, {
225
+ enabled: true,
226
+ weights: options?.weights
227
+ });
228
+ }
229
+ /**
230
+ * Disable search for a collection
231
+ *
232
+ * Drops the FTS table and triggers.
233
+ */
234
+ async disableSearch(collectionSlug) {
235
+ if (!isSqlite(this.db)) return;
236
+ await this.dropFtsTable(collectionSlug);
237
+ await this.setSearchConfig(collectionSlug, { enabled: false });
238
+ }
239
+ /**
240
+ * Get index statistics for a collection
241
+ */
242
+ async getIndexStats(collectionSlug) {
243
+ if (!isSqlite(this.db)) return null;
244
+ this.validateInputs(collectionSlug);
245
+ const ftsTable = this.getFtsTableName(collectionSlug);
246
+ if (!await this.ftsTableExists(collectionSlug)) return null;
247
+ return { indexed: (await sql`
248
+ SELECT COUNT(*) as count FROM "${sql.raw(ftsTable)}"
249
+ `.execute(this.db)).rows[0]?.count ?? 0 };
250
+ }
251
+ /**
252
+ * Verify FTS index integrity and rebuild if corrupted.
253
+ *
254
+ * Checks for row count mismatch between content table and FTS table.
255
+ *
256
+ * Returns true if the index was rebuilt, false if it was healthy.
257
+ */
258
+ async verifyAndRepairIndex(collectionSlug) {
259
+ if (!isSqlite(this.db)) return false;
260
+ this.validateInputs(collectionSlug);
261
+ const ftsTable = this.getFtsTableName(collectionSlug);
262
+ const contentTable = this.getContentTableName(collectionSlug);
263
+ if (!await this.ftsTableExists(collectionSlug)) return false;
264
+ const contentCount = await sql`
265
+ SELECT COUNT(*) as count FROM ${sql.ref(contentTable)}
266
+ WHERE deleted_at IS NULL
267
+ `.execute(this.db);
268
+ const ftsCount = await sql`
269
+ SELECT COUNT(*) as count FROM "${sql.raw(ftsTable)}"
270
+ `.execute(this.db);
271
+ const contentRows = contentCount.rows[0]?.count ?? 0;
272
+ const ftsRows = ftsCount.rows[0]?.count ?? 0;
273
+ if (contentRows !== ftsRows) {
274
+ console.warn(`FTS index for "${collectionSlug}" has ${ftsRows} rows but content table has ${contentRows}. Rebuilding.`);
275
+ const fields = await this.getSearchableFields(collectionSlug);
276
+ const config = await this.getSearchConfig(collectionSlug);
277
+ if (fields.length > 0) await this.rebuildIndex(collectionSlug, fields, config?.weights);
278
+ return true;
279
+ }
280
+ return false;
281
+ }
282
+ /**
283
+ * Verify and repair FTS indexes for all search-enabled collections.
284
+ *
285
+ * Intended to run at startup to auto-heal any corruption from
286
+ * previous process crashes.
287
+ */
288
+ async verifyAndRepairAll() {
289
+ if (!isSqlite(this.db)) return 0;
290
+ const collections = await this.db.selectFrom("_dineway_collections").select("slug").where("search_config", "is not", null).execute();
291
+ let repaired = 0;
292
+ for (const { slug } of collections) {
293
+ if (!(await this.getSearchConfig(slug))?.enabled) continue;
294
+ try {
295
+ if (await this.verifyAndRepairIndex(slug)) repaired++;
296
+ } catch (error) {
297
+ console.error(`Failed to verify/repair FTS index for "${slug}":`, error);
298
+ }
299
+ }
300
+ return repaired;
301
+ }
302
+ };
303
+
304
+ //#endregion
305
+ //#region src/schema/registry.ts
306
+ var registry_exports = /* @__PURE__ */ __exportAll({
307
+ SchemaError: () => SchemaError,
308
+ SchemaRegistry: () => SchemaRegistry
309
+ });
310
+ const SLUG_VALIDATION_PATTERN = /^[a-z][a-z0-9_]*$/;
311
+ const EC_PREFIX_PATTERN = /^ec_/;
312
+ const SINGLE_QUOTE_PATTERN = /'/g;
313
+ const UNDERSCORE_PATTERN = /_/g;
314
+ const WORD_BOUNDARY_PATTERN = /\b\w/g;
315
+ /** Valid column types for runtime validation */
316
+ const COLUMN_TYPES = new Set([
317
+ "TEXT",
318
+ "REAL",
319
+ "INTEGER",
320
+ "JSON"
321
+ ]);
322
+ /** Valid collection source prefixes/values */
323
+ const VALID_SOURCES = new Set([
324
+ "manual",
325
+ "discovered",
326
+ "seed"
327
+ ]);
328
+ function isCollectionSource(value) {
329
+ return VALID_SOURCES.has(value) || value.startsWith("template:") || value.startsWith("import:");
330
+ }
331
+ function isFieldType(value) {
332
+ return value in FIELD_TYPE_TO_COLUMN;
333
+ }
334
+ function isColumnType(value) {
335
+ return COLUMN_TYPES.has(value);
336
+ }
337
+ /**
338
+ * Error thrown when a schema operation fails
339
+ */
340
+ var SchemaError = class extends Error {
341
+ constructor(message, code, details) {
342
+ super(message);
343
+ this.code = code;
344
+ this.details = details;
345
+ this.name = "SchemaError";
346
+ }
347
+ };
348
+ /**
349
+ * Schema Registry
350
+ *
351
+ * Manages collection and field definitions stored in the database.
352
+ * Handles runtime DDL operations (CREATE TABLE, ALTER TABLE).
353
+ */
354
+ var SchemaRegistry = class {
355
+ constructor(db) {
356
+ this.db = db;
357
+ }
358
+ /**
359
+ * List all collections
360
+ */
361
+ async listCollections() {
362
+ return (await this.db.selectFrom("_dineway_collections").selectAll().orderBy("slug", "asc").execute()).map(this.mapCollectionRow);
363
+ }
364
+ /**
365
+ * Get a collection by slug
366
+ */
367
+ async getCollection(slug) {
368
+ const row = await this.db.selectFrom("_dineway_collections").where("slug", "=", slug).selectAll().executeTakeFirst();
369
+ return row ? this.mapCollectionRow(row) : null;
370
+ }
371
+ /**
372
+ * Get a collection with all its fields
373
+ */
374
+ async getCollectionWithFields(slug) {
375
+ const collection = await this.getCollection(slug);
376
+ if (!collection) return null;
377
+ const fields = await this.listFields(collection.id);
378
+ return {
379
+ ...collection,
380
+ fields
381
+ };
382
+ }
383
+ /**
384
+ * Create a new collection
385
+ */
386
+ async createCollection(input) {
387
+ this.validateSlug(input.slug, "collection");
388
+ if (RESERVED_COLLECTION_SLUGS.includes(input.slug)) throw new SchemaError(`Collection slug "${input.slug}" is reserved`, "RESERVED_SLUG");
389
+ if (await this.getCollection(input.slug)) throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS");
390
+ const id = ulid();
391
+ const hasSeo = input.hasSeo ?? input.supports?.includes("seo") ?? false;
392
+ await withTransaction(this.db, async (trx) => {
393
+ await trx.insertInto("_dineway_collections").values({
394
+ id,
395
+ slug: input.slug,
396
+ label: input.label,
397
+ label_singular: input.labelSingular ?? null,
398
+ description: input.description ?? null,
399
+ icon: input.icon ?? null,
400
+ supports: input.supports ? JSON.stringify(input.supports) : null,
401
+ source: input.source ?? "manual",
402
+ has_seo: hasSeo ? 1 : 0,
403
+ comments_enabled: input.commentsEnabled ? 1 : 0,
404
+ url_pattern: input.urlPattern ?? null
405
+ }).execute();
406
+ await this.createContentTable(input.slug, trx);
407
+ });
408
+ const collection = await this.getCollection(input.slug);
409
+ if (!collection) throw new SchemaError("Failed to create collection", "CREATE_FAILED");
410
+ return collection;
411
+ }
412
+ /**
413
+ * Update a collection
414
+ */
415
+ async updateCollection(slug, input) {
416
+ const existing = await this.getCollection(slug);
417
+ if (!existing) throw new SchemaError(`Collection "${slug}" not found`, "COLLECTION_NOT_FOUND");
418
+ const now = (/* @__PURE__ */ new Date()).toISOString();
419
+ const supportsArray = input.supports ?? existing.supports;
420
+ const hasSeo = input.hasSeo !== void 0 ? input.hasSeo : input.supports !== void 0 ? supportsArray.includes("seo") : existing.hasSeo;
421
+ await this.db.updateTable("_dineway_collections").set({
422
+ label: input.label ?? existing.label,
423
+ label_singular: input.labelSingular ?? existing.labelSingular ?? null,
424
+ description: input.description ?? existing.description ?? null,
425
+ icon: input.icon ?? existing.icon ?? null,
426
+ supports: input.supports ? JSON.stringify(input.supports) : JSON.stringify(existing.supports),
427
+ url_pattern: input.urlPattern !== void 0 ? input.urlPattern ?? null : existing.urlPattern ?? null,
428
+ has_seo: hasSeo ? 1 : 0,
429
+ comments_enabled: input.commentsEnabled !== void 0 ? input.commentsEnabled ? 1 : 0 : existing.commentsEnabled ? 1 : 0,
430
+ comments_moderation: input.commentsModeration ?? existing.commentsModeration,
431
+ comments_closed_after_days: input.commentsClosedAfterDays !== void 0 ? input.commentsClosedAfterDays : existing.commentsClosedAfterDays,
432
+ comments_auto_approve_users: input.commentsAutoApproveUsers !== void 0 ? input.commentsAutoApproveUsers ? 1 : 0 : existing.commentsAutoApproveUsers ? 1 : 0,
433
+ updated_at: now
434
+ }).where("slug", "=", slug).execute();
435
+ const updated = await this.getCollection(slug);
436
+ if (!updated) throw new SchemaError("Failed to update collection", "UPDATE_FAILED");
437
+ return updated;
438
+ }
439
+ /**
440
+ * Delete a collection
441
+ */
442
+ async deleteCollection(slug, options) {
443
+ const existing = await this.getCollection(slug);
444
+ if (!existing) throw new SchemaError(`Collection "${slug}" not found`, "COLLECTION_NOT_FOUND");
445
+ if (!options?.force) {
446
+ if (await this.collectionHasContent(slug)) throw new SchemaError(`Collection "${slug}" has content. Use force: true to delete.`, "COLLECTION_HAS_CONTENT");
447
+ }
448
+ await this.dropContentTable(slug);
449
+ await this.db.deleteFrom("_dineway_collections").where("id", "=", existing.id).execute();
450
+ }
451
+ /**
452
+ * List fields for a collection
453
+ */
454
+ async listFields(collectionId) {
455
+ return (await this.db.selectFrom("_dineway_fields").where("collection_id", "=", collectionId).selectAll().orderBy("sort_order", "asc").orderBy("created_at", "asc").execute()).map(this.mapFieldRow);
456
+ }
457
+ /**
458
+ * Get a field by slug within a collection
459
+ */
460
+ async getField(collectionSlug, fieldSlug) {
461
+ const collection = await this.getCollection(collectionSlug);
462
+ if (!collection) return null;
463
+ const row = await this.db.selectFrom("_dineway_fields").where("collection_id", "=", collection.id).where("slug", "=", fieldSlug).selectAll().executeTakeFirst();
464
+ return row ? this.mapFieldRow(row) : null;
465
+ }
466
+ /**
467
+ * Create a new field
468
+ */
469
+ async createField(collectionSlug, input) {
470
+ const collection = await this.getCollection(collectionSlug);
471
+ if (!collection) throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND");
472
+ this.validateSlug(input.slug, "field");
473
+ if (RESERVED_FIELD_SLUGS.includes(input.slug)) throw new SchemaError(`Field slug "${input.slug}" is reserved`, "RESERVED_SLUG");
474
+ if (await this.getField(collectionSlug, input.slug)) throw new SchemaError(`Field "${input.slug}" already exists in collection "${collectionSlug}"`, "FIELD_EXISTS");
475
+ const id = ulid();
476
+ const columnType = FIELD_TYPE_TO_COLUMN[input.type];
477
+ const maxSort = await this.db.selectFrom("_dineway_fields").where("collection_id", "=", collection.id).select((eb) => eb.fn.max("sort_order").as("max")).executeTakeFirst();
478
+ const sortOrder = input.sortOrder ?? (maxSort?.max ?? -1) + 1;
479
+ await this.db.insertInto("_dineway_fields").values({
480
+ id,
481
+ collection_id: collection.id,
482
+ slug: input.slug,
483
+ label: input.label,
484
+ type: input.type,
485
+ column_type: columnType,
486
+ required: input.required ? 1 : 0,
487
+ unique: input.unique ? 1 : 0,
488
+ default_value: input.defaultValue !== void 0 ? JSON.stringify(input.defaultValue) : null,
489
+ validation: input.validation ? JSON.stringify(input.validation) : null,
490
+ widget: input.widget ?? null,
491
+ options: input.options ? JSON.stringify(input.options) : null,
492
+ sort_order: sortOrder,
493
+ searchable: input.searchable ? 1 : 0,
494
+ translatable: input.translatable === false ? 0 : 1
495
+ }).execute();
496
+ await this.addColumn(collectionSlug, input.slug, input.type, {
497
+ required: input.required,
498
+ defaultValue: input.defaultValue
499
+ });
500
+ const field = await this.getField(collectionSlug, input.slug);
501
+ if (!field) throw new SchemaError("Failed to create field", "CREATE_FAILED");
502
+ return field;
503
+ }
504
+ /**
505
+ * Update a field
506
+ */
507
+ async updateField(collectionSlug, fieldSlug, input) {
508
+ const field = await this.getField(collectionSlug, fieldSlug);
509
+ if (!field) throw new SchemaError(`Field "${fieldSlug}" not found in collection "${collectionSlug}"`, "FIELD_NOT_FOUND");
510
+ await this.db.updateTable("_dineway_fields").set({
511
+ label: input.label ?? field.label,
512
+ required: input.required !== void 0 ? input.required ? 1 : 0 : field.required ? 1 : 0,
513
+ unique: input.unique !== void 0 ? input.unique ? 1 : 0 : field.unique ? 1 : 0,
514
+ searchable: input.searchable !== void 0 ? input.searchable ? 1 : 0 : field.searchable ? 1 : 0,
515
+ translatable: input.translatable !== void 0 ? input.translatable ? 1 : 0 : field.translatable ? 1 : 0,
516
+ default_value: input.defaultValue !== void 0 ? JSON.stringify(input.defaultValue) : field.defaultValue !== void 0 ? JSON.stringify(field.defaultValue) : null,
517
+ validation: input.validation ? JSON.stringify(input.validation) : field.validation ? JSON.stringify(field.validation) : null,
518
+ widget: input.widget ?? field.widget ?? null,
519
+ options: input.options ? JSON.stringify(input.options) : field.options ? JSON.stringify(field.options) : null,
520
+ sort_order: input.sortOrder ?? field.sortOrder
521
+ }).where("id", "=", field.id).execute();
522
+ const updated = await this.getField(collectionSlug, fieldSlug);
523
+ if (!updated) throw new SchemaError("Failed to update field", "UPDATE_FAILED");
524
+ if (input.searchable !== void 0 && input.searchable !== field.searchable) await this.rebuildSearchIndex(collectionSlug);
525
+ return updated;
526
+ }
527
+ /**
528
+ * Rebuild the search index for a collection
529
+ *
530
+ * Called when searchable fields change. If search is enabled for the collection,
531
+ * this will rebuild the FTS table with the updated field list.
532
+ */
533
+ async rebuildSearchIndex(collectionSlug) {
534
+ const ftsManager = new FTSManager(this.db);
535
+ const config = await ftsManager.getSearchConfig(collectionSlug);
536
+ if (!config?.enabled) return;
537
+ const searchableFields = await ftsManager.getSearchableFields(collectionSlug);
538
+ if (searchableFields.length === 0) await ftsManager.disableSearch(collectionSlug);
539
+ else await ftsManager.rebuildIndex(collectionSlug, searchableFields, config.weights);
540
+ }
541
+ /**
542
+ * Delete a field
543
+ */
544
+ async deleteField(collectionSlug, fieldSlug) {
545
+ const field = await this.getField(collectionSlug, fieldSlug);
546
+ if (!field) throw new SchemaError(`Field "${fieldSlug}" not found in collection "${collectionSlug}"`, "FIELD_NOT_FOUND");
547
+ await this.dropColumn(collectionSlug, fieldSlug);
548
+ await this.db.deleteFrom("_dineway_fields").where("id", "=", field.id).execute();
549
+ }
550
+ /**
551
+ * Reorder fields
552
+ */
553
+ async reorderFields(collectionSlug, fieldSlugs) {
554
+ const collection = await this.getCollection(collectionSlug);
555
+ if (!collection) throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND");
556
+ for (let i = 0; i < fieldSlugs.length; i++) await this.db.updateTable("_dineway_fields").set({ sort_order: i }).where("collection_id", "=", collection.id).where("slug", "=", fieldSlugs[i]).execute();
557
+ }
558
+ /**
559
+ * Create a content table for a collection
560
+ */
561
+ async createContentTable(slug, db) {
562
+ const conn = db ?? this.db;
563
+ const tableName = this.getTableName(slug);
564
+ await conn.schema.createTable(tableName).addColumn("id", "text", (col) => col.primaryKey()).addColumn("slug", "text").addColumn("status", "text", (col) => col.defaultTo("draft")).addColumn("author_id", "text").addColumn("primary_byline_id", "text").addColumn("created_at", "text", (col) => col.defaultTo(currentTimestamp(conn))).addColumn("updated_at", "text", (col) => col.defaultTo(currentTimestamp(conn))).addColumn("published_at", "text").addColumn("scheduled_at", "text").addColumn("deleted_at", "text").addColumn("version", "integer", (col) => col.defaultTo(1)).addColumn("live_revision_id", "text", (col) => col.references("revisions.id")).addColumn("draft_revision_id", "text", (col) => col.references("revisions.id")).addColumn("locale", "text", (col) => col.notNull().defaultTo("en")).addColumn("translation_group", "text").addUniqueConstraint(`${tableName}_slug_locale_unique`, ["slug", "locale"]).execute();
565
+ await sql`
566
+ CREATE INDEX ${sql.ref(`idx_${tableName}_slug`)}
567
+ ON ${sql.ref(tableName)} (slug)
568
+ `.execute(conn);
569
+ await sql`
570
+ CREATE INDEX ${sql.ref(`idx_${tableName}_scheduled`)}
571
+ ON ${sql.ref(tableName)} (scheduled_at)
572
+ WHERE scheduled_at IS NOT NULL
573
+ `.execute(conn);
574
+ await sql`
575
+ CREATE INDEX ${sql.ref(`idx_${tableName}_live_revision`)}
576
+ ON ${sql.ref(tableName)} (live_revision_id)
577
+ `.execute(conn);
578
+ await sql`
579
+ CREATE INDEX ${sql.ref(`idx_${tableName}_draft_revision`)}
580
+ ON ${sql.ref(tableName)} (draft_revision_id)
581
+ `.execute(conn);
582
+ await sql`
583
+ CREATE INDEX ${sql.ref(`idx_${tableName}_author`)}
584
+ ON ${sql.ref(tableName)} (author_id)
585
+ `.execute(conn);
586
+ await sql`
587
+ CREATE INDEX ${sql.ref(`idx_${tableName}_primary_byline`)}
588
+ ON ${sql.ref(tableName)} (primary_byline_id)
589
+ `.execute(conn);
590
+ await sql`
591
+ CREATE INDEX ${sql.ref(`idx_${tableName}_locale`)}
592
+ ON ${sql.ref(tableName)} (locale)
593
+ `.execute(conn);
594
+ await sql`
595
+ CREATE INDEX ${sql.ref(`idx_${tableName}_translation_group`)}
596
+ ON ${sql.ref(tableName)} (translation_group)
597
+ `.execute(conn);
598
+ await sql`
599
+ CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_updated_id`)}
600
+ ON ${sql.ref(tableName)} (deleted_at, updated_at DESC, id DESC)
601
+ `.execute(conn);
602
+ await sql`
603
+ CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_status`)}
604
+ ON ${sql.ref(tableName)} (deleted_at, status)
605
+ `.execute(conn);
606
+ await sql`
607
+ CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_created_id`)}
608
+ ON ${sql.ref(tableName)} (deleted_at, created_at DESC, id DESC)
609
+ `.execute(conn);
610
+ await sql`
611
+ CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_published_id`)}
612
+ ON ${sql.ref(tableName)} (deleted_at, published_at DESC, id DESC)
613
+ `.execute(conn);
614
+ }
615
+ /**
616
+ * Drop a content table
617
+ */
618
+ async dropContentTable(slug) {
619
+ const tableName = this.getTableName(slug);
620
+ await sql`DROP TABLE IF EXISTS ${sql.ref(tableName)}`.execute(this.db);
621
+ }
622
+ /**
623
+ * Add a column to a content table
624
+ */
625
+ async addColumn(collectionSlug, fieldSlug, fieldType, options) {
626
+ const tableName = this.getTableName(collectionSlug);
627
+ const columnType = FIELD_TYPE_TO_COLUMN[fieldType];
628
+ const columnName = this.getColumnName(fieldSlug);
629
+ if (options?.required && options?.defaultValue !== void 0) {
630
+ const defaultVal = this.formatDefaultValue(options.defaultValue, fieldType);
631
+ await sql`
632
+ ALTER TABLE ${sql.ref(tableName)}
633
+ ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)} NOT NULL DEFAULT ${sql.raw(defaultVal)}
634
+ `.execute(this.db);
635
+ } else if (options?.required) {
636
+ const defaultVal = this.getEmptyDefault(fieldType);
637
+ await sql`
638
+ ALTER TABLE ${sql.ref(tableName)}
639
+ ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)} NOT NULL DEFAULT ${sql.raw(defaultVal)}
640
+ `.execute(this.db);
641
+ } else await sql`
642
+ ALTER TABLE ${sql.ref(tableName)}
643
+ ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)}
644
+ `.execute(this.db);
645
+ }
646
+ /**
647
+ * Drop a column from a content table
648
+ */
649
+ async dropColumn(collectionSlug, fieldSlug) {
650
+ const tableName = this.getTableName(collectionSlug);
651
+ const columnName = this.getColumnName(fieldSlug);
652
+ await sql`
653
+ ALTER TABLE ${sql.ref(tableName)}
654
+ DROP COLUMN ${sql.ref(columnName)}
655
+ `.execute(this.db);
656
+ }
657
+ /**
658
+ * Check if a collection has any content
659
+ */
660
+ async collectionHasContent(slug) {
661
+ const tableName = this.getTableName(slug);
662
+ try {
663
+ return ((await sql`
664
+ SELECT COUNT(*) as count FROM ${sql.ref(tableName)}
665
+ WHERE deleted_at IS NULL
666
+ `.execute(this.db)).rows[0]?.count ?? 0) > 0;
667
+ } catch {
668
+ return false;
669
+ }
670
+ }
671
+ /**
672
+ * Get table name for a collection
673
+ */
674
+ getTableName(slug) {
675
+ return `ec_${slug}`;
676
+ }
677
+ /**
678
+ * Get column name for a field
679
+ */
680
+ getColumnName(slug) {
681
+ return slug;
682
+ }
683
+ /**
684
+ * Validate a slug
685
+ */
686
+ validateSlug(slug, type) {
687
+ if (!slug || typeof slug !== "string") throw new SchemaError(`${type} slug is required`, "INVALID_SLUG");
688
+ if (!SLUG_VALIDATION_PATTERN.test(slug)) throw new SchemaError(`${type} slug must start with a letter and contain only lowercase letters, numbers, and underscores`, "INVALID_SLUG");
689
+ if (slug.length > 63) throw new SchemaError(`${type} slug must be 63 characters or less`, "INVALID_SLUG");
690
+ }
691
+ /**
692
+ * Format a default value for SQL.
693
+ *
694
+ * SQLite `ALTER TABLE ADD COLUMN ... DEFAULT` requires a literal constant
695
+ * expression — parameterized values cannot be used here. We manually escape
696
+ * single quotes and coerce types to ensure the output is safe.
697
+ *
698
+ * INTEGER/REAL values are coerced through `Number()` which can only produce
699
+ * digits, `.`, `-`, `e`, `Infinity`, or `NaN` — all safe in SQL.
700
+ * TEXT/JSON values have single quotes escaped via SQL standard doubling (`''`).
701
+ */
702
+ formatDefaultValue(value, fieldType) {
703
+ if (value === null || value === void 0) return "NULL";
704
+ const columnType = FIELD_TYPE_TO_COLUMN[fieldType];
705
+ if (columnType === "JSON") return `'${JSON.stringify(value).replace(SINGLE_QUOTE_PATTERN, "''")}'`;
706
+ if (columnType === "INTEGER") {
707
+ if (typeof value === "boolean") return value ? "1" : "0";
708
+ const num = Number(value);
709
+ if (!Number.isFinite(num)) return "0";
710
+ return String(Math.trunc(num));
711
+ }
712
+ if (columnType === "REAL") {
713
+ const num = Number(value);
714
+ if (!Number.isFinite(num)) return "0";
715
+ return String(num);
716
+ }
717
+ let text;
718
+ if (typeof value === "string") text = value;
719
+ else if (typeof value === "number" || typeof value === "boolean") text = String(value);
720
+ else if (typeof value === "object" && value !== null) text = JSON.stringify(value);
721
+ else text = "";
722
+ return `'${text.replace(SINGLE_QUOTE_PATTERN, "''")}'`;
723
+ }
724
+ /**
725
+ * Get empty default for a field type
726
+ */
727
+ getEmptyDefault(fieldType) {
728
+ switch (FIELD_TYPE_TO_COLUMN[fieldType]) {
729
+ case "INTEGER": return "0";
730
+ case "REAL": return "0.0";
731
+ case "JSON": return "'null'";
732
+ default: return "''";
733
+ }
734
+ }
735
+ /**
736
+ * Map a collection row to a Collection object
737
+ */
738
+ mapCollectionRow = (row) => {
739
+ const moderation = row.comments_moderation;
740
+ return {
741
+ id: row.id,
742
+ slug: row.slug,
743
+ label: row.label,
744
+ labelSingular: row.label_singular ?? void 0,
745
+ description: row.description ?? void 0,
746
+ icon: row.icon ?? void 0,
747
+ supports: row.supports ? JSON.parse(row.supports) : [],
748
+ source: row.source && isCollectionSource(row.source) ? row.source : void 0,
749
+ hasSeo: row.has_seo === 1,
750
+ urlPattern: row.url_pattern ?? void 0,
751
+ commentsEnabled: row.comments_enabled === 1,
752
+ commentsModeration: moderation === "all" || moderation === "first_time" || moderation === "none" ? moderation : "first_time",
753
+ commentsClosedAfterDays: row.comments_closed_after_days ?? 90,
754
+ commentsAutoApproveUsers: row.comments_auto_approve_users === 1,
755
+ createdAt: row.created_at,
756
+ updatedAt: row.updated_at
757
+ };
758
+ };
759
+ /**
760
+ * Map a field row to a Field object
761
+ */
762
+ mapFieldRow = (row) => {
763
+ return {
764
+ id: row.id,
765
+ collectionId: row.collection_id,
766
+ slug: row.slug,
767
+ label: row.label,
768
+ type: isFieldType(row.type) ? row.type : "string",
769
+ columnType: isColumnType(row.column_type) ? row.column_type : "TEXT",
770
+ required: row.required === 1,
771
+ unique: row.unique === 1,
772
+ defaultValue: row.default_value ? JSON.parse(row.default_value) : void 0,
773
+ validation: row.validation ? JSON.parse(row.validation) : void 0,
774
+ widget: row.widget ?? void 0,
775
+ options: row.options ? JSON.parse(row.options) : void 0,
776
+ sortOrder: row.sort_order,
777
+ searchable: row.searchable === 1,
778
+ translatable: row.translatable !== 0,
779
+ createdAt: row.created_at
780
+ };
781
+ };
782
+ /**
783
+ * Discover orphaned content tables
784
+ *
785
+ * Finds ec_* tables that exist in the database but don't have a
786
+ * corresponding entry in _dineway_collections.
787
+ */
788
+ async discoverOrphanedTables() {
789
+ const allTables = await listTablesLike(this.db, "ec_%");
790
+ const registered = await this.listCollections();
791
+ const registeredSlugs = new Set(registered.map((c) => c.slug));
792
+ const orphans = [];
793
+ for (const tableName of allTables) {
794
+ const slug = tableName.replace(EC_PREFIX_PATTERN, "");
795
+ if (!registeredSlugs.has(slug)) try {
796
+ const countResult = await sql`
797
+ SELECT COUNT(*) as count FROM ${sql.ref(tableName)}
798
+ WHERE deleted_at IS NULL
799
+ `.execute(this.db);
800
+ orphans.push({
801
+ slug,
802
+ tableName,
803
+ rowCount: countResult.rows[0]?.count ?? 0
804
+ });
805
+ } catch {
806
+ orphans.push({
807
+ slug,
808
+ tableName,
809
+ rowCount: 0
810
+ });
811
+ }
812
+ }
813
+ return orphans;
814
+ }
815
+ /**
816
+ * Register an orphaned table as a collection
817
+ *
818
+ * Creates a _dineway_collections entry for an existing ec_* table.
819
+ */
820
+ async registerOrphanedTable(slug, options) {
821
+ const tableName = this.getTableName(slug);
822
+ if (!await tableExists(this.db, tableName)) throw new SchemaError(`Table "${tableName}" does not exist`, "TABLE_NOT_FOUND");
823
+ if (await this.getCollection(slug)) throw new SchemaError(`Collection "${slug}" is already registered`, "COLLECTION_EXISTS");
824
+ const id = ulid();
825
+ const label = options?.label || this.slugToLabel(slug);
826
+ await this.db.insertInto("_dineway_collections").values({
827
+ id,
828
+ slug,
829
+ label,
830
+ label_singular: options?.labelSingular ?? null,
831
+ description: options?.description ?? null,
832
+ icon: null,
833
+ supports: JSON.stringify([]),
834
+ source: "discovered",
835
+ has_seo: 0,
836
+ url_pattern: null
837
+ }).execute();
838
+ const collection = await this.getCollection(slug);
839
+ if (!collection) throw new SchemaError("Failed to register orphaned table", "REGISTER_FAILED");
840
+ return collection;
841
+ }
842
+ /**
843
+ * Convert slug to human-readable label
844
+ */
845
+ slugToLabel(slug) {
846
+ return slug.replace(UNDERSCORE_PATTERN, " ").replace(WORD_BOUNDARY_PATTERN, (c) => c.toUpperCase());
847
+ }
848
+ };
849
+
850
+ //#endregion
851
+ export { withTransaction as a, FTSManager as i, SchemaRegistry as n, registry_exports as r, SchemaError as t };