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
@@ -20,6 +20,7 @@ import type {
20
20
  import type { EmDashManifest, ManifestCollection } from "./astro/types.js";
21
21
  import { getAuthMode } from "./auth/mode.js";
22
22
  import { isSqlite } from "./database/dialect-helpers.js";
23
+ import { kyselyLogOption } from "./database/instrumentation.js";
23
24
  import { runMigrations } from "./database/migrations/runner.js";
24
25
  import { RevisionRepository } from "./database/repositories/revision.js";
25
26
  import type { ContentItem as ContentItemInternal } from "./database/repositories/types.js";
@@ -88,6 +89,7 @@ function isValidMetadataContribution(c: unknown): c is PageMetadataContribution
88
89
  }
89
90
  }
90
91
 
92
+ import { after } from "./after.js";
91
93
  import { loadBundleFromR2 } from "./api/handlers/marketplace.js";
92
94
  import { runSystemCleanup } from "./cleanup.js";
93
95
  import {
@@ -153,6 +155,7 @@ import { FTSManager } from "./search/fts-manager.js";
153
155
  const FIELD_TYPE_TO_KIND: Record<FieldType, string> = {
154
156
  string: "string",
155
157
  slug: "string",
158
+ url: "url",
156
159
  text: "richText",
157
160
  number: "number",
158
161
  integer: "number",
@@ -286,6 +289,18 @@ export class EmDashRuntime {
286
289
  private enabledPlugins: Set<string>;
287
290
  private pluginStates: Map<string, string>;
288
291
 
292
+ private _cachedManifest: EmDashManifest | null = null;
293
+ private _manifestPromise: Promise<EmDashManifest> | null = null;
294
+ private readonly _manifestCacheKey: string;
295
+
296
+ /**
297
+ * Set to true after FTS indexes have been verified for this worker
298
+ * lifetime so we don't re-scan on every admin request. See
299
+ * ensureSearchHealthy().
300
+ */
301
+ private _searchHealthChecked = false;
302
+ private _searchHealthPromise: Promise<void> | null = null;
303
+
289
304
  /** Current hook pipeline. Use the `hooks` getter for external access. */
290
305
  get hooks(): HookPipeline {
291
306
  return this._hooks;
@@ -344,6 +359,7 @@ export class EmDashRuntime {
344
359
  },
345
360
  runtimeDeps: RuntimeDependencies,
346
361
  pipelineRef: { current: HookPipeline },
362
+ manifestCacheKey: string,
347
363
  ) {
348
364
  this._db = db;
349
365
  this.storage = storage;
@@ -364,6 +380,7 @@ export class EmDashRuntime {
364
380
  this.pipelineFactoryOptions = pipelineFactoryOptions;
365
381
  this.runtimeDeps = runtimeDeps;
366
382
  this.pipelineRef = pipelineRef;
383
+ this._manifestCacheKey = manifestCacheKey;
367
384
  }
368
385
 
369
386
  /**
@@ -413,6 +430,7 @@ export class EmDashRuntime {
413
430
  this.enabledPlugins.delete(pluginId);
414
431
  await this.rebuildHookPipeline();
415
432
  }
433
+ this.invalidateManifest();
416
434
  }
417
435
 
418
436
  /**
@@ -565,36 +583,46 @@ export class EmDashRuntime {
565
583
  /**
566
584
  * Create and initialize the runtime
567
585
  */
568
- static async create(deps: RuntimeDependencies): Promise<EmDashRuntime> {
569
- // Initialize database
570
- const db = await EmDashRuntime.getDatabase(deps);
571
-
572
- // Verify and repair FTS indexes (auto-heal crash corruption)
573
- // FTS5 is SQLite-only; on other dialects, search is a no-op until
574
- // the pluggable SearchProvider work lands.
575
- if (isSqlite(db)) {
586
+ static async create(
587
+ deps: RuntimeDependencies,
588
+ timings?: Array<{ name: string; dur: number; desc?: string }>,
589
+ ): Promise<EmDashRuntime> {
590
+ // Helper: time a phase and push into the shared timings array when
591
+ // provided. Uses performance.now() monotonic across async boundaries.
592
+ // No-op when `timings` wasn't passed (preserves backwards compatibility
593
+ // with callers that don't care about per-phase breakdown).
594
+ const phase = async <T>(name: string, desc: string, fn: () => Promise<T>): Promise<T> => {
595
+ if (!timings) return fn();
596
+ const t0 = performance.now();
576
597
  try {
577
- const ftsManager = new FTSManager(db);
578
- const repaired = await ftsManager.verifyAndRepairAll();
579
- if (repaired > 0) {
580
- console.log(`Repaired ${repaired} corrupted FTS index(es) at startup`);
581
- }
582
- } catch {
583
- // FTS tables may not exist yet (pre-setup). Non-fatal.
598
+ return await fn();
599
+ } finally {
600
+ timings.push({ name, dur: performance.now() - t0, desc });
584
601
  }
585
- }
602
+ };
586
603
 
587
- // Initialize storage
604
+ // Initialize database (connects, runs migrations if needed)
605
+ const db = await phase("rt.db", "DB init + migrations", () => EmDashRuntime.getDatabase(deps));
606
+
607
+ // FTS verify/repair is deferred off the cold-start hot path.
608
+ // See EmDashRuntime.ensureSearchHealthy().
609
+
610
+ // Initialize storage (sync)
588
611
  const storage = EmDashRuntime.getStorage(deps);
589
612
 
590
613
  // Fetch plugin states from database
591
614
  let pluginStates: Map<string, string> = new Map();
592
- try {
593
- const states = await db.selectFrom("_plugin_state").select(["plugin_id", "status"]).execute();
594
- pluginStates = new Map(states.map((s) => [s.plugin_id, s.status]));
595
- } catch {
596
- // Plugin state table may not exist yet
597
- }
615
+ await phase("rt.plugins", "Plugin states", async () => {
616
+ try {
617
+ const states = await db
618
+ .selectFrom("_plugin_state")
619
+ .select(["plugin_id", "status"])
620
+ .execute();
621
+ pluginStates = new Map(states.map((s) => [s.plugin_id, s.status]));
622
+ } catch {
623
+ // Plugin state table may not exist yet
624
+ }
625
+ });
598
626
 
599
627
  // Build set of enabled plugins
600
628
  const enabledPlugins = new Set<string>();
@@ -605,21 +633,25 @@ export class EmDashRuntime {
605
633
  }
606
634
  }
607
635
 
608
- // Load site info for plugin context extensions
636
+ // Load site info for plugin context extensions (1 batch query instead of 3)
609
637
  let siteInfo: { siteName?: string; siteUrl?: string; locale?: string } | undefined;
610
- try {
611
- const optionsRepo = new OptionsRepository(db);
612
- const siteName = await optionsRepo.get<string>("emdash:site_title");
613
- const siteUrl = await optionsRepo.get<string>("emdash:site_url");
614
- const locale = await optionsRepo.get<string>("emdash:locale");
615
- siteInfo = {
616
- siteName: siteName ?? undefined,
617
- siteUrl: siteUrl ?? undefined,
618
- locale: locale ?? undefined,
619
- };
620
- } catch {
621
- // Options table may not exist yet (pre-setup)
622
- }
638
+ await phase("rt.site", "Site info options", async () => {
639
+ try {
640
+ const optionsRepo = new OptionsRepository(db);
641
+ const siteOpts = await optionsRepo.getMany<string>([
642
+ "emdash:site_title",
643
+ "emdash:site_url",
644
+ "emdash:locale",
645
+ ]);
646
+ siteInfo = {
647
+ siteName: siteOpts.get("emdash:site_title") ?? undefined,
648
+ siteUrl: siteOpts.get("emdash:site_url") ?? undefined,
649
+ locale: siteOpts.get("emdash:locale") ?? undefined,
650
+ };
651
+ } catch {
652
+ // Options table may not exist yet (pre-setup)
653
+ }
654
+ });
623
655
 
624
656
  // Build the full list of pipeline-eligible plugins: all configured
625
657
  // plugins (regardless of current enabled status) plus built-in plugins.
@@ -685,11 +717,15 @@ export class EmDashRuntime {
685
717
  const pipeline = createHookPipeline(enabledPluginList, pipelineFactoryOptions);
686
718
 
687
719
  // Load sandboxed plugins (build-time)
688
- const sandboxedPlugins = await EmDashRuntime.loadSandboxedPlugins(deps, db);
720
+ const sandboxedPlugins = await phase("rt.sandbox", "Sandboxed plugins", () =>
721
+ EmDashRuntime.loadSandboxedPlugins(deps, db),
722
+ );
689
723
 
690
724
  // Cold-start: load marketplace-installed plugins from site R2
691
725
  if (deps.config.marketplace && storage) {
692
- await EmDashRuntime.loadMarketplacePlugins(db, storage, deps, sandboxedPlugins);
726
+ await phase("rt.market", "Marketplace plugins", () =>
727
+ EmDashRuntime.loadMarketplacePlugins(db, storage, deps, sandboxedPlugins),
728
+ );
693
729
  }
694
730
 
695
731
  // Initialize media providers
@@ -707,7 +743,9 @@ export class EmDashRuntime {
707
743
  }
708
744
 
709
745
  // Resolve exclusive hooks — auto-select providers and sync with DB
710
- await EmDashRuntime.resolveExclusiveHooks(pipeline, db, deps);
746
+ await phase("rt.hooks", "Exclusive hook resolution", () =>
747
+ EmDashRuntime.resolveExclusiveHooks(pipeline, db, deps),
748
+ );
711
749
 
712
750
  // ── Email pipeline ───────────────────────────────────────────────
713
751
  // The email pipeline orchestrates beforeSend → deliver → afterSend.
@@ -740,52 +778,84 @@ export class EmDashRuntime {
740
778
  let cronExecutor: CronExecutor | null = null;
741
779
  let cronScheduler: CronScheduler | null = null;
742
780
 
743
- try {
744
- cronExecutor = new CronExecutor(db, invokeCronHook);
745
-
746
- // Recover stale locks from previous crashes
747
- const recovered = await cronExecutor.recoverStaleLocks();
748
- if (recovered > 0) {
749
- console.log(`[cron] Recovered ${recovered} stale task lock(s)`);
750
- }
781
+ await phase("rt.cron", "Cron init (recovery deferred post-response)", async () => {
782
+ try {
783
+ cronExecutor = new CronExecutor(db, invokeCronHook);
784
+
785
+ // Recover stale locks from previous crashes. Pure bookkeeping
786
+ // against the _emdash_cron_tasks table — no request needs the
787
+ // result so we defer it past the response via after(). On
788
+ // Cloudflare this goes into waitUntil (extending the worker
789
+ // lifetime); on Node it's fire-and-forget (the process stays
790
+ // up anyway). Saves one cold-start write per D1 isolate.
791
+ const executorForRecovery = cronExecutor;
792
+ after(async () => {
793
+ try {
794
+ const recovered = await executorForRecovery.recoverStaleLocks();
795
+ if (recovered > 0) {
796
+ console.log(`[cron] Recovered ${recovered} stale task lock(s)`);
797
+ }
798
+ } catch (error) {
799
+ // Keep the `[cron]` prefix so a failure is easy to trace back
800
+ // rather than surfacing as a generic deferred-task error.
801
+ console.error("[cron] Failed to recover stale task locks:", error);
802
+ }
803
+ });
751
804
 
752
- // Detect platform and create appropriate scheduler.
753
- // On Cloudflare Workers, setTimeout is available but unreliable for
754
- // long durations — use PiggybackScheduler as default.
755
- // In Node/Bun, use NodeCronScheduler with real timers.
756
- const isWorkersRuntime =
757
- typeof globalThis.navigator !== "undefined" &&
758
- globalThis.navigator.userAgent === "Cloudflare-Workers";
759
-
760
- if (isWorkersRuntime) {
761
- cronScheduler = new PiggybackScheduler(cronExecutor);
762
- } else {
763
- cronScheduler = new NodeCronScheduler(cronExecutor);
764
- }
805
+ // Detect platform and create appropriate scheduler.
806
+ // On Cloudflare Workers, setTimeout is available but unreliable for
807
+ // long durations — use PiggybackScheduler as default.
808
+ // In Node/Bun, use NodeCronScheduler with real timers.
809
+ const isWorkersRuntime =
810
+ typeof globalThis.navigator !== "undefined" &&
811
+ globalThis.navigator.userAgent === "Cloudflare-Workers";
765
812
 
766
- // Register system cleanup to run alongside each scheduler tick.
767
- // Pass storage so cleanupPendingUploads can delete orphaned files.
768
- cronScheduler.setSystemCleanup(async () => {
769
- try {
770
- await runSystemCleanup(db, storage ?? undefined);
771
- } catch (error) {
772
- // Non-fatal -- individual cleanup failures are already logged
773
- // by runSystemCleanup. This catches unexpected errors.
774
- console.error("[cleanup] System cleanup failed:", error);
813
+ if (isWorkersRuntime) {
814
+ cronScheduler = new PiggybackScheduler(cronExecutor);
815
+ } else {
816
+ cronScheduler = new NodeCronScheduler(cronExecutor);
775
817
  }
776
- });
777
818
 
778
- // Add cron reschedule callback (merges with existing factory options)
779
- pipeline.setContextFactory({
780
- cronReschedule: () => cronScheduler?.reschedule(),
781
- });
819
+ // Register system cleanup to run alongside each scheduler tick.
820
+ // Pass storage so cleanupPendingUploads can delete orphaned files.
821
+ cronScheduler.setSystemCleanup(async () => {
822
+ try {
823
+ await runSystemCleanup(db, storage ?? undefined);
824
+ } catch (error) {
825
+ // Non-fatal -- individual cleanup failures are already logged
826
+ // by runSystemCleanup. This catches unexpected errors.
827
+ console.error("[cleanup] System cleanup failed:", error);
828
+ }
829
+ });
782
830
 
783
- // Start the scheduler
784
- await cronScheduler.start();
785
- } catch (error) {
786
- console.warn("[cron] Failed to initialize cron system:", error);
787
- // Non-fatal — CMS works without cron
788
- }
831
+ // Add cron reschedule callback (merges with existing factory options)
832
+ pipeline.setContextFactory({
833
+ cronReschedule: () => cronScheduler?.reschedule(),
834
+ });
835
+
836
+ // Start the scheduler
837
+ await cronScheduler.start();
838
+ } catch (error) {
839
+ console.warn("[cron] Failed to initialize cron system:", error);
840
+ // Non-fatal — CMS works without cron
841
+ }
842
+ });
843
+
844
+ // SHA of emdash commit + user config that affects the manifest.
845
+ // COMMIT captures emdash code changes; plugin IDs/versions and i18n
846
+ // capture user astro.config changes (e.g. upgrading a plugin package).
847
+ // DB-driven changes (collections, fields, plugin toggle) go through
848
+ // invalidateManifest(). Sorted for stability across nondeterministic
849
+ // plugin ordering.
850
+ const manifestCacheKey = await hashString(
851
+ [
852
+ COMMIT,
853
+ ...deps.plugins.map((p) => `${p.id}@${p.version ?? ""}`).toSorted(),
854
+ ...deps.sandboxedPluginEntries.map((e) => `${e.id}@${e.version}`).toSorted(),
855
+ virtualConfig?.i18n?.defaultLocale ?? "",
856
+ (virtualConfig?.i18n?.locales ?? []).toSorted().join(","),
857
+ ].join("|"),
858
+ );
789
859
 
790
860
  return new EmDashRuntime(
791
861
  db,
@@ -806,6 +876,7 @@ export class EmDashRuntime {
806
876
  pipelineFactoryOptions,
807
877
  deps,
808
878
  pipelineRef,
879
+ manifestCacheKey,
809
880
  );
810
881
  }
811
882
 
@@ -837,12 +908,14 @@ export class EmDashRuntime {
837
908
  * Get or create database instance
838
909
  */
839
910
  private static async getDatabase(deps: RuntimeDependencies): Promise<Kysely<Database>> {
840
- // If a per-request DB override is set (e.g. by the playground middleware
841
- // which runs before the runtime init), use that directly. This allows
842
- // the runtime to initialize against the real DO database instead of
843
- // the dummy singleton dialect.
911
+ // Only use the per-request `ctx.db` when it's an isolated instance
912
+ // (playground / DO preview). Plain D1 Sessions set `ctx.db` on every
913
+ // anonymous request if we captured one of those session-bound
914
+ // Kyselys into the cached runtime, every request would accidentally
915
+ // share one request's session. The configured `deps.createDialect`
916
+ // path gives us a fresh singleton instead.
844
917
  const ctx = getRequestContext();
845
- if (ctx?.db) {
918
+ if (ctx?.dbIsIsolated && ctx.db) {
846
919
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- db in context is typed as unknown to avoid circular deps
847
920
  return ctx.db as Kysely<Database>;
848
921
  }
@@ -878,9 +951,20 @@ export class EmDashRuntime {
878
951
 
879
952
  dbInitPromise = (async () => {
880
953
  const dialect = deps.createDialect(dbConfig.config);
881
- const db = new Kysely<Database>({ dialect });
954
+ const db = new Kysely<Database>({ dialect, log: kyselyLogOption() });
882
955
 
883
- await runMigrations(db);
956
+ const { applied } = await runMigrations(db);
957
+
958
+ // If migrations were applied, the schema changed — clear the
959
+ // DB-persisted manifest cache so getManifest() rebuilds it.
960
+ if (applied.length > 0) {
961
+ try {
962
+ const options = new OptionsRepository(db);
963
+ await options.delete("emdash:manifest_cache");
964
+ } catch {
965
+ // Non-fatal
966
+ }
967
+ }
884
968
 
885
969
  // Auto-seed schema if no collections exist and setup hasn't run.
886
970
  // This covers first-load on sites that skip the setup wizard.
@@ -1142,9 +1226,82 @@ export class EmDashRuntime {
1142
1226
  // =========================================================================
1143
1227
 
1144
1228
  /**
1145
- * Build the manifest (rebuilt on each request for freshness)
1229
+ * Get the manifest, using an in-memory cache with a DB-persisted
1230
+ * fallback for cold starts. Avoids N+1 schema registry queries
1231
+ * on every request.
1232
+ *
1233
+ * Cache is invalidated by invalidateManifest(), called from schema
1234
+ * API routes, MCP server, plugin toggle, and taxonomy def changes.
1146
1235
  */
1147
1236
  async getManifest(): Promise<EmDashManifest> {
1237
+ // When the DB is overridden by an isolated instance (playground /
1238
+ // DO-preview sessions), bypass the module-scoped manifest cache —
1239
+ // its schema may diverge from the configured DB. Plain D1 Sessions
1240
+ // routing does NOT set `dbIsIsolated`, so the cache still applies.
1241
+ if (getRequestContext()?.dbIsIsolated) {
1242
+ return this._buildManifest();
1243
+ }
1244
+
1245
+ if (this._cachedManifest) return this._cachedManifest;
1246
+
1247
+ // DB-persisted cache (1 query instead of N+1 rebuild on cold start).
1248
+ // Keyed by SHA of commit + config to bust on deploys. DB-driven
1249
+ // changes (collections, fields, plugins, taxonomies) go through
1250
+ // invalidateManifest().
1251
+ try {
1252
+ const options = new OptionsRepository(this.db);
1253
+ const cached = await options.get<{ key: string; manifest: EmDashManifest }>(
1254
+ "emdash:manifest_cache",
1255
+ );
1256
+ if (cached && cached.key === this._manifestCacheKey && cached.manifest) {
1257
+ this._cachedManifest = cached.manifest;
1258
+ return cached.manifest;
1259
+ }
1260
+ } catch {
1261
+ // Options table may not exist yet
1262
+ }
1263
+
1264
+ // Full rebuild, then persist. Track which promise is current so
1265
+ // an invalidation during the build can't be overwritten.
1266
+ if (!this._manifestPromise) {
1267
+ let manifestPromise: Promise<EmDashManifest>;
1268
+ const isCurrentLoad = () => this._manifestPromise === manifestPromise;
1269
+ manifestPromise = this._loadManifest(isCurrentLoad);
1270
+ this._manifestPromise = manifestPromise;
1271
+ }
1272
+ return this._manifestPromise;
1273
+ }
1274
+
1275
+ private async _loadManifest(isCurrentLoad: () => boolean): Promise<EmDashManifest> {
1276
+ try {
1277
+ const manifest = await this._buildManifest();
1278
+
1279
+ if (isCurrentLoad()) {
1280
+ this._cachedManifest = manifest;
1281
+
1282
+ try {
1283
+ const options = new OptionsRepository(this.db);
1284
+ await options.set("emdash:manifest_cache", {
1285
+ key: this._manifestCacheKey,
1286
+ manifest,
1287
+ });
1288
+ } catch {
1289
+ // Non-fatal — will just rebuild next time
1290
+ }
1291
+ }
1292
+
1293
+ return manifest;
1294
+ } finally {
1295
+ if (isCurrentLoad()) {
1296
+ this._manifestPromise = null;
1297
+ }
1298
+ }
1299
+ }
1300
+
1301
+ /**
1302
+ * Build the manifest from database (N+1 collection queries).
1303
+ */
1304
+ private async _buildManifest(): Promise<EmDashManifest> {
1148
1305
  // Build collections from database.
1149
1306
  // Use this.db (ALS-aware getter) so playground mode picks up the
1150
1307
  // per-session DO database instead of the hardcoded singleton.
@@ -1370,11 +1527,72 @@ export class EmDashRuntime {
1370
1527
 
1371
1528
  /**
1372
1529
  * Invalidate cached data derived from the manifest/schema.
1373
- * Called when collections are created, updated, or deleted.
1530
+ * Called when collections, fields, plugins, or taxonomy defs change.
1374
1531
  */
1375
1532
  invalidateManifest(): void {
1376
- // Invalidate the URL pattern cache used by resolveEmDashPath
1533
+ this._cachedManifest = null;
1534
+ this._manifestPromise = null;
1377
1535
  invalidateUrlPatternCache();
1536
+ // Delete DB-persisted cache so the next cold start rebuilds.
1537
+ // Fire-and-forget: in-memory is already cleared for this worker,
1538
+ // DB delete is best-effort for the next cold start.
1539
+ try {
1540
+ const options = new OptionsRepository(this.db);
1541
+ options.delete("emdash:manifest_cache").catch((error) => {
1542
+ console.error("Failed to delete persisted manifest cache", error);
1543
+ });
1544
+ } catch (error) {
1545
+ console.error("Failed to initialize manifest cache invalidation", error);
1546
+ }
1547
+ }
1548
+
1549
+ /**
1550
+ * Verify and repair FTS indexes on demand. Runs at most once per worker
1551
+ * lifetime.
1552
+ *
1553
+ * Originally called from `EmDashRuntime.create()`, but on a busy D1 link
1554
+ * (e.g. SIN replica ~80-150ms per query) it added ~1.5s to every cold
1555
+ * start for a modest-sized site — more than every other init phase
1556
+ * combined. Anonymous public reads never touch the search write path,
1557
+ * so the cost isn't paid back for the vast majority of requests.
1558
+ *
1559
+ * Instead, search endpoints call this lazily: the first request that
1560
+ * actually needs the index pays the verify cost (usually fast — no
1561
+ * rebuild needed), everyone else runs cold-free.
1562
+ *
1563
+ * Uses the runtime's singleton database (`this._db`) rather than the
1564
+ * request-scoped DB. Verify reads only, but `rebuildIndex` writes, and
1565
+ * a GET search request on D1 carries a `first-unconstrained` session
1566
+ * that's free to route at a read replica — unsafe for writes. The
1567
+ * singleton always goes through the default binding, which the D1
1568
+ * adapter will promote to `first-primary` for write statements.
1569
+ *
1570
+ * Safe to call concurrently: repeated callers share the same in-flight
1571
+ * promise. Errors are swallowed internally so callers don't need to
1572
+ * defend against FTS not existing yet (pre-setup).
1573
+ */
1574
+ async ensureSearchHealthy(): Promise<void> {
1575
+ if (this._searchHealthChecked) return;
1576
+ if (this._searchHealthPromise) return this._searchHealthPromise;
1577
+ if (!isSqlite(this._db)) {
1578
+ this._searchHealthChecked = true;
1579
+ return;
1580
+ }
1581
+ this._searchHealthPromise = (async () => {
1582
+ try {
1583
+ const ftsManager = new FTSManager(this._db);
1584
+ const repaired = await ftsManager.verifyAndRepairAll();
1585
+ if (repaired > 0) {
1586
+ console.log(`Repaired ${repaired} corrupted FTS index(es)`);
1587
+ }
1588
+ } catch {
1589
+ // FTS tables may not exist yet (pre-setup). Non-fatal.
1590
+ } finally {
1591
+ this._searchHealthChecked = true;
1592
+ this._searchHealthPromise = null;
1593
+ }
1594
+ })();
1595
+ return this._searchHealthPromise;
1378
1596
  }
1379
1597
 
1380
1598
  // =========================================================================
package/src/index.ts CHANGED
@@ -130,6 +130,10 @@ export type {
130
130
  export { getRequestContext, runWithContext } from "./request-context.js";
131
131
  export type { EmDashRequestContext } from "./request-context.js";
132
132
 
133
+ // Defer work past the response (waitUntil on workerd, fire-and-forget on Node)
134
+ export { after } from "./after.js";
135
+ export type { WaitUntilFn } from "./after.js";
136
+
133
137
  // i18n configuration (from Astro config)
134
138
  export { getI18nConfig, isI18nEnabled, getFallbackChain } from "./i18n/config.js";
135
139
  export type { I18nConfig } from "./i18n/config.js";
@@ -400,7 +404,9 @@ export {
400
404
  getTerm,
401
405
  getEntryTerms,
402
406
  getTermsForEntries,
407
+ getAllTermsForEntries,
403
408
  getEntriesByTerm,
409
+ invalidateTermCache,
404
410
  } from "./taxonomies/index.js";
405
411
  export type {
406
412
  TaxonomyDef,
package/src/loader.ts CHANGED
@@ -15,10 +15,12 @@ import type { LiveLoader } from "astro/loaders";
15
15
  import { Kysely, sql, type Dialect } from "kysely";
16
16
 
17
17
  import { currentTimestampValue, isPostgres } from "./database/dialect-helpers.js";
18
+ import { kyselyLogOption } from "./database/instrumentation.js";
18
19
  import { decodeCursor, encodeCursor } from "./database/repositories/types.js";
19
20
  import { validateIdentifier } from "./database/validate.js";
20
21
  import type { Database } from "./index.js";
21
22
  import { getRequestContext } from "./request-context.js";
23
+ import { isMissingTableError } from "./utils/db-errors.js";
22
24
 
23
25
  const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
24
26
 
@@ -63,26 +65,29 @@ function getTableName(type: string): string {
63
65
  let taxonomyNames: Set<string> | null = null;
64
66
 
65
67
  /**
66
- * Get all taxonomy names (cached for primary DB, fresh for overrides)
68
+ * Get all taxonomy names (cached for the primary DB, bypassed only when
69
+ * the per-request DB is an isolated instance — playground / DO preview).
70
+ * Plain D1 Sessions routing shares schema with the singleton, so the
71
+ * module-scoped cache stays valid.
67
72
  */
68
73
  async function getTaxonomyNames(db: Kysely<Database>): Promise<Set<string>> {
69
- const hasDbOverride = !!getRequestContext()?.db;
74
+ const hasIsolatedDb = getRequestContext()?.dbIsIsolated === true;
70
75
 
71
- if (!hasDbOverride && taxonomyNames) {
76
+ if (!hasIsolatedDb && taxonomyNames) {
72
77
  return taxonomyNames;
73
78
  }
74
79
 
75
80
  try {
76
81
  const defs = await db.selectFrom("_emdash_taxonomy_defs").select("name").execute();
77
82
  const names = new Set(defs.map((d) => d.name));
78
- if (!hasDbOverride) {
83
+ if (!hasIsolatedDb) {
79
84
  taxonomyNames = names;
80
85
  }
81
86
  return names;
82
87
  } catch {
83
88
  // Table doesn't exist yet, return empty set
84
89
  const empty = new Set<string>();
85
- if (!hasDbOverride) {
90
+ if (!hasIsolatedDb) {
86
91
  taxonomyNames = empty;
87
92
  }
88
93
  return empty;
@@ -406,7 +411,7 @@ export async function getDb(): Promise<Kysely<Database>> {
406
411
  );
407
412
  }
408
413
  const dialect = virtualCreateDialect(virtualConfig.database.config);
409
- dbInstance = new Kysely<Database>({ dialect });
414
+ dbInstance = new Kysely<Database>({ dialect, log: kyselyLogOption() });
410
415
  }
411
416
  return dbInstance;
412
417
  }
@@ -617,18 +622,13 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
617
622
  },
618
623
  };
619
624
  } catch (error) {
620
- // Handle missing table gracefully - return empty collection
621
- // This happens before migrations have run
622
- const message = error instanceof Error ? error.message : String(error);
623
- const lowerMessage = message.toLowerCase();
624
- if (
625
- lowerMessage.includes("no such table") ||
626
- (lowerMessage.includes("table") && lowerMessage.includes("does not exist")) ||
627
- (lowerMessage.includes("relation") && lowerMessage.includes("does not exist"))
628
- ) {
625
+ // Handle missing table gracefully - return empty collection.
626
+ // This happens before migrations have run.
627
+ if (isMissingTableError(error)) {
629
628
  return { entries: [] };
630
629
  }
631
630
 
631
+ const message = error instanceof Error ? error.message : String(error);
632
632
  return {
633
633
  error: new Error(`Failed to load collection: ${message}`),
634
634
  };
@@ -751,18 +751,13 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
751
751
  },
752
752
  };
753
753
  } catch (error) {
754
- // Handle missing table gracefully - return undefined (not found)
755
- // This happens before migrations have run
756
- const message = error instanceof Error ? error.message : String(error);
757
- const lowerMessage = message.toLowerCase();
758
- if (
759
- lowerMessage.includes("no such table") ||
760
- (lowerMessage.includes("table") && lowerMessage.includes("does not exist")) ||
761
- (lowerMessage.includes("relation") && lowerMessage.includes("does not exist"))
762
- ) {
754
+ // Handle missing table gracefully - return undefined (not found).
755
+ // This happens before migrations have run.
756
+ if (isMissingTableError(error)) {
763
757
  return undefined;
764
758
  }
765
759
 
760
+ const message = error instanceof Error ? error.message : String(error);
766
761
  return {
767
762
  error: new Error(`Failed to load entry: ${message}`),
768
763
  };