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.
- package/LICENSE +9 -0
- package/README.md +89 -0
- package/dist/adapters-BlzWJG82.d.mts +106 -0
- package/dist/apply-CAPvMfoU.mjs +1339 -0
- package/dist/astro/index.d.mts +50 -0
- package/dist/astro/index.mjs +1326 -0
- package/dist/astro/middleware/auth.d.mts +30 -0
- package/dist/astro/middleware/auth.mjs +708 -0
- package/dist/astro/middleware/redirect.d.mts +21 -0
- package/dist/astro/middleware/redirect.mjs +62 -0
- package/dist/astro/middleware/request-context.d.mts +17 -0
- package/dist/astro/middleware/request-context.mjs +1371 -0
- package/dist/astro/middleware/setup.d.mts +19 -0
- package/dist/astro/middleware/setup.mjs +46 -0
- package/dist/astro/middleware.d.mts +12 -0
- package/dist/astro/middleware.mjs +1716 -0
- package/dist/astro/types.d.mts +269 -0
- package/dist/astro/types.mjs +1 -0
- package/dist/base64-F8-DUraK.mjs +58 -0
- package/dist/byline-DeWCMU_i.mjs +234 -0
- package/dist/bylines-DyqBV9EQ.mjs +137 -0
- package/dist/chunk-ClPoSABd.mjs +21 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.mjs +3987 -0
- package/dist/client/external-auth-headers.d.mts +38 -0
- package/dist/client/external-auth-headers.mjs +101 -0
- package/dist/client/index.d.mts +397 -0
- package/dist/client/index.mjs +345 -0
- package/dist/config-Cq8H0SfX.mjs +46 -0
- package/dist/connection-C9pxzuag.mjs +52 -0
- package/dist/content-zSgdNmnt.mjs +836 -0
- package/dist/db/index.d.mts +4 -0
- package/dist/db/index.mjs +62 -0
- package/dist/db/libsql.d.mts +10 -0
- package/dist/db/libsql.mjs +21 -0
- package/dist/db/postgres.d.mts +10 -0
- package/dist/db/postgres.mjs +29 -0
- package/dist/db/sqlite.d.mts +10 -0
- package/dist/db/sqlite.mjs +15 -0
- package/dist/default-WYlzADZL.mjs +80 -0
- package/dist/dialect-helpers-B9uSp2GJ.mjs +89 -0
- package/dist/error-DrxtnGPg.mjs +26 -0
- package/dist/index-C-jx21qs.d.mts +4771 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +30 -0
- package/dist/load-C6FCD1FU.mjs +27 -0
- package/dist/loader-qKmo0wAY.mjs +446 -0
- package/dist/manifest-schema-CTSEyIJ3.mjs +186 -0
- package/dist/media/index.d.mts +25 -0
- package/dist/media/index.mjs +54 -0
- package/dist/media/local-runtime.d.mts +38 -0
- package/dist/media/local-runtime.mjs +132 -0
- package/dist/media-DMTr80Gv.mjs +199 -0
- package/dist/mode-BlyYtIFO.mjs +22 -0
- package/dist/page/index.d.mts +148 -0
- package/dist/page/index.mjs +419 -0
- package/dist/placeholder-B3knXwNc.mjs +267 -0
- package/dist/placeholder-bOx1xCTY.d.mts +283 -0
- package/dist/plugin-utils.d.mts +57 -0
- package/dist/plugin-utils.mjs +77 -0
- package/dist/plugins/adapt-sandbox-entry.d.mts +21 -0
- package/dist/plugins/adapt-sandbox-entry.mjs +112 -0
- package/dist/query-BiaPl_g2.mjs +459 -0
- package/dist/redirect-JPqLAbxa.mjs +328 -0
- package/dist/registry-DSd1GWB8.mjs +851 -0
- package/dist/request-context.d.mts +49 -0
- package/dist/request-context.mjs +42 -0
- package/dist/runner-B5l1JfOj.d.mts +26 -0
- package/dist/runner-BGUGywgG.mjs +1529 -0
- package/dist/runtime.d.mts +25 -0
- package/dist/runtime.mjs +41 -0
- package/dist/search-BNruJHDL.mjs +11054 -0
- package/dist/seed/index.d.mts +3 -0
- package/dist/seed/index.mjs +15 -0
- package/dist/seo/index.d.mts +69 -0
- package/dist/seo/index.mjs +69 -0
- package/dist/storage/local.d.mts +38 -0
- package/dist/storage/local.mjs +165 -0
- package/dist/storage/s3.d.mts +31 -0
- package/dist/storage/s3.mjs +174 -0
- package/dist/tokens-4vgYuXsZ.mjs +170 -0
- package/dist/transport-C5FYnid7.mjs +417 -0
- package/dist/transport-gIL-e43D.d.mts +41 -0
- package/dist/types-BawVha09.mjs +30 -0
- package/dist/types-BgQeVaPj.d.mts +192 -0
- package/dist/types-CLLdsG3g.d.mts +103 -0
- package/dist/types-D38djUXv.d.mts +1196 -0
- package/dist/types-DShnjzb6.mjs +15 -0
- package/dist/types-DkvMXalq.d.mts +425 -0
- package/dist/types-DuNbGKjF.mjs +74 -0
- package/dist/types-ju-_ORz7.d.mts +182 -0
- package/dist/validate-CXnRKfJK.mjs +327 -0
- package/dist/validate-CqRJb_xU.mjs +96 -0
- package/dist/validate-DVKJJ-M_.d.mts +377 -0
- package/locals.d.ts +47 -0
- 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 };
|