emdash 0.4.0 → 0.6.0

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 (212) hide show
  1. package/dist/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
  2. package/dist/adapters-Di31kZ28.d.mts.map +1 -0
  3. package/dist/{apply-Cma_PiF6.mjs → apply-B4MsLM-w.mjs} +27 -12
  4. package/dist/apply-B4MsLM-w.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +208 -34
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +34 -9
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +1 -1
  14. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  15. package/dist/astro/middleware/request-context.mjs +5 -3
  16. package/dist/astro/middleware/request-context.mjs.map +1 -1
  17. package/dist/astro/middleware/setup.mjs +1 -1
  18. package/dist/astro/middleware.d.mts.map +1 -1
  19. package/dist/astro/middleware.mjs +460 -180
  20. package/dist/astro/middleware.mjs.map +1 -1
  21. package/dist/astro/types.d.mts +8 -8
  22. package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
  23. package/dist/byline-C4OVd8b3.mjs.map +1 -0
  24. package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
  25. package/dist/bylines-hPTW79hw.mjs.map +1 -0
  26. package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
  27. package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
  28. package/dist/chunks-HGz06Soa.mjs +19 -0
  29. package/dist/chunks-HGz06Soa.mjs.map +1 -0
  30. package/dist/cli/index.mjs +9 -8
  31. package/dist/cli/index.mjs.map +1 -1
  32. package/dist/client/cf-access.d.mts +1 -1
  33. package/dist/client/index.d.mts +1 -1
  34. package/dist/client/index.mjs +1 -1
  35. package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
  36. package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
  37. package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
  38. package/dist/connection-2igzM-AT.mjs.map +1 -0
  39. package/dist/database/instrumentation.d.mts +45 -0
  40. package/dist/database/instrumentation.d.mts.map +1 -0
  41. package/dist/database/instrumentation.mjs +61 -0
  42. package/dist/database/instrumentation.mjs.map +1 -0
  43. package/dist/db/index.d.mts +3 -3
  44. package/dist/db/index.mjs.map +1 -1
  45. package/dist/db/libsql.d.mts +1 -1
  46. package/dist/db/postgres.d.mts +1 -1
  47. package/dist/db/sqlite.d.mts +1 -1
  48. package/dist/db-errors-D0UT85nC.mjs +41 -0
  49. package/dist/db-errors-D0UT85nC.mjs.map +1 -0
  50. package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
  51. package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
  52. package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
  53. package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
  54. package/dist/{index-CRg3PWfZ.d.mts → index-BYv0mB9g.d.mts} +135 -19
  55. package/dist/index-BYv0mB9g.d.mts.map +1 -0
  56. package/dist/index.d.mts +11 -11
  57. package/dist/index.mjs +20 -18
  58. package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
  59. package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
  60. package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
  61. package/dist/loader-DeiBJEMe.mjs.map +1 -0
  62. package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
  63. package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
  64. package/dist/media/index.d.mts +1 -1
  65. package/dist/media/index.mjs +1 -1
  66. package/dist/media/local-runtime.d.mts +7 -7
  67. package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
  68. package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
  69. package/dist/page/index.d.mts +11 -2
  70. package/dist/page/index.d.mts.map +1 -1
  71. package/dist/page/index.mjs +23 -1
  72. package/dist/page/index.mjs.map +1 -1
  73. package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
  74. package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
  75. package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
  76. package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
  77. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  78. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  79. package/dist/{query-B6Vu0d2i.mjs → query-Bk_3vKvU.mjs} +78 -11
  80. package/dist/query-Bk_3vKvU.mjs.map +1 -0
  81. package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
  82. package/dist/registry-Ci3WxVAr.mjs.map +1 -0
  83. package/dist/request-cache-DiR961CV.mjs +79 -0
  84. package/dist/request-cache-DiR961CV.mjs.map +1 -0
  85. package/dist/request-context.d.mts +19 -16
  86. package/dist/request-context.d.mts.map +1 -1
  87. package/dist/request-context.mjs.map +1 -1
  88. package/dist/{runner-DYv3rX8P.d.mts → runner-Fl2NcUUz.d.mts} +2 -2
  89. package/dist/{runner-DYv3rX8P.d.mts.map → runner-Fl2NcUUz.d.mts.map} +1 -1
  90. package/dist/runtime.d.mts +6 -6
  91. package/dist/runtime.mjs +1 -1
  92. package/dist/{search-B5p9D36n.mjs → search-DI4bM2w9.mjs} +110 -209
  93. package/dist/search-DI4bM2w9.mjs.map +1 -0
  94. package/dist/seed/index.d.mts +2 -2
  95. package/dist/seed/index.mjs +8 -7
  96. package/dist/seo/index.d.mts +1 -1
  97. package/dist/storage/local.d.mts +1 -1
  98. package/dist/storage/local.mjs +1 -1
  99. package/dist/storage/s3.d.mts +1 -1
  100. package/dist/storage/s3.mjs +1 -1
  101. package/dist/taxonomies-DbrKzDju.mjs +308 -0
  102. package/dist/taxonomies-DbrKzDju.mjs.map +1 -0
  103. package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
  104. package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
  105. package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
  106. package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
  107. package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
  108. package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
  109. package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
  110. package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
  111. package/dist/{types-B6BzlZxx.d.mts → types-8xrvl_68.d.mts} +1 -1
  112. package/dist/{types-B6BzlZxx.d.mts.map → types-8xrvl_68.d.mts.map} +1 -1
  113. package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
  114. package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
  115. package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
  116. package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
  117. package/dist/{types-gLYVCXCQ.d.mts → types-CnZYHyLW.d.mts} +55 -5
  118. package/dist/types-CnZYHyLW.d.mts.map +1 -0
  119. package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
  120. package/dist/types-DDS4MxsT.mjs.map +1 -0
  121. package/dist/{types-BYWYxLcp.d.mts → types-DgrIP0tF.d.mts} +9 -2
  122. package/dist/types-DgrIP0tF.d.mts.map +1 -0
  123. package/dist/{validate-CcNRWH6I.d.mts → validate-CaLH1Ia2.d.mts} +5 -52
  124. package/dist/validate-CaLH1Ia2.d.mts.map +1 -0
  125. package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
  126. package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
  127. package/dist/version-Uaf2ynPX.mjs +7 -0
  128. package/dist/{version-DlTDRdpv.mjs.map → version-Uaf2ynPX.mjs.map} +1 -1
  129. package/package.json +10 -5
  130. package/src/after.ts +62 -0
  131. package/src/api/handlers/oauth-authorization.ts +2 -32
  132. package/src/api/handlers/oauth-clients.ts +40 -4
  133. package/src/api/handlers/taxonomies.ts +13 -0
  134. package/src/api/oauth/redirect-uri.ts +34 -0
  135. package/src/api/openapi/document.ts +126 -118
  136. package/src/api/schemas/auth.ts +7 -0
  137. package/src/api/schemas/media.ts +26 -15
  138. package/src/api/schemas/schema.ts +1 -0
  139. package/src/astro/integration/font-provider.ts +176 -0
  140. package/src/astro/integration/index.ts +42 -0
  141. package/src/astro/integration/routes.ts +17 -1
  142. package/src/astro/integration/runtime.ts +63 -0
  143. package/src/astro/integration/virtual-modules.ts +41 -39
  144. package/src/astro/integration/vite-config.ts +16 -5
  145. package/src/astro/middleware/auth.ts +39 -6
  146. package/src/astro/middleware/request-context.ts +15 -3
  147. package/src/astro/middleware.ts +340 -263
  148. package/src/astro/routes/admin.astro +10 -5
  149. package/src/astro/routes/api/auth/invite/register-options.ts +78 -0
  150. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  151. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
  152. package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
  153. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +1 -1
  154. package/src/astro/routes/api/media/upload-url.ts +10 -2
  155. package/src/astro/routes/api/media.ts +10 -7
  156. package/src/astro/routes/api/oauth/register.ts +178 -0
  157. package/src/astro/routes/api/oauth/token.ts +15 -0
  158. package/src/astro/routes/api/openapi.json.ts +15 -5
  159. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
  160. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
  161. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
  162. package/src/astro/routes/api/search/index.ts +5 -0
  163. package/src/astro/routes/api/search/suggest.ts +3 -0
  164. package/src/astro/routes/api/taxonomies/index.ts +1 -0
  165. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +6 -4
  166. package/src/bylines/index.ts +22 -45
  167. package/src/components/EmDashHead.astro +23 -7
  168. package/src/components/Table.astro +73 -41
  169. package/src/components/index.ts +2 -12
  170. package/src/components/marks.ts +20 -0
  171. package/src/database/connection.ts +23 -1
  172. package/src/database/instrumentation.ts +98 -0
  173. package/src/db/adapters.ts +15 -0
  174. package/src/emdash-runtime.ts +309 -91
  175. package/src/index.ts +6 -0
  176. package/src/loader.ts +19 -24
  177. package/src/menus/index.ts +6 -3
  178. package/src/page/index.ts +1 -1
  179. package/src/page/seo-contributions.ts +36 -0
  180. package/src/plugins/context.ts +1 -0
  181. package/src/plugins/email-console.ts +9 -2
  182. package/src/plugins/types.ts +8 -0
  183. package/src/query.ts +104 -7
  184. package/src/request-cache.ts +106 -0
  185. package/src/request-context.ts +19 -0
  186. package/src/schema/query.ts +5 -2
  187. package/src/schema/registry.ts +243 -166
  188. package/src/schema/types.ts +13 -2
  189. package/src/schema/zod-generator.ts +4 -0
  190. package/src/search/fts-manager.ts +19 -5
  191. package/src/search/query.ts +4 -3
  192. package/src/seed/apply.ts +15 -1
  193. package/src/settings/index.ts +24 -5
  194. package/src/taxonomies/index.ts +324 -124
  195. package/src/utils/db-errors.ts +46 -0
  196. package/src/virtual-modules.d.ts +31 -10
  197. package/src/widgets/index.ts +54 -25
  198. package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
  199. package/dist/apply-Cma_PiF6.mjs.map +0 -1
  200. package/dist/byline-WuOq9MFJ.mjs.map +0 -1
  201. package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
  202. package/dist/connection-B4zVnQIa.mjs.map +0 -1
  203. package/dist/index-CRg3PWfZ.d.mts.map +0 -1
  204. package/dist/loader-BYzwzORf.mjs.map +0 -1
  205. package/dist/query-B6Vu0d2i.mjs.map +0 -1
  206. package/dist/registry-BgnP3ysR.mjs.map +0 -1
  207. package/dist/search-B5p9D36n.mjs.map +0 -1
  208. package/dist/types-BYWYxLcp.d.mts.map +0 -1
  209. package/dist/types-gLYVCXCQ.d.mts.map +0 -1
  210. package/dist/types-xxCWI3j0.mjs.map +0 -1
  211. package/dist/validate-CcNRWH6I.d.mts.map +0 -1
  212. package/dist/version-DlTDRdpv.mjs +0 -7
