@cosmicdrift/kumiko-framework 0.363.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 +7 -0
- 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/routes.js +46 -17
- 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 +8 -3
- package/dist/bun-db/query.js +27 -7
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/eagerload.js +2 -2
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +24 -22
- package/dist/db/event-store-executor-read.js +31 -16
- 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 +1 -1
- package/dist/db/migrate-runner.js +9 -8
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- 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 +2 -0
- package/dist/db/tenant-db.js +30 -4
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/soft-delete-cleanup.js +10 -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 +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/types/index.d.ts +1 -1
- 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/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 +1 -1
- 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/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +8 -4
- 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-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +99 -22
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +36 -9
- package/package.json +7 -5
- package/src/changes.json +207 -8
|
@@ -1,34 +1,60 @@
|
|
|
1
1
|
import { deleteMany, selectMany } from "../db/query.js";
|
|
2
2
|
import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
|
|
3
3
|
export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
|
|
4
|
+
const PAGE_ORDER = [
|
|
5
|
+
{ col: "deletedAt", direction: "asc" },
|
|
6
|
+
{ col: "id", direction: "asc" },
|
|
7
|
+
];
|
|
8
|
+
// Keyset page over (deletedAt, id): rows at the cursor's instant with a larger id first, then
|
|
9
|
+
// strictly later instants. `where` already bounds deletedAt by the cutoff; the later-instants
|
|
10
|
+
// query repeats that bound next to the cursor.
|
|
11
|
+
async function selectNextPage(args) {
|
|
12
|
+
const { db, table, where, expiredBefore, cursor, pageSize } = args;
|
|
13
|
+
const options = { orderBy: PAGE_ORDER, limit: pageSize };
|
|
14
|
+
if (cursor === undefined)
|
|
15
|
+
return selectMany(db, table, where, options);
|
|
16
|
+
const sameInstant = await selectMany(db, table, { ...where, deletedAt: cursor.deletedAt, id: { gt: cursor.id } }, options);
|
|
17
|
+
if (sameInstant.length >= pageSize)
|
|
18
|
+
return sameInstant;
|
|
19
|
+
const later = await selectMany(db, table, { ...where, deletedAt: { gt: cursor.deletedAt, lt: expiredBefore } }, { ...options, limit: pageSize - sameInstant.length });
|
|
20
|
+
return [...sameInstant, ...later];
|
|
21
|
+
}
|
|
4
22
|
// Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
|
|
5
23
|
// rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
|
|
6
|
-
// row whose bytes could not be removed stays and the next run retries it.
|
|
24
|
+
// row whose bytes could not be removed stays and the next run retries it. A run walks the
|
|
25
|
+
// expired rows page by page along (deletedAt, id), so rows that keep failing cannot hide the
|
|
26
|
+
// rows behind them; the run ends on the first empty page.
|
|
7
27
|
export async function purgeExpiredFileRefsWithBytes(args) {
|
|
8
|
-
const { db, table, where, files, log } = args;
|
|
28
|
+
const { db, table, where, expiredBefore, files, log } = args;
|
|
29
|
+
const pageSize = args.pageSize ?? EXPIRED_FILE_REF_PURGE_BATCH;
|
|
9
30
|
if (!files) {
|
|
10
31
|
log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
|
|
11
32
|
return;
|
|
12
33
|
}
|
|
13
|
-
const rows = await selectMany(db, table, where, {
|
|
14
|
-
orderBy: [{ col: "deletedAt", direction: "asc" }],
|
|
15
|
-
limit: EXPIRED_FILE_REF_PURGE_BATCH,
|
|
16
|
-
});
|
|
17
|
-
const purgedIds = [];
|
|
18
34
|
const store = { list: files.list, delete: (key) => files.ref(key).delete() };
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
let cursor;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const rows = await selectNextPage({ db, table, where, expiredBefore, cursor, pageSize });
|
|
38
|
+
const lastRow = rows[rows.length - 1];
|
|
39
|
+
// skip: an empty page means every expired row has been visited
|
|
40
|
+
if (lastRow === undefined)
|
|
41
|
+
return;
|
|
42
|
+
cursor = lastRow;
|
|
43
|
+
const purgedIds = [];
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
try {
|
|
46
|
+
const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
|
|
47
|
+
if (failedKeys.length === 0) {
|
|
48
|
+
purgedIds.push(row.id);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
25
55
|
}
|
|
26
|
-
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
|
|
27
|
-
}
|
|
28
|
-
catch (err) {
|
|
29
|
-
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
30
56
|
}
|
|
57
|
+
if (purgedIds.length > 0)
|
|
58
|
+
await deleteMany(db, table, { ...where, id: { in: purgedIds } });
|
|
31
59
|
}
|
|
32
|
-
if (purgedIds.length > 0)
|
|
33
|
-
await deleteMany(db, table, { ...where, id: { in: purgedIds } });
|
|
34
60
|
}
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
1
|
+
export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
2
2
|
export { createJobRunner, serializeJobSubject } from "./job-runner.js";
|
|
@@ -29,6 +29,9 @@ export type JobQueuedMeta = {
|
|
|
29
29
|
readonly tenantId: string;
|
|
30
30
|
readonly subject: string | null;
|
|
31
31
|
};
|
|
32
|
+
export type JobDroppedMeta = {
|
|
33
|
+
readonly tenantId: string;
|
|
34
|
+
};
|
|
32
35
|
export type JobOutcomeMeta = {
|
|
33
36
|
readonly tenantId: string;
|
|
34
37
|
readonly finalAttempt: boolean;
|
|
@@ -66,6 +69,6 @@ export type JobRunnerOptions = {
|
|
|
66
69
|
onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
|
|
67
70
|
onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
|
|
68
71
|
onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
|
|
69
|
-
onJobDropped?: (jobName: string, jobId: string) => void;
|
|
72
|
+
onJobDropped?: (jobName: string, jobId: string, meta: JobDroppedMeta) => void;
|
|
70
73
|
};
|
|
71
74
|
export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -611,12 +611,16 @@ export function createJobRunner(options) {
|
|
|
611
611
|
});
|
|
612
612
|
}
|
|
613
613
|
}
|
|
614
|
-
async function announceDropped(jobName, jobDef, bullJobId) {
|
|
614
|
+
async function announceDropped(jobName, jobDef, bullJobId, data) {
|
|
615
615
|
// skip: job did not opt in or no logger wired
|
|
616
616
|
if (!jobDef.tenantVisibleRun || !options.onJobDropped)
|
|
617
617
|
return;
|
|
618
|
+
const tenantId = tenantIdOfJobData(data);
|
|
619
|
+
// skip: tenant-less run — announceQueued never recorded a row for it
|
|
620
|
+
if (tenantId === SYSTEM_TENANT_ID)
|
|
621
|
+
return;
|
|
618
622
|
try {
|
|
619
|
-
await options.onJobDropped(jobName, bullJobId);
|
|
623
|
+
await options.onJobDropped(jobName, bullJobId, { tenantId });
|
|
620
624
|
}
|
|
621
625
|
catch (err) {
|
|
622
626
|
errorLogger.warn("tenant-visible queued state not cleared", {
|
|
@@ -732,7 +736,7 @@ export function createJobRunner(options) {
|
|
|
732
736
|
...buildRetryBullOpts(jobDef, priorAttempts),
|
|
733
737
|
});
|
|
734
738
|
if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
|
|
735
|
-
await announceDropped(jobName, jobDef, bullJob.id);
|
|
739
|
+
await announceDropped(jobName, jobDef, bullJob.id, bullJob.data);
|
|
736
740
|
}
|
|
737
741
|
await announceQueued(jobName, jobDef, requeued.id, requeuedData);
|
|
738
742
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
@@ -1298,7 +1302,7 @@ export function createJobRunner(options) {
|
|
|
1298
1302
|
for (const j of waiting) {
|
|
1299
1303
|
if (j.name === jobName && j.id) {
|
|
1300
1304
|
await j.remove();
|
|
1301
|
-
await announceDropped(jobName, jobDef, j.id);
|
|
1305
|
+
await announceDropped(jobName, jobDef, j.id, j.data);
|
|
1302
1306
|
}
|
|
1303
1307
|
}
|
|
1304
1308
|
break;
|
|
@@ -11,7 +11,7 @@ export declare function createEscapeHatchReportWindow(opts?: {
|
|
|
11
11
|
}): EscapeHatchReportWindow;
|
|
12
12
|
export type EscapeHatchProcessDedup = {
|
|
13
13
|
readonly kinds: ReadonlySet<EscapeHatchKind>;
|
|
14
|
-
readonly
|
|
14
|
+
readonly lastAuditedAt: Map<string, number>;
|
|
15
15
|
};
|
|
16
16
|
export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
|
|
17
17
|
export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
|
|
@@ -27,8 +27,11 @@ export function createEscapeHatchReportWindow(opts) {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
|
+
// A long-lived worker must leave an audit event inside every retention window
|
|
31
|
+
// (90 days), or its crons vanish from the log once the first event is pruned.
|
|
32
|
+
const PROCESS_REAUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
30
33
|
export function createEscapeHatchProcessDedup(kinds) {
|
|
31
|
-
return { kinds: new Set(kinds),
|
|
34
|
+
return { kinds: new Set(kinds), lastAuditedAt: new Map() };
|
|
32
35
|
}
|
|
33
36
|
// Default window for createEscapeHatchReporter callers that don't pass their own.
|
|
34
37
|
const fallbackReportWindow = createEscapeHatchReportWindow();
|
|
@@ -105,11 +108,20 @@ export function createEscapeHatchReporter(opts) {
|
|
|
105
108
|
emitEscapeHatchUse(opts.meter, opts.handler, kind);
|
|
106
109
|
const { processDedup } = opts;
|
|
107
110
|
if (processDedup?.kinds.has(kind)) {
|
|
108
|
-
const processKey = JSON.stringify([
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
const processKey = JSON.stringify([
|
|
112
|
+
opts.handler,
|
|
113
|
+
kind,
|
|
114
|
+
opts.tenantId,
|
|
115
|
+
target?.id ?? null,
|
|
116
|
+
target?.tenantId ?? null,
|
|
117
|
+
]);
|
|
118
|
+
const nowMs = now();
|
|
119
|
+
const lastAuditedAt = processDedup.lastAuditedAt.get(processKey);
|
|
120
|
+
// skip: audited by this process within the re-audit interval
|
|
121
|
+
if (lastAuditedAt !== undefined && nowMs - lastAuditedAt < PROCESS_REAUDIT_INTERVAL_MS) {
|
|
111
122
|
return;
|
|
112
|
-
|
|
123
|
+
}
|
|
124
|
+
processDedup.lastAuditedAt.set(processKey, nowMs);
|
|
113
125
|
reportEscapeHatchUse(eventFor(kind, reason, target), deps);
|
|
114
126
|
// skip: audited above, the window dedup below only serves the other kinds
|
|
115
127
|
return;
|
|
@@ -52,10 +52,13 @@ export function createCascadeDeleteHook(registry, tables) {
|
|
|
52
52
|
// block the parent delete here instead of changing with it. The error names
|
|
53
53
|
// the parent only: the caller may delete the parent without reading the child.
|
|
54
54
|
if (strategy === OnDeleteStrategies.cascade || strategy === OnDeleteStrategies.setNull) {
|
|
55
|
-
const
|
|
56
|
-
|
|
55
|
+
const childEntity = registry.getEntity(relation.target);
|
|
56
|
+
const childLock = childEntity?.lockedWhen;
|
|
57
|
+
if (childLock !== undefined && payload.lockGuardBypassed !== true) {
|
|
58
|
+
// A soft-deleted child is invisible to the caller and must not block.
|
|
57
59
|
const children = await selectMany(db, targetTable, {
|
|
58
60
|
[relation.foreignKey]: payload.id,
|
|
61
|
+
...(childEntity?.softDelete === true && { isDeleted: false }),
|
|
59
62
|
});
|
|
60
63
|
if (children.some((child) => isRecordLocked(childLock, child))) {
|
|
61
64
|
throw new RecordLockedError({ entityName, entityId: payload.id });
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { requestContext, runWithWriteOrigin } from "../api/request-context.js";
|
|
2
2
|
import { transaction } from "../db/query.js";
|
|
3
|
-
import { InternalError, toWriteErrorInfo, writeFailure } from "../errors/index.js";
|
|
3
|
+
import { InternalError, toWriteErrorInfo, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
4
4
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
5
5
|
import { parseJsonSafe } from "../utils/safe-json.js";
|
|
6
6
|
import { resolveDbSource } from "./dispatch-shared.js";
|
|
7
|
-
import { executeNestedWrite } from "./dispatch-write.js";
|
|
7
|
+
import { executeNestedWrite, executeWrite } from "./dispatch-write.js";
|
|
8
8
|
import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
9
9
|
import { reserveBeforeTransaction, runAsCommand, } from "./pre-transaction-reservations.js";
|
|
10
10
|
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
|
|
@@ -22,15 +22,44 @@ function toReservedScope(reservations, index, cmd) {
|
|
|
22
22
|
const reservation = reservations.reservedCommands.get(index);
|
|
23
23
|
return reservation && { type: cmd.type, reservation };
|
|
24
24
|
}
|
|
25
|
+
function isPerCommandHandler(ctx, type) {
|
|
26
|
+
return ctx.registry.getWriteHandler(type)?.transaction === "perCommand";
|
|
27
|
+
}
|
|
25
28
|
async function runReservedCommand(scope, cmd, index, tx) {
|
|
26
29
|
const { ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks } = scope;
|
|
27
30
|
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
|
|
28
31
|
origins.push(origin);
|
|
29
32
|
const hookStart = afterCommitHooks.length;
|
|
30
|
-
|
|
33
|
+
// Nested-write children would run inline with the in-transaction scope, so a perCommand command skips the expansion.
|
|
34
|
+
const execute = isPerCommandHandler(ctx, cmd.type) ? executeWrite : executeNestedWrite;
|
|
35
|
+
const res = await runAsCommand(toReservedScope(reservations, index, cmd), () => runWithWriteOrigin(origin, () => execute(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
|
|
31
36
|
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
32
37
|
return res;
|
|
33
38
|
}
|
|
39
|
+
// Sequential fallback without an outer transaction. A failure result and a throw both end the
|
|
40
|
+
// batch, so both give the reserved capacity back.
|
|
41
|
+
async function runCommandsWithoutTransaction(scope, commands, results) {
|
|
42
|
+
try {
|
|
43
|
+
for (let i = 0; i < commands.length; i++) {
|
|
44
|
+
const cmd = commands[i];
|
|
45
|
+
if (!cmd)
|
|
46
|
+
continue;
|
|
47
|
+
const res = await runReservedCommand(scope, cmd, i, undefined);
|
|
48
|
+
results.push(res);
|
|
49
|
+
if (!res.isSuccess) {
|
|
50
|
+
// No tx means no rollback — but we still drop afterCommit hooks,
|
|
51
|
+
// matching the semantic "failure = side-effects don't fire".
|
|
52
|
+
await scope.reservations.releaseAll();
|
|
53
|
+
return { index: i, error: res.error };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
await scope.reservations.releaseAll();
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
34
63
|
// Core batch logic extracted so write() and command() can reuse it
|
|
35
64
|
// (a single write = batch of one, running in its own transaction).
|
|
36
65
|
export async function runBatch(ctx, commands, user, requestId, inheritedOrigin) {
|
|
@@ -43,6 +72,30 @@ export async function runBatch(ctx, commands, user, requestId, inheritedOrigin)
|
|
|
43
72
|
const { signal: _signal, ...withoutSignal } = current;
|
|
44
73
|
return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId, inheritedOrigin));
|
|
45
74
|
}
|
|
75
|
+
function perCommandBatchRejection(ctx, commands) {
|
|
76
|
+
const index = commands.findIndex((cmd) => isPerCommandHandler(ctx, cmd.type));
|
|
77
|
+
const command = commands[index];
|
|
78
|
+
if (!command || commands.length < 2)
|
|
79
|
+
return undefined;
|
|
80
|
+
return {
|
|
81
|
+
isSuccess: false,
|
|
82
|
+
error: toWriteErrorInfo(new UnprocessableError("per_command_handler_in_batch", {
|
|
83
|
+
message: `handler "${command.type}" runs without a transaction (transaction: "perCommand") and cannot be part of a multi-command batch`,
|
|
84
|
+
})),
|
|
85
|
+
failedIndex: index,
|
|
86
|
+
results: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function checkBatchIdempotency(ctx, user, requestId) {
|
|
90
|
+
if (!requestId || !ctx.idempotency)
|
|
91
|
+
return {};
|
|
92
|
+
const checked = await ctx.idempotency.check(user.tenantId, user.id, requestId);
|
|
93
|
+
if (checked.status !== "cached")
|
|
94
|
+
return { token: checked.token };
|
|
95
|
+
const parsed = parseJsonSafe(checked.result, null);
|
|
96
|
+
// corrupted cache entry — treat as miss, let the request re-run
|
|
97
|
+
return parsed ? { cached: parsed } : {};
|
|
98
|
+
}
|
|
46
99
|
async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
47
100
|
const { idempotency, lifecycle, appContext: context } = ctx;
|
|
48
101
|
if (commands.length === 0) {
|
|
@@ -53,19 +106,10 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
53
106
|
// idempotencyToken is only set when we actually acquired the lock — the
|
|
54
107
|
// corrupted-cache fallthrough below leaves it unset, so finalize() skips
|
|
55
108
|
// store() rather than writing over an entry it never owned.
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const parsed = parseJsonSafe(checked.result, null);
|
|
61
|
-
if (parsed)
|
|
62
|
-
return parsed;
|
|
63
|
-
// corrupted cache entry — treat as miss, let the request re-run
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
idempotencyToken = checked.token;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
109
|
+
const idempotencyCheck = await checkBatchIdempotency(ctx, user, requestId);
|
|
110
|
+
if (idempotencyCheck.cached)
|
|
111
|
+
return idempotencyCheck.cached;
|
|
112
|
+
const idempotencyToken = idempotencyCheck.token;
|
|
69
113
|
// Hooks already ran on the plaintext results; what is returned and cached
|
|
70
114
|
// for retries must not carry writeOnly values.
|
|
71
115
|
const maskForClient = (result) => maskBatchResultForClient(ctx.registry, commands, result);
|
|
@@ -148,6 +192,10 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
148
192
|
return;
|
|
149
193
|
await runWithWriteOrigin(strictest, flushBatchHooksInner);
|
|
150
194
|
};
|
|
195
|
+
const perCommandRejection = perCommandBatchRejection(ctx, commands);
|
|
196
|
+
if (perCommandRejection)
|
|
197
|
+
return releaseOrFinalize(perCommandRejection, false);
|
|
198
|
+
const runsWithoutTransaction = commands.some((cmd) => isPerCommandHandler(ctx, cmd.type));
|
|
151
199
|
// Reserve before the transaction opens so no extra connection is held while the handler tx is
|
|
152
200
|
// open; the release below runs only after that tx has ended without committing.
|
|
153
201
|
const reservations = await reserveBeforeTransaction(ctx, commands, user, inheritedOrigin);
|
|
@@ -162,25 +210,24 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
162
210
|
// batch() opens its own outer transaction — needs the top-level
|
|
163
211
|
// connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
|
|
164
212
|
const db = resolveDbSource(ctx, undefined);
|
|
165
|
-
if (!db) {
|
|
166
|
-
// Without a DB connection
|
|
213
|
+
if (!db || runsWithoutTransaction) {
|
|
214
|
+
// Without a DB connection (or for a perCommand handler, whose writes open their own transactions)
|
|
215
|
+
// there is no transaction to open. Fall back to
|
|
167
216
|
// sequential execution — useful for unit tests that don't touch the DB.
|
|
168
217
|
// Each command runs independently; a failure stops the batch.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// matching the semantic "failure = side-effects don't fire".
|
|
178
|
-
await reservations.releaseAll();
|
|
179
|
-
return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
|
|
180
|
-
}
|
|
218
|
+
const failed = await runCommandsWithoutTransaction({ ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks }, commands, results);
|
|
219
|
+
if (failed) {
|
|
220
|
+
return finalize({
|
|
221
|
+
isSuccess: false,
|
|
222
|
+
error: failed.error,
|
|
223
|
+
failedIndex: failed.index,
|
|
224
|
+
results,
|
|
225
|
+
});
|
|
181
226
|
}
|
|
182
227
|
await flushAfterCommit();
|
|
183
|
-
|
|
228
|
+
// The inner commands of a perCommand handler already fired their own batch hooks.
|
|
229
|
+
if (!runsWithoutTransaction)
|
|
230
|
+
await flushBatchHooks();
|
|
184
231
|
return finalize({ isSuccess: true, results });
|
|
185
232
|
}
|
|
186
233
|
let transactionCallbackCompleted = false;
|
|
@@ -201,6 +248,7 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
201
248
|
}
|
|
202
249
|
catch (e) {
|
|
203
250
|
// Rollback or failed COMMIT: the transaction is over, so the reserved capacity goes back.
|
|
251
|
+
// The release is outcome-safe: it only gives back a reservation row the handler tx did not delete.
|
|
204
252
|
await reservations.releaseAll();
|
|
205
253
|
if (e instanceof BatchRollback) {
|
|
206
254
|
// Thrown inside the callback, so the tx rolled back. A 4xx is
|
|
@@ -6,7 +6,7 @@ import type { defineTransitions } from "../engine/state-machine.js";
|
|
|
6
6
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension.js";
|
|
7
7
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
8
8
|
import type { AppContext, HandlerContext, JobRunnerRef, PayloadRateLimitOption, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
9
|
-
import { type WriteErrorInfo } from "../errors/index.js";
|
|
9
|
+
import { InternalError, type WriteErrorInfo } from "../errors/index.js";
|
|
10
10
|
import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
11
11
|
import { type AfterCommitHook } from "./dispatcher-utils.js";
|
|
12
12
|
import type { IdempotencyGuard } from "./idempotency.js";
|
|
@@ -53,7 +53,12 @@ export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
|
|
|
53
53
|
export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
|
|
54
54
|
export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
|
|
55
55
|
export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
56
|
-
export declare function
|
|
56
|
+
export declare function perCommandNestedDenied(targetType: string): InternalError;
|
|
57
|
+
export declare function denyTransactionBoundSurfaces(handlerContext: HandlerContext): HandlerContext;
|
|
58
|
+
export declare function applyBeforeTransactionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
59
|
+
export type HandlerTransactionScope = "inTransaction" | "withoutTransaction" | "postCommit";
|
|
60
|
+
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope): Promise<HandlerContext>;
|
|
61
|
+
export declare function createHandlerBoundDigest(digest: (value: string) => string, handlerName: string): (value: string) => string;
|
|
57
62
|
export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
|
|
58
63
|
export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
|
|
59
64
|
export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;
|
|
@@ -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";
|
|
@@ -112,10 +112,10 @@ async function denyMemberResolutionWrite() {
|
|
|
112
112
|
}
|
|
113
113
|
// JobRunnerRef's declared type only carries handleEvent, but callers
|
|
114
114
|
// routinely bracket-access a fuller runner — a Proxy denies ANY property read.
|
|
115
|
-
function denyingJobRunnerProxy() {
|
|
115
|
+
function denyingJobRunnerProxy(denied = memberResolutionReadOnlyDenied) {
|
|
116
116
|
return new Proxy({}, {
|
|
117
117
|
get() {
|
|
118
|
-
throw
|
|
118
|
+
throw denied();
|
|
119
119
|
},
|
|
120
120
|
});
|
|
121
121
|
}
|
|
@@ -205,7 +205,62 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
205
205
|
}),
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
|
-
|
|
208
|
+
function beforeTransactionDenied(surface) {
|
|
209
|
+
return new InternalError({
|
|
210
|
+
message: `ctx.${surface} is not available before the transaction; move it into the handler`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function withoutTransactionDenied(surface) {
|
|
214
|
+
return new InternalError({
|
|
215
|
+
message: `ctx.${surface} is not available without an open transaction; run the write through ctx.write so it gets its own transaction`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
export function perCommandNestedDenied(targetType) {
|
|
219
|
+
return new InternalError({
|
|
220
|
+
message: `handler "${targetType}" runs without a transaction (transaction: "perCommand") and cannot run as a nested write inside an open transaction`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function denySurface(buildError, surface) {
|
|
224
|
+
return () => {
|
|
225
|
+
throw buildError(surface);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function denyEventStoreSurfaces(handlerContext, buildError) {
|
|
229
|
+
return {
|
|
230
|
+
...handlerContext,
|
|
231
|
+
appendEvent: denySurface(buildError, "appendEvent"),
|
|
232
|
+
unsafeAppendEvent: denySurface(buildError, "unsafeAppendEvent"),
|
|
233
|
+
tryAppendEvent: denySurface(buildError, "tryAppendEvent"),
|
|
234
|
+
fetchForWriting: denySurface(buildError, "fetchForWriting"),
|
|
235
|
+
archiveStream: denySurface(buildError, "archiveStream"),
|
|
236
|
+
restoreStream: denySurface(buildError, "restoreStream"),
|
|
237
|
+
snapshotAggregate: denySurface(buildError, "snapshotAggregate"),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
// Reservations write on purpose through the outside-transaction handles, so they keep those but
|
|
241
|
+
// lose every surface bound to the not-yet-open transaction.
|
|
242
|
+
export function denyTransactionBoundSurfaces(handlerContext) {
|
|
243
|
+
return {
|
|
244
|
+
...denyEventStoreSurfaces(handlerContext, beforeTransactionDenied),
|
|
245
|
+
write: denySurface(beforeTransactionDenied, "write"),
|
|
246
|
+
writeAs: denySurface(beforeTransactionDenied, "writeAs"),
|
|
247
|
+
scheduleAfterCommit: denySurface(beforeTransactionDenied, "scheduleAfterCommit"),
|
|
248
|
+
...(handlerContext.notify && { notify: denySurface(beforeTransactionDenied, "notify") }),
|
|
249
|
+
...(handlerContext.jobRunner && {
|
|
250
|
+
jobRunner: denyingJobRunnerProxy(() => beforeTransactionDenied("jobRunner")),
|
|
251
|
+
}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// The beforeTransaction phase has no write need at all, so it also loses the outside-transaction handles.
|
|
255
|
+
export function applyBeforeTransactionReadOnly(handlerContext) {
|
|
256
|
+
return {
|
|
257
|
+
...denyTransactionBoundSurfaces(handlerContext),
|
|
258
|
+
dbOutsideTransaction: undefined,
|
|
259
|
+
outsideTransaction: undefined,
|
|
260
|
+
forTenant: undefined,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction") {
|
|
209
264
|
const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
|
|
210
265
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
211
266
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
@@ -214,7 +269,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
214
269
|
const dbSource = resolveDbSource(ctx, tx);
|
|
215
270
|
const reqCtx = requestContext.get();
|
|
216
271
|
// global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write, query or stream escapeHatch.
|
|
217
|
-
const
|
|
272
|
+
const writeHandler = registry.getWriteHandler(type);
|
|
273
|
+
const writeEscapeHatch = writeHandler?.escapeHatch;
|
|
274
|
+
const isPerCommand = writeHandler?.transaction === "perCommand";
|
|
275
|
+
const denyDbWrites = transactionScope === "withoutTransaction";
|
|
218
276
|
const handlerEscapeHatch = writeEscapeHatch ??
|
|
219
277
|
registry.getQueryHandler(type)?.escapeHatch ??
|
|
220
278
|
registry.getStreamHandler(type)?.escapeHatch;
|
|
@@ -231,7 +289,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
231
289
|
});
|
|
232
290
|
const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
|
|
233
291
|
(isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
|
|
234
|
-
const buildTenantScopedDb = (source, signal, foreignTenantId) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
292
|
+
const buildTenantScopedDb = (source, signal, foreignTenantId, denyWrites = false) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
235
293
|
...(foreignTenantId === undefined && {
|
|
236
294
|
globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
|
|
237
295
|
unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
|
|
@@ -240,11 +298,14 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
240
298
|
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
241
299
|
personalDataGate: buildPersonalDataGate(registry, origin),
|
|
242
300
|
projectionRegistry: registry,
|
|
301
|
+
...(denyWrites && { writesDeniedReason: "without an open transaction" }),
|
|
243
302
|
});
|
|
244
303
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
245
304
|
// the client has disconnected — handlers with many sequential queries skip
|
|
246
305
|
// the rest of the chain instead of burning DB-CPU for results no one reads.
|
|
247
|
-
const db = dbSource
|
|
306
|
+
const db = dbSource
|
|
307
|
+
? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, denyDbWrites)
|
|
308
|
+
: undefined;
|
|
248
309
|
// Unbound pool, tenant-scoped like `db` but never tx-bound — writes
|
|
249
310
|
// through it survive a rollback of the handler's own transaction. No
|
|
250
311
|
// AbortSignal here: a client disconnect must not abort a durability write
|
|
@@ -286,7 +347,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
286
347
|
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
287
348
|
const bridgeSink = afterCommitHooks ?? [];
|
|
288
349
|
// Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
|
|
289
|
-
const hasLiveCommitSink = afterCommitHooks !== undefined &&
|
|
350
|
+
const hasLiveCommitSink = afterCommitHooks !== undefined && transactionScope !== "postCommit";
|
|
290
351
|
const scheduleAfterCommit = (hook) => {
|
|
291
352
|
bridgeSink.push(hook);
|
|
292
353
|
};
|
|
@@ -354,7 +415,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
354
415
|
const forTenant = !isSystem && dbSource && registry.getWriteHandler(type) !== undefined
|
|
355
416
|
? (targetTenantId) => {
|
|
356
417
|
if (targetTenantId === user.tenantId && exposedDb) {
|
|
357
|
-
|
|
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 };
|
|
358
423
|
}
|
|
359
424
|
if (!mayOverrideTenant(user)) {
|
|
360
425
|
throw new AccessDeniedError({
|
|
@@ -362,7 +427,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
362
427
|
});
|
|
363
428
|
}
|
|
364
429
|
return {
|
|
365
|
-
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId),
|
|
430
|
+
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && isPerCommand),
|
|
366
431
|
streamTenantId: targetTenantId,
|
|
367
432
|
};
|
|
368
433
|
}
|
|
@@ -400,6 +465,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
400
465
|
// failure result. The savepoint makes the failure undo exactly the inner write; the
|
|
401
466
|
// inner afterCommit hooks only reach the outer sink if it succeeded.
|
|
402
467
|
const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
|
|
468
|
+
if (registry.getWriteHandler(targetType)?.transaction === "perCommand") {
|
|
469
|
+
return writeFailure(perCommandNestedDenied(targetType));
|
|
470
|
+
}
|
|
403
471
|
if (!tx)
|
|
404
472
|
return executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
|
|
405
473
|
const innerAfterCommitHooks = [];
|
|
@@ -420,13 +488,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
420
488
|
throw e;
|
|
421
489
|
}
|
|
422
490
|
};
|
|
423
|
-
//
|
|
424
|
-
// auto-commit event append, projections and inTransaction hooks separately
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
const writeAsUser = async (asUser, targetType, payload) =>
|
|
428
|
-
?
|
|
429
|
-
:
|
|
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));
|
|
430
498
|
const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
|
|
431
499
|
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
|
|
432
500
|
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
@@ -761,11 +829,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
761
829
|
const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
762
830
|
// The payload-bucket digest is dispatcher-internal; handlers must not be
|
|
763
831
|
// able to compute bucket keys.
|
|
764
|
-
const { _rateLimitPayloadDigest:
|
|
832
|
+
const { _rateLimitPayloadDigest: payloadDigest,
|
|
765
833
|
// Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
|
|
766
834
|
_notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
|
|
767
835
|
const handlerContext = {
|
|
768
836
|
...contextForHandler,
|
|
837
|
+
...(payloadDigest && { rateLimitDigest: createHandlerBoundDigest(payloadDigest, type) }),
|
|
769
838
|
...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
|
|
770
839
|
registry,
|
|
771
840
|
db: exposedDb,
|
|
@@ -816,10 +885,18 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
816
885
|
...(includeDeleted && { includeDeleted: true }),
|
|
817
886
|
...bridge,
|
|
818
887
|
}; // @cast-boundary engine-bridge
|
|
888
|
+
const scopedContext = transactionScope === "withoutTransaction"
|
|
889
|
+
? denyEventStoreSurfaces(handlerContext, withoutTransactionDenied)
|
|
890
|
+
: handlerContext;
|
|
819
891
|
// A resolved member principal is read-only by construction.
|
|
820
892
|
return isMemberResolutionPrincipal(user)
|
|
821
|
-
? applyMemberResolutionReadOnly(
|
|
822
|
-
:
|
|
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}`);
|
|
823
900
|
}
|
|
824
901
|
// Resolves the agent risk the irreversible-operation gate should attribute to
|
|
825
902
|
// `type` when it becomes the request's entry handler — same defaulting rule
|
|
@@ -1107,7 +1184,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1107
1184
|
message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
|
|
1108
1185
|
});
|
|
1109
1186
|
}
|
|
1110
|
-
for (const { per, limit, windowSeconds, cost } of limits) {
|
|
1187
|
+
for (const { per, limit, windowSeconds, cost, normalize } of limits) {
|
|
1111
1188
|
const value = data[per.payloadField];
|
|
1112
1189
|
// skip: optional field not sent — nothing to bucket on
|
|
1113
1190
|
if (value === undefined)
|
|
@@ -1117,7 +1194,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1117
1194
|
message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
|
|
1118
1195
|
});
|
|
1119
1196
|
}
|
|
1120
|
-
const key = buildPayloadBucketKey(handlerName, per.payloadField, digest(normalizePayloadBucketValue(value)));
|
|
1197
|
+
const key = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
|
|
1121
1198
|
await context.rateLimit.enforce(key, { limit, windowSeconds, cost });
|
|
1122
1199
|
}
|
|
1123
1200
|
}
|