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
@@ -275,9 +275,10 @@ export async function getSuggestions(
275
275
  const ftsTable = ftsManager.getFtsTableName(collection);
276
276
  const contentTable = ftsManager.getContentTableName(collection);
277
277
 
278
- // Use prefix search for autocomplete
279
- const prefixQuery = `${escapeQuery(query)}*`;
280
- if (!prefixQuery || prefixQuery === "*") {
278
+ // Use prefix search for autocomplete. `escapeQuery` already appends `*`
279
+ // to each term for prefix matching, so we must not append another one.
280
+ const prefixQuery = escapeQuery(query);
281
+ if (!prefixQuery) {
281
282
  continue;
282
283
  }
283
284
 
package/src/seed/apply.ts CHANGED
@@ -792,7 +792,15 @@ async function applyContentTaxonomies(
792
792
  .execute();
793
793
  }
794
794
 
795
- if (!entry.taxonomies) return;
795
+ if (!entry.taxonomies) {
796
+ // In update mode we may have just deleted rows above; invalidate so
797
+ // hydration doesn't serve stale "has terms" cached value.
798
+ if (isUpdate) {
799
+ const { invalidateTermCache } = await import("../taxonomies/index.js");
800
+ invalidateTermCache();
801
+ }
802
+ return;
803
+ }
796
804
 
797
805
  for (const [taxonomyName, termSlugs] of Object.entries(entry.taxonomies)) {
798
806
  const termRepo = new TaxonomyRepository(db);
@@ -804,6 +812,12 @@ async function applyContentTaxonomies(
804
812
  }
805
813
  }
806
814
  }
815
+
816
+ // Seed writes directly to content_taxonomies. Clear the cache so
817
+ // the worker lifetime cached "has any term assignments" probe
818
+ // re-runs on the next read.
819
+ const { invalidateTermCache } = await import("../taxonomies/index.js");
820
+ invalidateTermCache();
807
821
  }
808
822
 
809
823
  /**
@@ -11,6 +11,7 @@ import { MediaRepository } from "../database/repositories/media.js";
11
11
  import { OptionsRepository } from "../database/repositories/options.js";
12
12
  import type { Database } from "../database/types.js";
13
13
  import { getDb } from "../loader.js";
14
+ import { peekRequestCache, requestCached } from "../request-cache.js";
14
15
  import type { Storage } from "../storage/types.js";
15
16
  import type { SiteSettings, SiteSettingKey, MediaReference } from "./types.js";
16
17
 
@@ -75,8 +76,24 @@ async function resolveMediaReference(
75
76
  export async function getSiteSetting<K extends SiteSettingKey>(
76
77
  key: K,
77
78
  ): Promise<SiteSettings[K] | undefined> {
78
- const db = await getDb();
79
- return getSiteSettingWithDb(key, db);
79
+ // If `getSiteSettings()` has already been called in this request,
80
+ // read from that (request-cached) batch rather than firing a second
81
+ // options-table query. Common layout: a Base template pulls the
82
+ // whole settings object up-front, then `EmDashHead` or a plugin
83
+ // asks for one key — no reason the singular call should round-trip
84
+ // again.
85
+ const primed = peekRequestCache<Partial<SiteSettings>>("siteSettings");
86
+ if (primed) {
87
+ const settings = await primed;
88
+ return settings[key];
89
+ }
90
+
91
+ // Otherwise cache per-key. Templates that pull several settings
92
+ // independently still share the in-flight query for each one.
93
+ return requestCached(`siteSetting:${key}`, async () => {
94
+ const db = await getDb();
95
+ return getSiteSettingWithDb(key, db);
96
+ });
80
97
  }
81
98
 
82
99
  /**
@@ -124,9 +141,11 @@ export async function getSiteSettingWithDb<K extends SiteSettingKey>(
124
141
  * console.log(settings.logo?.url); // "/_emdash/api/media/file/abc123"
125
142
  * ```
126
143
  */