@@ -188,53 +188,69 @@ export class SchemaRegistry {
188
188
  ? supportsArray.includes("seo")
189
189
  : existing.hasSeo;
190
190
 
191
- await this.db
192
- .updateTable("_emdash_collections")
193
- .set({
194
- label: input.label ?? existing.label,
195
- label_singular: input.labelSingular ?? existing.labelSingular ?? null,
196
- description: input.description ?? existing.description ?? null,
197
- icon: input.icon ?? existing.icon ?? null,
198
- supports: input.supports
199
- ? JSON.stringify(input.supports)
200
- : JSON.stringify(existing.supports),
201
- url_pattern:
202
- input.urlPattern !== undefined
203
- ? (input.urlPattern ?? null)
204
- : (existing.urlPattern ?? null),
205
- has_seo: hasSeo ? 1 : 0,
206
- comments_enabled:
207
- input.commentsEnabled !== undefined
208
- ? input.commentsEnabled
209
- ? 1
210
- : 0
211
- : existing.commentsEnabled
212
- ? 1
213
- : 0,
214
- comments_moderation: input.commentsModeration ?? existing.commentsModeration,
215
- comments_closed_after_days:
216
- input.commentsClosedAfterDays !== undefined
217
- ? input.commentsClosedAfterDays
218
- : existing.commentsClosedAfterDays,
219
- comments_auto_approve_users:
220
- input.commentsAutoApproveUsers !== undefined
221
- ? input.commentsAutoApproveUsers
222
- ? 1
223
- : 0
224
- : existing.commentsAutoApproveUsers
225
- ? 1
226
- : 0,
227
- updated_at: now,
228
- })
229
- .where("slug", "=", slug)
230
- .execute();
191
+ return withTransaction(this.db, async (trx) => {
192
+ await trx
193
+ .updateTable("_emdash_collections")
194
+ .set({
195
+ label: input.label ?? existing.label,
196
+ label_singular: input.labelSingular ?? existing.labelSingular ?? null,
197
+ description: input.description ?? existing.description ?? null,
198
+ icon: input.icon ?? existing.icon ?? null,
199
+ supports: input.supports
200
+ ? JSON.stringify(input.supports)
201
+ : JSON.stringify(existing.supports),
202
+ url_pattern:
203
+ input.urlPattern !== undefined
204
+ ? (input.urlPattern ?? null)
205
+ : (existing.urlPattern ?? null),
206
+ has_seo: hasSeo ? 1 : 0,
207
+ comments_enabled:
208
+ input.commentsEnabled !== undefined
209
+ ? input.commentsEnabled
210
+ ? 1
211
+ : 0
212
+ : existing.commentsEnabled
213
+ ? 1
214
+ : 0,
215
+ comments_moderation: input.commentsModeration ?? existing.commentsModeration,
216
+ comments_closed_after_days:
217
+ input.commentsClosedAfterDays !== undefined
218
+ ? input.commentsClosedAfterDays
219
+ : existing.commentsClosedAfterDays,
220
+ comments_auto_approve_users:
221
+ input.commentsAutoApproveUsers !== undefined
222
+ ? input.commentsAutoApproveUsers
223
+ ? 1
224
+ : 0
225
+ : existing.commentsAutoApproveUsers
226
+ ? 1
227
+ : 0,
228
+ updated_at: now,
229
+ })
230
+ .where("slug", "=", slug)
231
+ .execute();
231
232
 
232
- const updated = await this.getCollection(slug);
233
- if (!updated) {
234
- throw new SchemaError("Failed to update collection", "UPDATE_FAILED");
235
- }
233
+ const row = await trx
234
+ .selectFrom("_emdash_collections")
235
+ .where("slug", "=", slug)
236
+ .selectAll()
237
+ .executeTakeFirst();
238
+
239
+ if (!row) {
240
+ throw new SchemaError("Failed to update collection", "UPDATE_FAILED");
241
+ }
236
242
 
237
- return updated;
243
+ // Sync FTS state when the supports array changes (e.g. search toggled on/off)
244
+ if (input.supports !== undefined) {
245
+ const hadSearch = existing.supports.includes("search");
246
+ const hasSearch = (JSON.parse(row.supports ?? "[]") as string[]).includes("search");
247
+ if (hadSearch !== hasSearch) {
248
+ await this.syncSearchState(slug, trx);
249
+ }
250
+ }
251
+
252
+ return this.mapCollectionRow(row);
253
+ });
238
254
  }
