@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,248 @@
|
|
|
1
|
+
import { integer, real, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import type { SQLiteColumnBuilder } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import { sql } from 'drizzle-orm'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import type { FieldDef, FieldType, LinkValue } from '../../../core/server/utils/defineCollection'
|
|
6
|
+
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
7
|
+
import type { FieldTypeDescriptor } from './types'
|
|
8
|
+
import { sanitizeRichtext } from './sanitize'
|
|
9
|
+
import { choiceValues, numberIsInteger } from '../../app/utils/field-constraints'
|
|
10
|
+
import { slugify } from '../../../core/app/utils/slugify'
|
|
11
|
+
import { refineConditionalRequired } from '../utils/conditional-required'
|
|
12
|
+
|
|
13
|
+
// A conditional field is only `required` when its condition is met — which the per-column / per-field
|
|
14
|
+
// schema can't see (no sibling access). So it is never enforced HERE: the column stays nullable and the
|
|
15
|
+
// validator stays optional; the whole-record `applyConditions` hook re-enforces required-when-visible.
|
|
16
|
+
export function isHardRequired(field: FieldDef): boolean {
|
|
17
|
+
return !!field.required && !field.condition
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function constrain<T extends SQLiteColumnBuilder>(col: T, field: FieldDef): T {
|
|
21
|
+
let c = col
|
|
22
|
+
if (isHardRequired(field)) c = c.notNull() as T
|
|
23
|
+
if (field.unique) c = c.unique() as T
|
|
24
|
+
if (field.default !== undefined) c = c.default(field.default as never) as T
|
|
25
|
+
return c
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const opt = (s: z.ZodType, field: FieldDef): z.ZodType => (isHardRequired(field) ? s : s.nullish())
|
|
29
|
+
export const optArr = (s: z.ZodType, field?: FieldDef): z.ZodType =>
|
|
30
|
+
field && isHardRequired(field)
|
|
31
|
+
? s.refine((v) => Array.isArray(v) && v.length > 0, { message: 'At least one value is required' })
|
|
32
|
+
// Non-required: an EXPLICIT null is a clear → the empty array [] (so a PATCH `field: null` actually
|
|
33
|
+
// resets it, consistent with the .nullish() scalar fields, rather than mapping to undefined which
|
|
34
|
+
// drizzle's .set() silently skips). An absent (undefined) value stays undefined so the column default
|
|
35
|
+
// ([]) applies on insert.
|
|
36
|
+
: z.preprocess((v) => (v === null ? [] : v), s.optional())
|
|
37
|
+
|
|
38
|
+
// Array-backed column (multiple choice/media, many-relation, repeater). Honours a declared `default` (a
|
|
39
|
+
// default multi-selection / seed rows), serialized as a JSON literal by the schema renderer; else '[]'.
|
|
40
|
+
// (`unique` is intentionally not applied — a UNIQUE index over a JSON blob is not meaningful.)
|
|
41
|
+
const jsonArray = (dbName: string, field?: FieldDef) =>
|
|
42
|
+
text(dbName, { mode: 'json' }).$type<unknown[]>().notNull()
|
|
43
|
+
.default(field?.default !== undefined ? (field.default as never) : sql`'[]'`)
|
|
44
|
+
|
|
45
|
+
/** Type a built-in descriptor against its SPECIFIC arm (`FieldOf<T>`) so its body narrows correctly, then
|
|
46
|
+
* widen to the registry's general `FieldTypeDescriptor` for storage. */
|
|
47
|
+
const fieldType = <T extends FieldType>(d: FieldTypeDescriptor<T>): FieldTypeDescriptor =>
|
|
48
|
+
d as unknown as FieldTypeDescriptor
|
|
49
|
+
|
|
50
|
+
/** Whether `field.unique` actually reaches a DB constraint for THIS field (options-dependent, not just
|
|
51
|
+
* type-dependent — a `choice`/`media` field is only json-backed when `multiple`, a `relation` only when
|
|
52
|
+
* `many`). Mirrors the `column()` arms above exactly: the json/array-backed arms (`jsonArray`, the plain
|
|
53
|
+
* `json` type) never call `constrain()`, so `.unique()` is never invoked — `unique: true` there is a silent
|
|
54
|
+
* no-op, not a soft hint. `buildTable` uses this to fail loud at collection-build time instead. */
|
|
55
|
+
export function fieldCanEnforceUnique(field: FieldDef): boolean {
|
|
56
|
+
// `fieldIs` not `switch`: the open consumer arm makes `type` a non-discriminant (no switch narrowing).
|
|
57
|
+
if (fieldIs(field, 'choice')) return !field.options.multiple
|
|
58
|
+
if (fieldIs(field, 'media')) return !field.options?.multiple
|
|
59
|
+
if (fieldIs(field, 'relation')) return !field.relation.many
|
|
60
|
+
if (fieldIs(field, 'repeater')) return false
|
|
61
|
+
if (fieldIs(field, 'json')) return false
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const fieldTypes: Record<string, FieldTypeDescriptor> = {
|
|
66
|
+
text: fieldType<'text'>({
|
|
67
|
+
column: (n, f) => constrain(text(n), f),
|
|
68
|
+
// Plain text is stored verbatim (trimmed): the column is not HTML and every consumer
|
|
69
|
+
// escapes on output, so tag-stripping here would only corrupt legitimate input (e.g. "a < b").
|
|
70
|
+
validator: (f) => {
|
|
71
|
+
let s = z.string().trim()
|
|
72
|
+
if (f.type === 'text') {
|
|
73
|
+
if (f.options?.minLength !== undefined) s = s.min(f.options.minLength)
|
|
74
|
+
if (f.options?.maxLength !== undefined) s = s.max(f.options.maxLength)
|
|
75
|
+
}
|
|
76
|
+
// A hard-required string must be non-empty — '' / whitespace is "missing" to the client validator
|
|
77
|
+
// and the conditional-required check, so the server (sole authority) must agree. Only add the
|
|
78
|
+
// implicit min(1) when no explicit minLength already enforces a lower bound.
|
|
79
|
+
if (isHardRequired(f) && (f.type !== 'text' || f.options?.minLength === undefined)) s = s.min(1)
|
|
80
|
+
return opt(s, f)
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
// A url-safe slug, stored as text. Optionality-aware like text, but typically left non-required so it can
|
|
84
|
+
// be omitted and auto-generated from another field. `transform` derives it on write: an explicit value is
|
|
85
|
+
// slugified (normalised); a blank value falls back to slugify(record[options.from]) (e.g. the title). The
|
|
86
|
+
// `options.from`/`options.prefix` are read by the client widget too (prefix is display-only). A `unique`
|
|
87
|
+
// slug collision is a HARD, field-scoped error (`assertUniqueSlugs` in crud — no silent dedup; the editor
|
|
88
|
+
// must pick a different slug), needing the DB the pure `transform` hook can't touch, so it lives in crud.
|
|
89
|
+
slug: fieldType<'slug'>({
|
|
90
|
+
column: (n, f) => constrain(text(n), f),
|
|
91
|
+
validator: (f) => opt(isHardRequired(f) ? z.string().trim().min(1) : z.string().trim(), f),
|
|
92
|
+
transform: (value, record, field) => {
|
|
93
|
+
const explicit = typeof value === 'string' ? value.trim() : ''
|
|
94
|
+
if (explicit) return slugify(explicit)
|
|
95
|
+
const from = field.options?.from
|
|
96
|
+
const source = from ? record[from] : undefined
|
|
97
|
+
return typeof source === 'string' ? (slugify(source) || value) : value
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
richtext: {
|
|
101
|
+
column: (n, f) => constrain(text(n), f),
|
|
102
|
+
// Required richtext must carry content: sanitize first, then reject a sanitized-empty result
|
|
103
|
+
// (e.g. '' or markup that strips to nothing), mirroring the non-empty rule for plain text.
|
|
104
|
+
validator: (f) => {
|
|
105
|
+
const base = z.string().transform(sanitizeRichtext)
|
|
106
|
+
if (!isHardRequired(f)) return base.nullish()
|
|
107
|
+
return base.refine((v) => v.trim().length > 0, { message: 'This field is required' })
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
number: fieldType<'number'>({
|
|
111
|
+
column: (n, f) =>
|
|
112
|
+
constrain(!numberIsInteger(f.options) ? real(n) : integer(n), f),
|
|
113
|
+
validator: (f) => {
|
|
114
|
+
let n = z.number()
|
|
115
|
+
if (numberIsInteger(f.options)) n = n.int()
|
|
116
|
+
if (f.options?.min !== undefined) n = n.min(f.options.min)
|
|
117
|
+
if (f.options?.max !== undefined) n = n.max(f.options.max)
|
|
118
|
+
return opt(n, f)
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
boolean: {
|
|
122
|
+
column: (n, f) => constrain(integer(n, { mode: 'boolean' }), f),
|
|
123
|
+
validator: (f) => opt(z.boolean(), f),
|
|
124
|
+
},
|
|
125
|
+
datetime: fieldType<'datetime'>({
|
|
126
|
+
column: (n, f) =>
|
|
127
|
+
f.options?.range
|
|
128
|
+
? constrain(text(n, { mode: 'json' }).$type<{ start: string; end: string }>(), f)
|
|
129
|
+
: constrain(text(n), f),
|
|
130
|
+
validator: (f) => {
|
|
131
|
+
const p = f.options?.precision ?? 'datetime'
|
|
132
|
+
const base = p === 'date' ? z.iso.date() : p === 'time' ? z.iso.time() : z.iso.datetime({ local: true })
|
|
133
|
+
if (f.options?.range) {
|
|
134
|
+
const r = z.object({ start: base, end: base }).refine((v) => v.start <= v.end, { message: 'start must be before or equal to end' })
|
|
135
|
+
return isHardRequired(f) ? r : r.nullish()
|
|
136
|
+
}
|
|
137
|
+
return opt(base, f)
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
choice: fieldType<'choice'>({
|
|
141
|
+
column: (n, f) => (f.options.multiple ? jsonArray(n, f) : constrain(text(n), f)),
|
|
142
|
+
validator: (f) => {
|
|
143
|
+
const values = choiceValues(f.options)
|
|
144
|
+
const e = z.enum(values)
|
|
145
|
+
return f.options.multiple ? optArr(z.array(e), f) : opt(e, f)
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
link: {
|
|
149
|
+
column: (n, f) => constrain(text(n, { mode: 'json' }).$type<LinkValue>(), f),
|
|
150
|
+
// The validator is type-agnostic; options.types/collections only constrain the editor widget.
|
|
151
|
+
validator: (f) => {
|
|
152
|
+
const label = z.string().trim().optional()
|
|
153
|
+
// Optional URL fragment for internal links (e.g. link to a page's section). Stored WITHOUT the
|
|
154
|
+
// leading '#' (a lenient leading '#' is stripped); restricted to an id-safe charset since it ends
|
|
155
|
+
// up verbatim in a static <a href>. `linkToHref` appends it as `<path>#<hash>` at render.
|
|
156
|
+
const hash = z.string().trim().transform((s) => s.replace(/^#+/, '')).pipe(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/, 'Invalid anchor')).optional()
|
|
157
|
+
return opt(
|
|
158
|
+
z.discriminatedUnion('type', [
|
|
159
|
+
z.object({ type: z.literal('internal'), collection: z.string().min(1), id: z.number().int(), hash, label }),
|
|
160
|
+
z.object({
|
|
161
|
+
type: z.literal('external'),
|
|
162
|
+
// http(s) only, no control chars, no embedded credentials — the value ends up in a static <a href>.
|
|
163
|
+
url: z.string().trim().pipe(z.url({ protocol: /^https?$/ })).refine((v) => {
|
|
164
|
+
if (/[\u0000-\u001f]/.test(v)) return false
|
|
165
|
+
const u = new URL(v)
|
|
166
|
+
return !u.username && !u.password
|
|
167
|
+
}, 'URL must not contain control characters or embedded credentials'),
|
|
168
|
+
label,
|
|
169
|
+
}),
|
|
170
|
+
z.object({ type: z.literal('email'), email: z.string().trim().pipe(z.email()), label }),
|
|
171
|
+
z.object({ type: z.literal('tel'), tel: z.string().trim().min(1).regex(/^[+0-9 ()\-.\/]+$/).refine((v) => /[0-9]/.test(v), 'Tel must contain at least one digit'), label }),
|
|
172
|
+
]),
|
|
173
|
+
f,
|
|
174
|
+
)
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
media: fieldType<'media'>({
|
|
178
|
+
column: (n, f) => (f.options?.multiple ? jsonArray(n, f) : constrain(integer(n), f)),
|
|
179
|
+
validator: (f) =>
|
|
180
|
+
f.options?.multiple ? optArr(z.array(z.number().int()), f) : opt(z.number().int(), f),
|
|
181
|
+
}),
|
|
182
|
+
relation: fieldType<'relation'>({
|
|
183
|
+
column: (n, f) => (f.relation.many ? jsonArray(n, f) : constrain(integer(n), f)),
|
|
184
|
+
validator: (f) =>
|
|
185
|
+
f.relation.many ? optArr(z.array(z.number().int()), f) : opt(z.number().int(), f),
|
|
186
|
+
}),
|
|
187
|
+
repeater: fieldType<'repeater'>({
|
|
188
|
+
column: (n, f) => jsonArray(n, f),
|
|
189
|
+
validator: (f) => {
|
|
190
|
+
const shape = Object.fromEntries(
|
|
191
|
+
Object.entries(f.options.fields).map(([k, sub]) => [k, getFieldType(sub.type).validator(sub)]),
|
|
192
|
+
)
|
|
193
|
+
// Re-enforce conditional-required sub-fields per entry (the entry object is where siblings are visible).
|
|
194
|
+
return optArr(z.array(refineConditionalRequired(z.object(shape), f.options.fields)), f)
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
json: {
|
|
198
|
+
column: (n, f) => text(n, { mode: 'json' }).$type<unknown>().notNull().default(f?.default !== undefined ? (f.default as never) : sql`'{}'`),
|
|
199
|
+
// Optionality-aware like every other arm. The column is NOT NULL with a '{}' default, so a non-required
|
|
200
|
+
// null/undefined must become `undefined` (→ default applies) rather than reaching the column as null (an
|
|
201
|
+
// unmapped NOT NULL 500); a required json must be present (a clean 400, not a 500).
|
|
202
|
+
validator: (f) =>
|
|
203
|
+
isHardRequired(f)
|
|
204
|
+
? z.unknown().refine((v) => v !== null && v !== undefined, { message: 'This field is required' })
|
|
205
|
+
// Explicit null clears to {} (a PATCH reset); undefined stays absent so the column default ({}) applies.
|
|
206
|
+
: z.preprocess((v) => (v === null ? {} : v), z.unknown().optional()),
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The names Kestrel ships built-in (captured before any consumer registration). */
|
|
211
|
+
const BUILTIN_FIELD_TYPES = new Set(Object.keys(fieldTypes))
|
|
212
|
+
|
|
213
|
+
/** Register a consumer-defined field type (via `defineFieldType`). Later registration wins, but never
|
|
214
|
+
* silently: a bad descriptor throws a clear error at registration (not a cryptic TypeError when a table is
|
|
215
|
+
* later built), and ANY re-registration of an existing name warns — overriding a built-in, or one extension
|
|
216
|
+
* clobbering another's same-named type. */
|
|
217
|
+
export function registerFieldType(name: string, descriptor: FieldTypeDescriptor): void {
|
|
218
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
219
|
+
throw new Error('[kestrel] registerFieldType: name must be a non-empty string')
|
|
220
|
+
}
|
|
221
|
+
if (typeof descriptor?.column !== 'function') {
|
|
222
|
+
throw new Error(`[kestrel] registerFieldType("${name}"): descriptor.column must be a function`)
|
|
223
|
+
}
|
|
224
|
+
if (typeof descriptor?.validator !== 'function') {
|
|
225
|
+
throw new Error(`[kestrel] registerFieldType("${name}"): descriptor.validator must be a function`)
|
|
226
|
+
}
|
|
227
|
+
if (descriptor.transform !== undefined && typeof descriptor.transform !== 'function') {
|
|
228
|
+
throw new Error(`[kestrel] registerFieldType("${name}"): descriptor.transform must be a function if provided`)
|
|
229
|
+
}
|
|
230
|
+
// Object.hasOwn, not `in`: a field type named 'constructor'/'toString'/… would otherwise match an
|
|
231
|
+
// inherited Object.prototype member (a spurious "re-registered" warning, and below a truthy lookup).
|
|
232
|
+
if (Object.hasOwn(fieldTypes, name)) {
|
|
233
|
+
console.warn(BUILTIN_FIELD_TYPES.has(name)
|
|
234
|
+
? `[kestrel] field type "${name}" overrides a built-in`
|
|
235
|
+
: `[kestrel] field type "${name}" re-registered — the previous definition was overwritten`)
|
|
236
|
+
}
|
|
237
|
+
fieldTypes[name] = descriptor
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Look up a field-type descriptor, with a clear error for an unknown / unregistered type. */
|
|
241
|
+
export function getFieldType(type: string): FieldTypeDescriptor {
|
|
242
|
+
// Object.hasOwn guards the prototype chain: getFieldType('constructor') must throw the clear error, not
|
|
243
|
+
// return Object.prototype.constructor and then crash with `.column is not a function` at table build.
|
|
244
|
+
if (!Object.hasOwn(fieldTypes, type)) {
|
|
245
|
+
throw new Error(`[kestrel] unknown field type "${type}" — register it with defineFieldType in server/field-types/`)
|
|
246
|
+
}
|
|
247
|
+
return fieldTypes[type]
|
|
248
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isSingleRefColumn, type FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
|
|
3
|
+
export function toSnakeCase(key: string): string {
|
|
4
|
+
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveColumnName(key: string, field: FieldDef): { jsKey: string; dbName: string } {
|
|
8
|
+
if (isSingleRefColumn(field)) return { jsKey: `${key}Id`, dbName: `${toSnakeCase(key)}_id` }
|
|
9
|
+
return { jsKey: key, dbName: toSnakeCase(key) }
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import sanitizeHtml from 'sanitize-html'
|
|
2
|
+
import { RICHTEXT_LINK_SCHEME } from '../../app/utils/richtext-links'
|
|
3
|
+
|
|
4
|
+
export const RICHTEXT_ALLOWLIST: sanitizeHtml.IOptions = {
|
|
5
|
+
allowedTags: [
|
|
6
|
+
'p', 'br', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'sub', 'sup', 'mark',
|
|
7
|
+
'blockquote', 'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
8
|
+
'ul', 'ol', 'li', 'a', 'img', 'figure', 'figcaption', 'hr',
|
|
9
|
+
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
|
10
|
+
],
|
|
11
|
+
allowedAttributes: {
|
|
12
|
+
a: ['href', 'title', 'target', 'rel'],
|
|
13
|
+
img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
|
|
14
|
+
'*': ['class', 'style'],
|
|
15
|
+
},
|
|
16
|
+
allowedStyles: {
|
|
17
|
+
'*': { 'text-align': [/^(left|right|center|justify)$/] },
|
|
18
|
+
},
|
|
19
|
+
// `kestrel:` is the internal-link marker (`kestrel:<collection>:<id>`), resolved to a real localized
|
|
20
|
+
// path at read time; it must survive sanitisation so the resolver can rewrite it.
|
|
21
|
+
allowedSchemes: ['http', 'https', 'mailto', 'tel', RICHTEXT_LINK_SCHEME],
|
|
22
|
+
allowedSchemesAppliedToAttributes: ['href', 'src'],
|
|
23
|
+
disallowedTagsMode: 'discard',
|
|
24
|
+
transformTags: {
|
|
25
|
+
// Only absolute http(s) (incl. protocol-relative) links are "external" → open in a new isolated
|
|
26
|
+
// tab + nofollow. Internal/relative/mailto/tel/kestrel: links keep no target/rel (a same-site link
|
|
27
|
+
// must not open a new tab or be nofollowed); any author-supplied target/rel on them is dropped.
|
|
28
|
+
a: (tagName, attribs) => {
|
|
29
|
+
if (/^(https?:)?\/\//i.test(attribs.href ?? '')) {
|
|
30
|
+
return { tagName, attribs: { ...attribs, target: '_blank', rel: 'noopener noreferrer nofollow' } }
|
|
31
|
+
}
|
|
32
|
+
const { target: _t, rel: _r, ...rest } = attribs
|
|
33
|
+
return { tagName, attribs: rest }
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sanitizeRichtext(html: string): string {
|
|
39
|
+
return sanitizeHtml(html, RICHTEXT_ALLOWLIST)
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SQLiteColumnBuilderBase } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import type { ZodType } from 'zod'
|
|
3
|
+
import type { FieldOf, FieldType } from '../../../core/server/utils/defineCollection'
|
|
4
|
+
|
|
5
|
+
/** Descriptor for a field `type`. Generic over the type name so a built-in descriptor's body receives its
|
|
6
|
+
* SPECIFIC `FieldDef` arm (`FieldOf<'text'>` etc.) — the registry can't discriminate the union itself
|
|
7
|
+
* (the open consumer arm makes `type` a non-discriminant). The default `T = FieldType` widens `field` back
|
|
8
|
+
* to the full union for the registry's storage type + consumer-registered descriptors. */
|
|
9
|
+
export interface FieldTypeDescriptor<T extends FieldType = FieldType> {
|
|
10
|
+
/** The ONLY place a Drizzle column type is decided. Pure; no Nitro/DB. */
|
|
11
|
+
column(dbName: string, field: FieldOf<T>): SQLiteColumnBuilderBase
|
|
12
|
+
/** Complete, optionality-aware Zod for the field value (drop-in replacement schema). */
|
|
13
|
+
validator(field: FieldOf<T>): ZodType
|
|
14
|
+
/** Optional write-time derivation: given this field's (validated) value, the whole record, and the field
|
|
15
|
+
* def, return the value to persist — e.g. a slug auto-generated from another field when left blank. Runs
|
|
16
|
+
* on create/update AFTER validation, BEFORE insert. Pure (no DB/Nitro); cross-field reads come from
|
|
17
|
+
* `record`. */
|
|
18
|
+
transform?(value: unknown, record: Record<string, unknown>, field: FieldOf<T>): unknown
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { registerPopulator } from '../../../core/server/utils/populate'
|
|
2
|
+
import { buildFieldTreePopulator } from '../utils/field-populate'
|
|
3
|
+
|
|
4
|
+
// The single global row populator: walks a record's fields + block tree and dispatches each field to its
|
|
5
|
+
// per-type populator (media / link / richtext / relation), each registered by its owning layer. It recurses
|
|
6
|
+
// repeater entries, block props, and slots, so nested references resolve everywhere — not just at the top
|
|
7
|
+
// level. Field populators are looked up at read time, so this plugin's order vs the layer plugins that
|
|
8
|
+
// register them does not matter.
|
|
9
|
+
export default defineNitroPlugin(() => {
|
|
10
|
+
registerPopulator(buildFieldTreePopulator())
|
|
11
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Server-side twin of the admin's `cloneBlockTree` (layers/admin/app/utils/block-tree.ts): re-mint every
|
|
4
|
+
// block id at every slot depth, preserving type / props / structure. This is a DELIBERATE crossing-free
|
|
5
|
+
// duplication, NOT a reshape: `cloneBlockTree` lives in the ADMIN (client) layer and is unreachable from a
|
|
6
|
+
// server layer, so the record-duplicate operation gets its own pure, node-tested helper here in `fields`
|
|
7
|
+
// (the block-content-shape home) rather than inverting the layer dependency. No field-def dependency — a
|
|
8
|
+
// plain traversal of the `{ id, type, props?, slots? }` node shape — so it unit-tests hard.
|
|
9
|
+
|
|
10
|
+
type GenId = () => string
|
|
11
|
+
|
|
12
|
+
interface BlockNode {
|
|
13
|
+
id: string
|
|
14
|
+
type: string
|
|
15
|
+
props?: Record<string, unknown>
|
|
16
|
+
slots?: Record<string, unknown>
|
|
17
|
+
[key: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Return a copy of a block `content` array with a FRESH id on every node at every slot depth, so a
|
|
22
|
+
* duplicated record never shares block identity with its source (the id-addressed tree, error addressing
|
|
23
|
+
* and per-record edit state all rely on ids being unique per record). Props and slot structure are deep-
|
|
24
|
+
* cloned and preserved verbatim. A non-array `content` (or a non-object node) passes through untouched.
|
|
25
|
+
* `genId` is injectable for deterministic tests; it defaults to `crypto.randomUUID`.
|
|
26
|
+
*/
|
|
27
|
+
export function regenerateBlockIds(content: unknown, genId: GenId = randomUUID): unknown {
|
|
28
|
+
if (!Array.isArray(content)) return content
|
|
29
|
+
return content.map((node) => cloneNode(node, genId))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cloneNode(node: unknown, genId: GenId): unknown {
|
|
33
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return node
|
|
34
|
+
const n = node as BlockNode
|
|
35
|
+
const copy: BlockNode = { ...n, id: genId() }
|
|
36
|
+
if (n.props && typeof n.props === 'object') copy.props = structuredClone(n.props)
|
|
37
|
+
if (n.slots && typeof n.slots === 'object' && !Array.isArray(n.slots)) {
|
|
38
|
+
copy.slots = Object.fromEntries(
|
|
39
|
+
Object.entries(n.slots).map(([name, arr]) => [name, Array.isArray(arr) ? arr.map((c) => cloneNode(c, genId)) : arr]),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return copy
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import { getBlock } from './defineBlock'
|
|
3
|
+
|
|
4
|
+
/** A per-block-props mutation: populate/resolve one block's props in place, given its field defs. */
|
|
5
|
+
export type ApplyBlockFields = (props: Record<string, unknown>, fields: Record<string, FieldDef>) => void
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The shared block-content walker for server-side populators (media `$media`, internal-link resolution,
|
|
9
|
+
* …). Maps a `content` array, deep-cloning each block node, applying `applyFields` to a clone of its
|
|
10
|
+
* `props` (block type looked up via the registry), and recursing into every declared slot. The differing
|
|
11
|
+
* per-field work lives in `applyFields`; only this clone-and-recurse traversal is shared. Returns a fresh
|
|
12
|
+
* array (server populators hand back new rows); nodes that aren't block objects pass through untouched.
|
|
13
|
+
*/
|
|
14
|
+
export function buildBlockPopulator(applyFields: ApplyBlockFields): (nodes: unknown) => unknown[] {
|
|
15
|
+
const walk = (nodes: unknown): unknown[] => {
|
|
16
|
+
if (!Array.isArray(nodes)) return []
|
|
17
|
+
return nodes.map((node) => {
|
|
18
|
+
if (!node || typeof node !== 'object') return node
|
|
19
|
+
const original = node as { type?: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
|
|
20
|
+
const cloned: Record<string, unknown> = { ...original }
|
|
21
|
+
|
|
22
|
+
const blockDef = original.type ? getBlock(original.type) : undefined
|
|
23
|
+
if (blockDef && original.props) {
|
|
24
|
+
const props: Record<string, unknown> = { ...original.props }
|
|
25
|
+
cloned.props = props
|
|
26
|
+
applyFields(props, blockDef.fields)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (original.slots && typeof original.slots === 'object') {
|
|
30
|
+
const slots: Record<string, unknown> = {}
|
|
31
|
+
for (const [slotName, slotNodes] of Object.entries(original.slots)) {
|
|
32
|
+
slots[slotName] = walk(slotNodes)
|
|
33
|
+
}
|
|
34
|
+
cloned.slots = slots
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return cloned
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
return walk
|
|
41
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createInsertSchema, createUpdateSchema, createSelectSchema } from 'drizzle-zod'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { ZodType } from 'zod'
|
|
4
|
+
import type { CollectionDef } from '../../../core/server/utils/defineCollection'
|
|
5
|
+
import type { BuiltCollection, CollectionSchema } from '../../../core/server/utils/collection-types'
|
|
6
|
+
import { seoSchema } from '../../../core/server/utils/seo'
|
|
7
|
+
import { evaluateCondition, isEmptyValue } from '../../app/utils/condition'
|
|
8
|
+
import { buildTable } from './buildTable'
|
|
9
|
+
import { buildFieldSchema } from './buildFieldSchema'
|
|
10
|
+
import { resolveColumnName } from '../field-registry/naming'
|
|
11
|
+
import { allBlocks, buildBlocksSchema } from './defineBlock'
|
|
12
|
+
|
|
13
|
+
/** A page path is consumed verbatim at build time to derive prerender routes / output file paths,
|
|
14
|
+
* so it must be incapable of traversal: url-path-safe charset only, no `.`/`..` segments. */
|
|
15
|
+
export function isSafePagePath(p: string): boolean {
|
|
16
|
+
if (!/^[A-Za-z0-9._~%/-]*$/.test(p)) return false
|
|
17
|
+
return !p.split('/').some((seg) => seg === '.' || seg === '..')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function systemRefinements(def: CollectionDef): Record<string, unknown> {
|
|
21
|
+
const refine: Record<string, unknown> = {}
|
|
22
|
+
if (def.translatable) refine.locale = z.string().optional()
|
|
23
|
+
if (def.mode === 'single') refine.singletonKey = z.string().optional()
|
|
24
|
+
else if (def.translatable) refine.translationGroup = z.string().optional()
|
|
25
|
+
if (def.pageLike) refine.path = z.string().refine(isSafePagePath, 'Invalid path').nullable().optional()
|
|
26
|
+
// The status column is a two-state enum; without this the schema accepts any string and a typo
|
|
27
|
+
// ('publushed') stores verbatim and silently unpublishes the record (every gate matches only 'published').
|
|
28
|
+
if (def.status) refine.status = z.enum(['draft', 'published']).optional()
|
|
29
|
+
if (def.blocks?.enabled) refine.content = z.lazy(() => buildBlocksSchema(allBlocks(def.blocks?.allowed))).optional()
|
|
30
|
+
if (def.seo) refine.seo = seoSchema.partial().optional()
|
|
31
|
+
return refine
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Update is a partial PATCH: every field may be omitted, so the field refinements
|
|
35
|
+
// must be optional on update (a required validator would otherwise override
|
|
36
|
+
// drizzle-zod's partial column schema and force the field on every update).
|
|
37
|
+
function optionalize(refine: Record<string, ZodType>): Record<string, ZodType> {
|
|
38
|
+
const out: Record<string, ZodType> = {}
|
|
39
|
+
for (const [key, schema] of Object.entries(refine)) out[key] = schema.optional()
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Accept either a plain `CollectionDef` (the common consumer form: `export default defineCollection(…)`)
|
|
45
|
+
* or an already-built collection (the advanced form, when a server route needs the drizzle table object
|
|
46
|
+
* directly: `const built = buildCollection(…); export const t = built.table; export default built`).
|
|
47
|
+
* The discriminator is the presence of `table`, which only a BuiltCollection has.
|
|
48
|
+
*/
|
|
49
|
+
export function ensureBuilt(c: CollectionDef | BuiltCollection): BuiltCollection {
|
|
50
|
+
return 'table' in c ? c : buildCollection(c)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the `applyConditions` hook for a collection with conditional required fields, or `undefined`
|
|
55
|
+
* when it has none (so CRUD skips it). The per-field schema relaxed every conditional field to optional
|
|
56
|
+
* (it can't see siblings); this re-enforces `required` for the ones whose condition is met against the
|
|
57
|
+
* whole record. Issues are keyed by the field's def name (so they map to the editor's per-field errors),
|
|
58
|
+
* resolving each value at its column key (`<name>Id` for single relation/media).
|
|
59
|
+
*/
|
|
60
|
+
function buildApplyConditions(def: CollectionDef): BuiltCollection['applyConditions'] {
|
|
61
|
+
const required = Object.entries(def.fields).filter(([, f]) => f.condition && f.required)
|
|
62
|
+
if (!required.length) return undefined
|
|
63
|
+
return (record) => {
|
|
64
|
+
const scope: Record<string, unknown> = {}
|
|
65
|
+
for (const [key, field] of Object.entries(def.fields)) scope[key] = record[resolveColumnName(key, field).jsKey]
|
|
66
|
+
const issues = required
|
|
67
|
+
.filter(([key, field]) => evaluateCondition(field.condition!, scope) && isEmptyValue(record[resolveColumnName(key, field).jsKey]))
|
|
68
|
+
.map(([key]) => ({ path: [key], message: 'This field is required.' }))
|
|
69
|
+
return { issues }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildCollection(def: CollectionDef): BuiltCollection {
|
|
74
|
+
const table = buildTable(def)
|
|
75
|
+
const fieldRefine = buildFieldSchema(def.fields)
|
|
76
|
+
const system = systemRefinements(def)
|
|
77
|
+
return {
|
|
78
|
+
name: def.name,
|
|
79
|
+
def,
|
|
80
|
+
table,
|
|
81
|
+
// Cast through the loose `CollectionSchema`: drizzle-zod's overloads otherwise infer mismatched (ZodEnum)
|
|
82
|
+
// types that don't unify across the three builders (see CollectionSchema's note).
|
|
83
|
+
insert: createInsertSchema(table, { ...fieldRefine, ...system } as never) as unknown as CollectionSchema,
|
|
84
|
+
update: createUpdateSchema(table, { ...optionalize(fieldRefine), ...system } as never) as unknown as CollectionSchema,
|
|
85
|
+
select: createSelectSchema(table) as unknown as CollectionSchema,
|
|
86
|
+
applyConditions: buildApplyConditions(def),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ZodType } from 'zod'
|
|
2
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
import { getFieldType } from '../field-registry'
|
|
4
|
+
import { resolveColumnName } from '../field-registry/naming'
|
|
5
|
+
|
|
6
|
+
function buildShape(fields: Record<string, FieldDef>, keyOf: (key: string, field: FieldDef) => string): Record<string, ZodType> {
|
|
7
|
+
const out: Record<string, ZodType> = {}
|
|
8
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
9
|
+
out[keyOf(key, field)] = getFieldType(field.type).validator(field)
|
|
10
|
+
}
|
|
11
|
+
return out
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Collection columns: keyed by jsKey (matches buildTable's column keys). */
|
|
15
|
+
export function buildFieldSchema(fields: Record<string, FieldDef>): Record<string, ZodType> {
|
|
16
|
+
return buildShape(fields, (key, field) => resolveColumnName(key, field).jsKey)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Block props: keyed by the raw field name (props are not DB columns). */
|
|
20
|
+
export function buildFieldObjectSchema(fields: Record<string, FieldDef>): Record<string, ZodType> {
|
|
21
|
+
return buildShape(fields, (key) => key)
|
|
22
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { sqliteTable, integer, text, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import type { SQLiteColumnBuilderBase, SQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import { sql } from 'drizzle-orm'
|
|
4
|
+
import type { Block } from '../../../core/server/utils/blocks'
|
|
5
|
+
import type { SeoMeta } from '../../../core/server/utils/seo'
|
|
6
|
+
import type { CollectionDef } from '../../../core/server/utils/defineCollection'
|
|
7
|
+
import { getFieldType, fieldCanEnforceUnique } from '../field-registry'
|
|
8
|
+
import { resolveColumnName } from '../field-registry/naming'
|
|
9
|
+
|
|
10
|
+
/** The system columns this def actually emits (jsKey + DB-name forms), gated by the SAME flags that add
|
|
11
|
+
* them in `buildTable` — so a field may only use e.g. `content`/`status` on a collection that doesn't
|
|
12
|
+
* enable blocks/status. A field resolving to one of these would silently overwrite a system column. */
|
|
13
|
+
function reservedColumns(def: CollectionDef): { js: Set<string>; db: Set<string> } {
|
|
14
|
+
const js = new Set(['id', 'createdAt', 'updatedAt'])
|
|
15
|
+
const db = new Set(['id', 'created_at', 'updated_at'])
|
|
16
|
+
const add = (j: string, d: string) => { js.add(j); db.add(d) }
|
|
17
|
+
if (def.translatable) add('locale', 'locale')
|
|
18
|
+
if (def.mode === 'single') add('singletonKey', 'singleton_key')
|
|
19
|
+
else if (def.translatable) add('translationGroup', 'translation_group')
|
|
20
|
+
if (def.pageLike) add('path', 'path')
|
|
21
|
+
if (def.status) add('status', 'status')
|
|
22
|
+
if (def.seo) add('seo', 'seo')
|
|
23
|
+
if (def.blocks?.enabled) add('content', 'content')
|
|
24
|
+
return { js, db }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildIndexes(def: CollectionDef, t: Record<string, never>) {
|
|
28
|
+
const out = []
|
|
29
|
+
const names = new Set<string>()
|
|
30
|
+
// System indexes are named first, so a colliding field index (below) is the one that fails loud —
|
|
31
|
+
// the fixed system naming scheme can't be the thing that has to change.
|
|
32
|
+
const system = (name: string) => { names.add(name); return name }
|
|
33
|
+
if (def.mode === 'single') {
|
|
34
|
+
out.push(def.translatable
|
|
35
|
+
? uniqueIndex(system(`${def.name}_key_locale`)).on(t.singletonKey, t.locale)
|
|
36
|
+
: uniqueIndex(system(`${def.name}_key`)).on(t.singletonKey))
|
|
37
|
+
} else if (def.translatable) {
|
|
38
|
+
out.push(uniqueIndex(system(`${def.name}_group_locale`)).on(t.translationGroup, t.locale))
|
|
39
|
+
out.push(index(system(`${def.name}_group`)).on(t.translationGroup))
|
|
40
|
+
}
|
|
41
|
+
if (def.pageLike) {
|
|
42
|
+
out.push(def.translatable
|
|
43
|
+
? uniqueIndex(system(`${def.name}_path_locale`)).on(t.path, t.locale).where(sql`path is not null`)
|
|
44
|
+
: uniqueIndex(system(`${def.name}_path`)).on(t.path).where(sql`path is not null`))
|
|
45
|
+
}
|
|
46
|
+
// Opt-in non-unique field indexes (`index: true`) — for columns that are frequently filtered/sorted but
|
|
47
|
+
// not unique (e.g. media.folder). `unique` fields already get an index from the field-type's `.unique()`.
|
|
48
|
+
// A name that collides with a system index (e.g. a field named "key" on a singleton) would otherwise mint
|
|
49
|
+
// TWO indexes of the same name — drizzle-kit's `syncSchema` then throws at DEPLOY time, not authoring
|
|
50
|
+
// time. Fail loud here instead, same style as the reserved-column / duplicate-column guards above.
|
|
51
|
+
for (const [key, field] of Object.entries(def.fields)) {
|
|
52
|
+
if (!field.index || field.unique) continue
|
|
53
|
+
const { jsKey, dbName } = resolveColumnName(key, field)
|
|
54
|
+
const name = `${def.name}_${dbName}`
|
|
55
|
+
if (names.has(name)) {
|
|
56
|
+
throw new Error(`kestrel: collection "${def.name}" field "${key}" produces the index name "${name}", which collides with a system index — rename the field.`)
|
|
57
|
+
}
|
|
58
|
+
names.add(name)
|
|
59
|
+
out.push(index(name).on(t[jsKey as keyof typeof t]))
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildTable(def: CollectionDef): SQLiteTable {
|
|
65
|
+
const cols: Record<string, SQLiteColumnBuilderBase> = {}
|
|
66
|
+
cols.id = integer('id').primaryKey({ autoIncrement: true })
|
|
67
|
+
if (def.translatable) cols.locale = text('locale').notNull()
|
|
68
|
+
if (def.mode === 'single') cols.singletonKey = text('singleton_key').notNull()
|
|
69
|
+
else if (def.translatable) cols.translationGroup = text('translation_group').notNull()
|
|
70
|
+
if (def.pageLike) cols.path = text('path')
|
|
71
|
+
if (def.status) cols.status = text('status').notNull().default('draft')
|
|
72
|
+
if (def.seo) cols.seo = text('seo', { mode: 'json' }).$type<SeoMeta>().notNull().default(sql`'{}'`)
|
|
73
|
+
if (def.blocks?.enabled) cols.content = text('content', { mode: 'json' }).$type<Block[]>().notNull().default(sql`'[]'`)
|
|
74
|
+
|
|
75
|
+
const reserved = reservedColumns(def)
|
|
76
|
+
const seenJsKeys = new Set<string>()
|
|
77
|
+
const seenDbNames = new Set<string>()
|
|
78
|
+
for (const [key, field] of Object.entries(def.fields)) {
|
|
79
|
+
const { jsKey, dbName } = resolveColumnName(key, field)
|
|
80
|
+
// Fail loud (matching defineCollection's pageLike guard) rather than silently overwriting a system
|
|
81
|
+
// column — e.g. a field named `id`/`status` would clobber the PK / publish-gating column — or
|
|
82
|
+
// shadowing a sibling whose resolved key collides (a single-ref `foo` → `fooId` vs a literal `fooId`).
|
|
83
|
+
if (reserved.js.has(jsKey) || reserved.db.has(dbName)) {
|
|
84
|
+
throw new Error(`kestrel: collection "${def.name}" field "${key}" resolves to the reserved system column "${jsKey}" — rename the field.`)
|
|
85
|
+
}
|
|
86
|
+
if (seenJsKeys.has(jsKey) || seenDbNames.has(dbName)) {
|
|
87
|
+
throw new Error(`kestrel: collection "${def.name}" field "${key}" resolves to column "${jsKey}"/"${dbName}", which collides with another field — rename one of them.`)
|
|
88
|
+
}
|
|
89
|
+
// `unique` on a json/array-backed column (multi-choice, multi-media, many-relation, repeater, json) is
|
|
90
|
+
// a silent no-op — the column type never carries a DB constraint for it. Warn rather than throw: the
|
|
91
|
+
// flag is inert, so a definition that boots today must keep booting.
|
|
92
|
+
if (field.unique && !fieldCanEnforceUnique(field)) {
|
|
93
|
+
console.warn(`kestrel: collection "${def.name}" field "${key}" is "unique", but its storage (${field.type}, multi-valued) can never enforce a DB uniqueness constraint — the flag is ignored.`)
|
|
94
|
+
}
|
|
95
|
+
seenJsKeys.add(jsKey)
|
|
96
|
+
seenDbNames.add(dbName)
|
|
97
|
+
cols[jsKey] = getFieldType(field.type).column(dbName, field)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `timestamp_ms` (not `timestamp`) keeps MILLISECOND precision: the optimistic-concurrency check compares
|
|
101
|
+
// `updatedAt` exactly, and second-granularity would let two saves in the same wall-clock second share a
|
|
102
|
+
// token — a stale tab could then silently revert a newer save. Same INTEGER column type, so no DDL change.
|
|
103
|
+
cols.createdAt = integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date())
|
|
104
|
+
cols.updatedAt = integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date())
|
|
105
|
+
|
|
106
|
+
return sqliteTable(def.name, cols, (t) => buildIndexes(def, t as unknown as Record<string, never>))
|
|
107
|
+
}
|