127
- export async function getSiteSettings(): Promise<Partial<SiteSettings>> {
128
- const db = await getDb();
129
- return getSiteSettingsWithDb(db);
144
+ export function getSiteSettings(): Promise<Partial<SiteSettings>> {
145
+ return requestCached("siteSettings", async () => {
146
+ const db = await getDb();
147
+ return getSiteSettingsWithDb(db);
148
+ });
130
149
  }
131
150
 
132
151
  /**
@@ -5,103 +5,125 @@
5
5
  */
6
6
 
7
7
  import { getDb } from "../loader.js";
8
+ import { requestCached, setRequestCacheEntry } from "../request-cache.js";
9
+ import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
10
+ import { isMissingTableError } from "../utils/db-errors.js";
8
11
  import type { TaxonomyDef, TaxonomyTerm, TaxonomyTermRow } from "./types.js";
9
12
 
13
+ /**
14
+ * No-op — kept for API compatibility.
15
+ *
16
+ * Used to invalidate a worker-lifetime "has any term assignments?" probe.
17
+ * That probe added a query on every cold isolate to save one query on
18
+ * sites with zero term assignments (i.e. the wrong tradeoff), so we
19
+ * dropped it. The batch term join below returns an empty map for empty
20
+ * sites at the same cost as the probe, without the pre-check.
21
+ */
22
+ export function invalidateTermCache(): void {
23
+ // Intentionally empty.
24
+ }
25
+
10
26
  /**
11
27
  * Get all taxonomy definitions
12
28
  */
13
29
  export async function getTaxonomyDefs(): Promise<TaxonomyDef[]> {
14
- const db = await getDb();
30
+ return requestCached("taxonomy-defs:all", async () => {
31
+ const db = await getDb();
15
32
 
16
- const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
33
+ const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
17
34
 
18
- return rows.map((row) => ({
19
- id: row.id,
20
- name: row.name,
21
- label: row.label,
22
- labelSingular: row.label_singular ?? undefined,
23
- hierarchical: row.hierarchical === 1,
24
- collections: row.collections ? JSON.parse(row.collections) : [],
25
- }));
35
+ return rows.map((row) => ({
36
+ id: row.id,
37
+ name: row.name,
38
+ label: row.label,
39
+ labelSingular: row.label_singular ?? undefined,
40
+ hierarchical: row.hierarchical === 1,
41
+ collections: row.collections ? JSON.parse(row.collections) : [],
42
+ }));
43
+ });
26
44
  }
27
45
 
28
46
  /**
29
47
  * Get a single taxonomy definition by name
30
48
  */
31
49
  export async function getTaxonomyDef(name: string): Promise<TaxonomyDef | null> {
32
- const db = await getDb();
50
+ return requestCached(`taxonomy-def:${name}`, async () => {
51
+ const db = await getDb();
33
52
 
34
- const row = await db
35
- .selectFrom("_emdash_taxonomy_defs")
36
- .selectAll()
37
- .where("name", "=", name)
38
- .executeTakeFirst();
53
+ const row = await db
54
+ .selectFrom("_emdash_taxonomy_defs")
55
+ .selectAll()
56
+ .where("name", "=", name)
57
+ .executeTakeFirst();
39
58
 
40
- if (!row) return null;
59
+ if (!row) return null;
41
60
 
42
- return {
43
- id: row.id,
44
- name: row.name,
45
- label: row.label,
46
- labelSingular: row.label_singular ?? undefined,
47
- hierarchical: row.hierarchical === 1,
48
- collections: row.collections ? JSON.parse(row.collections) : [],
49
- };
61
+ return {
62
+ id: row.id,
63
+ name: row.name,
64
+ label: row.label,
65
+ labelSingular: row.label_singular ?? undefined,
66
+ hierarchical: row.hierarchical === 1,
67
+ collections: row.collections ? JSON.parse(row.collections) : [],
68
+ };
69
+ });
50
70
  }
51
71
 
52
72
  /**
53
73
  * Get all terms for a taxonomy (as tree for hierarchical, flat for tags)
54
74
  */
55
75
  export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
56
- const db = await getDb();
57
-
58
- // Get taxonomy definition to check if hierarchical
59
- const def = await getTaxonomyDef(taxonomyName);
60
- if (!def) return [];
61
-
62
- // Get all terms for this taxonomy
63
- const rows = await db
64
- .selectFrom("taxonomies")
65
- .selectAll()
66
- .where("name", "=", taxonomyName)
67
- .orderBy("label", "asc")
68
- .execute();
69
-
70
- // Count entries for each term
71
- const countsResult = await db
72
- .selectFrom("content_taxonomies")
73
- .select(["taxonomy_id"])
74
- .select((eb) => eb.fn.count<number>("entry_id").as("count"))
75
- .groupBy("taxonomy_id")
76
- .execute();
77
-
78
- const counts = new Map<string, number>();
79
- for (const row of countsResult) {
80
- counts.set(row.taxonomy_id, row.count);
81
- }
76
+ return requestCached(`taxonomy-terms:${taxonomyName}`, async () => {
77
+ const db = await getDb();
78
+
79
+ // Get taxonomy definition to check if hierarchical
80
+ const def = await getTaxonomyDef(taxonomyName);
81
+ if (!def) return [];
82
+
83
+ // Get all terms for this taxonomy
84
+ const rows = await db
85
+ .selectFrom("taxonomies")
86
+ .selectAll()
87
+ .where("name", "=", taxonomyName)
88
+ .orderBy("label", "asc")
89
+ .execute();
90
+
91
+ // Count entries for each term
92
+ const countsResult = await db
93
+ .selectFrom("content_taxonomies")
94
+ .select(["taxonomy_id"])
95
+ .select((eb) => eb.fn.count<number>("entry_id").as("count"))
96
+ .groupBy("taxonomy_id")
97
+ .execute();
98
+
99
+ const counts = new Map<string, number>();
100
+ for (const row of countsResult) {
101
+ counts.set(row.taxonomy_id, row.count);
102
+ }
82
103
 
83
- const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({
84
- id: row.id,
85
- name: row.name,
86
- slug: row.slug,
87
- label: row.label,
88
- parent_id: row.parent_id,
89
- data: row.data,
90
- }));
104
+ const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({
105
+ id: row.id,
106
+ name: row.name,
107
+ slug: row.slug,
108
+ label: row.label,
109
+ parent_id: row.parent_id,
110
+ data: row.data,
111
+ }));
91
112
 
92
- // If hierarchical, build tree. Otherwise return flat
93
- if (def.hierarchical) {
94
- return buildTree(flatTerms, counts);
95
- }
113
+ // If hierarchical, build tree. Otherwise return flat
114
+ if (def.hierarchical) {
115
+ return buildTree(flatTerms, counts);
116
+ }
96
117
 
97
- return flatTerms.map((term) => ({
98
- id: term.id,
99
- name: term.name,
100
- slug: term.slug,
101
- label: term.label,
102
- children: [],
103
- count: counts.get(term.id) ?? 0,
104
- }));
118
+ return flatTerms.map((term) => ({
119
+ id: term.id,
120
+ name: term.name,
121
+ slug: term.slug,
122
+ label: term.label,
123
+ children: [],
124
+ count: counts.get(term.id) ?? 0,
125
+ }));
126
+ });
105
127
  }