239
255
 
240
256
  /**
@@ -257,11 +273,18 @@ export class SchemaRegistry {
257
273
  }
258
274
  }
259
275
 
260
- // Drop the content table
261
- await this.dropContentTable(slug);
276
+ await withTransaction(this.db, async (trx) => {
277
+ // Drop FTS table and triggers before dropping the content table
278
+ const ftsManager = new FTSManager(trx);
279
+ await ftsManager.dropFtsTable(slug);
280
+
281
+ // Drop the content table
282
+ const tableName = this.getTableName(slug);
283
+ await sql`DROP TABLE IF EXISTS ${sql.ref(tableName)}`.execute(trx);
262
284
 
263
- // Delete the collection record (fields will cascade)
264
- await this.db.deleteFrom("_emdash_collections").where("id", "=", existing.id).execute();
285
+ // Delete the collection record (fields will cascade)
286
+ await trx.deleteFrom("_emdash_collections").where("id", "=", existing.id).execute();
287
+ });
265
288
  }
266
289
 
267
290
  // ============================================
@@ -336,40 +359,63 @@ export class SchemaRegistry {
336
359
 
337
360
  const sortOrder = input.sortOrder ?? (maxSort?.max ?? -1) + 1;
338
361
 
339
- // Insert field record
340
- await this.db
341
- .insertInto("_emdash_fields")
342
- .values({
343
- id,
344
- collection_id: collection.id,
345
- slug: input.slug,
346
- label: input.label,
347
- type: input.type,
348
- column_type: columnType,
349
- required: input.required ? 1 : 0,
350
- unique: input.unique ? 1 : 0,
351
- default_value: input.defaultValue !== undefined ? JSON.stringify(input.defaultValue) : null,
352
- validation: input.validation ? JSON.stringify(input.validation) : null,
353
- widget: input.widget ?? null,
354
- options: input.options ? JSON.stringify(input.options) : null,
355
- sort_order: sortOrder,
356
- searchable: input.searchable ? 1 : 0,
357
- translatable: input.translatable === false ? 0 : 1,
358
- })
359
- .execute();
362
+ return withTransaction(this.db, async (trx) => {
363
+ // Insert field record
364
+ await trx
365
+ .insertInto("_emdash_fields")
366
+ .values({
367
+ id,
368
+ collection_id: collection.id,
369
+ slug: input.slug,
370
+ label: input.label,
371
+ type: input.type,
372
+ column_type: columnType,
373
+ required: input.required ? 1 : 0,
374
+ unique: input.unique ? 1 : 0,
375
+ default_value:
376
+ input.defaultValue !== undefined ? JSON.stringify(input.defaultValue) : null,
377
+ validation: input.validation ? JSON.stringify(input.validation) : null,
378
+ widget: input.widget ?? null,
379
+ options: input.options ? JSON.stringify(input.options) : null,
380
+ sort_order: sortOrder,
381
+ searchable: input.searchable ? 1 : 0,
382
+ translatable: input.translatable === false ? 0 : 1,
383
+ })
384
+ .execute();
360
385
 
361
- // Add column to content table
362
- await this.addColumn(collectionSlug, input.slug, input.type, {
363
- required: input.required,
364
- defaultValue: input.defaultValue,
365
- });
386
+ // Add column to content table — pass trx to stay on the same connection
387
+ await this.addColumn(
388
+ collectionSlug,
389
+ input.slug,
390
+ input.type,
391
+ {
392
+ required: input.required,
393
+ defaultValue: input.defaultValue,
394
+ },
395
+ trx,
396
+ );
366
397
 
367
- const field = await this.getField(collectionSlug, input.slug);
368
- if (!field) {
369
- throw new SchemaError("Failed to create field", "CREATE_FAILED");
370
- }
398
+ // Read the created field via trx (not this.db) to avoid connection mutex deadlock
399
+ const fieldRow = await trx
400
+ .selectFrom("_emdash_fields")
401
+ .where("collection_id", "=", collection.id)
402
+ .where("slug", "=", input.slug)
403
+ .selectAll()
404
+ .executeTakeFirst();
405
+
406
+ if (!fieldRow) {
407
+ throw new SchemaError("Failed to create field", "CREATE_FAILED");
408
+ }
409
+
410
+ const field = this.mapFieldRow(fieldRow);
411
+
412
+ // Sync search state if this field is searchable; support checks are handled by syncSearchState()
413
+ if (input.searchable) {
414
+ await this.syncSearchState(collectionSlug, trx);
415
+ }
371
416
 
372
- return field;
417
+ return field;
418
+ });
373
419
  }
374
420
 
375
421
  /**
@@ -388,84 +434,106 @@ export class SchemaRegistry {
388
434
  );
389
435
  }
390
436
 
391
- await this.db
392
- .updateTable("_emdash_fields")
393
- .set({
394
- label: input.label ?? field.label,
395
- required: input.required !== undefined ? (input.required ? 1 : 0) : field.required ? 1 : 0,
396
- unique: input.unique !== undefined ? (input.unique ? 1 : 0) : field.unique ? 1 : 0,
397
- searchable:
398
- input.searchable !== undefined ? (input.searchable ? 1 : 0) : field.searchable ? 1 : 0,
399
- translatable:
400
- input.translatable !== undefined
401
- ? input.translatable
402
- ? 1
403
- : 0
404
- : field.translatable
405
- ? 1
406
- : 0,
407
- default_value:
408
- input.defaultValue !== undefined
409
- ? JSON.stringify(input.defaultValue)
410
- : field.defaultValue !== undefined
411
- ? JSON.stringify(field.defaultValue)
437
+ return withTransaction(this.db, async (trx) => {
438
+ await trx
439
+ .updateTable("_emdash_fields")
440
+ .set({
441
+ label: input.label ?? field.label,
442
+ required:
443
+ input.required !== undefined ? (input.required ? 1 : 0) : field.required ? 1 : 0,
444
+ unique: input.unique !== undefined ? (input.unique ? 1 : 0) : field.unique ? 1 : 0,
445
+ searchable:
446
+ input.searchable !== undefined ? (input.searchable ? 1 : 0) : field.searchable ? 1 : 0,
447
+ translatable:
448
+ input.translatable !== undefined
449
+ ? input.translatable
450
+ ? 1
451
+ : 0
452
+ : field.translatable
453
+ ? 1
454
+ : 0,
455
+ default_value:
456
+ input.defaultValue !== undefined
457
+ ? JSON.stringify(input.defaultValue)
458
+ : field.defaultValue !== undefined
459
+ ? JSON.stringify(field.defaultValue)
460
+ : null,
461
+ validation: input.validation
462
+ ? JSON.stringify(input.validation)
463
+ : field.validation
464
+ ? JSON.stringify(field.validation)
412
465
  : null,
413
- validation: input.validation
414
- ? JSON.stringify(input.validation)
415
- : field.validation
416
- ? JSON.stringify(field.validation)
417
- : null,
418
- widget: input.widget ?? field.widget ?? null,
419
- options: input.options
420
- ? JSON.stringify(input.options)
421
- : field.options
422
- ? JSON.stringify(field.options)
423
- : null,
424
- sort_order: input.sortOrder ?? field.sortOrder,
425
- })
426
- .where("id", "=", field.id)
427
- .execute();
466
+ widget: input.widget ?? field.widget ?? null,
467
+ options: input.options
468
+ ? JSON.stringify(input.options)
469
+ : field.options
470
+ ? JSON.stringify(field.options)
471
+ : null,
472
+ sort_order: input.sortOrder ?? field.sortOrder,
473
+ })
474
+ .where("id", "=", field.id)
475
+ .execute();
428
476
 
429
- const updated = await this.getField(collectionSlug, fieldSlug);
430
- if (!updated) {
431
- throw new SchemaError("Failed to update field", "UPDATE_FAILED");
432
- }
477
+ // Read the updated field via trx (not this.db) to avoid connection mutex deadlock
478
+ const updatedRow = await trx
479
+ .selectFrom("_emdash_fields")
480
+ .where("collection_id", "=", field.collectionId)
481
+ .where("slug", "=", fieldSlug)
482
+ .selectAll()
483
+ .executeTakeFirst();
433
484
 
434
- // If searchable changed, rebuild the FTS index for this collection
435
- const searchableChanged =
436
- input.searchable !== undefined && input.searchable !== field.searchable;
437
- if (searchableChanged) {
438
- await this.rebuildSearchIndex(collectionSlug);
439
- }
485
+ if (!updatedRow) {
486
+ throw new SchemaError("Failed to update field", "UPDATE_FAILED");
487
+ }
488
+
489
+ const updated = this.mapFieldRow(updatedRow);
490
+
491
+ // If searchable changed, sync FTS state for this collection
492
+ const searchableChanged =
493
+ input.searchable !== undefined && input.searchable !== field.searchable;
494
+ if (searchableChanged) {
495
+ await this.syncSearchState(collectionSlug, trx);
496
+ }
440
497
 
441
- return updated;
498
+ return updated;
499
+ });
442
500
  }
443
501
 
444
502
  /**
445
- * Rebuild the search index for a collection
503
+ * Synchronize an existing FTS index with the collection's current state.
504
+ *
505
+ * Only rebuilds or disables — never first-time enables. First-time FTS
506
+ * enablement is handled by the seed's explicit enableSearch call (which
507
+ * is try-caught) or the admin UI toggle.
508
+ *
509
+ * - FTS active + still has search support and searchable fields → rebuild
510
+ * - FTS active + lost search support or no searchable fields → disable
511
+ * - FTS not active → no-op
446
512
  *
447
- * Called when searchable fields change. If search is enabled for the collection,
448
- * this will rebuild the FTS table with the updated field list.
513
+ * Pass `db` when calling from within a transaction so FTS operations
514
+ * participate in the same transaction and are rolled back on failure.
449
515
  */
