@beechcms/core 0.8.0 → 0.9.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/dist/auth/account-email.d.ts +3 -0
- package/dist/auth/account-email.d.ts.map +1 -0
- package/dist/auth/account-email.js +9 -0
- package/dist/auth/password-reset-token.repository.d.ts +13 -2
- package/dist/auth/password-reset-token.repository.d.ts.map +1 -1
- package/dist/auth/user.repository.d.ts +69 -4
- package/dist/auth/user.repository.d.ts.map +1 -1
- package/dist/automations-grammar-words.d.ts +2 -0
- package/dist/automations-grammar-words.d.ts.map +1 -0
- package/dist/automations-grammar-words.js +8 -0
- package/dist/automations.repository.interface.d.ts +19 -0
- package/dist/automations.repository.interface.d.ts.map +1 -0
- package/dist/automations.repository.interface.js +3 -0
- package/dist/automations.runner.interface.d.ts +10 -0
- package/dist/automations.runner.interface.d.ts.map +1 -0
- package/dist/automations.runner.interface.js +3 -0
- package/dist/automations.runner.stub.d.ts +5 -0
- package/dist/automations.runner.stub.d.ts.map +1 -0
- package/dist/automations.runner.stub.js +7 -0
- package/dist/automations.types.d.ts +75 -0
- package/dist/automations.types.d.ts.map +1 -0
- package/dist/automations.types.js +3 -0
- package/dist/clock.d.ts +25 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +11 -0
- package/dist/common/canonical-json.d.ts +31 -0
- package/dist/common/canonical-json.d.ts.map +1 -0
- package/dist/common/canonical-json.js +91 -0
- package/dist/content/content-scan.repository.d.ts +1 -1
- package/dist/content/content.repository.d.ts +154 -3
- package/dist/content/content.repository.d.ts.map +1 -1
- package/dist/content/content.repository.js +62 -0
- package/dist/content/deletion-ledger.d.ts +39 -0
- package/dist/content/deletion-ledger.d.ts.map +1 -0
- package/dist/content/deletion-ledger.js +3 -0
- package/dist/content/seed-media-purge.repository.d.ts +34 -0
- package/dist/content/seed-media-purge.repository.d.ts.map +1 -0
- package/dist/content/seed-media-purge.repository.js +3 -0
- package/dist/content/seed.repository.d.ts +14 -0
- package/dist/content/seed.repository.d.ts.map +1 -1
- package/dist/content-scan.repository.d.ts +9 -0
- package/dist/content-scan.repository.d.ts.map +1 -0
- package/dist/content-scan.repository.js +3 -0
- package/dist/content.repository.d.ts +247 -0
- package/dist/content.repository.d.ts.map +1 -0
- package/dist/content.repository.js +59 -0
- package/dist/dashboard-layout/content-view.d.ts +757 -0
- package/dist/dashboard-layout/content-view.d.ts.map +1 -0
- package/dist/dashboard-layout/content-view.js +285 -0
- package/dist/dashboard-layout/content-view.repository.d.ts +39 -0
- package/dist/dashboard-layout/content-view.repository.d.ts.map +1 -0
- package/dist/dashboard-layout/content-view.repository.js +3 -0
- package/dist/dashboard-layout/dashboard-layout.d.ts +5 -0
- package/dist/dashboard-layout/dashboard-layout.d.ts.map +1 -1
- package/dist/dashboard-layout/dashboard-layout.js +17 -9
- package/dist/dashboard-layout/kanban/kanban.d.ts +32 -18
- package/dist/dashboard-layout/kanban/kanban.d.ts.map +1 -1
- package/dist/dashboard-layout/kanban/kanban.js +45 -21
- package/dist/dashboard-layout/seed-layout.d.ts +22 -31
- package/dist/dashboard-layout/seed-layout.d.ts.map +1 -1
- package/dist/dashboard-layout/seed-layout.js +93 -18
- package/dist/dashboard-layout/seed-layout.repository.d.ts +1 -5
- package/dist/dashboard-layout/seed-layout.repository.d.ts.map +1 -1
- package/dist/dashboard-layout.d.ts +154 -0
- package/dist/dashboard-layout.d.ts.map +1 -0
- package/dist/dashboard-layout.js +186 -0
- package/dist/dashboard-layout.repository.d.ts +18 -0
- package/dist/dashboard-layout.repository.d.ts.map +1 -0
- package/dist/dashboard-layout.repository.js +3 -0
- package/dist/dashboard-permissions.d.ts +3 -0
- package/dist/dashboard-permissions.d.ts.map +1 -0
- package/dist/dashboard-permissions.js +11 -0
- package/dist/dashboard-scopes.d.ts +12 -0
- package/dist/dashboard-scopes.d.ts.map +1 -0
- package/dist/dashboard-scopes.js +30 -0
- package/dist/define-seed.d.ts +3 -0
- package/dist/define-seed.d.ts.map +1 -0
- package/dist/define-seed.js +5 -0
- package/dist/demo-data.repository.d.ts +5 -0
- package/dist/demo-data.repository.d.ts.map +1 -0
- package/dist/demo-data.repository.js +3 -0
- package/dist/email.provider.d.ts +32 -0
- package/dist/email.provider.d.ts.map +1 -0
- package/dist/email.provider.js +4 -0
- package/dist/engine/ddl/ddl.d.ts +247 -0
- package/dist/engine/ddl/ddl.d.ts.map +1 -0
- package/dist/engine/ddl/ddl.js +653 -0
- package/dist/engine/ddl/schema-mutator.d.ts +29 -0
- package/dist/engine/ddl/schema-mutator.d.ts.map +1 -0
- package/dist/engine/ddl/schema-mutator.js +3 -0
- package/dist/engine/ddl/seed-ddl-destructive.d.ts +40 -0
- package/dist/engine/ddl/seed-ddl-destructive.d.ts.map +1 -0
- package/dist/engine/ddl/seed-ddl-destructive.js +149 -0
- package/dist/engine/ddl/seed-ddl.d.ts +43 -0
- package/dist/engine/ddl/seed-ddl.d.ts.map +1 -0
- package/dist/engine/ddl/seed-ddl.js +115 -0
- package/dist/engine/ddl/seed-purge-guards.d.ts +7 -0
- package/dist/engine/ddl/seed-purge-guards.d.ts.map +1 -0
- package/dist/engine/ddl/seed-purge-guards.js +19 -0
- package/dist/engine/engine.d.ts +4 -3
- package/dist/engine/engine.d.ts.map +1 -1
- package/dist/engine/engine.js +4 -3
- package/dist/engine/introspection/introspection.d.ts +101 -0
- package/dist/engine/introspection/introspection.d.ts.map +1 -0
- package/dist/engine/introspection/introspection.js +127 -0
- package/dist/engine/introspection/relation-audit.d.ts +46 -0
- package/dist/engine/introspection/relation-audit.d.ts.map +1 -0
- package/dist/engine/introspection/relation-audit.js +103 -0
- package/dist/engine/introspection/schema-fingerprint.d.ts +74 -0
- package/dist/engine/introspection/schema-fingerprint.d.ts.map +1 -0
- package/dist/engine/introspection/schema-fingerprint.js +102 -0
- package/dist/engine/introspection.d.ts +101 -0
- package/dist/engine/introspection.d.ts.map +1 -0
- package/dist/engine/introspection.js +127 -0
- package/dist/engine/localization/localization.d.ts +127 -0
- package/dist/engine/localization/localization.d.ts.map +1 -0
- package/dist/engine/localization/localization.js +212 -0
- package/dist/engine/localization.d.ts +127 -0
- package/dist/engine/localization.d.ts.map +1 -0
- package/dist/engine/localization.js +212 -0
- package/dist/engine/privacy/policies.d.ts +58 -0
- package/dist/engine/privacy/policies.d.ts.map +1 -0
- package/dist/engine/privacy/policies.js +191 -0
- package/dist/engine/privacy/privacy.service.d.ts +78 -0
- package/dist/engine/privacy/privacy.service.d.ts.map +1 -0
- package/dist/engine/privacy/privacy.service.js +138 -0
- package/dist/engine/privacy.service.d.ts.map +1 -1
- package/dist/engine/query/active-clause.d.ts +19 -0
- package/dist/engine/query/active-clause.d.ts.map +1 -0
- package/dist/engine/query/active-clause.js +27 -0
- package/dist/engine/query/query.d.ts +36 -0
- package/dist/engine/query/query.d.ts.map +1 -0
- package/dist/engine/query/query.js +393 -0
- package/dist/engine/query/relations.d.ts +28 -0
- package/dist/engine/query/relations.d.ts.map +1 -0
- package/dist/engine/query/relations.js +25 -0
- package/dist/engine/query/serialize.d.ts +22 -0
- package/dist/engine/query/serialize.d.ts.map +1 -0
- package/dist/engine/query/serialize.js +175 -0
- package/dist/engine/schema-fingerprint.d.ts +73 -0
- package/dist/engine/schema-fingerprint.d.ts.map +1 -0
- package/dist/engine/schema-fingerprint.js +97 -0
- package/dist/engine/seed-registry.d.ts.map +1 -1
- package/dist/engine/seeds/candidate-ids.d.ts +14 -0
- package/dist/engine/seeds/candidate-ids.d.ts.map +1 -0
- package/dist/engine/seeds/candidate-ids.js +40 -0
- package/dist/engine/seeds/define-seed.d.ts +3 -0
- package/dist/engine/seeds/define-seed.d.ts.map +1 -0
- package/dist/engine/seeds/define-seed.js +5 -0
- package/dist/engine/seeds/seed-definition-diff.d.ts +38 -0
- package/dist/engine/seeds/seed-definition-diff.d.ts.map +1 -0
- package/dist/engine/seeds/seed-definition-diff.js +51 -0
- package/dist/engine/seeds/seed-registry.d.ts +88 -0
- package/dist/engine/seeds/seed-registry.d.ts.map +1 -0
- package/dist/engine/seeds/seed-registry.js +194 -0
- package/dist/engine/seeds/seed-types-generator.d.ts +25 -0
- package/dist/engine/seeds/seed-types-generator.d.ts.map +1 -0
- package/dist/engine/seeds/seed-types-generator.js +116 -0
- package/dist/engine/seeds/seed-validation.d.ts +35 -0
- package/dist/engine/seeds/seed-validation.d.ts.map +1 -0
- package/dist/engine/seeds/seed-validation.js +384 -0
- package/dist/engine/seeds/seeds.d.ts +20 -0
- package/dist/engine/seeds/seeds.d.ts.map +1 -0
- package/dist/engine/seeds/seeds.js +21 -0
- package/dist/engine/seeds/tag-migration.d.ts +73 -0
- package/dist/engine/seeds/tag-migration.d.ts.map +1 -0
- package/dist/engine/seeds/tag-migration.js +212 -0
- package/dist/engine/seeds/tag-options.d.ts +15 -0
- package/dist/engine/seeds/tag-options.d.ts.map +1 -0
- package/dist/engine/seeds/tag-options.js +111 -0
- package/dist/engine/seeds/tag-value.d.ts +31 -0
- package/dist/engine/seeds/tag-value.d.ts.map +1 -0
- package/dist/engine/seeds/tag-value.js +51 -0
- package/dist/engine/types.d.ts +59 -7
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/engine/validation/cache.d.ts.map +1 -1
- package/dist/engine/validation/cache.js +20 -4
- package/dist/engine/validation/emptiness.d.ts +12 -0
- package/dist/engine/validation/emptiness.d.ts.map +1 -0
- package/dist/engine/validation/emptiness.js +33 -0
- package/dist/engine/validation/index.d.ts +11 -0
- package/dist/engine/validation/index.d.ts.map +1 -1
- package/dist/engine/validation/index.js +29 -33
- package/dist/engine/validation/primitives.d.ts +12 -0
- package/dist/engine/validation/primitives.d.ts.map +1 -1
- package/dist/engine/validation/primitives.js +22 -0
- package/dist/engine/validation/richtext-sanitizer.d.ts +9 -1
- package/dist/engine/validation/richtext-sanitizer.d.ts.map +1 -1
- package/dist/engine/validation/richtext-sanitizer.js +36 -7
- package/dist/engine/validation/schema-builders.d.ts +16 -0
- package/dist/engine/validation/schema-builders.d.ts.map +1 -1
- package/dist/engine/validation/schema-builders.js +125 -18
- package/dist/engine.d.ts +144 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +741 -0
- package/dist/file-types.d.ts +13 -0
- package/dist/file-types.d.ts.map +1 -0
- package/dist/file-types.js +85 -0
- package/dist/hooks.d.ts +29 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +3 -0
- package/dist/id-generator.d.ts +26 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +12 -0
- package/dist/idempotency.repository.d.ts +13 -0
- package/dist/idempotency.repository.d.ts.map +1 -0
- package/dist/idempotency.repository.js +3 -0
- package/dist/index.d.ts +34 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -11
- package/dist/kanban-position.repository.d.ts +19 -0
- package/dist/kanban-position.repository.d.ts.map +1 -0
- package/dist/kanban-position.repository.js +3 -0
- package/dist/kanban.d.ts +75 -0
- package/dist/kanban.d.ts.map +1 -0
- package/dist/kanban.js +82 -0
- package/dist/layout-permissions.d.ts +3 -0
- package/dist/layout-permissions.d.ts.map +1 -0
- package/dist/layout-permissions.js +11 -0
- package/dist/media/file-types.d.ts +12 -0
- package/dist/media/file-types.d.ts.map +1 -1
- package/dist/media/file-types.js +24 -4
- package/dist/media/image-transformer.d.ts +34 -0
- package/dist/media/image-transformer.d.ts.map +1 -0
- package/dist/media/image-transformer.js +3 -0
- package/dist/media/magic-bytes.d.ts.map +1 -1
- package/dist/media/magic-bytes.js +19 -9
- package/dist/media/media-transform.d.ts +92 -0
- package/dist/media/media-transform.d.ts.map +1 -0
- package/dist/media/media-transform.js +197 -0
- package/dist/media/media.repository.d.ts +5 -0
- package/dist/media/media.repository.d.ts.map +1 -1
- package/dist/media.repository.d.ts +61 -0
- package/dist/media.repository.d.ts.map +1 -0
- package/dist/media.repository.js +3 -0
- package/dist/notifications/notification.repository.d.ts +11 -2
- package/dist/notifications/notification.repository.d.ts.map +1 -1
- package/dist/oauth/authorization-code.repository.d.ts +10 -5
- package/dist/oauth/authorization-code.repository.d.ts.map +1 -1
- package/dist/oauth/role-guard.d.ts +30 -11
- package/dist/oauth/role-guard.d.ts.map +1 -1
- package/dist/oauth/role-guard.js +27 -5
- package/dist/oauth/token.repository.d.ts +2 -0
- package/dist/oauth/token.repository.d.ts.map +1 -1
- package/dist/policies.d.ts +10 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +33 -0
- package/dist/queue/queue.interface.d.ts +9 -1
- package/dist/queue/queue.interface.d.ts.map +1 -1
- package/dist/queue/queue.stub.d.ts +1 -1
- package/dist/queue/queue.stub.d.ts.map +1 -1
- package/dist/queue/queue.stub.js +2 -3
- package/dist/queue.interface.d.ts +39 -0
- package/dist/queue.interface.d.ts.map +1 -0
- package/dist/queue.interface.js +3 -0
- package/dist/queue.stub.d.ts +6 -0
- package/dist/queue.stub.d.ts.map +1 -0
- package/dist/queue.stub.js +8 -0
- package/dist/rate-limit/token-bucket-rate-limiter.d.ts +7 -0
- package/dist/rate-limit/token-bucket-rate-limiter.d.ts.map +1 -1
- package/dist/rate-limit/token-bucket-rate-limiter.js +66 -0
- package/dist/rbac/evaluate.d.ts +57 -0
- package/dist/rbac/evaluate.d.ts.map +1 -0
- package/dist/rbac/evaluate.js +113 -0
- package/dist/rbac/invitation.repository.d.ts +84 -0
- package/dist/rbac/invitation.repository.d.ts.map +1 -0
- package/dist/rbac/invitation.repository.js +3 -0
- package/dist/rbac/permissions.d.ts +39 -0
- package/dist/rbac/permissions.d.ts.map +1 -0
- package/dist/rbac/permissions.js +43 -0
- package/dist/rbac/types.d.ts +130 -0
- package/dist/rbac/types.d.ts.map +1 -0
- package/dist/rbac/types.js +4 -0
- package/dist/relations.d.ts +28 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +25 -0
- package/dist/richtext-render.d.ts +11 -0
- package/dist/richtext-render.d.ts.map +1 -0
- package/dist/richtext-render.js +87 -0
- package/dist/richtext.d.ts +11 -0
- package/dist/richtext.d.ts.map +1 -0
- package/dist/richtext.js +13 -0
- package/dist/scheduler.interface.d.ts +4 -0
- package/dist/scheduler.interface.d.ts.map +1 -0
- package/dist/scheduler.interface.js +3 -0
- package/dist/scheduler.stub.d.ts +5 -0
- package/dist/scheduler.stub.d.ts.map +1 -0
- package/dist/scheduler.stub.js +7 -0
- package/dist/schema/canonical.d.ts +15 -0
- package/dist/schema/canonical.d.ts.map +1 -0
- package/dist/schema/canonical.js +37 -0
- package/dist/schema/define.d.ts +42 -0
- package/dist/schema/define.d.ts.map +1 -0
- package/dist/schema/define.js +56 -0
- package/dist/schema/emit.d.ts +14 -0
- package/dist/schema/emit.d.ts.map +1 -0
- package/dist/schema/emit.js +43 -0
- package/dist/schema/index.d.ts +12 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +13 -0
- package/dist/schema/manifest-seeds.d.ts +12 -0
- package/dist/schema/manifest-seeds.d.ts.map +1 -0
- package/dist/schema/manifest-seeds.js +48 -0
- package/dist/schema/manifest-validation.d.ts +13 -0
- package/dist/schema/manifest-validation.d.ts.map +1 -0
- package/dist/schema/manifest-validation.js +41 -0
- package/dist/schema/manifest.types.d.ts +54 -0
- package/dist/schema/manifest.types.d.ts.map +1 -0
- package/dist/schema/manifest.types.js +7 -0
- package/dist/schema-mutator.d.ts +26 -0
- package/dist/schema-mutator.d.ts.map +1 -0
- package/dist/schema-mutator.js +3 -0
- package/dist/search/search.repository.d.ts +5 -4
- package/dist/search/search.repository.d.ts.map +1 -1
- package/dist/search/vector-extractor.d.ts.map +1 -1
- package/dist/search/vector-extractor.js +23 -2
- package/dist/search/vector.repository.d.ts +2 -1
- package/dist/search/vector.repository.d.ts.map +1 -1
- package/dist/security/time-trap-token.repository.d.ts +5 -3
- package/dist/security/time-trap-token.repository.d.ts.map +1 -1
- package/dist/seed-ddl-destructive.d.ts +40 -0
- package/dist/seed-ddl-destructive.d.ts.map +1 -0
- package/dist/seed-ddl-destructive.js +149 -0
- package/dist/seed-ddl.d.ts +40 -0
- package/dist/seed-ddl.d.ts.map +1 -0
- package/dist/seed-ddl.js +100 -0
- package/dist/seed-layout.d.ts +145 -0
- package/dist/seed-layout.d.ts.map +1 -0
- package/dist/seed-layout.js +192 -0
- package/dist/seed-layout.repository.d.ts +22 -0
- package/dist/seed-layout.repository.d.ts.map +1 -0
- package/dist/seed-layout.repository.js +3 -0
- package/dist/seed-registry.d.ts +71 -0
- package/dist/seed-registry.d.ts.map +1 -0
- package/dist/seed-registry.js +143 -0
- package/dist/seed-types-generator.d.ts +1 -1
- package/dist/seed-types-generator.d.ts.map +1 -1
- package/dist/seed-validation.d.ts +23 -0
- package/dist/seed-validation.d.ts.map +1 -0
- package/dist/seed-validation.js +224 -0
- package/dist/seed.repository.d.ts +37 -0
- package/dist/seed.repository.d.ts.map +1 -0
- package/dist/seed.repository.js +3 -0
- package/dist/seeds.d.ts +20 -0
- package/dist/seeds.d.ts.map +1 -0
- package/dist/seeds.js +21 -0
- package/dist/settings/site-settings.repository.d.ts +8 -0
- package/dist/settings/site-settings.repository.d.ts.map +1 -1
- package/dist/site-settings.repository.d.ts +16 -0
- package/dist/site-settings.repository.d.ts.map +1 -0
- package/dist/site-settings.repository.js +3 -0
- package/dist/slug-utils.d.ts +18 -0
- package/dist/slug-utils.d.ts.map +1 -0
- package/dist/slug-utils.js +32 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +3 -0
- package/dist/transfer/csv.d.ts +34 -0
- package/dist/transfer/csv.d.ts.map +1 -0
- package/dist/transfer/csv.js +135 -0
- package/dist/transfer/flat-seed.d.ts +13 -0
- package/dist/transfer/flat-seed.d.ts.map +1 -0
- package/dist/transfer/flat-seed.js +50 -0
- package/dist/transfer/index.d.ts +7 -0
- package/dist/transfer/index.d.ts.map +1 -0
- package/dist/transfer/index.js +8 -0
- package/dist/transfer/ndjson.d.ts +25 -0
- package/dist/transfer/ndjson.d.ts.map +1 -0
- package/dist/transfer/ndjson.js +62 -0
- package/dist/transfer/row-mapping.d.ts +36 -0
- package/dist/transfer/row-mapping.d.ts.map +1 -0
- package/dist/transfer/row-mapping.js +97 -0
- package/dist/transfer/transfer.constants.d.ts +20 -0
- package/dist/transfer/transfer.constants.d.ts.map +1 -0
- package/dist/transfer/transfer.constants.js +21 -0
- package/dist/transfer/transfer.types.d.ts +46 -0
- package/dist/transfer/transfer.types.d.ts.map +1 -0
- package/dist/transfer/transfer.types.js +6 -0
- package/dist/types.d.ts +243 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/validation.d.ts +50 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +660 -0
- package/dist/webhook-validation.d.ts +3 -0
- package/dist/webhook-validation.d.ts.map +1 -0
- package/dist/webhook-validation.js +6 -0
- package/package.json +11 -6
- package/dist/.tsbuildinfo +0 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
/**
|
|
4
|
+
* Converts a Uint8Array byte sequence to a Base64-encoded string.
|
|
5
|
+
* Native Edge-compatible implementation without Node.js Buffer dependencies.
|
|
6
|
+
*/
|
|
7
|
+
function bytesToBase64(bytes) {
|
|
8
|
+
let binary = '';
|
|
9
|
+
const len = bytes.byteLength;
|
|
10
|
+
for (let i = 0; i < len; i++) {
|
|
11
|
+
binary += String.fromCharCode(bytes[i]);
|
|
12
|
+
}
|
|
13
|
+
return btoa(binary);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Converts a Base64-encoded string back to a Uint8Array byte sequence.
|
|
17
|
+
* Native Edge-compatible implementation without Node.js Buffer dependencies.
|
|
18
|
+
*/
|
|
19
|
+
function base64ToBytes(base64) {
|
|
20
|
+
const binary = atob(base64);
|
|
21
|
+
const bytes = new Uint8Array(binary.length);
|
|
22
|
+
for (let i = 0; i < binary.length; i++) {
|
|
23
|
+
bytes[i] = binary.charCodeAt(i);
|
|
24
|
+
}
|
|
25
|
+
return bytes;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Concrete Edge-native implementation of {@link IPrivacyService}.
|
|
29
|
+
* Employs Web Crypto API (`crypto.subtle`) for zero-dependency execution in Cloudflare Workers.
|
|
30
|
+
*/
|
|
31
|
+
export class PrivacyService {
|
|
32
|
+
masterKey;
|
|
33
|
+
aesKeyPromise;
|
|
34
|
+
hmacKeyPromise;
|
|
35
|
+
/**
|
|
36
|
+
* Initializes the PrivacyService with a Master Encryption Key.
|
|
37
|
+
* @param masterKey - Secret key used to derive cryptographic keys for AES-GCM and HMAC.
|
|
38
|
+
* @throws Error if `masterKey` is empty or undefined.
|
|
39
|
+
*/
|
|
40
|
+
constructor(masterKey) {
|
|
41
|
+
if (!masterKey) {
|
|
42
|
+
throw new Error('PrivacyService requires a non-empty masterKey');
|
|
43
|
+
}
|
|
44
|
+
this.masterKey = masterKey;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Lazy-loads and caches the derived AES-256-GCM {@link CryptoKey}.
|
|
48
|
+
* Derives a 256-bit key from `masterKey` via SHA-256 digest.
|
|
49
|
+
*/
|
|
50
|
+
async getAesKey() {
|
|
51
|
+
if (!this.aesKeyPromise) {
|
|
52
|
+
this.aesKeyPromise = (async () => {
|
|
53
|
+
const keyData = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this.masterKey));
|
|
54
|
+
return crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
55
|
+
})();
|
|
56
|
+
}
|
|
57
|
+
return this.aesKeyPromise;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Lazy-loads and caches the derived HMAC SHA-256 {@link CryptoKey}.
|
|
61
|
+
*/
|
|
62
|
+
async getHmacKey() {
|
|
63
|
+
if (!this.hmacKeyPromise) {
|
|
64
|
+
this.hmacKeyPromise = (async () => {
|
|
65
|
+
return crypto.subtle.importKey('raw', new TextEncoder().encode(this.masterKey), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
66
|
+
})();
|
|
67
|
+
}
|
|
68
|
+
return this.hmacKeyPromise;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Encrypts a plaintext string with AES-256-GCM and a fresh 96-bit (12-byte) IV.
|
|
72
|
+
* Output is formatted as `v1:<iv_base64>:<ciphertext_base64>`.
|
|
73
|
+
*/
|
|
74
|
+
async encrypt(plaintext) {
|
|
75
|
+
const key = await this.getAesKey();
|
|
76
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
77
|
+
const encodedText = new TextEncoder().encode(plaintext);
|
|
78
|
+
const encryptedBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, encodedText);
|
|
79
|
+
const ivBase64 = bytesToBase64(iv);
|
|
80
|
+
const ciphertextBase64 = bytesToBase64(new Uint8Array(encryptedBuffer));
|
|
81
|
+
return `v1:${ivBase64}:${ciphertextBase64}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Decrypts a `v1:<iv_base64>:<ciphertext_base64>` string back to its plaintext value.
|
|
85
|
+
* If the input string is not prefixed with `v1:`, returns the raw string as unencrypted legacy data.
|
|
86
|
+
*/
|
|
87
|
+
async decrypt(ciphertext) {
|
|
88
|
+
if (!ciphertext || !ciphertext.startsWith('v1:')) {
|
|
89
|
+
return ciphertext;
|
|
90
|
+
}
|
|
91
|
+
const parts = ciphertext.split(':');
|
|
92
|
+
if (parts.length !== 3) {
|
|
93
|
+
throw new Error('Invalid ciphertext format');
|
|
94
|
+
}
|
|
95
|
+
const [, ivBase64, cipherBase64] = parts;
|
|
96
|
+
const iv = base64ToBytes(ivBase64);
|
|
97
|
+
const cipherBytes = base64ToBytes(cipherBase64);
|
|
98
|
+
const key = await this.getAesKey();
|
|
99
|
+
const decryptedBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, cipherBytes);
|
|
100
|
+
return new TextDecoder().decode(decryptedBuffer);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Computes a deterministic HMAC SHA-256 hex digest for blind index generation.
|
|
104
|
+
*/
|
|
105
|
+
async hash(plaintext) {
|
|
106
|
+
const key = await this.getHmacKey();
|
|
107
|
+
const encodedText = new TextEncoder().encode(plaintext);
|
|
108
|
+
const signatureBuffer = await crypto.subtle.sign('HMAC', key, encodedText);
|
|
109
|
+
const hashBytes = new Uint8Array(signatureBuffer);
|
|
110
|
+
return Array.from(hashBytes)
|
|
111
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
112
|
+
.join('');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Thrown when a protected field is processed but no master key is configured. */
|
|
116
|
+
export class PrivacyNotConfiguredError extends Error {
|
|
117
|
+
constructor() {
|
|
118
|
+
super('PRIVACY_MASTER_KEY is not configured: cannot encrypt or hash protected fields');
|
|
119
|
+
this.name = 'PrivacyNotConfiguredError';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Fail-closed {@link IPrivacyService} for installs without a master key.
|
|
124
|
+
* Never returns plaintext as protected output; legacy non-`v1:` values still read through.
|
|
125
|
+
*/
|
|
126
|
+
export class UnconfiguredPrivacyService {
|
|
127
|
+
async encrypt(_plaintext) {
|
|
128
|
+
throw new PrivacyNotConfiguredError();
|
|
129
|
+
}
|
|
130
|
+
async decrypt(ciphertext) {
|
|
131
|
+
if (ciphertext?.startsWith('v1:'))
|
|
132
|
+
throw new PrivacyNotConfiguredError();
|
|
133
|
+
return ciphertext;
|
|
134
|
+
}
|
|
135
|
+
async hash(_plaintext) {
|
|
136
|
+
throw new PrivacyNotConfiguredError();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"privacy.service.d.ts","sourceRoot":"","sources":["../../src/engine/privacy.service.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE3C;;;;;OAKG;IACH,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE5C;;;;;OAKG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;CACzC;AA4BD;;;GAGG;AACH,qBAAa,cAAe,YAAW,eAAe;IACpD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,aAAa,CAAC,CAAoB;IAC1C,OAAO,CAAC,cAAc,CAAC,CAAoB;IAE3C;;;;OAIG;
|
|
1
|
+
{"version":3,"file":"privacy.service.d.ts","sourceRoot":"","sources":["../../src/engine/privacy.service.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE3C;;;;;OAKG;IACH,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE5C;;;;;OAKG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;CACzC;AA4BD;;;GAGG;AACH,qBAAa,cAAe,YAAW,eAAe;IACpD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,aAAa,CAAC,CAAoB;IAC1C,OAAO,CAAC,cAAc,CAAC,CAAoB;IAE3C;;;;OAIG;IACH,YAAY,SAAS,EAAE,MAAM,EAK5B;IAED;;;OAGG;YACW,SAAS;IAmBvB;;OAEG;YACW,UAAU;IAexB;;;OAGG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYhD;IAED;;;OAGG;IACG,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBjD;IAED;;OAEG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAY7C;CACF"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Seed, TrashedMode } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if the
|
|
4
|
+
* seed does not use soft delete or mode is 'any'.
|
|
5
|
+
*
|
|
6
|
+
* @param seed - The seed schema definition.
|
|
7
|
+
* @param mode - 'active' (default), 'trashed', or 'any'.
|
|
8
|
+
* @param tableAlias - Optional table or alias qualifier prefix.
|
|
9
|
+
*/
|
|
10
|
+
export declare function activeCondition(seed: Seed, mode?: TrashedMode, tableAlias?: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Returns ` AND <activeCondition>` for a soft-delete seed, or `''` otherwise.
|
|
13
|
+
*
|
|
14
|
+
* @param seed - The seed schema definition.
|
|
15
|
+
* @param mode - 'active' (default), 'trashed', or 'any'.
|
|
16
|
+
* @param tableAlias - Optional table or alias qualifier prefix.
|
|
17
|
+
*/
|
|
18
|
+
export declare function activeClause(seed: Seed, mode?: TrashedMode, tableAlias?: string): string;
|
|
19
|
+
//# sourceMappingURL=active-clause.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"active-clause.d.ts","sourceRoot":"","sources":["../../../src/engine/query/active-clause.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEpD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,WAAsB,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,GAAG,IAAI,CAIf;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,WAAsB,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAGR"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
/**
|
|
4
|
+
* Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if the
|
|
5
|
+
* seed does not use soft delete or mode is 'any'.
|
|
6
|
+
*
|
|
7
|
+
* @param seed - The seed schema definition.
|
|
8
|
+
* @param mode - 'active' (default), 'trashed', or 'any'.
|
|
9
|
+
* @param tableAlias - Optional table or alias qualifier prefix.
|
|
10
|
+
*/
|
|
11
|
+
export function activeCondition(seed, mode = 'active', tableAlias) {
|
|
12
|
+
if (!seed.softDelete || mode === 'any')
|
|
13
|
+
return null;
|
|
14
|
+
const col = tableAlias ? `${tableAlias}.deleted_at` : 'deleted_at';
|
|
15
|
+
return mode === 'trashed' ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Returns ` AND <activeCondition>` for a soft-delete seed, or `''` otherwise.
|
|
19
|
+
*
|
|
20
|
+
* @param seed - The seed schema definition.
|
|
21
|
+
* @param mode - 'active' (default), 'trashed', or 'any'.
|
|
22
|
+
* @param tableAlias - Optional table or alias qualifier prefix.
|
|
23
|
+
*/
|
|
24
|
+
export function activeClause(seed, mode = 'active', tableAlias) {
|
|
25
|
+
const cond = activeCondition(seed, mode, tableAlias);
|
|
26
|
+
return cond ? ` AND ${cond}` : '';
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Seed, FilterGroup, SelectOptions, ParameterizedQuery } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds a parameterized SQL SELECT query and bindings for a given Seed based on search, filtering, status, and ordering options.
|
|
4
|
+
*
|
|
5
|
+
* If `options.isCount` is set to `true`, it generates a counting query (`COUNT(*) as total`) rather than returning database rows.
|
|
6
|
+
* In count mode, column projections (`fields`), ordering (`orderBy` / `kanbanOrder`), and pagination (`LIMIT` / `OFFSET`) clauses and
|
|
7
|
+
* bindings are omitted, while join and filtering clauses are preserved.
|
|
8
|
+
*
|
|
9
|
+
* @param seed The seed definition.
|
|
10
|
+
* @param options Query configuration options.
|
|
11
|
+
* @returns The SQL query string and bindings.
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const { sql, bindings } = buildSelectQuery(postSeed, {
|
|
15
|
+
* status: 'published',
|
|
16
|
+
* filters: [{ column: 'title', type: 'text', conditions: [{ op: 'contains', value: 'cms' }] }],
|
|
17
|
+
* orderBy: { column: 'created_at', dir: 'DESC' },
|
|
18
|
+
* pagination: { limit: 20, offset: 0 },
|
|
19
|
+
* })
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildSelectQuery(seed: Seed, options?: SelectOptions): ParameterizedQuery;
|
|
23
|
+
/**
|
|
24
|
+
* Lists the filter groups `buildSelectQuery` would silently leave out of the WHERE clause: an unknown
|
|
25
|
+
* column, or a condition whose value cannot be bound for the field type (empty or unparsable
|
|
26
|
+
* number/date, `in`/`not_in` value that is not an array). An empty `in` is not listed:
|
|
27
|
+
* it compiles to constant false and still constrains the read. A caller that needs the filter to constrain the read
|
|
28
|
+
* (e.g. automations resolving placeholders at run time) uses this to refuse the query instead of
|
|
29
|
+
* running it unconstrained.
|
|
30
|
+
*
|
|
31
|
+
* @param seed The seed the filters apply to.
|
|
32
|
+
* @param filters The filter groups to check.
|
|
33
|
+
* @returns The groups that would be dropped, in input order.
|
|
34
|
+
*/
|
|
35
|
+
export declare function findUnappliedFilters(seed: Seed, filters: FilterGroup[]): FilterGroup[];
|
|
36
|
+
//# sourceMappingURL=query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../../src/engine/query/query.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,IAAI,EAGJ,WAAW,EAGX,aAAa,EAEb,kBAAkB,EACnB,MAAM,aAAa,CAAC;AA8FrB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,kBAAkB,CAgH5F;AAGD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,WAAW,EAAE,CAKtF"}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { tableName, ftsTableName, isValidColumn, indexableSearchBranches, SYSTEM_COLUMNS } from '../ddl/ddl.js';
|
|
2
|
+
import { resolveClassification } from '../privacy/policies.js';
|
|
3
|
+
import { isLocaleCode, isLocalizedBranch } from '../localization/localization.js';
|
|
4
|
+
import { activeCondition } from './active-clause.js';
|
|
5
|
+
import { isSingleChoiceTags, tagOptionValues } from '../seeds/tag-options.js';
|
|
6
|
+
import { normalizeTagValue, TAG_OPTIONS_MAX_COUNT } from '../seeds/tag-value.js';
|
|
7
|
+
/** SQLite GLOB twins of `LOCALE_CODE_RE` (`it`, `ast`, `pt-BR`, `es-419`, …). Keep both in sync. */
|
|
8
|
+
const LOCALE_KEY_GLOBS = [
|
|
9
|
+
'[a-z][a-z]',
|
|
10
|
+
'[a-z][a-z][a-z]',
|
|
11
|
+
'[a-z][a-z]-[A-Z][A-Z]',
|
|
12
|
+
'[a-z][a-z][a-z]-[A-Z][A-Z]',
|
|
13
|
+
'[a-z][a-z]-[0-9][0-9][0-9]',
|
|
14
|
+
'[a-z][a-z][a-z]-[0-9][0-9][0-9]',
|
|
15
|
+
];
|
|
16
|
+
/** A locale code checked for inlining into SQL. The grammar check IS the injection guard — never skip it. */
|
|
17
|
+
function inlineLocale(code) {
|
|
18
|
+
if (!isLocaleCode(code))
|
|
19
|
+
throw new TypeError(`Invalid locale code '${code}'`);
|
|
20
|
+
return code;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* SQL twin of `resolveLocalizedValue`: the value of a localized column in `locale.code`. Self-contained
|
|
24
|
+
* (no bindings), because `buildFilterCondition` may interpolate a column more than once per clause.
|
|
25
|
+
* The nested CASE is deliberate: SQLite does not guarantee AND short-circuits, and `json_type` /
|
|
26
|
+
* `json_each` raise on malformed JSON (a legacy plain-text value).
|
|
27
|
+
* Dictionary detection mirrors `isStoredLocaleDictionary`: a non-empty object whose keys all match the
|
|
28
|
+
* locale grammar, and — for `json` only — at least one registered locale key.
|
|
29
|
+
*/
|
|
30
|
+
function localizedColumnSql(column, branch, locale) {
|
|
31
|
+
const keyIsLocale = LOCALE_KEY_GLOBS.map((glob) => `key GLOB '${glob}'`).join(' OR ');
|
|
32
|
+
const conditions = [
|
|
33
|
+
`json_type(${column}) = 'object'`,
|
|
34
|
+
`EXISTS (SELECT 1 FROM json_each(${column}))`,
|
|
35
|
+
`NOT EXISTS (SELECT 1 FROM json_each(${column}) WHERE NOT (${keyIsLocale}))`,
|
|
36
|
+
];
|
|
37
|
+
if (branch.type === 'json') {
|
|
38
|
+
const registered = locale.config.locales.map((code) => `'${inlineLocale(code)}'`).join(', ');
|
|
39
|
+
conditions.push(`EXISTS (SELECT 1 FROM json_each(${column}) WHERE key IN (${registered}))`);
|
|
40
|
+
}
|
|
41
|
+
const path = (code) => `'$."${inlineLocale(code)}"'`;
|
|
42
|
+
const resolved = `COALESCE(` +
|
|
43
|
+
`NULLIF(json_extract(${column}, ${path(locale.code)}), ''), ` +
|
|
44
|
+
`NULLIF(json_extract(${column}, ${path(locale.config.defaultLocale)}), ''), ` +
|
|
45
|
+
`(SELECT value FROM json_each(${column}) WHERE value IS NOT NULL AND value != '' LIMIT 1))`;
|
|
46
|
+
return `(CASE WHEN json_valid(${column}) THEN ` +
|
|
47
|
+
`(CASE WHEN ${conditions.join(' AND ')} THEN ${resolved} ELSE ${column} END) ` +
|
|
48
|
+
`ELSE ${column} END)`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The SQL reference for `alias` in WHERE / ORDER BY: table-qualified system column, localized expression
|
|
52
|
+
* when `locale` is set, bare alias otherwise (unchanged pre-localization behaviour).
|
|
53
|
+
*/
|
|
54
|
+
function columnSql(seed, table, alias, locale) {
|
|
55
|
+
if (SYSTEM_COLUMNS.has(alias))
|
|
56
|
+
return `${table}.${alias}`;
|
|
57
|
+
const branch = seed.branches.find((b) => b.alias === alias);
|
|
58
|
+
if (locale && branch && isLocalizedBranch(branch)) {
|
|
59
|
+
return localizedColumnSql(`${table}.${alias}`, branch, locale);
|
|
60
|
+
}
|
|
61
|
+
return alias;
|
|
62
|
+
}
|
|
63
|
+
/** A multiple-choice `tags` column has no meaningful order, so `orderBy` on it is ignored. */
|
|
64
|
+
function isMultipleChoiceTagsColumn(seed, alias) {
|
|
65
|
+
const branch = seed.branches.find((b) => b.alias === alias);
|
|
66
|
+
return branch?.type === 'tags' && !isSingleChoiceTags(branch);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* ORDER BY clause for a single-choice tags column: empty values (`NULL`, `[]`) always last, then the option
|
|
70
|
+
* position (a value outside the options ranks after every option) or, without options, the value alphabetically.
|
|
71
|
+
* Option values are pushed to `bindings`.
|
|
72
|
+
*/
|
|
73
|
+
function tagsOrderClause(col, branch, dir, bindings) {
|
|
74
|
+
const head = `(CASE WHEN json_valid(${col}) THEN json_extract(${col}, '$[0]') END)`;
|
|
75
|
+
const options = tagOptionValues(branch);
|
|
76
|
+
let rank = head;
|
|
77
|
+
if (options.length > 0) {
|
|
78
|
+
const branches = options.map((_, position) => `WHEN ? THEN ${position}`).join(' ');
|
|
79
|
+
rank = `CASE ${head} ${branches} ELSE ${options.length} END`;
|
|
80
|
+
bindings.push(...options);
|
|
81
|
+
}
|
|
82
|
+
return ` ORDER BY (${head} IS NULL) ASC, ${rank} ${dir}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Builds a parameterized SQL SELECT query and bindings for a given Seed based on search, filtering, status, and ordering options.
|
|
86
|
+
*
|
|
87
|
+
* If `options.isCount` is set to `true`, it generates a counting query (`COUNT(*) as total`) rather than returning database rows.
|
|
88
|
+
* In count mode, column projections (`fields`), ordering (`orderBy` / `kanbanOrder`), and pagination (`LIMIT` / `OFFSET`) clauses and
|
|
89
|
+
* bindings are omitted, while join and filtering clauses are preserved.
|
|
90
|
+
*
|
|
91
|
+
* @param seed The seed definition.
|
|
92
|
+
* @param options Query configuration options.
|
|
93
|
+
* @returns The SQL query string and bindings.
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* const { sql, bindings } = buildSelectQuery(postSeed, {
|
|
97
|
+
* status: 'published',
|
|
98
|
+
* filters: [{ column: 'title', type: 'text', conditions: [{ op: 'contains', value: 'cms' }] }],
|
|
99
|
+
* orderBy: { column: 'created_at', dir: 'DESC' },
|
|
100
|
+
* pagination: { limit: 20, offset: 0 },
|
|
101
|
+
* })
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function buildSelectQuery(seed, options = {}) {
|
|
105
|
+
const table = tableName(seed);
|
|
106
|
+
const { filters = [], orderBy, pagination, status, search, fields } = options;
|
|
107
|
+
const bindings = [];
|
|
108
|
+
const whereClauses = [];
|
|
109
|
+
let joinClause = '';
|
|
110
|
+
let kanbanOrderClause = '';
|
|
111
|
+
if (options.kanbanOrder) {
|
|
112
|
+
joinClause = `LEFT JOIN kanban_positions kp` +
|
|
113
|
+
` ON kp.seed_slug = ? AND kp.entry_id = ${table}.id AND kp.axis_branch_id = ?`;
|
|
114
|
+
bindings.push(options.kanbanOrder.seedSlug, options.kanbanOrder.axisBranchId);
|
|
115
|
+
kanbanOrderClause = ` ORDER BY (kp.position IS NULL) ASC, kp.position ASC`;
|
|
116
|
+
}
|
|
117
|
+
const rtBranches = indexableSearchBranches(seed);
|
|
118
|
+
if (search && rtBranches.length > 0) {
|
|
119
|
+
const ftsTable = ftsTableName(seed);
|
|
120
|
+
joinClause += (joinClause ? ' ' : '') + `INNER JOIN ${ftsTable} ON ${ftsTable}.entry_id = ${table}.id`;
|
|
121
|
+
whereClauses.push(`${ftsTable} MATCH ?`);
|
|
122
|
+
bindings.push(`"${search.replace(/"/g, '""')}"*`);
|
|
123
|
+
}
|
|
124
|
+
if (status !== undefined && status !== null) {
|
|
125
|
+
whereClauses.push(`${table}.status = ?`);
|
|
126
|
+
bindings.push(status);
|
|
127
|
+
}
|
|
128
|
+
// Soft delete: 'active' is the default, so a caller that forgets the option can never
|
|
129
|
+
// observe a trashed row — including the Public API, relation expansion and subqueries,
|
|
130
|
+
// which all reach SQL only through repository.findMany.
|
|
131
|
+
if (seed.softDelete) {
|
|
132
|
+
const trashed = options.trashed ?? 'active';
|
|
133
|
+
const cond = activeCondition(seed, trashed, table);
|
|
134
|
+
if (cond) {
|
|
135
|
+
whereClauses.push(cond);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const groupClauses = [];
|
|
139
|
+
for (const group of filters) {
|
|
140
|
+
if (!isValidColumn(seed, group.column))
|
|
141
|
+
continue;
|
|
142
|
+
const branch = seed.branches.find(b => b.alias === group.column);
|
|
143
|
+
const isEncrypted = branch ? resolveClassification(branch).storage === 'encrypt' : false;
|
|
144
|
+
const col = columnSql(seed, table, group.column, options.locale);
|
|
145
|
+
const condClauses = [];
|
|
146
|
+
for (const cond of group.conditions) {
|
|
147
|
+
if (isEncrypted) {
|
|
148
|
+
const { op } = cond;
|
|
149
|
+
if (op === 'eq' || op === 'neq' || op === 'in' || op === 'not_in') {
|
|
150
|
+
const targetCol = `${table}.${group.column}_bidx`;
|
|
151
|
+
const clause = buildFilterCondition(targetCol, group.type, cond, bindings);
|
|
152
|
+
if (clause)
|
|
153
|
+
condClauses.push(clause);
|
|
154
|
+
}
|
|
155
|
+
else if (op === 'is_empty' || op === 'is_not_empty') {
|
|
156
|
+
const clause = buildFilterCondition(col, group.type, cond, bindings);
|
|
157
|
+
if (clause)
|
|
158
|
+
condClauses.push(clause);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
throw new TypeError(`Invalid filter: operator '${op}' is not supported for encrypted field '${group.column}'`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const clause = buildFilterCondition(col, group.type, cond, bindings);
|
|
166
|
+
if (clause)
|
|
167
|
+
condClauses.push(clause);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (condClauses.length > 0) {
|
|
171
|
+
groupClauses.push(condClauses.length > 1 ? `(${condClauses.join(' AND ')})` : condClauses[0]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (groupClauses.length > 0) {
|
|
175
|
+
const joiner = options.filterLogic === 'OR' ? ' OR ' : ' AND ';
|
|
176
|
+
whereClauses.push(groupClauses.length > 1 ? `(${groupClauses.join(joiner)})` : groupClauses[0]);
|
|
177
|
+
}
|
|
178
|
+
let selectCols = options.isCount ? 'COUNT(*) as total' : `${table}.*`;
|
|
179
|
+
if (!options.isCount && fields && fields.length > 0) {
|
|
180
|
+
const valid = fields.filter(f => isValidColumn(seed, f));
|
|
181
|
+
if (valid.length > 0) {
|
|
182
|
+
selectCols = valid
|
|
183
|
+
.map(f => (SYSTEM_COLUMNS.has(f) ? `${table}.${f}` : f))
|
|
184
|
+
.join(', ');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!options.isCount && options.kanbanOrder)
|
|
188
|
+
selectCols += ', kp.position';
|
|
189
|
+
let sql = `SELECT ${selectCols} FROM ${table}`;
|
|
190
|
+
if (joinClause)
|
|
191
|
+
sql += ` ${joinClause}`;
|
|
192
|
+
if (whereClauses.length > 0)
|
|
193
|
+
sql += ` WHERE ${whereClauses.join(' AND ')}`;
|
|
194
|
+
if (!options.isCount) {
|
|
195
|
+
if (kanbanOrderClause) {
|
|
196
|
+
sql += kanbanOrderClause;
|
|
197
|
+
}
|
|
198
|
+
else if (orderBy && isValidColumn(seed, orderBy.column) && !isMultipleChoiceTagsColumn(seed, orderBy.column)) {
|
|
199
|
+
const dir = orderBy.dir === 'DESC' ? 'DESC' : 'ASC';
|
|
200
|
+
const col = columnSql(seed, table, orderBy.column, options.locale);
|
|
201
|
+
const orderedBranch = seed.branches.find((b) => b.alias === orderBy.column);
|
|
202
|
+
sql += orderedBranch && isSingleChoiceTags(orderedBranch)
|
|
203
|
+
? tagsOrderClause(col, orderedBranch, dir, bindings)
|
|
204
|
+
: ` ORDER BY ${col} ${dir}`;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
sql += ` ORDER BY ${table}.created_at DESC`;
|
|
208
|
+
}
|
|
209
|
+
if (pagination) {
|
|
210
|
+
sql += ` LIMIT ? OFFSET ?`;
|
|
211
|
+
bindings.push(pagination.limit, pagination.offset);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { sql, bindings };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Lists the filter groups `buildSelectQuery` would silently leave out of the WHERE clause: an unknown
|
|
218
|
+
* column, or a condition whose value cannot be bound for the field type (empty or unparsable
|
|
219
|
+
* number/date, `in`/`not_in` value that is not an array). An empty `in` is not listed:
|
|
220
|
+
* it compiles to constant false and still constrains the read. A caller that needs the filter to constrain the read
|
|
221
|
+
* (e.g. automations resolving placeholders at run time) uses this to refuse the query instead of
|
|
222
|
+
* running it unconstrained.
|
|
223
|
+
*
|
|
224
|
+
* @param seed The seed the filters apply to.
|
|
225
|
+
* @param filters The filter groups to check.
|
|
226
|
+
* @returns The groups that would be dropped, in input order.
|
|
227
|
+
*/
|
|
228
|
+
export function findUnappliedFilters(seed, filters) {
|
|
229
|
+
return filters.filter((group) => !isValidColumn(seed, group.column)
|
|
230
|
+
|| group.conditions.some((cond) => buildFilterCondition('col', group.type, cond, []) === null));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Normalizes user-provided filter values to a format appropriate for SQL execution.
|
|
234
|
+
*
|
|
235
|
+
* @param type The type of the filter.
|
|
236
|
+
* @param value The value to normalize.
|
|
237
|
+
* @returns The normalized value, or null.
|
|
238
|
+
*/
|
|
239
|
+
function normalizeFilterValue(type, value) {
|
|
240
|
+
if (value === null || value === undefined)
|
|
241
|
+
return null;
|
|
242
|
+
if (type === 'date') {
|
|
243
|
+
if (typeof value === 'number')
|
|
244
|
+
return value;
|
|
245
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
246
|
+
const d = new Date(value);
|
|
247
|
+
return Number.isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (type === 'boolean') {
|
|
252
|
+
return value ? 1 : 0;
|
|
253
|
+
}
|
|
254
|
+
if (type === 'number') {
|
|
255
|
+
if (typeof value === 'number')
|
|
256
|
+
return value;
|
|
257
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
258
|
+
const n = Number(value);
|
|
259
|
+
return Number.isNaN(n) ? null : n;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
/** On a `tags` column, equality-like operators test membership; `negated` ones are the complement. */
|
|
266
|
+
const TAGS_MEMBERSHIP_OPS = {
|
|
267
|
+
eq: { op: 'has_tag', negated: false },
|
|
268
|
+
contains: { op: 'has_tag', negated: false },
|
|
269
|
+
in: { op: 'has_any_tag', negated: false },
|
|
270
|
+
neq: { op: 'has_tag', negated: true },
|
|
271
|
+
not_contains: { op: 'has_tag', negated: true },
|
|
272
|
+
not_in: { op: 'has_any_tag', negated: true },
|
|
273
|
+
};
|
|
274
|
+
function buildTagsMembershipCondition(col, op, value, bindings) {
|
|
275
|
+
const { op: membershipOp, negated } = TAGS_MEMBERSHIP_OPS[op];
|
|
276
|
+
const isSetOp = membershipOp === 'has_any_tag';
|
|
277
|
+
let tags;
|
|
278
|
+
if (isSetOp) {
|
|
279
|
+
if (!Array.isArray(value))
|
|
280
|
+
return null;
|
|
281
|
+
tags = value.filter((v) => typeof v === 'string' || typeof v === 'number').map(String);
|
|
282
|
+
// Same rule as in/not_in: an empty set matches nothing, its negation adds no clause.
|
|
283
|
+
if (tags.length === 0)
|
|
284
|
+
return negated ? null : '0';
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
if (typeof value !== 'string' && typeof value !== 'number')
|
|
288
|
+
return null;
|
|
289
|
+
tags = [String(value)];
|
|
290
|
+
}
|
|
291
|
+
const condition = buildFilterCondition(col, 'tags', { op: membershipOp, value: isSetOp ? tags : tags[0] }, bindings);
|
|
292
|
+
return negated && condition ? `NOT (${condition})` : condition;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Translates a filter condition into its SQL clause and adds parameters to the bindings list.
|
|
296
|
+
*
|
|
297
|
+
* @param col The column name in the table.
|
|
298
|
+
* @param type The type of the filter.
|
|
299
|
+
* @param cond The condition containing the operator and value.
|
|
300
|
+
* @param bindings The array of bindings where parameterized values are appended.
|
|
301
|
+
* @returns The SQL condition fragment, or null if invalid.
|
|
302
|
+
*/
|
|
303
|
+
function buildFilterCondition(col, type, cond, bindings) {
|
|
304
|
+
const { op, value } = cond;
|
|
305
|
+
if (op === 'is_empty' || op === 'is_not_empty') {
|
|
306
|
+
const isEmpty = op === 'is_empty';
|
|
307
|
+
if (type === 'text') {
|
|
308
|
+
return isEmpty ? `(${col} IS NULL OR ${col} = '')` : `(${col} IS NOT NULL AND ${col} != '')`;
|
|
309
|
+
}
|
|
310
|
+
if (type === 'tags' || type === 'json' || type === 'repeater') {
|
|
311
|
+
return isEmpty
|
|
312
|
+
? `(${col} IS NULL OR ${col} = '[]' OR ${col} = '{}')`
|
|
313
|
+
: `(${col} IS NOT NULL AND ${col} != '[]' AND ${col} != '{}')`;
|
|
314
|
+
}
|
|
315
|
+
return isEmpty ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
316
|
+
}
|
|
317
|
+
if (type === 'tags' && Object.hasOwn(TAGS_MEMBERSHIP_OPS, op)) {
|
|
318
|
+
return buildTagsMembershipCondition(col, op, value, bindings);
|
|
319
|
+
}
|
|
320
|
+
if (op === 'in' || op === 'not_in') {
|
|
321
|
+
if (!Array.isArray(value))
|
|
322
|
+
return null;
|
|
323
|
+
const normalizedValues = value
|
|
324
|
+
.map((v) => normalizeFilterValue(type, v))
|
|
325
|
+
.filter((v) => v !== null);
|
|
326
|
+
// Membership in an empty set is false, not "no constraint"; dropping it would widen the result set.
|
|
327
|
+
// `not_in` over an empty set is vacuously true, so it adds no clause.
|
|
328
|
+
if (normalizedValues.length === 0)
|
|
329
|
+
return op === 'in' ? '0' : null;
|
|
330
|
+
const placeholders = normalizedValues.map(() => '?').join(', ');
|
|
331
|
+
bindings.push(...normalizedValues);
|
|
332
|
+
return `${col} ${op === 'in' ? 'IN' : 'NOT IN'} (${placeholders})`;
|
|
333
|
+
}
|
|
334
|
+
if (op === 'has_tag' || op === 'has_any_tag' || op === 'has_all_tags') {
|
|
335
|
+
if (type !== 'tags' && type !== 'json')
|
|
336
|
+
return null;
|
|
337
|
+
const requested = [...new Set((op === 'has_tag' ? [value] : Array.isArray(value) ? value : [value]).map(t => String(t)))];
|
|
338
|
+
if (requested.length === 0)
|
|
339
|
+
return null;
|
|
340
|
+
// One bound parameter per tag and D1 allows 100 per statement: an oversized set is a malformed filter.
|
|
341
|
+
if (requested.length > TAG_OPTIONS_MAX_COUNT)
|
|
342
|
+
return null;
|
|
343
|
+
// A stored `tags` value is always normalized, so the needle is too. One that cannot be normalized
|
|
344
|
+
// (blank, too long, ill-formed) matches nothing, and `has_all_tags` over it can never be satisfied.
|
|
345
|
+
let tags = requested;
|
|
346
|
+
if (type === 'tags') {
|
|
347
|
+
const normalized = requested.flatMap((t) => {
|
|
348
|
+
const result = normalizeTagValue(t);
|
|
349
|
+
return result.ok && result.value.length > 0 ? [result.value] : [];
|
|
350
|
+
});
|
|
351
|
+
if (normalized.length === 0 || (op === 'has_all_tags' && normalized.length < requested.length))
|
|
352
|
+
return '0';
|
|
353
|
+
tags = [...new Set(normalized)];
|
|
354
|
+
}
|
|
355
|
+
// json_each on array → value is the element; on object → key is the property name.
|
|
356
|
+
// Tags stored as ["photo"] (array) or {"photo":"#3b82f6"} (object) must both match.
|
|
357
|
+
const tagRef = `CASE json_type(${col}) WHEN 'array' THEN value ELSE key END`;
|
|
358
|
+
if (op === 'has_tag' || op === 'has_any_tag') {
|
|
359
|
+
const placeholders = tags.map(() => '?').join(', ');
|
|
360
|
+
bindings.push(...tags);
|
|
361
|
+
return `EXISTS (SELECT 1 FROM json_each(${col}) WHERE ${tagRef} IN (${placeholders}))`;
|
|
362
|
+
}
|
|
363
|
+
const clauses = tags.map(() => `EXISTS (SELECT 1 FROM json_each(${col}) WHERE ${tagRef} = ?)`);
|
|
364
|
+
bindings.push(...tags);
|
|
365
|
+
return `(${clauses.join(' AND ')})`;
|
|
366
|
+
}
|
|
367
|
+
const normalized = normalizeFilterValue(type, value);
|
|
368
|
+
if (normalized === null)
|
|
369
|
+
return null;
|
|
370
|
+
if (op === 'eq' || op === 'neq') {
|
|
371
|
+
const sqlOp = op === 'eq' ? '=' : '!=';
|
|
372
|
+
bindings.push(normalized);
|
|
373
|
+
return `${col} ${sqlOp} ?`;
|
|
374
|
+
}
|
|
375
|
+
if (op === 'contains' || op === 'not_contains' || op === 'starts_with' || op === 'ends_with') {
|
|
376
|
+
const sqlOp = op === 'not_contains' ? 'NOT LIKE' : 'LIKE';
|
|
377
|
+
let pattern = String(value);
|
|
378
|
+
if (op === 'contains' || op === 'not_contains')
|
|
379
|
+
pattern = `%${pattern}%`;
|
|
380
|
+
else if (op === 'starts_with')
|
|
381
|
+
pattern = `${pattern}%`;
|
|
382
|
+
else if (op === 'ends_with')
|
|
383
|
+
pattern = `%${pattern}`;
|
|
384
|
+
bindings.push(pattern);
|
|
385
|
+
return `${col} ${sqlOp} ?`;
|
|
386
|
+
}
|
|
387
|
+
const mathOps = { gt: '>', gte: '>=', lt: '<', lte: '<=' };
|
|
388
|
+
if (mathOps[op]) {
|
|
389
|
+
bindings.push(normalized);
|
|
390
|
+
return `${col} ${mathOps[op]} ?`;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Back-reference helpers for the Beech CMS relation system.
|
|
3
|
+
*
|
|
4
|
+
* `buildBackrefMap` is a pure function that iterates the seed registry once
|
|
5
|
+
* and builds a lookup map: targetSlug → sources that point to it.
|
|
6
|
+
* Build this once at app factory time and cache it for the process lifetime.
|
|
7
|
+
*/
|
|
8
|
+
import type { Seed } from '../types.js';
|
|
9
|
+
export interface BackrefSource {
|
|
10
|
+
/** Slug of the seed that owns the relation branch */
|
|
11
|
+
sourceSlug: string;
|
|
12
|
+
/** `branch.alias` — the FK column name (single) or join-table name suffix (multi) */
|
|
13
|
+
branchAlias: string;
|
|
14
|
+
/** `branch.label` — human label for UI grouping */
|
|
15
|
+
branchLabel: string;
|
|
16
|
+
/** 'single' for direct FK, 'multi' for many-to-many join table */
|
|
17
|
+
relationship: 'single' | 'multi';
|
|
18
|
+
/** True when branch.onDelete === 'RESTRICT' — used to disable Delete button */
|
|
19
|
+
restricts: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Map: targetSlug → every source that points to it */
|
|
22
|
+
export type BackrefMap = Map<string, BackrefSource[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Builds the back-reference map from an array of seeds.
|
|
25
|
+
* Pure function — no side-effects, safe to call at startup and cache forever.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildBackrefMap(seeds: Seed[]): BackrefMap;
|
|
28
|
+
//# sourceMappingURL=relations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relations.d.ts","sourceRoot":"","sources":["../../../src/engine/query/relations.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAEvC,MAAM,WAAW,aAAa;IAC5B,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAA;IAClB,qFAAqF;IACrF,WAAW,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,kEAAkE;IAClE,YAAY,EAAE,QAAQ,GAAG,OAAO,CAAA;IAChC,+EAA+E;IAC/E,SAAS,EAAE,OAAO,CAAA;CACnB;AAED,uDAAuD;AACvD,MAAM,MAAM,UAAU,GAAG,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAA;AAErD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,UAAU,CAoBzD"}
|