106
128
 
107
129
  /**
@@ -160,34 +182,36 @@ export async function getTerm(taxonomyName: string, slug: string): Promise<Taxon
160
182
  /**
161
183
  * Get terms assigned to an entry
162
184
  */
163
- export async function getEntryTerms(
185
+ export function getEntryTerms(
164
186
  collection: string,
165
187
  entryId: string,
166
188
  taxonomyName?: string,
167
189
  ): Promise<TaxonomyTerm[]> {
168
- const db = await getDb();
169
-
170
- let query = db
171
- .selectFrom("content_taxonomies")
172
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
173
- .selectAll("taxonomies")
174
- .where("content_taxonomies.collection", "=", collection)
175
- .where("content_taxonomies.entry_id", "=", entryId);
176
-
177
- if (taxonomyName) {
178
- query = query.where("taxonomies.name", "=", taxonomyName);
179
- }
190
+ return requestCached(`terms:${collection}:${entryId}:${taxonomyName ?? "*"}`, async () => {
191
+ const db = await getDb();
192
+
193
+ let query = db
194
+ .selectFrom("content_taxonomies")
195
+ .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
196
+ .selectAll("taxonomies")
197
+ .where("content_taxonomies.collection", "=", collection)
198
+ .where("content_taxonomies.entry_id", "=", entryId);
199
+
200
+ if (taxonomyName) {
201
+ query = query.where("taxonomies.name", "=", taxonomyName);
202
+ }
180
203
 
181
- const rows = await query.execute();
204
+ const rows = await query.execute();
182
205
 
183
- return rows.map((row) => ({
184
- id: row.id,
185
- name: row.name,
186
- slug: row.slug,
187
- label: row.label,
188
- parentId: row.parent_id ?? undefined,
189
- children: [],
190
- }));
206
+ return rows.map((row) => ({
207
+ id: row.id,
208
+ name: row.name,
209
+ slug: row.slug,
210
+ label: row.label,
211
+ parentId: row.parent_id ?? undefined,
212
+ children: [],
213
+ }));
214
+ });
191
215
  }
192
216
 
193
217
  /**
@@ -208,53 +232,229 @@ export async function getTermsForEntries(
208
232
  ): Promise<Map<string, TaxonomyTerm[]>> {
209
233
  const result = new Map<string, TaxonomyTerm[]>();
210
234
 
211
- // Initialize all entry IDs with empty arrays
212
- for (const id of entryIds) {
235
+ // Initialize all entry IDs with empty arrays so callers can always
236
+ // expect the key to be present.
237
+ const uniqueIds = [...new Set(entryIds)];
238
+ for (const id of uniqueIds) {
213
239
  result.set(id, []);
214
240
  }
215
241
 
216
- if (entryIds.length === 0) {
242
+ if (uniqueIds.length === 0) {
217
243
  return result;
218
244
  }
219
245
 
220
246
  const db = await getDb();
221
247
 
222
- const rows = await db
223
- .selectFrom("content_taxonomies")
224
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
225
- .select([
226
- "content_taxonomies.entry_id",
227
- "taxonomies.id",
228
- "taxonomies.name",
229
- "taxonomies.slug",
230
- "taxonomies.label",
231
- "taxonomies.parent_id",
232
- ])
233
- .where("content_taxonomies.collection", "=", collection)
234
- .where("content_taxonomies.entry_id", "in", entryIds)
235
- .where("taxonomies.name", "=", taxonomyName)
236
- .execute();
248
+ // Chunk the IN clause so we stay below D1's ~100 bound-parameter limit
249
+ // (and equivalent limits on other dialects). Matches getContentBylinesMany.
250
+ //
251
+ // Sites with no term assignments get back empty rows for one query —
252
+ // the previous "has any term assignments" probe spent a round-trip on
253
+ // every request to save that single query on empty sites, which is
254
+ // backwards. Pre-migration databases (content_taxonomies missing) fall
255
+ // through to the `isMissingTableError` catch and return empties.
256
+ for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {
257
+ let rows;
258
+ try {
259
+ rows = await db
260
+ .selectFrom("content_taxonomies")
261
+ .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
262
+ .select([
263
+ "content_taxonomies.entry_id",
264
+ "taxonomies.id",
265
+ "taxonomies.name",
266
+ "taxonomies.slug",
267
+ "taxonomies.label",
268
+ "taxonomies.parent_id",
269
+ ])
270
+ .where("content_taxonomies.collection", "=", collection)
271
+ .where("content_taxonomies.entry_id", "in", chunk)
272
+ .where("taxonomies.name", "=", taxonomyName)
273
+ .execute();
274
+ } catch (error) {
275
+ if (isMissingTableError(error)) return result;
276
+ throw error;
277
+ }
237
278
 
238
- for (const row of rows) {
239
- const entryId = row.entry_id;
240
- const term: TaxonomyTerm = {
241
- id: row.id,
242
- name: row.name,
243
- slug: row.slug,
244
- label: row.label,
245
- parentId: row.parent_id ?? undefined,
246
- children: [],
247
- };
279
+ for (const row of rows) {
280
+ const entryId = row.entry_id;
281
+ const term: TaxonomyTerm = {
282
+ id: row.id,
283
+ name: row.name,
284
+ slug: row.slug,
285
+ label: row.label,
286
+ parentId: row.parent_id ?? undefined,
287
+ children: [],
288
+ };
289
+
290
+ const terms = result.get(entryId);
291
+ if (terms) {
292
+ terms.push(term);
293
+ }
294
+ }
295
+ }
296
+
297
+ return result;
298
+ }
299
+
300
+ /**
301
+ * Batch-fetch terms for multiple entries across ALL taxonomies in a single query.
302
+ *
303
+ * Returns a Map keyed by entry ID, where each value is a Record keyed by
304
+ * taxonomy name with the matching terms as an array. Used by
305
+ * getEmDashCollection to eagerly hydrate `entry.data.terms` and avoid
306
+ * the N+1 pattern that callers hit when they loop and call getEntryTerms.
307
+ *
308
+ * Pre-migration databases (content_taxonomies missing) return an empty
309
+ * Map — the join falls through to the `isMissingTableError` branch.
310
+ */
311
+ export async function getAllTermsForEntries(
312
+ collection: string,
313
+ entryIds: string[],
314
+ ): Promise<Map<string, Record<string, TaxonomyTerm[]>>> {
315
+ const result = new Map<string, Record<string, TaxonomyTerm[]>>();
316
+
317
+ // Initialize unique entry IDs with empty objects so callers can always
318
+ // expect the key to be present. Deduping also reduces wasted bound
319
+ // parameters when a caller accidentally passes duplicates.
320
+ const uniqueIds = [...new Set(entryIds)];
321
+ for (const id of uniqueIds) {
322
+ result.set(id, {});
323
+ }
324
+
325
+ if (uniqueIds.length === 0) {
326
+ return result;
327
+ }
328
+
329
+ const db = await getDb();
248
330
 
249
- const terms = result.get(entryId);
250
- if (terms) {
251
- terms.push(term);
331
+ // Look up which taxonomies apply to this collection. Used below to
332
+ // seed empty arrays for taxonomies the entry has no terms in — so
333
+ // callers (including the pre-populated getEntryTerms cache) get a
334
+ // deterministic `[]` back rather than a cache miss that triggers a DB
335
+ // round-trip just to confirm "no terms".
336
+ const applicableTaxonomyNames = await getCollectionTaxonomyNames(collection);
337
+
338
+ // Chunk the IN clause to stay below D1's ~100 bound-parameter limit
339
+ // (and equivalent limits on other dialects). Matches getContentBylinesMany.
340
+ //
341
+ // Previously we did a separate "has any assignments" probe to skip the
342
+ // join on empty sites. That traded one query per request for a query
343
+ // saved only on empty sites — backwards. Now the join runs directly
344
+ // (returning zero rows cheaply) and pre-migration databases are caught
345
+ // by the `isMissingTableError` branch below.
346
+ for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {
347
+ let rows;
348
+ try {
349
+ rows = await db
350
+ .selectFrom("content_taxonomies")
351
+ .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
352
+ .select([
353
+ "content_taxonomies.entry_id",
354
+ "taxonomies.id",
355
+ "taxonomies.name",
356
+ "taxonomies.slug",
357
+ "taxonomies.label",
358
+ "taxonomies.parent_id",
359
+ ])
360
+ .where("content_taxonomies.collection", "=", collection)
361
+ .where("content_taxonomies.entry_id", "in", chunk)
362
+ .orderBy("taxonomies.label", "asc")
363
+ .execute();
364
+ } catch (error) {
365
+ if (isMissingTableError(error)) {
366
+ for (const id of uniqueIds) {
367
+ primeEntryTermsCache(collection, id, {}, applicableTaxonomyNames);
368
+ }
369
+ return result;
370
+ }
371
+ throw error;
252
372
  }
373
+
374
+ for (const row of rows) {
375
+ const entryId = row.entry_id;
376
+ const term: TaxonomyTerm = {
377
+ id: row.id,
378
+ name: row.name,
379
+ slug: row.slug,
380
+ label: row.label,
381
+ parentId: row.parent_id ?? undefined,
382
+ children: [],
383
+ };
384
+
385
+ const byTaxonomy = result.get(entryId);
386
+ if (!byTaxonomy) continue;
387
+ const existing = byTaxonomy[row.name];
388
+ if (existing) {
389
+ existing.push(term);
390
+ } else {
391
+ byTaxonomy[row.name] = [term];
392
+ }
393
+ }
394
+ }
395
+
396
+ // Prime the request-scoped cache so legacy callers of getEntryTerms
397
+ // (which still work per-entry) hit the in-memory cache instead of
398
+ // re-querying. This is what gives us the N+1 win in existing templates
399
+ // without requiring them to be rewritten.
400
+ for (const [entryId, byTaxonomy] of result) {
401
+ primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames);
253
402
  }
254
403
 
255
404
  return result;
256
405
  }
257
406
 
407
+ /**
408
+ * Return the list of taxonomy names applicable to a collection, request-
409
+ * cached so a page render only pays for it once.
410
+ *
411
+ * Returns an empty list when taxonomies haven't been defined yet.
412
+ */
413
+ async function getCollectionTaxonomyNames(collection: string): Promise<string[]> {
414
+ try {
415
+ const defs = await getTaxonomyDefs();
416
+ return defs.filter((d) => d.collections.includes(collection)).map((d) => d.name);
417
+ } catch (error) {
418
+ if (isMissingTableError(error)) return [];
419
+ throw error;
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Pre-populate the request-cache for every getEntryTerms call-shape that
425
+ * could hit this entry:
426
+ *
427
+ * getEntryTerms(collection, entryId) -> key `terms:C:E:*`
428
+ * getEntryTerms(collection, entryId, "tag") -> key `terms:C:E:tag`
429
+ * getEntryTerms(collection, entryId, "category") -> key `terms:C:E:category`
430
+ * ...one per taxonomy that applies to this collection
431
+ *
432
+ * Taxonomies with no rows on this entry are seeded with `[]` so legacy
433
+ * callers short-circuit to the cached empty array instead of re-querying.
434
+ */
435
+ function primeEntryTermsCache(
436
+ collection: string,
437
+ entryId: string,
438
+ byTaxonomy: Record<string, TaxonomyTerm[]>,
439
+ applicableTaxonomyNames: string[],
440
+ ): void {
441
+ // Seed every applicable taxonomy with at least [] so
442
+ // getEntryTerms(collection, id, "tag") doesn't miss the cache when an
443
+ // entry has no tags.
444
+ for (const name of applicableTaxonomyNames) {
445
+ setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, byTaxonomy[name] ?? []);
446
+ }
447
+ // Also seed individual names that show up in data but aren't listed
448
+ // as applicable (e.g. taxonomy reassigned to a different collection
449
+ // since the terms were written).
450
+ for (const [name, terms] of Object.entries(byTaxonomy)) {
451
+ setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, terms);
452
+ }
453
+ // Flattened `*` view — all terms across all taxonomies in one array.
454
+ const allTerms = Object.values(byTaxonomy).flat();
455
+ setRequestCacheEntry(`terms:${collection}:${entryId}:*`, allTerms);
456
+ }
457
+
258
458
  /**
259
459
  * Get entries by term (wraps getEmDashCollection)
260
460
  */
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared detection helpers for database-layer error messages.
3
+ *
4
+ * Different SQL dialects phrase "table or relation does not exist" differently:
5
+ *
6
+ * - SQLite / D1: "no such table: foo"
7
+ * - PostgreSQL: 'relation "foo" does not exist'
8
+ * 'table "foo" does not exist'
9
+ * - MySQL (future): "Table 'db.foo' doesn't exist"
10
+ *
11
+ * Runtime code paths that short-circuit on missing tables (pre-migration
12
+ * probes, optional feature tables, etc.) should use these helpers rather
13
+ * than hand-rolling string matches per call-site.
14
+ */
15
+
16
+ /**
17
+ * Extract a lowercase error message from any unknown value, safely.
18
+ */
19
+ function messageOf(error: unknown): string {
20
+ if (error instanceof Error) return error.message.toLowerCase();
21
+ if (typeof error === "string") return error.toLowerCase();
22
+ return "";
23
+ }
24
+
25
+ /**
26
+ * Returns true when `error` is a "table does not exist" error across the
27
+ * dialects EmDash supports (D1/SQLite and PostgreSQL). Used by runtime
28
+ * probes to treat pre-migration databases as empty without logging a scary
29
+ * warning, while still propagating unrelated errors (permissions, connection
30
+ * loss, syntax issues) to callers.
31
+ */
32
+ export function isMissingTableError(error: unknown): boolean {
33
+ const message = messageOf(error);
34
+ if (!message) return false;
35
+
36
+ // SQLite / D1
37
+ if (message.includes("no such table")) return true;
38
+
39
+ // PostgreSQL (and some MySQL variants): "relation ... does not exist" /
40
+ // "table ... does not exist" / "doesn't exist".
41
+ if (message.includes("does not exist") || message.includes("doesn't exist")) {
42
+ return message.includes("relation") || message.includes("table");
43
+ }
44
+
45
+ return false;
46
+ }