@cosmicdrift/kumiko-framework 0.364.0 → 0.365.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +36 -5
- package/agents/examples/anonymous-access-multitenant.md +9 -6
- package/agents/examples/anonymous-access.md +14 -26
- package/agents/examples/auth-mfa.md +5 -9
- package/agents/examples/custom-fields-basic.md +7 -6
- package/agents/examples/custom-handlers.md +72 -25
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/encrypted-tenant-config.md +17 -8
- package/agents/examples/error-contract.md +1 -5
- package/agents/examples/feature-toggles.md +28 -17
- package/agents/examples/ledger-banking.md +7 -6
- package/agents/examples/ledger-invoicing.md +7 -6
- package/agents/examples/legal-pages.md +2 -2
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +7 -6
- package/agents/examples/ownership.md +30 -22
- package/agents/examples/pdf-audit-export.md +87 -51
- package/agents/examples/session-revocation.md +1 -10
- package/agents/examples/tags-basic.md +7 -7
- package/agents/examples/user-data-rights.md +25 -17
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +3 -3
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +44 -43
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/server.d.ts +2 -2
- package/dist/api/server.js +21 -4
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +7 -0
- package/dist/bun-db/query.js +50 -18
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +2 -1
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +1 -0
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +70 -47
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +56 -31
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +20 -1
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/files/create-file-ref.d.ts +2 -1
- package/dist/files/create-file-ref.js +16 -2
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.js +4 -0
- package/dist/files/file-routes.js +9 -5
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/purge-expired-file-ref-bytes.js +2 -3
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +116 -33
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +31 -2
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +1 -1
- package/dist/pipeline/dispatch-shared.js +41 -30
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +2 -1
- package/dist/schema-cli.js +12 -1
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +4 -4
- package/src/changes.json +305 -0
package/dist/jobs/job-runner.js
CHANGED
|
@@ -12,7 +12,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
12
12
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
13
13
|
import { createFileContext } from "../files/file-handle.js";
|
|
14
14
|
import { createFallbackLogger } from "../logging/index.js";
|
|
15
|
-
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
15
|
+
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
16
16
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
17
17
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
18
18
|
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
@@ -82,6 +82,18 @@ export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
|
|
|
82
82
|
function schedulerSeenKey(queue) {
|
|
83
83
|
return queue.toKey("kumiko-scheduler-seen");
|
|
84
84
|
}
|
|
85
|
+
// "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
|
|
86
|
+
// their own ids periodically; a single stamp at start() would let a runner
|
|
87
|
+
// restarting during a mixed-version window longer than the grace period delete
|
|
88
|
+
// a scheduler a healthy newer pod still owns.
|
|
89
|
+
async function markSchedulersSeen(queue, schedulerIds) {
|
|
90
|
+
// skip: no cron scheduler on this lane, nothing to stamp
|
|
91
|
+
if (schedulerIds.size === 0)
|
|
92
|
+
return;
|
|
93
|
+
const stamp = String(Date.now());
|
|
94
|
+
const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
|
|
95
|
+
await (await queue.client).hset(schedulerSeenKey(queue), entries);
|
|
96
|
+
}
|
|
85
97
|
// A cron job dropped from the app (or an older scheduler-id format) leaves its
|
|
86
98
|
// scheduler in Redis, where it keeps firing jobs the worker no longer knows
|
|
87
99
|
// ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
|
|
@@ -364,6 +376,7 @@ export function createJobRunner(options) {
|
|
|
364
376
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
365
377
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
366
378
|
const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
|
|
379
|
+
const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
|
|
367
380
|
const redisOpts = parseRedisOpts(redisUrl);
|
|
368
381
|
// Use the context's tracer when present (observability-provider injected at
|
|
369
382
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
@@ -524,6 +537,11 @@ export function createJobRunner(options) {
|
|
|
524
537
|
}
|
|
525
538
|
let worker = null;
|
|
526
539
|
let queueDepthTimer = null;
|
|
540
|
+
let schedulerSeenTimer = null;
|
|
541
|
+
let schedulerStampPending = false;
|
|
542
|
+
// A timed-out gate is abandoned, not aborted (the job context carries no
|
|
543
|
+
// AbortSignal); stop() waits for it so it cannot write after the shutdown.
|
|
544
|
+
const abandonedBootGates = new Set();
|
|
527
545
|
// Forward reference to the runner's own API, exposed on the job-handler ctx
|
|
528
546
|
// so a handler can dispatch a follow-up job (job→job chaining, e.g.
|
|
529
547
|
// delivery.render → delivery.send). Assigned just before return; reads happen
|
|
@@ -943,6 +961,7 @@ export function createJobRunner(options) {
|
|
|
943
961
|
attempt,
|
|
944
962
|
finalAttempt,
|
|
945
963
|
log: createJobLogger(logs),
|
|
964
|
+
metricsFor: createMetricsForFactory(context.meter),
|
|
946
965
|
...(triggerName !== undefined && { triggerName }),
|
|
947
966
|
...(triggerEvent !== undefined && { triggerEvent }),
|
|
948
967
|
write: (qn, payload) => {
|
|
@@ -1078,17 +1097,18 @@ export function createJobRunner(options) {
|
|
|
1078
1097
|
});
|
|
1079
1098
|
}, BOOT_GATE_SLOW_WARN_MS)
|
|
1080
1099
|
: undefined;
|
|
1100
|
+
const gateRun = handleJob({
|
|
1101
|
+
id: bootJobIdForJobName(name),
|
|
1102
|
+
name,
|
|
1103
|
+
data: {},
|
|
1104
|
+
attemptsMade: 0,
|
|
1105
|
+
retryable: false,
|
|
1106
|
+
});
|
|
1107
|
+
const gateSettled = gateRun.then(() => undefined, () => undefined);
|
|
1108
|
+
abandonedBootGates.add(gateSettled);
|
|
1109
|
+
void gateSettled.then(() => abandonedBootGates.delete(gateSettled));
|
|
1081
1110
|
try {
|
|
1082
|
-
await Promise.race([
|
|
1083
|
-
handleJob({
|
|
1084
|
-
id: bootJobIdForJobName(name),
|
|
1085
|
-
name,
|
|
1086
|
-
data: {},
|
|
1087
|
-
attemptsMade: 0,
|
|
1088
|
-
retryable: false,
|
|
1089
|
-
}),
|
|
1090
|
-
...(gateTimeout ? [gateTimeout.promise] : []),
|
|
1091
|
-
]);
|
|
1111
|
+
await Promise.race([gateRun, ...(gateTimeout ? [gateTimeout.promise] : [])]);
|
|
1092
1112
|
}
|
|
1093
1113
|
catch (err) {
|
|
1094
1114
|
// Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
|
|
@@ -1104,6 +1124,74 @@ export function createJobRunner(options) {
|
|
|
1104
1124
|
}
|
|
1105
1125
|
}
|
|
1106
1126
|
}
|
|
1127
|
+
function cronSchedulerIdsForLane(lane) {
|
|
1128
|
+
const ids = new Set();
|
|
1129
|
+
for (const [name, jobDef] of allJobs) {
|
|
1130
|
+
if (laneForJob(jobDef) === lane && "cron" in jobDef.trigger) {
|
|
1131
|
+
ids.add(schedulerIdForJobName(name));
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return ids;
|
|
1135
|
+
}
|
|
1136
|
+
async function upsertCronSchedulers(queue, lane) {
|
|
1137
|
+
for (const [name, jobDef] of allJobs) {
|
|
1138
|
+
if (laneForJob(jobDef) !== lane)
|
|
1139
|
+
continue;
|
|
1140
|
+
if ("cron" in jobDef.trigger) {
|
|
1141
|
+
await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
|
|
1142
|
+
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
1143
|
+
data: {},
|
|
1144
|
+
// Queue-level defaultJobOptions (jobRetentionOpts) covers
|
|
1145
|
+
// retention; a count here would sweep queue-wide again and
|
|
1146
|
+
// evict perTenant children and boot jobs.
|
|
1147
|
+
opts: {
|
|
1148
|
+
...buildRetryBullOpts(jobDef),
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// After a failed initial stamp an older runner may have removed our
|
|
1155
|
+
// schedulers as unseen, so the next tick stamps and re-upserts before the
|
|
1156
|
+
// flag clears.
|
|
1157
|
+
async function refreshSchedulerSeen(queue, lane, schedulerIds) {
|
|
1158
|
+
await markSchedulersSeen(queue, schedulerIds);
|
|
1159
|
+
if (schedulerStampPending) {
|
|
1160
|
+
await upsertCronSchedulers(queue, lane);
|
|
1161
|
+
schedulerStampPending = false;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
function startSchedulerSeenRefresh(queue, lane, schedulerIds) {
|
|
1165
|
+
if (schedulerIds.size === 0)
|
|
1166
|
+
return null;
|
|
1167
|
+
const timer = setInterval(() => {
|
|
1168
|
+
refreshSchedulerSeen(queue, lane, schedulerIds).catch((err) => {
|
|
1169
|
+
errorLogger.warn("scheduler seen refresh failed", {
|
|
1170
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
}, schedulerSeenRefreshMs);
|
|
1174
|
+
timer.unref();
|
|
1175
|
+
return timer;
|
|
1176
|
+
}
|
|
1177
|
+
// Bounded: a gate that never returns must not block shutdown as well.
|
|
1178
|
+
async function settleAbandonedBootGates(maxWaitMs) {
|
|
1179
|
+
// skip: no timed-out gate left behind
|
|
1180
|
+
if (abandonedBootGates.size === 0)
|
|
1181
|
+
return;
|
|
1182
|
+
const wait = timeoutReject(maxWaitMs, "job-runner: abandoned boot gate still running at stop()");
|
|
1183
|
+
try {
|
|
1184
|
+
await Promise.race([Promise.all(abandonedBootGates), wait.promise]);
|
|
1185
|
+
}
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
errorLogger.warn("abandoned boot gate did not finish before stop()", {
|
|
1188
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
finally {
|
|
1192
|
+
wait.cancel();
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1107
1195
|
const runnerApi = {
|
|
1108
1196
|
async start() {
|
|
1109
1197
|
// skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
|
|
@@ -1152,28 +1240,18 @@ export function createJobRunner(options) {
|
|
|
1152
1240
|
// Only schedule cron + boot for jobs that belong to this lane. Jobs
|
|
1153
1241
|
// assigned to the other lane get their cron/boot wiring from the
|
|
1154
1242
|
// runner running on that lane. Running both here would double-fire.
|
|
1155
|
-
const expectedSchedulerIds =
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
// evict perTenant children and boot jobs.
|
|
1168
|
-
opts: {
|
|
1169
|
-
...buildRetryBullOpts(jobDef),
|
|
1170
|
-
},
|
|
1171
|
-
});
|
|
1172
|
-
await (await consumerQueue.client).hset(schedulerSeenKey(consumerQueue), {
|
|
1173
|
-
[schedulerId]: String(Date.now()),
|
|
1174
|
-
});
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1243
|
+
const expectedSchedulerIds = cronSchedulerIdsForLane(consumerLane);
|
|
1244
|
+
// Before the upserts: a seen entry without a scheduler is harmless, a
|
|
1245
|
+
// scheduler a concurrent older runner finds without one gets deleted.
|
|
1246
|
+
// Best-effort like the periodic refresh: a failed stamp must not stop the start.
|
|
1247
|
+
await markSchedulersSeen(consumerQueue, expectedSchedulerIds).catch((err) => {
|
|
1248
|
+
schedulerStampPending = true;
|
|
1249
|
+
errorLogger.warn("scheduler seen stamp failed", {
|
|
1250
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
await upsertCronSchedulers(consumerQueue, consumerLane);
|
|
1254
|
+
schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
|
|
1177
1255
|
await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
|
|
1178
1256
|
info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
|
|
1179
1257
|
warn: errorLogger.warn,
|
|
@@ -1221,6 +1299,10 @@ export function createJobRunner(options) {
|
|
|
1221
1299
|
clearInterval(queueDepthTimer);
|
|
1222
1300
|
queueDepthTimer = null;
|
|
1223
1301
|
}
|
|
1302
|
+
if (schedulerSeenTimer) {
|
|
1303
|
+
clearInterval(schedulerSeenTimer);
|
|
1304
|
+
schedulerSeenTimer = null;
|
|
1305
|
+
}
|
|
1224
1306
|
if (worker) {
|
|
1225
1307
|
await worker.close();
|
|
1226
1308
|
worker = null;
|
|
@@ -1230,6 +1312,7 @@ export function createJobRunner(options) {
|
|
|
1230
1312
|
// without listeners -> unhandled rejection (fw#1805). Wait for readiness
|
|
1231
1313
|
// first; a failed/timed-out wait must not block the close itself.
|
|
1232
1314
|
const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
|
|
1315
|
+
await settleAbandonedBootGates(stopReadyTimeoutMs);
|
|
1233
1316
|
const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
|
|
1234
1317
|
try {
|
|
1235
1318
|
await Promise.race([
|
|
@@ -15,6 +15,7 @@ export type EscapeHatchProcessDedup = {
|
|
|
15
15
|
};
|
|
16
16
|
export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
|
|
17
17
|
export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
|
|
18
|
+
export declare function withEscapeHatchAuditScopeStream<T>(run: () => AsyncGenerator<T>): AsyncGenerator<T>;
|
|
18
19
|
export declare function flushEscapeHatchAudits(): Promise<void>;
|
|
19
20
|
export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
|
|
20
21
|
readonly sink?: EscapeHatchAuditSink;
|
|
@@ -40,9 +40,14 @@ const processPendingAudits = new Set();
|
|
|
40
40
|
// The sink writes of the dispatch or job run currently executing.
|
|
41
41
|
const scopePendingAudits = new AsyncLocalStorage();
|
|
42
42
|
function trackPendingAudit(write) {
|
|
43
|
+
const scope = scopePendingAudits.getStore();
|
|
43
44
|
processPendingAudits.add(write);
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
scope?.add(write);
|
|
46
|
+
// Settled writes leave the scope so a long-lived stream does not accumulate them.
|
|
47
|
+
void write.finally(() => {
|
|
48
|
+
processPendingAudits.delete(write);
|
|
49
|
+
scope?.delete(write);
|
|
50
|
+
});
|
|
46
51
|
}
|
|
47
52
|
// Awaits the audit writes started inside `run` once it has returned, so callers place it around
|
|
48
53
|
// the transaction and the write is durable when the dispatch or run ends. Nested scopes belong
|
|
@@ -59,6 +64,30 @@ export async function withEscapeHatchAuditScope(run) {
|
|
|
59
64
|
await Promise.allSettled([...pending]);
|
|
60
65
|
}
|
|
61
66
|
}
|
|
67
|
+
// Stream variant: the scope has to be entered on every pull because an async generator body
|
|
68
|
+
// runs in the caller's async context, and the wait happens once the stream ends or is closed.
|
|
69
|
+
export async function* withEscapeHatchAuditScopeStream(run) {
|
|
70
|
+
if (scopePendingAudits.getStore() !== undefined) {
|
|
71
|
+
yield* run();
|
|
72
|
+
// skip: an enclosing scope already waits for the pending audits
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const pending = new Set();
|
|
76
|
+
const source = run();
|
|
77
|
+
try {
|
|
78
|
+
while (true) {
|
|
79
|
+
const next = await scopePendingAudits.run(pending, () => source.next());
|
|
80
|
+
// skip: the source is exhausted
|
|
81
|
+
if (next.done)
|
|
82
|
+
return;
|
|
83
|
+
yield next.value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await scopePendingAudits.run(pending, () => source.return(undefined));
|
|
88
|
+
await Promise.allSettled([...pending]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
62
91
|
export async function flushEscapeHatchAudits() {
|
|
63
92
|
while (processPendingAudits.size > 0) {
|
|
64
93
|
await Promise.allSettled([...processPendingAudits]);
|
|
@@ -4,7 +4,7 @@ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
|
|
|
4
4
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
5
5
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
6
6
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
7
|
-
export { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
|
|
7
|
+
export { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
|
|
8
8
|
export { type ObservabilityWiring, prometheusMetricsEnvSchema, resolveObservabilityWiring, } from "./metrics-wiring.js";
|
|
9
9
|
export { createNoopProvider } from "./noop-provider.js";
|
|
10
10
|
export { createPrometheusMeter, type PrometheusMeter, type PrometheusMeterSnapshot, serializeOpenMetrics, } from "./prometheus-meter.js";
|
|
@@ -5,7 +5,7 @@ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
|
|
|
5
5
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
6
6
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
7
7
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
8
|
-
export { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
|
|
8
|
+
export { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
|
|
9
9
|
export { prometheusMetricsEnvSchema, resolveObservabilityWiring, } from "./metrics-wiring.js";
|
|
10
10
|
export { createNoopProvider } from "./noop-provider.js";
|
|
11
11
|
export { createPrometheusMeter, serializeOpenMetrics, } from "./prometheus-meter.js";
|
|
@@ -3,3 +3,4 @@ export declare function createMetricsHandle(meter: Meter, featureName: string):
|
|
|
3
3
|
export declare function createSafeMetricsHandle(meter: Meter, featureName: string): MetricsHandle;
|
|
4
4
|
export declare function createUnboundMetricsHandle(meter: Meter): MetricsHandle;
|
|
5
5
|
export declare function createNoopMetricsHandle(): MetricsHandle;
|
|
6
|
+
export declare function createMetricsForFactory(meter: Meter | undefined): (featureName: string) => MetricsHandle;
|
|
@@ -100,3 +100,10 @@ export function createNoopMetricsHandle() {
|
|
|
100
100
|
set(_name, _value, _labels) { },
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
+
// Shared factory behind ctx.metricsFor for handler and job contexts, so both
|
|
104
|
+
// keep the same no-op-on-unregistered contract (framework#1844).
|
|
105
|
+
export function createMetricsForFactory(meter) {
|
|
106
|
+
return meter
|
|
107
|
+
? (featureName) => createSafeMetricsHandle(meter, featureName)
|
|
108
|
+
: () => createNoopMetricsHandle();
|
|
109
|
+
}
|
|
@@ -62,15 +62,15 @@ export const STANDARD_METRIC_DEFS = [
|
|
|
62
62
|
// Prometheus collapses per-instance shards into last-writer-wins and
|
|
63
63
|
// per-instance lag is invisible to alerting.
|
|
64
64
|
{
|
|
65
|
-
name: "
|
|
65
|
+
name: "kumiko_event_consumer_lag_events",
|
|
66
66
|
type: "gauge",
|
|
67
|
-
description: "Number of
|
|
67
|
+
description: "Number of events between the consumer's cursor and the events head.",
|
|
68
68
|
labels: ["consumer", "instance_id"],
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
|
-
name: "
|
|
71
|
+
name: "kumiko_event_consumer_pending_gaps",
|
|
72
72
|
type: "gauge",
|
|
73
|
-
description: "Number of
|
|
73
|
+
description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
|
|
74
74
|
labels: ["consumer", "instance_id"],
|
|
75
75
|
},
|
|
76
76
|
{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DbTx } from "../db/connection.js";
|
|
2
2
|
import type { SessionUser } from "../engine/types/index.js";
|
|
3
3
|
import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
|
|
4
|
-
|
|
4
|
+
import type { AfterCommitHook } from "./dispatcher-utils.js";
|
|
5
|
+
export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx, outerNotifySink?: AfterCommitHook[]): Promise<unknown>;
|
|
@@ -10,10 +10,10 @@ import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.j
|
|
|
10
10
|
// Standalone query execution — used by the public dispatcher.query() and
|
|
11
11
|
// by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
|
|
12
12
|
// field-level read filters for the given user, logs the event.
|
|
13
|
-
export async function executeQuery(ctx, type, payload, user, origin, tx) {
|
|
14
|
-
return runHandlerInstrumented(ctx, type, "query", user, () => executeQueryInner(ctx, type, payload, user, origin, tx));
|
|
13
|
+
export async function executeQuery(ctx, type, payload, user, origin, tx, outerNotifySink) {
|
|
14
|
+
return runHandlerInstrumented(ctx, type, "query", user, () => executeQueryInner(ctx, type, payload, user, origin, tx, outerNotifySink));
|
|
15
15
|
}
|
|
16
|
-
async function executeQueryInner(ctx, type, payload, user, origin, tx) {
|
|
16
|
+
async function executeQueryInner(ctx, type, payload, user, origin, tx, outerNotifySink) {
|
|
17
17
|
const { registry } = ctx;
|
|
18
18
|
const handler = registry.getQueryHandler(type);
|
|
19
19
|
if (!handler)
|
|
@@ -54,11 +54,11 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
|
|
|
54
54
|
// A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
|
|
55
55
|
return isMemberResolutionPrincipal(user)
|
|
56
56
|
? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
|
|
57
|
-
: runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
|
|
57
|
+
: runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx, outerNotifySink);
|
|
58
58
|
}
|
|
59
|
-
async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user, origin, tx) {
|
|
59
|
+
async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user, origin, tx, outerNotifySink) {
|
|
60
60
|
const { registry } = ctx;
|
|
61
|
-
const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, undefined, includeDeleted);
|
|
61
|
+
const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, undefined, includeDeleted, undefined, outerNotifySink);
|
|
62
62
|
let result = await handler.handler({ type, payload, user }, handlerContext);
|
|
63
63
|
// postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
|
|
64
64
|
// and can merge custom-fields/computed-counts/tags/etc. Each hook is
|
|
@@ -57,7 +57,7 @@ export declare function perCommandNestedDenied(targetType: string): InternalErro
|
|
|
57
57
|
export declare function denyTransactionBoundSurfaces(handlerContext: HandlerContext): HandlerContext;
|
|
58
58
|
export declare function applyBeforeTransactionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
59
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>;
|
|
60
|
+
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope, outerNotifySink?: AfterCommitHook[], readOnlyForTenant?: boolean): Promise<HandlerContext>;
|
|
61
61
|
export declare function createHandlerBoundDigest(digest: (value: string) => string, handlerName: string): (value: string) => string;
|
|
62
62
|
export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
|
|
63
63
|
export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
|
|
@@ -9,13 +9,13 @@ import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index
|
|
|
9
9
|
import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
|
|
10
10
|
import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
|
|
11
11
|
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
|
|
12
|
-
import {
|
|
12
|
+
import { loadAggregate, loadAggregateAsOf, loadAggregateForWriting, } from "../event-store/event-store.js";
|
|
13
13
|
import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
|
|
14
14
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
|
|
15
15
|
import { createFileContext } from "../files/file-handle.js";
|
|
16
16
|
import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
|
|
17
17
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
18
|
-
import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle,
|
|
18
|
+
import { createEscapeHatchReporter, createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
19
19
|
import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
|
|
20
20
|
import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
|
|
21
21
|
import { isPlainObject } from "../utils/is-plain-object.js";
|
|
@@ -257,10 +257,14 @@ export function applyBeforeTransactionReadOnly(handlerContext) {
|
|
|
257
257
|
...denyTransactionBoundSurfaces(handlerContext),
|
|
258
258
|
dbOutsideTransaction: undefined,
|
|
259
259
|
outsideTransaction: undefined,
|
|
260
|
-
forTenant: undefined,
|
|
261
260
|
};
|
|
262
261
|
}
|
|
263
|
-
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction"
|
|
262
|
+
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction",
|
|
263
|
+
// A query bridge has no commit sink of its own, but ctx.notify inside it must still wait for
|
|
264
|
+
// the outer write's commit: the outer sink is lent to notify only, not to scheduleAfterCommit.
|
|
265
|
+
outerNotifySink,
|
|
266
|
+
// beforeTransaction phase: ctx.forTenant stays available but every target db denies writes.
|
|
267
|
+
readOnlyForTenant = false) {
|
|
264
268
|
const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
|
|
265
269
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
266
270
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
@@ -358,23 +362,30 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
358
362
|
const notifyInTransactionFactory = notifyFactory && tx !== undefined
|
|
359
363
|
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
|
|
360
364
|
: undefined;
|
|
365
|
+
const notifyAfterCommitFactory = notifyFactory
|
|
366
|
+
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, undefined, context._fileProviderResolver)
|
|
367
|
+
: undefined;
|
|
361
368
|
const notify = buildNotify();
|
|
362
369
|
function buildNotify() {
|
|
363
|
-
if (!
|
|
370
|
+
if (!notifyAfterCommitFactory)
|
|
364
371
|
return undefined;
|
|
365
|
-
const notifyAfterCommit =
|
|
372
|
+
const notifyAfterCommit = notifyAfterCommitFactory(user, user.tenantId);
|
|
366
373
|
if (!notifyInTransactionFactory)
|
|
367
374
|
return notifyAfterCommit;
|
|
368
375
|
const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
|
|
369
|
-
// A rolled-back write must leave no notification behind.
|
|
370
|
-
|
|
376
|
+
// A rolled-back write must leave no notification behind. Without any commit to wait for (a
|
|
377
|
+
// post-commit context) delivery stays immediate.
|
|
378
|
+
const notifyCommitSink = hasLiveCommitSink ? bridgeSink : outerNotifySink;
|
|
379
|
+
if (notifyCommitSink === undefined)
|
|
371
380
|
return notifyInTransaction;
|
|
372
381
|
return createDeferringNotify({
|
|
373
382
|
registry,
|
|
374
383
|
tenantId: user.tenantId,
|
|
375
384
|
notifyAfterCommit,
|
|
376
385
|
notifyNow: notifyInTransaction,
|
|
377
|
-
scheduleAfterCommit
|
|
386
|
+
scheduleAfterCommit: (hook) => {
|
|
387
|
+
notifyCommitSink.push(hook);
|
|
388
|
+
},
|
|
378
389
|
});
|
|
379
390
|
}
|
|
380
391
|
// Mirror notify: only built when the config feature wired its factory.
|
|
@@ -417,7 +428,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
417
428
|
if (targetTenantId === user.tenantId && exposedDb) {
|
|
418
429
|
// Only perCommand handlers lose forTenant writes; reservations keep their pre-transaction contract.
|
|
419
430
|
const ownTenantDb = denyDbWrites && !isPerCommand
|
|
420
|
-
? buildTenantScopedDb(dbSource, reqCtx?.signal)
|
|
431
|
+
? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, readOnlyForTenant)
|
|
421
432
|
: exposedDb;
|
|
422
433
|
return { db: ownTenantDb, streamTenantId: undefined };
|
|
423
434
|
}
|
|
@@ -427,7 +438,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
427
438
|
});
|
|
428
439
|
}
|
|
429
440
|
return {
|
|
430
|
-
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && isPerCommand),
|
|
441
|
+
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && (isPerCommand || readOnlyForTenant)),
|
|
431
442
|
streamTenantId: targetTenantId,
|
|
432
443
|
};
|
|
433
444
|
}
|
|
@@ -455,16 +466,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
455
466
|
const metrics = meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
|
|
456
467
|
// ctx.metricsFor(featureName) — shared/library code binds to a feature
|
|
457
468
|
// name of its own choosing instead of the dispatching handler's
|
|
458
|
-
// (framework#1844). Unregistered names no-op rather than throw
|
|
459
|
-
|
|
460
|
-
const metricsFor = meter
|
|
461
|
-
? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
|
|
462
|
-
: () => createNoopMetricsHandle();
|
|
469
|
+
// (framework#1844). Unregistered names no-op rather than throw.
|
|
470
|
+
const metricsFor = createMetricsForFactory(meter);
|
|
463
471
|
// A failed inner write has already appended its event and projections when a later
|
|
464
472
|
// step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
|
|
465
473
|
// failure result. The savepoint makes the failure undo exactly the inner write; the
|
|
466
474
|
// inner afterCommit hooks only reach the outer sink if it succeeded.
|
|
467
|
-
const
|
|
475
|
+
const runNestedWriteInSavepoint = async (asUser, targetType, payload) => {
|
|
468
476
|
if (registry.getWriteHandler(targetType)?.transaction === "perCommand") {
|
|
469
477
|
return writeFailure(perCommandNestedDenied(targetType));
|
|
470
478
|
}
|
|
@@ -488,6 +496,15 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
488
496
|
throw e;
|
|
489
497
|
}
|
|
490
498
|
};
|
|
499
|
+
// Savepoints on one connection are not serialized by the driver: two parallel nested writes would
|
|
500
|
+
// interleave their statements and a ROLLBACK TO of the first would also discard the second's rows.
|
|
501
|
+
// Writes nested one level deeper run on their own savepoint handle and therefore their own chain.
|
|
502
|
+
let nestedWriteChain = Promise.resolve();
|
|
503
|
+
const executeNestedWriteInSavepoint = (asUser, targetType, payload) => {
|
|
504
|
+
const run = nestedWriteChain.then(() => runNestedWriteInSavepoint(asUser, targetType, payload));
|
|
505
|
+
nestedWriteChain = run.catch(() => undefined);
|
|
506
|
+
return run;
|
|
507
|
+
};
|
|
491
508
|
// Without an open transaction (post-commit hooks, stream/query handlers, perCommand handlers) a bare
|
|
492
509
|
// executeWrite would auto-commit event append, projections and inTransaction hooks separately and its
|
|
493
510
|
// afterCommit hooks would land in a sink nobody flushes. A batch-of-one gives the write its own
|
|
@@ -498,7 +515,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
498
515
|
const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
|
|
499
516
|
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
|
|
500
517
|
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
501
|
-
queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
|
|
518
|
+
queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
|
|
502
519
|
writeAs: writeAsUser,
|
|
503
520
|
}, identitySwitchAudit);
|
|
504
521
|
// Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
|
|
@@ -542,7 +559,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
542
559
|
return projTable === undefined || hasTenantColumn(projTable);
|
|
543
560
|
});
|
|
544
561
|
const bridge = {
|
|
545
|
-
query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
|
|
562
|
+
query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
|
|
546
563
|
queryAs: identitySwitch.queryAs,
|
|
547
564
|
write: (targetType, payload) => writeAsUser(user, targetType, payload),
|
|
548
565
|
writeAs: identitySwitch.writeAs,
|
|
@@ -597,16 +614,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
597
614
|
message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
|
|
598
615
|
});
|
|
599
616
|
}
|
|
600
|
-
// Stream-version authoritative (same policy as CRUD executor + Block 0)
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
// the events (business-rule checks), so fetch both in parallel.
|
|
604
|
-
const [storedEvents, fetchedVersion] = await Promise.all([
|
|
605
|
-
loadAggregate(dbSource, args.aggregateId, user.tenantId, {
|
|
606
|
-
aggregateType: args.aggregateType,
|
|
607
|
-
}),
|
|
608
|
-
getStreamVersion(dbSource, args.aggregateId, user.tenantId),
|
|
609
|
-
]);
|
|
617
|
+
// Stream-version authoritative (same policy as CRUD executor + Block 0), read together
|
|
618
|
+
// with the events so the pinned version covers exactly what the handler's rules saw.
|
|
619
|
+
const { events: storedEvents, streamVersion: fetchedVersion } = await loadAggregateForWriting(dbSource, args.aggregateId, user.tenantId, { aggregateType: args.aggregateType });
|
|
610
620
|
const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
|
|
611
621
|
db: dbSource,
|
|
612
622
|
tenantId: user.tenantId,
|
|
@@ -831,11 +841,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
831
841
|
// able to compute bucket keys.
|
|
832
842
|
const { _rateLimitPayloadDigest: payloadDigest,
|
|
833
843
|
// Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
|
|
834
|
-
_notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
|
|
844
|
+
_notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, _notifyAfterCommitFactory: _omitOuterNotifyAfterCommitFactory, ...contextForHandler } = context;
|
|
835
845
|
const handlerContext = {
|
|
836
846
|
...contextForHandler,
|
|
837
847
|
...(payloadDigest && { rateLimitDigest: createHandlerBoundDigest(payloadDigest, type) }),
|
|
838
848
|
...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
|
|
849
|
+
...(notifyAfterCommitFactory && { _notifyAfterCommitFactory: notifyAfterCommitFactory }),
|
|
839
850
|
registry,
|
|
840
851
|
db: exposedDb,
|
|
841
852
|
dbOutsideTransaction,
|
|
@@ -2,7 +2,7 @@ import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
|
|
|
2
2
|
import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
|
|
3
3
|
import { hasAccess } from "../engine/access.js";
|
|
4
4
|
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
5
|
-
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
|
|
5
|
+
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, UnauthenticatedError, validationErrorFromZod, } from "../errors/index.js";
|
|
6
6
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
7
7
|
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
|
|
8
8
|
import { handlerAccessError } from "./handler-access-error.js";
|
|
@@ -69,10 +69,16 @@ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
|
|
|
69
69
|
abandonedForInvalidation = true;
|
|
70
70
|
void nextPull.catch(() => { });
|
|
71
71
|
void iterator.return?.(undefined)?.then(undefined, () => { });
|
|
72
|
+
// An expired token is a lapsed session (401, so clients show the
|
|
73
|
+
// session-end hint); revocation is a permission change (403).
|
|
74
|
+
if (outcome.reason === "expired") {
|
|
75
|
+
throw new UnauthenticatedError({
|
|
76
|
+
message: `token expired mid-stream for ${type}`,
|
|
77
|
+
details: { handler: type, reason: outcome.reason },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
72
80
|
throw new AccessDeniedError({
|
|
73
|
-
message:
|
|
74
|
-
? `token expired mid-stream for ${type}`
|
|
75
|
-
: `access revoked mid-stream for ${type}`,
|
|
81
|
+
message: `access revoked mid-stream for ${type}`,
|
|
76
82
|
details: { handler: type },
|
|
77
83
|
});
|
|
78
84
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runWithWriteOrigin } from "../api/request-context.js";
|
|
2
2
|
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
|
|
3
3
|
import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
|
|
4
|
+
import { withEscapeHatchAuditScopeStream } from "../observability/escape-hatch-report.js";
|
|
4
5
|
import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
5
6
|
import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
|
|
6
7
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
@@ -114,7 +115,8 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
114
115
|
},
|
|
115
116
|
stream: (typeOrRef, payload, user, options) => {
|
|
116
117
|
const type = resolveType(typeOrRef);
|
|
117
|
-
|
|
118
|
+
const origin = rootWriteOrigin(registry, type, user);
|
|
119
|
+
return withEscapeHatchAuditScopeStream(() => executeStream(ctx, type, payload, user, origin, options));
|
|
118
120
|
},
|
|
119
121
|
async command(typeOrRef, payload, user) {
|
|
120
122
|
const type = resolveType(typeOrRef);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PgListenClient } from "../db/connection.js";
|
|
2
2
|
import type { AppContext } from "../engine/types/index.js";
|
|
3
3
|
import { type StoredEvent } from "../event-store/index.js";
|
|
4
4
|
import { type Meter, type Tracer } from "../observability/index.js";
|
|
@@ -46,7 +46,7 @@ export type EventDispatcherOptions = {
|
|
|
46
46
|
readonly tracer?: Tracer;
|
|
47
47
|
readonly meter?: Meter;
|
|
48
48
|
readonly instanceId?: string;
|
|
49
|
-
readonly pgClient?:
|
|
49
|
+
readonly pgClient?: PgListenClient;
|
|
50
50
|
};
|
|
51
51
|
export declare function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher;
|
|
52
52
|
export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin.js";
|