450
- private async rebuildSearchIndex(collectionSlug: string): Promise<void> {
451
- const ftsManager = new FTSManager(this.db);
516
+ private async syncSearchState(collectionSlug: string, db?: Kysely<Database>): Promise<void> {
517
+ const conn = db ?? this.db;
518
+ const ftsManager = new FTSManager(conn);
452
519
 
453
- // Check if search is enabled for this collection
454
- const config = await ftsManager.getSearchConfig(collectionSlug);
455
- if (!config?.enabled) {
456
- // Search not enabled, nothing to do
457
- return;
458
- }
520
+ // Query via conn (not this.db) to avoid connection mutex deadlock when called inside a transaction
521
+ const row = await conn
522
+ .selectFrom("_emdash_collections")
523
+ .where("slug", "=", collectionSlug)
524
+ .select("supports")
525
+ .executeTakeFirst();
526
+ if (!row) return;
459
527
 
460
- // Get current searchable fields
528
+ const wantsSearch = (JSON.parse(row.supports ?? "[]") as string[]).includes("search");
461
529
  const searchableFields = await ftsManager.getSearchableFields(collectionSlug);
530
+ const config = await ftsManager.getSearchConfig(collectionSlug);
531
+ const ftsActive = config?.enabled === true;
462
532
 
463
- if (searchableFields.length === 0) {
464
- // No searchable fields left, disable search
533
+ if (wantsSearch && searchableFields.length > 0 && ftsActive) {
534
+ await ftsManager.rebuildIndex(collectionSlug, searchableFields, config?.weights);
535
+ } else if (ftsActive && (!wantsSearch || searchableFields.length === 0)) {
465
536
  await ftsManager.disableSearch(collectionSlug);
466
- } else {
467
- // Rebuild the index with updated fields
468
- await ftsManager.rebuildIndex(collectionSlug, searchableFields, config.weights);
469
537
  }
470
538
  }
471
539
 
@@ -481,11 +549,22 @@ export class SchemaRegistry {
481
549
  );
482
550
  }
483
551
 
484
- // Drop column from content table
485
- await this.dropColumn(collectionSlug, fieldSlug);
552
+ await withTransaction(this.db, async (trx) => {
553
+ // Delete the field record first so syncSearchState sees the updated field list.
554
+ // This ordering matters for searchable fields: SQLite prevents dropping a column
555
+ // that is still referenced by a trigger. syncSearchState drops and recreates the
556
+ // FTS triggers based on the remaining searchable fields, clearing the dependency
557
+ // before we attempt the ALTER TABLE DROP COLUMN below.
558
+ await trx.deleteFrom("_emdash_fields").where("id", "=", field.id).execute();
559
+
560
+ // If the deleted field was searchable, sync FTS state (removes old triggers)
561
+ if (field.searchable) {
562
+ await this.syncSearchState(collectionSlug, trx);
563
+ }
486
564
 
487
- // Delete field record
488
- await this.db.deleteFrom("_emdash_fields").where("id", "=", field.id).execute();
565
+ // Drop column from content table — safe now because FTS triggers are gone
566
+ await this.dropColumn(collectionSlug, fieldSlug, trx);
567
+ });
489
568
  }
