@cosmicdrift/kumiko-framework 0.387.0 → 0.388.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 +6 -1
- package/agents/examples/anonymous-access.md +1 -1
- package/agents/examples/apex-landing.md +204 -0
- package/agents/examples/api-worker-split.md +64 -0
- package/agents/examples/app-config.md +14 -0
- package/agents/examples/auth-mfa.md +115 -0
- package/agents/examples/env-composition.md +28 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/prod-boot-options.md +16 -0
- package/agents/examples/rate-limiting.md +48 -0
- package/agents/examples/record-detail-layout.md +692 -0
- package/agents/examples/seed-migration.md +212 -0
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +1 -0
- package/dist/api/client-error-route.js +8 -2
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/server-boot-checks.d.ts +10 -0
- package/dist/api/server-boot-checks.js +157 -0
- package/dist/api/server-event-dispatcher.d.ts +26 -0
- package/dist/api/server-event-dispatcher.js +208 -0
- package/dist/api/server-extra-routes.d.ts +11 -0
- package/dist/api/server-extra-routes.js +203 -0
- package/dist/api/server-middleware.d.ts +20 -0
- package/dist/api/server-middleware.js +211 -0
- package/dist/api/server-routes.d.ts +19 -0
- package/dist/api/server-routes.js +293 -0
- package/dist/api/server-websocket-routes.d.ts +4 -0
- package/dist/api/server-websocket-routes.js +108 -0
- package/dist/api/server-wiring.d.ts +32 -0
- package/dist/api/server-wiring.js +236 -0
- package/dist/api/server.d.ts +10 -15
- package/dist/api/server.js +48 -1251
- package/dist/api/session-cookies.js +4 -4
- package/dist/api/sse-route.js +16 -12
- package/dist/crypto/owner-transition-rekey.d.ts +5 -1
- package/dist/crypto/owner-transition-rekey.js +1 -1
- package/dist/crypto/pii-field-encryption.d.ts +5 -1
- package/dist/crypto/pii-field-encryption.js +3 -1
- package/dist/db/aggregate-sql.d.ts +21 -0
- package/dist/db/aggregate-sql.js +155 -0
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +95 -0
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +1 -1
- package/dist/db/driver-row-coercion.d.ts +2 -0
- package/dist/db/driver-row-coercion.js +186 -0
- package/dist/db/driver-value-preparation.d.ts +16 -0
- package/dist/db/driver-value-preparation.js +99 -0
- package/dist/db/entity-field-encryption.d.ts +1 -0
- package/dist/db/entity-field-encryption.js +1 -1
- package/dist/db/entity-table-meta.js +103 -110
- package/dist/db/event-store-executor-context.d.ts +2 -1
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +112 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +203 -0
- package/dist/db/event-store-executor-read-paging.d.ts +14 -0
- package/dist/db/event-store-executor-read-paging.js +99 -0
- package/dist/db/event-store-executor-read-rows.d.ts +6 -0
- package/dist/db/event-store-executor-read-rows.js +32 -0
- package/dist/db/event-store-executor-read-search.d.ts +28 -0
- package/dist/db/event-store-executor-read-search.js +221 -0
- package/dist/db/event-store-executor-read-sort.d.ts +41 -0
- package/dist/db/event-store-executor-read-sort.js +130 -0
- package/dist/db/event-store-executor-read-where.d.ts +24 -0
- package/dist/db/event-store-executor-read-where.js +81 -0
- package/dist/db/event-store-executor-read.d.ts +2 -9
- package/dist/db/event-store-executor-read.js +48 -701
- package/dist/db/event-store-executor-write-steps.d.ts +20 -0
- package/dist/db/event-store-executor-write-steps.js +100 -0
- package/dist/db/event-store-executor-write.js +395 -499
- package/dist/db/index.d.ts +6 -2
- package/dist/db/index.js +5 -1
- package/dist/db/list-aggregate-labels.d.ts +14 -0
- package/dist/db/list-aggregate-labels.js +26 -0
- package/dist/db/migrate-generator.js +71 -86
- package/dist/db/migrate-runner.js +4 -1
- package/dist/db/query.d.ts +2 -15
- package/dist/db/query.js +8 -761
- package/dist/db/reference-data.js +6 -1
- package/dist/db/table-builder.js +113 -166
- package/dist/db/table-info.d.ts +10 -0
- package/dist/db/table-info.js +45 -0
- package/dist/db/temporal-value-guards.d.ts +6 -0
- package/dist/db/temporal-value-guards.js +15 -0
- package/dist/db/tenant-db-runner.d.ts +1 -0
- package/dist/db/tenant-db-runner.js +3 -0
- package/dist/db/tenant-db.js +39 -9
- package/dist/db/where-clause.d.ts +6 -0
- package/dist/db/where-clause.js +236 -0
- package/dist/derivatives/delete-stored-file.js +2 -1
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/detail-screens.js +2 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +17 -0
- package/dist/engine/boot-validator/form-derivations.js +35 -1
- package/dist/engine/boot-validator/i18n-keys.js +5 -3
- package/dist/engine/boot-validator/index.js +2 -0
- package/dist/engine/boot-validator/pii-retention.js +233 -155
- package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
- package/dist/engine/boot-validator/screens-actions.js +8 -1
- package/dist/engine/boot-validator/screens-dashboard.js +2 -5
- package/dist/engine/boot-validator/screens-forms.js +2 -6
- package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
- package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
- package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
- package/dist/engine/boot-validator/screens-lists.js +12 -9
- package/dist/engine/boot-validator/screens.d.ts +0 -2
- package/dist/engine/boot-validator/screens.js +99 -860
- package/dist/engine/build-app-schema.js +56 -96
- package/dist/engine/define-feature.js +9 -2
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/entity-handlers.d.ts +13 -0
- package/dist/engine/entity-handlers.js +10 -21
- package/dist/engine/entity-list-runtime-options.d.ts +8 -0
- package/dist/engine/entity-list-runtime-options.js +18 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/patch.js +4 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
- package/dist/engine/feature-ast/patterns.d.ts +9 -0
- package/dist/engine/feature-ast/patterns.js +43 -51
- package/dist/engine/feature-ast/render.js +48 -87
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
- package/dist/engine/project-app-schema-for-roles.js +6 -4
- package/dist/engine/registry-ingest.js +1 -1
- package/dist/engine/schema-builder.js +228 -212
- package/dist/engine/screen-helpers.js +1 -1
- package/dist/engine/screen-kind.d.ts +7 -0
- package/dist/engine/screen-kind.js +1 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
- package/dist/engine/validate-projection-allowlist.js +2 -1
- package/dist/entrypoint/index.d.ts +4 -1
- package/dist/entrypoint/index.js +7 -1
- package/dist/errors/docs-url.d.ts +1 -1
- package/dist/errors/docs-url.js +22 -2
- package/dist/es-ops/context.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/event-store.js +10 -4
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +4 -1
- package/dist/files/file-handle.js +2 -3
- package/dist/files/file-routes.js +11 -8
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/upload-ledger.js +7 -0
- package/dist/i18n/required-surface-keys.js +182 -206
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-context.d.ts +32 -0
- package/dist/jobs/job-runner-context.js +305 -0
- package/dist/jobs/job-runner-execution.d.ts +7 -0
- package/dist/jobs/job-runner-execution.js +160 -0
- package/dist/jobs/job-runner-gates.d.ts +19 -0
- package/dist/jobs/job-runner-gates.js +198 -0
- package/dist/jobs/job-runner-helpers.d.ts +17 -3
- package/dist/jobs/job-runner-helpers.js +61 -2
- package/dist/jobs/job-runner-scope.d.ts +44 -0
- package/dist/jobs/job-runner-scope.js +102 -0
- package/dist/jobs/job-runner-types.d.ts +12 -0
- package/dist/jobs/job-runner.d.ts +0 -1
- package/dist/jobs/job-runner.js +47 -657
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +12 -1
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-query.js +10 -3
- package/dist/pipeline/dispatch-shared.d.ts +13 -3
- package/dist/pipeline/dispatch-shared.js +58 -710
- package/dist/pipeline/dispatch-write.js +296 -189
- package/dist/pipeline/event-dispatcher-admin.js +2 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -5
- package/dist/pipeline/handler-context-auth.d.ts +9 -0
- package/dist/pipeline/handler-context-auth.js +54 -0
- package/dist/pipeline/handler-context-config.d.ts +14 -0
- package/dist/pipeline/handler-context-config.js +113 -0
- package/dist/pipeline/handler-context-db.d.ts +24 -0
- package/dist/pipeline/handler-context-db.js +149 -0
- package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
- package/dist/pipeline/handler-context-dispatch.js +89 -0
- package/dist/pipeline/handler-context-events.d.ts +43 -0
- package/dist/pipeline/handler-context-events.js +237 -0
- package/dist/pipeline/handler-context-notify.d.ts +12 -0
- package/dist/pipeline/handler-context-notify.js +55 -0
- package/dist/pipeline/handler-context-observability.d.ts +7 -0
- package/dist/pipeline/handler-context-observability.js +21 -0
- package/dist/pipeline/handler-context-scope.d.ts +48 -0
- package/dist/pipeline/handler-context-scope.js +88 -0
- package/dist/pipeline/notify-scope.js +12 -1
- package/dist/pipeline/pre-handler-gates.js +20 -4
- package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
- package/dist/pipeline/pre-transaction-reservations.js +9 -3
- package/dist/pipeline/projection-rebuild.js +2 -1
- package/dist/schema-cli/apply-command.d.ts +2 -0
- package/dist/schema-cli/apply-command.js +97 -0
- package/dist/schema-cli/baseline-command.d.ts +2 -0
- package/dist/schema-cli/baseline-command.js +33 -0
- package/dist/schema-cli/generate-command.d.ts +2 -0
- package/dist/schema-cli/generate-command.js +90 -0
- package/dist/schema-cli/shared.d.ts +39 -0
- package/dist/schema-cli/shared.js +95 -0
- package/dist/schema-cli/status-command.d.ts +2 -0
- package/dist/schema-cli/status-command.js +73 -0
- package/dist/schema-cli/validate-boot-input.d.ts +5 -0
- package/dist/schema-cli/validate-boot-input.js +130 -0
- package/dist/schema-cli/validate-command.d.ts +2 -0
- package/dist/schema-cli/validate-command.js +130 -0
- package/dist/schema-cli.d.ts +2 -18
- package/dist/schema-cli.js +47 -604
- package/dist/search/meilisearch-adapter.js +2 -1
- package/dist/upgrade-cli.js +149 -121
- package/package.json +4 -4
- package/src/changes.json +310 -0
- package/src/scripts/codemod/feature-layout.ts +10 -2
- package/src/scripts/codemod/object-form.ts +41 -4
- package/src/scripts/codemod/package-merge.ts +58 -8
- package/src/scripts/codemod/pii-personal-migration.ts +175 -145
- package/src/scripts/codemod/testing-injection.ts +122 -42
- package/src/scripts/codemod/testing-move.ts +29 -7
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { runInSavepoint, selectMany } from "../db/query.js";
|
|
2
|
+
import { hasTenantColumn } from "../db/tenant-db.js";
|
|
3
|
+
import { InternalError, memberResolutionReadOnlyDenied, VersionConflictError, } from "../errors/index.js";
|
|
4
|
+
import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
|
|
5
|
+
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
|
|
6
|
+
import { loadAggregate, loadAggregateAsOf, loadAggregateForWriting, } from "../event-store/event-store.js";
|
|
7
|
+
import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
|
|
8
|
+
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
|
|
9
|
+
import { appendDomainEventCore } from "./append-event-core.js";
|
|
10
|
+
import { isMemberResolutionPrincipal, resolveDbSource, } from "./dispatch-shared.js";
|
|
11
|
+
import { createGatedProjectionReader } from "./system-identity-switch.js";
|
|
12
|
+
function requireDbSource(ctx, tx, surfaceCall) {
|
|
13
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
14
|
+
if (!dbSource) {
|
|
15
|
+
throw new InternalError({
|
|
16
|
+
message: `${surfaceCall} requires a database connection — none is configured.`,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return dbSource;
|
|
20
|
+
}
|
|
21
|
+
// ctx.appendEvent — append a domain event onto a specific aggregate stream
|
|
22
|
+
// in the current tx, then fire matching inline projections. Core logic
|
|
23
|
+
// lives in appendDomainEventCore; this wrapper just locates dbSource +
|
|
24
|
+
// stringifies the SessionUser id for the shared helper.
|
|
25
|
+
async function appendDomainEvent(ctx, args, user, tx, callerFeature) {
|
|
26
|
+
// Sink behind every append surface, so a resolved member stays read-only even via fetchForWriting handles.
|
|
27
|
+
if (isMemberResolutionPrincipal(user))
|
|
28
|
+
throw memberResolutionReadOnlyDenied();
|
|
29
|
+
const { registry } = ctx;
|
|
30
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.appendEvent("${args.type}")`);
|
|
31
|
+
await appendDomainEventCore({
|
|
32
|
+
registry,
|
|
33
|
+
db: dbSource,
|
|
34
|
+
tenantId: user.tenantId,
|
|
35
|
+
userId: String(user.id),
|
|
36
|
+
callSiteLabel: "ctx.appendEvent",
|
|
37
|
+
callerFeature,
|
|
38
|
+
}, args);
|
|
39
|
+
}
|
|
40
|
+
// Savepoint-scoped append: catches a losing writer's VersionConflictError
|
|
41
|
+
// without poisoning the rest of the handler's transaction. postgres.js
|
|
42
|
+
// aborts the WHOLE begin() on an uncaught statement error
|
|
43
|
+
// (SQLSTATE 25P02) even if the JS error is caught — runInSavepoint
|
|
44
|
+
// wraps the append in a real SAVEPOINT so only that nested scope rolls
|
|
45
|
+
// back on conflict, and subsequent statements in the outer tx (e.g. a
|
|
46
|
+
// dedup-anchor insert) still succeed. Use when a handler must react to
|
|
47
|
+
// losing a concurrent-append race instead of failing the whole write.
|
|
48
|
+
function buildTryAppendEvent(scope) {
|
|
49
|
+
const { type, user, tx, registry } = scope;
|
|
50
|
+
return async (args) => {
|
|
51
|
+
if (!tx) {
|
|
52
|
+
throw new InternalError({
|
|
53
|
+
message: `ctx.tryAppendEvent("${args.type}") requires an active transaction — no tx is threaded through this call.`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const event = await runInSavepoint(tx, (sp) => appendDomainEventCore({
|
|
58
|
+
registry,
|
|
59
|
+
db: sp,
|
|
60
|
+
tenantId: user.tenantId,
|
|
61
|
+
userId: String(user.id),
|
|
62
|
+
callSiteLabel: "ctx.tryAppendEvent",
|
|
63
|
+
callerFeature: registry.getHandlerFeature(type),
|
|
64
|
+
}, args));
|
|
65
|
+
return { ok: true, event };
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e instanceof EventStoreVersionConflictError) {
|
|
69
|
+
return { ok: false, conflict: e };
|
|
70
|
+
}
|
|
71
|
+
if (e instanceof EventStoreIdempotentAppendConflictError) {
|
|
72
|
+
return { ok: false, conflict: e };
|
|
73
|
+
}
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function buildFetchForWriting(scope) {
|
|
79
|
+
const { ctx, type, user, tx, registry } = scope;
|
|
80
|
+
return async (args) => {
|
|
81
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.fetchForWriting("${args.aggregateId}")`);
|
|
82
|
+
// Stream-version authoritative (same policy as CRUD executor + Block 0), read together
|
|
83
|
+
// with the events so the pinned version covers exactly what the handler's rules saw.
|
|
84
|
+
const { events: storedEvents, streamVersion: fetchedVersion } = await loadAggregateForWriting(dbSource, args.aggregateId, user.tenantId, { aggregateType: args.aggregateType });
|
|
85
|
+
const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
|
|
86
|
+
db: dbSource,
|
|
87
|
+
tenantId: user.tenantId,
|
|
88
|
+
});
|
|
89
|
+
// Optimistic concurrency: if the caller knows the version they
|
|
90
|
+
// worked against (e.g. from a prior read-model row) and the stream
|
|
91
|
+
// has moved on, fail fast before any downstream work.
|
|
92
|
+
if (args.expectedVersion !== undefined && args.expectedVersion !== fetchedVersion) {
|
|
93
|
+
throw new VersionConflictError({
|
|
94
|
+
entityId: args.aggregateId,
|
|
95
|
+
expectedVersion: args.expectedVersion,
|
|
96
|
+
currentVersion: fetchedVersion,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Pinned into every append: a write landing between fetch and append
|
|
100
|
+
// fails with VersionConflictError instead of stacking on a state the
|
|
101
|
+
// caller's business rules never saw. Bumps per appendOne so consecutive
|
|
102
|
+
// appends stay in order without re-reading the DB.
|
|
103
|
+
let handleVersion = fetchedVersion;
|
|
104
|
+
const appendOne = async (appendArgs) => {
|
|
105
|
+
await appendDomainEvent(ctx, {
|
|
106
|
+
aggregateId: args.aggregateId,
|
|
107
|
+
aggregateType: args.aggregateType,
|
|
108
|
+
type: appendArgs.type,
|
|
109
|
+
payload: appendArgs.payload,
|
|
110
|
+
expectedVersion: handleVersion,
|
|
111
|
+
}, user, tx, registry.getHandlerFeature(type));
|
|
112
|
+
handleVersion += 1;
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
events,
|
|
116
|
+
get version() {
|
|
117
|
+
return handleVersion;
|
|
118
|
+
},
|
|
119
|
+
appendOne,
|
|
120
|
+
hasEvent: (type) => events.some((e) => e.type === type),
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function buildLoadAggregate(scope) {
|
|
125
|
+
const { ctx, user, tx, registry } = scope;
|
|
126
|
+
return async (aggregateId, loadOptions) => {
|
|
127
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.loadAggregate("${aggregateId}")`);
|
|
128
|
+
const events = loadOptions?.asOf
|
|
129
|
+
? await loadAggregateAsOf(dbSource, aggregateId, user.tenantId, loadOptions.asOf)
|
|
130
|
+
: await loadAggregate(dbSource, aggregateId, user.tenantId);
|
|
131
|
+
return upcastStoredEvents(events, registry.getEventUpcasters(), {
|
|
132
|
+
db: dbSource,
|
|
133
|
+
tenantId: user.tenantId,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function buildSnapshotAggregate(scope) {
|
|
138
|
+
const { ctx, user, tx, registry } = scope;
|
|
139
|
+
return async (snapshotArgs) => {
|
|
140
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.snapshotAggregate("${snapshotArgs.aggregateId}")`);
|
|
141
|
+
await saveSnapshot(dbSource, {
|
|
142
|
+
aggregateId: snapshotArgs.aggregateId,
|
|
143
|
+
tenantId: user.tenantId,
|
|
144
|
+
aggregateType: snapshotArgs.aggregateType,
|
|
145
|
+
version: snapshotArgs.version,
|
|
146
|
+
state: snapshotArgs.state,
|
|
147
|
+
snapshotVersion: effectiveSnapshotGeneration(snapshotArgs.snapshotVersion ?? 1, registry.getEntity(snapshotArgs.aggregateType)?.eventVersion),
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function buildLoadAggregateWithSnapshot(scope) {
|
|
152
|
+
const { ctx, user, tx, registry } = scope;
|
|
153
|
+
return async (aggregateId, reducer, initial, loadOptions) => {
|
|
154
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.loadAggregateWithSnapshot("${aggregateId}")`);
|
|
155
|
+
// Upcaster-aware: pass an upcastEvent callback so loadAggregateWithSnapshot
|
|
156
|
+
// walks every delta through the registered chain before invoking the
|
|
157
|
+
// user's (sync) reducer. Async upcasters (DB-enrichment) are awaited
|
|
158
|
+
// inside loadAggregateWithSnapshot — feature authors never see legacy
|
|
159
|
+
// payload shapes regardless of which load path they chose.
|
|
160
|
+
const upcasters = registry.getEventUpcasters();
|
|
161
|
+
const upcastCtx = { db: dbSource, tenantId: user.tenantId };
|
|
162
|
+
return loadAggregateWithSnapshot(dbSource, aggregateId, user.tenantId, reducer, initial, {
|
|
163
|
+
...loadOptions,
|
|
164
|
+
entityEventVersionOf: (aggregateType) => registry.getEntity(aggregateType)?.eventVersion,
|
|
165
|
+
upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx), // @wrapper-known semantic-alias
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// queryProjection works against both single-stream and multi-stream
|
|
170
|
+
// projections. MSPs without a table cannot be queried — those are
|
|
171
|
+
// side-effect-only consumers (no state to read back).
|
|
172
|
+
function buildHandlerProjectionReader(scope) {
|
|
173
|
+
const { ctx, type, user, tx, registry, hasIdentitySwitchGrant, identitySwitchAudit } = scope;
|
|
174
|
+
const findProjectionTable = (qualifiedName) => registry.getAllProjections().get(qualifiedName)?.table ??
|
|
175
|
+
registry.getAllMultiStreamProjections().get(qualifiedName)?.table;
|
|
176
|
+
const ungatedQueryProjection = async (qualifiedName, queryOptions) => {
|
|
177
|
+
const projTable = findProjectionTable(qualifiedName);
|
|
178
|
+
if (!projTable) {
|
|
179
|
+
const singleNames = [...registry.getAllProjections().keys()];
|
|
180
|
+
const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter((n) => registry.getAllMultiStreamProjections().get(n)?.table);
|
|
181
|
+
const all = [...singleNames, ...mspNames];
|
|
182
|
+
throw new InternalError({
|
|
183
|
+
message: `ctx.queryProjection("${qualifiedName}") — projection not registered, or it is a ` +
|
|
184
|
+
`table-less MSP (side-effect-only). Known queryable projections: ${all.join(", ") || "(none)"}`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.queryProjection("${qualifiedName}")`);
|
|
188
|
+
const where = hasTenantColumn(projTable) && queryOptions?.unsafeAllTenants !== true
|
|
189
|
+
? { tenantId: user.tenantId }
|
|
190
|
+
: undefined;
|
|
191
|
+
const rows = await selectMany(dbSource, projTable, where);
|
|
192
|
+
return rows; // @cast-boundary engine-payload
|
|
193
|
+
};
|
|
194
|
+
return createGatedProjectionReader(`handler "${type}"`, hasIdentitySwitchGrant, ungatedQueryProjection, identitySwitchAudit, (qualifiedName) => {
|
|
195
|
+
const projTable = findProjectionTable(qualifiedName);
|
|
196
|
+
// Unknown projection: keep the grant check first, the ungated reader reports the real error.
|
|
197
|
+
return projTable === undefined || hasTenantColumn(projTable);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
export function buildEventStoreFacet(scope) {
|
|
201
|
+
const { ctx, type, user, tx, registry } = scope;
|
|
202
|
+
return {
|
|
203
|
+
// Strict + unsafe share the same runtime — only the type-surface
|
|
204
|
+
// differs. The strict signature is what's exposed to typed callers;
|
|
205
|
+
// unsafe is the explicit escape-hatch for runtime-pluggable events.
|
|
206
|
+
appendEvent: (async (args) => {
|
|
207
|
+
await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
|
|
208
|
+
}), // @cast-boundary engine-bridge
|
|
209
|
+
unsafeAppendEvent: async (args) => {
|
|
210
|
+
await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
|
|
211
|
+
},
|
|
212
|
+
tryAppendEvent: buildTryAppendEvent(scope),
|
|
213
|
+
fetchForWriting: buildFetchForWriting(scope),
|
|
214
|
+
loadAggregate: buildLoadAggregate(scope),
|
|
215
|
+
archiveStream: async (aggregateId, archiveArgs) => {
|
|
216
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.archiveStream("${aggregateId}")`);
|
|
217
|
+
await archiveStreamHelper(dbSource, {
|
|
218
|
+
tenantId: user.tenantId,
|
|
219
|
+
aggregateId,
|
|
220
|
+
aggregateType: archiveArgs.aggregateType,
|
|
221
|
+
archivedBy: user.id,
|
|
222
|
+
reason: archiveArgs.reason,
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
restoreStream: async (aggregateId) => {
|
|
226
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.restoreStream("${aggregateId}")`);
|
|
227
|
+
await restoreStreamHelper(dbSource, user.tenantId, aggregateId);
|
|
228
|
+
},
|
|
229
|
+
isStreamArchived: async (aggregateId) => {
|
|
230
|
+
const dbSource = requireDbSource(ctx, tx, `ctx.isStreamArchived("${aggregateId}")`);
|
|
231
|
+
return isStreamArchived(dbSource, user.tenantId, aggregateId);
|
|
232
|
+
},
|
|
233
|
+
snapshotAggregate: buildSnapshotAggregate(scope),
|
|
234
|
+
loadAggregateWithSnapshot: buildLoadAggregateWithSnapshot(scope),
|
|
235
|
+
queryProjection: buildHandlerProjectionReader(scope),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DbConnection } from "../db/connection.ts";
|
|
2
|
+
import type { TenantId } from "../engine/types/identifiers.ts";
|
|
3
|
+
import type { NotifyFn, SessionUser } from "../engine/types/index.ts";
|
|
4
|
+
import type { CommitSinks } from "./handler-context-dispatch.ts";
|
|
5
|
+
import type { HandlerContextScope } from "./handler-context-scope.ts";
|
|
6
|
+
export declare function buildNotifyFacet(scope: HandlerContextScope, sinks: CommitSinks, outsideTxSource: DbConnection | undefined): {
|
|
7
|
+
notify: NotifyFn | undefined;
|
|
8
|
+
factoryFragment: {
|
|
9
|
+
_notifyInTransactionFactory?: ((notifyUser: SessionUser, notifyTenantId: TenantId) => NotifyFn) | undefined;
|
|
10
|
+
_notifyAfterCommitFactory?: ((notifyUser: SessionUser, notifyTenantId: TenantId) => NotifyFn) | undefined;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createDeferringNotify } from "./notify-scope.js";
|
|
2
|
+
// DispatchContext.jobRunner is typed as the handleEvent-only JobRunnerRef but
|
|
3
|
+
// holds the concrete runner at boot; narrow instead of casting.
|
|
4
|
+
function isNotifyJobDispatcher(runner) {
|
|
5
|
+
return (typeof runner === "object" &&
|
|
6
|
+
runner !== null &&
|
|
7
|
+
"dispatch" in runner &&
|
|
8
|
+
typeof runner.dispatch === "function");
|
|
9
|
+
}
|
|
10
|
+
function buildNotify(scope, sinks, notifyAfterCommitFactory, notifyInTransactionFactory) {
|
|
11
|
+
const { user, registry } = scope;
|
|
12
|
+
if (!notifyAfterCommitFactory)
|
|
13
|
+
return undefined;
|
|
14
|
+
const notifyAfterCommit = notifyAfterCommitFactory(user, user.tenantId);
|
|
15
|
+
if (!notifyInTransactionFactory)
|
|
16
|
+
return notifyAfterCommit;
|
|
17
|
+
const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
|
|
18
|
+
// A rolled-back write must leave no notification behind. Without any commit to wait for (a
|
|
19
|
+
// post-commit context) delivery stays immediate.
|
|
20
|
+
const { notifyCommitSink } = sinks;
|
|
21
|
+
if (notifyCommitSink === undefined)
|
|
22
|
+
return notifyInTransaction;
|
|
23
|
+
return createDeferringNotify({
|
|
24
|
+
registry,
|
|
25
|
+
tenantId: user.tenantId,
|
|
26
|
+
notifyAfterCommit,
|
|
27
|
+
notifyNow: notifyInTransaction,
|
|
28
|
+
scheduleAfterCommit: (hook) => {
|
|
29
|
+
notifyCommitSink.push(hook);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function buildNotifyFacet(scope, sinks, outsideTxSource) {
|
|
34
|
+
const { ctx, tx, context } = scope;
|
|
35
|
+
const notifyFactory = context._notifyFactory;
|
|
36
|
+
const notifyJobDispatcher = isNotifyJobDispatcher(ctx.jobRunner) ? ctx.jobRunner : undefined;
|
|
37
|
+
// Inside the write tx the app pool's slots can all be held by waiting write txs, so the
|
|
38
|
+
// delivery's own DB work (broadcast lookup, attempt log, channel sends) runs on the outside-tx pool.
|
|
39
|
+
const notifyInTransactionFactory = notifyFactory && tx !== undefined
|
|
40
|
+
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
|
|
41
|
+
: undefined;
|
|
42
|
+
const notifyAfterCommitFactory = notifyFactory
|
|
43
|
+
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, undefined, context._fileProviderResolver)
|
|
44
|
+
: undefined;
|
|
45
|
+
const notify = buildNotify(scope, sinks, notifyAfterCommitFactory, notifyInTransactionFactory);
|
|
46
|
+
return {
|
|
47
|
+
notify,
|
|
48
|
+
factoryFragment: {
|
|
49
|
+
...(notifyInTransactionFactory && {
|
|
50
|
+
_notifyInTransactionFactory: notifyInTransactionFactory,
|
|
51
|
+
}),
|
|
52
|
+
...(notifyAfterCommitFactory && { _notifyAfterCommitFactory: notifyAfterCommitFactory }),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HandlerContextScope } from "./handler-context-scope.ts";
|
|
2
|
+
export declare function buildObservabilityFacet(scope: HandlerContextScope): {
|
|
3
|
+
log: import("@cosmicdrift/kumiko-types/logger").Logger | undefined;
|
|
4
|
+
tracer: import("@cosmicdrift/kumiko-types/observability/span").Tracer;
|
|
5
|
+
metrics: import("@cosmicdrift/kumiko-types/observability/metric").MetricsHandle;
|
|
6
|
+
metricsFor: (featureName: string) => import("@cosmicdrift/kumiko-types/observability/metric").MetricsHandle;
|
|
7
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, getFallbackTracer, } from "../observability/index.js";
|
|
2
|
+
export function buildObservabilityFacet(scope) {
|
|
3
|
+
const { type, user, context, reqCtx, featureName } = scope;
|
|
4
|
+
const log = context.log?.child({
|
|
5
|
+
handler: type,
|
|
6
|
+
tenantId: user.tenantId,
|
|
7
|
+
userId: user.id,
|
|
8
|
+
...(reqCtx && { requestId: reqCtx.requestId }),
|
|
9
|
+
});
|
|
10
|
+
// Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
|
|
11
|
+
// resolves to kumiko_<feature>_foo. Unknown feature falls back to noop
|
|
12
|
+
// so legacy internal handlers don't crash.
|
|
13
|
+
const tracer = context.tracer ?? getFallbackTracer();
|
|
14
|
+
const meter = context.meter;
|
|
15
|
+
const metrics = meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
|
|
16
|
+
// ctx.metricsFor(featureName) — shared/library code binds to a feature
|
|
17
|
+
// name of its own choosing instead of the dispatching handler's.
|
|
18
|
+
// Unregistered names no-op rather than throw.
|
|
19
|
+
const metricsFor = createMetricsForFactory(meter);
|
|
20
|
+
return { log, tracer, metrics, metricsFor };
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type EscapeHatchDeclaration, type EscapeHatchReporter } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { type RequestContextData } from "../api/request-context.ts";
|
|
3
|
+
import type { DbConnection, DbTx } from "../db/connection.ts";
|
|
4
|
+
import type { AppContext, Registry, SessionUser } from "../engine/types/index.ts";
|
|
5
|
+
import { type DispatchContext, type HandlerTransactionScope } from "./dispatch-shared.ts";
|
|
6
|
+
import type { WriteOrigin } from "./write-origin.ts";
|
|
7
|
+
export type HandlerContextScope = {
|
|
8
|
+
readonly ctx: DispatchContext;
|
|
9
|
+
readonly type: string;
|
|
10
|
+
readonly user: SessionUser;
|
|
11
|
+
readonly origin: WriteOrigin;
|
|
12
|
+
readonly tx: DbTx | undefined;
|
|
13
|
+
readonly transactionScope: HandlerTransactionScope;
|
|
14
|
+
readonly registry: Registry;
|
|
15
|
+
readonly context: AppContext;
|
|
16
|
+
readonly isSystem: boolean;
|
|
17
|
+
readonly dbSource: DbConnection | DbTx | undefined;
|
|
18
|
+
readonly reqCtx: RequestContextData | undefined;
|
|
19
|
+
readonly writeEscapeHatch: EscapeHatchDeclaration | undefined;
|
|
20
|
+
readonly handlerEscapeHatch: EscapeHatchDeclaration | undefined;
|
|
21
|
+
readonly isWriteHandler: boolean;
|
|
22
|
+
readonly isPerCommand: boolean;
|
|
23
|
+
readonly denyDbWrites: boolean;
|
|
24
|
+
readonly featureName: string | undefined;
|
|
25
|
+
readonly hasIdentitySwitchGrant: boolean;
|
|
26
|
+
readonly reportEscapeHatch: EscapeHatchReporter;
|
|
27
|
+
readonly identitySwitchAudit: {
|
|
28
|
+
readonly reason: string | undefined;
|
|
29
|
+
readonly report: EscapeHatchReporter;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
export declare function resolveHandlerContextScope(input: {
|
|
33
|
+
readonly ctx: DispatchContext;
|
|
34
|
+
readonly type: string;
|
|
35
|
+
readonly user: SessionUser;
|
|
36
|
+
readonly origin: WriteOrigin;
|
|
37
|
+
readonly tx: DbTx | undefined;
|
|
38
|
+
readonly transactionScope: HandlerTransactionScope;
|
|
39
|
+
}): HandlerContextScope;
|
|
40
|
+
export declare function buildPreSaveFragment(scope: HandlerContextScope): {
|
|
41
|
+
runPreSave?: ((handlerName: string, changes: Record<string, unknown>, previous: Readonly<Record<string, unknown>>, isNew: boolean) => Promise<Record<string, unknown>>) | undefined;
|
|
42
|
+
};
|
|
43
|
+
export declare function buildRuntimeFragment(scope: HandlerContextScope): {
|
|
44
|
+
signal?: AbortSignal | undefined;
|
|
45
|
+
effectiveFeatures?: import("@cosmicdrift/kumiko-framework/engine").EffectiveFeaturesResolver | undefined;
|
|
46
|
+
cacheSync?: import("@cosmicdrift/kumiko-types/cache-sync-types").CacheSyncBus | undefined;
|
|
47
|
+
jobRunner?: import("@cosmicdrift/kumiko-types/handlers").JobRunnerRef | undefined;
|
|
48
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { escapeHatchFor, } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { requestContext } from "../api/request-context.js";
|
|
3
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
4
|
+
import { createEscapeHatchReporter } from "../observability/index.js";
|
|
5
|
+
import { resolveDbSource, } from "./dispatch-shared.js";
|
|
6
|
+
export function resolveHandlerContextScope(input) {
|
|
7
|
+
const { ctx, type, user, origin, tx, transactionScope } = input;
|
|
8
|
+
const { registry, appContext: context } = ctx;
|
|
9
|
+
const isSystem = registry.isHandlerSystemScoped(type);
|
|
10
|
+
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
11
|
+
// AppContext's `db` union also allows TenantDb (for downstream hook calls),
|
|
12
|
+
// but at this point we're the root of the pipeline — cast is safe.
|
|
13
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
14
|
+
const reqCtx = requestContext.get();
|
|
15
|
+
// global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write, query or stream escapeHatch.
|
|
16
|
+
const writeHandler = registry.getWriteHandler(type);
|
|
17
|
+
const writeEscapeHatch = writeHandler?.escapeHatch;
|
|
18
|
+
const isPerCommand = writeHandler?.transaction === "perCommand";
|
|
19
|
+
const denyDbWrites = transactionScope === "withoutTransaction";
|
|
20
|
+
const handlerEscapeHatch = writeEscapeHatch ??
|
|
21
|
+
registry.getQueryHandler(type)?.escapeHatch ??
|
|
22
|
+
registry.getStreamHandler(type)?.escapeHatch;
|
|
23
|
+
const identitySwitchEscapeHatch = escapeHatchFor(handlerEscapeHatch, "systemIdentity");
|
|
24
|
+
const hasIdentitySwitchGrant = isSystem || identitySwitchEscapeHatch !== undefined;
|
|
25
|
+
const featureName = registry.getHandlerFeature(type);
|
|
26
|
+
const reportEscapeHatch = createEscapeHatchReporter({
|
|
27
|
+
handler: type,
|
|
28
|
+
tenantId: user.tenantId,
|
|
29
|
+
actor: user.id,
|
|
30
|
+
sink: context._escapeHatchAuditSink,
|
|
31
|
+
log: context.log ?? createFallbackLogger("dispatcher"),
|
|
32
|
+
window: ctx.escapeHatchReportWindow,
|
|
33
|
+
meter: context.meter,
|
|
34
|
+
});
|
|
35
|
+
const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
|
|
36
|
+
(isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
|
|
37
|
+
return {
|
|
38
|
+
ctx,
|
|
39
|
+
type,
|
|
40
|
+
user,
|
|
41
|
+
origin,
|
|
42
|
+
tx,
|
|
43
|
+
transactionScope,
|
|
44
|
+
registry,
|
|
45
|
+
context,
|
|
46
|
+
isSystem,
|
|
47
|
+
dbSource,
|
|
48
|
+
reqCtx,
|
|
49
|
+
writeEscapeHatch,
|
|
50
|
+
handlerEscapeHatch,
|
|
51
|
+
isWriteHandler: writeHandler !== undefined,
|
|
52
|
+
isPerCommand,
|
|
53
|
+
denyDbWrites,
|
|
54
|
+
featureName,
|
|
55
|
+
hasIdentitySwitchGrant,
|
|
56
|
+
reportEscapeHatch,
|
|
57
|
+
identitySwitchAudit: { reason: identitySwitchGrantReason, report: reportEscapeHatch },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// preSave hooks need `changes`/`previous`/`isNew`, which only exist once
|
|
61
|
+
// a handler actually starts building its write — bound here so entity
|
|
62
|
+
// CRUD handlers (entity-handlers.ts) can forward it to the executor.
|
|
63
|
+
export function buildPreSaveFragment(scope) {
|
|
64
|
+
const { lifecycle } = scope.ctx;
|
|
65
|
+
return {
|
|
66
|
+
...(lifecycle && {
|
|
67
|
+
runPreSave: (handlerName, changes, previous, isNew) => lifecycle.runPreSave(handlerName, changes, previous, isNew, scope.context),
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function buildRuntimeFragment(scope) {
|
|
72
|
+
const { ctx, reqCtx } = scope;
|
|
73
|
+
const { effectiveFeatures, jobRunner } = ctx;
|
|
74
|
+
return {
|
|
75
|
+
// Cancellation signal flows from the HTTP middleware via
|
|
76
|
+
// requestContext. Conditional spread so non-HTTP entry-points
|
|
77
|
+
// (jobs, dispatcher MSP-applies) don't get a phantom signal that
|
|
78
|
+
// would always read aborted=false but feel meaningful.
|
|
79
|
+
...(reqCtx?.signal ? { signal: reqCtx.signal } : {}),
|
|
80
|
+
// Propagate the feature-toggle resolver so the lifecycle pipeline,
|
|
81
|
+
// MSP runner, and ctx.hasFeature all pull from the same source.
|
|
82
|
+
...(effectiveFeatures && { effectiveFeatures }),
|
|
83
|
+
...(ctx.cacheSync && { cacheSync: ctx.cacheSync }),
|
|
84
|
+
// Lets write handlers call ctx.jobRunner.dispatch(...) directly, same
|
|
85
|
+
// as a follow-up job would (test-stack.ts wires the matching runner).
|
|
86
|
+
...(jobRunner && { jobRunner }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertNotificationVariables } from "../engine/template-variables.js";
|
|
2
2
|
import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
|
|
3
|
+
import { parseJsonOrThrow } from "../utils/safe-json.js";
|
|
3
4
|
function isTenantBroadcast(to) {
|
|
4
5
|
return typeof to === "object" && to !== null && !Array.isArray(to) && "tenant" in to;
|
|
5
6
|
}
|
|
@@ -12,6 +13,16 @@ export function assertNotifyTargetsOwnTenant(options, tenantId) {
|
|
|
12
13
|
});
|
|
13
14
|
}
|
|
14
15
|
}
|
|
16
|
+
// Temporal values, functions and class instances in `data` are not structured-cloneable; the
|
|
17
|
+
// job pipeline serializes them to JSON on enqueue anyway, so the JSON form is the faithful snapshot.
|
|
18
|
+
function snapshotNotifyOptions(options) {
|
|
19
|
+
try {
|
|
20
|
+
return structuredClone(options);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return parseJsonOrThrow(JSON.stringify(options), "notify options snapshot");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
// Without `immediate`, a notify inside a write transaction waits for the commit so a
|
|
16
27
|
// rolled-back write leaves no notification behind.
|
|
17
28
|
export function createDeferringNotify(deps) {
|
|
@@ -24,7 +35,7 @@ export function createDeferringNotify(deps) {
|
|
|
24
35
|
if (options.immediate === true)
|
|
25
36
|
return notifyNow(notificationType, options);
|
|
26
37
|
// The hook runs after the handler returned; a caller reusing `data` meanwhile must not change what is sent.
|
|
27
|
-
const optionsAtCallTime =
|
|
38
|
+
const optionsAtCallTime = snapshotNotifyOptions(options);
|
|
28
39
|
scheduleAfterCommit(async () => {
|
|
29
40
|
await notifyAfterCommit(notificationType, optionsAtCallTime);
|
|
30
41
|
});
|
|
@@ -2,16 +2,17 @@ import { hasAccess } from "../engine/access.js";
|
|
|
2
2
|
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
3
3
|
import { checkWriteFieldRoles } from "../engine/field-access.js";
|
|
4
4
|
import { runValidation } from "../engine/validation.js";
|
|
5
|
-
import { AccessDeniedError, FrameworkReasons, isKumikoError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
|
|
5
|
+
import { AccessDeniedError, FrameworkReasons, InternalError, isKumikoError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
|
|
6
6
|
import { checkFeatureEnabled, enforcePayloadRateLimits, enforceRateLimit, } from "./dispatch-shared.js";
|
|
7
7
|
import { handlerAccessError } from "./handler-access-error.js";
|
|
8
8
|
async function rejectDisabledFeature(ctx, type, user) {
|
|
9
9
|
const disabledErr = await checkFeatureEnabled(ctx, type, user.tenantId);
|
|
10
10
|
return disabledErr ? writeFailure(disabledErr) : undefined;
|
|
11
11
|
}
|
|
12
|
-
async function rejectRateLimited(ctx, handler, type, user) {
|
|
12
|
+
async function rejectRateLimited(ctx, handler, type, user, resolveCostOverride) {
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
const costOverride = resolveCostOverride?.();
|
|
15
|
+
await enforceRateLimit(ctx, handler.rateLimit, type, user, ctx.registry.isHandlerSystemScoped(type), costOverride);
|
|
15
16
|
}
|
|
16
17
|
catch (e) {
|
|
17
18
|
if (isKumikoError(e))
|
|
@@ -20,6 +21,14 @@ async function rejectRateLimited(ctx, handler, type, user) {
|
|
|
20
21
|
}
|
|
21
22
|
return undefined;
|
|
22
23
|
}
|
|
24
|
+
function normalizeRateLimitCost(handlerName, cost) {
|
|
25
|
+
if (!Number.isFinite(cost)) {
|
|
26
|
+
throw new InternalError({
|
|
27
|
+
message: `Handler "${handlerName}" rateLimitCost returned ${cost}; expected a finite number.`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return Math.max(1, Math.ceil(cost));
|
|
31
|
+
}
|
|
23
32
|
async function rejectPayloadRateLimited(ctx, handler, type, user, payload) {
|
|
24
33
|
try {
|
|
25
34
|
await enforcePayloadRateLimits(ctx, handler.additionalRateLimits, type, user, payload);
|
|
@@ -70,7 +79,7 @@ async function rejectBeforeParse(ctx, handler, type, user, mode) {
|
|
|
70
79
|
const disabled = await rejectDisabledFeature(ctx, type, user);
|
|
71
80
|
if (disabled)
|
|
72
81
|
return disabled;
|
|
73
|
-
if (mode === "charge") {
|
|
82
|
+
if (mode === "charge" && !handler.rateLimitCost) {
|
|
74
83
|
const rateLimited = await rejectRateLimited(ctx, handler, type, user);
|
|
75
84
|
if (rateLimited)
|
|
76
85
|
return rateLimited;
|
|
@@ -85,6 +94,13 @@ async function rejectAfterParse(ctx, handler, type, user, payload, mode) {
|
|
|
85
94
|
const overrideDenied = tenantOverrideDenied(user, payload);
|
|
86
95
|
if (overrideDenied)
|
|
87
96
|
return writeFailure(overrideDenied);
|
|
97
|
+
if (mode === "charge" && handler.rateLimitCost) {
|
|
98
|
+
// Cost comes from the validated payload, so this bucket is billed after the parse instead of before it.
|
|
99
|
+
const { rateLimitCost } = handler;
|
|
100
|
+
const rateLimited = await rejectRateLimited(ctx, handler, type, user, () => normalizeRateLimitCost(type, rateLimitCost(payload)));
|
|
101
|
+
if (rateLimited)
|
|
102
|
+
return rateLimited;
|
|
103
|
+
}
|
|
88
104
|
if (mode === "charge") {
|
|
89
105
|
const rateLimited = await rejectPayloadRateLimited(ctx, handler, type, user, payload);
|
|
90
106
|
if (rateLimited)
|
|
@@ -7,6 +7,7 @@ type ReservationConfirm = ReservationHandle["confirmInTransaction"];
|
|
|
7
7
|
export type ConsumedReservation = {
|
|
8
8
|
readonly confirmInTransaction: ReservationConfirm | undefined;
|
|
9
9
|
readonly prepared: unknown;
|
|
10
|
+
readonly parsedPayload: unknown;
|
|
10
11
|
};
|
|
11
12
|
export declare function hasPreTransactionPhase(handler: WriteHandlerDef): boolean;
|
|
12
13
|
export declare function runAsCommand<T>(reserved: {
|
|
@@ -62,10 +62,10 @@ export async function reserveBeforeTransaction(ctx, commands, user, inheritedOri
|
|
|
62
62
|
return { isSuccess: false, error: gates.error, failedIndex: index };
|
|
63
63
|
}
|
|
64
64
|
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, command.type, user), inheritedOrigin);
|
|
65
|
-
const handlerContext = await buildHandlerContext(ctx, command.type, user, origin);
|
|
66
65
|
const event = { type: command.type, payload: gates.payload, user };
|
|
67
66
|
let confirmInTransaction;
|
|
68
67
|
if (handler.reserveBeforeTransaction) {
|
|
68
|
+
const handlerContext = await buildHandlerContext(ctx, command.type, user, origin);
|
|
69
69
|
const handle = toReservationHandle(await handler.reserveBeforeTransaction(event, denyTransactionBoundSurfaces(handlerContext)));
|
|
70
70
|
if (handle)
|
|
71
71
|
releases.push(handle.release);
|
|
@@ -73,7 +73,9 @@ export async function reserveBeforeTransaction(ctx, commands, user, inheritedOri
|
|
|
73
73
|
}
|
|
74
74
|
let prepared;
|
|
75
75
|
if (handler.beforeTransaction) {
|
|
76
|
-
const phaseContext = await buildHandlerContext(ctx, command.type, user, origin,
|
|
76
|
+
const phaseContext = await buildHandlerContext(ctx, command.type, user, origin, {
|
|
77
|
+
readOnlyForTenant: true,
|
|
78
|
+
});
|
|
77
79
|
const phase = await handler.beforeTransaction(event, applyBeforeTransactionReadOnly(phaseContext));
|
|
78
80
|
if (!phase.isSuccess) {
|
|
79
81
|
await releaseAll(ctx, releases);
|
|
@@ -83,7 +85,11 @@ export async function reserveBeforeTransaction(ctx, commands, user, inheritedOri
|
|
|
83
85
|
releases.push(phase.release);
|
|
84
86
|
prepared = phase.data;
|
|
85
87
|
}
|
|
86
|
-
reservedCommands.set(index, {
|
|
88
|
+
reservedCommands.set(index, {
|
|
89
|
+
confirmInTransaction,
|
|
90
|
+
prepared,
|
|
91
|
+
parsedPayload: gates.payload,
|
|
92
|
+
});
|
|
87
93
|
}
|
|
88
94
|
catch (error) {
|
|
89
95
|
await releaseAll(ctx, releases);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { countSubscribedEvents, finalizeProjectionRebuild, markProjectionRebuildFailed, markProjectionRebuilding, selectEventsForProjectionRebuildBatch, } from "../db/queries/projection-rebuild.js";
|
|
2
2
|
import { assertLiveColumnsMatchMeta, assertLiveTableHasNoRowLevelSecurity, assertNoBlindIndexLoss, assertNoUnreachableLiveRows, buildShadowTable, countColumnDrift, ensureRebuildSchema, fenceLiveTable, rebuildMetaOrThrow, swapShadowIntoLive, } from "../db/queries/shadow-swap.js";
|
|
3
|
-
import { coerceRow,
|
|
3
|
+
import { coerceRow, runInSavepoint, selectMany } from "../db/query.js";
|
|
4
4
|
import { withoutStatementTimeout } from "../db/statement-timeout.js";
|
|
5
|
+
import { extractTableInfo } from "../db/table-info.js";
|
|
5
6
|
import { withExecutorManagedWrites } from "../db/tenant-db.js";
|
|
6
7
|
import { eventsTable, getEventsHighWaterMark, upcastStoredEvent, } from "../event-store/index.js";
|
|
7
8
|
import { createRebuildDeadLetterTable, recordRebuildDeadLetters, } from "../event-store/rebuild-dead-letter.js";
|