@michaelthielemann/kestrel 3.0.1 → 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 +1 -3
- 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/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -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,98 +0,0 @@
|
|
|
1
|
-
import type { SerializedField } from '../../server/utils/serialize-collection' // type-only, erased client-side
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The single shared source of truth for list filtering: which normalized "kind" a field/meta column maps
|
|
5
|
-
* to, and which operators that kind allows. Both the server (validation + predicate building) and the admin
|
|
6
|
-
* UI (operator select + typed value control) import this — so the allowed-operators-per-field-type table is
|
|
7
|
-
* defined exactly once. Kept in `core/app/utils` (the server-and-client shared home) and free of any DB or
|
|
8
|
-
* column-naming detail: each side maps its own column keys.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export type FilterOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'contains' | 'notContains'
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* A field's storage/semantics bucket for filtering. `text`/`richtext` do substring LIKE; `stringSet`/`idSet`
|
|
15
|
-
* are JSON-array membership (multi choice / many relation+media); `ref` is a single FK id (eq/ne only);
|
|
16
|
-
* `enum`/`boolean` are equality-only; `number`/`datetime` support the full comparison set.
|
|
17
|
-
*/
|
|
18
|
-
export type FilterKind = 'number' | 'text' | 'datetime' | 'boolean' | 'enum' | 'ref' | 'richtext' | 'stringSet' | 'idSet'
|
|
19
|
-
|
|
20
|
-
const CMP = ['eq', 'ne', 'lt', 'lte', 'gt', 'gte'] as const
|
|
21
|
-
|
|
22
|
-
export const OPS_BY_KIND: Record<FilterKind, readonly FilterOp[]> = {
|
|
23
|
-
number: CMP,
|
|
24
|
-
datetime: CMP,
|
|
25
|
-
text: ['eq', 'ne', 'contains'],
|
|
26
|
-
richtext: ['contains'], // `contains` matches the stored HTML SOURCE (searching 'p' can hit a '<p>' tag) —
|
|
27
|
-
// a stripped shadow column / FTS index is a later enhancement (documented caveat).
|
|
28
|
-
boolean: ['eq', 'ne'],
|
|
29
|
-
enum: ['eq', 'ne'],
|
|
30
|
-
ref: ['eq', 'ne'], // a single relation/media FK id — lt/gt on an id is meaningless.
|
|
31
|
-
stringSet: ['contains', 'notContains'],
|
|
32
|
-
idSet: ['contains', 'notContains'],
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** The operator a column's filter row defaults to in the admin UI. `datetime` defaults to `gte` ("on or
|
|
36
|
-
* after") — `eq` on a millisecond timestamp practically never matches, so it made the filter useless. The
|
|
37
|
-
* admin always emits the operator explicitly, so this does NOT change the wire parser's separate
|
|
38
|
-
* absent-operator default (a bare `filter[field]` still parses as `eq`, URLs round-trip unchanged). */
|
|
39
|
-
export const DEFAULT_OP: Record<FilterKind, FilterOp> = {
|
|
40
|
-
number: 'eq', datetime: 'gte', text: 'eq', boolean: 'eq', enum: 'eq', ref: 'eq',
|
|
41
|
-
richtext: 'contains', stringSet: 'contains', idSet: 'contains',
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const ALL_OPS = new Set<string>(['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'contains', 'notContains'])
|
|
45
|
-
|
|
46
|
-
/** Whether a raw wire token is a known operator at all (parse-time guard → a clean 400 for garbage). */
|
|
47
|
-
export const isFilterOp = (s: string): s is FilterOp => ALL_OPS.has(s)
|
|
48
|
-
|
|
49
|
-
/** Whether `op` is allowed for a column of this kind (list-time guard → a clean 400, never a 500). */
|
|
50
|
-
export const opAllowed = (kind: FilterKind, op: FilterOp): boolean => OPS_BY_KIND[kind].includes(op)
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The wire key for a filter clause: `filter[field]` or `filter[field][op]`. `[^\]]+` keeps the field/op
|
|
54
|
-
* tokens bracket-free; the optional second group is the operator (absent → `eq`, so a bare
|
|
55
|
-
* `filter[status]=x` stays backward compatible). Defined ONCE here (the shared client+server home) so the
|
|
56
|
-
* admin URL parser (`parseListQuery`) and the server wire parser (`parseFilter`) use exactly one regex.
|
|
57
|
-
*/
|
|
58
|
-
export const FILTER_RE = /^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The FilterKind for a field, or `null` when it is not filterable (link / json / repeater / range-datetime).
|
|
62
|
-
* Unknown consumer types fall back to the conservative `text` (equality + substring on their scalar column).
|
|
63
|
-
*/
|
|
64
|
-
export function fieldFilterKind(f: SerializedField): FilterKind | null {
|
|
65
|
-
switch (f.type) {
|
|
66
|
-
case 'text':
|
|
67
|
-
case 'slug':
|
|
68
|
-
return 'text'
|
|
69
|
-
case 'richtext':
|
|
70
|
-
return 'richtext'
|
|
71
|
-
case 'number':
|
|
72
|
-
return 'number'
|
|
73
|
-
case 'boolean':
|
|
74
|
-
return 'boolean'
|
|
75
|
-
case 'datetime':
|
|
76
|
-
return f.options?.range ? null : 'datetime'
|
|
77
|
-
case 'choice':
|
|
78
|
-
return f.options?.multiple ? 'stringSet' : 'enum'
|
|
79
|
-
case 'relation':
|
|
80
|
-
case 'media':
|
|
81
|
-
return f.single ? 'ref' : 'idSet'
|
|
82
|
-
case 'link':
|
|
83
|
-
case 'json':
|
|
84
|
-
case 'repeater':
|
|
85
|
-
return null
|
|
86
|
-
default:
|
|
87
|
-
return 'text'
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The FilterKind of each filterable meta/system column (keyed by its wire key). */
|
|
92
|
-
export const META_FILTER_KIND: Record<string, FilterKind> = {
|
|
93
|
-
id: 'number',
|
|
94
|
-
path: 'text',
|
|
95
|
-
status: 'enum',
|
|
96
|
-
createdAt: 'datetime',
|
|
97
|
-
updatedAt: 'datetime',
|
|
98
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// The single source of truth for collection-list page sizes and, derived from them, the bulk-operation id
|
|
2
|
-
// cap. Lives in `core/app/utils` (the established server+client shared home — same as `filter-ops.ts`) so
|
|
3
|
-
// the admin list UI, the `list()` query engine, and the `bulk` endpoint all agree on ONE set of limits
|
|
4
|
-
// instead of each hardcoding its own.
|
|
5
|
-
|
|
6
|
-
/** The page sizes offered in the list's per-page selector. */
|
|
7
|
-
export const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500] as const
|
|
8
|
-
|
|
9
|
-
/** The page size a list falls back to when none (or a garbage value) is supplied. */
|
|
10
|
-
export const DEFAULT_PER_PAGE = 25
|
|
11
|
-
|
|
12
|
-
/** The largest page a single list request may return — also the ceiling `clampPerPage` clamps to. */
|
|
13
|
-
export const MAX_PER_PAGE = 500
|
|
14
|
-
|
|
15
|
-
/** Coerce an untrusted page size to a sane integer: a non-finite input (undefined / NaN / Infinity / a
|
|
16
|
-
* non-numeric string) falls back to `DEFAULT_PER_PAGE`; anything else is floored and clamped to
|
|
17
|
-
* `[1, MAX_PER_PAGE]`. Guards against `LIMIT NaN` (which SQLite binds as "no limit") — an unauthenticated
|
|
18
|
-
* cap-bypass / DoS lever. */
|
|
19
|
-
export function clampPerPage(n: unknown): number {
|
|
20
|
-
const v = Number(n)
|
|
21
|
-
if (!Number.isFinite(v)) return DEFAULT_PER_PAGE
|
|
22
|
-
return Math.min(MAX_PER_PAGE, Math.max(1, Math.floor(v)))
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** A bulk op can never carry more ids than one page can show (you select what you can see), so the id cap
|
|
26
|
-
* is DERIVED from the max page size, not guessed. */
|
|
27
|
-
export const MAX_BULK_IDS = MAX_PER_PAGE
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The public route path for a page row: other locales are prefixed with `/<locale>`; the primary locale
|
|
3
|
-
* is unprefixed unless `prefixPrimary` is set (then it is prefixed too, root `/` → `/<primary>`). Single
|
|
4
|
-
* source of truth for the URL scheme shared by the prerender routes, the sitemap, `populate-links`, the
|
|
5
|
-
* `[...slug].vue` resolution, the slug-uniqueness check, and the admin SEO preview. Pure, so it is
|
|
6
|
-
* unit-testable and safe to import from both server and app code.
|
|
7
|
-
*/
|
|
8
|
-
export function localePath(path: string, locale: string, primaryLocale: string, prefixPrimary = false): string {
|
|
9
|
-
const p = path.startsWith('/') ? path : `/${path}`
|
|
10
|
-
if (locale === primaryLocale && !prefixPrimary) return p
|
|
11
|
-
return p === '/' ? `/${locale}` : `/${locale}${p}`
|
|
12
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Turn arbitrary text (a record title) into a url-safe slug: lowercase, fold diacritics to ASCII
|
|
3
|
-
* (`Über` → `uber`, `Café` → `cafe`) via NFKD + combining-mark removal, then collapse every run of
|
|
4
|
-
* non-`[a-z0-9]` into a single hyphen and trim the ends. Letters with no ASCII fold (ß, CJK, …) act as
|
|
5
|
-
* separators — Kestrel does NO language-specific transliteration, so the result is predictable. Returns
|
|
6
|
-
* `''` for blank / symbol-only input (the caller decides: fall back to the manual slug, or reject). Pure.
|
|
7
|
-
*/
|
|
8
|
-
export function slugify(input: string): string {
|
|
9
|
-
if (typeof input !== 'string') return ''
|
|
10
|
-
return input
|
|
11
|
-
.normalize('NFKD')
|
|
12
|
-
.replace(/\p{M}/gu, '') // strip combining diacritical marks left by NFKD (é → e, ü → u)
|
|
13
|
-
.toLowerCase()
|
|
14
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
15
|
-
.replace(/^-+|-+$/g, '')
|
|
16
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// Per-record stale-reference map for the editor: every reference this record holds that now points at a
|
|
2
|
-
// deleted or unpublished target, with its field/block location + reason. Derived on read (auto-clears when
|
|
3
|
-
// the link is fixed or the target restored). Fetched non-blocking alongside the editor load, like the
|
|
4
|
-
// translations map.
|
|
5
|
-
export default defineEventHandler((event) => {
|
|
6
|
-
const collection = requireCollection(event)
|
|
7
|
-
return recordDeadRefs(useDb(), collection, requireId(event))
|
|
8
|
-
})
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler((event) => {
|
|
2
|
-
const collection = requireCollection(event)
|
|
3
|
-
const query = getQuery(event)
|
|
4
|
-
const depth = Number(query.depth ?? 0)
|
|
5
|
-
const locale = query.locale as string | undefined
|
|
6
|
-
const publishedOnly = publishedOnlyForScope(event.context.readScope)
|
|
7
|
-
// See index.get.ts: the public-set restriction follows the ROLE, so the renderer keeps full population;
|
|
8
|
-
// a missing principal fails closed onto it.
|
|
9
|
-
const publicOnly = (event.context.principal?.role ?? 'anonymous') === 'anonymous'
|
|
10
|
-
return getOne(useDb(), collection, requireId(event), depth, locale, publishedOnly, publicOnly)
|
|
11
|
-
})
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler(async (event) => {
|
|
2
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
-
const collection = requireCollection(event)
|
|
4
|
-
const id = requireId(event)
|
|
5
|
-
const body = await readBody(event)
|
|
6
|
-
// Optimistic concurrency: the editor sends the baseline it loaded; a stale save is refused with 409.
|
|
7
|
-
return update(useDb(), collection, id, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
|
|
8
|
-
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { MAX_BULK_IDS } from '../../../app/utils/list-limits'
|
|
2
|
-
import { BULK_ACTIONS, isBulkAction, type BulkAction } from '../../../app/utils/bulk-actions'
|
|
3
|
-
|
|
4
|
-
// The ONE command endpoint for collection batch operations — the single wire surface behind both the
|
|
5
|
-
// per-row Quick Actions and the selection Bulk bar (a row action IS a bulk action with one id). POST is
|
|
6
|
-
// classified 'write' on the `{collection}` resource by the access guard (resourceForPath('/api/{c}/bulk')
|
|
7
|
-
// === '{c}'), so CSRF + per-collection policy apply exactly as for a single-record write — no bypass.
|
|
8
|
-
// A literal `bulk.post.ts` segment beats the dynamic `[id]` route (no `[id].post.ts` exists), so no clash.
|
|
9
|
-
//
|
|
10
|
-
// body: { action: 'delete' | 'publish' | 'unpublish' | 'duplicate', ids: number[] }
|
|
11
|
-
// 200: { action, count, ids } (for 'duplicate', `ids` are the CREATED ids)
|
|
12
|
-
//
|
|
13
|
-
// delete + publish/unpublish are all-or-nothing (an unknown id 404s before any write); duplicate is a
|
|
14
|
-
// sequential row action. All batching lives in crud.ts — this handler is a thin parse-and-dispatch.
|
|
15
|
-
|
|
16
|
-
function parseBulkBody(body: unknown): { action: BulkAction; ids: number[] } {
|
|
17
|
-
const action = (body as { action?: unknown })?.action
|
|
18
|
-
if (!isBulkAction(action)) {
|
|
19
|
-
throw createError({ statusCode: 400, statusMessage: `Invalid bulk action: ${String(action)} (expected one of ${BULK_ACTIONS.join(', ')})` })
|
|
20
|
-
}
|
|
21
|
-
const ids = parseIdList((body as { ids?: unknown })?.ids, MAX_BULK_IDS)
|
|
22
|
-
return { action, ids }
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export default defineEventHandler(async (event) => {
|
|
26
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
27
|
-
const collection = requireCollection(event)
|
|
28
|
-
const { action, ids } = parseBulkBody(await readBody(event))
|
|
29
|
-
const db = useDb()
|
|
30
|
-
|
|
31
|
-
if (action === 'delete') return { action, ...removeMany(db, collection, ids) }
|
|
32
|
-
if (action === 'duplicate') {
|
|
33
|
-
const created = await duplicateMany(db, collection, ids)
|
|
34
|
-
return { action, count: created.length, ids: created.map((r) => r.id as number) }
|
|
35
|
-
}
|
|
36
|
-
return { action, ...setStatusMany(db, collection, ids, action === 'publish' ? 'published' : 'draft') }
|
|
37
|
-
})
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler((event) => {
|
|
2
|
-
const collection = requireCollection(event)
|
|
3
|
-
const query = getQuery(event)
|
|
4
|
-
const db = useDb()
|
|
5
|
-
const publishedOnly = publishedOnlyForScope(event.context.readScope)
|
|
6
|
-
// Keyed on the ROLE, not the read scope: the renderer reads published-only too, but it produces the
|
|
7
|
-
// static site and must still see every relation the output embeds. Only a visitor the guard scopes to
|
|
8
|
-
// the public collection set is barred from reaching further through a populated relation. Fail-CLOSED
|
|
9
|
-
// on a missing principal, like `publishedOnlyForScope` above — an absent one is a guard regression.
|
|
10
|
-
const publicOnly = (event.context.principal?.role ?? 'anonymous') === 'anonymous'
|
|
11
|
-
|
|
12
|
-
if (collection.def.mode === 'single') {
|
|
13
|
-
return getSingleton(db, collection, query.locale as string | undefined, publishedOnly, query.depth ? Number(query.depth) : 0, publicOnly)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
return list(db, collection, {
|
|
17
|
-
locale: query.locale as string | undefined,
|
|
18
|
-
sort: query.sort as string | undefined,
|
|
19
|
-
page: query.page ? Number(query.page) : undefined,
|
|
20
|
-
perPage: query.perPage ? Number(query.perPage) : undefined,
|
|
21
|
-
filter: parseFilter(query as Record<string, unknown>),
|
|
22
|
-
depth: query.depth ? Number(query.depth) : 0,
|
|
23
|
-
}, publishedOnly, publicOnly)
|
|
24
|
-
})
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler(async (event) => {
|
|
2
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
-
const collection = requireCollection(event)
|
|
4
|
-
const body = await readBody(event)
|
|
5
|
-
const row = create(useDb(), collection, body)
|
|
6
|
-
setResponseStatus(event, 201)
|
|
7
|
-
return row
|
|
8
|
-
})
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } 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 { putSingleton } from '../../utils/crud'
|
|
8
|
-
import { requireCollection } from '../../utils/http'
|
|
9
|
-
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
-
import { clearWriteListeners } from '../../utils/write-events'
|
|
11
|
-
import { registerWriteEffect, clearWriteEffects, runWriteEffects } from '../../utils/write-effects'
|
|
12
|
-
import { desiredSchema } from '../../schema/desired'
|
|
13
|
-
import { diffSchema } from '../../schema/diff'
|
|
14
|
-
import { renderSqlite } from '../../schema/render-sqlite'
|
|
15
|
-
|
|
16
|
-
const settings = buildCollection(defineCollection({
|
|
17
|
-
name: 'settings', mode: 'single',
|
|
18
|
-
fields: { title: { type: 'text', required: true } },
|
|
19
|
-
}))
|
|
20
|
-
|
|
21
|
-
interface FakeEvent { query: Record<string, unknown>; body: unknown; context: { params: Record<string, string> } }
|
|
22
|
-
|
|
23
|
-
let db: ReturnType<typeof drizzle>
|
|
24
|
-
|
|
25
|
-
// Bind the auto-imported helpers to the REAL implementations, so this proves the actual wiring rather
|
|
26
|
-
// than a stub (same rationale as options.get.test.ts).
|
|
27
|
-
Object.assign(globalThis, {
|
|
28
|
-
defineEventHandler: (handler: unknown) => handler,
|
|
29
|
-
createError,
|
|
30
|
-
getQuery: (event: FakeEvent) => event.query,
|
|
31
|
-
readBody: (event: FakeEvent) => Promise.resolve(event.body),
|
|
32
|
-
useDb: () => db,
|
|
33
|
-
requireAdmin: () => {},
|
|
34
|
-
requireCollection,
|
|
35
|
-
readIfUnmodifiedSince: () => undefined,
|
|
36
|
-
putSingleton,
|
|
37
|
-
runWriteEffects,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
const handler = (await import('./index.put')).default as unknown as (event: FakeEvent) => Promise<Record<string, unknown>>
|
|
41
|
-
const put = (collection: string, body: unknown) => handler({ query: {}, body, context: { params: { collection } } })
|
|
42
|
-
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
const sqlite = new Database(':memory:')
|
|
45
|
-
for (const stmt of renderSqlite(diffSchema(desiredSchema([settings.table]), {}))) sqlite.exec(stmt)
|
|
46
|
-
db = drizzle(sqlite)
|
|
47
|
-
clearRegistry()
|
|
48
|
-
registerCollection(settings)
|
|
49
|
-
clearWriteListeners()
|
|
50
|
-
clearWriteEffects()
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
describe('PUT /api/[collection] — singleton save', () => {
|
|
54
|
-
it('writes the row and returns it', async () => {
|
|
55
|
-
await expect(put('settings', { title: 'Hello' })).resolves.toMatchObject({ title: 'Hello' })
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('runs post-write effects with the saved row', async () => {
|
|
59
|
-
const seen: unknown[] = []
|
|
60
|
-
registerWriteEffect((e) => { seen.push([e.def.name, e.row.title]) })
|
|
61
|
-
await put('settings', { title: 'Hello' })
|
|
62
|
-
expect(seen).toEqual([['settings', 'Hello']])
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('fails the save when an effect rejects — a stale side effect must not report success', async () => {
|
|
66
|
-
registerWriteEffect(() => { throw createError({ statusCode: 500, statusMessage: 'artifact is stale' }) })
|
|
67
|
-
await expect(put('settings', { title: 'Hello' })).rejects.toMatchObject({ statusCode: 500, statusMessage: 'artifact is stale' })
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('does not run effects when validation rejects the body', async () => {
|
|
71
|
-
let ran = false
|
|
72
|
-
registerWriteEffect(() => { ran = true })
|
|
73
|
-
await expect(put('settings', { title: '' })).rejects.toMatchObject({ statusCode: 400 })
|
|
74
|
-
expect(ran).toBe(false)
|
|
75
|
-
})
|
|
76
|
-
})
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler(async (event) => {
|
|
2
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
-
const collection = requireCollection(event)
|
|
4
|
-
const query = getQuery(event)
|
|
5
|
-
const body = await readBody(event)
|
|
6
|
-
const saved = putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
|
|
7
|
-
// Awaited, and a rejection is the save's response: a singleton whose write has an external side effect
|
|
8
|
-
// (the redirects artifact the edge serves) is not really saved until that side effect landed, and the
|
|
9
|
-
// write-event bus swallows throws by design. See write-effects.ts.
|
|
10
|
-
try {
|
|
11
|
-
await runWriteEffects(collection.def, saved)
|
|
12
|
-
} catch (error) {
|
|
13
|
-
// The row IS committed and its `updatedAt` bumped, but the client only rebaselines on success — so
|
|
14
|
-
// hand the new baseline back with the error, or the retry these failures ask for would 409 on a
|
|
15
|
-
// precondition that is stale by construction.
|
|
16
|
-
const err = error as { statusCode?: number; statusMessage?: string; data?: Record<string, unknown> }
|
|
17
|
-
throw createError({
|
|
18
|
-
statusCode: err.statusCode ?? 500,
|
|
19
|
-
statusMessage: err.statusMessage ?? 'The record was saved but a follow-up step failed',
|
|
20
|
-
data: { ...(err.data ?? {}), savedUpdatedAt: new Date(saved.updatedAt as string | number).getTime() },
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
|
-
return saved
|
|
24
|
-
})
|
|
@@ -1,60 +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 } from '../../utils/crud'
|
|
8
|
-
import { requireCollection, parseIdList } from '../../utils/http'
|
|
9
|
-
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
-
import { pickerOptions } from '../../utils/picker'
|
|
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
|
-
|
|
20
|
-
interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string>; readScope?: string } }
|
|
21
|
-
|
|
22
|
-
let db: ReturnType<typeof drizzle>
|
|
23
|
-
|
|
24
|
-
// Same rationale as translations.get.test.ts: bind the handler's auto-imported helpers to the REAL
|
|
25
|
-
// implementations so this proves the actual wiring, not a stub the server does not have.
|
|
26
|
-
Object.assign(globalThis, {
|
|
27
|
-
defineEventHandler: (handler: unknown) => handler,
|
|
28
|
-
createError,
|
|
29
|
-
getQuery: (event: FakeEvent) => event.query,
|
|
30
|
-
useDb: () => db,
|
|
31
|
-
requireCollection,
|
|
32
|
-
parseIdList,
|
|
33
|
-
pickerOptions,
|
|
34
|
-
publishedOnlyForScope: () => false,
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
const handler = (await import('./options.get')).default as unknown as (event: FakeEvent) => ReturnType<typeof pickerOptions>
|
|
38
|
-
const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
const sqlite = new Database(':memory:')
|
|
42
|
-
for (const stmt of renderSqlite(diffSchema(desiredSchema([posts.table]), {}))) sqlite.exec(stmt)
|
|
43
|
-
db = drizzle(sqlite)
|
|
44
|
-
clearRegistry()
|
|
45
|
-
registerCollection(posts)
|
|
46
|
-
})
|
|
47
|
-
afterEach(() => clearRegistry())
|
|
48
|
-
|
|
49
|
-
describe('GET /api/{collection}/options?ids=', () => {
|
|
50
|
-
it('resolves more than 100 ids in one request instead of truncating', () => {
|
|
51
|
-
const ids = Array.from({ length: 150 }, (_, i) => (create(db, posts, { title: `T${i}` }) as { id: number }).id)
|
|
52
|
-
const r = get('posts', { ids: ids.join(',') })
|
|
53
|
-
expect(r.data.length).toBe(150)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('400s (never silently truncates) an ids list over the shared bulk cap', () => {
|
|
57
|
-
const ids = Array.from({ length: 501 }, (_, i) => i + 1)
|
|
58
|
-
expect(() => get('posts', { ids: ids.join(',') })).toThrowError(expect.objectContaining({ statusCode: 400 }))
|
|
59
|
-
})
|
|
60
|
-
})
|
|
@@ -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
|
-
})
|