@michaelthielemann/kestrel 1.2.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 (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,577 @@
1
+ import { and, asc, count, desc, eq, getTableColumns, inArray, ne } from 'drizzle-orm'
2
+ import { createError } from 'h3'
3
+ import { nanoid } from 'nanoid'
4
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
5
+ import type { AnySQLiteColumn, AnySQLiteTable } from 'drizzle-orm/sqlite-core'
6
+ import type { BuiltCollection } from './collection-types'
7
+ import { primaryLocale, prefixPrimaryLocale, resolveLocale, supportedLocales } from './locale'
8
+ import { populateRow } from './populate'
9
+ import { captureRead } from './read-capture'
10
+ import { emitWrite } from './write-events'
11
+ import { allCollections } from './registry'
12
+ import { resolvePageSlug } from './page-slug'
13
+ import { recordRefs } from '../database/record-refs'
14
+ import { collectionMayReference, deadTargets } from './record-ref-index'
15
+ // Intentional core→fields dependency: the `fields` layer is the field-type SPI the engine is built on (column
16
+ // + validator + transform + naming). It is effectively part of the engine, not a swappable layer, so the CRUD
17
+ // engine consumes it by direct import rather than through a registration seam.
18
+ import { getFieldType } from '../../../fields/server/field-registry/index'
19
+ import { resolveColumnName } from '../../../fields/server/field-registry/naming'
20
+ import { getBlock } from '../../../fields/server/utils/defineBlock'
21
+ import { fieldIs, type FieldDef } from './defineCollection'
22
+ import { serializeField } from './serialize-collection'
23
+ import { fieldFilterKind, FILTER_RE, isFilterOp, opAllowed, type FilterKind, type FilterOp } from '../../app/utils/filter-ops'
24
+ import { clampPerPage } from '../../app/utils/list-limits'
25
+ import { filterCondition } from './filter-predicate'
26
+ import { withResolveScope, resolveBudgetFor } from './resolve-scope'
27
+
28
+ type DB = BetterSQLite3Database
29
+ type Row = Record<string, unknown>
30
+
31
+ // Upper bound on relation/media populate recursion. `depth` is attacker-controlled on anonymous reads
32
+ // (both list + detail routes accept `?depth`), and the relation populator recurses one getOne per level —
33
+ // an unbounded value drives thousands of synchronous DB reads / a stack overflow. No real content nests
34
+ // beyond a handful of hops, so clamp hard.
35
+ const MAX_DEPTH = 10
36
+ const clampDepth = (value: unknown): number => Math.min(MAX_DEPTH, Math.max(0, Number(value) || 0))
37
+
38
+ export interface ListQuery {
39
+ locale?: string | string[]
40
+ sort?: string | string[]
41
+ page?: number
42
+ perPage?: number
43
+ filter?: FilterClause[]
44
+ depth?: number
45
+ /** Skip the total count() query (default: compute it). Set false on the prerender/resolvePage hot
46
+ * path, which reads only the first row and discards `total` — saving a count() per page-like probe. */
47
+ withTotal?: boolean
48
+ /** Skip the publish dependency capture (default: capture a collection-level tag). Set false for the
49
+ * resolvePage self-lookup, which captures the found record instead of the whole collection. */
50
+ capture?: boolean
51
+ }
52
+
53
+ // The table is built at runtime from the collection's fields, so its columns can't be statically typed —
54
+ // `Record<string, AnySQLiteColumn>` is the honest shape (keyed by jsKey) every query helper indexes into.
55
+ function columns(c: BuiltCollection): Record<string, AnySQLiteColumn> {
56
+ return getTableColumns(c.table) as Record<string, AnySQLiteColumn>
57
+ }
58
+
59
+ function table(c: BuiltCollection): AnySQLiteTable {
60
+ return c.table as AnySQLiteTable
61
+ }
62
+
63
+ /** Whether a thrown DB error is a SQLite UNIQUE-constraint violation. The single home for this brittle
64
+ * driver-message heuristic, reused by every write path that maps it to a 409 (crud + media upload). */
65
+ export function isUniqueViolation(error: unknown): boolean {
66
+ return String((error as Error)?.message).includes('UNIQUE')
67
+ }
68
+
69
+ /** Run a DB write, mapping SQLite's UNIQUE-constraint failure to a 409 (duplicate locale-in-group or path). */
70
+ function runCatchingUnique<T>(fn: () => T): T {
71
+ try {
72
+ return fn()
73
+ } catch (error) {
74
+ if (isUniqueViolation(error)) {
75
+ throw createError({ statusCode: 409, statusMessage: 'Conflict: duplicate locale in translation group, or duplicate path' })
76
+ }
77
+ throw error
78
+ }
79
+ }
80
+
81
+ /** WHERE matching a collection's singleton row: keyed by name, plus locale for translatable singletons. */
82
+ function singletonWhere(cols: Record<string, AnySQLiteColumn>, c: BuiltCollection, loc: string | undefined) {
83
+ const key = eq(cols.singletonKey, c.name)
84
+ return c.def.translatable ? and(key, eq(cols.locale, loc)) : key
85
+ }
86
+
87
+ /** One parsed filter clause. The wire stays a FilterClause[] (not a `Record<field,value>`) so a repeated
88
+ * key AND-s (two `contains` on one array field) and a range clause-builder can layer on later without a
89
+ * re-migration. */
90
+ export interface FilterClause {
91
+ field: string
92
+ op: FilterOp
93
+ value: string
94
+ }
95
+
96
+ /** Parse the `filter[...]` query keys into clauses. A bare key means `eq`. ufo turns a repeated key into an
97
+ * array, so one clause is emitted per value (repeated same-op AND). An unknown operator TOKEN is a clean
98
+ * 400 here; an operator that is merely disallowed FOR THE FIELD's kind is caught in list(). */
99
+ export function parseFilter(query: Record<string, unknown>): FilterClause[] {
100
+ const out: FilterClause[] = []
101
+ for (const [key, value] of Object.entries(query)) {
102
+ const m = FILTER_RE.exec(key)
103
+ if (!m) continue
104
+ const op = m[2] ?? 'eq'
105
+ if (!isFilterOp(op)) throw createError({ statusCode: 400, statusMessage: `Unknown filter operator: ${op}` })
106
+ for (const v of Array.isArray(value) ? value : [value]) out.push({ field: m[1]!, op, value: String(v) })
107
+ }
108
+ return out
109
+ }
110
+
111
+ /** The allow-list of a collection's filterable columns → their FilterKind (drives op validation + predicate
112
+ * building). Built per-request from the schema; a column absent here can't be filtered (a clean 400). */
113
+ function filterKindMap(c: BuiltCollection): Record<string, FilterKind> {
114
+ const map: Record<string, FilterKind> = { id: 'number' }
115
+ for (const [key, f] of Object.entries(c.def.fields)) {
116
+ const kind = fieldFilterKind(serializeField(f))
117
+ if (kind) map[resolveColumnName(key, f).jsKey] = kind
118
+ }
119
+ if (c.def.pageLike) map.path = 'text'
120
+ if (c.def.status) map.status = 'enum'
121
+ map.createdAt = 'datetime'
122
+ map.updatedAt = 'datetime'
123
+ return map
124
+ }
125
+
126
+ export function list(db: DB, c: BuiltCollection, q: ListQuery, publishedOnly = false) {
127
+ const cols = columns(c)
128
+ // A listing depends on the whole collection (any add/remove/edit changes it) — tag it collection-level.
129
+ if (q.capture !== false) captureRead(c.def.name)
130
+ const conds = []
131
+
132
+ const localeRaw = Array.isArray(q.locale) ? q.locale[0] : q.locale
133
+ const sortRaw = Array.isArray(q.sort) ? q.sort[0] : q.sort
134
+
135
+ // One normalized locale drives BOTH the WHERE filter and the populate locale. `all` means
136
+ // "no locale filter"; there is no single populate locale then, so fall back to primary.
137
+ let populateLocale = primaryLocale()
138
+ if (c.def.translatable && localeRaw !== 'all') {
139
+ const loc = resolveLocale(localeRaw)
140
+ conds.push(eq(cols.locale, loc))
141
+ populateLocale = loc
142
+ }
143
+
144
+ const kinds = filterKindMap(c)
145
+ for (const cl of q.filter ?? []) {
146
+ // Object.hasOwn on both maps is prototype-safe: a `toString`/`__proto__` field never resolves to an
147
+ // inherited member — it is an unknown (unfilterable) field → a clean 400.
148
+ if (!Object.hasOwn(kinds, cl.field) || !Object.hasOwn(cols, cl.field)) {
149
+ throw createError({ statusCode: 400, statusMessage: `Unknown filter field: ${cl.field}` })
150
+ }
151
+ const kind = kinds[cl.field]!
152
+ if (!opAllowed(kind, cl.op)) {
153
+ throw createError({ statusCode: 400, statusMessage: `Operator "${cl.op}" is not allowed for field "${cl.field}"` })
154
+ }
155
+ conds.push(filterCondition(cols[cl.field]!, kind, cl.op, cl.value))
156
+ }
157
+ if (publishedOnly && Object.hasOwn(cols, 'status')) conds.push(eq(cols.status, 'published'))
158
+
159
+ const where = conds.length ? and(...conds) : undefined
160
+
161
+ let orderColumn = cols.createdAt
162
+ let direction: typeof asc = desc
163
+ if (sortRaw) {
164
+ const descending = sortRaw.startsWith('-')
165
+ const name = descending ? sortRaw.slice(1) : sortRaw
166
+ if (!Object.hasOwn(cols, name)) throw createError({ statusCode: 400, statusMessage: `Unknown sort field: ${name}` })
167
+ orderColumn = cols[name]
168
+ direction = descending ? desc : asc
169
+ }
170
+
171
+ // `?? default` only catches null/undefined, NOT NaN (e.g. `?page=abc` → Number('abc') === NaN), which
172
+ // would then bind `.offset(NaN)` = OFFSET NULL — a garbage query. Guard `page` with Number.isFinite;
173
+ // `perPage` is clamped by the shared `clampPerPage` (which also blocks the `.limit(NaN)` cap-bypass).
174
+ const num = (v: number | undefined, fallback: number) => (Number.isFinite(v) ? (v as number) : fallback)
175
+ const page = Math.max(1, Math.floor(num(q.page, 1)))
176
+ const perPage = clampPerPage(q.perPage) // the shared page-size cap (also caps a bulk selection)
177
+
178
+ const depth = clampDepth(q.depth)
179
+
180
+ const rawData = db.select().from(table(c)).where(where)
181
+ .orderBy(direction(orderColumn)).limit(perPage).offset((page - 1) * perPage).all()
182
+ const totalRow = q.withTotal === false
183
+ ? undefined
184
+ : db.select({ value: count() }).from(table(c)).where(where).get() as { value: number } | undefined
185
+
186
+ // One resolve scope per request: repeated refs across the page's rows resolve once, and the DISTINCT
187
+ // fan-out is budgeted — an anonymous `?depth=10&perPage=500` read can no longer multiply into an
188
+ // unbounded number of synchronous DB reads (each blocks the single event-loop thread).
189
+ const data = withResolveScope(
190
+ () => rawData.map((r) => populateRow(r as Record<string, unknown>, { depth, locale: populateLocale, def: c.def })),
191
+ resolveBudgetFor(perPage), // scale the ceiling with the page size so a full legitimate page always populates
192
+ `list ${c.def.name}`,
193
+ ) as Row[]
194
+ attachTranslationStatus(db, c, data, publishedOnly)
195
+ // Dead-reference warnings are an admin-editor signal; skip the extra query on published-scope
196
+ // (public / prerender) reads, where it is irrelevant and would tax the hot path.
197
+ if (!publishedOnly) attachDeadRefs(db, c, data)
198
+ return { data, total: Number(totalRow?.value ?? 0), page, perPage }
199
+ }
200
+
201
+ /**
202
+ * Attach `$hasDeadRefs` (a boolean sidecar, like `$translations`) to a page of admin list rows in ONE
203
+ * batched query over `record_refs` (no N+1): true when any reference the row holds points at a deleted or
204
+ * unpublished target. A no-op for collections that can never hold a reference. The warning is DERIVED on
205
+ * read, so it clears the instant the link is removed/repointed or the target is restored/republished.
206
+ */
207
+ function attachDeadRefs(db: DB, c: BuiltCollection, rows: Row[]): void {
208
+ if (rows.length === 0 || !collectionMayReference(c.def)) return
209
+ const ids = rows.map((r) => r.id).filter((x): x is number => typeof x === 'number')
210
+ if (!ids.length) return
211
+ let edges: { sourceId: number; targetColl: string; targetId: number }[]
212
+ try {
213
+ edges = db.select({ sourceId: recordRefs.sourceId, targetColl: recordRefs.targetColl, targetId: recordRefs.targetId })
214
+ .from(recordRefs).where(and(eq(recordRefs.sourceColl, c.def.name), inArray(recordRefs.sourceId, ids))).all()
215
+ } catch {
216
+ return // record_refs not migrated yet (e.g. a bare DB) — derive no warnings rather than break the list.
217
+ }
218
+ for (const r of rows) r.$hasDeadRefs = false
219
+ if (!edges.length) return
220
+ const dead = deadTargets(db, edges.map((e) => ({ collection: e.targetColl, id: e.targetId })))
221
+ if (!dead.size) return
222
+ const deadSources = new Set<number>()
223
+ for (const e of edges) if (dead.has(`${e.targetColl}:${e.targetId}`)) deadSources.add(e.sourceId)
224
+ for (const row of rows) if (deadSources.has(row.id as number)) row.$hasDeadRefs = true
225
+ }
226
+
227
+ /**
228
+ * Attach the per-row translation status (`$translations`: locale → sibling row id, or null when the
229
+ * locale is missing) for a page of list rows in a SINGLE batched query — the no-N+1 alternative to
230
+ * calling resolveTranslations() once per row. Same shape as resolveTranslations(); the `$`-prefix
231
+ * marks it a server-computed sidecar (mirrors the media `$media` convention), so it never collides
232
+ * with a user-defined field. Only multi-mode translatable collections own a translationGroup, so it
233
+ * is a no-op (and issues no query) for every other collection and for an empty page. The sibling
234
+ * lookup honours `publishedOnly` (same as the page query) so a published-scope read never reveals
235
+ * draft translations.
236
+ */
237
+ function attachTranslationStatus(db: DB, c: BuiltCollection, rows: Row[], publishedOnly: boolean): void {
238
+ if (c.def.mode !== 'multi' || !c.def.translatable || rows.length === 0) return
239
+ const cols = columns(c)
240
+ const groups = [...new Set(rows.map((r) => r.translationGroup as string).filter((g) => g != null))]
241
+ if (groups.length === 0) return
242
+
243
+ const conds = [inArray(cols.translationGroup, groups)]
244
+ if (publishedOnly && Object.hasOwn(cols, 'status')) conds.push(eq(cols.status, 'published'))
245
+ const siblings = db.select({ translationGroup: cols.translationGroup, locale: cols.locale, id: cols.id })
246
+ .from(table(c)).where(and(...conds)).all() as Array<{ translationGroup: string; locale: string; id: number }>
247
+
248
+ const byGroup = new Map<string, Record<string, number | null>>()
249
+ for (const g of groups) {
250
+ const map: Record<string, number | null> = {}
251
+ for (const loc of supportedLocales()) map[loc] = null
252
+ byGroup.set(g, map)
253
+ }
254
+ for (const s of siblings) byGroup.get(s.translationGroup)![s.locale] = s.id
255
+ for (const r of rows) {
256
+ const g = r.translationGroup as string | undefined
257
+ if (g != null) r.$translations = byGroup.get(g) ?? null
258
+ }
259
+ }
260
+
261
+ export function getOne(db: DB, c: BuiltCollection, id: number, depth = 0, locale?: string, publishedOnly = false): Row {
262
+ captureRead(c.def.name, id) // a detail read depends on exactly this record
263
+ const cols = columns(c)
264
+ const row = db.select().from(table(c)).where(eq(cols.id, id)).get() as Row | undefined
265
+ if (!row) throw createError({ statusCode: 404, statusMessage: `${c.name} ${id} not found` })
266
+ if (publishedOnly && Object.hasOwn(cols, 'status') && row.status !== 'published') {
267
+ throw createError({ statusCode: 404, statusMessage: `${c.name} ${id} not found` })
268
+ }
269
+ const safeDepth = clampDepth(depth)
270
+ const loc = c.def.translatable ? resolveLocale(locale) : primaryLocale()
271
+ // Nested reads (the relation populator's recursive getOne) reuse the enclosing request's scope.
272
+ return withResolveScope(
273
+ () => populateRow(row as Record<string, unknown>, { depth: safeDepth, locale: loc, def: c.def }),
274
+ resolveBudgetFor(1),
275
+ `get ${c.def.name}:${id}`,
276
+ ) as Row
277
+ }
278
+
279
+ /** Re-enforce `required` for conditional fields whose condition is met (the per-field schema relaxes
280
+ * them since it can't see siblings). Runs on the EFFECTIVE record — for a partial update/PUT that is
281
+ * the existing row overlaid with the parsed change, so a patch that merely makes the condition met is
282
+ * validated too. Same 400 shape as a Zod failure, keyed by the field's def name. */
283
+ function assertConditions(c: BuiltCollection, record: Row): void {
284
+ const issues = c.applyConditions?.(record).issues
285
+ if (issues?.length) {
286
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed', data: issues })
287
+ }
288
+ }
289
+
290
+ /** Apply field-type write-transforms (e.g. slug auto-generation) before insert/update. `record` is the
291
+ * cross-field context a transform reads (`options.from`). On CREATE every transforming field runs (so an
292
+ * omitted slug is still generated); on UPDATE only fields present in the patch run (an unrelated edit must
293
+ * not silently rewrite a slug/URL). Mutates `values` in place. */
294
+ export function applyFieldTransforms(c: BuiltCollection, values: Row, record: Row, all: boolean): void {
295
+ for (const [key, fieldDef] of Object.entries(c.def.fields)) {
296
+ const { jsKey: col } = resolveColumnName(key, fieldDef) // values are keyed by jsKey
297
+ if (!all && !Object.hasOwn(values, col)) continue
298
+ if (fieldIs(fieldDef, 'repeater')) {
299
+ const arr = values[col]
300
+ if (Array.isArray(arr)) for (const entry of arr) if (entry && typeof entry === 'object') transformNested(fieldDef.options.fields, entry as Row)
301
+ continue
302
+ }
303
+ const transform = getFieldType(fieldDef.type).transform
304
+ if (transform) values[col] = transform(values[col], record, fieldDef)
305
+ }
306
+ // Block content is sent whole, so recurse transforms through each block's props + nested slots.
307
+ if (c.def.blocks?.enabled && (all || Object.hasOwn(values, 'content'))) transformBlocks(values.content)
308
+ }
309
+
310
+ /** Apply transforms inside a NESTED scope (a repeater entry or a block's props): the object IS the full
311
+ * sibling context (the whole nested value is always sent), so a transform reads AND writes it. Recurses
312
+ * into nested repeaters. */
313
+ function transformNested(fields: Record<string, FieldDef>, scope: Row): void {
314
+ for (const [key, fieldDef] of Object.entries(fields)) {
315
+ if (fieldIs(fieldDef, 'repeater')) {
316
+ const arr = scope[key]
317
+ if (Array.isArray(arr)) for (const entry of arr) if (entry && typeof entry === 'object') transformNested(fieldDef.options.fields, entry as Row)
318
+ continue
319
+ }
320
+ const transform = getFieldType(fieldDef.type).transform
321
+ if (transform) scope[key] = transform(scope[key], scope, fieldDef)
322
+ }
323
+ }
324
+
325
+ /** Recurse transforms through block content: each block's props (keyed by field name) + its slots' blocks. */
326
+ function transformBlocks(blocks: unknown): void {
327
+ if (!Array.isArray(blocks)) return
328
+ for (const block of blocks) {
329
+ if (!block || typeof block !== 'object') continue
330
+ const b = block as { type?: string; props?: unknown; slots?: unknown }
331
+ const def = typeof b.type === 'string' ? getBlock(b.type) : undefined
332
+ if (def && b.props && typeof b.props === 'object') transformNested(def.fields, b.props as Row)
333
+ if (b.slots && typeof b.slots === 'object') for (const sub of Object.values(b.slots as Record<string, unknown>)) transformBlocks(sub)
334
+ }
335
+ }
336
+
337
+ /** Reject (hard, NO silent dedup) a `unique` slug that already exists — so the editor gets a clear
338
+ * field-scoped "already exists" error and the photographer must choose a different slug (Pruvious-style).
339
+ * Runs AFTER transforms (sees the generated value), BEFORE insert/update. Throws a 400 keyed to the field
340
+ * (`path: [key]` → the slug widget shows it inline). `excludeId` skips the row's own row on update so
341
+ * re-saving an unchanged slug isn't a false collision. Scope = the whole table (a plain `unique` column is
342
+ * global). The DB UNIQUE index stays as the integrity backstop against a race. */
343
+ function assertUniqueSlugs(db: DB, c: BuiltCollection, values: Row, excludeId: number | null): void {
344
+ let cols: Record<string, AnySQLiteColumn> | undefined
345
+ const issues: { path: string[]; message: string }[] = []
346
+ for (const [key, fieldDef] of Object.entries(c.def.fields)) {
347
+ if (fieldDef.type !== 'slug' || !fieldDef.unique) continue
348
+ const { jsKey } = resolveColumnName(key, fieldDef)
349
+ const slug = values[jsKey]
350
+ if (typeof slug !== 'string' || !slug) continue
351
+ cols ??= columns(c)
352
+ const where = excludeId == null ? eq(cols[jsKey], slug) : and(eq(cols[jsKey], slug), ne(cols.id, excludeId))
353
+ if (db.select({ id: cols.id }).from(table(c)).where(where).get()) {
354
+ issues.push({ path: [key], message: `The slug “${slug}” already exists — choose a different one.` })
355
+ }
356
+ }
357
+ if (issues.length) throw createError({ statusCode: 400, statusMessage: 'Validation failed', data: issues })
358
+ }
359
+
360
+ export function create(db: DB, c: BuiltCollection, body: unknown): Row {
361
+ if (c.def.mode === 'single') {
362
+ throw createError({ statusCode: 405, statusMessage: 'Use PUT for singletons' })
363
+ }
364
+ const parsed = c.insert.safeParse(body)
365
+ if (!parsed.success) {
366
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed', data: parsed.error.issues })
367
+ }
368
+ assertConditions(c, parsed.data as Row)
369
+ const values = { ...(parsed.data as Row) }
370
+ if (c.def.translatable) values.locale = resolveLocale(values.locale as string | undefined)
371
+ if (c.def.mode === 'multi' && c.def.translatable && !values.translationGroup) values.translationGroup = nanoid()
372
+ // pageLike: require a slug (auto-generate from the title when blank) + enforce global resolved-route
373
+ // uniqueness. Owns the path normalization (leading slash + lowercase).
374
+ if (c.def.pageLike) resolvePageSlug(db, c, values, { id: null, existing: null, collections: allCollections(), primary: primaryLocale(), prefixPrimary: prefixPrimaryLocale() })
375
+ applyFieldTransforms(c, values, values, true) // e.g. a blank `slug` field auto-generated from `title`
376
+ assertUniqueSlugs(db, c, values, null) // a duplicate slug is a clear field error, never a silent rewrite
377
+ delete values.id
378
+ delete values.createdAt
379
+ delete values.updatedAt
380
+
381
+ const row = runCatchingUnique(() => db.insert(table(c)).values(values).returning().get() as Row)
382
+ emitWrite(c.def, null, row)
383
+ return row
384
+ }
385
+
386
+ export interface UpdateOptions {
387
+ /** Optimistic-concurrency precondition: the `updatedAt` (epoch ms) the caller last read. When given and
388
+ * it no longer matches the stored row, the update is refused with 409 — so a stale editor tab can't
389
+ * silently revert a newer save (and propagate that revert into the static output). Omitted → unconditional. */
390
+ expectedUpdatedAt?: number
391
+ }
392
+
393
+ export function update(db: DB, c: BuiltCollection, id: number, body: unknown, opts: UpdateOptions = {}): Row {
394
+ if (c.def.mode === 'single') throw createError({ statusCode: 405, statusMessage: 'Use PUT for singletons' })
395
+ const parsed = c.update.safeParse(body)
396
+ if (!parsed.success) {
397
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed', data: parsed.error.issues })
398
+ }
399
+ const values: Row = { ...(parsed.data as Row), updatedAt: new Date() }
400
+ // Normalize whenever `locale` is present — including '' (the update schema allows any string). Mirrors
401
+ // create(): resolveLocale('') coerces to the primary, so an empty locale can never be persisted (which
402
+ // would drop the row from every locale-filtered list and mis-route a pageLike record).
403
+ if (c.def.translatable && 'locale' in values) values.locale = resolveLocale(values.locale as string | undefined)
404
+ delete values.id
405
+ delete values.createdAt
406
+ delete values.translationGroup
407
+ delete values.singletonKey
408
+
409
+ const cols = columns(c)
410
+ const before = db.select().from(table(c)).where(eq(cols.id, id)).get() as Row | undefined
411
+ // Optimistic concurrency: refuse a write whose baseline is stale, BEFORE any mutation (a missing row
412
+ // still 404s below, not 409 — the precondition only guards an existing row the caller means to replace).
413
+ if (before && opts.expectedUpdatedAt !== undefined) {
414
+ const current = before.updatedAt instanceof Date ? before.updatedAt.getTime() : new Date(before.updatedAt as string | number).getTime()
415
+ if (current !== opts.expectedUpdatedAt) {
416
+ throw createError({ statusCode: 409, statusMessage: 'This record changed since you opened it. Reload to see the latest version before saving.' })
417
+ }
418
+ }
419
+ // Conditional-required is enforced on the merged record (existing ⊕ patch); skip when the row is
420
+ // missing — the update returns 404 below rather than a misleading 400.
421
+ if (before) assertConditions(c, { ...before, ...(parsed.data as Row) })
422
+ // pageLike slug: re-validate when the update changes the resolved route — either `path` directly, OR
423
+ // the `locale` (the route is localePath(path, locale, …), so a locale-only change re-routes the row).
424
+ // For a locale-only change, seed the existing path so resolvePageSlug re-checks it as an explicit path
425
+ // under the new locale (rejects a cross-collection 409). An update touching neither leaves the route as-is.
426
+ const localeChanged = !!c.def.translatable && 'locale' in values && values.locale !== before?.locale
427
+ if (c.def.pageLike && before && ('path' in values || localeChanged)) {
428
+ if (!('path' in values) && typeof before.path === 'string') values.path = before.path
429
+ if ('path' in values) resolvePageSlug(db, c, values, { id, existing: before, collections: allCollections(), primary: primaryLocale(), prefixPrimary: prefixPrimaryLocale() })
430
+ }
431
+ // Field write-transforms (only the patched fields; merged record gives transforms their cross-field source).
432
+ if (before) applyFieldTransforms(c, values, { ...before, ...values }, false)
433
+ assertUniqueSlugs(db, c, values, id) // a patched slug that collides errors (excluding this row itself)
434
+ const row = runCatchingUnique(() => db.update(table(c)).set(values).where(eq(cols.id, id)).returning().get() as Row | undefined)
435
+ if (!row) throw createError({ statusCode: 404, statusMessage: `${c.name} ${id} not found` })
436
+ emitWrite(c.def, before ?? null, row)
437
+ return row
438
+ }
439
+
440
+ /** Single-record delete — a thin delegate over `removeMany([id])` so there is ONE delete implementation.
441
+ * The `[id].delete.ts` route and its `{ deleted, id }` contract are unchanged. */
442
+ export function remove(db: DB, c: BuiltCollection, id: number): { deleted: true; id: number } {
443
+ removeMany(db, c, [id])
444
+ return { deleted: true, id }
445
+ }
446
+
447
+ /**
448
+ * Delete a batch of rows by id — the single implementation behind both the single-record delete and the
449
+ * bulk `delete` action (a row action IS a bulk action with one id). ALL-OR-NOTHING: a pre-flight existence
450
+ * check aborts the whole delete with a clean 404 if ANY id is absent from THIS collection (a foreign or a
451
+ * stale id), so a partial silent success is impossible. The delete is one atomic multi-row statement; the
452
+ * per-row write events fire strictly AFTER it (never inside a rollback-able transaction) so a failed delete
453
+ * can never leave a stray publish-prune enqueued for an un-deleted row.
454
+ */
455
+ export function removeMany(db: DB, c: BuiltCollection, ids: number[]): { count: number; ids: number[] } {
456
+ if (c.def.mode === 'single') throw createError({ statusCode: 405, statusMessage: 'Use PUT for singletons' })
457
+ const cols = columns(c)
458
+ const rows = db.select().from(table(c)).where(inArray(cols.id, ids)).all() as Row[]
459
+ const found = new Set(rows.map((r) => r.id as number))
460
+ const missing = ids.filter((id) => !found.has(id))
461
+ if (missing.length) throw createError({ statusCode: 404, statusMessage: `${c.name} not found: ${missing.join(', ')}` })
462
+ db.delete(table(c)).where(inArray(cols.id, ids)).run()
463
+ for (const before of rows) emitWrite(c.def, before, null)
464
+ return { count: rows.length, ids: rows.map((r) => r.id as number) }
465
+ }
466
+
467
+ /**
468
+ * Publish / unpublish a batch of rows by persisting their `status` — NOT a separate publish path. Writing
469
+ * `status` and emitting the SAME write event the editor save emits IS the publish: classifyWrite →
470
+ * planInvalidation → the publish queue coalesces N emits into one incremental publish (PUBLISH renders the
471
+ * self route, UNPUBLISH prunes the old route). ALL-OR-NOTHING like `removeMany` (a missing id 404s before
472
+ * any write). Validation (`assertConditions`) runs on PUBLISH ONLY — unpublishing must never be blockable
473
+ * (you must always be able to take a broken page offline). Omits `update()`'s slug/transform branches,
474
+ * which are provably inert for a status-only change.
475
+ */
476
+ export function setStatusMany(db: DB, c: BuiltCollection, ids: number[], status: 'draft' | 'published'): { count: number; ids: number[] } {
477
+ if (c.def.mode === 'single') throw createError({ statusCode: 405, statusMessage: 'Use PUT for singletons' })
478
+ if (!c.def.status) throw createError({ statusCode: 400, statusMessage: `${c.name} has no status` })
479
+ const cols = columns(c)
480
+ const before = db.select().from(table(c)).where(inArray(cols.id, ids)).all() as Row[]
481
+ const found = new Set(before.map((r) => r.id as number))
482
+ const missing = ids.filter((id) => !found.has(id))
483
+ if (missing.length) throw createError({ statusCode: 404, statusMessage: `${c.name} not found: ${missing.join(', ')}` })
484
+ if (status === 'published') for (const row of before) assertConditions(c, { ...row, status })
485
+ const updatedAt = new Date()
486
+ db.update(table(c)).set({ status, updatedAt }).where(inArray(cols.id, ids)).run()
487
+ for (const b of before) emitWrite(c.def, b, { ...b, status, updatedAt })
488
+ return { count: before.length, ids: before.map((r) => r.id as number) }
489
+ }
490
+
491
+ /**
492
+ * Duplicate a batch of rows — sequential and best-effort (a duplicate is a row action, not an all-or-nothing
493
+ * transaction): each id becomes a new draft copy via `duplicateRecord`, and the first failing id throws its
494
+ * own status. `duplicate.ts` is imported lazily because it composes `create()` from THIS module — a static
495
+ * import would form a load-time cycle (crud → duplicate → crud); deferring keeps crud's module graph acyclic.
496
+ */
497
+ export async function duplicateMany(db: DB, c: BuiltCollection, ids: number[]): Promise<Row[]> {
498
+ const { duplicateRecord } = await import('./duplicate')
499
+ const out: Row[] = []
500
+ for (const id of ids) out.push(duplicateRecord(db, c, id))
501
+ return out
502
+ }
503
+
504
+ export function resolveTranslations(db: DB, c: BuiltCollection, id: number): Record<string, number | null> {
505
+ if (c.def.mode === 'single' || !c.def.translatable) {
506
+ throw createError({ statusCode: 400, statusMessage: 'Translations are not enabled for this collection' })
507
+ }
508
+ const cols = columns(c)
509
+ const base = db.select().from(table(c)).where(eq(cols.id, id)).get() as Row | undefined
510
+ if (!base) throw createError({ statusCode: 404, statusMessage: `${c.name} ${id} not found` })
511
+ const rows = db.select().from(table(c)).where(eq(cols.translationGroup, base.translationGroup)).all() as Row[]
512
+
513
+ const result: Record<string, number | null> = {}
514
+ for (const loc of supportedLocales()) result[loc] = null
515
+ for (const row of rows) result[row.locale as string] = row.id as number
516
+ return result
517
+ }
518
+
519
+ export function getSingleton(db: DB, c: BuiltCollection, locale?: string, publishedOnly = false, depth = 0): Row | null {
520
+ captureRead(c.def.name) // a singleton (nav/settings/footer) is global — any page that reads it depends on it
521
+ const cols = columns(c)
522
+ const loc = c.def.translatable ? resolveLocale(locale) : primaryLocale()
523
+ const where = singletonWhere(cols, c, c.def.translatable ? loc : undefined)
524
+ const row = db.select().from(table(c)).where(where).get() as Row | undefined
525
+ if (!row) return null
526
+ if (publishedOnly && Object.hasOwn(cols, 'status') && row.status !== 'published') return null
527
+ // Populate like list()/getOne() so a singleton's media/relation/link fields resolve at depth > 0 — the
528
+ // canonical settings-singleton (site logo, nav link repeater) relies on this exactly as any collection does.
529
+ return withResolveScope(
530
+ () => populateRow(row as Record<string, unknown>, { depth: clampDepth(depth), locale: loc, def: c.def }),
531
+ resolveBudgetFor(1),
532
+ `singleton ${c.def.name}`,
533
+ ) as Row
534
+ }
535
+
536
+ export function putSingleton(db: DB, c: BuiltCollection, locale: string | undefined, body: unknown, opts: UpdateOptions = {}): Row {
537
+ if (c.def.mode !== 'single') {
538
+ throw createError({ statusCode: 405, statusMessage: 'PUT is only for singletons' })
539
+ }
540
+ const loc = c.def.translatable ? resolveLocale(locale) : undefined
541
+ const cols = columns(c)
542
+ const existing = db.select().from(table(c))
543
+ .where(singletonWhere(cols, c, loc))
544
+ .get() as Row | undefined
545
+ // Optimistic concurrency: reject an overwrite whose baseline is stale (a concurrent edit of the same
546
+ // singleton). Only applies to an OVERWRITE — a first save (no existing row) has no baseline to conflict.
547
+ if (existing && opts.expectedUpdatedAt !== undefined) {
548
+ const current = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : new Date(existing.updatedAt as string | number).getTime()
549
+ if (current !== opts.expectedUpdatedAt) {
550
+ throw createError({ statusCode: 409, statusMessage: 'This record changed since you opened it. Reload to see the latest version before saving.' })
551
+ }
552
+ }
553
+ // First save (no existing row) validates with the FULL insert schema so a missing hard-required field is
554
+ // a clean 400 — `.partial()` would let it through and hit the NOT NULL column as an unmapped 500. An
555
+ // overwrite is a PATCH-style partial (only the sent fields change).
556
+ const parsed = (existing ? c.insert.partial() : c.insert).safeParse(body)
557
+ if (!parsed.success) {
558
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed', data: parsed.error.issues })
559
+ }
560
+ // A PUT replaces (create or overwrite) the singleton — enforce conditional-required on the merged record.
561
+ assertConditions(c, { ...(existing ?? {}), ...(parsed.data as Row) })
562
+
563
+ const values: Row = { ...(parsed.data as Row), singletonKey: c.name }
564
+ if (c.def.translatable) values.locale = loc
565
+ delete values.id
566
+ delete values.createdAt
567
+ delete values.updatedAt
568
+ // Mirror create()/update(): auto-generate transform-driven fields (e.g. a blank slug from `title`) and
569
+ // reject a colliding unique slug with a clean field error, BEFORE the write.
570
+ applyFieldTransforms(c, values, existing ? { ...existing, ...values } : values, !existing)
571
+ assertUniqueSlugs(db, c, values, (existing?.id as number) ?? null)
572
+ const saved = runCatchingUnique(() => existing
573
+ ? db.update(table(c)).set({ ...values, updatedAt: new Date() }).where(eq(cols.id, existing.id)).returning().get() as Row
574
+ : db.insert(table(c)).values(values).returning().get() as Row)
575
+ emitWrite(c.def, existing ?? null, saved)
576
+ return saved
577
+ }
@@ -0,0 +1,26 @@
1
+ import { dirname } from 'node:path'
2
+ import { mkdirSync } from 'node:fs'
3
+ import Database from 'better-sqlite3'
4
+ import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
5
+ import { resolveServerKestrel, serverRuntimeConfig } from './server-config'
6
+
7
+ let instance: BetterSQLite3Database | undefined
8
+
9
+ export function useDb(): BetterSQLite3Database {
10
+ if (!instance) {
11
+ // Prefer the path the kestrel module resolved from the consumer's `kestrel: {}` (via runtimeConfig);
12
+ // fall back to resolving Kestrel's own config file + env for non-runtime callers (e.g. node scripts).
13
+ const fromRc = serverRuntimeConfig()?.kestrel as { dbPath?: string } | undefined
14
+ const path = fromRc?.dbPath || resolveServerKestrel().dbPath
15
+ if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true })
16
+ const sqlite = new Database(path)
17
+ sqlite.pragma('journal_mode = WAL')
18
+ // NORMAL is durable under WAL (only loses the very last txn on OS crash, never corrupts) and avoids an
19
+ // fsync per commit — markedly faster for admin writes and bulk seed/import. busy_timeout lets a reader
20
+ // (e.g. a concurrent prerender) wait for a writer instead of failing immediately with SQLITE_BUSY.
21
+ sqlite.pragma('synchronous = NORMAL')
22
+ sqlite.pragma('busy_timeout = 5000')
23
+ instance = drizzle(sqlite)
24
+ }
25
+ return instance
26
+ }