@cosmicdrift/kumiko-framework 0.364.0 → 0.365.1
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/webhook-step.md +11 -1
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +4 -4
- 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 +29 -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/in-memory-kms-adapter.js +4 -7
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pg-kms-adapter.js +10 -6
- 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 +85 -58
- 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 +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +21 -0
- package/dist/engine/registry.js +2 -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/engine/types/index.d.ts +1 -1
- 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 +2 -0
- package/dist/jobs/job-runner.js +183 -48
- package/dist/jobs/max-per-tenant.d.ts +8 -0
- package/dist/jobs/max-per-tenant.js +14 -0
- 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/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
- package/dist/pipeline/multi-stream-apply-context.js +1 -0
- 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 +323 -0
package/dist/jobs/job-runner.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
|
-
import { Queue, UnrecoverableError, Worker } from "bullmq";
|
|
3
|
+
import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
|
|
4
4
|
import { Redis } from "ioredis";
|
|
5
5
|
import { requestContext } from "../api/request-context.js";
|
|
6
6
|
import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
|
|
@@ -12,13 +12,14 @@ 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";
|
|
19
19
|
import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
|
|
20
20
|
import { bridgeStub } from "../testing/handler-context.js";
|
|
21
21
|
import { cronRepeatOptions } from "./cron-repeat-options.js";
|
|
22
|
+
import { resolveMaxPerTenant } from "./max-per-tenant.js";
|
|
22
23
|
// Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
|
|
23
24
|
// (a security-relevant escape hatch that must fail the run closed), a
|
|
24
25
|
// malformed `_triggerEvent` only costs an idempotency-key convenience — the
|
|
@@ -82,6 +83,18 @@ export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
|
|
|
82
83
|
function schedulerSeenKey(queue) {
|
|
83
84
|
return queue.toKey("kumiko-scheduler-seen");
|
|
84
85
|
}
|
|
86
|
+
// "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
|
|
87
|
+
// their own ids periodically; a single stamp at start() would let a runner
|
|
88
|
+
// restarting during a mixed-version window longer than the grace period delete
|
|
89
|
+
// a scheduler a healthy newer pod still owns.
|
|
90
|
+
async function markSchedulersSeen(queue, schedulerIds) {
|
|
91
|
+
// skip: no cron scheduler on this lane, nothing to stamp
|
|
92
|
+
if (schedulerIds.size === 0)
|
|
93
|
+
return;
|
|
94
|
+
const stamp = String(Date.now());
|
|
95
|
+
const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
|
|
96
|
+
await (await queue.client).hset(schedulerSeenKey(queue), entries);
|
|
97
|
+
}
|
|
85
98
|
// A cron job dropped from the app (or an older scheduler-id format) leaves its
|
|
86
99
|
// scheduler in Redis, where it keeps firing jobs the worker no longer knows
|
|
87
100
|
// ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
|
|
@@ -236,6 +249,9 @@ function tenantFailureMessageKey(err, declaration) {
|
|
|
236
249
|
// Leading underscore matches the existing internal-meta convention
|
|
237
250
|
// (_triggeredById, _tenantId, _payload).
|
|
238
251
|
const TRACE_CONTEXT_KEY = "_traceContext";
|
|
252
|
+
const DEFER_COUNT_KEY = "_deferCount";
|
|
253
|
+
const DEFER_DELAY_CAP_MS = 30_000;
|
|
254
|
+
const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
|
|
239
255
|
function readTraceContext(data) {
|
|
240
256
|
const raw = data[TRACE_CONTEXT_KEY];
|
|
241
257
|
if (!raw || typeof raw !== "object")
|
|
@@ -364,6 +380,7 @@ export function createJobRunner(options) {
|
|
|
364
380
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
365
381
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
366
382
|
const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
|
|
383
|
+
const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
|
|
367
384
|
const redisOpts = parseRedisOpts(redisUrl);
|
|
368
385
|
// Use the context's tracer when present (observability-provider injected at
|
|
369
386
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
@@ -524,6 +541,11 @@ export function createJobRunner(options) {
|
|
|
524
541
|
}
|
|
525
542
|
let worker = null;
|
|
526
543
|
let queueDepthTimer = null;
|
|
544
|
+
let schedulerSeenTimer = null;
|
|
545
|
+
let schedulerStampPending = false;
|
|
546
|
+
// A timed-out gate is abandoned, not aborted (the job context carries no
|
|
547
|
+
// AbortSignal); stop() waits for it so it cannot write after the shutdown.
|
|
548
|
+
const abandonedBootGates = new Set();
|
|
527
549
|
// Forward reference to the runner's own API, exposed on the job-handler ctx
|
|
528
550
|
// so a handler can dispatch a follow-up job (job→job chaining, e.g.
|
|
529
551
|
// delivery.render → delivery.send). Assigned just before return; reads happen
|
|
@@ -537,18 +559,23 @@ export function createJobRunner(options) {
|
|
|
537
559
|
// lane (jobDef.runIn is static), but walking both is cheap and avoids
|
|
538
560
|
// a subtle bug if someone ever reassigns a job to a different lane
|
|
539
561
|
// between deploys while old queue contents are still draining.
|
|
540
|
-
|
|
562
|
+
// `activeOnly` + `self` serve the defer check, which runs inside the job being
|
|
563
|
+
// counted: it must not see waiting jobs or itself, and only counts jobs that
|
|
564
|
+
// became active before it. Without that order, workers that fetch a burst of
|
|
565
|
+
// jobs together all see each other as active and all defer, again and again.
|
|
566
|
+
async function isOverPerTenantLimit(jobName, tenantId, max, scope = {}) {
|
|
541
567
|
const results = await Promise.all([
|
|
542
568
|
queues.api.getActive(),
|
|
543
|
-
queues.api.getWaiting(),
|
|
544
569
|
queues.worker.getActive(),
|
|
545
|
-
queues.worker.getWaiting(),
|
|
570
|
+
...(scope.activeOnly ? [] : [queues.api.getWaiting(), queues.worker.getWaiting()]),
|
|
546
571
|
]);
|
|
547
572
|
let count = 0;
|
|
548
573
|
for (const list of results) {
|
|
549
574
|
for (const j of list) {
|
|
550
575
|
if (j.name !== jobName)
|
|
551
576
|
continue;
|
|
577
|
+
if (scope.self && !isActiveBefore(j, scope.self))
|
|
578
|
+
continue;
|
|
552
579
|
const t = j.data?._tenantId; // @cast-boundary dynamic-key
|
|
553
580
|
if (t === tenantId) {
|
|
554
581
|
count += 1;
|
|
@@ -559,6 +586,22 @@ export function createJobRunner(options) {
|
|
|
559
586
|
}
|
|
560
587
|
return false;
|
|
561
588
|
}
|
|
589
|
+
function isActiveBefore(other, self) {
|
|
590
|
+
if (other.id === self.id)
|
|
591
|
+
return false;
|
|
592
|
+
if (self.processedOn === undefined)
|
|
593
|
+
return true;
|
|
594
|
+
const otherProcessedOn = other.processedOn ?? 0;
|
|
595
|
+
if (otherProcessedOn !== self.processedOn)
|
|
596
|
+
return otherProcessedOn < self.processedOn;
|
|
597
|
+
return (other.id ?? "") < (self.id ?? "");
|
|
598
|
+
}
|
|
599
|
+
// Growing delay bounds Redis and worker load while one tenant has a large
|
|
600
|
+
// backlog: every deferred job would otherwise be re-checked every deferMs.
|
|
601
|
+
function deferDelayMs(deferMs, deferCount) {
|
|
602
|
+
const grown = Math.min(deferMs * 2 ** Math.min(deferCount, DEFER_BACKOFF_MAX_DOUBLINGS), DEFER_DELAY_CAP_MS);
|
|
603
|
+
return grown + Math.floor(Math.random() * deferMs);
|
|
604
|
+
}
|
|
562
605
|
// Only err.message, never payload or the logs array: job payloads can carry PII.
|
|
563
606
|
function logFailedAttempt(entry) {
|
|
564
607
|
errorLogger.error("job failed", {
|
|
@@ -629,7 +672,7 @@ export function createJobRunner(options) {
|
|
|
629
672
|
});
|
|
630
673
|
}
|
|
631
674
|
}
|
|
632
|
-
async function handleJob(bullJob) {
|
|
675
|
+
async function handleJob(bullJob, token) {
|
|
633
676
|
const rawName = bullJob.name;
|
|
634
677
|
// Handle perTenant dispatch jobs — fan out to one job per tenant. The
|
|
635
678
|
// fan-out re-enqueues into the lane the actual job is assigned to;
|
|
@@ -711,6 +754,25 @@ export function createJobRunner(options) {
|
|
|
711
754
|
await reportPreRunFailure(jobName, bullJob, err);
|
|
712
755
|
throw err;
|
|
713
756
|
}
|
|
757
|
+
// maxPerTenant defer: checked before anything is recorded for this run, so a
|
|
758
|
+
// deferred pickup leaves no span, run-log entry or failure trace and does not
|
|
759
|
+
// consume an attempt (moveToDelayed + DelayedError, not a retry).
|
|
760
|
+
const maxPerTenant = resolveMaxPerTenant(jobDef);
|
|
761
|
+
const deferTenantId = bullJob.data["_tenantId"];
|
|
762
|
+
if (maxPerTenant?.onLimit === "defer" &&
|
|
763
|
+
typeof deferTenantId === "string" &&
|
|
764
|
+
token !== undefined &&
|
|
765
|
+
bullJob.moveToDelayed &&
|
|
766
|
+
(await isOverPerTenantLimit(jobName, deferTenantId, maxPerTenant.limit, {
|
|
767
|
+
activeOnly: true,
|
|
768
|
+
self: { id: bullJob.id, processedOn: bullJob.processedOn },
|
|
769
|
+
}))) {
|
|
770
|
+
const priorDeferCount = bullJob.data[DEFER_COUNT_KEY];
|
|
771
|
+
const deferCount = typeof priorDeferCount === "number" ? priorDeferCount : 0;
|
|
772
|
+
await bullJob.updateData?.({ ...bullJob.data, [DEFER_COUNT_KEY]: deferCount + 1 });
|
|
773
|
+
await bullJob.moveToDelayed(Date.now() + deferDelayMs(maxPerTenant.deferMs, deferCount), token);
|
|
774
|
+
throw new DelayedError();
|
|
775
|
+
}
|
|
714
776
|
// Sequential gate: try to claim the per-name lock. If another worker
|
|
715
777
|
// (or this worker on a different bullJob) holds it, re-enqueue with a
|
|
716
778
|
// small delay and exit *successfully* — using throw would burn the
|
|
@@ -943,6 +1005,7 @@ export function createJobRunner(options) {
|
|
|
943
1005
|
attempt,
|
|
944
1006
|
finalAttempt,
|
|
945
1007
|
log: createJobLogger(logs),
|
|
1008
|
+
metricsFor: createMetricsForFactory(context.meter),
|
|
946
1009
|
...(triggerName !== undefined && { triggerName }),
|
|
947
1010
|
...(triggerEvent !== undefined && { triggerEvent }),
|
|
948
1011
|
write: (qn, payload) => {
|
|
@@ -1078,17 +1141,18 @@ export function createJobRunner(options) {
|
|
|
1078
1141
|
});
|
|
1079
1142
|
}, BOOT_GATE_SLOW_WARN_MS)
|
|
1080
1143
|
: undefined;
|
|
1144
|
+
const gateRun = handleJob({
|
|
1145
|
+
id: bootJobIdForJobName(name),
|
|
1146
|
+
name,
|
|
1147
|
+
data: {},
|
|
1148
|
+
attemptsMade: 0,
|
|
1149
|
+
retryable: false,
|
|
1150
|
+
});
|
|
1151
|
+
const gateSettled = gateRun.then(() => undefined, () => undefined);
|
|
1152
|
+
abandonedBootGates.add(gateSettled);
|
|
1153
|
+
void gateSettled.then(() => abandonedBootGates.delete(gateSettled));
|
|
1081
1154
|
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
|
-
]);
|
|
1155
|
+
await Promise.race([gateRun, ...(gateTimeout ? [gateTimeout.promise] : [])]);
|
|
1092
1156
|
}
|
|
1093
1157
|
catch (err) {
|
|
1094
1158
|
// Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
|
|
@@ -1104,6 +1168,74 @@ export function createJobRunner(options) {
|
|
|
1104
1168
|
}
|
|
1105
1169
|
}
|
|
1106
1170
|
}
|
|
1171
|
+
function cronSchedulerIdsForLane(lane) {
|
|
1172
|
+
const ids = new Set();
|
|
1173
|
+
for (const [name, jobDef] of allJobs) {
|
|
1174
|
+
if (laneForJob(jobDef) === lane && "cron" in jobDef.trigger) {
|
|
1175
|
+
ids.add(schedulerIdForJobName(name));
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return ids;
|
|
1179
|
+
}
|
|
1180
|
+
async function upsertCronSchedulers(queue, lane) {
|
|
1181
|
+
for (const [name, jobDef] of allJobs) {
|
|
1182
|
+
if (laneForJob(jobDef) !== lane)
|
|
1183
|
+
continue;
|
|
1184
|
+
if ("cron" in jobDef.trigger) {
|
|
1185
|
+
await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
|
|
1186
|
+
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
1187
|
+
data: {},
|
|
1188
|
+
// Queue-level defaultJobOptions (jobRetentionOpts) covers
|
|
1189
|
+
// retention; a count here would sweep queue-wide again and
|
|
1190
|
+
// evict perTenant children and boot jobs.
|
|
1191
|
+
opts: {
|
|
1192
|
+
...buildRetryBullOpts(jobDef),
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// After a failed initial stamp an older runner may have removed our
|
|
1199
|
+
// schedulers as unseen, so the next tick stamps and re-upserts before the
|
|
1200
|
+
// flag clears.
|
|
1201
|
+
async function refreshSchedulerSeen(queue, lane, schedulerIds) {
|
|
1202
|
+
await markSchedulersSeen(queue, schedulerIds);
|
|
1203
|
+
if (schedulerStampPending) {
|
|
1204
|
+
await upsertCronSchedulers(queue, lane);
|
|
1205
|
+
schedulerStampPending = false;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function startSchedulerSeenRefresh(queue, lane, schedulerIds) {
|
|
1209
|
+
if (schedulerIds.size === 0)
|
|
1210
|
+
return null;
|
|
1211
|
+
const timer = setInterval(() => {
|
|
1212
|
+
refreshSchedulerSeen(queue, lane, schedulerIds).catch((err) => {
|
|
1213
|
+
errorLogger.warn("scheduler seen refresh failed", {
|
|
1214
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
}, schedulerSeenRefreshMs);
|
|
1218
|
+
timer.unref();
|
|
1219
|
+
return timer;
|
|
1220
|
+
}
|
|
1221
|
+
// Bounded: a gate that never returns must not block shutdown as well.
|
|
1222
|
+
async function settleAbandonedBootGates(maxWaitMs) {
|
|
1223
|
+
// skip: no timed-out gate left behind
|
|
1224
|
+
if (abandonedBootGates.size === 0)
|
|
1225
|
+
return;
|
|
1226
|
+
const wait = timeoutReject(maxWaitMs, "job-runner: abandoned boot gate still running at stop()");
|
|
1227
|
+
try {
|
|
1228
|
+
await Promise.race([Promise.all(abandonedBootGates), wait.promise]);
|
|
1229
|
+
}
|
|
1230
|
+
catch (err) {
|
|
1231
|
+
errorLogger.warn("abandoned boot gate did not finish before stop()", {
|
|
1232
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
finally {
|
|
1236
|
+
wait.cancel();
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1107
1239
|
const runnerApi = {
|
|
1108
1240
|
async start() {
|
|
1109
1241
|
// skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
|
|
@@ -1114,7 +1246,7 @@ export function createJobRunner(options) {
|
|
|
1114
1246
|
return;
|
|
1115
1247
|
}
|
|
1116
1248
|
const consumerQueue = queues[consumerLane];
|
|
1117
|
-
worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
|
|
1249
|
+
worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), (job, token) => handleJob(job, token), {
|
|
1118
1250
|
connection: redisOpts,
|
|
1119
1251
|
concurrency: 5,
|
|
1120
1252
|
// Not consuming until the boot gates passed: a job already queued from a
|
|
@@ -1152,28 +1284,18 @@ export function createJobRunner(options) {
|
|
|
1152
1284
|
// Only schedule cron + boot for jobs that belong to this lane. Jobs
|
|
1153
1285
|
// assigned to the other lane get their cron/boot wiring from the
|
|
1154
1286
|
// 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
|
-
}
|
|
1287
|
+
const expectedSchedulerIds = cronSchedulerIdsForLane(consumerLane);
|
|
1288
|
+
// Before the upserts: a seen entry without a scheduler is harmless, a
|
|
1289
|
+
// scheduler a concurrent older runner finds without one gets deleted.
|
|
1290
|
+
// Best-effort like the periodic refresh: a failed stamp must not stop the start.
|
|
1291
|
+
await markSchedulersSeen(consumerQueue, expectedSchedulerIds).catch((err) => {
|
|
1292
|
+
schedulerStampPending = true;
|
|
1293
|
+
errorLogger.warn("scheduler seen stamp failed", {
|
|
1294
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1295
|
+
});
|
|
1296
|
+
});
|
|
1297
|
+
await upsertCronSchedulers(consumerQueue, consumerLane);
|
|
1298
|
+
schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
|
|
1177
1299
|
await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
|
|
1178
1300
|
info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
|
|
1179
1301
|
warn: errorLogger.warn,
|
|
@@ -1221,6 +1343,10 @@ export function createJobRunner(options) {
|
|
|
1221
1343
|
clearInterval(queueDepthTimer);
|
|
1222
1344
|
queueDepthTimer = null;
|
|
1223
1345
|
}
|
|
1346
|
+
if (schedulerSeenTimer) {
|
|
1347
|
+
clearInterval(schedulerSeenTimer);
|
|
1348
|
+
schedulerSeenTimer = null;
|
|
1349
|
+
}
|
|
1224
1350
|
if (worker) {
|
|
1225
1351
|
await worker.close();
|
|
1226
1352
|
worker = null;
|
|
@@ -1230,6 +1356,7 @@ export function createJobRunner(options) {
|
|
|
1230
1356
|
// without listeners -> unhandled rejection (fw#1805). Wait for readiness
|
|
1231
1357
|
// first; a failed/timed-out wait must not block the close itself.
|
|
1232
1358
|
const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
|
|
1359
|
+
await settleAbandonedBootGates(stopReadyTimeoutMs);
|
|
1233
1360
|
const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
|
|
1234
1361
|
try {
|
|
1235
1362
|
await Promise.race([
|
|
@@ -1278,10 +1405,11 @@ export function createJobRunner(options) {
|
|
|
1278
1405
|
// against; without it the guard is inactive (system jobs, ambient
|
|
1279
1406
|
// dispatch). Fan-out children of perTenant jobs land here on their
|
|
1280
1407
|
// recursive queue.add and DO carry _tenantId.
|
|
1281
|
-
|
|
1408
|
+
const maxPerTenant = resolveMaxPerTenant(jobDef);
|
|
1409
|
+
if (maxPerTenant?.onLimit === "skip") {
|
|
1282
1410
|
const tenantId = payload?._tenantId; // @cast-boundary dynamic-key
|
|
1283
1411
|
if (tenantId !== undefined &&
|
|
1284
|
-
(await isOverPerTenantLimit(jobName, tenantId,
|
|
1412
|
+
(await isOverPerTenantLimit(jobName, tenantId, maxPerTenant.limit))) {
|
|
1285
1413
|
return "skipped:max-per-tenant";
|
|
1286
1414
|
}
|
|
1287
1415
|
}
|
|
@@ -1323,6 +1451,8 @@ export function createJobRunner(options) {
|
|
|
1323
1451
|
bullOpts["timeout"] = jobDef.timeout;
|
|
1324
1452
|
if (meta?.priority !== undefined)
|
|
1325
1453
|
bullOpts["priority"] = meta.priority;
|
|
1454
|
+
if (meta?.jobId !== undefined)
|
|
1455
|
+
bullOpts["jobId"] = meta.jobId;
|
|
1326
1456
|
// Pack meta into job data with _ prefix
|
|
1327
1457
|
const data = { ...payload };
|
|
1328
1458
|
if (meta?.triggeredById !== undefined)
|
|
@@ -1384,8 +1514,9 @@ export function createJobRunner(options) {
|
|
|
1384
1514
|
data["_correlationId"] = reqCtx.correlationId;
|
|
1385
1515
|
// Same maxPerTenant guard as dispatch — events that fan into many
|
|
1386
1516
|
// jobs must respect the per-tenant cap or the limit is one-sided.
|
|
1387
|
-
|
|
1388
|
-
|
|
1517
|
+
const maxPerTenant = resolveMaxPerTenant(jobDef);
|
|
1518
|
+
if (maxPerTenant?.onLimit === "skip" && user?.tenantId !== undefined) {
|
|
1519
|
+
if (await isOverPerTenantLimit(name, String(user.tenantId), maxPerTenant.limit)) {
|
|
1389
1520
|
continue;
|
|
1390
1521
|
}
|
|
1391
1522
|
}
|
|
@@ -1402,17 +1533,21 @@ export function createJobRunner(options) {
|
|
|
1402
1533
|
async countPendingJobs() {
|
|
1403
1534
|
let total = 0;
|
|
1404
1535
|
for (const queue of Object.values(queues)) {
|
|
1405
|
-
|
|
1536
|
+
// One getJobCounts call is a single atomic snapshot. Reading delayed in a
|
|
1537
|
+
// second call misses a deferred job that cycles delayed -> active between
|
|
1538
|
+
// the reads, so delayed is counted here and only repeat schedulers (which
|
|
1539
|
+
// stay delayed between cron fires) are subtracted afterwards.
|
|
1540
|
+
const counts = await queue.getJobCounts("waiting", "active", "prioritized", "waiting-children", "delayed");
|
|
1406
1541
|
total +=
|
|
1407
1542
|
(counts["waiting"] ?? 0) +
|
|
1408
1543
|
(counts["active"] ?? 0) +
|
|
1409
1544
|
(counts["prioritized"] ?? 0) +
|
|
1410
|
-
(counts["waiting-children"] ?? 0)
|
|
1411
|
-
|
|
1412
|
-
// loads
|
|
1545
|
+
(counts["waiting-children"] ?? 0) +
|
|
1546
|
+
(counts["delayed"] ?? 0);
|
|
1547
|
+
// getJobs(["delayed"]) loads full Job objects just to read .id; fine for
|
|
1413
1548
|
// test-draining's low volume, not a pattern for a prod-path hot loop.
|
|
1414
1549
|
const delayed = await queue.getJobs(["delayed"]);
|
|
1415
|
-
total
|
|
1550
|
+
total -= delayed.filter((job) => (job.id ?? "").startsWith("repeat:")).length;
|
|
1416
1551
|
}
|
|
1417
1552
|
return total;
|
|
1418
1553
|
},
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { JobDefinition } from "../engine/types/index.js";
|
|
2
|
+
export declare const DEFAULT_MAX_PER_TENANT_DEFER_MS = 1000;
|
|
3
|
+
export type ResolvedMaxPerTenant = {
|
|
4
|
+
readonly limit: number;
|
|
5
|
+
readonly onLimit: "skip" | "defer";
|
|
6
|
+
readonly deferMs: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function resolveMaxPerTenant(jobDef: Pick<JobDefinition, "maxPerTenant">): ResolvedMaxPerTenant | undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const DEFAULT_MAX_PER_TENANT_DEFER_MS = 1_000;
|
|
2
|
+
export function resolveMaxPerTenant(jobDef) {
|
|
3
|
+
const raw = jobDef.maxPerTenant;
|
|
4
|
+
if (raw === undefined)
|
|
5
|
+
return undefined;
|
|
6
|
+
if (typeof raw === "number") {
|
|
7
|
+
return { limit: raw, onLimit: "skip", deferMs: DEFAULT_MAX_PER_TENANT_DEFER_MS };
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
limit: raw.limit,
|
|
11
|
+
onLimit: raw.onLimit,
|
|
12
|
+
deferMs: raw.deferMs ?? DEFAULT_MAX_PER_TENANT_DEFER_MS,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -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>;
|