@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.
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +234 -0
- package/kestrel.config.ts +28 -0
- package/layers/access/nuxt.config.ts +5 -0
- package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
- package/layers/access/server/middleware/access-guard.ts +30 -0
- package/layers/access/server/types.d.ts +8 -0
- package/layers/access/server/utils/csrf.ts +39 -0
- package/layers/access/server/utils/grant-registry.ts +36 -0
- package/layers/access/server/utils/guard.ts +44 -0
- package/layers/access/server/utils/ip-allowlist.ts +76 -0
- package/layers/access/server/utils/policy.ts +98 -0
- package/layers/access/server/utils/public-resources.ts +11 -0
- package/layers/access/server/utils/render-context.ts +41 -0
- package/layers/access/server/utils/require-admin.ts +15 -0
- package/layers/admin/app/components/AdminAccount.vue +212 -0
- package/layers/admin/app/components/AdminNav.vue +44 -0
- package/layers/admin/app/components/BlockFields.vue +68 -0
- package/layers/admin/app/components/BlockPreview.vue +401 -0
- package/layers/admin/app/components/BlockTree.vue +433 -0
- package/layers/admin/app/components/BlocksBody.vue +162 -0
- package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
- package/layers/admin/app/components/CollectionEditor.vue +203 -0
- package/layers/admin/app/components/CollectionList.vue +1091 -0
- package/layers/admin/app/components/EditorStatus.vue +199 -0
- package/layers/admin/app/components/EditorUnsupported.vue +28 -0
- package/layers/admin/app/components/FieldsBody.vue +23 -0
- package/layers/admin/app/components/LocaleBar.vue +105 -0
- package/layers/admin/app/components/PageFields.vue +101 -0
- package/layers/admin/app/components/PageFieldsPane.vue +39 -0
- package/layers/admin/app/components/SeoFields.vue +169 -0
- package/layers/admin/app/components/SingletonEditor.vue +92 -0
- package/layers/admin/app/composables/useAuth.ts +68 -0
- package/layers/admin/app/composables/useBlockTree.ts +95 -0
- package/layers/admin/app/composables/useBlocks.ts +7 -0
- package/layers/admin/app/composables/useCachedResource.ts +35 -0
- package/layers/admin/app/composables/useCollectionOps.ts +53 -0
- package/layers/admin/app/composables/useCollections.ts +7 -0
- package/layers/admin/app/composables/useContentLocales.ts +23 -0
- package/layers/admin/app/composables/useEditForm.ts +370 -0
- package/layers/admin/app/composables/useLinkResolver.ts +40 -0
- package/layers/admin/app/composables/useListColumns.ts +53 -0
- package/layers/admin/app/composables/useListUrlState.ts +53 -0
- package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
- package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
- package/layers/admin/app/composables/usePublishStatus.ts +98 -0
- package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
- package/layers/admin/app/composables/useTheme.ts +36 -0
- package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
- package/layers/admin/app/layouts/admin.vue +267 -0
- package/layers/admin/app/middleware/admin-auth.ts +4 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
- package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
- package/layers/admin/app/pages/admin/index.vue +118 -0
- package/layers/admin/app/pages/admin/login.vue +68 -0
- package/layers/admin/app/pages/admin/media.vue +28 -0
- package/layers/admin/app/pages/admin/references.vue +146 -0
- package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
- package/layers/admin/app/plugins/reauth.client.ts +20 -0
- package/layers/admin/app/utils/admin-style-guard.ts +20 -0
- package/layers/admin/app/utils/block-tree.ts +214 -0
- package/layers/admin/app/utils/collection-ops.ts +44 -0
- package/layers/admin/app/utils/dead-refs.ts +30 -0
- package/layers/admin/app/utils/edit-form.ts +223 -0
- package/layers/admin/app/utils/editor-expose.ts +26 -0
- package/layers/admin/app/utils/editor-form-context.ts +83 -0
- package/layers/admin/app/utils/editor-registry.ts +20 -0
- package/layers/admin/app/utils/field-keys.ts +11 -0
- package/layers/admin/app/utils/list-columns.ts +141 -0
- package/layers/admin/app/utils/list-query.ts +96 -0
- package/layers/admin/app/utils/nav-active.ts +9 -0
- package/layers/admin/app/utils/populate-blocks.ts +288 -0
- package/layers/admin/app/utils/preview-channel.ts +62 -0
- package/layers/admin/app/utils/preview-viewport.ts +80 -0
- package/layers/admin/app/utils/reauth.ts +68 -0
- package/layers/admin/app/utils/record-title.ts +15 -0
- package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
- package/layers/admin/app/utils/safe-redirect.ts +12 -0
- package/layers/admin/nuxt.config.ts +17 -0
- package/layers/auth/nuxt.config.ts +1 -0
- package/layers/auth/server/api/auth/login.post.ts +38 -0
- package/layers/auth/server/api/auth/logout.post.ts +9 -0
- package/layers/auth/server/api/auth/session.get.ts +1 -0
- package/layers/auth/server/utils/admin-credential.ts +42 -0
- package/layers/auth/server/utils/client-ip.ts +88 -0
- package/layers/auth/server/utils/login-throttle.ts +126 -0
- package/layers/auth/server/utils/password.ts +52 -0
- package/layers/auth/server/utils/session-cookie.ts +44 -0
- package/layers/auth/server/utils/session-epoch.ts +96 -0
- package/layers/auth/server/utils/session.ts +85 -0
- package/layers/collections/nuxt.config.ts +1 -0
- package/layers/collections/server/collections/pages.ts +19 -0
- package/layers/collections/server/plugins/01.register.ts +32 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
- package/layers/collections/server/utils/populate-relations.ts +66 -0
- package/layers/core/app/utils/bulk-actions.ts +9 -0
- package/layers/core/app/utils/filter-ops.ts +98 -0
- package/layers/core/app/utils/list-limits.ts +27 -0
- package/layers/core/app/utils/locale-path.ts +12 -0
- package/layers/core/app/utils/slugify.ts +16 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
- package/layers/core/modules/auto-discovery/index.ts +44 -0
- package/layers/core/modules/auto-discovery/scan.ts +78 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
- package/layers/core/modules/kestrel/index.ts +86 -0
- package/layers/core/nuxt.config.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
- package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
- package/layers/core/server/api/[collection]/[id].get.ts +8 -0
- package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
- package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
- package/layers/core/server/api/[collection]/index.get.ts +19 -0
- package/layers/core/server/api/[collection]/index.post.ts +8 -0
- package/layers/core/server/api/[collection]/index.put.ts +7 -0
- package/layers/core/server/api/[collection]/options.get.ts +18 -0
- package/layers/core/server/api/[collection]/translations.get.ts +25 -0
- package/layers/core/server/api/collections/[name].get.ts +4 -0
- package/layers/core/server/api/collections/index.get.ts +3 -0
- package/layers/core/server/api/references/broken.get.ts +3 -0
- package/layers/core/server/api/references/referrers.get.ts +24 -0
- package/layers/core/server/database/record-refs.ts +20 -0
- package/layers/core/server/plugins/00.migrate.ts +11 -0
- package/layers/core/server/plugins/02.schema-sync.ts +63 -0
- package/layers/core/server/plugins/03.record-refs.ts +12 -0
- package/layers/core/server/schema/bootstrap.ts +41 -0
- package/layers/core/server/schema/desired.ts +99 -0
- package/layers/core/server/schema/dev-sync.ts +23 -0
- package/layers/core/server/schema/dialect.ts +59 -0
- package/layers/core/server/schema/diff.ts +110 -0
- package/layers/core/server/schema/introspect.ts +67 -0
- package/layers/core/server/schema/model.ts +61 -0
- package/layers/core/server/schema/render-sqlite.ts +89 -0
- package/layers/core/server/schema/sync.ts +201 -0
- package/layers/core/server/tasks/db/migrate.ts +54 -0
- package/layers/core/server/utils/blocks.ts +6 -0
- package/layers/core/server/utils/collection-types.ts +27 -0
- package/layers/core/server/utils/crud.ts +577 -0
- package/layers/core/server/utils/db.ts +26 -0
- package/layers/core/server/utils/defineCollection.ts +207 -0
- package/layers/core/server/utils/duplicate.ts +162 -0
- package/layers/core/server/utils/field-layout.ts +125 -0
- package/layers/core/server/utils/filter-predicate.ts +101 -0
- package/layers/core/server/utils/http.ts +50 -0
- package/layers/core/server/utils/if-unmodified.ts +13 -0
- package/layers/core/server/utils/kestrel-config.ts +410 -0
- package/layers/core/server/utils/key-lock.ts +33 -0
- package/layers/core/server/utils/locale.ts +53 -0
- package/layers/core/server/utils/page-route.ts +58 -0
- package/layers/core/server/utils/page-slug.ts +127 -0
- package/layers/core/server/utils/picker.ts +82 -0
- package/layers/core/server/utils/populate.ts +48 -0
- package/layers/core/server/utils/prerender-memo.ts +23 -0
- package/layers/core/server/utils/read-capture.ts +37 -0
- package/layers/core/server/utils/record-ref-index.ts +200 -0
- package/layers/core/server/utils/registry.ts +24 -0
- package/layers/core/server/utils/resolve-scope.ts +79 -0
- package/layers/core/server/utils/seo.ts +24 -0
- package/layers/core/server/utils/serialize-collection.ts +149 -0
- package/layers/core/server/utils/server-config.ts +18 -0
- package/layers/core/server/utils/sql.ts +2 -0
- package/layers/core/server/utils/storage.local.ts +117 -0
- package/layers/core/server/utils/storage.s3.ts +197 -0
- package/layers/core/server/utils/storage.ts +90 -0
- package/layers/core/server/utils/write-events.ts +31 -0
- package/layers/fields/app/utils/condition.ts +71 -0
- package/layers/fields/app/utils/define-block.ts +13 -0
- package/layers/fields/app/utils/field-constraints.ts +17 -0
- package/layers/fields/app/utils/field-factories.ts +129 -0
- package/layers/fields/app/utils/field-meta.ts +33 -0
- package/layers/fields/app/utils/field-validate.ts +75 -0
- package/layers/fields/app/utils/field-value.ts +7 -0
- package/layers/fields/app/utils/richtext-links.ts +48 -0
- package/layers/fields/nuxt.config.ts +1 -0
- package/layers/fields/server/api/blocks/index.get.ts +9 -0
- package/layers/fields/server/field-registry/index.ts +248 -0
- package/layers/fields/server/field-registry/naming.ts +10 -0
- package/layers/fields/server/field-registry/sanitize.ts +40 -0
- package/layers/fields/server/field-registry/types.ts +19 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
- package/layers/fields/server/utils/block-ids.ts +43 -0
- package/layers/fields/server/utils/block-populate.ts +41 -0
- package/layers/fields/server/utils/buildCollection.ts +88 -0
- package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
- package/layers/fields/server/utils/buildTable.ts +107 -0
- package/layers/fields/server/utils/conditional-required.ts +25 -0
- package/layers/fields/server/utils/defineBlock.ts +86 -0
- package/layers/fields/server/utils/defineFieldType.ts +33 -0
- package/layers/fields/server/utils/extract-refs.ts +152 -0
- package/layers/fields/server/utils/field-populate.ts +68 -0
- package/layers/media/app/components/KestrelImg.vue +65 -0
- package/layers/media/app/components/MediaConflictDialog.vue +35 -0
- package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
- package/layers/media/app/components/MediaGrid.vue +113 -0
- package/layers/media/app/components/MediaImage.vue +70 -0
- package/layers/media/app/components/MediaLibrary.vue +451 -0
- package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
- package/layers/media/app/components/MediaPathBar.vue +163 -0
- package/layers/media/app/components/MediaPicker.vue +19 -0
- package/layers/media/app/components/MediaRenameDialog.vue +32 -0
- package/layers/media/app/components/MediaTable.vue +75 -0
- package/layers/media/app/components/MediaThumb.vue +25 -0
- package/layers/media/app/components/MediaToolbar.vue +90 -0
- package/layers/media/app/components/MediaUploadDialog.vue +87 -0
- package/layers/media/app/components/MediaViewer.vue +88 -0
- package/layers/media/app/components/field/Media.vue +261 -0
- package/layers/media/app/composables/useBlurUp.ts +55 -0
- package/layers/media/app/composables/useMediaClipboard.ts +14 -0
- package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
- package/layers/media/app/composables/useMediaDnd.ts +56 -0
- package/layers/media/app/composables/useMediaLibrary.ts +151 -0
- package/layers/media/app/composables/useMediaOps.ts +50 -0
- package/layers/media/app/composables/useMediaRelocate.ts +39 -0
- package/layers/media/app/composables/useMediaResolver.ts +50 -0
- package/layers/media/app/composables/useMediaUpload.ts +93 -0
- package/layers/media/app/composables/useMediaVariant.ts +34 -0
- package/layers/media/app/plugins/field-media.client.ts +6 -0
- package/layers/media/app/utils/dnd.ts +69 -0
- package/layers/media/app/utils/library.ts +79 -0
- package/layers/media/app/utils/ops.ts +62 -0
- package/layers/media/app/utils/picture-model.ts +125 -0
- package/layers/media/app/utils/thumbhash.ts +19 -0
- package/layers/media/app/utils/variant-name.ts +20 -0
- package/layers/media/nuxt.config.ts +3 -0
- package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
- package/layers/media/server/api/media/[id].delete.ts +18 -0
- package/layers/media/server/api/media/[id].get.ts +10 -0
- package/layers/media/server/api/media/[id].patch.ts +41 -0
- package/layers/media/server/api/media/backfill.post.ts +18 -0
- package/layers/media/server/api/media/copy.post.ts +7 -0
- package/layers/media/server/api/media/delete.post.ts +13 -0
- package/layers/media/server/api/media/folders.post.ts +18 -0
- package/layers/media/server/api/media/index.get.ts +15 -0
- package/layers/media/server/api/media/index.post.ts +157 -0
- package/layers/media/server/api/media/library.get.ts +17 -0
- package/layers/media/server/api/media/move.post.ts +7 -0
- package/layers/media/server/api/media/rename.post.ts +7 -0
- package/layers/media/server/api/media/resolve.get.ts +13 -0
- package/layers/media/server/collections/media-settings.ts +25 -0
- package/layers/media/server/collections/media.ts +29 -0
- package/layers/media/server/database/folders.ts +10 -0
- package/layers/media/server/middleware/ondemand-variants.ts +37 -0
- package/layers/media/server/plugins/02.register-media.ts +19 -0
- package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
- package/layers/media/server/plugins/04.variant-capture.ts +23 -0
- package/layers/media/server/schema-tables/folders.ts +4 -0
- package/layers/media/server/tasks/media/backfill.ts +18 -0
- package/layers/media/server/utils/backfill.ts +132 -0
- package/layers/media/server/utils/derive.ts +59 -0
- package/layers/media/server/utils/folder-paths.ts +42 -0
- package/layers/media/server/utils/folders.ts +12 -0
- package/layers/media/server/utils/library.ts +90 -0
- package/layers/media/server/utils/media-cleanup.ts +27 -0
- package/layers/media/server/utils/media-enabled.ts +23 -0
- package/layers/media/server/utils/media-ops.ts +118 -0
- package/layers/media/server/utils/media-write.ts +13 -0
- package/layers/media/server/utils/naming.ts +52 -0
- package/layers/media/server/utils/ondemand.ts +69 -0
- package/layers/media/server/utils/persist-upload.ts +62 -0
- package/layers/media/server/utils/populate.ts +47 -0
- package/layers/media/server/utils/record.ts +63 -0
- package/layers/media/server/utils/relocate-ops.ts +341 -0
- package/layers/media/server/utils/relocate-plan.ts +63 -0
- package/layers/media/server/utils/resolve.ts +86 -0
- package/layers/media/server/utils/sanitize-svg.ts +12 -0
- package/layers/media/server/utils/sniff.ts +68 -0
- package/layers/media/server/utils/storage-relocate.ts +139 -0
- package/layers/media/server/utils/translations.ts +19 -0
- package/layers/media/server/utils/usages.ts +66 -0
- package/layers/media/server/utils/variant-capture.ts +70 -0
- package/layers/media/server/utils/variants.ts +108 -0
- package/layers/public/app/app.vue +5 -0
- package/layers/public/app/components/BlockRenderer.vue +56 -0
- package/layers/public/app/components/KestrelLink.vue +26 -0
- package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
- package/layers/public/app/composables/public-page.ts +24 -0
- package/layers/public/app/error.vue +12 -0
- package/layers/public/app/layouts/default.vue +5 -0
- package/layers/public/app/pages/[...slug].vue +157 -0
- package/layers/public/app/pages/__kestrel/preview.vue +34 -0
- package/layers/public/app/utils/block-edit-context.ts +16 -0
- package/layers/public/app/utils/link-href.ts +46 -0
- package/layers/public/app/utils/page-head.ts +84 -0
- package/layers/public/app/utils/preview-protocol.ts +81 -0
- package/layers/public/app/utils/public-route.ts +46 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
- package/layers/public/modules/deploy-output/index.ts +82 -0
- package/layers/public/modules/prerender-routes/discover.ts +37 -0
- package/layers/public/modules/prerender-routes/index.ts +85 -0
- package/layers/public/modules/prerender-routes/routes.ts +16 -0
- package/layers/public/modules/prune-media/index.ts +48 -0
- package/layers/public/modules/prune-media/prune-media.ts +81 -0
- package/layers/public/nuxt.config.ts +11 -0
- package/layers/public/server/api/links/resolve.get.ts +19 -0
- package/layers/public/server/api/publish-status.get.ts +44 -0
- package/layers/public/server/api/route.get.ts +18 -0
- package/layers/public/server/database/publish-deps.ts +12 -0
- package/layers/public/server/database/publish-status.ts +19 -0
- package/layers/public/server/plugins/02.register-links.ts +17 -0
- package/layers/public/server/plugins/zz.publish.ts +63 -0
- package/layers/public/server/routes/llms.txt.get.ts +72 -0
- package/layers/public/server/routes/robots.txt.get.ts +8 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
- package/layers/public/server/schema-tables/publish-deps.ts +4 -0
- package/layers/public/server/schema-tables/publish-status.ts +4 -0
- package/layers/public/server/tasks/publish/run.ts +26 -0
- package/layers/public/server/utils/link-resolve.ts +35 -0
- package/layers/public/server/utils/llms.ts +39 -0
- package/layers/public/server/utils/page-resolve.ts +86 -0
- package/layers/public/server/utils/populate-links.ts +66 -0
- package/layers/public/server/utils/publish/coalesce.ts +22 -0
- package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
- package/layers/public/server/utils/publish/deps.ts +87 -0
- package/layers/public/server/utils/publish/invalidation.ts +141 -0
- package/layers/public/server/utils/publish/publish-status.ts +54 -0
- package/layers/public/server/utils/publish/publisher.ts +291 -0
- package/layers/public/server/utils/publish/queue.ts +67 -0
- package/layers/public/server/utils/publish/route-for-record.ts +16 -0
- package/layers/public/server/utils/publish/route-keys.ts +13 -0
- package/layers/public/server/utils/site-url.ts +32 -0
- package/layers/public/server/utils/sitemap.ts +97 -0
- package/layers/ui/app/assets/scss/_base.scss +16 -0
- package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
- package/layers/ui/app/assets/scss/_mixins.scss +50 -0
- package/layers/ui/app/assets/scss/_reset.scss +65 -0
- package/layers/ui/app/assets/scss/_tokens.scss +183 -0
- package/layers/ui/app/assets/scss/main.scss +4 -0
- package/layers/ui/app/components/field/Boolean.vue +30 -0
- package/layers/ui/app/components/field/Choice.vue +68 -0
- package/layers/ui/app/components/field/Datetime.vue +96 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
- package/layers/ui/app/components/field/Json.vue +63 -0
- package/layers/ui/app/components/field/Layout.vue +140 -0
- package/layers/ui/app/components/field/Link.vue +112 -0
- package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
- package/layers/ui/app/components/field/Number.vue +30 -0
- package/layers/ui/app/components/field/Relation.vue +49 -0
- package/layers/ui/app/components/field/Renderer.vue +30 -0
- package/layers/ui/app/components/field/Repeater.vue +460 -0
- package/layers/ui/app/components/field/Richtext.vue +26 -0
- package/layers/ui/app/components/field/Slug.vue +66 -0
- package/layers/ui/app/components/field/Text.vue +35 -0
- package/layers/ui/app/components/field/Unsupported.vue +34 -0
- package/layers/ui/app/components/ui/Alert.vue +50 -0
- package/layers/ui/app/components/ui/Brand.vue +22 -0
- package/layers/ui/app/components/ui/Button.vue +116 -0
- package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
- package/layers/ui/app/components/ui/Checkbox.vue +52 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
- package/layers/ui/app/components/ui/Combobox.vue +343 -0
- package/layers/ui/app/components/ui/DatePicker.vue +92 -0
- package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
- package/layers/ui/app/components/ui/Dialog.vue +107 -0
- package/layers/ui/app/components/ui/EmptyState.vue +46 -0
- package/layers/ui/app/components/ui/Field.vue +57 -0
- package/layers/ui/app/components/ui/Fieldset.vue +64 -0
- package/layers/ui/app/components/ui/Icon.vue +44 -0
- package/layers/ui/app/components/ui/Menu.vue +54 -0
- package/layers/ui/app/components/ui/NumberInput.vue +173 -0
- package/layers/ui/app/components/ui/Richtext.vue +119 -0
- package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
- package/layers/ui/app/components/ui/Select.vue +51 -0
- package/layers/ui/app/components/ui/TextInput.vue +110 -0
- package/layers/ui/app/components/ui/Textarea.vue +38 -0
- package/layers/ui/app/components/ui/TimeInput.vue +17 -0
- package/layers/ui/app/components/ui/Toasts.vue +103 -0
- package/layers/ui/app/components/ui/Tooltip.vue +45 -0
- package/layers/ui/app/composables/useAdminLang.ts +8 -0
- package/layers/ui/app/composables/useDragReorder.ts +41 -0
- package/layers/ui/app/composables/useEchoGuard.ts +28 -0
- package/layers/ui/app/composables/useLinkField.ts +70 -0
- package/layers/ui/app/composables/useRecordOptions.ts +84 -0
- package/layers/ui/app/composables/useRepeater.ts +99 -0
- package/layers/ui/app/composables/useT.ts +32 -0
- package/layers/ui/app/composables/useToast.ts +46 -0
- package/layers/ui/app/i18n/de.ts +406 -0
- package/layers/ui/app/i18n/en.ts +440 -0
- package/layers/ui/app/utils/boolean-field.ts +10 -0
- package/layers/ui/app/utils/field-component.ts +22 -0
- package/layers/ui/app/utils/field-empty.ts +21 -0
- package/layers/ui/app/utils/field-registry.ts +36 -0
- package/layers/ui/app/utils/humanize.ts +14 -0
- package/layers/ui/app/utils/icons.ts +147 -0
- package/layers/ui/app/utils/iso-date.ts +18 -0
- package/layers/ui/app/utils/localized.ts +13 -0
- package/layers/ui/app/utils/record-context.ts +15 -0
- package/layers/ui/app/utils/reorder.ts +8 -0
- package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
- package/layers/ui/app/utils/useFieldA11y.ts +40 -0
- package/layers/ui/nuxt.config.ts +5 -0
- package/nuxt.config.ts +105 -0
- package/package.json +122 -0
- package/scripts/hash-password.mjs +26 -0
- 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
|
+
}
|