@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
package/dist/jobs/job-runner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
3
|
import { Queue, UnrecoverableError, Worker } from "bullmq";
|
|
3
4
|
import { Redis } from "ioredis";
|
|
4
5
|
import { requestContext } from "../api/request-context.js";
|
|
@@ -24,12 +25,11 @@ import { cronRepeatOptions } from "./cron-repeat-options.js";
|
|
|
24
25
|
// run proceeds without it rather than failing over what would be a framework
|
|
25
26
|
// bug, not untrusted input.
|
|
26
27
|
function isJobTriggerEvent(value) {
|
|
27
|
-
if (
|
|
28
|
+
if (!isPlainObject(value))
|
|
28
29
|
return false;
|
|
29
|
-
|
|
30
|
-
if (typeof candidate["id"] !== "string")
|
|
30
|
+
if (typeof value["id"] !== "string")
|
|
31
31
|
return false;
|
|
32
|
-
const headers =
|
|
32
|
+
const headers = value["headers"];
|
|
33
33
|
if (typeof headers !== "object" || headers === null)
|
|
34
34
|
return false;
|
|
35
35
|
return Object.values(headers).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
|
|
@@ -611,12 +611,16 @@ export function createJobRunner(options) {
|
|
|
611
611
|
});
|
|
612
612
|
}
|
|
613
613
|
}
|
|
614
|
-
async function announceDropped(jobName, jobDef, bullJobId) {
|
|
614
|
+
async function announceDropped(jobName, jobDef, bullJobId, data) {
|
|
615
615
|
// skip: job did not opt in or no logger wired
|
|
616
616
|
if (!jobDef.tenantVisibleRun || !options.onJobDropped)
|
|
617
617
|
return;
|
|
618
|
+
const tenantId = tenantIdOfJobData(data);
|
|
619
|
+
// skip: tenant-less run — announceQueued never recorded a row for it
|
|
620
|
+
if (tenantId === SYSTEM_TENANT_ID)
|
|
621
|
+
return;
|
|
618
622
|
try {
|
|
619
|
-
await options.onJobDropped(jobName, bullJobId);
|
|
623
|
+
await options.onJobDropped(jobName, bullJobId, { tenantId });
|
|
620
624
|
}
|
|
621
625
|
catch (err) {
|
|
622
626
|
errorLogger.warn("tenant-visible queued state not cleared", {
|
|
@@ -732,7 +736,7 @@ export function createJobRunner(options) {
|
|
|
732
736
|
...buildRetryBullOpts(jobDef, priorAttempts),
|
|
733
737
|
});
|
|
734
738
|
if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
|
|
735
|
-
await announceDropped(jobName, jobDef, bullJob.id);
|
|
739
|
+
await announceDropped(jobName, jobDef, bullJob.id, bullJob.data);
|
|
736
740
|
}
|
|
737
741
|
await announceQueued(jobName, jobDef, requeued.id, requeuedData);
|
|
738
742
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
@@ -834,7 +838,7 @@ export function createJobRunner(options) {
|
|
|
834
838
|
// must resolve for jobs the same way they do for write-handlers, or an
|
|
835
839
|
// app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
|
|
836
840
|
// at runtime the write-handler path never would (framework#1532).
|
|
837
|
-
const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner);
|
|
841
|
+
const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner, undefined, context._fileProviderResolver);
|
|
838
842
|
const configDb = context.db; // @cast-boundary db-operator
|
|
839
843
|
// Shared by the config accessor and ctx.derivatives below — both need the
|
|
840
844
|
// same tenant-scoped db, and building it twice would let the two calls
|
|
@@ -1298,7 +1302,7 @@ export function createJobRunner(options) {
|
|
|
1298
1302
|
for (const j of waiting) {
|
|
1299
1303
|
if (j.name === jobName && j.id) {
|
|
1300
1304
|
await j.remove();
|
|
1301
|
-
await announceDropped(jobName, jobDef, j.id);
|
|
1305
|
+
await announceDropped(jobName, jobDef, j.id, j.data);
|
|
1302
1306
|
}
|
|
1303
1307
|
}
|
|
1304
1308
|
break;
|
|
@@ -11,7 +11,7 @@ export declare function createEscapeHatchReportWindow(opts?: {
|
|
|
11
11
|
}): EscapeHatchReportWindow;
|
|
12
12
|
export type EscapeHatchProcessDedup = {
|
|
13
13
|
readonly kinds: ReadonlySet<EscapeHatchKind>;
|
|
14
|
-
readonly
|
|
14
|
+
readonly lastAuditedAt: Map<string, number>;
|
|
15
15
|
};
|
|
16
16
|
export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
|
|
17
17
|
export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
|
|
@@ -27,8 +27,11 @@ export function createEscapeHatchReportWindow(opts) {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
|
+
// A long-lived worker must leave an audit event inside every retention window
|
|
31
|
+
// (90 days), or its crons vanish from the log once the first event is pruned.
|
|
32
|
+
const PROCESS_REAUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
30
33
|
export function createEscapeHatchProcessDedup(kinds) {
|
|
31
|
-
return { kinds: new Set(kinds),
|
|
34
|
+
return { kinds: new Set(kinds), lastAuditedAt: new Map() };
|
|
32
35
|
}
|
|
33
36
|
// Default window for createEscapeHatchReporter callers that don't pass their own.
|
|
34
37
|
const fallbackReportWindow = createEscapeHatchReportWindow();
|
|
@@ -105,11 +108,20 @@ export function createEscapeHatchReporter(opts) {
|
|
|
105
108
|
emitEscapeHatchUse(opts.meter, opts.handler, kind);
|
|
106
109
|
const { processDedup } = opts;
|
|
107
110
|
if (processDedup?.kinds.has(kind)) {
|
|
108
|
-
const processKey = JSON.stringify([
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
const processKey = JSON.stringify([
|
|
112
|
+
opts.handler,
|
|
113
|
+
kind,
|
|
114
|
+
opts.tenantId,
|
|
115
|
+
target?.id ?? null,
|
|
116
|
+
target?.tenantId ?? null,
|
|
117
|
+
]);
|
|
118
|
+
const nowMs = now();
|
|
119
|
+
const lastAuditedAt = processDedup.lastAuditedAt.get(processKey);
|
|
120
|
+
// skip: audited by this process within the re-audit interval
|
|
121
|
+
if (lastAuditedAt !== undefined && nowMs - lastAuditedAt < PROCESS_REAUDIT_INTERVAL_MS) {
|
|
111
122
|
return;
|
|
112
|
-
|
|
123
|
+
}
|
|
124
|
+
processDedup.lastAuditedAt.set(processKey, nowMs);
|
|
113
125
|
reportEscapeHatchUse(eventFor(kind, reason, target), deps);
|
|
114
126
|
// skip: audited above, the window dedup below only serves the other kinds
|
|
115
127
|
return;
|
|
@@ -52,10 +52,13 @@ export function createCascadeDeleteHook(registry, tables) {
|
|
|
52
52
|
// block the parent delete here instead of changing with it. The error names
|
|
53
53
|
// the parent only: the caller may delete the parent without reading the child.
|
|
54
54
|
if (strategy === OnDeleteStrategies.cascade || strategy === OnDeleteStrategies.setNull) {
|
|
55
|
-
const
|
|
56
|
-
|
|
55
|
+
const childEntity = registry.getEntity(relation.target);
|
|
56
|
+
const childLock = childEntity?.lockedWhen;
|
|
57
|
+
if (childLock !== undefined && payload.lockGuardBypassed !== true) {
|
|
58
|
+
// A soft-deleted child is invisible to the caller and must not block.
|
|
57
59
|
const children = await selectMany(db, targetTable, {
|
|
58
60
|
[relation.foreignKey]: payload.id,
|
|
61
|
+
...(childEntity?.softDelete === true && { isDeleted: false }),
|
|
59
62
|
});
|
|
60
63
|
if (children.some((child) => isRecordLocked(childLock, child))) {
|
|
61
64
|
throw new RecordLockedError({ entityName, entityId: payload.id });
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { requestContext, runWithWriteOrigin } from "../api/request-context.js";
|
|
2
2
|
import { transaction } from "../db/query.js";
|
|
3
|
-
import { InternalError, toWriteErrorInfo, writeFailure } from "../errors/index.js";
|
|
3
|
+
import { InternalError, toWriteErrorInfo, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
4
4
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
5
5
|
import { parseJsonSafe } from "../utils/safe-json.js";
|
|
6
6
|
import { resolveDbSource } from "./dispatch-shared.js";
|
|
7
|
-
import { executeNestedWrite } from "./dispatch-write.js";
|
|
7
|
+
import { executeNestedWrite, executeWrite } from "./dispatch-write.js";
|
|
8
8
|
import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
9
9
|
import { reserveBeforeTransaction, runAsCommand, } from "./pre-transaction-reservations.js";
|
|
10
10
|
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
|
|
@@ -22,15 +22,44 @@ function toReservedScope(reservations, index, cmd) {
|
|
|
22
22
|
const reservation = reservations.reservedCommands.get(index);
|
|
23
23
|
return reservation && { type: cmd.type, reservation };
|
|
24
24
|
}
|
|
25
|
+
function isPerCommandHandler(ctx, type) {
|
|
26
|
+
return ctx.registry.getWriteHandler(type)?.transaction === "perCommand";
|
|
27
|
+
}
|
|
25
28
|
async function runReservedCommand(scope, cmd, index, tx) {
|
|
26
29
|
const { ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks } = scope;
|
|
27
30
|
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
|
|
28
31
|
origins.push(origin);
|
|
29
32
|
const hookStart = afterCommitHooks.length;
|
|
30
|
-
|
|
33
|
+
// Nested-write children would run inline with the in-transaction scope, so a perCommand command skips the expansion.
|
|
34
|
+
const execute = isPerCommandHandler(ctx, cmd.type) ? executeWrite : executeNestedWrite;
|
|
35
|
+
const res = await runAsCommand(toReservedScope(reservations, index, cmd), () => runWithWriteOrigin(origin, () => execute(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
|
|
31
36
|
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
32
37
|
return res;
|
|
33
38
|
}
|
|
39
|
+
// Sequential fallback without an outer transaction. A failure result and a throw both end the
|
|
40
|
+
// batch, so both give the reserved capacity back.
|
|
41
|
+
async function runCommandsWithoutTransaction(scope, commands, results) {
|
|
42
|
+
try {
|
|
43
|
+
for (let i = 0; i < commands.length; i++) {
|
|
44
|
+
const cmd = commands[i];
|
|
45
|
+
if (!cmd)
|
|
46
|
+
continue;
|
|
47
|
+
const res = await runReservedCommand(scope, cmd, i, undefined);
|
|
48
|
+
results.push(res);
|
|
49
|
+
if (!res.isSuccess) {
|
|
50
|
+
// No tx means no rollback — but we still drop afterCommit hooks,
|
|
51
|
+
// matching the semantic "failure = side-effects don't fire".
|
|
52
|
+
await scope.reservations.releaseAll();
|
|
53
|
+
return { index: i, error: res.error };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
await scope.reservations.releaseAll();
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
34
63
|
// Core batch logic extracted so write() and command() can reuse it
|
|
35
64
|
// (a single write = batch of one, running in its own transaction).
|
|
36
65
|
export async function runBatch(ctx, commands, user, requestId, inheritedOrigin) {
|
|
@@ -43,6 +72,30 @@ export async function runBatch(ctx, commands, user, requestId, inheritedOrigin)
|
|
|
43
72
|
const { signal: _signal, ...withoutSignal } = current;
|
|
44
73
|
return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId, inheritedOrigin));
|
|
45
74
|
}
|
|
75
|
+
function perCommandBatchRejection(ctx, commands) {
|
|
76
|
+
const index = commands.findIndex((cmd) => isPerCommandHandler(ctx, cmd.type));
|
|
77
|
+
const command = commands[index];
|
|
78
|
+
if (!command || commands.length < 2)
|
|
79
|
+
return undefined;
|
|
80
|
+
return {
|
|
81
|
+
isSuccess: false,
|
|
82
|
+
error: toWriteErrorInfo(new UnprocessableError("per_command_handler_in_batch", {
|
|
83
|
+
message: `handler "${command.type}" runs without a transaction (transaction: "perCommand") and cannot be part of a multi-command batch`,
|
|
84
|
+
})),
|
|
85
|
+
failedIndex: index,
|
|
86
|
+
results: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function checkBatchIdempotency(ctx, user, requestId) {
|
|
90
|
+
if (!requestId || !ctx.idempotency)
|
|
91
|
+
return {};
|
|
92
|
+
const checked = await ctx.idempotency.check(user.tenantId, user.id, requestId);
|
|
93
|
+
if (checked.status !== "cached")
|
|
94
|
+
return { token: checked.token };
|
|
95
|
+
const parsed = parseJsonSafe(checked.result, null);
|
|
96
|
+
// corrupted cache entry — treat as miss, let the request re-run
|
|
97
|
+
return parsed ? { cached: parsed } : {};
|
|
98
|
+
}
|
|
46
99
|
async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
47
100
|
const { idempotency, lifecycle, appContext: context } = ctx;
|
|
48
101
|
if (commands.length === 0) {
|
|
@@ -53,19 +106,10 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
53
106
|
// idempotencyToken is only set when we actually acquired the lock — the
|
|
54
107
|
// corrupted-cache fallthrough below leaves it unset, so finalize() skips
|
|
55
108
|
// store() rather than writing over an entry it never owned.
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const parsed = parseJsonSafe(checked.result, null);
|
|
61
|
-
if (parsed)
|
|
62
|
-
return parsed;
|
|
63
|
-
// corrupted cache entry — treat as miss, let the request re-run
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
idempotencyToken = checked.token;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
109
|
+
const idempotencyCheck = await checkBatchIdempotency(ctx, user, requestId);
|
|
110
|
+
if (idempotencyCheck.cached)
|
|
111
|
+
return idempotencyCheck.cached;
|
|
112
|
+
const idempotencyToken = idempotencyCheck.token;
|
|
69
113
|
// Hooks already ran on the plaintext results; what is returned and cached
|
|
70
114
|
// for retries must not carry writeOnly values.
|
|
71
115
|
const maskForClient = (result) => maskBatchResultForClient(ctx.registry, commands, result);
|
|
@@ -148,6 +192,10 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
148
192
|
return;
|
|
149
193
|
await runWithWriteOrigin(strictest, flushBatchHooksInner);
|
|
150
194
|
};
|
|
195
|
+
const perCommandRejection = perCommandBatchRejection(ctx, commands);
|
|
196
|
+
if (perCommandRejection)
|
|
197
|
+
return releaseOrFinalize(perCommandRejection, false);
|
|
198
|
+
const runsWithoutTransaction = commands.some((cmd) => isPerCommandHandler(ctx, cmd.type));
|
|
151
199
|
// Reserve before the transaction opens so no extra connection is held while the handler tx is
|
|
152
200
|
// open; the release below runs only after that tx has ended without committing.
|
|
153
201
|
const reservations = await reserveBeforeTransaction(ctx, commands, user, inheritedOrigin);
|
|
@@ -162,25 +210,24 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
162
210
|
// batch() opens its own outer transaction — needs the top-level
|
|
163
211
|
// connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
|
|
164
212
|
const db = resolveDbSource(ctx, undefined);
|
|
165
|
-
if (!db) {
|
|
166
|
-
// Without a DB connection
|
|
213
|
+
if (!db || runsWithoutTransaction) {
|
|
214
|
+
// Without a DB connection (or for a perCommand handler, whose writes open their own transactions)
|
|
215
|
+
// there is no transaction to open. Fall back to
|
|
167
216
|
// sequential execution — useful for unit tests that don't touch the DB.
|
|
168
217
|
// Each command runs independently; a failure stops the batch.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// matching the semantic "failure = side-effects don't fire".
|
|
178
|
-
await reservations.releaseAll();
|
|
179
|
-
return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
|
|
180
|
-
}
|
|
218
|
+
const failed = await runCommandsWithoutTransaction({ ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks }, commands, results);
|
|
219
|
+
if (failed) {
|
|
220
|
+
return finalize({
|
|
221
|
+
isSuccess: false,
|
|
222
|
+
error: failed.error,
|
|
223
|
+
failedIndex: failed.index,
|
|
224
|
+
results,
|
|
225
|
+
});
|
|
181
226
|
}
|
|
182
227
|
await flushAfterCommit();
|
|
183
|
-
|
|
228
|
+
// The inner commands of a perCommand handler already fired their own batch hooks.
|
|
229
|
+
if (!runsWithoutTransaction)
|
|
230
|
+
await flushBatchHooks();
|
|
184
231
|
return finalize({ isSuccess: true, results });
|
|
185
232
|
}
|
|
186
233
|
let transactionCallbackCompleted = false;
|
|
@@ -201,6 +248,7 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
201
248
|
}
|
|
202
249
|
catch (e) {
|
|
203
250
|
// Rollback or failed COMMIT: the transaction is over, so the reserved capacity goes back.
|
|
251
|
+
// The release is outcome-safe: it only gives back a reservation row the handler tx did not delete.
|
|
204
252
|
await reservations.releaseAll();
|
|
205
253
|
if (e instanceof BatchRollback) {
|
|
206
254
|
// Thrown inside the callback, so the tx rolled back. A 4xx is
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { hasAccess } from "../engine/access.js";
|
|
2
3
|
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
3
4
|
import { filterReadFields } from "../engine/field-access.js";
|
|
@@ -49,9 +50,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
|
|
|
49
50
|
// schema (and custom query schemas that opt in) carries `includeDeleted`,
|
|
50
51
|
// so other handlers never see the flag. Visibility filters still apply
|
|
51
52
|
// downstream (see HandlerContext.includeDeleted) — safe from raw input.
|
|
52
|
-
const includeDeleted =
|
|
53
|
-
parsed.data !== null &&
|
|
54
|
-
parsed.data["includeDeleted"] === true; // @cast-boundary validated-payload
|
|
53
|
+
const includeDeleted = propertyOf(parsed.data, "includeDeleted") === true;
|
|
55
54
|
// A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
|
|
56
55
|
return isMemberResolutionPrincipal(user)
|
|
57
56
|
? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
|
|
@@ -6,7 +6,7 @@ import type { defineTransitions } from "../engine/state-machine.js";
|
|
|
6
6
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension.js";
|
|
7
7
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
8
8
|
import type { AppContext, HandlerContext, JobRunnerRef, PayloadRateLimitOption, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
9
|
-
import { type WriteErrorInfo } from "../errors/index.js";
|
|
9
|
+
import { InternalError, type WriteErrorInfo } from "../errors/index.js";
|
|
10
10
|
import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
11
11
|
import { type AfterCommitHook } from "./dispatcher-utils.js";
|
|
12
12
|
import type { IdempotencyGuard } from "./idempotency.js";
|
|
@@ -53,7 +53,12 @@ export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
|
|
|
53
53
|
export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
|
|
54
54
|
export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
|
|
55
55
|
export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
56
|
-
export declare function
|
|
56
|
+
export declare function perCommandNestedDenied(targetType: string): InternalError;
|
|
57
|
+
export declare function denyTransactionBoundSurfaces(handlerContext: HandlerContext): HandlerContext;
|
|
58
|
+
export declare function applyBeforeTransactionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
59
|
+
export type HandlerTransactionScope = "inTransaction" | "withoutTransaction" | "postCommit";
|
|
60
|
+
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope): Promise<HandlerContext>;
|
|
61
|
+
export declare function createHandlerBoundDigest(digest: (value: string) => string, handlerName: string): (value: string) => string;
|
|
57
62
|
export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
|
|
58
63
|
export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
|
|
59
64
|
export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;
|