@cosmicdrift/kumiko-framework 0.334.0 → 0.335.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/dist/api/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +14 -7
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +1 -1
- package/dist/api/sse-route.js +39 -6
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +11 -1
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +39 -36
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +41 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +103 -33
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +62 -19
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +38 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +22 -3
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +6 -1
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +150 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +26 -11
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +9 -7
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-jobs.js +24 -12
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +206 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +56 -19
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
package/dist/jobs/job-runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Queue, Worker } from "bullmq";
|
|
1
|
+
import { Queue, UnrecoverableError, Worker } from "bullmq";
|
|
2
2
|
import { Redis } from "ioredis";
|
|
3
3
|
import { requestContext } from "../api/request-context.js";
|
|
4
4
|
import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
|
|
@@ -10,8 +10,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
10
10
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
11
11
|
import { createFileContext } from "../files/file-handle.js";
|
|
12
12
|
import { createFallbackLogger } from "../logging/index.js";
|
|
13
|
-
import { createEscapeHatchReporter } from "../observability/
|
|
14
|
-
import { emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, } from "../observability/index.js";
|
|
13
|
+
import { createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
15
14
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
16
15
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
17
16
|
import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
|
|
@@ -122,6 +121,14 @@ function createJobLogger(logs, baseContext = {}) {
|
|
|
122
121
|
};
|
|
123
122
|
return logger;
|
|
124
123
|
}
|
|
124
|
+
// Single source of the stored subject string: the writer (jobSubjectKey) and
|
|
125
|
+
// the tenant failures query both go through it. Matching is exact — field names
|
|
126
|
+
// and JSON types must equal what the writer stored (`"1"` is not `1`).
|
|
127
|
+
export function serializeJobSubject(subject) {
|
|
128
|
+
return JSON.stringify(Object.keys(subject)
|
|
129
|
+
.sort()
|
|
130
|
+
.map((field) => [field, subject[field] ?? null]));
|
|
131
|
+
}
|
|
125
132
|
// Stable identity for the declared payload fields: sorted keys, so two runs
|
|
126
133
|
// with the same subject values produce the same string and the later one
|
|
127
134
|
// replaces the earlier record. Non-primitives would serialize into something
|
|
@@ -129,15 +136,31 @@ function createJobLogger(logs, baseContext = {}) {
|
|
|
129
136
|
function jobSubjectKey(jobName, payload, fields) {
|
|
130
137
|
if (fields === undefined || fields.length === 0)
|
|
131
138
|
return null;
|
|
132
|
-
const
|
|
133
|
-
for (const field of
|
|
139
|
+
const subject = {};
|
|
140
|
+
for (const field of fields) {
|
|
134
141
|
const value = payload[field] ?? null;
|
|
135
142
|
if (value !== null && typeof value === "object") {
|
|
136
143
|
throw new Error(`Job "${jobName}": tenantVisibleFailure.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
|
|
137
144
|
}
|
|
138
|
-
|
|
145
|
+
subject[field] = value;
|
|
139
146
|
}
|
|
140
|
-
return
|
|
147
|
+
return serializeJobSubject(subject);
|
|
148
|
+
}
|
|
149
|
+
// Jobs that never touch ctx.db run fine on a runner built without one; a job
|
|
150
|
+
// that does gets a named error instead of a bare TypeError on undefined.
|
|
151
|
+
function missingDbProxy(jobName) {
|
|
152
|
+
// @cast-boundary db-operator — every access throws, so no TenantDb member is ever returned
|
|
153
|
+
return new Proxy({}, {
|
|
154
|
+
get() {
|
|
155
|
+
throw new InternalError({
|
|
156
|
+
message: `[jobs] job runner constructed without a db connection — ctx.db is unavailable for "${jobName}"`,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function priorAttemptsOf(data) {
|
|
162
|
+
const value = data["_priorAttempts"];
|
|
163
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
|
|
141
164
|
}
|
|
142
165
|
// Only a translation key ever travels to the tenant: the thrown error's own
|
|
143
166
|
// i18nKey when it carries one, otherwise the key declared at the job. The
|
|
@@ -202,6 +225,9 @@ function parseRedisOpts(url) {
|
|
|
202
225
|
// redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
|
|
203
226
|
// would otherwise hang start() forever with no health endpoint to notice.
|
|
204
227
|
const BOOT_REDIS_TIMEOUT_MS = 10_000;
|
|
228
|
+
// Shutdown must fit a k8s grace period even when Redis is down, so the
|
|
229
|
+
// readiness wait in stop() is capped well below the boot timeout.
|
|
230
|
+
const STOP_QUEUE_READY_TIMEOUT_MS = 1_000;
|
|
205
231
|
function timeoutReject(ms, message) {
|
|
206
232
|
let timer;
|
|
207
233
|
const promise = new Promise((_, reject) => {
|
|
@@ -224,10 +250,10 @@ const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
|
|
|
224
250
|
// perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
|
|
225
251
|
// set must retry the same way regardless of how it got enqueued, or it
|
|
226
252
|
// fails for good on the very first error on whichever path skips this.
|
|
227
|
-
function buildRetryBullOpts(jobDef) {
|
|
253
|
+
function buildRetryBullOpts(jobDef, priorAttempts = 0) {
|
|
228
254
|
const opts = {};
|
|
229
255
|
if (jobDef.retries !== undefined)
|
|
230
|
-
opts.attempts = jobDef.retries + 1;
|
|
256
|
+
opts.attempts = Math.max(1, jobDef.retries + 1 - priorAttempts);
|
|
231
257
|
if (jobDef.backoff) {
|
|
232
258
|
opts.backoff =
|
|
233
259
|
typeof jobDef.backoff === "string"
|
|
@@ -282,6 +308,10 @@ export function createJobRunner(options) {
|
|
|
282
308
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
283
309
|
const tracer = context.tracer ?? getFallbackTracer();
|
|
284
310
|
const errorLogger = createFallbackLogger("job-runner", context.log);
|
|
311
|
+
// The last-success gauge throws if its metric was never registered; the runner
|
|
312
|
+
// must not depend on buildServer having done it first. Idempotent.
|
|
313
|
+
if (context.meter)
|
|
314
|
+
registerStandardMetrics(context.meter);
|
|
285
315
|
// Set at the top of stop() — a graceful shutdown closes the redis/BullMQ
|
|
286
316
|
// clients itself, which fires the exact same 'error' listeners below with
|
|
287
317
|
// an expected "Connection is closed." Downgrading to debug once stopping
|
|
@@ -318,7 +348,12 @@ export function createJobRunner(options) {
|
|
|
318
348
|
registry,
|
|
319
349
|
...bridgeStub(),
|
|
320
350
|
});
|
|
321
|
-
|
|
351
|
+
if (!Array.isArray(result) || result.some((id) => typeof id !== "string")) {
|
|
352
|
+
throw new InternalError({
|
|
353
|
+
message: `${ACTIVE_TENANT_IDS_QUERY_NAME} must return string[] of tenant ids`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
// @cast-boundary engine-payload — checked to be string[] above; TenantId is a brand
|
|
322
357
|
return result;
|
|
323
358
|
};
|
|
324
359
|
}
|
|
@@ -455,6 +490,12 @@ export function createJobRunner(options) {
|
|
|
455
490
|
}
|
|
456
491
|
return false;
|
|
457
492
|
}
|
|
493
|
+
async function reportPreRunFailure(jobName, bullJob, err) {
|
|
494
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
495
|
+
await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
|
|
496
|
+
{ level: "error", message, timestamp: Temporal.Now.instant() },
|
|
497
|
+
]);
|
|
498
|
+
}
|
|
458
499
|
async function handleJob(bullJob) {
|
|
459
500
|
const rawName = bullJob.name;
|
|
460
501
|
// Handle perTenant dispatch jobs — fan out to one job per tenant. The
|
|
@@ -463,34 +504,51 @@ export function createJobRunner(options) {
|
|
|
463
504
|
// (it's picked up by this runner's own worker).
|
|
464
505
|
if (rawName.startsWith("_perTenant:")) {
|
|
465
506
|
const actualName = rawName.slice("_perTenant:".length);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
507
|
+
// These throws happen before runInSpan, so its onJobFailed never sees them
|
|
508
|
+
// and a drained test stack would stay green over a fan-out that never ran.
|
|
509
|
+
try {
|
|
510
|
+
if (!getActiveTenantIds) {
|
|
511
|
+
if (registry.getQueryHandler(ACTIVE_TENANT_IDS_QUERY_NAME)) {
|
|
512
|
+
throw new Error(`perTenant job "${actualName}": the tenant feature is mounted but the job runner has no ` +
|
|
513
|
+
"`context.db`: pass a DbConnection in the runner context or supply options.getActiveTenantIds");
|
|
514
|
+
}
|
|
515
|
+
throw new Error(`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
|
|
516
|
+
`tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
|
|
517
|
+
"framework uses to resolve active tenants on its own)");
|
|
518
|
+
}
|
|
519
|
+
const actualDef = allJobs.get(actualName);
|
|
520
|
+
if (!actualDef) {
|
|
521
|
+
throw new Error(`Unknown job: ${actualName}`);
|
|
522
|
+
}
|
|
523
|
+
const tenantIds = await getActiveTenantIds();
|
|
524
|
+
const targetQueue = queues[laneForJob(actualDef)];
|
|
525
|
+
// wrapperJobId is only absent for a Job BullMQ somehow never assigned
|
|
526
|
+
// an id to (not observed in practice) — falling back to a constant
|
|
527
|
+
// there would collide every such run's children onto the same ids
|
|
528
|
+
// and silently swallow later runs as "duplicates", which is worse
|
|
529
|
+
// than the duplicate this is meant to prevent. Skip id-based dedup
|
|
530
|
+
// for that edge case instead: enqueue plainly, same as before.
|
|
531
|
+
const wrapperJobId = bullJob.id;
|
|
532
|
+
for (const tenantId of tenantIds) {
|
|
533
|
+
await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
|
|
534
|
+
...buildRetryBullOpts(actualDef),
|
|
535
|
+
// Dedup over wrapper retries only holds as long as the children
|
|
536
|
+
// stay in Redis for the wrapper's whole retry window — see the
|
|
537
|
+
// COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
|
|
538
|
+
...(wrapperJobId !== undefined
|
|
539
|
+
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
540
|
+
: {}),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
474
543
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const wrapperJobId = bullJob.id;
|
|
484
|
-
for (const tenantId of tenantIds) {
|
|
485
|
-
await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
|
|
486
|
-
...buildRetryBullOpts(actualDef),
|
|
487
|
-
// Dedup over wrapper retries only holds as long as the children
|
|
488
|
-
// stay in Redis for the wrapper's whole retry window — see the
|
|
489
|
-
// COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
|
|
490
|
-
...(wrapperJobId !== undefined
|
|
491
|
-
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
492
|
-
: {}),
|
|
493
|
-
});
|
|
544
|
+
catch (err) {
|
|
545
|
+
// A retry may still succeed, and a succeeding wrapper reports no completion
|
|
546
|
+
// that could clear an earlier failure, so only the final attempt is reported.
|
|
547
|
+
const retries = allJobs.get(actualName)?.retries ?? 0;
|
|
548
|
+
if (bullJob.attemptsMade + 1 >= retries + 1) {
|
|
549
|
+
await reportPreRunFailure(rawName, bullJob, err);
|
|
550
|
+
}
|
|
551
|
+
throw err;
|
|
494
552
|
}
|
|
495
553
|
// skip: fan-out dispatcher job, per-tenant children enqueued
|
|
496
554
|
return;
|
|
@@ -498,7 +556,9 @@ export function createJobRunner(options) {
|
|
|
498
556
|
const jobName = rawName;
|
|
499
557
|
const jobDef = allJobs.get(jobName);
|
|
500
558
|
if (!jobDef) {
|
|
501
|
-
|
|
559
|
+
const err = new Error(`Unknown job: ${jobName}`);
|
|
560
|
+
await reportPreRunFailure(jobName, bullJob, err);
|
|
561
|
+
throw err;
|
|
502
562
|
}
|
|
503
563
|
// Sequential gate: try to claim the per-name lock. If another worker
|
|
504
564
|
// (or this worker on a different bullJob) holds it, re-enqueue with a
|
|
@@ -516,13 +576,12 @@ export function createJobRunner(options) {
|
|
|
516
576
|
// same queue the worker just picked from (since only the consuming
|
|
517
577
|
// lane runs handleJob at all), but route explicitly — no implicit
|
|
518
578
|
// coupling to "whichever queue the caller happened to be on".
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
|
|
579
|
+
// Attempts already spent travel in _priorAttempts so a lock conflict
|
|
580
|
+
// on a retry cannot hand out a fresh budget and exceed `retries`.
|
|
581
|
+
const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
|
|
582
|
+
await queues[laneForJob(jobDef)].add(jobName, { ...bullJob.data, _priorAttempts: priorAttempts }, {
|
|
524
583
|
delay: SEQUENTIAL_RETRY_DELAY_MS,
|
|
525
|
-
...buildRetryBullOpts(jobDef),
|
|
584
|
+
...buildRetryBullOpts(jobDef, priorAttempts),
|
|
526
585
|
});
|
|
527
586
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
528
587
|
return;
|
|
@@ -537,7 +596,7 @@ export function createJobRunner(options) {
|
|
|
537
596
|
// retry number, so audit queries distinguish fresh from retry runs
|
|
538
597
|
// without peeking at BullMQ internals.
|
|
539
598
|
const rawData = bullJob.data;
|
|
540
|
-
const attempt = bullJob.attemptsMade + 1;
|
|
599
|
+
const attempt = bullJob.attemptsMade + 1 + priorAttemptsOf(rawData);
|
|
541
600
|
const meta = {
|
|
542
601
|
triggeredById: rawData["_triggeredById"], // @cast-boundary dynamic-key
|
|
543
602
|
payload: rawData["_payload"], // @cast-boundary dynamic-key
|
|
@@ -558,16 +617,26 @@ export function createJobRunner(options) {
|
|
|
558
617
|
// subject is read from the handler payload, so it is resolved here where
|
|
559
618
|
// the payload is built, not in the callbacks.
|
|
560
619
|
const tenantVisibleDecl = jobDef.tenantVisibleFailure;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
620
|
+
// A bad subject config must surface as a failed run, so it is thrown from
|
|
621
|
+
// inside runInSpan below instead of here, before any run record exists.
|
|
622
|
+
let tenantVisibleSubject = null;
|
|
623
|
+
let subjectConfigError;
|
|
624
|
+
if (tenantVisibleDecl) {
|
|
625
|
+
try {
|
|
626
|
+
tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields);
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
subjectConfigError = err instanceof Error ? err : new Error(String(err));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
564
632
|
// BullMQ stops retrying once attemptsMade reaches the configured attempts
|
|
565
633
|
// (`retries + 1`), so this is the attempt whose failure is final.
|
|
566
|
-
const finalAttempt = attempt >= (jobDef.retries ?? 0) + 1;
|
|
634
|
+
const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
|
|
567
635
|
const outcomeMeta = (messageKey) => ({
|
|
568
636
|
tenantId,
|
|
569
637
|
finalAttempt,
|
|
570
|
-
...(tenantVisibleDecl &&
|
|
638
|
+
...(tenantVisibleDecl &&
|
|
639
|
+
!subjectConfigError && {
|
|
571
640
|
tenantVisible: { subject: tenantVisibleSubject, messageKey },
|
|
572
641
|
}),
|
|
573
642
|
});
|
|
@@ -658,8 +727,7 @@ export function createJobRunner(options) {
|
|
|
658
727
|
let memberReader;
|
|
659
728
|
const jobContext = {
|
|
660
729
|
...context,
|
|
661
|
-
|
|
662
|
-
db: jobDb, // @cast-boundary db-operator
|
|
730
|
+
db: jobDb ?? missingDbProxy(jobName),
|
|
663
731
|
files,
|
|
664
732
|
derivatives,
|
|
665
733
|
...(notify !== undefined && { notify }),
|
|
@@ -721,6 +789,10 @@ export function createJobRunner(options) {
|
|
|
721
789
|
const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
|
|
722
790
|
const runInSpan = async () => {
|
|
723
791
|
try {
|
|
792
|
+
if (subjectConfigError) {
|
|
793
|
+
// Retrying cannot fix a declaration error.
|
|
794
|
+
throw new UnrecoverableError(subjectConfigError.message);
|
|
795
|
+
}
|
|
724
796
|
if (writeOriginInvalid) {
|
|
725
797
|
throw new InternalError({
|
|
726
798
|
message: `Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
|
|
@@ -732,13 +804,22 @@ export function createJobRunner(options) {
|
|
|
732
804
|
correlationId: jobCorrelationId,
|
|
733
805
|
// #3043 — events a job writes carry the job as their origin.
|
|
734
806
|
handler: jobName,
|
|
735
|
-
feature: qnScope(jobName),
|
|
807
|
+
feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
|
|
736
808
|
writeOrigin: jobOrigin,
|
|
737
809
|
}, () => jobDef.handler(payload, jobContext));
|
|
738
810
|
// Stamped before the observer hook: a throwing onJobComplete must not
|
|
739
811
|
// make a run that actually succeeded look dead to the liveness alert.
|
|
740
|
-
if (context.meter)
|
|
741
|
-
|
|
812
|
+
if (context.meter) {
|
|
813
|
+
try {
|
|
814
|
+
emitJobLastSuccess(context.meter, jobName);
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
errorLogger.warn("job last-success metric failed", {
|
|
818
|
+
job: jobName,
|
|
819
|
+
error: err instanceof Error ? err.message : String(err),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
742
823
|
const duration = Date.now() - startTime;
|
|
743
824
|
await options.onJobComplete?.(jobName, jobId, duration, logs, outcomeMeta(null));
|
|
744
825
|
}
|
|
@@ -795,7 +876,13 @@ export function createJobRunner(options) {
|
|
|
795
876
|
if (!jobDef.bootGate)
|
|
796
877
|
continue;
|
|
797
878
|
try {
|
|
798
|
-
await handleJob({
|
|
879
|
+
await handleJob({
|
|
880
|
+
id: bootJobIdForJobName(name),
|
|
881
|
+
name,
|
|
882
|
+
data: {},
|
|
883
|
+
attemptsMade: 0,
|
|
884
|
+
retryable: false,
|
|
885
|
+
});
|
|
799
886
|
}
|
|
800
887
|
catch (err) {
|
|
801
888
|
// The worker is already consuming here; leaving its Redis connections
|
|
@@ -925,15 +1012,19 @@ export function createJobRunner(options) {
|
|
|
925
1012
|
// rejects the in-flight INFO with "Connection is closed." on an emitter
|
|
926
1013
|
// without listeners -> unhandled rejection (fw#1805). Wait for readiness
|
|
927
1014
|
// first; a failed/timed-out wait must not block the close itself.
|
|
928
|
-
const
|
|
1015
|
+
const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
|
|
1016
|
+
const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
|
|
929
1017
|
try {
|
|
930
1018
|
await Promise.race([
|
|
931
1019
|
Promise.all([queues.api.waitUntilReady(), queues.worker.waitUntilReady()]),
|
|
932
1020
|
readinessTimeout.promise,
|
|
933
1021
|
]);
|
|
934
1022
|
}
|
|
935
|
-
catch {
|
|
936
|
-
// close() below still has to run
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
// close() below still has to run; stopping is already true, so debug matches the other shutdown logs
|
|
1025
|
+
errorLogger.debug("queue readiness wait failed during stop()", {
|
|
1026
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1027
|
+
});
|
|
937
1028
|
}
|
|
938
1029
|
finally {
|
|
939
1030
|
readinessTimeout.cancel();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { assertKumikoSchemaCurrent, type ChecksumMismatch, detectKumikoDrift, formatKumikoDriftReport, type KumikoDriftReport, SchemaDriftError, } from "./kumiko-drift.js";
|
|
2
|
-
export { createPendingRebuildsTable, type EnqueueProjectionRebuildDeps, type EnqueueProjectionRebuildResult, enqueueProjectionRebuild, listPendingRebuilds, type PendingRebuildRun, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, type RunPendingRebuildsOptions, rebuildProjectionOrMultiStream, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
2
|
+
export { createPendingRebuildsTable, type EnqueueProjectionRebuildDeps, type EnqueueProjectionRebuildResult, enqueueProjectionRebuild, listPendingRebuilds, type PendingRebuildRun, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, type RunPendingRebuildsOptions, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
3
3
|
export { createPiiBackfillStateTable, type PiiEventBackfillOptions, type PiiEventBackfillResult, type PiiRebuildRun, piiAnnotationFingerprint, piiBackfillStateTable, runPiiEventBackfill, } from "./pii-event-backfill.js";
|
|
4
4
|
export { buildProjectionTableIndex } from "./projection-table-index.js";
|
package/dist/migrations/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Drizzle-free gate (kumiko/migrations system) — the canonical boot-gate.
|
|
2
2
|
export { assertKumikoSchemaCurrent, detectKumikoDrift, formatKumikoDriftReport, SchemaDriftError, } from "./kumiko-drift.js";
|
|
3
3
|
// Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
|
|
4
|
-
export { createPendingRebuildsTable, enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
4
|
+
export { createPendingRebuildsTable, enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
5
5
|
// Boot-time backfill: re-encrypts plaintext events after a PII annotation was added.
|
|
6
6
|
export { createPiiBackfillStateTable, piiAnnotationFingerprint, piiBackfillStateTable, runPiiEventBackfill, } from "./pii-event-backfill.js";
|
|
7
7
|
// tableName → projection-name, für den app-seitigen Projection-Rebuild.
|
|
@@ -57,6 +57,10 @@ export declare function rebuildProjectionOrMultiStream(name: string, deps: {
|
|
|
57
57
|
readonly skipApplyErrors?: boolean;
|
|
58
58
|
};
|
|
59
59
|
}): Promise<RebuildResult>;
|
|
60
|
+
/** Logs a finished queue drain and throws when any projection failed; the
|
|
61
|
+
* failed tables stay queued. Shared by every `schema apply` entry point so the
|
|
62
|
+
* report and the error text cannot drift apart. */
|
|
63
|
+
export declare function reportPendingRebuildRun(run: PendingRebuildRun, log: (line: string) => void): void;
|
|
60
64
|
/** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
|
|
61
65
|
* rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
|
|
62
66
|
* ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
|
|
@@ -71,7 +71,12 @@ export async function clearPendingRebuilds(db, rows) {
|
|
|
71
71
|
export async function rebuildProjectionOrMultiStream(name, deps) {
|
|
72
72
|
const { db, registry, signal, errorPolicy } = deps;
|
|
73
73
|
if (registry.getAllMultiStreamProjections().has(name)) {
|
|
74
|
-
return rebuildMultiStreamProjection(name, {
|
|
74
|
+
return rebuildMultiStreamProjection(name, {
|
|
75
|
+
db,
|
|
76
|
+
registry,
|
|
77
|
+
markDeadOnFailure: false,
|
|
78
|
+
...(signal && { signal }),
|
|
79
|
+
});
|
|
75
80
|
}
|
|
76
81
|
return rebuildProjection(name, {
|
|
77
82
|
db,
|
|
@@ -80,6 +85,23 @@ export async function rebuildProjectionOrMultiStream(name, deps) {
|
|
|
80
85
|
...(errorPolicy && { errorPolicy }),
|
|
81
86
|
});
|
|
82
87
|
}
|
|
88
|
+
/** Logs a finished queue drain and throws when any projection failed; the
|
|
89
|
+
* failed tables stay queued. Shared by every `schema apply` entry point so the
|
|
90
|
+
* report and the error text cannot drift apart. */
|
|
91
|
+
export function reportPendingRebuildRun(run, log) {
|
|
92
|
+
if (run.rebuilt.length > 0) {
|
|
93
|
+
log(` Rebuild ${run.rebuilt.length} Projection(s)…`);
|
|
94
|
+
for (const r of run.rebuilt) {
|
|
95
|
+
log(` ↻ ${r.projection} (${r.eventsProcessed} events)`);
|
|
96
|
+
}
|
|
97
|
+
log("");
|
|
98
|
+
}
|
|
99
|
+
if (run.failed.length > 0) {
|
|
100
|
+
throw new Error(`Projection rebuild failed for: ${run.failed
|
|
101
|
+
.map((f) => `${f.projection} (${f.error})`)
|
|
102
|
+
.join("; ")}. Table(s) stay queued in kumiko_pending_rebuilds — retried on the next apply.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
83
105
|
/** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
|
|
84
106
|
* rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
|
|
85
107
|
* ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
|
|
@@ -20,4 +20,5 @@ export declare function createEscapeHatchReporter(opts: {
|
|
|
20
20
|
readonly log?: Logger;
|
|
21
21
|
readonly window?: EscapeHatchReportWindow;
|
|
22
22
|
}): EscapeHatchReporter;
|
|
23
|
+
export declare const UNATTRIBUTED_ACTOR = "system";
|
|
23
24
|
export declare function fallbackEscapeHatchReporter(tenantId: TenantId): EscapeHatchReporter;
|
|
@@ -83,6 +83,9 @@ export function createEscapeHatchReporter(opts) {
|
|
|
83
83
|
reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
// Actor for escape-hatch uses whose caller did not thread one through. Stays
|
|
87
|
+
// "system" so audit events persisted before the constant existed remain valid.
|
|
88
|
+
export const UNATTRIBUTED_ACTOR = "system";
|
|
86
89
|
export function fallbackEscapeHatchReporter(tenantId) {
|
|
87
90
|
return createEscapeHatchReporter({
|
|
88
91
|
handler: "<unattributed>",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider.js";
|
|
2
2
|
export { observabilityContext } from "./context.js";
|
|
3
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
3
4
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
4
5
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
5
6
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Public surface of the observability module.
|
|
2
2
|
export { createConsoleProvider } from "./console-provider.js";
|
|
3
3
|
export { observabilityContext } from "./context.js";
|
|
4
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
4
5
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
5
6
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
6
7
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { DbTx } from "../db/connection.js";
|
|
2
2
|
import type { SessionUser } from "../engine/types/index.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
|
|
4
4
|
export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx): Promise<unknown>;
|
|
@@ -2,7 +2,7 @@ import { hasAccess } from "../engine/access.js";
|
|
|
2
2
|
import { filterReadFields } from "../engine/field-access.js";
|
|
3
3
|
import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors/index.js";
|
|
4
4
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
5
|
-
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, runHandlerInstrumented, } from "./dispatch-shared.js";
|
|
5
|
+
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runHandlerInstrumented, } from "./dispatch-shared.js";
|
|
6
6
|
import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.js";
|
|
7
7
|
// Standalone query execution — used by the public dispatcher.query() and
|
|
8
8
|
// by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
|
|
@@ -51,7 +51,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
|
|
|
51
51
|
parsed.data !== null &&
|
|
52
52
|
parsed.data["includeDeleted"] === true; // @cast-boundary validated-payload
|
|
53
53
|
// A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
|
|
54
|
-
return user
|
|
54
|
+
return isMemberResolutionPrincipal(user)
|
|
55
55
|
? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
|
|
56
56
|
: runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
|
|
57
57
|
}
|
|
@@ -6,8 +6,7 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
|
|
|
6
6
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
7
7
|
import type { AppContext, HandlerContext, JobRunnerRef, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
8
8
|
import { type WriteErrorInfo } from "../errors/index.js";
|
|
9
|
-
import { type EscapeHatchReportWindow } from "../observability/
|
|
10
|
-
import { type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
9
|
+
import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
11
10
|
import { type AfterCommitHook } from "./dispatcher-utils.js";
|
|
12
11
|
import type { IdempotencyGuard } from "./idempotency.js";
|
|
13
12
|
import type { LifecycleHooks } from "./lifecycle-pipeline.js";
|
|
@@ -49,7 +48,7 @@ export type DispatchContext = {
|
|
|
49
48
|
export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
|
|
50
49
|
export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
|
|
51
50
|
export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
52
|
-
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean): Promise<HandlerContext>;
|
|
51
|
+
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, isPostCommit?: boolean): Promise<HandlerContext>;
|
|
53
52
|
export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
|
|
54
53
|
export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
|
|
55
54
|
export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;
|
|
@@ -11,13 +11,13 @@ import { loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapsho
|
|
|
11
11
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
|
|
12
12
|
import { createFileContext } from "../files/file-handle.js";
|
|
13
13
|
import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
|
|
14
|
-
import { createEscapeHatchReporter, } from "../observability/
|
|
15
|
-
import { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
14
|
+
import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
16
15
|
import { buildBucketKey } from "../rate-limit/index.js";
|
|
17
16
|
import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
|
|
18
17
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
19
18
|
import { appendDomainEventCore } from "./append-event-core.js";
|
|
20
19
|
import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver.js";
|
|
20
|
+
import { runBatch, unwrapSingle } from "./dispatch-batch.js";
|
|
21
21
|
import { executeQuery } from "./dispatch-query.js";
|
|
22
22
|
import { executeWrite } from "./dispatch-write.js";
|
|
23
23
|
import { dispatcherSpanAttributes, isFailedWriteResult, } from "./dispatcher-utils.js";
|
|
@@ -135,7 +135,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
135
135
|
...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
|
-
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted) {
|
|
138
|
+
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
|
|
139
139
|
const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
|
|
140
140
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
141
141
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
@@ -241,6 +241,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
241
241
|
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
242
242
|
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
243
243
|
const bridgeSink = afterCommitHooks ?? [];
|
|
244
|
+
// Post-commit hooks have no transaction left, so a bare executeWrite would
|
|
245
|
+
// auto-commit event append, projections and inTransaction hooks separately
|
|
246
|
+
// and its afterCommit hooks would land in an already-flushed sink. A
|
|
247
|
+
// batch-of-one gives the write its own transaction and its own flush.
|
|
248
|
+
const writeAsUser = async (asUser, targetType, payload) => isPostCommit
|
|
249
|
+
? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
|
|
250
|
+
: executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
|
|
244
251
|
const scheduleAfterCommit = (hook) => {
|
|
245
252
|
bridgeSink.push(hook);
|
|
246
253
|
};
|
|
@@ -248,7 +255,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
248
255
|
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
|
|
249
256
|
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
250
257
|
queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
|
|
251
|
-
writeAs:
|
|
258
|
+
writeAs: writeAsUser,
|
|
252
259
|
}, identitySwitchAudit);
|
|
253
260
|
// Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
|
|
254
261
|
// on first actual use, not on every HandlerContext build.
|
|
@@ -289,10 +296,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
289
296
|
const bridge = {
|
|
290
297
|
query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
|
|
291
298
|
queryAs: identitySwitch.queryAs,
|
|
292
|
-
write:
|
|
293
|
-
const res = await executeWrite(ctx, targetType, payload, user, origin, tx, bridgeSink);
|
|
294
|
-
return res;
|
|
295
|
-
},
|
|
299
|
+
write: (targetType, payload) => writeAsUser(user, targetType, payload),
|
|
296
300
|
writeAs: identitySwitch.writeAs,
|
|
297
301
|
// Strict + unsafe share the same runtime — only the type-surface
|
|
298
302
|
// differs. The strict signature is what's exposed to typed callers;
|
|
@@ -545,9 +549,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
545
549
|
tenantTz = cachedTz.value;
|
|
546
550
|
}
|
|
547
551
|
else {
|
|
552
|
+
const generationAtRead = ctx.tenantTimezoneCache.generation();
|
|
548
553
|
tenantTz = await config(TENANT_TIMEZONE_CONFIG_KEY);
|
|
549
554
|
if (tx === undefined) {
|
|
550
|
-
ctx.tenantTimezoneCache.
|
|
555
|
+
ctx.tenantTimezoneCache.setIfFresh(user.tenantId, typeof tenantTz === "string" ? tenantTz : undefined, generationAtRead);
|
|
551
556
|
}
|
|
552
557
|
}
|
|
553
558
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
|
|
1
2
|
import { hasAccess } from "../engine/access.js";
|
|
2
3
|
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
|
|
3
4
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
@@ -39,13 +40,9 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
|
39
40
|
const invalidated = new Promise((resolve) => {
|
|
40
41
|
resolveInvalidated = resolve;
|
|
41
42
|
});
|
|
42
|
-
const streamCredential = {
|
|
43
|
-
...(user.sid !== undefined && { sid: user.sid }),
|
|
44
|
-
...(user.pat?.tokenId !== undefined && { patTokenId: user.pat.tokenId }),
|
|
45
|
-
};
|
|
46
43
|
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
|
|
47
44
|
resolveInvalidated?.();
|
|
48
|
-
},
|
|
45
|
+
}, accessInvalidationCredentialFor(user));
|
|
49
46
|
let iterator;
|
|
50
47
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
51
48
|
// Awaiting `iterator.return()` in that state deadlocks async generators in
|