@cosmicdrift/kumiko-framework 0.387.0 → 0.389.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/AGENTS.md +9 -1
- package/agents/examples/anonymous-access.md +1 -1
- package/agents/examples/apex-landing.md +204 -0
- package/agents/examples/api-worker-split.md +64 -0
- package/agents/examples/app-config.md +14 -0
- package/agents/examples/auth-mfa.md +115 -0
- package/agents/examples/env-composition.md +28 -1
- package/agents/examples/golden-path.md +13 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +55 -2
- package/agents/examples/list-table-options.md +466 -0
- package/agents/examples/pdf-audit-export.md +4 -2
- package/agents/examples/prod-boot-options.md +16 -0
- package/agents/examples/rate-limiting.md +48 -0
- package/agents/examples/record-detail-layout.md +692 -0
- package/agents/examples/seed-migration.md +212 -0
- package/agents/examples/state-machine.md +3 -2
- package/agents/examples/tenant-isolation.md +73 -1
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/examples.md +1 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +2 -0
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-routes-shared.d.ts +2 -0
- package/dist/api/auth-routes-shared.js +5 -0
- package/dist/api/client-error-route.js +10 -5
- package/dist/api/entity-read-access.d.ts +2 -1
- package/dist/api/entity-read-access.js +14 -0
- package/dist/api/export/export-cells-reference.d.ts +9 -0
- package/dist/api/export/export-cells-reference.js +21 -0
- package/dist/api/export/export-cells.d.ts +11 -0
- package/dist/api/export/export-cells.js +136 -0
- package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
- package/dist/api/export/export-ciphertext-guard.js +27 -0
- package/dist/api/export/export-columns.d.ts +29 -0
- package/dist/api/export/export-columns.js +85 -0
- package/dist/api/export/export-errors.d.ts +4 -0
- package/dist/api/export/export-errors.js +14 -0
- package/dist/api/export/export-route.d.ts +4 -0
- package/dist/api/export/export-route.js +74 -0
- package/dist/api/export/export-rows.d.ts +13 -0
- package/dist/api/export/export-rows.js +62 -0
- package/dist/api/export/export-screen.d.ts +12 -0
- package/dist/api/export/export-screen.js +47 -0
- package/dist/api/export/export-xlsx-writer.d.ts +4 -0
- package/dist/api/export/export-xlsx-writer.js +21 -0
- package/dist/api/extra-route.d.ts +4 -0
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/pii-leak-guard.d.ts +3 -0
- package/dist/api/pii-leak-guard.js +3 -2
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/routes.d.ts +16 -0
- package/dist/api/routes.js +2 -2
- package/dist/api/server-boot-checks.d.ts +10 -0
- package/dist/api/server-boot-checks.js +157 -0
- package/dist/api/server-event-dispatcher.d.ts +26 -0
- package/dist/api/server-event-dispatcher.js +208 -0
- package/dist/api/server-extra-routes.d.ts +11 -0
- package/dist/api/server-extra-routes.js +206 -0
- package/dist/api/server-middleware.d.ts +20 -0
- package/dist/api/server-middleware.js +211 -0
- package/dist/api/server-routes.d.ts +19 -0
- package/dist/api/server-routes.js +299 -0
- package/dist/api/server-websocket-routes.d.ts +4 -0
- package/dist/api/server-websocket-routes.js +108 -0
- package/dist/api/server-wiring.d.ts +32 -0
- package/dist/api/server-wiring.js +238 -0
- package/dist/api/server.d.ts +10 -15
- package/dist/api/server.js +48 -1251
- package/dist/api/session-cookies.js +4 -4
- package/dist/api/sse-route.d.ts +2 -1
- package/dist/api/sse-route.js +27 -13
- package/dist/config/index.d.ts +3 -1
- package/dist/crypto/owner-transition-rekey.d.ts +5 -1
- package/dist/crypto/owner-transition-rekey.js +1 -1
- package/dist/crypto/pg-kms-adapter.d.ts +1 -0
- package/dist/crypto/pg-kms-adapter.js +12 -18
- package/dist/crypto/pii-field-encryption.d.ts +5 -1
- package/dist/crypto/pii-field-encryption.js +3 -1
- package/dist/db/aggregate-sql.d.ts +23 -0
- package/dist/db/aggregate-sql.js +165 -0
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +88 -0
- package/dist/db/connection.d.ts +4 -5
- package/dist/db/connection.js +29 -20
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +1 -1
- package/dist/db/driver-row-coercion.d.ts +2 -0
- package/dist/db/driver-row-coercion.js +186 -0
- package/dist/db/driver-value-preparation.d.ts +16 -0
- package/dist/db/driver-value-preparation.js +101 -0
- package/dist/db/entity-field-encryption.d.ts +1 -0
- package/dist/db/entity-field-encryption.js +1 -1
- package/dist/db/entity-table-meta.js +103 -110
- package/dist/db/event-store-executor-context.d.ts +2 -1
- package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
- package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +143 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +206 -0
- package/dist/db/event-store-executor-read-paging.d.ts +14 -0
- package/dist/db/event-store-executor-read-paging.js +99 -0
- package/dist/db/event-store-executor-read-rows.d.ts +6 -0
- package/dist/db/event-store-executor-read-rows.js +32 -0
- package/dist/db/event-store-executor-read-search.d.ts +28 -0
- package/dist/db/event-store-executor-read-search.js +221 -0
- package/dist/db/event-store-executor-read-sort.d.ts +41 -0
- package/dist/db/event-store-executor-read-sort.js +130 -0
- package/dist/db/event-store-executor-read-where.d.ts +24 -0
- package/dist/db/event-store-executor-read-where.js +81 -0
- package/dist/db/event-store-executor-read.d.ts +2 -9
- package/dist/db/event-store-executor-read.js +48 -701
- package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
- package/dist/db/event-store-executor-write-atomic.js +41 -0
- package/dist/db/event-store-executor-write-steps.d.ts +17 -0
- package/dist/db/event-store-executor-write-steps.js +95 -0
- package/dist/db/event-store-executor-write.js +405 -509
- package/dist/db/event-store-executor.js +2 -1
- package/dist/db/executor-managed-tables.js +1 -1
- package/dist/db/index.d.ts +7 -3
- package/dist/db/index.js +6 -2
- package/dist/db/list-aggregate-labels.d.ts +14 -0
- package/dist/db/list-aggregate-labels.js +26 -0
- package/dist/db/migrate-generator.js +71 -86
- package/dist/db/migrate-runner.js +7 -4
- package/dist/db/pg-error.js +26 -21
- package/dist/db/queries/backfill-pii.js +3 -6
- package/dist/db/queries/database-now.js +2 -1
- package/dist/db/queries/shadow-swap.js +2 -3
- package/dist/db/query.d.ts +16 -15
- package/dist/db/query.js +76 -804
- package/dist/db/reference-data.js +19 -2
- package/dist/db/statement-timeout.js +2 -1
- package/dist/db/table-builder.js +113 -166
- package/dist/db/table-info.d.ts +10 -0
- package/dist/db/table-info.js +45 -0
- package/dist/db/temporal-value-guards.d.ts +6 -0
- package/dist/db/temporal-value-guards.js +15 -0
- package/dist/db/tenant-db-runner.d.ts +1 -0
- package/dist/db/tenant-db-runner.js +3 -0
- package/dist/db/tenant-db-upsert.d.ts +5 -0
- package/dist/db/tenant-db-upsert.js +91 -0
- package/dist/db/tenant-db.js +50 -10
- package/dist/db/where-clause.d.ts +6 -0
- package/dist/db/where-clause.js +236 -0
- package/dist/derivatives/delete-stored-file.js +2 -1
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
- package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
- package/dist/engine/boot-validator/detail-screens.js +2 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +17 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
- package/dist/engine/boot-validator/form-derivations.js +35 -1
- package/dist/engine/boot-validator/i18n-keys.js +5 -3
- package/dist/engine/boot-validator/index.js +2 -0
- package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
- package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
- package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
- package/dist/engine/boot-validator/list-column-layout.js +41 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
- package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-export-options.js +27 -0
- package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
- package/dist/engine/boot-validator/list-table-options.js +77 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
- package/dist/engine/boot-validator/pii-retention.js +233 -155
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +3 -13
- package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
- package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
- package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
- package/dist/engine/boot-validator/screens-actions.js +8 -1
- package/dist/engine/boot-validator/screens-dashboard.js +2 -5
- package/dist/engine/boot-validator/screens-forms.js +2 -6
- package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +56 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
- package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
- package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
- package/dist/engine/boot-validator/screens-lists.js +24 -11
- package/dist/engine/boot-validator/screens.d.ts +0 -2
- package/dist/engine/boot-validator/screens.js +99 -860
- package/dist/engine/build-app-schema.d.ts +2 -1
- package/dist/engine/build-app-schema.js +65 -97
- package/dist/engine/build-config-feature-schema.js +6 -1
- package/dist/engine/define-feature.js +13 -2
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/entity-handlers.d.ts +13 -0
- package/dist/engine/entity-handlers.js +10 -21
- package/dist/engine/entity-list-runtime-options.d.ts +8 -0
- package/dist/engine/entity-list-runtime-options.js +18 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round1.js +6 -0
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +2 -0
- package/dist/engine/feature-ast/patch.js +5 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -245
- package/dist/engine/feature-ast/pattern-change-schema.js +10 -163
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +211 -0
- package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
- package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
- package/dist/engine/feature-ast/patterns.d.ts +14 -1
- package/dist/engine/feature-ast/patterns.js +44 -51
- package/dist/engine/feature-ast/render.js +52 -87
- package/dist/engine/feature-builder-state.d.ts +1 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/field-access.d.ts +1 -0
- package/dist/engine/field-access.js +12 -0
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +9 -0
- package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
- package/dist/engine/project-app-schema-for-roles.js +6 -4
- package/dist/engine/registry-ingest.js +1 -1
- package/dist/engine/schema-builder.js +228 -212
- package/dist/engine/screen-filter-ops.js +2 -0
- package/dist/engine/screen-helpers.js +1 -1
- package/dist/engine/screen-kind.d.ts +7 -0
- package/dist/engine/screen-kind.js +1 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +58 -17
- package/dist/engine/types/feature.d.ts +1 -0
- package/dist/engine/types/index.d.ts +4 -3
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/engine/validate-projection-allowlist.js +2 -1
- package/dist/entrypoint/index.d.ts +6 -1
- package/dist/entrypoint/index.js +10 -1
- package/dist/errors/docs-url.d.ts +1 -1
- package/dist/errors/docs-url.js +22 -2
- package/dist/es-ops/context.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +17 -8
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +7 -1
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +11 -2
- package/dist/files/file-handle.js +2 -3
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +40 -10
- package/dist/files/provider-resolver.d.ts +1 -0
- package/dist/files/provider-resolver.js +1 -1
- package/dist/files/record-write-check.d.ts +6 -0
- package/dist/files/record-write-check.js +13 -0
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/files/upload-ledger.js +11 -4
- package/dist/i18n/required-surface-keys.js +189 -208
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/internal/testing.js +1 -1
- package/dist/jobs/job-runner-context.d.ts +32 -0
- package/dist/jobs/job-runner-context.js +305 -0
- package/dist/jobs/job-runner-execution.d.ts +7 -0
- package/dist/jobs/job-runner-execution.js +160 -0
- package/dist/jobs/job-runner-gates.d.ts +19 -0
- package/dist/jobs/job-runner-gates.js +198 -0
- package/dist/jobs/job-runner-helpers.d.ts +17 -3
- package/dist/jobs/job-runner-helpers.js +61 -2
- package/dist/jobs/job-runner-scope.d.ts +44 -0
- package/dist/jobs/job-runner-scope.js +102 -0
- package/dist/jobs/job-runner-types.d.ts +12 -0
- package/dist/jobs/job-runner.d.ts +0 -1
- package/dist/jobs/job-runner.js +50 -657
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +21 -3
- package/dist/pipeline/after-rollback-sink.d.ts +6 -0
- package/dist/pipeline/after-rollback-sink.js +32 -0
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-batch.js +8 -1
- package/dist/pipeline/dispatch-query.js +10 -3
- package/dist/pipeline/dispatch-shared.d.ts +13 -3
- package/dist/pipeline/dispatch-shared.js +62 -710
- package/dist/pipeline/dispatch-write.js +296 -189
- package/dist/pipeline/event-dispatcher-admin.js +2 -1
- package/dist/pipeline/event-dispatcher-delivery.js +6 -6
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +6 -9
- package/dist/pipeline/handler-context-auth.d.ts +9 -0
- package/dist/pipeline/handler-context-auth.js +54 -0
- package/dist/pipeline/handler-context-config.d.ts +14 -0
- package/dist/pipeline/handler-context-config.js +113 -0
- package/dist/pipeline/handler-context-db.d.ts +24 -0
- package/dist/pipeline/handler-context-db.js +149 -0
- package/dist/pipeline/handler-context-dispatch.d.ts +23 -0
- package/dist/pipeline/handler-context-dispatch.js +101 -0
- package/dist/pipeline/handler-context-events.d.ts +41 -0
- package/dist/pipeline/handler-context-events.js +239 -0
- package/dist/pipeline/handler-context-notify.d.ts +12 -0
- package/dist/pipeline/handler-context-notify.js +55 -0
- package/dist/pipeline/handler-context-observability.d.ts +7 -0
- package/dist/pipeline/handler-context-observability.js +21 -0
- package/dist/pipeline/handler-context-scope.d.ts +48 -0
- package/dist/pipeline/handler-context-scope.js +88 -0
- package/dist/pipeline/notify-scope.js +12 -1
- package/dist/pipeline/pre-handler-gates.js +20 -4
- package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
- package/dist/pipeline/pre-transaction-reservations.js +9 -3
- package/dist/pipeline/projection-rebuild.js +2 -1
- package/dist/pipeline/system-hooks.d.ts +3 -1
- package/dist/pipeline/system-hooks.js +55 -6
- package/dist/pipeline/write-result-masking.js +13 -10
- package/dist/schema-cli/apply-command.d.ts +2 -0
- package/dist/schema-cli/apply-command.js +97 -0
- package/dist/schema-cli/baseline-command.d.ts +2 -0
- package/dist/schema-cli/baseline-command.js +33 -0
- package/dist/schema-cli/generate-command.d.ts +2 -0
- package/dist/schema-cli/generate-command.js +90 -0
- package/dist/schema-cli/shared.d.ts +39 -0
- package/dist/schema-cli/shared.js +95 -0
- package/dist/schema-cli/status-command.d.ts +2 -0
- package/dist/schema-cli/status-command.js +73 -0
- package/dist/schema-cli/validate-boot-input.d.ts +5 -0
- package/dist/schema-cli/validate-boot-input.js +130 -0
- package/dist/schema-cli/validate-command.d.ts +2 -0
- package/dist/schema-cli/validate-command.js +130 -0
- package/dist/schema-cli.d.ts +2 -18
- package/dist/schema-cli.js +47 -604
- package/dist/search/meilisearch-adapter.js +2 -1
- package/dist/search/reindex-entity.js +15 -5
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-changelog.d.ts +2 -0
- package/dist/upgrade-changelog.js +14 -0
- package/dist/upgrade-cli.d.ts +3 -12
- package/dist/upgrade-cli.js +171 -197
- package/dist/upgrade-marker-parse.d.ts +32 -0
- package/dist/upgrade-marker-parse.js +73 -0
- package/package.json +9 -6
- package/src/changes.json +555 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/db-factory.ts +1 -1
- package/src/scripts/codemod/feature-layout.ts +10 -2
- package/src/scripts/codemod/object-form.ts +41 -4
- package/src/scripts/codemod/package-merge.ts +58 -8
- package/src/scripts/codemod/pii-personal-migration.ts +175 -145
- package/src/scripts/codemod/testing-injection.ts +122 -42
- package/src/scripts/codemod/testing-move.ts +29 -7
- package/dist/engine/provider-plugin-context.d.ts +0 -3
- package/dist/engine/provider-plugin-context.js +0 -28
|
@@ -35,6 +35,10 @@ back.
|
|
|
35
35
|
- **`r.hook({ type: "postDelete", target: { allOf: entityRef }, handler })`** — fires after commit.
|
|
36
36
|
External side effects (logs, notifications) belong here because
|
|
37
37
|
failures cannot roll the delete back.
|
|
38
|
+
- **`ctx.scheduleAfterRollback(fn)`** — the counterpart of `scheduleAfterCommit`: runs `fn` when
|
|
39
|
+
the handler transaction (or the enclosing savepoint of a nested `ctx.write`) does not commit.
|
|
40
|
+
Use it to give back something reserved outside the transaction, like the slot in
|
|
41
|
+
`article:reserve-slot`. A failed COMMIT triggers it too, so write the callback idempotent.
|
|
38
42
|
|
|
39
43
|
## When to reach for it
|
|
40
44
|
|
|
@@ -71,7 +75,7 @@ import {
|
|
|
71
75
|
type TestStack,
|
|
72
76
|
unsafeCreateEntityTable,
|
|
73
77
|
} from "@cosmicdrift/kumiko-testing/stack";
|
|
74
|
-
import { articleEntity, articlesFeature, hookLog } from "../feature";
|
|
78
|
+
import { articleEntity, articlesFeature, hookLog, reservedSlots } from "../feature";
|
|
75
79
|
|
|
76
80
|
let stack: TestStack;
|
|
77
81
|
|
|
@@ -90,6 +94,7 @@ afterAll(async () => {
|
|
|
90
94
|
beforeEach(() => {
|
|
91
95
|
stack.events.reset();
|
|
92
96
|
hookLog.length = 0;
|
|
97
|
+
reservedSlots.clear();
|
|
93
98
|
});
|
|
94
99
|
|
|
95
100
|
describe("validation hook: single handler", () => {
|
|
@@ -256,6 +261,29 @@ describe("preDelete + postDelete entity hooks", () => {
|
|
|
256
261
|
expect(hookLog.map((e) => e.type)).toEqual(["deleted"]);
|
|
257
262
|
});
|
|
258
263
|
});
|
|
264
|
+
|
|
265
|
+
describe("scheduleAfterRollback", () => {
|
|
266
|
+
test("keeps the outside-transaction reservation when the write commits", async () => {
|
|
267
|
+
await stack.http.writeOk(
|
|
268
|
+
"blog:write:article:reserve-slot",
|
|
269
|
+
{ slot: "kept", reject: false },
|
|
270
|
+
editor,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(reservedSlots.has("kept")).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("gives the reservation back when the write rolls back", async () => {
|
|
277
|
+
const error = await stack.http.writeErr(
|
|
278
|
+
"blog:write:article:reserve-slot",
|
|
279
|
+
{ slot: "released", reject: true },
|
|
280
|
+
editor,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expectErrorIncludes(error, "slot_rejected");
|
|
284
|
+
expect(reservedSlots.has("released")).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
259
287
|
```
|
|
260
288
|
|
|
261
289
|
### src/feature.ts
|
|
@@ -276,7 +304,12 @@ import {
|
|
|
276
304
|
defineFeature,
|
|
277
305
|
type SaveContext,
|
|
278
306
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
279
|
-
import {
|
|
307
|
+
import {
|
|
308
|
+
ConflictError,
|
|
309
|
+
UnprocessableError,
|
|
310
|
+
writeFailure,
|
|
311
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
312
|
+
import * as z from "zod";
|
|
280
313
|
|
|
281
314
|
export const articleEntity = createEntity({
|
|
282
315
|
table: "read_sample_articles",
|
|
@@ -298,6 +331,9 @@ export const articleEntity = createEntity({
|
|
|
298
331
|
// Collect hook events for testing
|
|
299
332
|
export const hookLog: { type: string; data: Record<string, unknown> }[] = [];
|
|
300
333
|
|
|
334
|
+
// Stands in for a quota held in an external system, outside the handler transaction.
|
|
335
|
+
export const reservedSlots = new Set<string>();
|
|
336
|
+
|
|
301
337
|
const editorWrite = { access: { roles: ["Admin", "Editor"] } } as const;
|
|
302
338
|
|
|
303
339
|
export const articlesFeature = defineFeature("blog", (r) => {
|
|
@@ -311,6 +347,23 @@ export const articlesFeature = defineFeature("blog", (r) => {
|
|
|
311
347
|
);
|
|
312
348
|
r.writeHandler(defineEntityDeleteHandler("article", articleEntity, editorWrite));
|
|
313
349
|
|
|
350
|
+
// --- scheduleAfterRollback: give back a reservation made outside the transaction when it does
|
|
351
|
+
// not commit. The callback must be idempotent: a failed COMMIT also triggers it.
|
|
352
|
+
r.writeHandler({
|
|
353
|
+
name: "article:reserve-slot",
|
|
354
|
+
schema: z.object({ slot: z.string(), reject: z.boolean() }),
|
|
355
|
+
access: editorWrite.access,
|
|
356
|
+
handler: async (event, ctx) => {
|
|
357
|
+
const { slot, reject } = event.payload;
|
|
358
|
+
reservedSlots.add(slot);
|
|
359
|
+
ctx.scheduleAfterRollback?.(async () => {
|
|
360
|
+
reservedSlots.delete(slot);
|
|
361
|
+
});
|
|
362
|
+
if (reject) return writeFailure(new UnprocessableError("slot_rejected"));
|
|
363
|
+
return { isSuccess: true as const, data: {} };
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
314
367
|
// --- Validation hook on create: reject banned words + length ---
|
|
315
368
|
r.hook({
|
|
316
369
|
type: "validation",
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/list-table-options. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# list-table-options
|
|
4
|
+
|
|
5
|
+
Opt-in table behaviour of a list: remembered search/sort/filters, one reset button, column picker with hidden columns, row density, server-side groups with sums, a totals row and a number range facet.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# List table options
|
|
10
|
+
|
|
11
|
+
Opt-in table behaviour for entity, projection and related lists. A list without
|
|
12
|
+
the `table` block renders and behaves as before.
|
|
13
|
+
|
|
14
|
+
## What it shows
|
|
15
|
+
|
|
16
|
+
- `table.rememberState`: search, sort, page size, facets and date range survive
|
|
17
|
+
leaving and re-entering the list within the browser session.
|
|
18
|
+
- `table.resetAll`: one button resets everything that differs from the defaults.
|
|
19
|
+
- `table.columnPicker`: the view menu lets the user hide and reorder columns;
|
|
20
|
+
`hidden: true` on a column starts it hidden.
|
|
21
|
+
- `table.density`: row density default and an optional user choice.
|
|
22
|
+
- `table.pinning`: the user pins columns left or right in the view menu;
|
|
23
|
+
`pinned: "left"` on a column starts it pinned.
|
|
24
|
+
- `table.resizing`: columns can be resized (table-layout fixed); `width` and
|
|
25
|
+
`minWidth` on a column are start values in px.
|
|
26
|
+
- `table.keyboardNavigation`: rows are one tab stop, arrow keys move between rows,
|
|
27
|
+
Space selects the focused row.
|
|
28
|
+
- `table.export`: an Export button downloads the whole filtered list as an XLSX
|
|
29
|
+
file; `maxRows` lowers the default limit of 10000 rows.
|
|
30
|
+
Reference columns export their label and timestamps use the browser time zone.
|
|
31
|
+
- `bulkActions`: row checkboxes and a selection bar. "Delete" runs the delete
|
|
32
|
+
row action once per selected row; done tasks are skipped.
|
|
33
|
+
- `table.grouping`: the server groups the list by status; each header shows the
|
|
34
|
+
group's count and estimate sum, "done" starts collapsed and the view menu
|
|
35
|
+
offers grouping by priority or none.
|
|
36
|
+
- `table.totals`: a footer row with the estimate sum over the filtered list.
|
|
37
|
+
- `facets.estimateHours: { display: "range" }`: from/to inputs for a number field.
|
|
38
|
+
- The same block on a `projectionList`, and `task:workload`, a hand-written list
|
|
39
|
+
query that keeps grouping and totals by accepting `aggregate`
|
|
40
|
+
(`entityListSchema`) and answering it with `answerListAggregate`.
|
|
41
|
+
|
|
42
|
+
## Source
|
|
43
|
+
|
|
44
|
+
Feature entry point: `src/feature.ts`.
|
|
45
|
+
|
|
46
|
+
## Tests
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd samples/recipes/list-table-options
|
|
50
|
+
bun test
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The test boots the feature through the boot validator and checks that a
|
|
54
|
+
misspelled key, a hidden column without the picker, an unknown density, a pinned column
|
|
55
|
+
without `pinning`, a width without `resizing` and a bulk action on a navigate
|
|
56
|
+
row action are rejected.
|
|
57
|
+
`workload.integration.test.ts` runs the hand-written query over HTTP and checks
|
|
58
|
+
the grouped sums and the filtered total.
|
|
59
|
+
|
|
60
|
+
## Source files
|
|
61
|
+
|
|
62
|
+
### src/__tests__/feature.test.ts
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { describe, expect, test } from "bun:test";
|
|
66
|
+
import {
|
|
67
|
+
createRegistry,
|
|
68
|
+
defineFeature,
|
|
69
|
+
validateBoot as validateBootRaw,
|
|
70
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
71
|
+
import type { ListTableOptions } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
72
|
+
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
|
|
73
|
+
import { createTaskBoardFeature, openAccess, taskEntity } from "../feature";
|
|
74
|
+
|
|
75
|
+
// The boot validator is the net for blocks the type system never sees (JS, generated features).
|
|
76
|
+
function featureWithBlock(block: object) {
|
|
77
|
+
return createTaskBoardFeature(block as ListTableOptions);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
81
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("list-table-options recipe", () => {
|
|
85
|
+
test("the full table block boots and reaches the registered screen", () => {
|
|
86
|
+
const feature = createTaskBoardFeature();
|
|
87
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
88
|
+
const screen = createRegistry([feature]).getScreen("task-board:screen:task-list");
|
|
89
|
+
expect(screen?.type === "entityList" ? screen.table : undefined).toEqual({
|
|
90
|
+
rememberState: true,
|
|
91
|
+
resetAll: true,
|
|
92
|
+
columnPicker: true,
|
|
93
|
+
density: { default: "comfortable", userChoice: true },
|
|
94
|
+
pinning: true,
|
|
95
|
+
resizing: true,
|
|
96
|
+
keyboardNavigation: true,
|
|
97
|
+
export: { xlsx: true, maxRows: 5000 },
|
|
98
|
+
grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
|
|
99
|
+
totals: [{ field: "estimateHours", kind: "sum" }],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the bulk action resolves to the delete row action", () => {
|
|
104
|
+
const screen = createRegistry([createTaskBoardFeature()]).getScreen(
|
|
105
|
+
"task-board:screen:task-list",
|
|
106
|
+
);
|
|
107
|
+
expect(screen?.type === "entityList" ? screen.bulkActions : undefined).toEqual([
|
|
108
|
+
{ id: "delete", rowAction: "delete" },
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a bulk action that points at a navigate row action is rejected", () => {
|
|
113
|
+
const feature = defineFeature("bulk-on-navigate", (r) => {
|
|
114
|
+
r.crud("task", taskEntity, { write: openAccess, read: openAccess });
|
|
115
|
+
r.screen({
|
|
116
|
+
id: "task-list",
|
|
117
|
+
type: "entityList",
|
|
118
|
+
entity: "task",
|
|
119
|
+
columns: ["title"],
|
|
120
|
+
rowActions: [{ kind: "navigate", id: "open", label: "Open", screen: "task-list" }],
|
|
121
|
+
bulkActions: [{ id: "open", rowAction: "open" }],
|
|
122
|
+
access: openAccess.access,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
126
|
+
/bulk action "open" points at rowAction "open" of kind "navigate"/,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a pinned column needs table.pinning", () => {
|
|
131
|
+
expect(() => validateBoot([featureWithBlock({ columnPicker: true, resizing: true })])).toThrow(
|
|
132
|
+
/column "title" is pinned but table\.pinning is not enabled/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("width and minWidth need table.resizing", () => {
|
|
137
|
+
expect(() => validateBoot([featureWithBlock({ columnPicker: true, pinning: true })])).toThrow(
|
|
138
|
+
/column "title" sets width but table\.resizing is not enabled/,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a misspelled table key is rejected with the allowed keys", () => {
|
|
143
|
+
expect(() => validateBoot([featureWithBlock({ rememberStatee: true })])).toThrow(
|
|
144
|
+
/unknown key\(s\) "rememberStatee"\. Allowed keys: rememberState, resetAll, columnPicker, density, pinning, resizing, keyboardNavigation, export/,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a hidden column needs the column picker", () => {
|
|
149
|
+
expect(() => validateBoot([featureWithBlock({ rememberState: true, resetAll: true })])).toThrow(
|
|
150
|
+
/column "estimateHours" is hidden but table\.columnPicker is not enabled/,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a density outside the union is rejected", () => {
|
|
155
|
+
expect(() =>
|
|
156
|
+
validateBoot([featureWithBlock({ columnPicker: true, density: { default: "tight" } })]),
|
|
157
|
+
).toThrow(/table\.density\.default is "tight"/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("an export row limit above the hard cap is rejected", () => {
|
|
161
|
+
expect(() =>
|
|
162
|
+
validateBoot([
|
|
163
|
+
featureWithBlock({ columnPicker: true, export: { xlsx: true, maxRows: 100001 } }),
|
|
164
|
+
]),
|
|
165
|
+
).toThrow(/table\.export\.maxRows is 100001 .* at most 100000/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### src/__tests__/workload.integration.test.ts
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
174
|
+
import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
|
|
175
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
176
|
+
import { createTaskBoardFeature } from "../feature";
|
|
177
|
+
|
|
178
|
+
type ListAggregateResult = {
|
|
179
|
+
readonly groups: readonly { key: unknown; count: number; measures: readonly unknown[] }[];
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
let stack: TestStack;
|
|
183
|
+
const user = createTestUser({ roles: ["User"], tenantId: testTenantId(1) });
|
|
184
|
+
|
|
185
|
+
const TASKS = [
|
|
186
|
+
{ title: "Quarterly VAT filing", assignee: "Ines", estimateHours: 6 },
|
|
187
|
+
{ title: "Prepare audit", assignee: "Ines", estimateHours: 24 },
|
|
188
|
+
{ title: "Plan offsite", assignee: "Mara", estimateHours: 7 },
|
|
189
|
+
] as const;
|
|
190
|
+
|
|
191
|
+
beforeAll(async () => {
|
|
192
|
+
stack = await setupTestStack({ features: [createTaskBoardFeature()], systemHooks: [] });
|
|
193
|
+
for (const task of TASKS) {
|
|
194
|
+
await stack.http.writeOk("task-board:write:task:create", task, user);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
afterAll(async () => {
|
|
199
|
+
await stack.cleanup();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("hand-written list query with aggregate", () => {
|
|
203
|
+
test("groups by assignee with the server count and estimate sum", async () => {
|
|
204
|
+
const result = await stack.http.queryOk<ListAggregateResult>(
|
|
205
|
+
"task-board:query:task:workload",
|
|
206
|
+
{ aggregate: { groupBy: "assignee", measures: [{ field: "estimateHours", kind: "sum" }] } },
|
|
207
|
+
user,
|
|
208
|
+
);
|
|
209
|
+
expect(result.groups.map(({ key, count, measures }) => ({ key, count, measures }))).toEqual([
|
|
210
|
+
{ key: "Ines", count: 2, measures: [30] },
|
|
211
|
+
{ key: "Mara", count: 1, measures: [7] },
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("the totals answer follows the list filter", async () => {
|
|
216
|
+
const result = await stack.http.queryOk<ListAggregateResult>(
|
|
217
|
+
"task-board:query:task:workload",
|
|
218
|
+
{
|
|
219
|
+
filters: [{ field: "estimateHours", op: "lte", value: 7 }],
|
|
220
|
+
aggregate: { measures: [{ field: "estimateHours", kind: "sum" }] },
|
|
221
|
+
},
|
|
222
|
+
user,
|
|
223
|
+
);
|
|
224
|
+
expect(result.groups.map((group) => group.measures)).toEqual([[13]]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("without aggregate the same query lists rows", async () => {
|
|
228
|
+
const list = await stack.http.queryOk<{ rows: { title: string }[] }>(
|
|
229
|
+
"task-board:query:task:workload",
|
|
230
|
+
{ sort: "title" },
|
|
231
|
+
user,
|
|
232
|
+
);
|
|
233
|
+
expect(list.rows.map((row) => row.title)).toEqual([
|
|
234
|
+
"Plan offsite",
|
|
235
|
+
"Prepare audit",
|
|
236
|
+
"Quarterly VAT filing",
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### src/feature.ts
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
import {
|
|
246
|
+
answerListAggregate,
|
|
247
|
+
buildEntityTable,
|
|
248
|
+
createEventStoreExecutor,
|
|
249
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
250
|
+
import {
|
|
251
|
+
createDateField,
|
|
252
|
+
createEntity,
|
|
253
|
+
createNumberField,
|
|
254
|
+
createSelectField,
|
|
255
|
+
createTextField,
|
|
256
|
+
defineFeature,
|
|
257
|
+
entityListSchema,
|
|
258
|
+
type FeatureDefinition,
|
|
259
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
260
|
+
import type { ListTableOptions } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
261
|
+
import { z } from "zod";
|
|
262
|
+
|
|
263
|
+
export const taskEntity = createEntity({
|
|
264
|
+
table: "read_list_table_tasks",
|
|
265
|
+
fields: {
|
|
266
|
+
title: createTextField({
|
|
267
|
+
personal: false,
|
|
268
|
+
reason: "is_business_data",
|
|
269
|
+
required: true,
|
|
270
|
+
searchable: true,
|
|
271
|
+
sortable: true,
|
|
272
|
+
}),
|
|
273
|
+
status: createSelectField({
|
|
274
|
+
options: ["open", "inProgress", "blocked", "done"] as const,
|
|
275
|
+
default: "open",
|
|
276
|
+
sortable: true,
|
|
277
|
+
filterable: true,
|
|
278
|
+
}),
|
|
279
|
+
priority: createSelectField({
|
|
280
|
+
options: ["low", "medium", "high"] as const,
|
|
281
|
+
default: "medium",
|
|
282
|
+
sortable: true,
|
|
283
|
+
filterable: true,
|
|
284
|
+
}),
|
|
285
|
+
dueDate: createDateField({ sortable: true }),
|
|
286
|
+
assignee: createTextField({ personal: false, reason: "is_business_data", sortable: true }),
|
|
287
|
+
estimateHours: createNumberField({ sortable: true, filterable: true }),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
export const openAccess = {
|
|
292
|
+
access: {
|
|
293
|
+
openToAll: { reason: "recipe: any signed-in user manages the tasks; no per-user ownership" },
|
|
294
|
+
},
|
|
295
|
+
} as const;
|
|
296
|
+
|
|
297
|
+
// Overridable so a test can feed the boot validator a block that must be rejected.
|
|
298
|
+
export function createTaskBoardFeature(
|
|
299
|
+
taskTable: ListTableOptions = {
|
|
300
|
+
rememberState: true,
|
|
301
|
+
resetAll: true,
|
|
302
|
+
columnPicker: true,
|
|
303
|
+
density: { default: "comfortable", userChoice: true },
|
|
304
|
+
pinning: true,
|
|
305
|
+
resizing: true,
|
|
306
|
+
keyboardNavigation: true,
|
|
307
|
+
export: { xlsx: true, maxRows: 5000 },
|
|
308
|
+
grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
|
|
309
|
+
totals: [{ field: "estimateHours", kind: "sum" }],
|
|
310
|
+
},
|
|
311
|
+
): FeatureDefinition {
|
|
312
|
+
return defineFeature("task-board", (r) => {
|
|
313
|
+
r.crud("task", taskEntity, { write: openAccess, read: openAccess });
|
|
314
|
+
|
|
315
|
+
r.screen({
|
|
316
|
+
id: "task-list",
|
|
317
|
+
type: "entityList",
|
|
318
|
+
entity: "task",
|
|
319
|
+
columns: [
|
|
320
|
+
// Starts pinned left; width and minWidth are start values for resizing.
|
|
321
|
+
{ field: "title", pinned: "left", width: 280, minWidth: 160 },
|
|
322
|
+
"status",
|
|
323
|
+
"priority",
|
|
324
|
+
{ field: "dueDate", hideOnNarrow: true },
|
|
325
|
+
{ field: "assignee", hideOnNarrow: true },
|
|
326
|
+
// Starts hidden; the user can add it back in the view menu.
|
|
327
|
+
{ field: "estimateHours", hidden: true, hideOnNarrow: true },
|
|
328
|
+
],
|
|
329
|
+
facets: { status: { display: "chips" }, priority: {}, estimateHours: { display: "range" } },
|
|
330
|
+
defaultSort: { field: "dueDate", dir: "asc" },
|
|
331
|
+
searchable: true,
|
|
332
|
+
table: taskTable,
|
|
333
|
+
rowActions: [
|
|
334
|
+
{ kind: "navigate", id: "open", label: "Open", screen: "task-edit", rowClick: true },
|
|
335
|
+
{
|
|
336
|
+
kind: "writeHandler",
|
|
337
|
+
id: "delete",
|
|
338
|
+
label: "Delete",
|
|
339
|
+
handler: "task-board:write:task:delete",
|
|
340
|
+
style: "danger",
|
|
341
|
+
// Done tasks are skipped by the bulk run and counted as skipped.
|
|
342
|
+
visible: { field: "status", ne: "done" },
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
// Row checkboxes and a selection bar; each selected row gets its own delete write.
|
|
346
|
+
bulkActions: [{ id: "delete", rowAction: "delete" }],
|
|
347
|
+
access: openAccess.access,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
r.screen({
|
|
351
|
+
id: "task-edit",
|
|
352
|
+
type: "entityEdit",
|
|
353
|
+
entity: "task",
|
|
354
|
+
dormant: true,
|
|
355
|
+
listScreenId: "task-list",
|
|
356
|
+
layout: {
|
|
357
|
+
sections: [
|
|
358
|
+
{
|
|
359
|
+
columns: 2,
|
|
360
|
+
fields: ["title", "assignee", "status", "priority", "dueDate", "estimateHours"],
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// The same block works on a list over any query.
|
|
367
|
+
r.queryHandler({
|
|
368
|
+
name: "task:overdue",
|
|
369
|
+
schema: z.object({}),
|
|
370
|
+
handler: async () => ({
|
|
371
|
+
rows: [{ id: "t1", title: "Quarterly VAT filing", assignee: "Ines", daysLate: 3 }],
|
|
372
|
+
nextCursor: null,
|
|
373
|
+
}),
|
|
374
|
+
access: openAccess.access,
|
|
375
|
+
outputSchema: z.object({
|
|
376
|
+
rows: z.array(
|
|
377
|
+
z.object({
|
|
378
|
+
id: z.string(),
|
|
379
|
+
title: z.string(),
|
|
380
|
+
assignee: z.string(),
|
|
381
|
+
daysLate: z.number(),
|
|
382
|
+
}),
|
|
383
|
+
),
|
|
384
|
+
nextCursor: z.string().nullable(),
|
|
385
|
+
}),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
r.screen({
|
|
389
|
+
id: "task-overdue",
|
|
390
|
+
type: "projectionList",
|
|
391
|
+
query: "task-board:query:task:overdue",
|
|
392
|
+
columns: ["title", "assignee", "daysLate"],
|
|
393
|
+
table: { rememberState: true, density: { userChoice: true } },
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// A hand-written list query keeps grouping and totals when it accepts
|
|
397
|
+
// `aggregate` (entityListSchema carries it) and answers it on the server.
|
|
398
|
+
const taskExecutor = createEventStoreExecutor(
|
|
399
|
+
buildEntityTable("task", taskEntity),
|
|
400
|
+
taskEntity,
|
|
401
|
+
{
|
|
402
|
+
entityName: "task",
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
r.queryHandler({
|
|
406
|
+
name: "task:workload",
|
|
407
|
+
schema: entityListSchema,
|
|
408
|
+
handler: async (query, ctx) => {
|
|
409
|
+
const parentVisibility = { entities: ctx.registry.getAllEntities() };
|
|
410
|
+
const listPayload = query.payload;
|
|
411
|
+
if (listPayload.aggregate !== undefined) {
|
|
412
|
+
return answerListAggregate(
|
|
413
|
+
taskExecutor,
|
|
414
|
+
{ ...listPayload, aggregate: listPayload.aggregate },
|
|
415
|
+
query.user,
|
|
416
|
+
ctx.db,
|
|
417
|
+
{ parentVisibility },
|
|
418
|
+
{
|
|
419
|
+
entity: taskEntity,
|
|
420
|
+
resolveEntity: (name) => ctx.registry.getEntity(name),
|
|
421
|
+
viewer: { user: query.user, parentVisibility },
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return taskExecutor.list(listPayload, query.user, ctx.db, { parentVisibility });
|
|
426
|
+
},
|
|
427
|
+
access: openAccess.access,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
r.screen({
|
|
431
|
+
id: "task-workload",
|
|
432
|
+
type: "projectionList",
|
|
433
|
+
query: "task-board:query:task:workload",
|
|
434
|
+
columns: ["title", "assignee", "status", "estimateHours"],
|
|
435
|
+
defaultSort: { field: "title", dir: "asc" },
|
|
436
|
+
table: {
|
|
437
|
+
grouping: { field: "assignee" },
|
|
438
|
+
totals: [{ field: "estimateHours", kind: "sum" }],
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
r.nav({ id: "tasks", label: "task-board:nav.tasks", icon: "list", order: 10 });
|
|
443
|
+
r.nav({
|
|
444
|
+
id: "task-list",
|
|
445
|
+
label: "task-board:nav.list",
|
|
446
|
+
parent: "task-board:nav:tasks",
|
|
447
|
+
screen: "task-board:screen:task-list",
|
|
448
|
+
order: 10,
|
|
449
|
+
});
|
|
450
|
+
r.nav({
|
|
451
|
+
id: "task-overdue",
|
|
452
|
+
label: "task-board:nav.overdue",
|
|
453
|
+
parent: "task-board:nav:tasks",
|
|
454
|
+
screen: "task-board:screen:task-overdue",
|
|
455
|
+
order: 20,
|
|
456
|
+
});
|
|
457
|
+
r.nav({
|
|
458
|
+
id: "task-workload",
|
|
459
|
+
label: "task-board:nav.workload",
|
|
460
|
+
parent: "task-board:nav:tasks",
|
|
461
|
+
screen: "task-board:screen:task-workload",
|
|
462
|
+
order: 30,
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
```
|
|
@@ -42,6 +42,7 @@ bun test
|
|
|
42
42
|
// and its text contains exactly the inspections of the requested period.
|
|
43
43
|
|
|
44
44
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
45
|
+
import { MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
|
|
45
46
|
import {
|
|
46
47
|
createFilesFeature,
|
|
47
48
|
createInMemoryFileProvider,
|
|
@@ -150,7 +151,7 @@ describe("export-audit", () => {
|
|
|
150
151
|
|
|
151
152
|
test("a period with more inspections than the export holds is rejected instead of cut off", async () => {
|
|
152
153
|
const assetName = "Großanlage";
|
|
153
|
-
for (let day = 0; day <
|
|
154
|
+
for (let day = 0; day < MAX_LIST_LIMIT; day += 1) {
|
|
154
155
|
const inspectedOn = new Date(Date.UTC(2025, 0, 1 + day)).toISOString().slice(0, 10);
|
|
155
156
|
await addInspection(inspectedOn, "ok", `Prüfer-${day}`, assetName);
|
|
156
157
|
}
|
|
@@ -185,6 +186,7 @@ import {
|
|
|
185
186
|
createTextField,
|
|
186
187
|
defineFeature,
|
|
187
188
|
defineWriteHandler,
|
|
189
|
+
MAX_LIST_LIMIT,
|
|
188
190
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
189
191
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
190
192
|
import {
|
|
@@ -221,7 +223,7 @@ function toFileNameSegment(text: string): string {
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.
|
|
224
|
-
const AUDIT_ROW_LIMIT =
|
|
226
|
+
const AUDIT_ROW_LIMIT = MAX_LIST_LIMIT - 1;
|
|
225
227
|
|
|
226
228
|
const access = { access: { roles: ["Admin", "User"] } } as const;
|
|
227
229
|
|
|
@@ -57,6 +57,22 @@ bun test samples/recipes/prod-boot-options
|
|
|
57
57
|
|
|
58
58
|
## Source files
|
|
59
59
|
|
|
60
|
+
### bin/server.ts
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
|
|
64
|
+
import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
|
|
65
|
+
import { appFeatures, bootOptions } from "../src/app";
|
|
66
|
+
|
|
67
|
+
const features = appFeatures();
|
|
68
|
+
|
|
69
|
+
await runProdApp({
|
|
70
|
+
features,
|
|
71
|
+
envSchema: composeEnvSchema({ features }),
|
|
72
|
+
...bootOptions(process.env),
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
60
76
|
### src/__tests__/boot-options.test.ts
|
|
61
77
|
|
|
62
78
|
```ts
|
|
@@ -36,6 +36,24 @@ exhausted) and no bucket is charged. Each `per` may appear once; an empty list f
|
|
|
36
36
|
An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
|
|
37
37
|
stays a single declaration.
|
|
38
38
|
|
|
39
|
+
## Cost per payload element
|
|
40
|
+
|
|
41
|
+
A bulk handler books one token per item instead of one per request. Declare `rateLimitCost`
|
|
42
|
+
next to `rateLimit`:
|
|
43
|
+
|
|
44
|
+
```ts illustration
|
|
45
|
+
r.writeHandler({
|
|
46
|
+
// ...
|
|
47
|
+
rateLimit: { per: "tenant+handler", limit: 20, windowSeconds: 600 },
|
|
48
|
+
rateLimitCost: (payload) => payload.files.length,
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The cost is computed from the validated payload and booked on every declared bucket, so an
|
|
53
|
+
invalid payload costs nothing and a rejected request consumes nothing. Results below 1 count
|
|
54
|
+
as 1. Because it books after the parse, boot rejects `rateLimitCost` on anonymous or ip-limited
|
|
55
|
+
handlers.
|
|
56
|
+
|
|
39
57
|
## Per-payload limits
|
|
40
58
|
|
|
41
59
|
A public write handler that mails an address typed by the caller is also limited per
|
|
@@ -229,6 +247,24 @@ describe("L2 — auth-endpoints middleware (tighter cap on /api/auth/*)", () =>
|
|
|
229
247
|
expect(body.error.details.bucket).toBe("l2");
|
|
230
248
|
});
|
|
231
249
|
});
|
|
250
|
+
|
|
251
|
+
describe("L3 — cost per payload element (bulk-upload)", () => {
|
|
252
|
+
const bulkUpload = "rl-showcase:write:bulk-upload";
|
|
253
|
+
const files = (count: number): { files: string[] } => ({
|
|
254
|
+
files: Array.from({ length: count }, (_, index) => `file-${index}.pdf`),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a request books one token per file; a request over the remaining budget is rejected", async () => {
|
|
258
|
+
expect((await stack.http.write(bulkUpload, files(10), admin)).status).toBe(200);
|
|
259
|
+
expect((await stack.http.write(bulkUpload, files(15), admin)).status).toBe(429);
|
|
260
|
+
// The rejected request consumed nothing: 10 more still fit.
|
|
261
|
+
expect((await stack.http.write(bulkUpload, files(10), admin)).status).toBe(200);
|
|
262
|
+
const blocked = await stack.http.write(bulkUpload, files(1), admin);
|
|
263
|
+
expect(blocked.status).toBe(429);
|
|
264
|
+
const body = (await blocked.json()) as { error: { code: string } };
|
|
265
|
+
expect(body.error.code).toBe("rate_limited");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
232
268
|
```
|
|
233
269
|
|
|
234
270
|
### src/feature.ts
|
|
@@ -269,6 +305,18 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
|
|
|
269
305
|
{ per: "tenant+handler", limit: 3, windowSeconds: 3600 },
|
|
270
306
|
],
|
|
271
307
|
});
|
|
308
|
+
|
|
309
|
+
// Bulk upload: a request books one token per item instead of one per request. The cost
|
|
310
|
+
// comes from the validated payload, so a schema-invalid request costs nothing; a request
|
|
311
|
+
// that would exceed the remaining budget is rejected and consumes nothing.
|
|
312
|
+
r.writeHandler({
|
|
313
|
+
name: "bulk-upload",
|
|
314
|
+
schema: z.object({ files: z.array(z.string()).min(1).max(20) }),
|
|
315
|
+
handler: async () => ({ isSuccess: true as const, data: {} }),
|
|
316
|
+
access: { roles: ["Admin", "User"] },
|
|
317
|
+
rateLimit: { per: "tenant+handler", limit: 20, windowSeconds: 600 },
|
|
318
|
+
rateLimitCost: (payload) => payload.files.length,
|
|
319
|
+
});
|
|
272
320
|
});
|
|
273
321
|
}
|
|
274
322
|
```
|