@michaelthielemann/kestrel 3.0.1 → 4.0.1

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 +69 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +92 -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 +160 -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,118 +0,0 @@
1
- import { eq, inArray, or, getTableColumns, sql } from 'drizzle-orm'
2
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
- import { findMediaUsagesForMany, type MediaUsage } from './usages'
4
- import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
5
- import { media } from '../collections/media'
6
- import { folders } from '../database/folders'
7
- import { isUnder } from './folder-paths'
8
- import type { DerivativeManifest } from './record'
9
- import { emitMediaWrite } from './media-write'
10
- import type { StorageDriver } from '../../../core/server/utils/storage'
11
- import { escapeLike } from '../../../core/server/utils/sql'
12
-
13
- export type OpItem = { type: 'file'; id: number } | { type: 'folder'; path: string }
14
-
15
- export interface AffectedMedia {
16
- id: number; storageKey: string; folder: string | null; filename: string
17
- size: number; derivatives: DerivativeManifest | null
18
- }
19
- export interface AffectedSet { media: AffectedMedia[]; folders: string[] }
20
-
21
- /** Resolve op items into the concrete affected media rows + folder paths. A `folder` item
22
- * expands to itself + every descendant (media + folder rows). Folders are returned deepest-first
23
- * (so a delete removes children before parents); media is deduped by id. */
24
- export function collectAffected(db: BetterSQLite3Database, items: OpItem[]): AffectedSet {
25
- const cols = getTableColumns(media) as Record<string, never>
26
- const t = media as AnySQLiteTable
27
- const mediaById = new Map<number, AffectedMedia>()
28
- const folderSet = new Set<string>()
29
- // Only a folder item ever consults the full folder list (to find its descendants) — an all-file list
30
- // never touches it, so skip the read rather than materialize every row for nothing.
31
- const allFolders = items.some((i) => i.type === 'folder')
32
- ? db.select({ path: folders.path }).from(folders).all().map((f) => f.path)
33
- : []
34
-
35
- const add = (rows: Record<string, unknown>[]) => {
36
- for (const r of rows) mediaById.set(r.id as number, {
37
- id: r.id as number, storageKey: r.storageKey as string, folder: (r.folder as string | null) ?? null,
38
- filename: r.filename as string, size: r.size as number, derivatives: (r.derivatives as DerivativeManifest | null) ?? null,
39
- })
40
- }
41
-
42
- for (const item of items) {
43
- if (item.type === 'file') {
44
- const row = db.select().from(t).where(eq(cols.id, item.id)).get() as Record<string, unknown> | undefined
45
- if (row) add([row])
46
- } else {
47
- for (const fp of allFolders) if (isUnder(item.path, fp)) folderSet.add(fp)
48
- folderSet.add(item.path)
49
- const rows = db.select().from(t).where(
50
- or(eq(cols.folder, item.path), sql`${cols.folder} like ${`${escapeLike(item.path)}/%`} escape '\\'`),
51
- ).all() as Record<string, unknown>[]
52
- add(rows)
53
- }
54
- }
55
-
56
- const folderList = [...folderSet].sort((a, b) => b.split('/').length - a.split('/').length)
57
- return { media: [...mediaById.values()], folders: folderList }
58
- }
59
-
60
- /** Reverse-usage lookup for many media ids at once: id -> the records referencing it. */
61
- export function bulkUsages(db: BetterSQLite3Database, ids: number[]): Record<number, MediaUsage[]> {
62
- return findMediaUsagesForMany(db, ids)
63
- }
64
-
65
- export interface DeleteReport {
66
- summary: { files: number; folders: number; totalBytes: number }
67
- usages?: Record<number, MediaUsage[]>
68
- /** storageKeys whose blob delete failed — the row is already gone (rows commit before storage), so a
69
- * failure here is an orphaned object, not a retryable half-delete; surfaced for the caller to log/alert. */
70
- failedKeys?: string[]
71
- }
72
-
73
- /** Two-wave delete. dryRun → report impact + per-media usages, no mutation. Otherwise → delete
74
- * all affected media rows + folder rows in one transaction, then delete the storage objects
75
- * (original + derivatives). Folder cascade included. */
76
- export async function deleteAffected(
77
- db: BetterSQLite3Database, driver: StorageDriver, items: OpItem[], dryRun: boolean,
78
- ): Promise<DeleteReport> {
79
- const affected = collectAffected(db, items)
80
- const summary = {
81
- files: affected.media.length,
82
- folders: affected.folders.length,
83
- totalBytes: affected.media.reduce((n, m) => n + (m.size || 0), 0),
84
- }
85
- if (dryRun) return { summary, usages: bulkUsages(db, affected.media.map((m) => m.id)) }
86
-
87
- const cols = getTableColumns(media) as Record<string, never>
88
- const mediaIds = affected.media.map((m) => m.id)
89
- db.transaction((tx) => {
90
- if (mediaIds.length) tx.delete(media).where(inArray(cols.id, mediaIds)).run()
91
- if (affected.folders.length) tx.delete(folders).where(inArray(folders.path, affected.folders)).run()
92
- })
93
-
94
- // Best-effort per item: the rows are already committed gone, so a storage error here means an orphan
95
- // object, not a rollback candidate — one bad key (a throttled/5xx S3 call) must not abort the loop and
96
- // leave every LATER item's blobs undeleted too with no row left to find them again.
97
- const failedKeys: string[] = []
98
- for (const m of affected.media) {
99
- try {
100
- await driver.delete(m.storageKey)
101
- for (const d of Object.values(m.derivatives ?? {})) await driver.delete(d.key)
102
- } catch (error) {
103
- console.error(`[kestrel] media storage delete failed for ${m.storageKey}:`, (error as Error)?.message ?? error)
104
- failedKeys.push(m.storageKey)
105
- }
106
- emitMediaWrite({ id: m.id }, null) // deleted media → re-render listings that query it
107
- }
108
- // Clean up the folder dir ONLY when nothing unmanaged remains under it. removeDir is a recursive subtree
109
- // wipe, so a blind call would destroy any non-media object living under the same path — e.g. an extension's
110
- // encrypted blob namespace, or any consumer blob written via useStorageDriver — which no media row (and no
111
- // dryRun count) accounts for. If anything is still there, leave the whole path alone.
112
- for (const it of items) {
113
- if (it.type !== 'folder') continue
114
- const remaining = driver.listPrefix ? await driver.listPrefix(it.path) : ['guard']
115
- if (remaining.length === 0) await driver.removeDir?.(it.path)
116
- }
117
- return failedKeys.length ? { summary, failedKeys } : { summary }
118
- }
@@ -1,13 +0,0 @@
1
- import { emitWrite } from '../../../core/server/utils/write-events'
2
- import mediaCollection from '../collections/media'
3
-
4
- /**
5
- * Notify the publish runtime that a media row changed. The media-library write paths (relocate / duplicate
6
- * / delete / alt-edit) bypass core CRUD, so — unlike a normal content write — they emit no write event on
7
- * their own. Without this, a page that embeds the media (dep-tagged `media:<id>`) is never re-rendered, so
8
- * the static output keeps the old (now 404) URL or stale alt text. `before`/`after` need only carry `id`
9
- * for the media classification (non-pageLike, no status column).
10
- */
11
- export function emitMediaWrite(before: Record<string, unknown> | null, after: Record<string, unknown> | null): void {
12
- emitWrite(mediaCollection.def, before, after)
13
- }
@@ -1,52 +0,0 @@
1
- export function sanitizeFilename(name: string): string {
2
- const base = name.split(/[/\\]/).pop() ?? ''
3
- const cleaned = base.replace(/ /g, '_').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/_+/g, '_').slice(0, 120)
4
- if (cleaned === '' || cleaned === '.' || cleaned === '..') return 'file'
5
- return cleaned
6
- }
7
-
8
- /** The last extension of a filename (no leading dot), or '' when there is none. */
9
- export function extensionOf(name: string): string {
10
- const dot = name.lastIndexOf('.')
11
- return dot > 0 ? name.slice(dot + 1) : ''
12
- }
13
-
14
- /** Sanitize `name` and force its extension to `ext` (drops any client-supplied extension).
15
- * The stored extension must follow the authenticated content type, never the upload filename. */
16
- export function withExtension(name: string, ext: string): string {
17
- const cleaned = sanitizeFilename(name)
18
- const dot = cleaned.lastIndexOf('.')
19
- const stem = dot > 0 ? cleaned.slice(0, dot) : cleaned
20
- return ext ? `${stem}.${ext}` : stem
21
- }
22
-
23
- /** Safe relative folder: drops `.`/`..` segments (no traversal) and cleans each segment.
24
- * Deliberately NOT length-capped: truncating would silently map two distinct folders onto one
25
- * storage path. An unusable path is caught where it is created — folders.post.ts runs `ensureDir`
26
- * before committing the row, so an over-long name fails there instead of stranding a row. */
27
- export function sanitizeFolder(folder: string): string {
28
- return folder
29
- .split(/[/\\]+/)
30
- .map((seg) => seg.trim())
31
- .filter((seg) => seg && seg !== '.' && seg !== '..')
32
- .map((seg) => seg.replace(/ /g, '_').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/_+/g, '_'))
33
- .filter((seg) => seg.length > 0)
34
- .join('/')
35
- }
36
-
37
- export function buildKey(folder: string, filename: string): string {
38
- const f = sanitizeFolder(folder)
39
- return f ? `${f}/${filename}` : filename
40
- }
41
-
42
- export function suggestFreeName(filename: string, isFree: (name: string) => boolean): string {
43
- if (isFree(filename)) return filename
44
- const dot = filename.lastIndexOf('.')
45
- const stem = dot > 0 ? filename.slice(0, dot) : filename
46
- const ext = dot > 0 ? filename.slice(dot) : ''
47
- for (let i = 2; i < 10000; i++) {
48
- const candidate = `${stem}-${i}${ext}`
49
- if (isFree(candidate)) return candidate
50
- }
51
- throw new Error('no free filename found')
52
- }
@@ -1,69 +0,0 @@
1
- import { inArray, getTableColumns } from 'drizzle-orm'
2
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
- import type { ResolvedImagePolicy, VariantFit } from '../../../core/server/utils/kestrel-config'
4
- import type { StorageDriver } from '../../../core/server/utils/storage'
5
- import { media } from '../collections/media'
6
- import { deriveImage, RASTER } from './derive'
7
-
8
- const FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
9
-
10
- /** The storage key a request addresses, or null when the request is not for media at all: outside the
11
- * configured base URL, bare, or carrying a malformed %-escape (which must degrade to a static 404, never
12
- * a 500). `baseUrl` is the media base as resolved config holds it — its default is the same `/uploads`. */
13
- export function variantKeyFromPath(path: string, baseUrl: string | undefined): string | null {
14
- const base = (baseUrl || '/uploads').replace(/\/+$/, '')
15
- const clean = path.split('?')[0]!
16
- if (!clean.startsWith(`${base}/`)) return null
17
- try {
18
- return decodeURIComponent(clean.slice(base.length + 1)) || null
19
- } catch {
20
- return null
21
- }
22
- }
23
-
24
- /** Inverse of variantName: `w<width>` | `c<w>x<h>[-<fit>]` → the shape to derive, or null for anything else. */
25
- export function parseVariantName(name: string): { width: number; height: number | null; fit: VariantFit } | null {
26
- const w = /^w(\d+)$/.exec(name)
27
- if (w) return { width: +w[1], height: null, fit: 'cover' }
28
- const c = /^c(\d+)x(\d+)(?:-([a-z]+))?$/.exec(name)
29
- if (c) {
30
- const fit = (c[3] ?? 'cover') as VariantFit
31
- return FITS.has(fit) ? { width: +c[1], height: +c[2], fit } : null
32
- }
33
- return null
34
- }
35
-
36
- export interface VariantRequest { storageKey: string; mime: string; name: string; format: string }
37
-
38
- // requestedKey is `<storageKey>-<name>.<format>`; storageKey contains dots and a name may contain '-', so
39
- // resolve the original by trying every '-' split and matching a real media row (longest storageKey wins).
40
- export function resolveVariantRequest(db: BetterSQLite3Database, requestedKey: string): VariantRequest | null {
41
- const dot = requestedKey.lastIndexOf('.')
42
- if (dot < 0) return null
43
- const format = requestedKey.slice(dot + 1)
44
- const stem = requestedKey.slice(0, dot)
45
- const candidates: string[] = []
46
- for (let i = 1; i < stem.length; i++) if (stem[i] === '-') candidates.push(stem.slice(0, i))
47
- if (!candidates.length) return null
48
- const cols = getTableColumns(media) as Record<string, never>
49
- const rows = db.select({ storageKey: cols.storageKey, mime: cols.mime }).from(media).where(inArray(cols.storageKey, candidates)).all() as { storageKey: string; mime: string }[]
50
- const match = rows.map((r) => ({ ...r, name: stem.slice(r.storageKey.length + 1), format })).sort((a, b) => b.storageKey.length - a.storageKey.length)[0]
51
- return match ?? null
52
- }
53
-
54
- const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
55
-
56
- // DEV-only: derive one missing variant from the stored original and cache it. Registry-INDEPENDENT (parses
57
- // the name), so a size/format a KestrelImg declares but the scan hasn't registered yet still shows in the
58
- // editor preview without a full publish.
59
- export async function deriveOnDemand(db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, requestedKey: string): Promise<{ bytes: Buffer; mime: string } | null> {
60
- const req = resolveVariantRequest(db, requestedKey)
61
- if (!req || !RASTER.has(req.mime) || (req.format !== 'webp' && req.format !== 'jpeg')) return null
62
- const shape = parseVariantName(req.name)
63
- if (!shape || typeof driver.get !== 'function') return null
64
- const spec = { name: req.name, width: shape.width, height: shape.height, fit: shape.fit, position: 'centre', formats: [req.format] as ('webp' | 'jpeg')[] }
65
- const v = (await deriveImage(await driver.get(req.storageKey), { ...policy, variants: [spec] })).variants[0]
66
- if (!v) return null
67
- await driver.put(requestedKey, v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
68
- return { bytes: v.bytes, mime: v.mime }
69
- }
@@ -1,62 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
- import { media } from '../collections/media'
4
- import { derivativeKey, withPreservedTranslations, type DerivativeManifest } from './record'
5
- import type { DerivedImage } from './derive'
6
- import type { StorageDriver } from '../../../core/server/utils/storage'
7
- import type { Translations } from './translations'
8
-
9
- // Long-lived (non-immutable, so a replace isn't pinned) cache for originals + derivatives — effective on
10
- // the S3 driver, ignored by the local driver (NGINX owns its cache). Mirrors the header in backfill.ts.
11
- export const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
12
-
13
- export interface PersistArgs {
14
- storageKey: string
15
- bytes: Buffer
16
- mime: string
17
- derived?: DerivedImage
18
- values: Record<string, unknown>
19
- existing?: { id: number; derivatives?: DerivativeManifest; translations?: Translations }
20
- overwrite: boolean
21
- }
22
-
23
- /**
24
- * Write the original + derivative objects and insert/update the media row. On the CREATE path (no
25
- * pre-existing row) a failure at ANY step removes every blob written during the attempt — otherwise a
26
- * partial write (an S3 hiccup mid-derivative, a lost INSERT race) strands orphan blobs that permanently
27
- * 409-block the filename (overwrite can't reclaim a row-less key, and no reconcile removes them). The
28
- * OVERWRITE path leaves the (pre-existing) original in place on failure — it was there before this upload.
29
- */
30
- export async function persistUpload(db: BetterSQLite3Database, driver: StorageDriver, args: PersistArgs): Promise<Record<string, unknown>> {
31
- const { storageKey, bytes, mime, derived, values, existing, overwrite } = args
32
- const cols = getTableColumns(media) as Record<string, never>
33
- const isOverwrite = !!(existing && overwrite)
34
- const derivKeys = (derived?.variants ?? []).map((v) => derivativeKey(storageKey, v.name, v.format))
35
-
36
- try {
37
- await driver.put(storageKey, bytes, mime, { cacheControl: MEDIA_CACHE_CONTROL })
38
- for (const v of derived?.variants ?? []) {
39
- await driver.put(derivativeKey(storageKey, v.name, v.format), v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
40
- }
41
- if (isOverwrite) {
42
- // Drop any old derivative objects the new manifest no longer references, then update the row.
43
- const newKeys = new Set(Object.values(values.derivatives as DerivativeManifest).map((d) => d.key))
44
- for (const old of Object.values(existing!.derivatives ?? {})) {
45
- if (!newKeys.has(old.key)) await driver.delete(old.key)
46
- }
47
- // Re-read translations fresh, right before the merge — `existing` was captured by the caller BEFORE
48
- // the (potentially slow) driver.put calls above; a concurrent alt-text PATCH landing during that
49
- // window would otherwise be silently reverted by merging the pre-I/O snapshot.
50
- const fresh = db.select({ translations: cols.translations }).from(media)
51
- .where(eq(cols.storageKey, storageKey)).get() as { translations?: Translations } | undefined
52
- return db.update(media).set({ ...withPreservedTranslations(values, fresh ?? existing), updatedAt: new Date() })
53
- .where(eq(cols.storageKey, storageKey)).returning().get() as Record<string, unknown>
54
- }
55
- return db.insert(media).values(values as never).returning().get() as Record<string, unknown>
56
- } catch (error) {
57
- if (!isOverwrite) {
58
- for (const k of [storageKey, ...derivKeys]) await driver.delete(k).catch(() => {})
59
- }
60
- throw error
61
- }
62
- }
@@ -1,47 +0,0 @@
1
- import { fieldIs } from '../../../core/server/utils/defineCollection'
2
- import type { FieldPopulator } from '../../../core/server/utils/populate'
3
- import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
4
- import { memoResolver } from '../../../core/server/utils/resolve-scope'
5
- import { captureRead } from '../../../core/server/utils/read-capture'
6
- import type { ResolvedMedia } from './resolve'
7
-
8
- export type ResolveById = (id: number, locale: string) => ResolvedMedia | null
9
-
10
- function attach(target: Record<string, unknown>, key: string, value: unknown): void {
11
- // Clone the existing $media bag rather than mutate it in place: if `target` is a shallow clone that
12
- // happens to share a pre-existing $media sidecar with the stored row, in-place mutation would leak into
13
- // the original. No current caller feeds a $media-bearing bag into the walker, but this keeps the
14
- // populator unconditionally non-destructive (future-proof against a double-populate / pre-seeded bag).
15
- target.$media = { ...((target.$media as Record<string, unknown> | undefined) ?? {}), [key]: value }
16
- }
17
-
18
- /**
19
- * The `media` field populator: resolves a media id (single, key `${name}Id` in columns / bare `name` in
20
- * props) or id array (multiple, always bare) to a `ResolvedMedia` under the row/entry's `$media` bag,
21
- * leaving the raw id column untouched. Registered per-type via `registerFieldPopulator('media', …)`; the
22
- * shared field-tree walker drives it over top-level fields, block props, and (now) repeater entries.
23
- */
24
- export function buildMediaFieldPopulator(resolveById: ResolveById): FieldPopulator {
25
- // The same media id+locale is resolved once — build-wide during a generate run, request-/publish-run-
26
- // wide via the resolve scope (which also budgets one live request's distinct fan-out).
27
- // memoResolver OUTERMOST so a per-scope budget-skip null is never cached build-wide by the prerender memo.
28
- const key = (id: number, locale: string) => `media:${id}:${locale}`
29
- const resolve = memoResolver(memoDuringPrerender(resolveById, key), key)
30
- return (bag, key, field, ctx, keyMode) => {
31
- if (!fieldIs(field, 'media')) return
32
- // captureRead runs even on a memo hit (each embedding page must record the dep), so it sits here, not
33
- // inside `resolve`: it durably ties this render's route to `media:<id>`, so a later media change
34
- // (rename/move/alt-edit) re-renders the page instead of leaving a stale/broken image in the static output.
35
- if (!field.options?.multiple) {
36
- const id = bag[keyMode === 'columns' ? `${key}Id` : key]
37
- if (typeof id === 'number') { captureRead('media', id); attach(bag, key, resolve(id, ctx.locale)) }
38
- } else {
39
- const ids = bag[key]
40
- if (Array.isArray(ids)) {
41
- const nums = ids.filter((x): x is number => typeof x === 'number')
42
- for (const id of nums) captureRead('media', id)
43
- attach(bag, key, nums.map((id) => resolve(id, ctx.locale)))
44
- }
45
- }
46
- }
47
- }
@@ -1,71 +0,0 @@
1
- import type { DerivedImage } from './derive'
2
- import { mergeTranslations, type Translations } from './translations'
3
-
4
- export interface DerivativeEntry { key: string; width: number; height: number; mime: string }
5
- export type DerivativeManifest = Record<string, DerivativeEntry>
6
-
7
- export interface MediaInput {
8
- storageKey: string
9
- folder: string
10
- filename: string
11
- mime: string
12
- ext: string
13
- size: number
14
- checksum: string
15
- derived?: DerivedImage
16
- translations?: Record<string, { alt?: string; title?: string; description?: string }>
17
- /** EU AI Act disclosure to write. Omitted/null ⇒ the column is left out of the values entirely, which is
18
- * what keeps an overwrite from wiping a disclosure the re-upload did not re-send. */
19
- aiSourceType?: string | null
20
- aiNote?: string | null
21
- }
22
-
23
- /**
24
- * Object key for a resized/reformatted variant of an original. The stem is the FULL original key
25
- * (extension included), so two same-stem uploads that differ only in extension — `logo.png` and
26
- * `logo.jpg`, a designer exporting both formats — never derive the same key and overwrite/cross-delete
27
- * each other's variants. `storageKey` is unique, so this stays injective per original.
28
- */
29
- export function derivativeKey(originalKey: string, name: string, format: string): string {
30
- return `${originalKey}-${name}.${format}`
31
- }
32
-
33
- export function buildMediaValues(input: MediaInput): Record<string, unknown> {
34
- const manifest: DerivativeManifest = {}
35
- const src = input.derived
36
- for (const v of src?.variants ?? []) {
37
- // Name-keyed (`<name>.<format>`); height is the variant's REAL output dim (crops break the aspect ratio).
38
- manifest[`${v.name}.${v.format}`] = { key: derivativeKey(input.storageKey, v.name, v.format), width: v.width, height: v.height, mime: v.mime }
39
- }
40
- return {
41
- storageKey: input.storageKey,
42
- folder: input.folder || null,
43
- filename: input.filename,
44
- mime: input.mime,
45
- ext: input.ext,
46
- size: input.size,
47
- width: input.derived?.width ?? null,
48
- height: input.derived?.height ?? null,
49
- checksum: input.checksum,
50
- thumbhash: input.derived?.thumbhash ?? null,
51
- derivatives: manifest,
52
- translations: input.translations ?? {},
53
- // Written only when there is something to write: the overwrite path feeds these same values to an
54
- // UPDATE, and a null here would silently clear a disclosure an editor set on the existing row.
55
- ...(input.aiSourceType != null ? { aiSourceType: input.aiSourceType } : {}),
56
- ...(input.aiNote != null ? { aiNote: input.aiNote } : {}),
57
- }
58
- }
59
-
60
- /**
61
- * On overwrite (re-upload of an existing storageKey) the multipart request rarely re-sends
62
- * alt/title/description — the conflict-dialog overwrite path never does — so a bare re-upload must not
63
- * wipe the per-locale metadata editors maintain via the viewer. Merge any incoming fields over the
64
- * existing map (same semantics as the PATCH endpoint); an empty incoming map leaves the existing intact.
65
- */
66
- export function withPreservedTranslations(
67
- values: Record<string, unknown>,
68
- existing: { translations?: Translations } | undefined,
69
- ): Record<string, unknown> {
70
- return { ...values, translations: mergeTranslations(existing?.translations, (values.translations ?? {}) as Translations) }
71
- }