@michaelthielemann/kestrel 3.0.0 → 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 (353) 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 +6 -8
  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/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -1,139 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import { createError } from 'h3'
3
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
4
- import { buildKey } from './naming'
5
- import { derivativeKey, type DerivativeManifest } from './record'
6
- import { media } from '../collections/media'
7
- import { emitMediaWrite } from './media-write'
8
- import type { StorageDriver } from '../../../core/server/utils/storage'
9
- import { withLock, mediaLockKey } from '../../../core/server/utils/key-lock'
10
-
11
- export interface RelocatePlan {
12
- storageKey: string
13
- derivatives: DerivativeManifest
14
- copies: { src: string; dst: string }[]
15
- }
16
-
17
- /** Pure: compute the new storageKey, the rewritten derivative manifest, and the list of object
18
- * copies (original + each derivative) for moving/renaming a media row to `folder`/`filename`. */
19
- export function planObjectRelocation(
20
- row: { storageKey: string; derivatives: DerivativeManifest | null },
21
- folder: string,
22
- filename: string,
23
- ): RelocatePlan {
24
- const storageKey = buildKey(folder, filename)
25
- const derivatives: DerivativeManifest = {}
26
- const copies: { src: string; dst: string }[] = [{ src: row.storageKey, dst: storageKey }]
27
- for (const [manifestKey, entry] of Object.entries(row.derivatives ?? {})) {
28
- // manifestKey is `<name>.<format>`; variant names are `[A-Za-z0-9_-]` (no dots), so the LAST dot
29
- // splits name from format cleanly — rebuild the destination key from the name, not the (now-absent
30
- // from the key scheme) width.
31
- const dot = manifestKey.lastIndexOf('.')
32
- const name = dot > 0 ? manifestKey.slice(0, dot) : manifestKey
33
- const format = dot > 0 ? manifestKey.slice(dot + 1) : (entry.mime.split('/').pop() ?? 'bin')
34
- const dst = derivativeKey(storageKey, name, format)
35
- derivatives[manifestKey] = { ...entry, key: dst }
36
- copies.push({ src: entry.key, dst })
37
- }
38
- return { storageKey, derivatives, copies }
39
- }
40
-
41
- interface MediaRowLite { id: number; storageKey: string; folder: string | null; filename: string; derivatives: DerivativeManifest | null }
42
-
43
- function mediaCols() { return getTableColumns(media) as Record<string, never> }
44
-
45
- /** Reject if any destination object already exists on disk (e.g. another media's derivative, which
46
- * carries no `storageKey` row, so the DB clash check misses it). Keys the plan itself vacates are
47
- * excluded so a plain move never collides with its own source. */
48
- async function assertDestKeysFree(driver: StorageDriver, plan: RelocatePlan): Promise<void> {
49
- const sources = new Set(plan.copies.map((c) => c.src))
50
- for (const c of plan.copies) {
51
- if (sources.has(c.dst)) continue
52
- if (await driver.exists?.(c.dst)) {
53
- throw createError({ statusCode: 409, statusMessage: `A file already exists at ${c.dst}` })
54
- }
55
- }
56
- }
57
-
58
- /** Move and/or rename a media object (+ its derivatives). Pre-checks the destination key (409 if
59
- * taken), copies to the new keys, updates the row, then deletes the old keys; on a DB failure the
60
- * freshly-copied objects are removed so the source is never lost. No-op if the target equals the
61
- * current key. */
62
- export async function relocateMedia(
63
- db: BetterSQLite3Database, driver: StorageDriver, id: number,
64
- target: { folder?: string; filename?: string },
65
- ): Promise<void> {
66
- const cols = mediaCols()
67
- const row = db.select().from(media).where(eq(cols.id, id)).get() as MediaRowLite | undefined
68
- if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
69
-
70
- const folder = target.folder ?? row.folder ?? ''
71
- const filename = target.filename ?? row.filename
72
- const plan = planObjectRelocation(row, folder, filename)
73
- if (plan.storageKey === row.storageKey) return
74
-
75
- // Same lock the upload path holds across its own exists-check → put → insert (mediaLockKey, keyed on
76
- // the DESTINATION key only): without it, a concurrent upload or another relocate targeting this same
77
- // key can observe it free mid-way through this one and land on top of it — or, two relocates racing
78
- // for the same destination, have the loser's failure-compensation delete the WINNER's just-landed blob.
79
- await withLock(mediaLockKey(plan.storageKey), async () => {
80
- const clash = db.select({ id: cols.id }).from(media).where(eq(cols.storageKey, plan.storageKey)).get() as { id: number } | undefined
81
- if (clash) throw createError({ statusCode: 409, statusMessage: `A file already exists at ${plan.storageKey}` })
82
- await assertDestKeysFree(driver, plan)
83
-
84
- for (const c of plan.copies) await driver.copy(c.src, c.dst)
85
- try {
86
- db.update(media).set({ storageKey: plan.storageKey, folder: folder || null, filename, derivatives: plan.derivatives, updatedAt: new Date() }).where(eq(cols.id, id)).run()
87
- } catch (error) {
88
- for (const c of plan.copies) await driver.delete(c.dst)
89
- throw error
90
- }
91
- for (const c of plan.copies) await driver.delete(c.src)
92
- })
93
- // The public URL is built from storageKey — moving/renaming changes it, so embedding pages must re-render.
94
- emitMediaWrite({ id }, { id })
95
- }
96
-
97
- interface MediaRowFull extends MediaRowLite {
98
- mime: string; ext: string; size: number; width: number | null; height: number | null
99
- checksum: string | null; thumbhash: string | null
100
- translations: Record<string, unknown> | null
101
- }
102
-
103
- /** Duplicate a media object (+ derivatives) to a new key and insert a new row (same bytes →
104
- * same checksum/dims/translations). Rejects 409 if the target key is taken. */
105
- export async function duplicateMedia(
106
- db: BetterSQLite3Database, driver: StorageDriver, id: number,
107
- target: { folder?: string; filename?: string },
108
- ): Promise<{ id: number }> {
109
- const cols = mediaCols()
110
- const row = db.select().from(media).where(eq(cols.id, id)).get() as MediaRowFull | undefined
111
- if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
112
-
113
- const folder = target.folder ?? row.folder ?? ''
114
- const filename = target.filename ?? row.filename
115
- const plan = planObjectRelocation(row, folder, filename)
116
-
117
- // See relocateMedia: same destination-key lock the upload path holds, so a concurrent upload/relocate
118
- // targeting this exact key can't observe it free mid-way through this copy+insert.
119
- return withLock(mediaLockKey(plan.storageKey), async () => {
120
- const clash = db.select({ id: cols.id }).from(media).where(eq(cols.storageKey, plan.storageKey)).get() as { id: number } | undefined
121
- if (clash) throw createError({ statusCode: 409, statusMessage: `A file already exists at ${plan.storageKey}` })
122
- await assertDestKeysFree(driver, plan)
123
-
124
- for (const c of plan.copies) await driver.copy(c.src, c.dst)
125
- try {
126
- const created = db.insert(media).values({
127
- storageKey: plan.storageKey, folder: folder || null, filename,
128
- mime: row.mime, ext: row.ext, size: row.size, width: row.width, height: row.height,
129
- checksum: row.checksum, thumbhash: row.thumbhash, derivatives: plan.derivatives,
130
- translations: row.translations ?? {},
131
- } as never).returning({ id: cols.id }).get() as { id: number }
132
- emitMediaWrite(null, { id: created.id }) // new media row → re-render listings
133
- return created
134
- } catch (error) {
135
- for (const c of plan.copies) await driver.delete(c.dst)
136
- throw error
137
- }
138
- })
139
- }
@@ -1,19 +0,0 @@
1
- export type LocaleFields = Record<string, unknown>
2
- export type Translations = Record<string, LocaleFields>
3
-
4
- const isPlainObject = (v: unknown): v is Record<string, unknown> =>
5
- typeof v === 'object' && v !== null && !Array.isArray(v)
6
-
7
- /** Deep-merge a partial translations patch into the current map: new locales are added, and within a
8
- * locale the patched fields are merged over the existing ones — so patching `en.alt` keeps `en.title`
9
- * / `en.description`, and other locales are untouched. Defensive against malformed input: a non-object
10
- * current/patch or a non-object per-locale value is ignored rather than persisting garbage. */
11
- export function mergeTranslations(current: Translations | null | undefined, patch: Translations): Translations {
12
- const out: Translations = isPlainObject(current) ? { ...current } : {}
13
- if (!isPlainObject(patch)) return out
14
- for (const [loc, fields] of Object.entries(patch)) {
15
- if (!isPlainObject(fields)) continue
16
- out[loc] = { ...(isPlainObject(out[loc]) ? out[loc] : {}), ...fields }
17
- }
18
- return out
19
- }
@@ -1,66 +0,0 @@
1
- import { sql, getTableColumns, getTableName, type SQL } from 'drizzle-orm'
2
- import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
3
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
4
- import { allCollections } from '../../../core/server/utils/registry'
5
- import { fieldIs } from '../../../core/server/utils/defineCollection'
6
- import { resolveColumnName } from '../../../fields/server/field-registry/naming'
7
-
8
- export interface MediaUsage { collection: string; recordId: number; field: string }
9
-
10
- /**
11
- * Reverse lookup for a whole set of media ids at once: ONE query per (collection, column), never one per
12
- * id. The JSON columns can only be searched by a full table scan, and better-sqlite3 is synchronous, so a
13
- * per-id loop over a folder-sized selection would block the event loop for seconds. Every requested id
14
- * gets an entry (empty when unreferenced). Mirrors `findReferrersForMany` in core's record-ref index.
15
- */
16
- export function findMediaUsagesForMany(db: BetterSQLite3Database, ids: number[]): Record<number, MediaUsage[]> {
17
- const out: Record<number, MediaUsage[]> = {}
18
- for (const id of ids) out[id] = []
19
- if (!ids.length) return out
20
- // The id set travels as ONE bound parameter (a JSON array), not one placeholder per id: a recursive
21
- // folder delete can hand us tens of thousands of ids, well past SQLite's 32766-variable ceiling.
22
- const idSet = sql`(select value from json_each(${JSON.stringify(ids)}))`
23
-
24
- for (const c of allCollections()) {
25
- if (c.name === 'media') continue
26
- const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
27
- const tbl = sql.identifier(getTableName(c.table))
28
- const rowId = sql.identifier(cols.id.name)
29
- const collect = (query: SQL, field: string) => {
30
- for (const r of db.all(query) as { rid: number; mid: number }[]) {
31
- out[r.mid]?.push({ collection: c.name, recordId: r.rid, field })
32
- }
33
- }
34
- // Media ids can nest anywhere inside a repeater/json/content/seo value — json_tree the whole column and
35
- // match any integer atom (a safe over-approximation for usage warnings).
36
- const scanJson = (column: string, field: string) => collect(
37
- sql`select distinct t.${rowId} as rid, jt.atom as mid from ${tbl} t, json_tree(t.${sql.identifier(column)}) jt
38
- where jt.type = 'integer' and jt.atom in ${idSet}`,
39
- field,
40
- )
41
-
42
- for (const [key, field] of Object.entries(c.def.fields)) {
43
- if (fieldIs(field, 'media') && !field.options?.multiple) {
44
- const { jsKey } = resolveColumnName(key, field)
45
- const col = sql.identifier(cols[jsKey].name)
46
- collect(sql`select t.${rowId} as rid, t.${col} as mid from ${tbl} t where t.${col} in ${idSet}`, key)
47
- } else if (fieldIs(field, 'media') && field.options?.multiple) {
48
- const col = sql.identifier(cols[key].name)
49
- collect(sql`select distinct t.${rowId} as rid, je.value as mid from ${tbl} t, json_each(t.${col}) je
50
- where je.value in ${idSet}`, key)
51
- } else if (field.type === 'repeater' || field.type === 'json') {
52
- const { jsKey } = resolveColumnName(key, field)
53
- scanJson(cols[jsKey].name, key)
54
- }
55
- }
56
- if (c.def.blocks?.enabled) scanJson('content', 'content')
57
- // The `seo` system column carries the social-share image, so a page whose only reference to an asset is
58
- // its og:image must still be reported — otherwise deleting it silently 404s the published meta tag.
59
- if (c.def.seo) scanJson('seo', 'seo.image')
60
- }
61
- return out
62
- }
63
-
64
- export function findMediaUsages(db: BetterSQLite3Database, id: number): MediaUsage[] {
65
- return findMediaUsagesForMany(db, [id])[id]!
66
- }
@@ -1,70 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
- import type { ResolvedVariant } from '../../../core/server/utils/kestrel-config'
4
- import { mediaSettings } from '../collections/media-settings'
5
- import { reconcileVariants, type StoredVariant } from './variants'
6
-
7
- // Auto-discovery accumulator. `KestrelImg`/`useMediaVariant` stash their concrete specs on the render's
8
- // `event.context.kestrelVariants`; a `beforeResponse` hook (only for publish renders, gated on the
9
- // `x-kestrel-publish` header) drains them into this module Map, keyed by name and unioning formats across
10
- // usages. At the end of a FULL publish / generate `saveDiscoveredVariants` reconciles the union into the
11
- // registry and clears it — so the registry reflects exactly what the last full render actually used.
12
- const discovered = new Map<string, ResolvedVariant>()
13
-
14
- /** Merge freshly-declared specs into the accumulator (union formats for same-named variants). */
15
- export function recordVariants(specs: readonly ResolvedVariant[]): void {
16
- for (const s of specs) {
17
- if (!s?.name) continue
18
- const prev = discovered.get(s.name)
19
- if (prev) prev.formats = [...new Set([...prev.formats, ...(s.formats ?? [])])]
20
- else discovered.set(s.name, { ...s, formats: [...(s.formats ?? ['webp'])] })
21
- }
22
- }
23
-
24
- export function collectVariants(): ResolvedVariant[] {
25
- return [...discovered.values()]
26
- }
27
-
28
- export function clearVariants(): void {
29
- discovered.clear()
30
- }
31
-
32
- /**
33
- * Reconcile the accumulated discovered specs into the `media_settings` registry (scan entries replaced,
34
- * manual/pinned kept) and clear the accumulator. Called at the end of a full publish / generate.
35
- *
36
- * Safety: if NOTHING was discovered this run, leave the registry untouched rather than wiping every scan
37
- * entry — a zero result almost always means the capture didn't fire (no KestrelImg rendered, or a plumbing
38
- * gap), not that the site genuinely uses no variants. Narrowing still works whenever ≥1 variant is found.
39
- *
40
- * `{ clear: false }` reconciles WITHOUT draining the accumulator — the classic `nuxt generate` topology has
41
- * no publisher to run this once at the end, so it reconciles after each prerendered route and the growing
42
- * accumulator converges to the full used set by the last route.
43
- */
44
- export function saveDiscoveredVariants(db: BetterSQLite3Database, opts: { clear?: boolean } = {}): void {
45
- const found = collectVariants()
46
- if (!found.length) { if (opts.clear !== false) clearVariants(); return }
47
- const cols = getTableColumns(mediaSettings) as Record<string, never>
48
- try {
49
- // The registry is a read-modify-write, and a `nuxt generate` can run against the SAME DB as a live
50
- // server's publisher — an unguarded select→reconcile→update loses one side's variants. An IMMEDIATE
51
- // transaction takes SQLite's write lock up front, so a concurrent writer WAITS (busy_timeout) and then
52
- // reconciles against the already-updated row instead of clobbering it (cross-process safe).
53
- db.transaction((tx) => {
54
- const row = tx.select().from(mediaSettings).where(eq(cols.singletonKey, 'media_settings')).get() as
55
- | { variants?: StoredVariant[] | null }
56
- | undefined
57
- // The json field type's column default is the OBJECT shape '{}' (not '[]') — a generic singleton
58
- // PUT that omits `variants` leaves exactly that, so nullish-only guarding isn't enough here.
59
- const next = reconcileVariants(Array.isArray(row?.variants) ? row.variants : [], found)
60
- if (row) tx.update(mediaSettings).set({ variants: next, updatedAt: new Date() } as never).where(eq(cols.singletonKey, 'media_settings')).run()
61
- else tx.insert(mediaSettings).values({ singletonKey: 'media_settings', variants: next } as never).run()
62
- }, { behavior: 'immediate' })
63
- // Drain the accumulator only AFTER a durable write, so a transient failure keeps it for the next pass.
64
- if (opts.clear !== false) clearVariants()
65
- } catch (error) {
66
- // media_settings not migrated yet — skip persisting the registry (narrowing stays off, config fallback
67
- // applies) rather than crash the publish. See the schema-drift note; run `drizzle-kit generate`.
68
- console.warn('[kestrel] media_settings registry not available — variant discovery not persisted:', (error as Error)?.message ?? error)
69
- }
70
- }
@@ -1,123 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
- import type { ResolvedVariant, VariantFit } from '../../../core/server/utils/kestrel-config'
4
- import { mediaSettings } from '../collections/media-settings'
5
-
6
- const VARIANT_FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
7
- // sharp's crop gravity/position values (resize({fit:'cover'|'contain', position})).
8
- const SHARP_POSITIONS = new Set([
9
- 'top', 'right top', 'right', 'right bottom', 'bottom', 'left bottom', 'left', 'left top', 'centre', 'center',
10
- 'north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest', 'entropy', 'attention',
11
- ])
12
-
13
- /**
14
- * A variant persisted in the `media_settings` registry: a resolved variant plus its provenance. `source`
15
- * distinguishes an auto-discovered (`'scan'`) entry from a hand-authored (`'manual'`) one; a `manual`/`pinned`
16
- * entry survives the scan reconcile and wins a name collision with a scanned entry.
17
- */
18
- export interface StoredVariant extends ResolvedVariant {
19
- source?: 'manual' | 'scan'
20
- pinned?: boolean
21
- }
22
-
23
- const rank = (v: StoredVariant): number => (v.pinned || v.source === 'manual' ? 1 : 0)
24
-
25
- /**
26
- * The effective variant set to derive: the stored registry entries (deduped by name, a manual/pinned entry
27
- * winning a scan collision, stripped of provenance and re-defaulted defensively), or `fallback` (the resolved
28
- * config policy variants) when the registry is empty/absent. `presets` are config-authored named variants
29
- * (`image.variants`): explicit, name-referenced declarations that are never scan-discovered, so they stay
30
- * active through usage-driven narrowing (unioned into a non-empty registry, winning any name collision).
31
- *
32
- * `fromRegistry` is the verdict a DELETING caller (the backfill prune) must gate on: true only when at least
33
- * one stored entry SURVIVED validation, so the set really is the registered one. Absent, unread, empty and
34
- * wholly-rejected registries all resolve to the same superset fallback, under which every registered
35
- * derivative looks deregistered — the row count alone cannot tell those apart from a narrowed set.
36
- * Pure — the load-bearing logic, unit-tested without a DB.
37
- */
38
- export function resolveActiveVariants(
39
- stored: StoredVariant[] | null | undefined, fallback: ResolvedVariant[], presets: ResolvedVariant[] = [],
40
- ): { specs: ResolvedVariant[]; fromRegistry: boolean } {
41
- // A registry row is hand-authorable via the media_settings JSON PATCH; reject a name outside the
42
- // derivative-key charset ([A-Za-z0-9_-]) — an out-of-charset char breaks the URL / the prune-media
43
- // referencedKeys regex (its derivative is pruned though pages reference it) and a `/` nests a pseudo-folder.
44
- const safeName = (n: unknown): n is string => typeof n === 'string' && /^[A-Za-z0-9_-]+$/.test(n)
45
- const list = (Array.isArray(stored) ? stored : []).filter(
46
- (v): v is StoredVariant => !!v && safeName(v.name) && Number.isFinite(v.width) && v.width >= 1,
47
- )
48
- // Empty registry ⇒ the fallback already contains the presets (resolveVariants unions them), so return it as-is.
49
- if (!list.length) return { specs: fallback, fromRegistry: false }
50
- const byName = new Map<string, StoredVariant>()
51
- for (const v of list) {
52
- const prev = byName.get(v.name)
53
- if (!prev || rank(v) > rank(prev)) byName.set(v.name, v)
54
- }
55
- for (const p of presets) {
56
- if (p && typeof p.name === 'string' && p.name !== '' && Number.isFinite(p.width) && p.width >= 1) byName.set(p.name, { ...p, source: 'manual' })
57
- }
58
- const specs = [...byName.values()].map((v): ResolvedVariant => ({
59
- name: v.name,
60
- width: Math.floor(v.width),
61
- // coerce a garbage/stale height to null so it never reaches sharp's crop resize and 500s the upload
62
- height: typeof v.height === 'number' && Number.isFinite(v.height) && v.height >= 1 ? Math.floor(v.height) : null,
63
- // A hand-authored (media_settings PATCH) fit/position outside sharp's enum would otherwise reach
64
- // sharp.resize() verbatim and throw for EVERY upload that derives this variant — allow-list, don't
65
- // just default the nullish case.
66
- fit: v.fit && VARIANT_FITS.has(v.fit) ? v.fit : 'cover',
67
- position: typeof v.position === 'string' && SHARP_POSITIONS.has(v.position) ? v.position : 'centre',
68
- formats: v.formats?.length ? v.formats : ['webp'],
69
- }))
70
- return { specs, fromRegistry: true }
71
- }
72
-
73
- /**
74
- * Reconcile a fresh scan into the stored registry: every hand-authored entry (`source:'manual'` or
75
- * `pinned`) is KEPT, and the discovered specs replace ALL prior `source:'scan'` entries (so a variant whose
76
- * last `KestrelImg` usage was removed disappears — usage-driven narrowing). A manual/pinned entry wins a
77
- * name collision with a discovered one. Pure — the load-bearing reconcile, unit-tested without a DB.
78
- */
79
- export function reconcileVariants(existing: StoredVariant[], discovered: ResolvedVariant[]): StoredVariant[] {
80
- const out: StoredVariant[] = []
81
- const seen = new Set<string>()
82
- // Belt-and-braces against the json field type's object-shaped default ('{}', not '[]') reaching here
83
- // directly — mirrors the Array.isArray guard in resolveActiveVariants.
84
- for (const e of (Array.isArray(existing) ? existing : [])) {
85
- if ((e.source === 'manual' || e.pinned) && e.name && !seen.has(e.name)) { out.push(e); seen.add(e.name) }
86
- }
87
- for (const d of discovered) {
88
- if (!d.name || seen.has(d.name)) continue
89
- out.push({ ...d, source: 'scan' })
90
- seen.add(d.name)
91
- }
92
- return out
93
- }
94
-
95
- /**
96
- * The raw stored registry rows (the `media_settings` singleton), or `null` when there are none to resolve
97
- * from — the read threw (media_settings not migrated) or nothing is stored. Whether the resolved set may be
98
- * DELETED against is not decidable here: that is `resolveActiveVariants`' `fromRegistry` verdict, which only
99
- * a validation pass over these rows can give.
100
- */
101
- export function readVariantRegistry(db: BetterSQLite3Database): StoredVariant[] | null {
102
- const cols = getTableColumns(mediaSettings) as Record<string, never>
103
- let row: { variants?: StoredVariant[] | null } | undefined
104
- try {
105
- row = db.select().from(mediaSettings).where(eq(cols.singletonKey, 'media_settings')).get() as
106
- | { variants?: StoredVariant[] | null }
107
- | undefined
108
- } catch {
109
- return null
110
- }
111
- const stored = row?.variants
112
- return Array.isArray(stored) ? stored : null
113
- }
114
-
115
- /**
116
- * Resolves the active variant set for the upload path (narrow generation: registered set + presets).
117
- * Deliberately forgiving: an unreadable registry degrades to the config fallback rather than 500 the
118
- * upload (mirrors attachDeadRefs tolerating a missing record_refs). Discards `fromRegistry` — never use
119
- * this to decide what may be pruned; a deleting caller needs `resolveActiveVariants` directly.
120
- */
121
- export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVariant[], presets: ResolvedVariant[] = []): ResolvedVariant[] {
122
- return resolveActiveVariants(readVariantRegistry(db), fallback, presets).specs
123
- }