@michaelthielemann/kestrel 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +6 -8
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { MAX_BULK_IDS } from '../../../app/utils/list-limits'
|
|
2
|
-
|
|
3
|
-
export default defineEventHandler((event) => {
|
|
4
|
-
const collection = requireCollection(event)
|
|
5
|
-
const query = getQuery(event)
|
|
6
|
-
const db = useDb()
|
|
7
|
-
|
|
8
|
-
return pickerOptions(db, collection, {
|
|
9
|
-
search: query.search ? String(query.search) : undefined,
|
|
10
|
-
ids: query.ids ? parseIdList(query.ids, MAX_BULK_IDS) : undefined,
|
|
11
|
-
label: query.label ? String(query.label) : undefined,
|
|
12
|
-
page: query.page ? Number(query.page) : undefined,
|
|
13
|
-
perPage: query.perPage ? Number(query.perPage) : undefined,
|
|
14
|
-
locale: query.locale ? String(query.locale) : undefined,
|
|
15
|
-
// Match list()/getOne(): a published-scope read (anonymous / SSG) must not surface draft labels.
|
|
16
|
-
publishedOnly: publishedOnlyForScope(event.context.readScope),
|
|
17
|
-
})
|
|
18
|
-
})
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { createError } from 'h3'
|
|
3
|
-
import Database from 'better-sqlite3'
|
|
4
|
-
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
5
|
-
import { buildCollection } from '../../../../fields/server/utils/buildCollection'
|
|
6
|
-
import { defineCollection } from '../../utils/defineCollection'
|
|
7
|
-
import { create, getOne, getSingleton, list, parseFilter, putSingleton } from '../../utils/crud'
|
|
8
|
-
import { requireCollection, requireId } from '../../utils/http'
|
|
9
|
-
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
-
import { clearPopulator, registerPopulator, type PopulateCtx } from '../../utils/populate'
|
|
11
|
-
import { desiredSchema } from '../../schema/desired'
|
|
12
|
-
import { diffSchema } from '../../schema/diff'
|
|
13
|
-
import { renderSqlite } from '../../schema/render-sqlite'
|
|
14
|
-
|
|
15
|
-
const posts = buildCollection(defineCollection({
|
|
16
|
-
name: 'posts', mode: 'multi', translatable: false,
|
|
17
|
-
fields: { title: { type: 'text', required: true } },
|
|
18
|
-
}))
|
|
19
|
-
const settings = buildCollection(defineCollection({
|
|
20
|
-
name: 'settings', mode: 'single', translatable: false,
|
|
21
|
-
fields: { siteName: { type: 'text' } },
|
|
22
|
-
}))
|
|
23
|
-
|
|
24
|
-
interface FakeEvent {
|
|
25
|
-
query: Record<string, unknown>
|
|
26
|
-
context: { params: Record<string, string>; readScope?: string; principal?: { userId: string | null; role: string } }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
let db: ReturnType<typeof drizzle>
|
|
30
|
-
const seen: PopulateCtx[] = []
|
|
31
|
-
|
|
32
|
-
// The handlers are Nitro routes: their auto-imported helpers are plain globals in a node test. Every read
|
|
33
|
-
// entry point is the REAL one, so this exercises the actual crud → populate threading.
|
|
34
|
-
Object.assign(globalThis, {
|
|
35
|
-
defineEventHandler: (handler: unknown) => handler,
|
|
36
|
-
createError,
|
|
37
|
-
getQuery: (event: FakeEvent) => event.query,
|
|
38
|
-
useDb: () => db,
|
|
39
|
-
requireCollection,
|
|
40
|
-
requireId,
|
|
41
|
-
list,
|
|
42
|
-
getOne,
|
|
43
|
-
getSingleton,
|
|
44
|
-
parseFilter,
|
|
45
|
-
// Published-only for EVERY role, so a flag that tracked the read scope instead of the role would be
|
|
46
|
-
// indistinguishable here — the renderer/admin cases below would then fail.
|
|
47
|
-
publishedOnlyForScope: () => true,
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
const listHandler = (await import('./index.get')).default as unknown as (event: FakeEvent) => unknown
|
|
51
|
-
const detailHandler = (await import('./[id].get')).default as unknown as (event: FakeEvent) => unknown
|
|
52
|
-
|
|
53
|
-
let postId: number
|
|
54
|
-
|
|
55
|
-
beforeEach(() => {
|
|
56
|
-
clearRegistry()
|
|
57
|
-
clearPopulator()
|
|
58
|
-
seen.length = 0
|
|
59
|
-
const sqlite = new Database(':memory:')
|
|
60
|
-
for (const stmt of renderSqlite(diffSchema(desiredSchema([posts.table, settings.table]), {}))) sqlite.exec(stmt)
|
|
61
|
-
db = drizzle(sqlite)
|
|
62
|
-
registerCollection(posts)
|
|
63
|
-
registerCollection(settings)
|
|
64
|
-
postId = (create(db, posts, { title: 'A' }) as Record<string, unknown>).id as number
|
|
65
|
-
putSingleton(db, settings, undefined, { siteName: 'Kestrel' })
|
|
66
|
-
registerPopulator((row, ctx) => { seen.push(ctx); return row })
|
|
67
|
-
})
|
|
68
|
-
afterEach(() => {
|
|
69
|
-
clearRegistry()
|
|
70
|
-
clearPopulator()
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
const eventFor = (collection: string, role: string | undefined, params: Record<string, string> = {}): FakeEvent => ({
|
|
74
|
-
query: { depth: 1 },
|
|
75
|
-
context: {
|
|
76
|
-
params: { collection, ...params },
|
|
77
|
-
readScope: 'published',
|
|
78
|
-
principal: role ? { userId: null, role } : undefined,
|
|
79
|
-
},
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
describe('read routes — public-only populate scope', () => {
|
|
83
|
-
it('marks an anonymous list read public-only', () => {
|
|
84
|
-
listHandler(eventFor('posts', 'anonymous'))
|
|
85
|
-
expect(seen[0]?.publicOnly).toBe(true)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('marks an anonymous detail read public-only', () => {
|
|
89
|
-
detailHandler(eventFor('posts', 'anonymous', { id: String(postId) }))
|
|
90
|
-
expect(seen[0]?.publicOnly).toBe(true)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('marks an anonymous singleton read public-only', () => {
|
|
94
|
-
listHandler(eventFor('settings', 'anonymous'))
|
|
95
|
-
expect(seen[0]?.publicOnly).toBe(true)
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
// The renderer produces the static site: stripping its relation sidecars would silently empty the
|
|
99
|
-
// generated HTML, so its read stays unrestricted even though it too is published-only.
|
|
100
|
-
it('leaves a renderer read unrestricted', () => {
|
|
101
|
-
listHandler(eventFor('posts', 'renderer'))
|
|
102
|
-
detailHandler(eventFor('posts', 'renderer', { id: String(postId) }))
|
|
103
|
-
expect(seen.map((c) => c.publicOnly)).toEqual([false, false])
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('leaves an admin read unrestricted', () => {
|
|
107
|
-
listHandler(eventFor('posts', 'admin'))
|
|
108
|
-
detailHandler(eventFor('posts', 'admin', { id: String(postId) }))
|
|
109
|
-
expect(seen.map((c) => c.publicOnly)).toEqual([false, false])
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
// A principal-less request is a guard regression, never a trusted caller — it must fail the same
|
|
113
|
-
// direction as the `publishedOnly` flag the handler derives beside it.
|
|
114
|
-
it('treats a missing principal as public-only', () => {
|
|
115
|
-
listHandler(eventFor('posts', undefined))
|
|
116
|
-
detailHandler(eventFor('posts', undefined, { id: String(postId) }))
|
|
117
|
-
expect(seen.map((c) => c.publicOnly)).toEqual([true, true])
|
|
118
|
-
})
|
|
119
|
-
})
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { createError } from 'h3'
|
|
3
|
-
import Database from 'better-sqlite3'
|
|
4
|
-
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
5
|
-
import { buildCollection } from '../../../../fields/server/utils/buildCollection'
|
|
6
|
-
import { defineCollection } from '../../utils/defineCollection'
|
|
7
|
-
import { create, resolveTranslations } from '../../utils/crud'
|
|
8
|
-
import { requireCollection } from '../../utils/http'
|
|
9
|
-
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
-
import { desiredSchema } from '../../schema/desired'
|
|
11
|
-
import { diffSchema } from '../../schema/diff'
|
|
12
|
-
import { renderSqlite } from '../../schema/render-sqlite'
|
|
13
|
-
|
|
14
|
-
const pages = buildCollection(defineCollection({
|
|
15
|
-
name: 'pages', mode: 'multi', translatable: true, pageLike: true,
|
|
16
|
-
fields: { title: { type: 'text', required: true } },
|
|
17
|
-
}))
|
|
18
|
-
const notes = buildCollection(defineCollection({
|
|
19
|
-
name: 'notes', mode: 'multi', translatable: false,
|
|
20
|
-
fields: { title: { type: 'text', required: true } },
|
|
21
|
-
}))
|
|
22
|
-
|
|
23
|
-
// `context.params` is where Nitro puts the route params the real `requireCollection` reads via h3.
|
|
24
|
-
interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string> } }
|
|
25
|
-
|
|
26
|
-
let db: ReturnType<typeof drizzle>
|
|
27
|
-
|
|
28
|
-
// The handler is a Nitro route: its auto-imported helpers are plain globals in a node test — bound to the
|
|
29
|
-
// REAL implementations so nothing here can pass against a stub the server does not have.
|
|
30
|
-
Object.assign(globalThis, {
|
|
31
|
-
defineEventHandler: (handler: unknown) => handler,
|
|
32
|
-
createError,
|
|
33
|
-
getQuery: (event: FakeEvent) => event.query,
|
|
34
|
-
useDb: () => db,
|
|
35
|
-
requireCollection,
|
|
36
|
-
resolveTranslations,
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
// Calling the handler directly says nothing about the path ever reaching it: that the literal `translations`
|
|
40
|
-
// segment wins over the sibling `[id]` route is only provable on a real server → `test/e2e/api.test.ts`.
|
|
41
|
-
const handler = (await import('./translations.get')).default as unknown as (event: FakeEvent) => Record<string, number | null>
|
|
42
|
-
const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
|
|
43
|
-
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
const sqlite = new Database(':memory:')
|
|
46
|
-
for (const stmt of renderSqlite(diffSchema(desiredSchema([pages.table, notes.table]), {}))) sqlite.exec(stmt)
|
|
47
|
-
db = drizzle(sqlite)
|
|
48
|
-
clearRegistry()
|
|
49
|
-
registerCollection(pages)
|
|
50
|
-
registerCollection(notes)
|
|
51
|
-
})
|
|
52
|
-
afterEach(() => clearRegistry())
|
|
53
|
-
|
|
54
|
-
describe('GET /api/{collection}/translations?group=', () => {
|
|
55
|
-
it('resolves the group\'s locale → sibling id map without a record id', () => {
|
|
56
|
-
const en = create(db, pages, { title: 'Home' }) as Record<string, unknown>
|
|
57
|
-
const de = create(db, pages, { title: 'Start', locale: 'de', translationGroup: en.translationGroup as string }) as Record<string, unknown>
|
|
58
|
-
expect(get('pages', { group: en.translationGroup })).toEqual({ en: en.id, de: de.id })
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('reports a locale with no sibling as null (what the "+ create" affordance keys off)', () => {
|
|
62
|
-
const en = create(db, pages, { title: 'Only EN' }) as Record<string, unknown>
|
|
63
|
-
expect(get('pages', { group: en.translationGroup })).toEqual({ en: en.id, de: null })
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('returns exactly the per-record map for the same group (one map builder, two entry points)', () => {
|
|
67
|
-
const en = create(db, pages, { title: 'Home' }) as Record<string, unknown>
|
|
68
|
-
create(db, pages, { title: 'Start', locale: 'de', translationGroup: en.translationGroup as string })
|
|
69
|
-
expect(get('pages', { group: en.translationGroup })).toEqual(resolveTranslations(db, pages, en.id as number))
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('400s without a group rather than answering for an arbitrary one', () => {
|
|
73
|
-
expect(() => get('pages', {})).toThrowError(expect.objectContaining({ statusCode: 400 }))
|
|
74
|
-
expect(() => get('pages', { group: ' ' })).toThrowError(expect.objectContaining({ statusCode: 400 }))
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('404s for a group that has no rows', () => {
|
|
78
|
-
create(db, pages, { title: 'Home' })
|
|
79
|
-
expect(() => get('pages', { group: 'nope' })).toThrowError(expect.objectContaining({ statusCode: 404 }))
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('400s (never 500s) for a collection without translations — it has no group column at all', () => {
|
|
83
|
-
create(db, notes, { title: 'Note' })
|
|
84
|
-
expect(() => get('notes', { group: 'g1' })).toThrowError(/Translations are not enabled/)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('404s for an unknown collection', () => {
|
|
88
|
-
expect(() => get('nope', { group: 'g1' })).toThrowError(expect.objectContaining({ statusCode: 404 }))
|
|
89
|
-
})
|
|
90
|
-
})
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
|
|
3
|
-
|
|
4
|
-
// The sibling map keyed by translation GROUP — the twin of `/:id/translations` for the editor of a record
|
|
5
|
-
// that does not exist yet (`/admin/pages/new?locale=de&group=…`), which knows its group but has no id. It
|
|
6
|
-
// resolves one member of the group and hands off to the per-id map builder, so both entry points can never
|
|
7
|
-
// answer differently. The literal `translations` segment beats the dynamic `[id]` route (as `options.get.ts`
|
|
8
|
-
// already relies on). Admin-only: `resourceForPath` maps it to the `<collection>/translations` resource, the
|
|
9
|
-
// same gate as the per-record route (the map enumerates draft ids).
|
|
10
|
-
export default defineEventHandler((event) => {
|
|
11
|
-
const collection = requireCollection(event)
|
|
12
|
-
// Checked here, not left to resolveTranslations: a collection without translations has no
|
|
13
|
-
// translationGroup column, so the member lookup below would blow up on an undefined column.
|
|
14
|
-
if (collection.def.mode === 'single' || !collection.def.translatable) {
|
|
15
|
-
throw createError({ statusCode: 400, statusMessage: 'Translations are not enabled for this collection' })
|
|
16
|
-
}
|
|
17
|
-
const group = String(getQuery(event).group ?? '').trim()
|
|
18
|
-
if (!group) throw createError({ statusCode: 400, statusMessage: 'A translation group is required' })
|
|
19
|
-
|
|
20
|
-
const db = useDb()
|
|
21
|
-
const cols = getTableColumns(collection.table) as Record<string, AnySQLiteColumn>
|
|
22
|
-
const member = db.select({ id: cols.id }).from(collection.table).where(eq(cols.translationGroup, group)).get() as { id: number } | undefined
|
|
23
|
-
if (!member) throw createError({ statusCode: 404, statusMessage: `Unknown translation group: ${group}` })
|
|
24
|
-
return resolveTranslations(db, collection, member.id)
|
|
25
|
-
})
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Every reference in the index whose target is currently deleted or unpublished — the global broken-
|
|
2
|
-
// references report. Admin-only (the `references` resource is not in the public set). Derived on read.
|
|
3
|
-
// A `null` scan means the index itself could not be read (e.g. record_refs not migrated yet); answering
|
|
4
|
-
// `[]` there would report a verified-clean site when nothing was actually checked.
|
|
5
|
-
export default defineEventHandler(() => {
|
|
6
|
-
const rows = findBrokenRefs(useDb())
|
|
7
|
-
if (rows === null) throw createError({ statusCode: 503, statusMessage: 'Reference index unavailable' })
|
|
8
|
-
return rows
|
|
9
|
-
})
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { MAX_BULK_IDS } from '../../../app/utils/list-limits'
|
|
2
|
-
|
|
3
|
-
// The distinct records that reference a given target — the reverse "what links here" lookup. Admin-only
|
|
4
|
-
// (the `references` resource is not in the public set). Powers the editor's pre-delete referrer warning.
|
|
5
|
-
// Two arms:
|
|
6
|
-
// ?collection=X&id=1 -> FieldRef[] (single target; the editor's warning)
|
|
7
|
-
// ?collection=X&ids=1,2,3 -> { counts: Record<id, count>, checked: boolean } (a selection; the bulk-delete warning)
|
|
8
|
-
// `checked` is false when the lookup itself failed (e.g. record_refs not migrated yet) — an empty `counts`
|
|
9
|
-
// then means "could not check", NOT "no referrers", so a caller must not read it as a green light to delete.
|
|
10
|
-
export default defineEventHandler((event) => {
|
|
11
|
-
const q = getQuery(event)
|
|
12
|
-
const collection = String(q.collection ?? '')
|
|
13
|
-
if (q.ids != null) {
|
|
14
|
-
if (!collection) throw createError({ statusCode: 400, statusMessage: 'collection and ids query params are required' })
|
|
15
|
-
const ids = parseIdList(q.ids, MAX_BULK_IDS)
|
|
16
|
-
const counts = findReferrersForMany(useDb(), collection, ids)
|
|
17
|
-
return { counts: counts ?? {}, checked: counts !== null }
|
|
18
|
-
}
|
|
19
|
-
const id = Number(q.id)
|
|
20
|
-
if (!collection || !Number.isInteger(id) || id <= 0) {
|
|
21
|
-
throw createError({ statusCode: 400, statusMessage: 'collection and id query params are required' })
|
|
22
|
-
}
|
|
23
|
-
return findReferrers(useDb(), collection, id) ?? []
|
|
24
|
-
})
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
|
|
3
|
-
// Durable reference index: one row per `(source record) → (target record)` edge, over ALL reference
|
|
4
|
-
// types (relation / media / internal link / richtext links / link fields). Maintained on every content
|
|
5
|
-
// write (see `maintainRecordRefs`) and queried on read to DERIVE stale-reference warnings — never stores
|
|
6
|
-
// a warning message (a stored "X deleted" goes stale when X is restored). A non-collection system table,
|
|
7
|
-
// so it lives here (not `server/collections`, which would wire it into the CRUD API/admin). Indexed both
|
|
8
|
-
// forward (what a record references) and reverse (what references a record). `createdAt` via `$defaultFn`
|
|
9
|
-
// (no SQL default → it won't perpetually diff as a rebuild).
|
|
10
|
-
export const recordRefs = sqliteTable('record_refs', {
|
|
11
|
-
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
12
|
-
sourceColl: text('source_coll').notNull(),
|
|
13
|
-
sourceId: integer('source_id').notNull(),
|
|
14
|
-
targetColl: text('target_coll').notNull(),
|
|
15
|
-
targetId: integer('target_id').notNull(),
|
|
16
|
-
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
17
|
-
}, (t) => [
|
|
18
|
-
index('record_refs_source').on(t.sourceColl, t.sourceId),
|
|
19
|
-
index('record_refs_target').on(t.targetColl, t.targetId),
|
|
20
|
-
])
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { registerWriteListener } from '../utils/write-events'
|
|
2
|
-
import { maintainRecordRefs } from '../utils/record-ref-index'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Keep the durable `record_refs` index current on every content write — in ALL environments (the dead-
|
|
6
|
-
* reference warnings it powers are an editor feature, not gated on publishing, so unlike the publish
|
|
7
|
-
* plugin this is never skipped in dev). Its own listener, so it coexists with the publish listener; the
|
|
8
|
-
* bus try/catch-isolates each, so an index-maintenance failure never breaks the content write.
|
|
9
|
-
*/
|
|
10
|
-
export default defineNitroPlugin(() => {
|
|
11
|
-
registerWriteListener((event) => maintainRecordRefs(useDb(), event))
|
|
12
|
-
})
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
import type { BuiltCollection } from '../utils/collection-types'
|
|
3
|
-
import type { CollectionDef } from '../utils/defineCollection'
|
|
4
|
-
import { ensureBuilt } from '../../../fields/server/utils/buildCollection'
|
|
5
|
-
import { recordRefs } from '../database/record-refs'
|
|
6
|
-
import { desiredSchema } from './desired'
|
|
7
|
-
import type { SchemaSnapshot } from './model'
|
|
8
|
-
|
|
9
|
-
/** The ONE rule for whether a discovered collection is active: built-ins (`pages`, `media`, …) can be
|
|
10
|
-
* disabled per consumer via `kestrel: { collections: { <name>: false } }`. Shared by the registry
|
|
11
|
-
* plugin AND the schema engine, so the runtime surface and the desired schema can never diverge —
|
|
12
|
-
* a disabled built-in neither registers routes nor keeps (or creates) its table. */
|
|
13
|
-
export function collectionEnabled(def: CollectionDef, toggles?: Record<string, boolean>): boolean {
|
|
14
|
-
return !(def.builtin && toggles?.[def.name] === false)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface DesiredOptions {
|
|
18
|
-
/** Built-in collection toggles (see `collectionEnabled`). Omitted → everything discovered is desired. */
|
|
19
|
-
toggles?: Record<string, boolean>
|
|
20
|
-
/** Standalone (non-collection) tables owned by upper layers — discovered via `#kestrel/schema-tables`
|
|
21
|
-
* (each layer's `server/schema-tables/*.ts` default-exports one table) and passed in by the Nitro
|
|
22
|
-
* callers, so core hardcodes no upper-layer schema. `record_refs` is core-owned and always included. */
|
|
23
|
-
extraTables?: AnySQLiteTable[]
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The desired DB schema for the discovered collections (the `#kestrel/collections` virtual, passed in
|
|
28
|
-
* so this stays pure and unit-testable): every ENABLED collection's table plus core's `record_refs` and
|
|
29
|
-
* the callers' discovered standalone tables. The single definition of "what the live DB should look
|
|
30
|
-
* like", shared by the dev auto-sync plugin (02.schema-sync) and the `db:migrate` task so the two can
|
|
31
|
-
* never disagree on the target schema. Discovered files may default-export a plain def (consumer form)
|
|
32
|
-
* or an already-built collection.
|
|
33
|
-
*/
|
|
34
|
-
export function desiredFromCollections(
|
|
35
|
-
collections: (CollectionDef | BuiltCollection)[],
|
|
36
|
-
opts: DesiredOptions = {},
|
|
37
|
-
): SchemaSnapshot {
|
|
38
|
-
const built = collections.map(ensureBuilt).filter((c) => collectionEnabled(c.def, opts.toggles))
|
|
39
|
-
const tables = [...built.map((c) => c.table), recordRefs, ...(opts.extraTables ?? [])]
|
|
40
|
-
return desiredSchema(tables, new Map(built.map((c) => [c.def.name, c.def])))
|
|
41
|
-
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { getTableConfig, SQLiteSyncDialect, type SQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
import { SQL, is } from 'drizzle-orm'
|
|
3
|
-
import { resolveColumnName, toSnakeCase } from '../../../fields/server/field-registry/naming'
|
|
4
|
-
import type { CollectionDef } from '../utils/defineCollection'
|
|
5
|
-
import { fieldIs } from '../utils/defineCollection'
|
|
6
|
-
import type { ColumnShape, IndexShape, TableShape, SchemaSnapshot } from './model'
|
|
7
|
-
|
|
8
|
-
// Build the *desired* schema snapshot from the Drizzle tables that collections compile to (the
|
|
9
|
-
// counterpart to introspect's *actual* snapshot). Reads drizzle's table metadata via getTableConfig and
|
|
10
|
-
// normalizes it into the same model `diffSchema` consumes (ADR-0002).
|
|
11
|
-
|
|
12
|
-
const dialect = new SQLiteSyncDialect()
|
|
13
|
-
const sqlText = (value: SQL) => dialect.sqlToQuery(value).sql
|
|
14
|
-
|
|
15
|
-
/** Map a Drizzle column default to its raw DDL token: a `sql\`…\`` default renders verbatim, a JS literal is quoted. */
|
|
16
|
-
export function defaultToken(value: unknown): string | null {
|
|
17
|
-
if (value === undefined || value === null) return null
|
|
18
|
-
if (is(value, SQL)) return sqlText(value)
|
|
19
|
-
if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`
|
|
20
|
-
if (typeof value === 'boolean') return value ? '1' : '0'
|
|
21
|
-
// Object / array default (a json-backed column — e.g. a custom field type): emit a quoted JSON string
|
|
22
|
-
// literal, matching how json/repeater columns default via sql`'{}'`. Else String({}) → "[object Object]".
|
|
23
|
-
if (typeof value === 'object') return `'${JSON.stringify(value).replace(/'/g, "''")}'`
|
|
24
|
-
return String(value)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function desiredTable(table: SQLiteTable, def?: CollectionDef): TableShape {
|
|
28
|
-
const cfg = getTableConfig(table)
|
|
29
|
-
|
|
30
|
-
// new column name -> old column name (and any single↔multiple shape transform). Two sources:
|
|
31
|
-
// - an explicit `renamedFrom` field hint (same field type → same naming rule for both keys);
|
|
32
|
-
// - a relation/media field, which maps to its opposite-multiplicity column name (`<col>` ↔ `<col>_id`)
|
|
33
|
-
// so toggling `multiple`/`many` renames + reshapes the data instead of dropping it. Inert unless the
|
|
34
|
-
// opposite column actually exists in the live DB.
|
|
35
|
-
const renames = new Map<string, string>()
|
|
36
|
-
const transforms = new Map<string, 'wrap' | 'unwrap'>()
|
|
37
|
-
if (def) {
|
|
38
|
-
for (const [key, field] of Object.entries(def.fields)) {
|
|
39
|
-
if (field.renamedFrom) {
|
|
40
|
-
renames.set(resolveColumnName(key, field).dbName, resolveColumnName(field.renamedFrom, field).dbName)
|
|
41
|
-
continue
|
|
42
|
-
}
|
|
43
|
-
if (fieldIs(field, 'media') || fieldIs(field, 'relation')) {
|
|
44
|
-
const multiple = fieldIs(field, 'media') ? field.options?.multiple === true : field.relation.many === true
|
|
45
|
-
const base = toSnakeCase(key)
|
|
46
|
-
const col = multiple ? base : `${base}_id`
|
|
47
|
-
renames.set(col, multiple ? `${base}_id` : base)
|
|
48
|
-
transforms.set(col, multiple ? 'wrap' : 'unwrap')
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const columns: ColumnShape[] = cfg.columns.map((c) => ({
|
|
54
|
-
name: c.name,
|
|
55
|
-
type: c.getSQLType(),
|
|
56
|
-
notNull: c.notNull,
|
|
57
|
-
primaryKey: c.primary,
|
|
58
|
-
autoIncrement: (c as unknown as { autoIncrement?: boolean }).autoIncrement === true,
|
|
59
|
-
default: defaultToken(c.default),
|
|
60
|
-
...(renames.has(c.name) ? { renamedFrom: renames.get(c.name) } : {}),
|
|
61
|
-
...(transforms.has(c.name) ? { renameTransform: transforms.get(c.name) } : {}),
|
|
62
|
-
}))
|
|
63
|
-
|
|
64
|
-
const indexes: IndexShape[] = cfg.indexes.map((i) => {
|
|
65
|
-
const cfgI = i.config as unknown as { name: string; unique: boolean; columns: { name: string }[]; where?: SQL }
|
|
66
|
-
return {
|
|
67
|
-
name: cfgI.name,
|
|
68
|
-
table: cfg.name,
|
|
69
|
-
columns: cfgI.columns.map((x) => x.name),
|
|
70
|
-
unique: cfgI.unique,
|
|
71
|
-
where: cfgI.where ? sqlText(cfgI.where) : null,
|
|
72
|
-
}
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
// A column-level `.unique()` is materialized by drizzle-kit as a named unique index
|
|
76
|
-
// (`<table>_<column>_unique`) — surface it the same way so introspect's view and ours agree.
|
|
77
|
-
for (const c of cfg.columns) {
|
|
78
|
-
const u = c as unknown as { isUnique?: boolean; uniqueName?: string }
|
|
79
|
-
if (u.isUnique) {
|
|
80
|
-
indexes.push({ name: u.uniqueName ?? `${cfg.name}_${c.name}_unique`, table: cfg.name, columns: [c.name], unique: true, where: null })
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return { name: cfg.name, columns, indexes }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Build a full desired snapshot from a set of Drizzle tables (registered collections + standalone tables).
|
|
89
|
-
* `defsByName` supplies each collection's def so `renamedFrom` field hints become column renames; tables
|
|
90
|
-
* without a def (e.g. `folders`) simply carry no renames.
|
|
91
|
-
*/
|
|
92
|
-
export function desiredSchema(tables: SQLiteTable[], defsByName?: Map<string, CollectionDef>): SchemaSnapshot {
|
|
93
|
-
const snapshot: SchemaSnapshot = {}
|
|
94
|
-
for (const table of tables) {
|
|
95
|
-
const shape = desiredTable(table, defsByName?.get(getTableConfig(table).name))
|
|
96
|
-
snapshot[shape.name] = shape
|
|
97
|
-
}
|
|
98
|
-
return snapshot
|
|
99
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { syncSchema, describeOp, type SyncDb } from './sync'
|
|
2
|
-
import { sqlite, type Dialect } from './dialect'
|
|
3
|
-
import type { SchemaSnapshot } from './model'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The body of the dev-only schema auto-sync plugin, isolated so a failure can NEVER take down the whole
|
|
7
|
-
* server. syncSchema throws by design on an infeasible migration (and on any transient DDL error); if
|
|
8
|
-
* that escaped the Nitro plugin it would 500 every route — including the admin UI needed to diagnose it.
|
|
9
|
-
* Here we log loudly and let the server keep running on the previous schema. The operator-triggered
|
|
10
|
-
* db:migrate task is one-shot and keeps failing hard; only this always-on plugin must isolate.
|
|
11
|
-
*/
|
|
12
|
-
export function runDevSchemaSync(client: SyncDb, desired: SchemaSnapshot, dialect: Dialect = sqlite): void {
|
|
13
|
-
try {
|
|
14
|
-
const { applied, skipped } = syncSchema(client, desired, {}, dialect)
|
|
15
|
-
if (applied.length) console.info(`[kestrel] dev schema auto-sync applied ${applied.length} statement(s)`)
|
|
16
|
-
// Destructive changes are never auto-applied — name them so the data loss is visible before opting in.
|
|
17
|
-
if (skipped.length) {
|
|
18
|
-
console.warn(`[kestrel] ${skipped.length} destructive schema change(s) detected — not auto-applied:\n - ${skipped.map(describeOp).join('\n - ')}\n apply with the db:migrate task ({ force: true }) — dev: GET /_nitro/tasks/db:migrate, prod: runTask('db:migrate', { payload: { force: true } })`)
|
|
19
|
-
}
|
|
20
|
-
} catch (err) {
|
|
21
|
-
console.error('[kestrel] dev schema auto-sync FAILED — the server keeps running on the PREVIOUS schema; fix the collection or its data and restart:\n ', err instanceof Error ? err.message : err)
|
|
22
|
-
}
|
|
23
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import type { SchemaSnapshot, SchemaOp } from './model'
|
|
2
|
-
import type { IntrospectDb } from './introspect'
|
|
3
|
-
import { introspect } from './introspect'
|
|
4
|
-
import { renderSqlite } from './render-sqlite'
|
|
5
|
-
|
|
6
|
-
// The dialect seam (ADR-0002). `model.ts` + `diffSchema` are dialect-agnostic: a diff is computed as a
|
|
7
|
-
// `SchemaOp[]` over the normalized model, independent of any database. A `Dialect` is what binds that
|
|
8
|
-
// core to a concrete backend — it knows how to read the backend's live schema back into the model
|
|
9
|
-
// (`introspect`), how to turn ops into the backend's DDL (`render`), and how to quote an identifier.
|
|
10
|
-
// `sync.ts` is threaded with a Dialect (defaulting to `sqlite`), so DDL rendering, introspection and
|
|
11
|
-
// identifier quoting are swappable without touching the diff. The seam stops there: `diff.ts` emits
|
|
12
|
-
// `rebuild_table` because SQLite has no ALTER/DROP COLUMN, and sync's pre-flight feasibility probes are
|
|
13
|
-
// SQLite SQL (`PRAGMA table_info`, `json_extract`) — a second backend needs both adjusted alongside its
|
|
14
|
-
// new Dialect. SQLite is the only implementation today; `postgres` is a reserved, fail-loud slot.
|
|
15
|
-
export interface Dialect {
|
|
16
|
-
/** Stable identifier, e.g. `sqlite` | `postgres`. */
|
|
17
|
-
readonly name: string
|
|
18
|
-
/** Read the live database's actual schema into the normalized model, for `diffSchema` to compare. */
|
|
19
|
-
introspect(db: IntrospectDb): SchemaSnapshot
|
|
20
|
-
/** Render structured ops into this backend's DDL statements, preserving the apply-safe order. */
|
|
21
|
-
render(ops: SchemaOp[]): string[]
|
|
22
|
-
/** Quote an identifier for this backend (backticks for SQLite, double-quotes for Postgres). */
|
|
23
|
-
quote(id: string): string
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** The SQLite dialect: the current engine, now behind the seam. `render`/`introspect` are the existing
|
|
27
|
-
* pure functions; `quote` is SQLite's backtick quoting (matching `render-sqlite`'s own internal escape). */
|
|
28
|
-
export const sqlite: Dialect = {
|
|
29
|
-
name: 'sqlite',
|
|
30
|
-
introspect,
|
|
31
|
-
render: renderSqlite,
|
|
32
|
-
quote: (id) => `\`${id.replace(/`/g, '``')}\``,
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const pgNotImplemented = (op: string) =>
|
|
36
|
-
new Error(
|
|
37
|
-
`kestrel: the postgres dialect is not implemented yet (Dialect.${op}). It is a reserved slot ` +
|
|
38
|
-
`(ADR-0002) — a Postgres backend needs its own DDL renderer (real ALTER/DROP COLUMN, not SQLite's ` +
|
|
39
|
-
`table rebuild), information_schema introspection, and a pg-typed desired snapshot. Use \`sqlite\`.`,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
/** Reserved Postgres slot. Every substantive entry point fails loud so a misconfiguration can never
|
|
43
|
-
* silently fall back to emitting SQLite DDL against a non-SQLite database. `quote` is the one piece that
|
|
44
|
-
* is trivially correct (standard double-quote escaping), so it is implemented rather than thrown. */
|
|
45
|
-
export const postgres: Dialect = {
|
|
46
|
-
name: 'postgres',
|
|
47
|
-
introspect() { throw pgNotImplemented('introspect') },
|
|
48
|
-
render() { throw pgNotImplemented('render') },
|
|
49
|
-
quote: (id) => `"${id.replace(/"/g, '""')}"`,
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const dialects: Record<string, Dialect> = { sqlite, postgres }
|
|
53
|
-
|
|
54
|
-
/** Resolve a dialect by name (the config-driven selection point for the migration engine). Throws a
|
|
55
|
-
* clear error naming the supported dialects rather than silently defaulting, so a typo'd or unsupported
|
|
56
|
-
* backend fails at startup instead of mis-migrating. */
|
|
57
|
-
export function resolveDialect(name: string): Dialect {
|
|
58
|
-
const d = dialects[name]
|
|
59
|
-
if (!d) throw new Error(`kestrel: unknown database dialect "${name}" — supported: ${Object.keys(dialects).join(', ')}`)
|
|
60
|
-
return d
|
|
61
|
-
}
|