@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -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 +319 -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 +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -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 +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -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 +215 -0
- package/agents/examples/ledger-invoicing.md +236 -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 +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -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 +250 -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 +418 -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 +75 -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 +7 -0
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- 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/routes.js +46 -17
- package/dist/api/server.d.ts +4 -3
- package/dist/api/server.js +39 -10
- 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/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- 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 +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -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 +87 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- 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 +8 -8
- 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/query-refs.js +14 -2
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -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 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- 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 +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- 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 +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -9
- 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/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
|
@@ -6,16 +6,16 @@ import { createDerivativesContext } from "../derivatives/derivatives-context.js"
|
|
|
6
6
|
import { mayOverrideTenant } from "../engine/cross-tenant.js";
|
|
7
7
|
import { createSystemUser } from "../engine/system-user.js";
|
|
8
8
|
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
|
|
9
|
-
import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
|
|
9
|
+
import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
|
|
10
10
|
import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
|
|
11
11
|
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
|
|
12
|
-
import {
|
|
12
|
+
import { loadAggregate, loadAggregateAsOf, loadAggregateForWriting, } from "../event-store/event-store.js";
|
|
13
13
|
import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
|
|
14
14
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
|
|
15
15
|
import { createFileContext } from "../files/file-handle.js";
|
|
16
16
|
import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
|
|
17
17
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
18
|
-
import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle,
|
|
18
|
+
import { createEscapeHatchReporter, createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
19
19
|
import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
|
|
20
20
|
import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
|
|
21
21
|
import { isPlainObject } from "../utils/is-plain-object.js";
|
|
@@ -112,10 +112,10 @@ async function denyMemberResolutionWrite() {
|
|
|
112
112
|
}
|
|
113
113
|
// JobRunnerRef's declared type only carries handleEvent, but callers
|
|
114
114
|
// routinely bracket-access a fuller runner — a Proxy denies ANY property read.
|
|
115
|
-
function denyingJobRunnerProxy() {
|
|
115
|
+
function denyingJobRunnerProxy(denied = memberResolutionReadOnlyDenied) {
|
|
116
116
|
return new Proxy({}, {
|
|
117
117
|
get() {
|
|
118
|
-
throw
|
|
118
|
+
throw denied();
|
|
119
119
|
},
|
|
120
120
|
});
|
|
121
121
|
}
|
|
@@ -205,7 +205,66 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
205
205
|
}),
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
|
-
|
|
208
|
+
function beforeTransactionDenied(surface) {
|
|
209
|
+
return new InternalError({
|
|
210
|
+
message: `ctx.${surface} is not available before the transaction; move it into the handler`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function withoutTransactionDenied(surface) {
|
|
214
|
+
return new InternalError({
|
|
215
|
+
message: `ctx.${surface} is not available without an open transaction; run the write through ctx.write so it gets its own transaction`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
export function perCommandNestedDenied(targetType) {
|
|
219
|
+
return new InternalError({
|
|
220
|
+
message: `handler "${targetType}" runs without a transaction (transaction: "perCommand") and cannot run as a nested write inside an open transaction`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function denySurface(buildError, surface) {
|
|
224
|
+
return () => {
|
|
225
|
+
throw buildError(surface);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function denyEventStoreSurfaces(handlerContext, buildError) {
|
|
229
|
+
return {
|
|
230
|
+
...handlerContext,
|
|
231
|
+
appendEvent: denySurface(buildError, "appendEvent"),
|
|
232
|
+
unsafeAppendEvent: denySurface(buildError, "unsafeAppendEvent"),
|
|
233
|
+
tryAppendEvent: denySurface(buildError, "tryAppendEvent"),
|
|
234
|
+
fetchForWriting: denySurface(buildError, "fetchForWriting"),
|
|
235
|
+
archiveStream: denySurface(buildError, "archiveStream"),
|
|
236
|
+
restoreStream: denySurface(buildError, "restoreStream"),
|
|
237
|
+
snapshotAggregate: denySurface(buildError, "snapshotAggregate"),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
// Reservations write on purpose through the outside-transaction handles, so they keep those but
|
|
241
|
+
// lose every surface bound to the not-yet-open transaction.
|
|
242
|
+
export function denyTransactionBoundSurfaces(handlerContext) {
|
|
243
|
+
return {
|
|
244
|
+
...denyEventStoreSurfaces(handlerContext, beforeTransactionDenied),
|
|
245
|
+
write: denySurface(beforeTransactionDenied, "write"),
|
|
246
|
+
writeAs: denySurface(beforeTransactionDenied, "writeAs"),
|
|
247
|
+
scheduleAfterCommit: denySurface(beforeTransactionDenied, "scheduleAfterCommit"),
|
|
248
|
+
...(handlerContext.notify && { notify: denySurface(beforeTransactionDenied, "notify") }),
|
|
249
|
+
...(handlerContext.jobRunner && {
|
|
250
|
+
jobRunner: denyingJobRunnerProxy(() => beforeTransactionDenied("jobRunner")),
|
|
251
|
+
}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// The beforeTransaction phase has no write need at all, so it also loses the outside-transaction handles.
|
|
255
|
+
export function applyBeforeTransactionReadOnly(handlerContext) {
|
|
256
|
+
return {
|
|
257
|
+
...denyTransactionBoundSurfaces(handlerContext),
|
|
258
|
+
dbOutsideTransaction: undefined,
|
|
259
|
+
outsideTransaction: undefined,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction",
|
|
263
|
+
// A query bridge has no commit sink of its own, but ctx.notify inside it must still wait for
|
|
264
|
+
// the outer write's commit: the outer sink is lent to notify only, not to scheduleAfterCommit.
|
|
265
|
+
outerNotifySink,
|
|
266
|
+
// beforeTransaction phase: ctx.forTenant stays available but every target db denies writes.
|
|
267
|
+
readOnlyForTenant = false) {
|
|
209
268
|
const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
|
|
210
269
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
211
270
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
@@ -214,7 +273,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
214
273
|
const dbSource = resolveDbSource(ctx, tx);
|
|
215
274
|
const reqCtx = requestContext.get();
|
|
216
275
|
// global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write, query or stream escapeHatch.
|
|
217
|
-
const
|
|
276
|
+
const writeHandler = registry.getWriteHandler(type);
|
|
277
|
+
const writeEscapeHatch = writeHandler?.escapeHatch;
|
|
278
|
+
const isPerCommand = writeHandler?.transaction === "perCommand";
|
|
279
|
+
const denyDbWrites = transactionScope === "withoutTransaction";
|
|
218
280
|
const handlerEscapeHatch = writeEscapeHatch ??
|
|
219
281
|
registry.getQueryHandler(type)?.escapeHatch ??
|
|
220
282
|
registry.getStreamHandler(type)?.escapeHatch;
|
|
@@ -231,7 +293,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
231
293
|
});
|
|
232
294
|
const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
|
|
233
295
|
(isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
|
|
234
|
-
const buildTenantScopedDb = (source, signal, foreignTenantId) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
296
|
+
const buildTenantScopedDb = (source, signal, foreignTenantId, denyWrites = false) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
235
297
|
...(foreignTenantId === undefined && {
|
|
236
298
|
globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
|
|
237
299
|
unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
|
|
@@ -240,11 +302,14 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
240
302
|
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
241
303
|
personalDataGate: buildPersonalDataGate(registry, origin),
|
|
242
304
|
projectionRegistry: registry,
|
|
305
|
+
...(denyWrites && { writesDeniedReason: "without an open transaction" }),
|
|
243
306
|
});
|
|
244
307
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
245
308
|
// the client has disconnected — handlers with many sequential queries skip
|
|
246
309
|
// the rest of the chain instead of burning DB-CPU for results no one reads.
|
|
247
|
-
const db = dbSource
|
|
310
|
+
const db = dbSource
|
|
311
|
+
? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, denyDbWrites)
|
|
312
|
+
: undefined;
|
|
248
313
|
// Unbound pool, tenant-scoped like `db` but never tx-bound — writes
|
|
249
314
|
// through it survive a rollback of the handler's own transaction. No
|
|
250
315
|
// AbortSignal here: a client disconnect must not abort a durability write
|
|
@@ -286,7 +351,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
286
351
|
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
287
352
|
const bridgeSink = afterCommitHooks ?? [];
|
|
288
353
|
// Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
|
|
289
|
-
const hasLiveCommitSink = afterCommitHooks !== undefined &&
|
|
354
|
+
const hasLiveCommitSink = afterCommitHooks !== undefined && transactionScope !== "postCommit";
|
|
290
355
|
const scheduleAfterCommit = (hook) => {
|
|
291
356
|
bridgeSink.push(hook);
|
|
292
357
|
};
|
|
@@ -297,23 +362,30 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
297
362
|
const notifyInTransactionFactory = notifyFactory && tx !== undefined
|
|
298
363
|
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
|
|
299
364
|
: undefined;
|
|
365
|
+
const notifyAfterCommitFactory = notifyFactory
|
|
366
|
+
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, undefined, context._fileProviderResolver)
|
|
367
|
+
: undefined;
|
|
300
368
|
const notify = buildNotify();
|
|
301
369
|
function buildNotify() {
|
|
302
|
-
if (!
|
|
370
|
+
if (!notifyAfterCommitFactory)
|
|
303
371
|
return undefined;
|
|
304
|
-
const notifyAfterCommit =
|
|
372
|
+
const notifyAfterCommit = notifyAfterCommitFactory(user, user.tenantId);
|
|
305
373
|
if (!notifyInTransactionFactory)
|
|
306
374
|
return notifyAfterCommit;
|
|
307
375
|
const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
|
|
308
|
-
// A rolled-back write must leave no notification behind.
|
|
309
|
-
|
|
376
|
+
// A rolled-back write must leave no notification behind. Without any commit to wait for (a
|
|
377
|
+
// post-commit context) delivery stays immediate.
|
|
378
|
+
const notifyCommitSink = hasLiveCommitSink ? bridgeSink : outerNotifySink;
|
|
379
|
+
if (notifyCommitSink === undefined)
|
|
310
380
|
return notifyInTransaction;
|
|
311
381
|
return createDeferringNotify({
|
|
312
382
|
registry,
|
|
313
383
|
tenantId: user.tenantId,
|
|
314
384
|
notifyAfterCommit,
|
|
315
385
|
notifyNow: notifyInTransaction,
|
|
316
|
-
scheduleAfterCommit
|
|
386
|
+
scheduleAfterCommit: (hook) => {
|
|
387
|
+
notifyCommitSink.push(hook);
|
|
388
|
+
},
|
|
317
389
|
});
|
|
318
390
|
}
|
|
319
391
|
// Mirror notify: only built when the config feature wired its factory.
|
|
@@ -354,7 +426,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
354
426
|
const forTenant = !isSystem && dbSource && registry.getWriteHandler(type) !== undefined
|
|
355
427
|
? (targetTenantId) => {
|
|
356
428
|
if (targetTenantId === user.tenantId && exposedDb) {
|
|
357
|
-
|
|
429
|
+
// Only perCommand handlers lose forTenant writes; reservations keep their pre-transaction contract.
|
|
430
|
+
const ownTenantDb = denyDbWrites && !isPerCommand
|
|
431
|
+
? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, readOnlyForTenant)
|
|
432
|
+
: exposedDb;
|
|
433
|
+
return { db: ownTenantDb, streamTenantId: undefined };
|
|
358
434
|
}
|
|
359
435
|
if (!mayOverrideTenant(user)) {
|
|
360
436
|
throw new AccessDeniedError({
|
|
@@ -362,7 +438,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
362
438
|
});
|
|
363
439
|
}
|
|
364
440
|
return {
|
|
365
|
-
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId),
|
|
441
|
+
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && (isPerCommand || readOnlyForTenant)),
|
|
366
442
|
streamTenantId: targetTenantId,
|
|
367
443
|
};
|
|
368
444
|
}
|
|
@@ -390,16 +466,16 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
390
466
|
const metrics = meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
|
|
391
467
|
// ctx.metricsFor(featureName) — shared/library code binds to a feature
|
|
392
468
|
// name of its own choosing instead of the dispatching handler's
|
|
393
|
-
// (framework#1844). Unregistered names no-op rather than throw
|
|
394
|
-
|
|
395
|
-
const metricsFor = meter
|
|
396
|
-
? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
|
|
397
|
-
: () => createNoopMetricsHandle();
|
|
469
|
+
// (framework#1844). Unregistered names no-op rather than throw.
|
|
470
|
+
const metricsFor = createMetricsForFactory(meter);
|
|
398
471
|
// A failed inner write has already appended its event and projections when a later
|
|
399
472
|
// step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
|
|
400
473
|
// failure result. The savepoint makes the failure undo exactly the inner write; the
|
|
401
474
|
// inner afterCommit hooks only reach the outer sink if it succeeded.
|
|
402
|
-
const
|
|
475
|
+
const runNestedWriteInSavepoint = async (asUser, targetType, payload) => {
|
|
476
|
+
if (registry.getWriteHandler(targetType)?.transaction === "perCommand") {
|
|
477
|
+
return writeFailure(perCommandNestedDenied(targetType));
|
|
478
|
+
}
|
|
403
479
|
if (!tx)
|
|
404
480
|
return executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
|
|
405
481
|
const innerAfterCommitHooks = [];
|
|
@@ -420,17 +496,26 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
420
496
|
throw e;
|
|
421
497
|
}
|
|
422
498
|
};
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
499
|
+
// Savepoints on one connection are not serialized by the driver: two parallel nested writes would
|
|
500
|
+
// interleave their statements and a ROLLBACK TO of the first would also discard the second's rows.
|
|
501
|
+
// Writes nested one level deeper run on their own savepoint handle and therefore their own chain.
|
|
502
|
+
let nestedWriteChain = Promise.resolve();
|
|
503
|
+
const executeNestedWriteInSavepoint = (asUser, targetType, payload) => {
|
|
504
|
+
const run = nestedWriteChain.then(() => runNestedWriteInSavepoint(asUser, targetType, payload));
|
|
505
|
+
nestedWriteChain = run.catch(() => undefined);
|
|
506
|
+
return run;
|
|
507
|
+
};
|
|
508
|
+
// Without an open transaction (post-commit hooks, stream/query handlers, perCommand handlers) a bare
|
|
509
|
+
// executeWrite would auto-commit event append, projections and inTransaction hooks separately and its
|
|
510
|
+
// afterCommit hooks would land in a sink nobody flushes. A batch-of-one gives the write its own
|
|
511
|
+
// transaction and its own flush.
|
|
512
|
+
const writeAsUser = async (asUser, targetType, payload) => transactionScope === "inTransaction"
|
|
513
|
+
? executeNestedWriteInSavepoint(asUser, targetType, payload)
|
|
514
|
+
: unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin));
|
|
430
515
|
const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
|
|
431
516
|
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
|
|
432
517
|
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
433
|
-
queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
|
|
518
|
+
queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
|
|
434
519
|
writeAs: writeAsUser,
|
|
435
520
|
}, identitySwitchAudit);
|
|
436
521
|
// Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
|
|
@@ -474,7 +559,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
474
559
|
return projTable === undefined || hasTenantColumn(projTable);
|
|
475
560
|
});
|
|
476
561
|
const bridge = {
|
|
477
|
-
query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
|
|
562
|
+
query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
|
|
478
563
|
queryAs: identitySwitch.queryAs,
|
|
479
564
|
write: (targetType, payload) => writeAsUser(user, targetType, payload),
|
|
480
565
|
writeAs: identitySwitch.writeAs,
|
|
@@ -529,16 +614,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
529
614
|
message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
|
|
530
615
|
});
|
|
531
616
|
}
|
|
532
|
-
// Stream-version authoritative (same policy as CRUD executor + Block 0)
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
// the events (business-rule checks), so fetch both in parallel.
|
|
536
|
-
const [storedEvents, fetchedVersion] = await Promise.all([
|
|
537
|
-
loadAggregate(dbSource, args.aggregateId, user.tenantId, {
|
|
538
|
-
aggregateType: args.aggregateType,
|
|
539
|
-
}),
|
|
540
|
-
getStreamVersion(dbSource, args.aggregateId, user.tenantId),
|
|
541
|
-
]);
|
|
617
|
+
// Stream-version authoritative (same policy as CRUD executor + Block 0), read together
|
|
618
|
+
// with the events so the pinned version covers exactly what the handler's rules saw.
|
|
619
|
+
const { events: storedEvents, streamVersion: fetchedVersion } = await loadAggregateForWriting(dbSource, args.aggregateId, user.tenantId, { aggregateType: args.aggregateType });
|
|
542
620
|
const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
|
|
543
621
|
db: dbSource,
|
|
544
622
|
tenantId: user.tenantId,
|
|
@@ -761,12 +839,14 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
761
839
|
const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
762
840
|
// The payload-bucket digest is dispatcher-internal; handlers must not be
|
|
763
841
|
// able to compute bucket keys.
|
|
764
|
-
const { _rateLimitPayloadDigest:
|
|
842
|
+
const { _rateLimitPayloadDigest: payloadDigest,
|
|
765
843
|
// Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
|
|
766
|
-
_notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
|
|
844
|
+
_notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, _notifyAfterCommitFactory: _omitOuterNotifyAfterCommitFactory, ...contextForHandler } = context;
|
|
767
845
|
const handlerContext = {
|
|
768
846
|
...contextForHandler,
|
|
847
|
+
...(payloadDigest && { rateLimitDigest: createHandlerBoundDigest(payloadDigest, type) }),
|
|
769
848
|
...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
|
|
849
|
+
...(notifyAfterCommitFactory && { _notifyAfterCommitFactory: notifyAfterCommitFactory }),
|
|
770
850
|
registry,
|
|
771
851
|
db: exposedDb,
|
|
772
852
|
dbOutsideTransaction,
|
|
@@ -816,10 +896,18 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
816
896
|
...(includeDeleted && { includeDeleted: true }),
|
|
817
897
|
...bridge,
|
|
818
898
|
}; // @cast-boundary engine-bridge
|
|
899
|
+
const scopedContext = transactionScope === "withoutTransaction"
|
|
900
|
+
? denyEventStoreSurfaces(handlerContext, withoutTransactionDenied)
|
|
901
|
+
: handlerContext;
|
|
819
902
|
// A resolved member principal is read-only by construction.
|
|
820
903
|
return isMemberResolutionPrincipal(user)
|
|
821
|
-
? applyMemberResolutionReadOnly(
|
|
822
|
-
:
|
|
904
|
+
? applyMemberResolutionReadOnly(scopedContext)
|
|
905
|
+
: scopedContext;
|
|
906
|
+
}
|
|
907
|
+
// The handler-name prefix keeps a handler from reproducing the digests the
|
|
908
|
+
// dispatcher uses for payload buckets (those hash the bare normalized value).
|
|
909
|
+
export function createHandlerBoundDigest(digest, handlerName) {
|
|
910
|
+
return (value) => digest(`handler:${handlerName}:${value}`);
|
|
823
911
|
}
|
|
824
912
|
// Resolves the agent risk the irreversible-operation gate should attribute to
|
|
825
913
|
// `type` when it becomes the request's entry handler — same defaulting rule
|
|
@@ -1107,7 +1195,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1107
1195
|
message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
|
|
1108
1196
|
});
|
|
1109
1197
|
}
|
|
1110
|
-
for (const { per, limit, windowSeconds, cost } of limits) {
|
|
1198
|
+
for (const { per, limit, windowSeconds, cost, normalize } of limits) {
|
|
1111
1199
|
const value = data[per.payloadField];
|
|
1112
1200
|
// skip: optional field not sent — nothing to bucket on
|
|
1113
1201
|
if (value === undefined)
|
|
@@ -1117,7 +1205,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1117
1205
|
message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
|
|
1118
1206
|
});
|
|
1119
1207
|
}
|
|
1120
|
-
const key = buildPayloadBucketKey(handlerName, per.payloadField, digest(normalizePayloadBucketValue(value)));
|
|
1208
|
+
const key = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
|
|
1121
1209
|
await context.rateLimit.enforce(key, { limit, windowSeconds, cost });
|
|
1122
1210
|
}
|
|
1123
1211
|
}
|
|
@@ -2,7 +2,7 @@ import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
|
|
|
2
2
|
import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
|
|
3
3
|
import { hasAccess } from "../engine/access.js";
|
|
4
4
|
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
5
|
-
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
|
|
5
|
+
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, UnauthenticatedError, validationErrorFromZod, } from "../errors/index.js";
|
|
6
6
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
7
7
|
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
|
|
8
8
|
import { handlerAccessError } from "./handler-access-error.js";
|
|
@@ -69,10 +69,16 @@ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
|
|
|
69
69
|
abandonedForInvalidation = true;
|
|
70
70
|
void nextPull.catch(() => { });
|
|
71
71
|
void iterator.return?.(undefined)?.then(undefined, () => { });
|
|
72
|
+
// An expired token is a lapsed session (401, so clients show the
|
|
73
|
+
// session-end hint); revocation is a permission change (403).
|
|
74
|
+
if (outcome.reason === "expired") {
|
|
75
|
+
throw new UnauthenticatedError({
|
|
76
|
+
message: `token expired mid-stream for ${type}`,
|
|
77
|
+
details: { handler: type, reason: outcome.reason },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
72
80
|
throw new AccessDeniedError({
|
|
73
|
-
message:
|
|
74
|
-
? `token expired mid-stream for ${type}`
|
|
75
|
-
: `access revoked mid-stream for ${type}`,
|
|
81
|
+
message: `access revoked mid-stream for ${type}`,
|
|
76
82
|
details: { handler: type },
|
|
77
83
|
});
|
|
78
84
|
}
|
|
@@ -11,12 +11,13 @@ import { AccessDeniedError, FrameworkReasons, InternalError, memberResolutionRea
|
|
|
11
11
|
import { assertFileRefsBelongToTenant } from "../files/assert-file-refs-belong-to-tenant.js";
|
|
12
12
|
import { bindPendingFileRefs } from "../files/bind-pending-file-refs.js";
|
|
13
13
|
import { disposeRemovedFileRefs } from "../files/dispose-removed-file-refs.js";
|
|
14
|
+
import { loadSaveFileRefs } from "../files/load-save-file-refs.js";
|
|
14
15
|
import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
|
|
15
16
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
16
|
-
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
17
|
+
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, perCommandNestedDenied, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
17
18
|
import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
18
19
|
import { runPreHandlerGates } from "./pre-handler-gates.js";
|
|
19
|
-
import { consumeReservation } from "./pre-transaction-reservations.js";
|
|
20
|
+
import { consumeReservation, hasPreTransactionPhase, } from "./pre-transaction-reservations.js";
|
|
20
21
|
import { runProjections } from "./projections-runner.js";
|
|
21
22
|
function getTable(ctx, entityName) {
|
|
22
23
|
const { registry, tableCache } = ctx;
|
|
@@ -115,12 +116,13 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
115
116
|
await runProjections(result, handlerContext.registry, runner);
|
|
116
117
|
if (result.kind === "save") {
|
|
117
118
|
const fileRefArgs = { registry: handlerContext.registry, result, user, db: handlerContext.db };
|
|
118
|
-
await
|
|
119
|
-
|
|
119
|
+
const fileRefRows = await loadSaveFileRefs(fileRefArgs);
|
|
120
|
+
assertFileRefsBelongToTenant({ ...fileRefArgs, fileRefRows });
|
|
121
|
+
await bindPendingFileRefs({ ...fileRefArgs, fileRefRows });
|
|
120
122
|
await disposeRemovedFileRefs(fileRefArgs);
|
|
121
123
|
await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
|
|
122
124
|
afterCommitHooks.push(async () => {
|
|
123
|
-
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined,
|
|
125
|
+
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, "postCommit");
|
|
124
126
|
await lifecycle.runPostSave(type, result, afterCommitContext, HookPhases.afterCommit);
|
|
125
127
|
});
|
|
126
128
|
}
|
|
@@ -128,7 +130,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
128
130
|
await lifecycle.runPreDelete(type, result, handlerContext);
|
|
129
131
|
await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
|
|
130
132
|
afterCommitHooks.push(async () => {
|
|
131
|
-
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined,
|
|
133
|
+
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, "postCommit");
|
|
132
134
|
await lifecycle.runPostDelete(type, result, afterCommitContext, HookPhases.afterCommit);
|
|
133
135
|
});
|
|
134
136
|
}
|
|
@@ -337,23 +339,29 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
337
339
|
if (isMemberResolutionPrincipal(user)) {
|
|
338
340
|
return writeFailure(memberResolutionReadOnlyDenied());
|
|
339
341
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
342
|
+
const isPerCommand = handler.transaction === "perCommand";
|
|
343
|
+
if (isPerCommand && tx !== undefined) {
|
|
344
|
+
return writeFailure(perCommandNestedDenied(type));
|
|
345
|
+
}
|
|
346
|
+
// A command the dispatcher ran pre-transaction phases for was already billed against the rate
|
|
347
|
+
// limits; the gates only run again against the in-transaction state.
|
|
348
|
+
const needsPreTransactionPhase = hasPreTransactionPhase(handler);
|
|
349
|
+
const reservation = needsPreTransactionPhase ? consumeReservation(type) : undefined;
|
|
343
350
|
const gates = await runPreHandlerGates(ctx, handler, type, payload, user, reservation ? "recheck" : "charge");
|
|
344
351
|
if (!gates.isSuccess)
|
|
345
352
|
return gates;
|
|
346
353
|
const parsedPayload = gates.payload;
|
|
347
|
-
// A handler
|
|
348
|
-
// it
|
|
349
|
-
// handler is rejected instead.
|
|
350
|
-
if (
|
|
354
|
+
// A handler with a pre-transaction phase cannot run that phase from inside an open transaction;
|
|
355
|
+
// running it without would skip the reservation or the prepared data, so a nested ctx.write of
|
|
356
|
+
// such a handler is rejected instead.
|
|
357
|
+
if (needsPreTransactionPhase && !reservation) {
|
|
351
358
|
return writeFailure(new InternalError({
|
|
352
|
-
message: `handler "${type}"
|
|
359
|
+
message: `handler "${type}" has a pre-transaction phase (reserveBeforeTransaction/beforeTransaction) and cannot run as a nested write unless the top-level command ran it`,
|
|
353
360
|
}));
|
|
354
361
|
}
|
|
355
362
|
const entityName = registry.getHandlerEntity(type);
|
|
356
|
-
|
|
363
|
+
// Without a database there is no transaction either, but ordinary handlers keep the in-transaction nested-write semantics.
|
|
364
|
+
const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, undefined, isPerCommand ? "withoutTransaction" : "inTransaction");
|
|
357
365
|
// Auto transition guard: if entity has transitions and handler doesn't skip it.
|
|
358
366
|
// Reads via the guard's own db handle — for r.systemScope() handlers
|
|
359
367
|
// handlerContext.db is fail-closed, so this reaches for
|
|
@@ -410,7 +418,8 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
410
418
|
// writeFailure, not via a rethrow) so batches roll back naturally.
|
|
411
419
|
let result;
|
|
412
420
|
try {
|
|
413
|
-
|
|
421
|
+
const event = { type, payload: parsedPayload, user, prepared: reservation?.prepared };
|
|
422
|
+
result = await handler.handler(event, handlerContext);
|
|
414
423
|
}
|
|
415
424
|
catch (e) {
|
|
416
425
|
return writeFailure(wrapToKumiko(e));
|
|
@@ -434,12 +443,15 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
434
443
|
return confirmFailure;
|
|
435
444
|
}
|
|
436
445
|
if (result.isSuccess) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
446
|
+
// A perCommand handler's writes ran as their own commands; its result carries no SaveContext of its own to project.
|
|
447
|
+
if (!isPerCommand) {
|
|
448
|
+
try {
|
|
449
|
+
const runner = resolveDbSource(ctx, tx);
|
|
450
|
+
await runLifecycle(ctx, type, result.data, handlerContext, user, origin, afterCommitHooks, runner);
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
return writeFailure(wrapToKumiko(e));
|
|
454
|
+
}
|
|
443
455
|
}
|
|
444
456
|
// jobRunner has external side-effects (BullMQ enqueue) — must NOT
|
|
445
457
|
// fire for rolled-back writes. Defer to afterCommit.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runWithWriteOrigin } from "../api/request-context.js";
|
|
2
2
|
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
|
|
3
3
|
import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
|
|
4
|
+
import { withEscapeHatchAuditScopeStream } from "../observability/escape-hatch-report.js";
|
|
4
5
|
import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
5
6
|
import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
|
|
6
7
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
@@ -114,7 +115,8 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
114
115
|
},
|
|
115
116
|
stream: (typeOrRef, payload, user, options) => {
|
|
116
117
|
const type = resolveType(typeOrRef);
|
|
117
|
-
|
|
118
|
+
const origin = rootWriteOrigin(registry, type, user);
|
|
119
|
+
return withEscapeHatchAuditScopeStream(() => executeStream(ctx, type, payload, user, origin, options));
|
|
118
120
|
},
|
|
119
121
|
async command(typeOrRef, payload, user) {
|
|
120
122
|
const type = resolveType(typeOrRef);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PgListenClient } from "../db/connection.js";
|
|
2
2
|
import type { AppContext } from "../engine/types/index.js";
|
|
3
3
|
import { type StoredEvent } from "../event-store/index.js";
|
|
4
4
|
import { type Meter, type Tracer } from "../observability/index.js";
|
|
@@ -46,7 +46,7 @@ export type EventDispatcherOptions = {
|
|
|
46
46
|
readonly tracer?: Tracer;
|
|
47
47
|
readonly meter?: Meter;
|
|
48
48
|
readonly instanceId?: string;
|
|
49
|
-
readonly pgClient?:
|
|
49
|
+
readonly pgClient?: PgListenClient;
|
|
50
50
|
};
|
|
51
51
|
export declare function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher;
|
|
52
52
|
export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin.js";
|
|
@@ -114,7 +114,7 @@ export function createEventDispatcher(options) {
|
|
|
114
114
|
// to EVENTS_PUBSUB_CHANNEL; cleared by .stop(). The timer remains active
|
|
115
115
|
// even with LISTEN attached — it's a cheap safety net against missed
|
|
116
116
|
// NOTIFYs (subscription drop, crash mid-commit).
|
|
117
|
-
let
|
|
117
|
+
let pgSubscription = null;
|
|
118
118
|
// Bounds how many consumer turns hold a DB transaction at once. Each turn's
|
|
119
119
|
// db.begin() checks out one pool connection, plus any writes the handler
|
|
120
120
|
// itself makes via context.db — the app db pool defaults to postgres-js's
|
|
@@ -498,15 +498,16 @@ export function createEventDispatcher(options) {
|
|
|
498
498
|
// succeeds (onlisten callback). It drops to 0 on the initial connect
|
|
499
499
|
// failure, on stop() and when the idle pre-check detects a DB outage.
|
|
500
500
|
// An outage shorter than one poll tick is not detected; the gauge then
|
|
501
|
-
// stays at 1 until
|
|
501
|
+
// stays at 1 until the driver's delayed re-LISTEN lands.
|
|
502
502
|
emitEventDispatcherListenConnected(meter, false);
|
|
503
503
|
if (options.pgClient) {
|
|
504
504
|
try {
|
|
505
505
|
const sub = await options.pgClient.listen(EVENTS_PUBSUB_CHANNEL, runBackgroundPass, () => {
|
|
506
|
-
// Fires on initial connect and again when
|
|
506
|
+
// Fires on initial connect and again when the driver's own
|
|
507
507
|
// re-LISTEN lands after a drop; during an outage that re-LISTEN
|
|
508
|
-
// waits in its connection backoff (measured 10-30s
|
|
509
|
-
// returns
|
|
508
|
+
// waits in its connection backoff (postgres-js: measured 10-30s
|
|
509
|
+
// after the DB returns, Bun.SQL: up to 32s), polling covers
|
|
510
|
+
// delivery meanwhile. Never unlisten()
|
|
510
511
|
// + listen() by hand: that leaves duplicate listeners.
|
|
511
512
|
// ponytail: no own LISTEN reconnect, the backoff gap is polled;
|
|
512
513
|
// add a dedicated LISTEN connection if prod wires a pgClient
|
|
@@ -514,7 +515,7 @@ export function createEventDispatcher(options) {
|
|
|
514
515
|
listenSubscribed = true;
|
|
515
516
|
emitEventDispatcherListenConnected(meter, true);
|
|
516
517
|
});
|
|
517
|
-
|
|
518
|
+
pgSubscription = sub;
|
|
518
519
|
}
|
|
519
520
|
catch (e) {
|
|
520
521
|
emitEventDispatcherListenConnected(meter, false);
|
|
@@ -532,12 +533,13 @@ export function createEventDispatcher(options) {
|
|
|
532
533
|
clearInterval(timer);
|
|
533
534
|
timer = null;
|
|
534
535
|
}
|
|
535
|
-
if (
|
|
536
|
-
|
|
536
|
+
if (pgSubscription) {
|
|
537
|
+
// Called on the subscription: Bun.SQL's unlisten is a prototype method.
|
|
538
|
+
await pgSubscription.unlisten().catch(() => {
|
|
537
539
|
// skip: unlisten failure only matters during shutdown — the
|
|
538
540
|
// subscription is being torn down anyway.
|
|
539
541
|
});
|
|
540
|
-
|
|
542
|
+
pgSubscription = null;
|
|
541
543
|
listenSubscribed = false;
|
|
542
544
|
emitEventDispatcherListenConnected(meter, false);
|
|
543
545
|
}
|
|
@@ -34,6 +34,10 @@ export async function pruneEvents(db, options) {
|
|
|
34
34
|
if (options.aggregateIds !== undefined && options.aggregateIds.length === 0) {
|
|
35
35
|
throw new Error("pruneEvents: aggregateIds must be non-empty when set.");
|
|
36
36
|
}
|
|
37
|
+
if (options.batchSize !== undefined &&
|
|
38
|
+
(!Number.isInteger(options.batchSize) || options.batchSize < 1)) {
|
|
39
|
+
throw new Error("pruneEvents: batchSize must be a positive integer.");
|
|
40
|
+
}
|
|
37
41
|
const aggregateTypes = options.aggregateTypes;
|
|
38
42
|
const aggregateIds = options.aggregateIds;
|
|
39
43
|
const dryRun = options.dryRun === true;
|
|
@@ -56,7 +60,9 @@ export async function pruneEvents(db, options) {
|
|
|
56
60
|
aggregateType: [...aggregateTypes],
|
|
57
61
|
...(aggregateIds ? { aggregateId: [...aggregateIds] } : {}),
|
|
58
62
|
createdAt: { lt: cutoff },
|
|
59
|
-
}
|
|
63
|
+
}, options.batchSize === undefined
|
|
64
|
+
? undefined
|
|
65
|
+
: { orderBy: { col: "id", direction: "asc" }, limit: options.batchSize });
|
|
60
66
|
if (candidates.length === 0) {
|
|
61
67
|
return { deletedCount: 0, cutoff, aggregateTypes, dryRun };
|
|
62
68
|
}
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
|
|
2
2
|
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
|
|
3
|
+
export { normalizePayloadBucketValue } from "../rate-limit/bucket.js";
|
|
3
4
|
export type { AppendDomainEventCoreDeps } from "./append-event-core.js";
|
|
4
5
|
export { appendDomainEventCore } from "./append-event-core.js";
|
|
5
6
|
export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
|
|
2
|
+
export { normalizePayloadBucketValue } from "../rate-limit/bucket.js";
|
|
2
3
|
export { appendDomainEventCore } from "./append-event-core.js";
|
|
3
4
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
4
5
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|