@michaelthielemann/kestrel 3.0.1 → 4.0.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 (341) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +1 -3
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  133. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  134. package/layers/ui/app/composables/useLinkField.ts +1 -1
  135. package/layers/ui/app/composables/useRepeater.ts +1 -1
  136. package/layers/ui/app/i18n/de.ts +7 -7
  137. package/layers/ui/app/i18n/en.ts +7 -39
  138. package/layers/ui/app/utils/field-component.ts +1 -1
  139. package/layers/ui/app/utils/field-registry.ts +1 -1
  140. package/layers/ui/app/utils/icons.ts +2 -6
  141. package/layers/ui/app/utils/localized.ts +1 -1
  142. package/nuxt.config.ts +1 -1
  143. package/package.json +53 -19
  144. package/scripts/consumer-template-ci.mjs +157 -0
  145. package/scripts/coverage-all.mjs +163 -0
  146. package/scripts/dashboard.mjs +89 -0
  147. package/scripts/hash-password.mjs +1 -1
  148. package/scripts/lib/coverage-model.mjs +51 -0
  149. package/scripts/port-graph.mjs +350 -0
  150. package/scripts/typecheck.mjs +11 -0
  151. package/templates/starter/app/blocks/Prose.vue +1 -1
  152. package/layers/access/server/utils/csrf.ts +0 -39
  153. package/layers/access/server/utils/grant-registry.ts +0 -36
  154. package/layers/access/server/utils/guard.ts +0 -44
  155. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  156. package/layers/access/server/utils/policy.ts +0 -98
  157. package/layers/access/server/utils/public-resources.ts +0 -11
  158. package/layers/access/server/utils/render-context.ts +0 -41
  159. package/layers/access/server/utils/require-admin.ts +0 -15
  160. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  161. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  162. package/layers/auth/server/api/auth/login.post.ts +0 -38
  163. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  164. package/layers/auth/server/api/auth/session.get.ts +0 -1
  165. package/layers/auth/server/utils/admin-credential.ts +0 -42
  166. package/layers/auth/server/utils/client-ip.ts +0 -88
  167. package/layers/auth/server/utils/login-throttle.ts +0 -126
  168. package/layers/auth/server/utils/password.ts +0 -52
  169. package/layers/auth/server/utils/session-cookie.ts +0 -44
  170. package/layers/auth/server/utils/session-epoch.ts +0 -96
  171. package/layers/auth/server/utils/session.ts +0 -88
  172. package/layers/collections/server/collections/pages.ts +0 -19
  173. package/layers/collections/server/utils/populate-relations.ts +0 -79
  174. package/layers/core/app/utils/bulk-actions.ts +0 -9
  175. package/layers/core/app/utils/filter-ops.ts +0 -98
  176. package/layers/core/app/utils/list-limits.ts +0 -27
  177. package/layers/core/app/utils/locale-path.ts +0 -12
  178. package/layers/core/app/utils/slugify.ts +0 -16
  179. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  180. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  181. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  182. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  183. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  184. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  185. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  186. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  187. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  188. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  189. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  190. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  191. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  192. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  193. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  194. package/layers/core/server/api/collections/[name].get.ts +0 -4
  195. package/layers/core/server/api/collections/index.get.ts +0 -3
  196. package/layers/core/server/api/references/broken.get.ts +0 -9
  197. package/layers/core/server/api/references/referrers.get.ts +0 -24
  198. package/layers/core/server/database/record-refs.ts +0 -20
  199. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  200. package/layers/core/server/schema/bootstrap.ts +0 -41
  201. package/layers/core/server/schema/desired.ts +0 -99
  202. package/layers/core/server/schema/dev-sync.ts +0 -23
  203. package/layers/core/server/schema/dialect.ts +0 -61
  204. package/layers/core/server/schema/diff.ts +0 -110
  205. package/layers/core/server/schema/introspect.ts +0 -68
  206. package/layers/core/server/schema/model.ts +0 -61
  207. package/layers/core/server/schema/render-sqlite.ts +0 -89
  208. package/layers/core/server/schema/sync.ts +0 -201
  209. package/layers/core/server/utils/blocks.ts +0 -6
  210. package/layers/core/server/utils/collection-types.ts +0 -28
  211. package/layers/core/server/utils/crud.ts +0 -578
  212. package/layers/core/server/utils/db.ts +0 -26
  213. package/layers/core/server/utils/defineCollection.ts +0 -213
  214. package/layers/core/server/utils/duplicate.ts +0 -162
  215. package/layers/core/server/utils/field-layout.ts +0 -125
  216. package/layers/core/server/utils/filter-predicate.ts +0 -101
  217. package/layers/core/server/utils/http.ts +0 -50
  218. package/layers/core/server/utils/if-unmodified.ts +0 -13
  219. package/layers/core/server/utils/kestrel-config.ts +0 -451
  220. package/layers/core/server/utils/key-lock.ts +0 -33
  221. package/layers/core/server/utils/locale.ts +0 -53
  222. package/layers/core/server/utils/page-route.ts +0 -58
  223. package/layers/core/server/utils/page-slug.ts +0 -127
  224. package/layers/core/server/utils/picker.ts +0 -82
  225. package/layers/core/server/utils/populate.ts +0 -50
  226. package/layers/core/server/utils/prerender-memo.ts +0 -23
  227. package/layers/core/server/utils/read-capture.ts +0 -37
  228. package/layers/core/server/utils/record-ref-index.ts +0 -200
  229. package/layers/core/server/utils/registry.ts +0 -24
  230. package/layers/core/server/utils/resolve-scope.ts +0 -79
  231. package/layers/core/server/utils/seo.ts +0 -42
  232. package/layers/core/server/utils/serialize-collection.ts +0 -147
  233. package/layers/core/server/utils/sql.ts +0 -2
  234. package/layers/core/server/utils/storage.local.ts +0 -117
  235. package/layers/core/server/utils/storage.s3.ts +0 -197
  236. package/layers/core/server/utils/storage.ts +0 -90
  237. package/layers/core/server/utils/write-effects.ts +0 -40
  238. package/layers/core/server/utils/write-events.ts +0 -31
  239. package/layers/fields/app/utils/condition.ts +0 -71
  240. package/layers/fields/app/utils/define-block.ts +0 -13
  241. package/layers/fields/app/utils/field-constraints.ts +0 -17
  242. package/layers/fields/app/utils/field-meta.ts +0 -33
  243. package/layers/fields/app/utils/field-validate.ts +0 -75
  244. package/layers/fields/app/utils/field-value.ts +0 -7
  245. package/layers/fields/app/utils/richtext-links.ts +0 -48
  246. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  247. package/layers/fields/server/field-registry/index.ts +0 -249
  248. package/layers/fields/server/field-registry/naming.ts +0 -10
  249. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  250. package/layers/fields/server/field-registry/types.ts +0 -19
  251. package/layers/fields/server/utils/block-ids.ts +0 -43
  252. package/layers/fields/server/utils/block-populate.ts +0 -41
  253. package/layers/fields/server/utils/buildCollection.ts +0 -90
  254. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  255. package/layers/fields/server/utils/buildTable.ts +0 -113
  256. package/layers/fields/server/utils/conditional-required.ts +0 -25
  257. package/layers/fields/server/utils/defineBlock.ts +0 -86
  258. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  259. package/layers/fields/server/utils/extract-refs.ts +0 -152
  260. package/layers/fields/server/utils/field-populate.ts +0 -68
  261. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  262. package/layers/media/server/api/media/[id].delete.ts +0 -18
  263. package/layers/media/server/api/media/[id].get.ts +0 -10
  264. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  265. package/layers/media/server/api/media/[id].patch.ts +0 -65
  266. package/layers/media/server/api/media/backfill.post.ts +0 -18
  267. package/layers/media/server/api/media/copy.post.ts +0 -7
  268. package/layers/media/server/api/media/delete.post.ts +0 -13
  269. package/layers/media/server/api/media/folders.post.ts +0 -18
  270. package/layers/media/server/api/media/index.get.ts +0 -15
  271. package/layers/media/server/api/media/index.post.ts +0 -173
  272. package/layers/media/server/api/media/library.get.ts +0 -17
  273. package/layers/media/server/api/media/move.post.ts +0 -7
  274. package/layers/media/server/api/media/rename.post.ts +0 -7
  275. package/layers/media/server/api/media/resolve.get.ts +0 -13
  276. package/layers/media/server/collections/media-settings.ts +0 -25
  277. package/layers/media/server/collections/media.ts +0 -43
  278. package/layers/media/server/database/folders.ts +0 -10
  279. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  280. package/layers/media/server/schema-tables/folders.ts +0 -4
  281. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  282. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  283. package/layers/media/server/utils/backfill.ts +0 -144
  284. package/layers/media/server/utils/derive.ts +0 -59
  285. package/layers/media/server/utils/folder-paths.ts +0 -42
  286. package/layers/media/server/utils/folders.ts +0 -12
  287. package/layers/media/server/utils/library.ts +0 -91
  288. package/layers/media/server/utils/media-cleanup.ts +0 -27
  289. package/layers/media/server/utils/media-enabled.ts +0 -23
  290. package/layers/media/server/utils/media-ops.ts +0 -118
  291. package/layers/media/server/utils/media-write.ts +0 -13
  292. package/layers/media/server/utils/naming.ts +0 -52
  293. package/layers/media/server/utils/ondemand.ts +0 -69
  294. package/layers/media/server/utils/persist-upload.ts +0 -62
  295. package/layers/media/server/utils/populate.ts +0 -47
  296. package/layers/media/server/utils/record.ts +0 -71
  297. package/layers/media/server/utils/relocate-ops.ts +0 -341
  298. package/layers/media/server/utils/relocate-plan.ts +0 -63
  299. package/layers/media/server/utils/resolve.ts +0 -98
  300. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  301. package/layers/media/server/utils/sniff.ts +0 -68
  302. package/layers/media/server/utils/storage-relocate.ts +0 -139
  303. package/layers/media/server/utils/translations.ts +0 -19
  304. package/layers/media/server/utils/usages.ts +0 -66
  305. package/layers/media/server/utils/variant-capture.ts +0 -70
  306. package/layers/media/server/utils/variants.ts +0 -123
  307. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  308. package/layers/public/server/api/links/resolve.get.ts +0 -19
  309. package/layers/public/server/api/preview.get.ts +0 -28
  310. package/layers/public/server/api/preview.post.ts +0 -93
  311. package/layers/public/server/api/publish-status.get.ts +0 -58
  312. package/layers/public/server/api/publish.post.ts +0 -84
  313. package/layers/public/server/api/route.get.ts +0 -54
  314. package/layers/public/server/collections/redirects.ts +0 -75
  315. package/layers/public/server/collections/site.ts +0 -44
  316. package/layers/public/server/database/publish-deps.ts +0 -12
  317. package/layers/public/server/database/publish-status.ts +0 -19
  318. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  319. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  320. package/layers/public/server/utils/link-resolve.ts +0 -38
  321. package/layers/public/server/utils/llms-full.ts +0 -125
  322. package/layers/public/server/utils/llms.ts +0 -52
  323. package/layers/public/server/utils/page-resolve.ts +0 -208
  324. package/layers/public/server/utils/populate-links.ts +0 -66
  325. package/layers/public/server/utils/preview-token.ts +0 -109
  326. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  327. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  328. package/layers/public/server/utils/publish/deps.ts +0 -87
  329. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  330. package/layers/public/server/utils/publish/pending.ts +0 -74
  331. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  332. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  333. package/layers/public/server/utils/publish/publisher.ts +0 -362
  334. package/layers/public/server/utils/publish/queue.ts +0 -67
  335. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  336. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  337. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  338. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  339. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  340. package/layers/public/server/utils/site-url.ts +0 -40
  341. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -1,208 +0,0 @@