490
569
 
491
570
  /**
@@ -603,14 +682,6 @@ export class SchemaRegistry {
603
682
  `.execute(conn);
604
683
  }
605
684
 
606
- /**
607
- * Drop a content table
608
- */
609
- private async dropContentTable(slug: string): Promise<void> {
610
- const tableName = this.getTableName(slug);
611
- await sql`DROP TABLE IF EXISTS ${sql.ref(tableName)}`.execute(this.db);
612
- }
613
-
614
685
  /**
615
686
  * Add a column to a content table
616
687
  */
@@ -619,7 +690,9 @@ export class SchemaRegistry {
619
690
  fieldSlug: string,
620
691
  fieldType: FieldType,
621
692
  options?: { required?: boolean; defaultValue?: unknown },
693
+ db?: Kysely<Database>,
622
694
  ): Promise<void> {
695
+ const conn = db ?? this.db;
623
696
  const tableName = this.getTableName(collectionSlug);
624
697
  const columnType = FIELD_TYPE_TO_COLUMN[fieldType];
625
698
  const columnName = this.getColumnName(fieldSlug);
@@ -629,35 +702,39 @@ export class SchemaRegistry {
629
702
  if (options?.required && options?.defaultValue !== undefined) {
630
703
  const defaultVal = this.formatDefaultValue(options.defaultValue, fieldType);
631
704
  await sql`
632
- ALTER TABLE ${sql.ref(tableName)}
705
+ ALTER TABLE ${sql.ref(tableName)}
633
706
  ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)} NOT NULL DEFAULT ${sql.raw(defaultVal)}
634
- `.execute(this.db);
707
+ `.execute(conn);
635
708
  } else if (options?.required) {
636
709
  // For required fields without default, use empty string/0 as default
637
710
  const defaultVal = this.getEmptyDefault(fieldType);
638
711
  await sql`
639
- ALTER TABLE ${sql.ref(tableName)}
712
+ ALTER TABLE ${sql.ref(tableName)}
640
713
  ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)} NOT NULL DEFAULT ${sql.raw(defaultVal)}
641
- `.execute(this.db);
714
+ `.execute(conn);
642
715
  } else {
643
716
  await sql`
644
- ALTER TABLE ${sql.ref(tableName)}
717
+ ALTER TABLE ${sql.ref(tableName)}
645
718
  ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)}
646
- `.execute(this.db);
719
+ `.execute(conn);
647
720
  }
648
721
  }
649
722
 
650
723
  /**
651
724
  * Drop a column from a content table
652
725
  */
653
- private async dropColumn(collectionSlug: string, fieldSlug: string): Promise<void> {
726
+ private async dropColumn(
727
+ collectionSlug: string,
728
+ fieldSlug: string,
729
+ db?: Kysely<Database>,
730
+ ): Promise<void> {
654
731
  const tableName = this.getTableName(collectionSlug);
655
732
  const columnName = this.getColumnName(fieldSlug);
656
733
 
657
734
  await sql`
658
- ALTER TABLE ${sql.ref(tableName)}
735
+ ALTER TABLE ${sql.ref(tableName)}
659
736
  DROP COLUMN ${sql.ref(columnName)}
660
- `.execute(this.db);
737
+ `.execute(db ?? this.db);
661
738
  }
