@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
|
@@ -6,7 +6,7 @@ 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
12
|
import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
|
|
@@ -27,6 +27,7 @@ import { executeQuery } from "./dispatch-query.js";
|
|
|
27
27
|
import { executeWrite } from "./dispatch-write.js";
|
|
28
28
|
import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } from "./dispatcher-utils.js";
|
|
29
29
|
import { createMemberReaderFn } from "./member-reader.js";
|
|
30
|
+
import { createDeferringNotify } from "./notify-scope.js";
|
|
30
31
|
import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
|
|
31
32
|
import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
|
|
32
33
|
// Framework/pipeline stays bundled-features-free, so this can't import the
|
|
@@ -47,8 +48,10 @@ export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
|
|
|
47
48
|
// dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
|
|
48
49
|
// appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
|
|
49
50
|
export function resolveDbSource(ctx, tx) {
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
return tx ?? resolveAppPoolDb(ctx);
|
|
52
|
+
}
|
|
53
|
+
function resolveAppPoolDb(ctx) {
|
|
54
|
+
return ctx.appContext.db; // @cast-boundary db-operator
|
|
52
55
|
}
|
|
53
56
|
// ctx.appendEvent — append a domain event onto a specific aggregate stream
|
|
54
57
|
// in the current tx, then fire matching inline projections. Core logic
|
|
@@ -109,10 +112,10 @@ async function denyMemberResolutionWrite() {
|
|
|
109
112
|
}
|
|
110
113
|
// JobRunnerRef's declared type only carries handleEvent, but callers
|
|
111
114
|
// routinely bracket-access a fuller runner — a Proxy denies ANY property read.
|
|
112
|
-
function denyingJobRunnerProxy() {
|
|
115
|
+
function denyingJobRunnerProxy(denied = memberResolutionReadOnlyDenied) {
|
|
113
116
|
return new Proxy({}, {
|
|
114
117
|
get() {
|
|
115
|
-
throw
|
|
118
|
+
throw denied();
|
|
116
119
|
},
|
|
117
120
|
});
|
|
118
121
|
}
|
|
@@ -202,7 +205,62 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
202
205
|
}),
|
|
203
206
|
};
|
|
204
207
|
}
|
|
205
|
-
|
|
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
|
+
forTenant: undefined,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction") {
|
|
206
264
|
const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
|
|
207
265
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
208
266
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
@@ -211,7 +269,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
211
269
|
const dbSource = resolveDbSource(ctx, tx);
|
|
212
270
|
const reqCtx = requestContext.get();
|
|
213
271
|
// global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write, query or stream escapeHatch.
|
|
214
|
-
const
|
|
272
|
+
const writeHandler = registry.getWriteHandler(type);
|
|
273
|
+
const writeEscapeHatch = writeHandler?.escapeHatch;
|
|
274
|
+
const isPerCommand = writeHandler?.transaction === "perCommand";
|
|
275
|
+
const denyDbWrites = transactionScope === "withoutTransaction";
|
|
215
276
|
const handlerEscapeHatch = writeEscapeHatch ??
|
|
216
277
|
registry.getQueryHandler(type)?.escapeHatch ??
|
|
217
278
|
registry.getStreamHandler(type)?.escapeHatch;
|
|
@@ -228,7 +289,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
228
289
|
});
|
|
229
290
|
const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
|
|
230
291
|
(isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
|
|
231
|
-
const buildTenantScopedDb = (source, signal, foreignTenantId) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
292
|
+
const buildTenantScopedDb = (source, signal, foreignTenantId, denyWrites = false) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
232
293
|
...(foreignTenantId === undefined && {
|
|
233
294
|
globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
|
|
234
295
|
unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
|
|
@@ -237,18 +298,21 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
237
298
|
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
238
299
|
personalDataGate: buildPersonalDataGate(registry, origin),
|
|
239
300
|
projectionRegistry: registry,
|
|
301
|
+
...(denyWrites && { writesDeniedReason: "without an open transaction" }),
|
|
240
302
|
});
|
|
241
303
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
242
304
|
// the client has disconnected — handlers with many sequential queries skip
|
|
243
305
|
// the rest of the chain instead of burning DB-CPU for results no one reads.
|
|
244
|
-
const db = dbSource
|
|
306
|
+
const db = dbSource
|
|
307
|
+
? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, denyDbWrites)
|
|
308
|
+
: undefined;
|
|
245
309
|
// Unbound pool, tenant-scoped like `db` but never tx-bound — writes
|
|
246
310
|
// through it survive a rollback of the handler's own transaction. No
|
|
247
311
|
// AbortSignal here: a client disconnect must not abort a durability write
|
|
248
312
|
// that is meant to outlive the request. Computed before `systemDb` below
|
|
249
313
|
// so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
|
|
250
314
|
// this same TenantDb instead of a second, independently-built one.
|
|
251
|
-
const outsideTxSource = ctx.outsideTransactionDb ??
|
|
315
|
+
const outsideTxSource = ctx.outsideTransactionDb ?? resolveAppPoolDb(ctx);
|
|
252
316
|
const rawDbOutsideTransaction = outsideTxSource
|
|
253
317
|
? buildTenantScopedDb(outsideTxSource, undefined)
|
|
254
318
|
: undefined;
|
|
@@ -279,9 +343,40 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
279
343
|
userId: user.id,
|
|
280
344
|
...(reqCtx && { requestId: reqCtx.requestId }),
|
|
281
345
|
});
|
|
282
|
-
|
|
283
|
-
|
|
346
|
+
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
347
|
+
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
348
|
+
const bridgeSink = afterCommitHooks ?? [];
|
|
349
|
+
// Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
|
|
350
|
+
const hasLiveCommitSink = afterCommitHooks !== undefined && transactionScope !== "postCommit";
|
|
351
|
+
const scheduleAfterCommit = (hook) => {
|
|
352
|
+
bridgeSink.push(hook);
|
|
353
|
+
};
|
|
354
|
+
const notifyFactory = context._notifyFactory;
|
|
355
|
+
const notifyJobDispatcher = isNotifyJobDispatcher(jobRunner) ? jobRunner : undefined;
|
|
356
|
+
// Inside the write tx the app pool's slots can all be held by waiting write txs, so the
|
|
357
|
+
// delivery's own DB work (broadcast lookup, attempt log, channel sends) runs on the outside-tx pool.
|
|
358
|
+
const notifyInTransactionFactory = notifyFactory && tx !== undefined
|
|
359
|
+
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
|
|
284
360
|
: undefined;
|
|
361
|
+
const notify = buildNotify();
|
|
362
|
+
function buildNotify() {
|
|
363
|
+
if (!notifyFactory)
|
|
364
|
+
return undefined;
|
|
365
|
+
const notifyAfterCommit = notifyFactory(user, user.tenantId, notifyJobDispatcher, undefined, context._fileProviderResolver);
|
|
366
|
+
if (!notifyInTransactionFactory)
|
|
367
|
+
return notifyAfterCommit;
|
|
368
|
+
const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
|
|
369
|
+
// A rolled-back write must leave no notification behind.
|
|
370
|
+
if (!hasLiveCommitSink)
|
|
371
|
+
return notifyInTransaction;
|
|
372
|
+
return createDeferringNotify({
|
|
373
|
+
registry,
|
|
374
|
+
tenantId: user.tenantId,
|
|
375
|
+
notifyAfterCommit,
|
|
376
|
+
notifyNow: notifyInTransaction,
|
|
377
|
+
scheduleAfterCommit,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
285
380
|
// Mirror notify: only built when the config feature wired its factory.
|
|
286
381
|
const config = context._configAccessorFactory && db
|
|
287
382
|
? context._configAccessorFactory({
|
|
@@ -320,7 +415,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
320
415
|
const forTenant = !isSystem && dbSource && registry.getWriteHandler(type) !== undefined
|
|
321
416
|
? (targetTenantId) => {
|
|
322
417
|
if (targetTenantId === user.tenantId && exposedDb) {
|
|
323
|
-
|
|
418
|
+
// Only perCommand handlers lose forTenant writes; reservations keep their pre-transaction contract.
|
|
419
|
+
const ownTenantDb = denyDbWrites && !isPerCommand
|
|
420
|
+
? buildTenantScopedDb(dbSource, reqCtx?.signal)
|
|
421
|
+
: exposedDb;
|
|
422
|
+
return { db: ownTenantDb, streamTenantId: undefined };
|
|
324
423
|
}
|
|
325
424
|
if (!mayOverrideTenant(user)) {
|
|
326
425
|
throw new AccessDeniedError({
|
|
@@ -328,7 +427,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
328
427
|
});
|
|
329
428
|
}
|
|
330
429
|
return {
|
|
331
|
-
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId),
|
|
430
|
+
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && isPerCommand),
|
|
332
431
|
streamTenantId: targetTenantId,
|
|
333
432
|
};
|
|
334
433
|
}
|
|
@@ -361,20 +460,20 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
361
460
|
const metricsFor = meter
|
|
362
461
|
? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
|
|
363
462
|
: () => createNoopMetricsHandle();
|
|
364
|
-
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
365
|
-
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
366
|
-
const bridgeSink = afterCommitHooks ?? [];
|
|
367
463
|
// A failed inner write has already appended its event and projections when a later
|
|
368
464
|
// step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
|
|
369
465
|
// failure result. The savepoint makes the failure undo exactly the inner write; the
|
|
370
466
|
// inner afterCommit hooks only reach the outer sink if it succeeded.
|
|
371
467
|
const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
|
|
468
|
+
if (registry.getWriteHandler(targetType)?.transaction === "perCommand") {
|
|
469
|
+
return writeFailure(perCommandNestedDenied(targetType));
|
|
470
|
+
}
|
|
372
471
|
if (!tx)
|
|
373
472
|
return executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
|
|
374
473
|
const innerAfterCommitHooks = [];
|
|
375
474
|
try {
|
|
376
475
|
const result = await runInSavepoint(tx, async (savepointTx) => {
|
|
377
|
-
const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary
|
|
476
|
+
const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary db-runner savepoint handle is a transaction handle
|
|
378
477
|
innerAfterCommitHooks);
|
|
379
478
|
if (isFailedWriteResult(innerResult))
|
|
380
479
|
throw new NestedWriteRollback(innerResult);
|
|
@@ -389,16 +488,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
389
488
|
throw e;
|
|
390
489
|
}
|
|
391
490
|
};
|
|
392
|
-
//
|
|
393
|
-
// auto-commit event append, projections and inTransaction hooks separately
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
const writeAsUser = async (asUser, targetType, payload) =>
|
|
397
|
-
?
|
|
398
|
-
:
|
|
399
|
-
const scheduleAfterCommit = (hook) => {
|
|
400
|
-
bridgeSink.push(hook);
|
|
401
|
-
};
|
|
491
|
+
// Without an open transaction (post-commit hooks, stream/query handlers, perCommand handlers) a bare
|
|
492
|
+
// executeWrite would auto-commit event append, projections and inTransaction hooks separately and its
|
|
493
|
+
// afterCommit hooks would land in a sink nobody flushes. A batch-of-one gives the write its own
|
|
494
|
+
// transaction and its own flush.
|
|
495
|
+
const writeAsUser = async (asUser, targetType, payload) => transactionScope === "inTransaction"
|
|
496
|
+
? executeNestedWriteInSavepoint(asUser, targetType, payload)
|
|
497
|
+
: unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin));
|
|
402
498
|
const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
|
|
403
499
|
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
|
|
404
500
|
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
@@ -733,9 +829,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
733
829
|
const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
734
830
|
// The payload-bucket digest is dispatcher-internal; handlers must not be
|
|
735
831
|
// able to compute bucket keys.
|
|
736
|
-
const { _rateLimitPayloadDigest:
|
|
832
|
+
const { _rateLimitPayloadDigest: payloadDigest,
|
|
833
|
+
// Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
|
|
834
|
+
_notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
|
|
737
835
|
const handlerContext = {
|
|
738
836
|
...contextForHandler,
|
|
837
|
+
...(payloadDigest && { rateLimitDigest: createHandlerBoundDigest(payloadDigest, type) }),
|
|
838
|
+
...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
|
|
739
839
|
registry,
|
|
740
840
|
db: exposedDb,
|
|
741
841
|
dbOutsideTransaction,
|
|
@@ -781,14 +881,22 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
781
881
|
_userId: user.id,
|
|
782
882
|
_tenantId: user.tenantId,
|
|
783
883
|
_handlerType: type,
|
|
784
|
-
scheduleAfterCommit,
|
|
884
|
+
...(hasLiveCommitSink && { scheduleAfterCommit }),
|
|
785
885
|
...(includeDeleted && { includeDeleted: true }),
|
|
786
886
|
...bridge,
|
|
787
887
|
}; // @cast-boundary engine-bridge
|
|
888
|
+
const scopedContext = transactionScope === "withoutTransaction"
|
|
889
|
+
? denyEventStoreSurfaces(handlerContext, withoutTransactionDenied)
|
|
890
|
+
: handlerContext;
|
|
788
891
|
// A resolved member principal is read-only by construction.
|
|
789
892
|
return isMemberResolutionPrincipal(user)
|
|
790
|
-
? applyMemberResolutionReadOnly(
|
|
791
|
-
:
|
|
893
|
+
? applyMemberResolutionReadOnly(scopedContext)
|
|
894
|
+
: scopedContext;
|
|
895
|
+
}
|
|
896
|
+
// The handler-name prefix keeps a handler from reproducing the digests the
|
|
897
|
+
// dispatcher uses for payload buckets (those hash the bare normalized value).
|
|
898
|
+
export function createHandlerBoundDigest(digest, handlerName) {
|
|
899
|
+
return (value) => digest(`handler:${handlerName}:${value}`);
|
|
792
900
|
}
|
|
793
901
|
// Resolves the agent risk the irreversible-operation gate should attribute to
|
|
794
902
|
// `type` when it becomes the request's entry handler — same defaulting rule
|
|
@@ -1076,7 +1184,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1076
1184
|
message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
|
|
1077
1185
|
});
|
|
1078
1186
|
}
|
|
1079
|
-
for (const { per, limit, windowSeconds, cost } of limits) {
|
|
1187
|
+
for (const { per, limit, windowSeconds, cost, normalize } of limits) {
|
|
1080
1188
|
const value = data[per.payloadField];
|
|
1081
1189
|
// skip: optional field not sent — nothing to bucket on
|
|
1082
1190
|
if (value === undefined)
|
|
@@ -1086,7 +1194,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1086
1194
|
message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
|
|
1087
1195
|
});
|
|
1088
1196
|
}
|
|
1089
|
-
const key = buildPayloadBucketKey(handlerName, per.payloadField, digest(normalizePayloadBucketValue(value)));
|
|
1197
|
+
const key = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
|
|
1090
1198
|
await context.rateLimit.enforce(key, { limit, windowSeconds, cost });
|
|
1091
1199
|
}
|
|
1092
1200
|
}
|
|
@@ -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.
|
|
@@ -57,8 +57,7 @@ export async function acquireConsumerState(tx, name, instanceId, rearmCooldownMs
|
|
|
57
57
|
const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
|
|
58
58
|
if (cooldownElapsed && state.rearmCount < maxRearmCount) {
|
|
59
59
|
const rearmed = await rearmDeadConsumer(tx, name, instanceId);
|
|
60
|
-
const rearmedState = rearmed &&
|
|
61
|
-
coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
|
|
60
|
+
const rearmedState = rearmed && coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
|
|
62
61
|
if (rearmedState)
|
|
63
62
|
return { state: rearmedState, skip: null };
|
|
64
63
|
}
|
|
@@ -149,7 +148,7 @@ export async function markProcessing(tx, name, instanceId) {
|
|
|
149
148
|
export async function fetchPendingEvents(tx, cursor, batchSize, pendingRanges = []) {
|
|
150
149
|
const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
|
|
151
150
|
const info = extractTableInfo(eventsTable);
|
|
152
|
-
return rawRows.map((row) => coerceRow(row, info));
|
|
151
|
+
return rawRows.map((row) => coerceRow(row, info));
|
|
153
152
|
}
|
|
154
153
|
// Deliver events to the consumer's handler in events.id order, or — when
|
|
155
154
|
// the consumer wires a batchHandler — through it in one call first. Halt-
|
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";
|
|
@@ -30,6 +31,7 @@ export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pip
|
|
|
30
31
|
export { createLifecycleHooks } from "./lifecycle-pipeline.js";
|
|
31
32
|
export type { MspRebuildDeps } from "./msp-rebuild.js";
|
|
32
33
|
export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
|
|
34
|
+
export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
|
|
33
35
|
export type { ProjectionProgress, RebuildResult } from "./projection-rebuild.js";
|
|
34
36
|
export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
|
|
35
37
|
export type { ProjectionStatus } from "./projection-state.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";
|
|
@@ -15,6 +16,7 @@ export { createIdempotencyGuard } from "./idempotency.js";
|
|
|
15
16
|
export { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "./irreversible-operation-gate.js";
|
|
16
17
|
export { createLifecycleHooks } from "./lifecycle-pipeline.js";
|
|
17
18
|
export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
|
|
19
|
+
export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
|
|
18
20
|
export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
|
|
19
21
|
export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.js";
|
|
20
22
|
export { runProjectionsForEvent } from "./projections-runner.js";
|
|
@@ -34,7 +34,7 @@ async function runInDiscardedReadOnlySavepoint(tx, timeoutMs, bindIdleTimeout, f
|
|
|
34
34
|
await runInSavepoint(tx, async (sp) => {
|
|
35
35
|
// kumiko-lint-ignore raw-sql transaction characteristic, no query helper
|
|
36
36
|
await asRawClient(sp).unsafe(memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout));
|
|
37
|
-
// @cast-boundary
|
|
37
|
+
// @cast-boundary db-runner savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
|
|
38
38
|
settled.outcome = { value: await fn(sp) };
|
|
39
39
|
// Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
|
|
40
40
|
throw new SavepointDiscard();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TenantId } from "../engine/types/identifiers.js";
|
|
2
|
+
import type { NotifyFn, NotifyOptions, Registry } from "../engine/types/index.js";
|
|
3
|
+
import type { AfterCommitHook } from "./dispatcher-utils.js";
|
|
4
|
+
export declare function assertNotifyTargetsOwnTenant(options: NotifyOptions, tenantId: TenantId): void;
|
|
5
|
+
type DeferringNotifyDeps = {
|
|
6
|
+
readonly registry: Pick<Registry, "getAllNotifications"> | undefined;
|
|
7
|
+
readonly tenantId: TenantId;
|
|
8
|
+
readonly notifyAfterCommit: NotifyFn;
|
|
9
|
+
readonly notifyNow: NotifyFn;
|
|
10
|
+
readonly scheduleAfterCommit: (hook: AfterCommitHook) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function createDeferringNotify(deps: DeferringNotifyDeps): NotifyFn;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { assertNotificationVariables } from "../engine/template-variables.js";
|
|
2
|
+
import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
|
|
3
|
+
function isTenantBroadcast(to) {
|
|
4
|
+
return typeof to === "object" && to !== null && !Array.isArray(to) && "tenant" in to;
|
|
5
|
+
}
|
|
6
|
+
export function assertNotifyTargetsOwnTenant(options, tenantId) {
|
|
7
|
+
const { to } = options;
|
|
8
|
+
if (isTenantBroadcast(to) && to.tenant !== tenantId) {
|
|
9
|
+
throw new AccessDeniedError({
|
|
10
|
+
message: "ctx.notify can only broadcast to the caller's own tenant",
|
|
11
|
+
details: { reason: FrameworkReasons.notifyForeignTenantBroadcast },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Without `immediate`, a notify inside a write transaction waits for the commit so a
|
|
16
|
+
// rolled-back write leaves no notification behind.
|
|
17
|
+
export function createDeferringNotify(deps) {
|
|
18
|
+
const { registry, tenantId, notifyAfterCommit, notifyNow, scheduleAfterCommit } = deps;
|
|
19
|
+
async function notifyDeferringUntilCommit(notificationType, options) {
|
|
20
|
+
assertNotifyTargetsOwnTenant(options, tenantId);
|
|
21
|
+
// Fail the write now: after the commit the problem would only surface in the render job.
|
|
22
|
+
if (registry)
|
|
23
|
+
assertNotificationVariables(registry, notificationType, options.data);
|
|
24
|
+
if (options.immediate === true)
|
|
25
|
+
return notifyNow(notificationType, options);
|
|
26
|
+
scheduleAfterCommit(async () => {
|
|
27
|
+
await notifyAfterCommit(notificationType, options);
|
|
28
|
+
});
|
|
29
|
+
return { kind: "deferred" };
|
|
30
|
+
}
|
|
31
|
+
return notifyDeferringUntilCommit;
|
|
32
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
-
import type { SessionUser } from "../engine/types/index.js";
|
|
2
|
+
import type { SessionUser, WriteHandlerDef } from "../engine/types/index.js";
|
|
3
3
|
import { type WriteErrorInfo } from "../errors/index.js";
|
|
4
4
|
import type { BatchCommand, DispatchContext } from "./dispatch-shared.js";
|
|
5
5
|
import type { WriteOrigin } from "./write-origin.js";
|
|
6
6
|
type ReservationConfirm = ReservationHandle["confirmInTransaction"];
|
|
7
7
|
export type ConsumedReservation = {
|
|
8
8
|
readonly confirmInTransaction: ReservationConfirm | undefined;
|
|
9
|
+
readonly prepared: unknown;
|
|
9
10
|
};
|
|
11
|
+
export declare function hasPreTransactionPhase(handler: WriteHandlerDef): boolean;
|
|
10
12
|
export declare function runAsCommand<T>(reserved: {
|
|
11
13
|
readonly type: string;
|
|
12
14
|
readonly reservation: ConsumedReservation;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { toWriteErrorInfo } from "../errors/index.js";
|
|
3
3
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
4
|
-
import { buildHandlerContext, isMemberResolutionPrincipal } from "./dispatch-shared.js";
|
|
4
|
+
import { applyBeforeTransactionReadOnly, buildHandlerContext, denyTransactionBoundSurfaces, isMemberResolutionPrincipal, } from "./dispatch-shared.js";
|
|
5
5
|
import { wrapToKumiko } from "./dispatcher-utils.js";
|
|
6
6
|
import { runPreHandlerGates } from "./pre-handler-gates.js";
|
|
7
7
|
import { effectiveWriteOrigin, rootWriteOrigin } from "./write-origin.js";
|
|
8
|
+
export function hasPreTransactionPhase(handler) {
|
|
9
|
+
return handler.reserveBeforeTransaction !== undefined || handler.beforeTransaction !== undefined;
|
|
10
|
+
}
|
|
8
11
|
// One-shot permission per top-level command: the handler that reserved before the transaction
|
|
9
12
|
// consumes it on entry. A capped handler reached any other way (nested ctx.write, writeAs, a
|
|
10
13
|
// second execution of the same type) finds it spent and is rejected instead of running unreserved.
|
|
@@ -36,13 +39,13 @@ async function releaseAll(ctx, releases) {
|
|
|
36
39
|
await release();
|
|
37
40
|
}
|
|
38
41
|
catch (error) {
|
|
39
|
-
log.error("releasing a pre-transaction
|
|
42
|
+
log.error("releasing a pre-transaction phase failed", { error });
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
// Same gates as the in-transaction run, but before any transaction: a caller who is rate limited,
|
|
44
|
-
// denied or sends an invalid payload never reserves anything, so a rejected request
|
|
45
|
-
// capacity or trigger
|
|
47
|
+
// denied or sends an invalid payload never reserves or prepares anything, so a rejected request
|
|
48
|
+
// cannot consume capacity or trigger external calls.
|
|
46
49
|
export async function reserveBeforeTransaction(ctx, commands, user, inheritedOrigin) {
|
|
47
50
|
const releases = [];
|
|
48
51
|
const reservedCommands = new Map();
|
|
@@ -51,7 +54,7 @@ export async function reserveBeforeTransaction(ctx, commands, user, inheritedOri
|
|
|
51
54
|
for (const [index, command] of commands.entries()) {
|
|
52
55
|
try {
|
|
53
56
|
const handler = ctx.registry.getWriteHandler(command.type);
|
|
54
|
-
if (!handler
|
|
57
|
+
if (!handler || !hasPreTransactionPhase(handler))
|
|
55
58
|
continue;
|
|
56
59
|
const gates = await runPreHandlerGates(ctx, handler, command.type, command.payload, user, "charge");
|
|
57
60
|
if (!gates.isSuccess) {
|
|
@@ -60,10 +63,26 @@ export async function reserveBeforeTransaction(ctx, commands, user, inheritedOri
|
|
|
60
63
|
}
|
|
61
64
|
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, command.type, user), inheritedOrigin);
|
|
62
65
|
const handlerContext = await buildHandlerContext(ctx, command.type, user, origin);
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const event = { type: command.type, payload: gates.payload, user };
|
|
67
|
+
let confirmInTransaction;
|
|
68
|
+
if (handler.reserveBeforeTransaction) {
|
|
69
|
+
const handle = toReservationHandle(await handler.reserveBeforeTransaction(event, denyTransactionBoundSurfaces(handlerContext)));
|
|
70
|
+
if (handle)
|
|
71
|
+
releases.push(handle.release);
|
|
72
|
+
confirmInTransaction = handle?.confirmInTransaction;
|
|
73
|
+
}
|
|
74
|
+
let prepared;
|
|
75
|
+
if (handler.beforeTransaction) {
|
|
76
|
+
const phase = await handler.beforeTransaction(event, applyBeforeTransactionReadOnly(handlerContext));
|
|
77
|
+
if (!phase.isSuccess) {
|
|
78
|
+
await releaseAll(ctx, releases);
|
|
79
|
+
return { isSuccess: false, error: phase.error, failedIndex: index };
|
|
80
|
+
}
|
|
81
|
+
if (phase.release)
|
|
82
|
+
releases.push(phase.release);
|
|
83
|
+
prepared = phase.data;
|
|
84
|
+
}
|
|
85
|
+
reservedCommands.set(index, { confirmInTransaction, prepared });
|
|
67
86
|
}
|
|
68
87
|
catch (error) {
|
|
69
88
|
await releaseAll(ctx, releases);
|