@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,139 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import { createError } from 'h3'
|
|
3
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
4
|
-
import { buildKey } from './naming'
|
|
5
|
-
import { derivativeKey, type DerivativeManifest } from './record'
|
|
6
|
-
import { media } from '../collections/media'
|
|
7
|
-
import { emitMediaWrite } from './media-write'
|
|
8
|
-
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
9
|
-
import { withLock, mediaLockKey } from '../../../core/server/utils/key-lock'
|
|
10
|
-
|
|
11
|
-
export interface RelocatePlan {
|
|
12
|
-
storageKey: string
|
|
13
|
-
derivatives: DerivativeManifest
|
|
14
|
-
copies: { src: string; dst: string }[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Pure: compute the new storageKey, the rewritten derivative manifest, and the list of object
|
|
18
|
-
* copies (original + each derivative) for moving/renaming a media row to `folder`/`filename`. */
|
|
19
|
-
export function planObjectRelocation(
|
|
20
|
-
row: { storageKey: string; derivatives: DerivativeManifest | null },
|
|
21
|
-
folder: string,
|
|
22
|
-
filename: string,
|
|
23
|
-
): RelocatePlan {
|
|
24
|
-
const storageKey = buildKey(folder, filename)
|
|
25
|
-
const derivatives: DerivativeManifest = {}
|
|
26
|
-
const copies: { src: string; dst: string }[] = [{ src: row.storageKey, dst: storageKey }]
|
|
27
|
-
for (const [manifestKey, entry] of Object.entries(row.derivatives ?? {})) {
|
|
28
|
-
// manifestKey is `<name>.<format>`; variant names are `[A-Za-z0-9_-]` (no dots), so the LAST dot
|
|
29
|
-
// splits name from format cleanly — rebuild the destination key from the name, not the (now-absent
|
|
30
|
-
// from the key scheme) width.
|
|
31
|
-
const dot = manifestKey.lastIndexOf('.')
|
|
32
|
-
const name = dot > 0 ? manifestKey.slice(0, dot) : manifestKey
|
|
33
|
-
const format = dot > 0 ? manifestKey.slice(dot + 1) : (entry.mime.split('/').pop() ?? 'bin')
|
|
34
|
-
const dst = derivativeKey(storageKey, name, format)
|
|
35
|
-
derivatives[manifestKey] = { ...entry, key: dst }
|
|
36
|
-
copies.push({ src: entry.key, dst })
|
|
37
|
-
}
|
|
38
|
-
return { storageKey, derivatives, copies }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface MediaRowLite { id: number; storageKey: string; folder: string | null; filename: string; derivatives: DerivativeManifest | null }
|
|
42
|
-
|
|
43
|
-
function mediaCols() { return getTableColumns(media) as Record<string, never> }
|
|
44
|
-
|
|
45
|
-
/** Reject if any destination object already exists on disk (e.g. another media's derivative, which
|
|
46
|
-
* carries no `storageKey` row, so the DB clash check misses it). Keys the plan itself vacates are
|
|
47
|
-
* excluded so a plain move never collides with its own source. */
|
|
48
|
-
async function assertDestKeysFree(driver: StorageDriver, plan: RelocatePlan): Promise<void> {
|
|
49
|
-
const sources = new Set(plan.copies.map((c) => c.src))
|
|
50
|
-
for (const c of plan.copies) {
|
|
51
|
-
if (sources.has(c.dst)) continue
|
|
52
|
-
if (await driver.exists?.(c.dst)) {
|
|
53
|
-
throw createError({ statusCode: 409, statusMessage: `A file already exists at ${c.dst}` })
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Move and/or rename a media object (+ its derivatives). Pre-checks the destination key (409 if
|
|
59
|
-
* taken), copies to the new keys, updates the row, then deletes the old keys; on a DB failure the
|
|
60
|
-
* freshly-copied objects are removed so the source is never lost. No-op if the target equals the
|
|
61
|
-
* current key. */
|
|
62
|
-
export async function relocateMedia(
|
|
63
|
-
db: BetterSQLite3Database, driver: StorageDriver, id: number,
|
|
64
|
-
target: { folder?: string; filename?: string },
|
|
65
|
-
): Promise<void> {
|
|
66
|
-
const cols = mediaCols()
|
|
67
|
-
const row = db.select().from(media).where(eq(cols.id, id)).get() as MediaRowLite | undefined
|
|
68
|
-
if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
|
|
69
|
-
|
|
70
|
-
const folder = target.folder ?? row.folder ?? ''
|
|
71
|
-
const filename = target.filename ?? row.filename
|
|
72
|
-
const plan = planObjectRelocation(row, folder, filename)
|
|
73
|
-
if (plan.storageKey === row.storageKey) return
|
|
74
|
-
|
|
75
|
-
// Same lock the upload path holds across its own exists-check → put → insert (mediaLockKey, keyed on
|
|
76
|
-
// the DESTINATION key only): without it, a concurrent upload or another relocate targeting this same
|
|
77
|
-
// key can observe it free mid-way through this one and land on top of it — or, two relocates racing
|
|
78
|
-
// for the same destination, have the loser's failure-compensation delete the WINNER's just-landed blob.
|
|
79
|
-
await withLock(mediaLockKey(plan.storageKey), async () => {
|
|
80
|
-
const clash = db.select({ id: cols.id }).from(media).where(eq(cols.storageKey, plan.storageKey)).get() as { id: number } | undefined
|
|
81
|
-
if (clash) throw createError({ statusCode: 409, statusMessage: `A file already exists at ${plan.storageKey}` })
|
|
82
|
-
await assertDestKeysFree(driver, plan)
|
|
83
|
-
|
|
84
|
-
for (const c of plan.copies) await driver.copy(c.src, c.dst)
|
|
85
|
-
try {
|
|
86
|
-
db.update(media).set({ storageKey: plan.storageKey, folder: folder || null, filename, derivatives: plan.derivatives, updatedAt: new Date() }).where(eq(cols.id, id)).run()
|
|
87
|
-
} catch (error) {
|
|
88
|
-
for (const c of plan.copies) await driver.delete(c.dst)
|
|
89
|
-
throw error
|
|
90
|
-
}
|
|
91
|
-
for (const c of plan.copies) await driver.delete(c.src)
|
|
92
|
-
})
|
|
93
|
-
// The public URL is built from storageKey — moving/renaming changes it, so embedding pages must re-render.
|
|
94
|
-
emitMediaWrite({ id }, { id })
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
interface MediaRowFull extends MediaRowLite {
|
|
98
|
-
mime: string; ext: string; size: number; width: number | null; height: number | null
|
|
99
|
-
checksum: string | null; thumbhash: string | null
|
|
100
|
-
translations: Record<string, unknown> | null
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Duplicate a media object (+ derivatives) to a new key and insert a new row (same bytes →
|
|
104
|
-
* same checksum/dims/translations). Rejects 409 if the target key is taken. */
|
|
105
|
-
export async function duplicateMedia(
|
|
106
|
-
db: BetterSQLite3Database, driver: StorageDriver, id: number,
|
|
107
|
-
target: { folder?: string; filename?: string },
|
|
108
|
-
): Promise<{ id: number }> {
|
|
109
|
-
const cols = mediaCols()
|
|
110
|
-
const row = db.select().from(media).where(eq(cols.id, id)).get() as MediaRowFull | undefined
|
|
111
|
-
if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
|
|
112
|
-
|
|
113
|
-
const folder = target.folder ?? row.folder ?? ''
|
|
114
|
-
const filename = target.filename ?? row.filename
|
|
115
|
-
const plan = planObjectRelocation(row, folder, filename)
|
|
116
|
-
|
|
117
|
-
// See relocateMedia: same destination-key lock the upload path holds, so a concurrent upload/relocate
|
|
118
|
-
// targeting this exact key can't observe it free mid-way through this copy+insert.
|
|
119
|
-
return withLock(mediaLockKey(plan.storageKey), async () => {
|
|
120
|
-
const clash = db.select({ id: cols.id }).from(media).where(eq(cols.storageKey, plan.storageKey)).get() as { id: number } | undefined
|
|
121
|
-
if (clash) throw createError({ statusCode: 409, statusMessage: `A file already exists at ${plan.storageKey}` })
|
|
122
|
-
await assertDestKeysFree(driver, plan)
|
|
123
|
-
|
|
124
|
-
for (const c of plan.copies) await driver.copy(c.src, c.dst)
|
|
125
|
-
try {
|
|
126
|
-
const created = db.insert(media).values({
|
|
127
|
-
storageKey: plan.storageKey, folder: folder || null, filename,
|
|
128
|
-
mime: row.mime, ext: row.ext, size: row.size, width: row.width, height: row.height,
|
|
129
|
-
checksum: row.checksum, thumbhash: row.thumbhash, derivatives: plan.derivatives,
|
|
130
|
-
translations: row.translations ?? {},
|
|
131
|
-
} as never).returning({ id: cols.id }).get() as { id: number }
|
|
132
|
-
emitMediaWrite(null, { id: created.id }) // new media row → re-render listings
|
|
133
|
-
return created
|
|
134
|
-
} catch (error) {
|
|
135
|
-
for (const c of plan.copies) await driver.delete(c.dst)
|
|
136
|
-
throw error
|
|
137
|
-
}
|
|
138
|
-
})
|
|
139
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export type LocaleFields = Record<string, unknown>
|
|
2
|
-
export type Translations = Record<string, LocaleFields>
|
|
3
|
-
|
|
4
|
-
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
5
|
-
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
6
|
-
|
|
7
|
-
/** Deep-merge a partial translations patch into the current map: new locales are added, and within a
|
|
8
|
-
* locale the patched fields are merged over the existing ones — so patching `en.alt` keeps `en.title`
|
|
9
|
-
* / `en.description`, and other locales are untouched. Defensive against malformed input: a non-object
|
|
10
|
-
* current/patch or a non-object per-locale value is ignored rather than persisting garbage. */
|
|
11
|
-
export function mergeTranslations(current: Translations | null | undefined, patch: Translations): Translations {
|
|
12
|
-
const out: Translations = isPlainObject(current) ? { ...current } : {}
|
|
13
|
-
if (!isPlainObject(patch)) return out
|
|
14
|
-
for (const [loc, fields] of Object.entries(patch)) {
|
|
15
|
-
if (!isPlainObject(fields)) continue
|
|
16
|
-
out[loc] = { ...(isPlainObject(out[loc]) ? out[loc] : {}), ...fields }
|
|
17
|
-
}
|
|
18
|
-
return out
|
|
19
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { sql, getTableColumns, getTableName, type SQL } from 'drizzle-orm'
|
|
2
|
-
import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
|
|
3
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
4
|
-
import { allCollections } from '../../../core/server/utils/registry'
|
|
5
|
-
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
6
|
-
import { resolveColumnName } from '../../../fields/server/field-registry/naming'
|
|
7
|
-
|
|
8
|
-
export interface MediaUsage { collection: string; recordId: number; field: string }
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Reverse lookup for a whole set of media ids at once: ONE query per (collection, column), never one per
|
|
12
|
-
* id. The JSON columns can only be searched by a full table scan, and better-sqlite3 is synchronous, so a
|
|
13
|
-
* per-id loop over a folder-sized selection would block the event loop for seconds. Every requested id
|
|
14
|
-
* gets an entry (empty when unreferenced). Mirrors `findReferrersForMany` in core's record-ref index.
|
|
15
|
-
*/
|
|
16
|
-
export function findMediaUsagesForMany(db: BetterSQLite3Database, ids: number[]): Record<number, MediaUsage[]> {
|
|
17
|
-
const out: Record<number, MediaUsage[]> = {}
|
|
18
|
-
for (const id of ids) out[id] = []
|
|
19
|
-
if (!ids.length) return out
|
|
20
|
-
// The id set travels as ONE bound parameter (a JSON array), not one placeholder per id: a recursive
|
|
21
|
-
// folder delete can hand us tens of thousands of ids, well past SQLite's 32766-variable ceiling.
|
|
22
|
-
const idSet = sql`(select value from json_each(${JSON.stringify(ids)}))`
|
|
23
|
-
|
|
24
|
-
for (const c of allCollections()) {
|
|
25
|
-
if (c.name === 'media') continue
|
|
26
|
-
const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
|
|
27
|
-
const tbl = sql.identifier(getTableName(c.table))
|
|
28
|
-
const rowId = sql.identifier(cols.id.name)
|
|
29
|
-
const collect = (query: SQL, field: string) => {
|
|
30
|
-
for (const r of db.all(query) as { rid: number; mid: number }[]) {
|
|
31
|
-
out[r.mid]?.push({ collection: c.name, recordId: r.rid, field })
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
// Media ids can nest anywhere inside a repeater/json/content/seo value — json_tree the whole column and
|
|
35
|
-
// match any integer atom (a safe over-approximation for usage warnings).
|
|
36
|
-
const scanJson = (column: string, field: string) => collect(
|
|
37
|
-
sql`select distinct t.${rowId} as rid, jt.atom as mid from ${tbl} t, json_tree(t.${sql.identifier(column)}) jt
|
|
38
|
-
where jt.type = 'integer' and jt.atom in ${idSet}`,
|
|
39
|
-
field,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
for (const [key, field] of Object.entries(c.def.fields)) {
|
|
43
|
-
if (fieldIs(field, 'media') && !field.options?.multiple) {
|
|
44
|
-
const { jsKey } = resolveColumnName(key, field)
|
|
45
|
-
const col = sql.identifier(cols[jsKey].name)
|
|
46
|
-
collect(sql`select t.${rowId} as rid, t.${col} as mid from ${tbl} t where t.${col} in ${idSet}`, key)
|
|
47
|
-
} else if (fieldIs(field, 'media') && field.options?.multiple) {
|
|
48
|
-
const col = sql.identifier(cols[key].name)
|
|
49
|
-
collect(sql`select distinct t.${rowId} as rid, je.value as mid from ${tbl} t, json_each(t.${col}) je
|
|
50
|
-
where je.value in ${idSet}`, key)
|
|
51
|
-
} else if (field.type === 'repeater' || field.type === 'json') {
|
|
52
|
-
const { jsKey } = resolveColumnName(key, field)
|
|
53
|
-
scanJson(cols[jsKey].name, key)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
if (c.def.blocks?.enabled) scanJson('content', 'content')
|
|
57
|
-
// The `seo` system column carries the social-share image, so a page whose only reference to an asset is
|
|
58
|
-
// its og:image must still be reported — otherwise deleting it silently 404s the published meta tag.
|
|
59
|
-
if (c.def.seo) scanJson('seo', 'seo.image')
|
|
60
|
-
}
|
|
61
|
-
return out
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function findMediaUsages(db: BetterSQLite3Database, id: number): MediaUsage[] {
|
|
65
|
-
return findMediaUsagesForMany(db, [id])[id]!
|
|
66
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
-
import type { ResolvedVariant } from '../../../core/server/utils/kestrel-config'
|
|
4
|
-
import { mediaSettings } from '../collections/media-settings'
|
|
5
|
-
import { reconcileVariants, type StoredVariant } from './variants'
|
|
6
|
-
|
|
7
|
-
// Auto-discovery accumulator. `KestrelImg`/`useMediaVariant` stash their concrete specs on the render's
|
|
8
|
-
// `event.context.kestrelVariants`; a `beforeResponse` hook (only for publish renders, gated on the
|
|
9
|
-
// `x-kestrel-publish` header) drains them into this module Map, keyed by name and unioning formats across
|
|
10
|
-
// usages. At the end of a FULL publish / generate `saveDiscoveredVariants` reconciles the union into the
|
|
11
|
-
// registry and clears it — so the registry reflects exactly what the last full render actually used.
|
|
12
|
-
const discovered = new Map<string, ResolvedVariant>()
|
|
13
|
-
|
|
14
|
-
/** Merge freshly-declared specs into the accumulator (union formats for same-named variants). */
|
|
15
|
-
export function recordVariants(specs: readonly ResolvedVariant[]): void {
|
|
16
|
-
for (const s of specs) {
|
|
17
|
-
if (!s?.name) continue
|
|
18
|
-
const prev = discovered.get(s.name)
|
|
19
|
-
if (prev) prev.formats = [...new Set([...prev.formats, ...(s.formats ?? [])])]
|
|
20
|
-
else discovered.set(s.name, { ...s, formats: [...(s.formats ?? ['webp'])] })
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function collectVariants(): ResolvedVariant[] {
|
|
25
|
-
return [...discovered.values()]
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function clearVariants(): void {
|
|
29
|
-
discovered.clear()
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Reconcile the accumulated discovered specs into the `media_settings` registry (scan entries replaced,
|
|
34
|
-
* manual/pinned kept) and clear the accumulator. Called at the end of a full publish / generate.
|
|
35
|
-
*
|
|
36
|
-
* Safety: if NOTHING was discovered this run, leave the registry untouched rather than wiping every scan
|
|
37
|
-
* entry — a zero result almost always means the capture didn't fire (no KestrelImg rendered, or a plumbing
|
|
38
|
-
* gap), not that the site genuinely uses no variants. Narrowing still works whenever ≥1 variant is found.
|
|
39
|
-
*
|
|
40
|
-
* `{ clear: false }` reconciles WITHOUT draining the accumulator — the classic `nuxt generate` topology has
|
|
41
|
-
* no publisher to run this once at the end, so it reconciles after each prerendered route and the growing
|
|
42
|
-
* accumulator converges to the full used set by the last route.
|
|
43
|
-
*/
|
|
44
|
-
export function saveDiscoveredVariants(db: BetterSQLite3Database, opts: { clear?: boolean } = {}): void {
|
|
45
|
-
const found = collectVariants()
|
|
46
|
-
if (!found.length) { if (opts.clear !== false) clearVariants(); return }
|
|
47
|
-
const cols = getTableColumns(mediaSettings) as Record<string, never>
|
|
48
|
-
try {
|
|
49
|
-
// The registry is a read-modify-write, and a `nuxt generate` can run against the SAME DB as a live
|
|
50
|
-
// server's publisher — an unguarded select→reconcile→update loses one side's variants. An IMMEDIATE
|
|
51
|
-
// transaction takes SQLite's write lock up front, so a concurrent writer WAITS (busy_timeout) and then
|
|
52
|
-
// reconciles against the already-updated row instead of clobbering it (cross-process safe).
|
|
53
|
-
db.transaction((tx) => {
|
|
54
|
-
const row = tx.select().from(mediaSettings).where(eq(cols.singletonKey, 'media_settings')).get() as
|
|
55
|
-
| { variants?: StoredVariant[] | null }
|
|
56
|
-
| undefined
|
|
57
|
-
// The json field type's column default is the OBJECT shape '{}' (not '[]') — a generic singleton
|
|
58
|
-
// PUT that omits `variants` leaves exactly that, so nullish-only guarding isn't enough here.
|
|
59
|
-
const next = reconcileVariants(Array.isArray(row?.variants) ? row.variants : [], found)
|
|
60
|
-
if (row) tx.update(mediaSettings).set({ variants: next, updatedAt: new Date() } as never).where(eq(cols.singletonKey, 'media_settings')).run()
|
|
61
|
-
else tx.insert(mediaSettings).values({ singletonKey: 'media_settings', variants: next } as never).run()
|
|
62
|
-
}, { behavior: 'immediate' })
|
|
63
|
-
// Drain the accumulator only AFTER a durable write, so a transient failure keeps it for the next pass.
|
|
64
|
-
if (opts.clear !== false) clearVariants()
|
|
65
|
-
} catch (error) {
|
|
66
|
-
// media_settings not migrated yet — skip persisting the registry (narrowing stays off, config fallback
|
|
67
|
-
// applies) rather than crash the publish. See the schema-drift note; run `drizzle-kit generate`.
|
|
68
|
-
console.warn('[kestrel] media_settings registry not available — variant discovery not persisted:', (error as Error)?.message ?? error)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
-
import type { ResolvedVariant, VariantFit } from '../../../core/server/utils/kestrel-config'
|
|
4
|
-
import { mediaSettings } from '../collections/media-settings'
|
|
5
|
-
|
|
6
|
-
const VARIANT_FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
|
|
7
|
-
// sharp's crop gravity/position values (resize({fit:'cover'|'contain', position})).
|
|
8
|
-
const SHARP_POSITIONS = new Set([
|
|
9
|
-
'top', 'right top', 'right', 'right bottom', 'bottom', 'left bottom', 'left', 'left top', 'centre', 'center',
|
|
10
|
-
'north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest', 'entropy', 'attention',
|
|
11
|
-
])
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* A variant persisted in the `media_settings` registry: a resolved variant plus its provenance. `source`
|
|
15
|
-
* distinguishes an auto-discovered (`'scan'`) entry from a hand-authored (`'manual'`) one; a `manual`/`pinned`
|
|
16
|
-
* entry survives the scan reconcile and wins a name collision with a scanned entry.
|
|
17
|
-
*/
|
|
18
|
-
export interface StoredVariant extends ResolvedVariant {
|
|
19
|
-
source?: 'manual' | 'scan'
|
|
20
|
-
pinned?: boolean
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const rank = (v: StoredVariant): number => (v.pinned || v.source === 'manual' ? 1 : 0)
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* The effective variant set to derive: the stored registry entries (deduped by name, a manual/pinned entry
|
|
27
|
-
* winning a scan collision, stripped of provenance and re-defaulted defensively), or `fallback` (the resolved
|
|
28
|
-
* config policy variants) when the registry is empty/absent. `presets` are config-authored named variants
|
|
29
|
-
* (`image.variants`): explicit, name-referenced declarations that are never scan-discovered, so they stay
|
|
30
|
-
* active through usage-driven narrowing (unioned into a non-empty registry, winning any name collision).
|
|
31
|
-
*
|
|
32
|
-
* `fromRegistry` is the verdict a DELETING caller (the backfill prune) must gate on: true only when at least
|
|
33
|
-
* one stored entry SURVIVED validation, so the set really is the registered one. Absent, unread, empty and
|
|
34
|
-
* wholly-rejected registries all resolve to the same superset fallback, under which every registered
|
|
35
|
-
* derivative looks deregistered — the row count alone cannot tell those apart from a narrowed set.
|
|
36
|
-
* Pure — the load-bearing logic, unit-tested without a DB.
|
|
37
|
-
*/
|
|
38
|
-
export function resolveActiveVariants(
|
|
39
|
-
stored: StoredVariant[] | null | undefined, fallback: ResolvedVariant[], presets: ResolvedVariant[] = [],
|
|
40
|
-
): { specs: ResolvedVariant[]; fromRegistry: boolean } {
|
|
41
|
-
// A registry row is hand-authorable via the media_settings JSON PATCH; reject a name outside the
|
|
42
|
-
// derivative-key charset ([A-Za-z0-9_-]) — an out-of-charset char breaks the URL / the prune-media
|
|
43
|
-
// referencedKeys regex (its derivative is pruned though pages reference it) and a `/` nests a pseudo-folder.
|
|
44
|
-
const safeName = (n: unknown): n is string => typeof n === 'string' && /^[A-Za-z0-9_-]+$/.test(n)
|
|
45
|
-
const list = (Array.isArray(stored) ? stored : []).filter(
|
|
46
|
-
(v): v is StoredVariant => !!v && safeName(v.name) && Number.isFinite(v.width) && v.width >= 1,
|
|
47
|
-
)
|
|
48
|
-
// Empty registry ⇒ the fallback already contains the presets (resolveVariants unions them), so return it as-is.
|
|
49
|
-
if (!list.length) return { specs: fallback, fromRegistry: false }
|
|
50
|
-
const byName = new Map<string, StoredVariant>()
|
|
51
|
-
for (const v of list) {
|
|
52
|
-
const prev = byName.get(v.name)
|
|
53
|
-
if (!prev || rank(v) > rank(prev)) byName.set(v.name, v)
|
|
54
|
-
}
|
|
55
|
-
for (const p of presets) {
|
|
56
|
-
if (p && typeof p.name === 'string' && p.name !== '' && Number.isFinite(p.width) && p.width >= 1) byName.set(p.name, { ...p, source: 'manual' })
|
|
57
|
-
}
|
|
58
|
-
const specs = [...byName.values()].map((v): ResolvedVariant => ({
|
|
59
|
-
name: v.name,
|
|
60
|
-
width: Math.floor(v.width),
|
|
61
|
-
// coerce a garbage/stale height to null so it never reaches sharp's crop resize and 500s the upload
|
|
62
|
-
height: typeof v.height === 'number' && Number.isFinite(v.height) && v.height >= 1 ? Math.floor(v.height) : null,
|
|
63
|
-
// A hand-authored (media_settings PATCH) fit/position outside sharp's enum would otherwise reach
|
|
64
|
-
// sharp.resize() verbatim and throw for EVERY upload that derives this variant — allow-list, don't
|
|
65
|
-
// just default the nullish case.
|
|
66
|
-
fit: v.fit && VARIANT_FITS.has(v.fit) ? v.fit : 'cover',
|
|
67
|
-
position: typeof v.position === 'string' && SHARP_POSITIONS.has(v.position) ? v.position : 'centre',
|
|
68
|
-
formats: v.formats?.length ? v.formats : ['webp'],
|
|
69
|
-
}))
|
|
70
|
-
return { specs, fromRegistry: true }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Reconcile a fresh scan into the stored registry: every hand-authored entry (`source:'manual'` or
|
|
75
|
-
* `pinned`) is KEPT, and the discovered specs replace ALL prior `source:'scan'` entries (so a variant whose
|
|
76
|
-
* last `KestrelImg` usage was removed disappears — usage-driven narrowing). A manual/pinned entry wins a
|
|
77
|
-
* name collision with a discovered one. Pure — the load-bearing reconcile, unit-tested without a DB.
|
|
78
|
-
*/
|
|
79
|
-
export function reconcileVariants(existing: StoredVariant[], discovered: ResolvedVariant[]): StoredVariant[] {
|
|
80
|
-
const out: StoredVariant[] = []
|
|
81
|
-
const seen = new Set<string>()
|
|
82
|
-
// Belt-and-braces against the json field type's object-shaped default ('{}', not '[]') reaching here
|
|
83
|
-
// directly — mirrors the Array.isArray guard in resolveActiveVariants.
|
|
84
|
-
for (const e of (Array.isArray(existing) ? existing : [])) {
|
|
85
|
-
if ((e.source === 'manual' || e.pinned) && e.name && !seen.has(e.name)) { out.push(e); seen.add(e.name) }
|
|
86
|
-
}
|
|
87
|
-
for (const d of discovered) {
|
|
88
|
-
if (!d.name || seen.has(d.name)) continue
|
|
89
|
-
out.push({ ...d, source: 'scan' })
|
|
90
|
-
seen.add(d.name)
|
|
91
|
-
}
|
|
92
|
-
return out
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The raw stored registry rows (the `media_settings` singleton), or `null` when there are none to resolve
|
|
97
|
-
* from — the read threw (media_settings not migrated) or nothing is stored. Whether the resolved set may be
|
|
98
|
-
* DELETED against is not decidable here: that is `resolveActiveVariants`' `fromRegistry` verdict, which only
|
|
99
|
-
* a validation pass over these rows can give.
|
|
100
|
-
*/
|
|
101
|
-
export function readVariantRegistry(db: BetterSQLite3Database): StoredVariant[] | null {
|
|
102
|
-
const cols = getTableColumns(mediaSettings) as Record<string, never>
|
|
103
|
-
let row: { variants?: StoredVariant[] | null } | undefined
|
|
104
|
-
try {
|
|
105
|
-
row = db.select().from(mediaSettings).where(eq(cols.singletonKey, 'media_settings')).get() as
|
|
106
|
-
| { variants?: StoredVariant[] | null }
|
|
107
|
-
| undefined
|
|
108
|
-
} catch {
|
|
109
|
-
return null
|
|
110
|
-
}
|
|
111
|
-
const stored = row?.variants
|
|
112
|
-
return Array.isArray(stored) ? stored : null
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Resolves the active variant set for the upload path (narrow generation: registered set + presets).
|
|
117
|
-
* Deliberately forgiving: an unreadable registry degrades to the config fallback rather than 500 the
|
|
118
|
-
* upload (mirrors attachDeadRefs tolerating a missing record_refs). Discards `fromRegistry` — never use
|
|
119
|
-
* this to decide what may be pruned; a deleting caller needs `resolveActiveVariants` directly.
|
|
120
|
-
*/
|
|
121
|
-
export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVariant[], presets: ResolvedVariant[] = []): ResolvedVariant[] {
|
|
122
|
-
return resolveActiveVariants(readVariantRegistry(db), fallback, presets).specs
|
|
123
|
-
}
|