662
739
 
663
740
  // ============================================
@@ -671,7 +748,7 @@ export class SchemaRegistry {
671
748
  const tableName = this.getTableName(slug);
672
749
  try {
673
750
  const result = await sql<{ count: number }>`
674
- SELECT COUNT(*) as count FROM ${sql.ref(tableName)}
751
+ SELECT COUNT(*) as count FROM ${sql.ref(tableName)}
675
752
  WHERE deleted_at IS NULL
676
753
  `.execute(this.db);
677
754
  return (result.rows[0]?.count ?? 0) > 0;
@@ -11,6 +11,7 @@
11
11
  export type FieldType =
12
12
  | "string"
13
13
  | "text"
14
+ | "url"
14
15
  | "number"
15
16
  | "integer"
16
17
  | "boolean"
@@ -31,6 +32,7 @@ export type FieldType =
31
32
  export const FIELD_TYPES: readonly FieldType[] = [
32
33
  "string",
33
34
  "text",
35
+ "url",
34
36
  "number",
35
37
  "integer",
36
38
  "boolean",
@@ -69,6 +71,7 @@ export const FIELD_TYPE_TO_COLUMN: Record<FieldType, ColumnType> = {
69
71
  reference: "TEXT",
70
72
  json: "JSON",
71
73
  slug: "TEXT",
74
+ url: "TEXT",
72
75
  repeater: "JSON",
73
76
  };
74
77
 
@@ -99,7 +102,7 @@ export type CollectionSource =
99
102
  /** Sub-field definition for repeater fields */
100
103
  export interface RepeaterSubField {
101
104
  slug: string;
102
- type: "string" | "text" | "number" | "integer" | "boolean" | "datetime" | "select";
105
+ type: "string" | "text" | "url" | "number" | "integer" | "boolean" | "datetime" | "select";
103
106
  label: string;
104
107
  required?: boolean;
105
108
  options?: string[]; // For select sub-fields
@@ -109,6 +112,7 @@ export interface RepeaterSubField {
109
112
  export const REPEATER_SUB_FIELD_TYPES = [
110
113
  "string",
111
114
  "text",
115
+ "url",
112
116
  "number",
113
117
  "integer",
114
118
  "boolean",
@@ -270,7 +274,10 @@ export interface CollectionWithFields extends Collection {
270
274
  }
271
275
 
272
276
  /**
273
- * Reserved field slugs that cannot be used
277
+ * Reserved field slugs that cannot be used.
278
+ *
279
+ * Includes names reserved for runtime hydration (`terms`, `bylines`, `byline`)
280
+ * so user-defined fields never shadow the auto-hydrated values on entry.data.
274
281
  */
275
282
  export const RESERVED_FIELD_SLUGS = [
276
283
  "id",
@@ -286,6 +293,10 @@ export const RESERVED_FIELD_SLUGS = [
286
293
  "version",
287
294
  "live_revision_id",
288
295
  "draft_revision_id",
296
+ // Runtime-hydrated fields
297
+ "terms",
298
+ "bylines",
299
+ "byline",
289
300
  ];
290
301
 
291
302
  /**
@@ -53,6 +53,9 @@ export function generateFieldSchema(field: Field): ZodTypeAny {
53
53
  */
54
54
  function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
55
55
  switch (type) {
56
+ case "url":
57
+ return z.string().url();
58
+
56
59
  case "string":
57
60
  case "text":
58
61
  case "slug":
@@ -330,6 +333,7 @@ function fieldTypeToTypeScript(field: Field): string {
330
333
  case "string":
331
334
  case "text":
332
335
  case "slug":
336
+ case "url":
333
337
  case "datetime":
334
338
  return "string";
335
339
 
@@ -113,7 +113,6 @@ export class FTSManager {
113
113
  const contentTable = this.getContentTableName(collectionSlug);
114
114
  const fieldList = searchableFields.join(", ");
115
115
  const newFieldList = searchableFields.map((f) => `NEW.${f}`).join(", ");
116
-
117
116
  // Insert trigger - only index non-deleted content
118
117
  await sql
119
118
  .raw(`
@@ -342,7 +341,8 @@ export class FTSManager {
342
341
  async disableSearch(collectionSlug: string): Promise<void> {
343
342
  if (!isSqlite(this.db)) return;
344
343
  await this.dropFtsTable(collectionSlug);
345
- await this.setSearchConfig(collectionSlug, { enabled: false });
344
+ const existing = await this.getSearchConfig(collectionSlug);
345
+ await this.setSearchConfig(collectionSlug, { enabled: false, weights: existing?.weights });
346
346
  }
347
347
 
348
348
  /**
@@ -354,6 +354,7 @@ export class FTSManager {
354
354
  if (!isSqlite(this.db)) return null;
355
355
  this.validateInputs(collectionSlug);
356
356
  const ftsTable = this.getFtsTableName(collectionSlug);
357
+ const ftsDocsizeTable = `${ftsTable}_docsize`;
357
358
 
358
359
  // Check if table exists
359
360
  if (!(await this.ftsTableExists(collectionSlug))) {
@@ -362,7 +363,7 @@ export class FTSManager {
362
363
 
363
364
  // Count indexed rows
364
365
  const result = await sql<{ count: number }>`
365
- SELECT COUNT(*) as count FROM "${sql.raw(ftsTable)}"
366
+ SELECT COUNT(*) as count FROM "${sql.raw(ftsDocsizeTable)}"
366
367
  `.execute(this.db);
367
368
 
368
369
  return {
@@ -381,10 +382,19 @@ export class FTSManager {
381
382
  if (!isSqlite(this.db)) return false;
382
383
  this.validateInputs(collectionSlug);
383
384
  const ftsTable = this.getFtsTableName(collectionSlug);
385
+ const ftsDocsizeTable = `${ftsTable}_docsize`;
384
386
  const contentTable = this.getContentTableName(collectionSlug);
387
+ const fields = await this.getSearchableFields(collectionSlug);
388
+ const config = await this.getSearchConfig(collectionSlug);
385
389
 
386
390
  if (!(await this.ftsTableExists(collectionSlug))) {
387
- return false;
391
+ if (!config?.enabled || fields.length === 0) {
392
+ return false;
393
+ }
394
+
395
+ console.warn(`FTS index for "${collectionSlug}" is missing. Rebuilding.`);
396
+ await this.rebuildIndex(collectionSlug, fields, config.weights);
397
+ return true;
388
398
  }
389
399
 
390
400
  // Check 1: Row count mismatch
@@ -393,8 +403,12 @@ export class FTSManager {
393
403
  WHERE deleted_at IS NULL
394
404
  `.execute(this.db);
395
405
 
406
+ // For external-content FTS tables, COUNT(*) on the virtual table is
407
+ // answered from the backing content table, including soft-deleted rows.
408
+ // The docsize shadow table tracks the rows actually present in the
409
+ // full-text index, which is what we need for repair decisions.
396
410
  const ftsCount = await sql<{ count: number }>`
397
- SELECT COUNT(*) as count FROM "${sql.raw(ftsTable)}"
411
+ SELECT COUNT(*) as count FROM "${sql.raw(ftsDocsizeTable)}"
398
412
  `.execute(this.db);
399
413
 
400
414
  const contentRows = contentCount.rows[0]?.count ?? 0;