1
- import { and, asc, eq, getTableColumns } from 'drizzle-orm'
2
- import { list } from '../../../core/server/utils/crud'
3
- import { captureRead } from '../../../core/server/utils/read-capture'
4
- import { pagePathTag, translationGroupTag } from './publish/invalidation'
5
- import type { BuiltCollection } from '../../../core/server/utils/collection-types'
6
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
7
-
8
- export interface PageAlternate { locale: string; path: string }
9
- /** One published page above this one in the path hierarchy — a real, linkable breadcrumb step. */
10
- export interface PageAncestor {
11
- path: string
12
- title?: string
13
- /** The ancestor's OWN locale, so the crumb builds the URL that was actually published. Absent for a
14
- * non-translatable collection, whose rows have a single unprefixed (primary-locale) URL — prefixing
15
- * one of those with the reader's locale would link a page `nuxt generate` never wrote. */
16
- locale?: string
17
- }
18
- export interface ResolvedPage {
19
- collection: string
20
- page: Record<string, unknown>
21
- alternates: PageAlternate[]
22
- ancestors: PageAncestor[]
23
- }
24
-
25
- /** The matched page (or null) plus the collections whose lookup threw. `failed` is non-empty ⇒ the scan
26
- * was INCOMPLETE, so `page: null` must never be treated as an authoritative "no such page". */
27
- export interface PageResolution {
28
- page: ResolvedPage | null
29
- failed: string[]
30
- }
31
-
32
- /**
33
- * The page's published, INDEXABLE translation siblings (self included) as locale→path pairs — the hreflang
34
- * set the public head emits. Mirrors the sitemap's rules exactly so the two never disagree: published-only
35
- * (a draft URL must never be advertised to crawlers), `seo.noindex` variants EXCLUDED (advertising an
36
- * hreflang to a noindexed page is a conflicting signal), null-path rows skipped, and a single-member group
37
- * returns [] (hreflang is meaningless for a lone page). EVERY sibling in the group is `captureRead`-tagged,
38
- * filtered-out ones included, so an incremental publish re-renders every group member when a sibling is
39
- * renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href. The GROUP
40
- * itself is tagged too: a sibling that does not exist yet has no id to have been captured, so the group tag
41
- * is the only edge a later CREATE can match.
42
- */
43
- function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page: Record<string, unknown>): PageAlternate[] {
44
- if (c.def.mode !== 'multi' || !c.def.translatable) return []
45
- const group = page.translationGroup
46
- if (typeof group !== 'string' || !group) return []
47
- captureRead(translationGroupTag(c.def.name, group))
48
- const cols = getTableColumns(c.table) as Record<string, never>
49
- // A status-less pageLike collection has no draft state — every sibling is "published".
50
- const hasStatus = Object.hasOwn(cols, 'status')
51
- const proj: Record<string, unknown> = { id: cols.id, locale: cols.locale, path: cols.path }
52
- if (hasStatus) proj.status = cols.status
53
- if (c.def.seo) proj.seo = cols.seo
54
- // Read the WHOLE group and filter in JS: a row excluded by SQL is never captured, so a draft sibling
55
- // would carry no dependency edge for the publish tag `<coll>:<id>` to match, and this page would keep a
56
- // baked hreflang set missing the newly-published sibling until the next full generate.
57
- const rows = db.select(proj as never)
58
- .from(c.table)
59
- .where(eq(cols.translationGroup, group))
60
- .orderBy(asc(cols.locale))
61
- .all() as Array<{ id: number; locale: string; path: unknown; status?: string; seo?: { noindex?: boolean } | null }>
62
- const alternates: PageAlternate[] = []
63
- let selfAdvertisable = false
64
- for (const r of rows) {
65
- // Capture the sibling as a publish dependency even if it is filtered out below — a draft/noindexed/
66
- // null-path sibling that later becomes advertisable must still re-render this page's hreflang set.
67
- captureRead(c.def.name, r.id)
68
- if (hasStatus && r.status !== 'published') continue // a draft URL must never be advertised
69
- if (typeof r.path !== 'string' || !r.path) continue // null/empty path — no advertisable URL
70
- if (r.seo?.noindex) continue // noindexed — never advertise it via hreflang (matches the sitemap)
71
- if (r.id === page.id) selfAdvertisable = true
72
- alternates.push({ locale: r.locale, path: r.path })
73
- }
74
- // A hreflang set with no self-reference is a signal search engines ignore wholesale — if the page
75
- // being rendered didn't itself survive the filters above, advertise nothing rather than an orphan set.
76
- if (!selfAdvertisable) return []
77
- return alternates.length >= 2 ? alternates : []
78
- }
79
-
80
- /** Every strict path prefix of a page's path, outermost first: `/blog/hello` → `['/', '/blog']`. The
81
- * site root is an ancestor of everything except itself. */
82
- function ancestorPaths(path: string): string[] {
83
- if (path === '/' || !path.startsWith('/')) return []
84
- const segments = path.split('/').filter(Boolean)
85
- const paths = ['/']
86
- for (let i = 1; i < segments.length; i += 1) paths.push(`/${segments.slice(0, i).join('/')}`)
87
- return paths
88
- }
89
-
90
- /**
91
- * The page's breadcrumb trail: the published, INDEXABLE page at each ancestor path, in the page's own
92
- * locale. A path segment with no page behind it is SKIPPED rather than synthesised — schema.org
93
- * breadcrumb items are links, and a trail that points at a 404 is a worse signal than a shorter trail.
94
- * The filters mirror the sitemap's, so a breadcrumb never advertises what the sitemap withholds.
95
- *
96
- * TWO dependency edges are captured per ancestor, because neither covers the other:
97
- * - the PATH (`pagePathTag`), for every path looked in — including those with no page at all and those
98
- * whose lookup threw. Kestrel has no parent/child relation between pages, so an ancestor is a
99
- * path-prefix match, and a page CREATED at `/blog` after `/blog/hello` was published has no id anything
100
- * could have captured beforehand. A path is knowable before its page exists.
101
- * - the RECORD (`<coll>:<id>`) of whatever row sits there, captured before the filters below so a
102
- * currently-invisible one counts too — the edge that carries a rename, a `noindex` or an unpublish,
103
- * none of which `crumbTags` can see (see the comment at that capture).
104
- * `publishedAlternates` pairs a group tag with a record tag for exactly the same reason.
105
- */
106
- function publishedAncestors(
107
- db: BetterSQLite3Database,
108
- collections: BuiltCollection[],
109
- path: string,
110
- locale: string | undefined,
111
- publishedOnly: boolean,
112
- ): PageAncestor[] {
113
- const out: PageAncestor[] = []
114
- for (const ancestorPath of ancestorPaths(path)) {
115
- // Before the scan, so the edge exists whatever the scan finds — no page, a draft, a noindexed one, or
116
- // a collection whose table could not be read. A write at this path emits the same tag (`crumbTags` in
117
- // classifyWrite), which is what repairs this page's trail.
118
- captureRead(pagePathTag(ancestorPath))
119
- for (const c of collections) {
120
- if (!c.def.pageLike) continue
121
- // Project only what a crumb needs — never the row: this runs once per path segment per render, and
122
- // pulling every page's block JSON to read a title would multiply the cost of a full publish.
123
- const cols = getTableColumns(c.table) as Record<string, never>
124
- const hasStatus = Object.hasOwn(cols, 'status')
125
- const proj: Record<string, unknown> = { id: cols.id, path: cols.path }
126
- if (hasStatus) proj.status = cols.status
127
- if (c.def.seo) proj.seo = cols.seo
128
- if (c.def.translatable) proj.locale = cols.locale
129
- if (Object.hasOwn(cols, 'title')) proj.title = cols.title
130
- const scoped = c.def.translatable && locale ? and(eq(cols.path, ancestorPath), eq(cols.locale, locale)) : eq(cols.path, ancestorPath)
131
- let row: { id: number; status?: string; seo?: { title?: string; noindex?: boolean } | null; title?: unknown; locale?: string } | undefined
132
- try {
133
- // Order by locale so an unscoped lookup (no locale requested) still answers deterministically
134
- // rather than with whatever row the table happens to yield first.
135
- const q = db.select(proj as never).from(c.table).where(scoped)
136
- row = (c.def.translatable ? q.orderBy(asc(cols.locale)) : q).limit(1).get() as typeof row
137
- } catch (error) {
138
- // Same isolation rule as the main scan: a drifted collection loses its crumbs, loudly, instead of
139
- // failing the whole render.
140
- console.error(`[kestrel] resolvePage: skipped ancestor lookup in ${c.def.name}:`, (error as Error)?.message ?? error)
141
- continue
142
- }
143
- if (!row) continue
144
- // BOTH edges are needed, and neither subsumes the other — the same pairing `publishedAlternates`
145
- // uses. The record tag covers every change to the row sitting here, INCLUDING the ones that make it
146
- // stop being a crumb: the explicit publish action classifies its write as `before === after` (the
147
- // record's current state), so a rename, a `noindex` or an unpublish is invisible in `crumbTags`,
148
- // which only ever names where the record is NOW. `<coll>:<id>` is in that write's tag list whatever
149
- // the row looks like, so it is what repairs the trail. Captured BEFORE the filters below, so a
150
- // draft/noindexed/shadowing row — one that is currently NOT the crumb — still carries the edge that
151
- // fires when it goes away.
152
- captureRead(c.def.name, row.id)
153
- if (publishedOnly && hasStatus && row.status !== 'published') break
154
- if (row.seo?.noindex) break
155
- const ancestor: PageAncestor = { path: ancestorPath }
156
- const title = row.seo?.title || (typeof row.title === 'string' ? row.title : undefined)
157
- if (title) ancestor.title = title
158
- if (c.def.translatable && row.locale) ancestor.locale = row.locale
159
- out.push(ancestor)
160
- break
161
- }
162
- }
163
- return out
164
- }
165
-
166
- /**
167
- * The first page-like record (across all collections, in registration order) whose `path` matches,
168
- * populated at depth 1 — or null, alongside the collections that could not be read at all. Reuses the
169
- * access-scoped `list()` so media/links populate exactly as a direct collection read would.
170
- * `publishedOnly` defaults true: a static render (prerender / runtime publisher) and an anonymous live
171
- * request must never see drafts. The authenticated-admin live preview passes false to surface a draft at
172
- * its real URL. Registration order is the precedence rule when two page-like collections happen to share
173
- * a path.
174
- */
175
- export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): PageResolution {
176
- const failed: string[] = []
177
- for (const c of collections) {
178
- if (!c.def.pageLike) continue
179
- // withTotal:false — read only the first row, skip count(). capture:false — don't tag the whole
180
- // collection here (the self-lookup is by path); instead tag the found record below, so this page
181
- // depends on its OWN record, not on every sibling in the collection (precise invalidation).
182
- let result: ReturnType<typeof list>
183
- try {
184
- result = list(db, c, { filter: [{ field: 'path', op: 'eq', value: path }], locale, depth: 1, perPage: 1, withTotal: false, capture: false }, publishedOnly)
185
- } catch (error) {
186
- // Table not migrated yet (e.g. a bare prerender DB) — isolate this collection's drift from the rest,
187
- // but never silently: an unread collection is indistinguishable from one with no matching page, and
188
- // the publisher would write a 404 over every one of its routes.
189
- failed.push(c.def.name)
190
- console.error(`[kestrel] resolvePage: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
191
- continue
192
- }
193
- const { data } = result
194
- if (data.length) {
195
- captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
196
- return {
197
- page: {
198
- collection: c.def.name,
199
- page: data[0]!,
200
- alternates: publishedAlternates(db, c, data[0]!),
201
- ancestors: publishedAncestors(db, collections, path, locale, publishedOnly),
202
- },
203
- failed,
204
- }
205
- }
206
- }
207
- return { page: null, failed }
208
- }
@@ -1,66 +0,0 @@
1
- import type { FieldPopulator } from '../../../core/server/utils/populate'
2
- import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
3
- import { memoResolver } from '../../../core/server/utils/resolve-scope'
4
- import { captureRead } from '../../../core/server/utils/read-capture'
5
- import { collectRichtextRefs, resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
6
-
7
- // The localized-href-for-a-page-row rule lives once in core (`pageRowHref`); the link populator/resolver
8
- // use it as the target-row href. The resolver (link-resolve) STATUS-GATES: a missing OR draft target
9
- // renders `'#'` (no draft-slug leak); only a published target gets its real path.
10
-
11
- // Resolves an internal link `{collection, id}` to the target's localized path, or null when it has no
12
- // public path (target missing, not page-like, or no path). The locale is the TARGET row's own, so the
13
- // resolver takes no locale argument — mirroring how the media populator injects its by-id resolver.
14
- export type ResolveHref = (collection: string, id: number) => string | null
15
-
16
- interface InternalLink { type: 'internal'; collection: string; id: number; href?: string }
17
-
18
- function isInternal(value: unknown): value is InternalLink {
19
- if (!value || typeof value !== 'object') return false
20
- const v = value as Record<string, unknown>
21
- return v.type === 'internal' && typeof v.collection === 'string' && typeof v.id === 'number'
22
- }
23
-
24
- // Replace an internal link value in place with a cloned `{...value, href}` (KestrelLink reads value.href).
25
- // external / email / tel and unresolvable internals pass through untouched (the latter render '#').
26
- function resolveLink(value: unknown, resolveHref: ResolveHref): unknown {
27
- if (!isInternal(value)) return value
28
- const href = resolveHref(value.collection, value.id)
29
- return href == null ? value : { ...value, href }
30
- }
31
-
32
- /**
33
- * The `link` + `richtext` field populators. `link` replaces an internal link value with `{...value, href}`;
34
- * `richtext` rewrites `kestrel:` internal-link markers in the HTML to resolved localized paths. Both are
35
- * bare-keyed in every key-mode (link/richtext are not single-ref columns), so `keyMode` is irrelevant.
36
- * Registered per-type via `registerFieldPopulator`; the shared field-tree walker drives them over
37
- * top-level fields, block props, and (now) repeater entries. Both share one prerender memo so a target is
38
- * resolved once per generate across links and richtext alike.
39
- */
40
- export function buildLinkFieldPopulators(resolveHref: ResolveHref): { link: FieldPopulator; richtext: FieldPopulator } {
41
- // Same composition as the relation/media populators: build-wide memo during generate, request-/publish-
42
- // run-wide via the resolve scope (budgeted per live request, read-tags replayed on hits).
43
- // memoResolver OUTERMOST so a per-scope budget-skip null is never cached build-wide by the prerender memo.
44
- const key = (collection: string, id: number) => `link:${collection}:${id}`
45
- const resolve = memoResolver(memoDuringPrerender(resolveHref, key), key)
46
- return {
47
- // captureRead runs even on a memo hit (each embedding page must record the dep), so it sits here, not
48
- // inside `resolve`: it durably ties this render's route to `<collection>:<id>`, so renaming the target
49
- // re-renders the referrer instead of leaving a baked href pointing at the pruned old path. A DRAFT
50
- // target is tagged for the same reason: resolution is status-gated, so the referrer baked '#', and this
51
- // edge is what lets the availability tag `<collection>:<id>` (planInvalidation's publish/unpublish/
52
- // delete branches) re-render it into the real path. A dangling target's tag is simply inert — no write
53
- // ever emits it.
54
- link: (bag, key) => {
55
- const value = bag[key]
56
- if (isInternal(value)) captureRead(value.collection, value.id)
57
- bag[key] = resolveLink(value, resolve)
58
- },
59
- richtext: (bag, key) => {
60
- const html = bag[key]
61
- if (typeof html !== 'string') return
62
- for (const ref of collectRichtextRefs(html)) captureRead(ref.collection, ref.id)
63
- bag[key] = resolveRichtextLinks(html, resolve)
64
- },
65
- }
66
- }
@@ -1,109 +0,0 @@
1
- import { randomBytes } from 'node:crypto'
2
- import type { H3Event } from 'h3'
3
-
4
- /**
5
- * Short-lived tickets carrying the editor's UNSAVED form state to a real page render. The editor's own
6
- * iframe gets unsaved content over postMessage, but an external tab has no parent window to talk to — so
7
- * instead of saving (which would publish intent the user never expressed) it mints a ticket and opens
8
- * `<url>?kestrel-preview-token=…`. Nothing is written to the DB; the ticket lives in this process only.
9
- *
10
- * The admin session is the actual gate (both endpoints are admin-only under the default-deny API guard).
11
- * The owner binding on top is NOT per-session isolation today: Kestrel has one shared admin credential and
12
- * `derivePrincipal` (access layer) never mints more than one admin identity, so every caller that reaches
13
- * this store has already been narrowed by `requireAdmin` to the same principal, and `previewOwner()`
14
- * resolves to the literal string `'admin'` every time — `t.owner === owner` cannot currently be false for
15
- * an admin caller. The binding is kept because it is the seam that makes the check meaningful the moment
16
- * (if ever) a per-user identity is added upstream; until then it costs nothing and documents the intent.
17
- * Tickets stay readable until they expire — a preview tab may be reloaded — and the store bounds itself in
18
- * both directions: a sweep on every mint, and a hard cap that evicts the oldest ticket.
19
- *
20
- * In-memory by design: previewing is a per-editor, per-minute affair, and a second server instance would
21
- * simply re-mint. Nothing durable depends on it.
22
- */
23
- export interface PreviewPayload {
24
- collection: string
25
- /** The record being previewed, or null for one that has never been saved. */
26
- id: number | null
27
- locale?: string
28
- /** The editor's populated values — the same tree the live-preview bridge posts into the iframe. */
29
- values: Record<string, unknown>
30
- }
31
-
32
- export interface PreviewTicket {
33
- token: string
34
- expiresAt: number
35
- }
36
-
37
- export interface PreviewStore {
38
- mint: (owner: string, payload: PreviewPayload) => PreviewTicket
39
- read: (token: string, owner: string) => PreviewPayload | null
40
- size: () => number
41
- }
42
-
43
- export interface PreviewStoreOptions {
44
- ttlMs?: number
45
- max?: number
46
- now?: () => number
47
- randomToken?: () => string
48
- }
49
-
50
- const TTL_MS = 10 * 60 * 1000
51
- const MAX_TICKETS = 32
52
-
53
- export function createPreviewStore(opts: PreviewStoreOptions = {}): PreviewStore {
54
- const ttlMs = opts.ttlMs ?? TTL_MS
55
- const max = opts.max ?? MAX_TICKETS
56
- const now = opts.now ?? Date.now
57
- const randomToken = opts.randomToken ?? (() => randomBytes(24).toString('base64url'))
58
- // Insertion-ordered, which is what makes "evict the oldest" a single `keys().next()`.
59
- const tickets = new Map<string, { owner: string; payload: PreviewPayload; expiresAt: number }>()
60
-
61
- function sweep(at: number): void {
62
- for (const [token, t] of tickets) if (t.expiresAt <= at) tickets.delete(token)
63
- }
64
-
65
- return {
66
- mint(owner, payload) {
67
- const at = now()
68
- sweep(at)
69
- while (tickets.size >= max) tickets.delete(tickets.keys().next().value as string)
70
- const token = randomToken()
71
- const expiresAt = at + ttlMs
72
- tickets.set(token, { owner, payload, expiresAt })
73
- return { token, expiresAt }
74
- },
75
- read(token, owner) {
76
- const t = tickets.get(token)
77
- if (!t) return null
78
- if (t.expiresAt <= now()) {
79
- tickets.delete(token)
80
- return null
81
- }
82
- // Bound to the minting owner rather than trusted on token possession alone — inert while every admin
83
- // caller resolves to the same owner (see the module docstring), but the check a future per-user
84
- // identity would need is already the one being run, not one that would need to be added later.
85
- return t.owner === owner ? t.payload : null
86
- },
87
- size: () => tickets.size,
88
- }
89
- }
90
-
91
- /**
92
- * Who a ticket belongs to. In production this only ever runs after `requireAdmin(event)` has already
93
- * thrown for anyone but the admin principal, and `derivePrincipal` (access layer) always gives that
94
- * principal a fixed `userId: 'admin'` — so the first branch always wins and this always returns the
95
- * literal `'admin'`. The `role` / `'anonymous'` fallbacks are unreached by any principal shape
96
- * `derivePrincipal` produces today; kept as a defensive default rather than a non-null assertion, since
97
- * this function has no way to enforce that invariant itself.
98
- */
99
- export function previewOwner(event: H3Event): string {
100
- const principal = event.context.principal as { userId?: string | null; role?: string } | undefined
101
- return principal?.userId ?? principal?.role ?? 'anonymous'
102
- }
103
-
104
- /** The process-wide store the two `/api/preview` handlers share. */
105
- let shared: PreviewStore | null = null
106
- export function usePreviewStore(): PreviewStore {
107
- shared ??= createPreviewStore()
108
- return shared
109
- }
@@ -1,22 +0,0 @@
1
- import type { Invalidation } from './invalidation'
2
-
3
- /**
4
- * Merge a burst of invalidations collected during a debounce window into one: any `full` wins (a
5
- * full rebuild subsumes everything); otherwise union the `tags`/`render`/`prune` sets (dedup, stable
6
- * insertion order); all-`noop` (or empty) collapses to `noop`.
7
- */
8
- export function coalesce(items: Invalidation[]): Invalidation {
9
- if (items.some((i) => i.type === 'full')) return { type: 'full' }
10
- const tags = new Set<string>()
11
- const render = new Set<string>()
12
- const prune = new Set<string>()
13
- let any = false
14
- for (const i of items) {
15
- if (i.type !== 'tags') continue
16
- any = true
17
- for (const t of i.tags) tags.add(t)
18
- for (const r of i.render) render.add(r)
19
- for (const p of i.prune) prune.add(p)
20
- }
21
- return any ? { type: 'tags', tags: [...tags], render: [...render], prune: [...prune] } : { type: 'noop' }
22
- }
@@ -1,43 +0,0 @@
1
- import { eq } from 'drizzle-orm'
2
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
- import { publishDeps } from '../../database/publish-deps'
4
- import type { DepsPersistence } from './deps'
5
-
6
- /** No-op persistence: a `DepsStore` backed by it runs purely in-memory. */
7
- const inMemoryOnly: DepsPersistence = { load: () => [], save: () => {}, remove: () => {}, clearAll: () => {} }
8
-
9
- /**
10
- * SQLite-backed durable persistence for the publish deps index. Takes the drizzle db as an argument (the
11
- * caller passes `useDb()`) so this stays a pure function of an injected db — fully unit-testable against an
12
- * in-memory database, no auto-import in the module. Tags are stored JSON-encoded (a small bounded set per
13
- * route).
14
- *
15
- * Probes the `publish_deps` table once on creation: if it is absent (a not-yet-migrated deploy — prod never
16
- * auto-DDLs, so an operator must run `db:migrate`) it warns and returns an in-memory-only no-op rather than
17
- * crashing the boot publish + every write. Full durability (cross-restart pruning) returns once the table
18
- * exists. Probing once (not per-op) keeps ALL of load/save/remove/clearAll consistent — the boot publish
19
- * calls `save()` per route, so a resilient `load()` alone was not enough.
20
- */
21
- export function createSqlitePersistence(db: BetterSQLite3Database): DepsPersistence {
22
- try {
23
- db.select().from(publishDeps).limit(1).all()
24
- } catch {
25
- console.warn('[kestrel] publish_deps table is missing — run the `db:migrate` task. Falling back to in-memory deps (no cross-restart prune until migrated).')
26
- return inMemoryOnly
27
- }
28
- return {
29
- load() {
30
- return db.select().from(publishDeps).all().map((r) => [r.route, JSON.parse(r.tags) as string[]] as const)
31
- },
32
- save(route, tags) {
33
- const json = JSON.stringify([...tags])
34
- db.insert(publishDeps).values({ route, tags: json }).onConflictDoUpdate({ target: publishDeps.route, set: { tags: json } }).run()
35
- },
36
- remove(route) {
37
- db.delete(publishDeps).where(eq(publishDeps.route, route)).run()
38
- },
39
- clearAll() {
40
- db.delete(publishDeps).run()
41
- },
42
- }
43
- }
@@ -1,87 +0,0 @@
1
- /**
2
- * Routes whose captured dependency tags intersect `changed`. The publisher captures, per published
3
- * route, the set of data tags it read while rendering (`<coll>` for a `list`, `<coll>:<id>` for a
4
- * `getOne`, `<coll>` for a singleton). On a write, the changed tags `{coll, coll:id}` map back to
5
- * exactly the routes that embed that data — so editing one speaker re-renders its detail page + the
6
- * overview that lists speakers, and nothing else. Pure.
7
- */
8
- export function routesForTags(changed: Iterable<string>, index: Map<string, Set<string>>): string[] {
9
- const want = new Set(changed)
10
- const out: string[] = []
11
- for (const [route, tags] of index) {
12
- for (const t of tags) {
13
- if (want.has(t)) { out.push(route); break }
14
- }
15
- }
16
- return out
17
- }
18
-
19
- /**
20
- * Routes the publisher previously wrote (tracked in the deps index) that are no longer in the published
21
- * set — a page that was unpublished, deleted, or whose slug changed. Their static files must be removed
22
- * on the next full publish. Pure; the diff drives a targeted prune (it only ever deletes files this
23
- * publisher created — output ≡ DB, no opt-in toggle).
24
- */
25
- export function staleRoutes(tracked: Iterable<string>, publishedNow: Iterable<string>): string[] {
26
- const live = new Set(publishedNow)
27
- return [...tracked].filter((route) => !live.has(route))
28
- }
29
-
30
- /**
31
- * Durable backing for the deps index: a write-through `route → tags` store that survives a restart.
32
- * The in-memory index is the (synchronous) query layer; this port persists every mutation and is read
33
- * back once on construction to rehydrate. Kept abstract so `DepsStore` stays pure-unit-testable (a fake
34
- * port in node tests) while production injects the SQLite-backed adapter (`createSqlitePersistence`).
35
- */
36
- export interface DepsPersistence {
37
- /** Every persisted `route → tags` pair (read once on DepsStore construction to rehydrate). */
38
- load(): Iterable<readonly [string, Iterable<string>]>
39
- /** Persist (upsert) a route's tag set. */
40
- save(route: string, tags: Iterable<string>): void
41
- /** Remove a route. */
42
- remove(route: string): void
43
- /** Drop every route. */
44
- clearAll(): void
45
- }
46
-
47
- /**
48
- * In-memory `route → tags` index, populated as routes are published and queried on each write — the fast
49
- * synchronous query layer. When constructed with a `DepsPersistence`, the index is rehydrated from it on
50
- * boot and every mutation is written through, so the store survives a restart: a boot full-publish's
51
- * `staleRoutes` can then prune pages that were unpublished/deleted while the server was down.
52
- */
53
- export class DepsStore {
54
- private index = new Map<string, Set<string>>()
55
-
56
- constructor(private persistence?: DepsPersistence) {
57
- if (persistence) for (const [route, tags] of persistence.load()) this.index.set(route, new Set(tags))
58
- }
59
-
60
- /** Replace the tag set captured for `route`. */
61
- record(route: string, tags: Iterable<string>): void {
62
- const set = new Set(tags)
63
- this.index.set(route, set)
64
- this.persistence?.save(route, set)
65
- }
66
-
67
- /** Routes whose tags intersect `changed`. */
68
- routesForTags(changed: Iterable<string>): string[] {
69
- return routesForTags(changed, this.index)
70
- }
71
-
72
- /** Every route with a captured tag set. */
73
- routes(): string[] {
74
- return [...this.index.keys()]
75
- }
76
-
77
- /** Drop `route` from the index (on prune/unpublish). */
78
- forget(route: string): void {
79
- this.index.delete(route)
80
- this.persistence?.remove(route)
81
- }
82
-
83
- clear(): void {
84
- this.index.clear()
85
- this.persistence?.clearAll()
86
- }
87
- }