@cosmicdrift/kumiko-framework 0.353.0 → 0.354.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/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +28 -3
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +2 -6
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +29 -2
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +59 -18
- package/dist/db/event-store-executor-read.js +67 -25
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +11 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +4 -0
- package/dist/db/queries/event-store.js +40 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +32 -3
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +5 -0
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +40 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +98 -7
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +55 -34
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +41 -4
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +2 -3
- package/dist/engine/index.d.ts +8 -2
- package/dist/engine/index.js +6 -1
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/project-app-schema-for-roles.js +3 -1
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.js +45 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.js +11 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-routes.js +10 -4
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +198 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Allowlist: samples/*/migration/, scripts/migrations/, die Definition
|
|
9
9
|
// selbst, das Guard-Script selbst.
|
|
10
10
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
11
|
-
import { claimXactId } from "../db/queries/event-store.js";
|
|
11
|
+
import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
|
|
12
12
|
import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
|
|
13
13
|
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
14
14
|
function mapEventUniqueViolation(e, event) {
|
|
@@ -26,13 +26,15 @@ export async function appendRaw(runner, event) {
|
|
|
26
26
|
try {
|
|
27
27
|
// See db/queries/event-store.ts's claimXactId — gap-finality needs a
|
|
28
28
|
// real xact id assigned before this holder's insert.
|
|
29
|
-
await
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
await runAtomicEventWrite(runner, async (tx) => {
|
|
30
|
+
await claimXactId(tx);
|
|
31
|
+
if (event.expectedVersion === 0) {
|
|
32
|
+
await insertRawFirst(tx, event, newVersion, eventVersion);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
await insertRawSubsequent(tx, event, newVersion, eventVersion);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
36
38
|
}
|
|
37
39
|
catch (e) {
|
|
38
40
|
if (isUniqueViolation(e)) {
|
|
@@ -90,8 +92,10 @@ export async function appendRawBatch(runner, events) {
|
|
|
90
92
|
return `($${baseIdx + 1}::uuid, $${baseIdx + 2}, $${baseIdx + 3}::uuid, $${baseIdx + 4}, $${baseIdx + 5}, $${baseIdx + 6}, $${baseIdx + 7}::jsonb, $${baseIdx + 8}::jsonb, $${baseIdx + 9}::timestamptz, $${baseIdx + 10})`;
|
|
91
93
|
});
|
|
92
94
|
try {
|
|
93
|
-
await
|
|
94
|
-
|
|
95
|
+
await runAtomicEventWrite(runner, async (tx) => {
|
|
96
|
+
await claimXactId(tx);
|
|
97
|
+
await insertRawEventBatch(tx, valuesClauses.join(", "), params);
|
|
98
|
+
});
|
|
95
99
|
}
|
|
96
100
|
catch (e) {
|
|
97
101
|
if (isUniqueViolation(e)) {
|
|
@@ -4,7 +4,7 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
|
4
4
|
import { requestContext } from "../api/request-context.js";
|
|
5
5
|
import { encryptEventPayloadPii } from "../crypto/event-pii.js";
|
|
6
6
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
7
|
-
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
|
|
7
|
+
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
|
|
8
8
|
import { insertOne, selectMany } from "../db/query.js";
|
|
9
9
|
import { isStreamArchived } from "./archive.js";
|
|
10
10
|
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
@@ -50,14 +50,16 @@ export async function append(db, event) {
|
|
|
50
50
|
const newVersion = toStore.expectedVersion + 1;
|
|
51
51
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
52
52
|
try {
|
|
53
|
-
await
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
const row = await runAtomicEventWrite(db, async (runner) => {
|
|
54
|
+
await claimXactId(runner);
|
|
55
|
+
const inserted = toStore.expectedVersion === 0
|
|
56
|
+
? await insertFirstEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt)
|
|
57
|
+
: await insertSubsequentEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt);
|
|
58
|
+
// NOTIFY after the INSERT and inside the same transaction: PG delivers it on
|
|
59
|
+
// commit, so the dispatcher cannot wake before the row is visible.
|
|
60
|
+
await notifyPgChannel(runner, EVENTS_PUBSUB_CHANNEL);
|
|
61
|
+
return inserted;
|
|
62
|
+
});
|
|
61
63
|
return buildStoredEvent(toStore, newVersion, eventVersion, row);
|
|
62
64
|
}
|
|
63
65
|
catch (e) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// sql now comes from native dialect
|
|
2
2
|
import { bigserial, index, instant, integer, jsonb, table as pgTable, sql, text, uniqueIndex, uuid, } from "../db/dialect.js";
|
|
3
3
|
import { tableExists } from "../db/index.js";
|
|
4
|
-
import { ensureIdempotencyKeyIndex } from "../db/queries/event-store.js";
|
|
4
|
+
import { ensureIdempotencyKeyIndex, ensureMembershipPayloadTenantIndex, } from "../db/queries/event-store.js";
|
|
5
5
|
import { unsafePushTables } from "../stack/index.js";
|
|
6
6
|
import { createArchivedStreamsTable } from "./archive.js";
|
|
7
7
|
import { createSnapshotsTable } from "./snapshot.js";
|
|
@@ -61,6 +61,7 @@ export async function createEventsTable(db) {
|
|
|
61
61
|
// that predate the idempotency-key index get healed the same way
|
|
62
62
|
// ensureSnapshotVersionColumn heals kumiko_snapshots.
|
|
63
63
|
await ensureIdempotencyKeyIndex(db);
|
|
64
|
+
await ensureMembershipPayloadTenantIndex(db);
|
|
64
65
|
await createArchivedStreamsTable(db);
|
|
65
66
|
await createSnapshotsTable(db);
|
|
66
67
|
}
|
|
@@ -2,6 +2,6 @@ import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-han
|
|
|
2
2
|
import type { FileStorageProvider } from "./types.js";
|
|
3
3
|
export type { FileContext, FileHandle };
|
|
4
4
|
export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
|
|
5
|
-
export declare function createFileContext(resolve: () => Promise<FileStorageProvider
|
|
5
|
+
export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
|
|
6
6
|
export declare function deriveKey(key: string, suffix: string): string;
|
|
7
7
|
export declare function storageKeyStemPrefix(key: string): string;
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
// the bytes, and big files stay in the storage layer where they belong.
|
|
8
8
|
//
|
|
9
9
|
// `derive(suffix)` is the primitive for thumbnail/variant keys: it inserts a
|
|
10
|
-
// suffix before the file extension — `foo/bar.jpg` + `"medium"` →
|
|
11
|
-
// `foo/bar.medium
|
|
10
|
+
// suffix before the file extension — `foo/bar.jpg` + `"medium-<16 hex>"` →
|
|
11
|
+
// `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
|
|
12
|
+
// the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
|
|
13
|
+
import { isDerivativeSuffix } from "../derivatives/variant-key.js";
|
|
12
14
|
// `getProvider` is a lazily-resolved, memoized accessor — the provider is
|
|
13
15
|
// resolved (config + s3.secretAccessKey secret read) only when a handle method
|
|
14
16
|
// actually does I/O, never on every request that merely builds a ctx.
|
|
@@ -19,7 +21,15 @@ export function createFileHandle(key, getProvider) {
|
|
|
19
21
|
write: async (data, mimeType) => (await getProvider()).write(key, data, mimeType),
|
|
20
22
|
delete: async () => (await getProvider()).delete(key),
|
|
21
23
|
exists: async () => (await getProvider()).exists(key),
|
|
22
|
-
derive: (suffix) =>
|
|
24
|
+
derive: (suffix) => {
|
|
25
|
+
// A suffix outside the derivative grammar would write a key that the
|
|
26
|
+
// forget/tenant-destroy sweep (isDerivativeKeyOf) can never recognize,
|
|
27
|
+
// so the binary would survive erasure.
|
|
28
|
+
if (!isDerivativeSuffix(suffix)) {
|
|
29
|
+
throw new Error(`FileHandle.derive: suffix "${suffix}" must be <name>-<16 hex chars>; build it with variantSuffix(name, spec)`);
|
|
30
|
+
}
|
|
31
|
+
return createFileHandle(deriveKey(key, suffix), getProvider);
|
|
32
|
+
},
|
|
23
33
|
};
|
|
24
34
|
}
|
|
25
35
|
// `resolve` is a tenant-bound thunk (the caller binds the tenant). The result
|
|
@@ -27,15 +37,29 @@ export function createFileHandle(key, getProvider) {
|
|
|
27
37
|
// produces — never a process-global cache, since each FileContext is bound to a
|
|
28
38
|
// single request/event tenant; sharing would leak one tenant's provider (and
|
|
29
39
|
// its bucket/credentials) to another.
|
|
30
|
-
|
|
40
|
+
//
|
|
41
|
+
// With `tenantId`, `ref` and `list` only accept keys/prefixes inside `${tenantId}/` —
|
|
42
|
+
// the key space is the isolation boundary in a store shared by several tenants, and
|
|
43
|
+
// a request-derived key or prefix (or "") would otherwise reach every tenant's keys.
|
|
44
|
+
export function createFileContext(resolve, tenantId) {
|
|
31
45
|
let cached;
|
|
32
46
|
const getProvider = () => {
|
|
33
47
|
cached ??= resolve();
|
|
34
48
|
return cached;
|
|
35
49
|
};
|
|
36
50
|
return {
|
|
37
|
-
ref: (key) =>
|
|
38
|
-
|
|
51
|
+
ref: (key) => {
|
|
52
|
+
if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
|
|
53
|
+
throw new Error(`FileContext.ref: key must start with "${tenantId}/"`);
|
|
54
|
+
}
|
|
55
|
+
return createFileHandle(key, getProvider);
|
|
56
|
+
},
|
|
57
|
+
list: async (prefix) => {
|
|
58
|
+
if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
|
|
59
|
+
throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
|
|
60
|
+
}
|
|
61
|
+
return (await getProvider()).list(prefix);
|
|
62
|
+
},
|
|
39
63
|
};
|
|
40
64
|
}
|
|
41
65
|
// Splits a key into the part before the last dot of its last path segment
|
|
@@ -13,7 +13,7 @@ import { buildContentDispositionHeader } from "./content-disposition.js";
|
|
|
13
13
|
import { createFileContext } from "./file-handle.js";
|
|
14
14
|
import { fileRefEntity } from "./file-ref-entity.js";
|
|
15
15
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
16
|
-
import { resolveContentType } from "./resolve-content-type.js";
|
|
16
|
+
import { neutralFileNameForMimeType, resolveContentType } from "./resolve-content-type.js";
|
|
17
17
|
import { buildStorageKey, parseMaxSize, resolveServedContentType, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
|
|
18
18
|
const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
|
|
19
19
|
// 15 minutes — long enough for a download to start, short enough that a
|
|
@@ -97,6 +97,7 @@ export function createFileRoutes(options) {
|
|
|
97
97
|
const api = new Hono();
|
|
98
98
|
const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
|
|
99
99
|
const tenantDbFor = (tenantId) => createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionGrants);
|
|
100
|
+
const fileNameIsPersonal = piiSubjectFields.includes("fileName");
|
|
100
101
|
function kmsContextFor() {
|
|
101
102
|
return { requestId: requestContext.get()?.requestId ?? "file-routes" };
|
|
102
103
|
}
|
|
@@ -311,7 +312,7 @@ export function createFileRoutes(options) {
|
|
|
311
312
|
}
|
|
312
313
|
// Built per request: createFileContext caches the resolved provider, so
|
|
313
314
|
// one shared across requests would serve tenant A's store to tenant B.
|
|
314
|
-
const files = createFileContext(() => options.resolveProvider(user.tenantId));
|
|
315
|
+
const files = createFileContext(() => options.resolveProvider(user.tenantId), user.tenantId);
|
|
315
316
|
const derivatives = createDerivativesContext({
|
|
316
317
|
files,
|
|
317
318
|
registry,
|
|
@@ -397,10 +398,15 @@ export function createFileRoutes(options) {
|
|
|
397
398
|
const expiresInSeconds = SIGNED_URL_DEFAULT_EXPIRY_SECONDS;
|
|
398
399
|
const url = await storageProvider.getSignedUrl(fileRef.storageKey, expiresInSeconds, {
|
|
399
400
|
// Hint the provider to set Content-Disposition so the browser prompts
|
|
400
|
-
// with
|
|
401
|
+
// with a readable filename instead of the UUID-based storage key.
|
|
401
402
|
// Sanitised via buildContentDispositionHeader — the same attacker-
|
|
402
403
|
// controlled fileName reaches the provider's presigned response.
|
|
403
|
-
|
|
404
|
+
// A personal fileName never goes into the URL: the signed query string
|
|
405
|
+
// lands in storage access logs and proxies and outlives a crypto-shred.
|
|
406
|
+
// The streaming route keeps the real name in a response header only.
|
|
407
|
+
contentDisposition: buildContentDispositionHeader(fileNameIsPersonal
|
|
408
|
+
? neutralFileNameForMimeType(fileRef.mimeType)
|
|
409
|
+
: await resolveFileName(fileRef)),
|
|
404
410
|
});
|
|
405
411
|
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
|
406
412
|
return c.json({ url, expiresAt });
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** A name derived from the MIME type alone, for places a personal file name must not appear. */
|
|
2
|
+
export declare function neutralFileNameForMimeType(mimeType: string): string;
|
|
1
3
|
/**
|
|
2
4
|
* Content type for a file whose sender may not have declared a useful one
|
|
3
5
|
* (mail attachments commonly arrive as application/octet-stream). A concrete
|
|
@@ -26,6 +26,14 @@ const EXTENSION_FALLBACK = {
|
|
|
26
26
|
csv: "text/csv",
|
|
27
27
|
txt: "text/plain",
|
|
28
28
|
};
|
|
29
|
+
const EXTENSION_BY_MIME_TYPE = new Map(Object.entries(EXTENSION_FALLBACK)
|
|
30
|
+
.reverse()
|
|
31
|
+
.map(([extension, mime]) => [mime, extension]));
|
|
32
|
+
/** A name derived from the MIME type alone, for places a personal file name must not appear. */
|
|
33
|
+
export function neutralFileNameForMimeType(mimeType) {
|
|
34
|
+
const extension = EXTENSION_BY_MIME_TYPE.get(normalizeMimeType(mimeType));
|
|
35
|
+
return extension === undefined ? "download" : `download.${extension}`;
|
|
36
|
+
}
|
|
29
37
|
function hasPrefix(bytes, prefix) {
|
|
30
38
|
return prefix.every((byte, i) => bytes[i] === byte);
|
|
31
39
|
}
|
|
@@ -10,6 +10,8 @@ export declare const PROJECTION_DETAIL_ENTITY = "__projection-detail__";
|
|
|
10
10
|
* drawn from the host record's display entity). */
|
|
11
11
|
export declare const WRITE_FORM_SECTION_ENTITY = "__write-form-section__";
|
|
12
12
|
export declare function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string;
|
|
13
|
+
export declare function handlerTitleKey(handlerQn: string): string;
|
|
14
|
+
export declare function handlerFieldLabelKey(handlerQn: string, fieldName: string): string;
|
|
13
15
|
export declare function booleanFacetOptionKeys(featureName: string, entityName: string, fieldName: string): readonly string[];
|
|
14
16
|
export declare function selectFacetOptionKey(featureName: string, entityName: string, fieldName: string, value: string): string;
|
|
15
17
|
export declare function screenTitleKey(screenId: string): string;
|
|
@@ -13,6 +13,12 @@ export const WRITE_FORM_SECTION_ENTITY = "__write-form-section__";
|
|
|
13
13
|
export function fieldLabelKey(featureName, entityName, fieldName) {
|
|
14
14
|
return `${featureName}:entity:${entityName}:field:${fieldName}`;
|
|
15
15
|
}
|
|
16
|
+
export function handlerTitleKey(handlerQn) {
|
|
17
|
+
return `${handlerQn}:title`;
|
|
18
|
+
}
|
|
19
|
+
export function handlerFieldLabelKey(handlerQn, fieldName) {
|
|
20
|
+
return `${handlerQn}:field:${fieldName}`;
|
|
21
|
+
}
|
|
16
22
|
export function booleanFacetOptionKeys(featureName, entityName, fieldName) {
|
|
17
23
|
const base = `${featureName}:entity:${entityName}:field:${fieldName}:option`;
|
|
18
24
|
return [`${base}:true`, `${base}:false`];
|
|
@@ -116,6 +122,8 @@ function pushDashboardPanelKeys(out, panel) {
|
|
|
116
122
|
function pushDashboardChartRangeKeys(out, panel) {
|
|
117
123
|
for (const option of panel.ranges?.options ?? [])
|
|
118
124
|
pushKey(out, option.label);
|
|
125
|
+
for (const kind of Object.values(panel.markerKinds ?? {}))
|
|
126
|
+
pushKey(out, kind.label);
|
|
119
127
|
}
|
|
120
128
|
function pushDashboardFilterKeys(out, filter) {
|
|
121
129
|
pushKey(out, filter.label);
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
1
|
+
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
2
2
|
export { createJobRunner, serializeJobSubject } from "./job-runner.js";
|
|
@@ -19,6 +19,14 @@ export type JobMeta = {
|
|
|
19
19
|
payload?: string | undefined;
|
|
20
20
|
attempt?: number | undefined;
|
|
21
21
|
priority?: number | undefined;
|
|
22
|
+
tenantVisibleRun?: {
|
|
23
|
+
readonly tenantId: string;
|
|
24
|
+
readonly subject: string | null;
|
|
25
|
+
} | undefined;
|
|
26
|
+
};
|
|
27
|
+
export type JobQueuedMeta = {
|
|
28
|
+
readonly tenantId: string;
|
|
29
|
+
readonly subject: string | null;
|
|
22
30
|
};
|
|
23
31
|
export type JobOutcomeMeta = {
|
|
24
32
|
readonly tenantId: string;
|
|
@@ -27,6 +35,9 @@ export type JobOutcomeMeta = {
|
|
|
27
35
|
readonly subject: string | null;
|
|
28
36
|
readonly messageKey: string | null;
|
|
29
37
|
} | undefined;
|
|
38
|
+
readonly tenantVisibleRun?: {
|
|
39
|
+
readonly subject: string | null;
|
|
40
|
+
} | undefined;
|
|
30
41
|
};
|
|
31
42
|
export type JobSubjectValue = string | number | boolean | null;
|
|
32
43
|
export declare function serializeJobSubject(subject: Readonly<Record<string, JobSubjectValue>>): string;
|
|
@@ -53,5 +64,7 @@ export type JobRunnerOptions = {
|
|
|
53
64
|
onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
|
|
54
65
|
onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
|
|
55
66
|
onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
|
|
67
|
+
onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
|
|
68
|
+
onJobDropped?: (jobName: string, jobId: string) => void;
|
|
56
69
|
};
|
|
57
70
|
export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -134,14 +134,14 @@ export function serializeJobSubject(subject) {
|
|
|
134
134
|
// with the same subject values produce the same string and the later one
|
|
135
135
|
// replaces the earlier record. Non-primitives would serialize into something
|
|
136
136
|
// no caller can reconstruct for a lookup, so they fail the run loudly.
|
|
137
|
-
function jobSubjectKey(jobName, payload, fields) {
|
|
137
|
+
function jobSubjectKey(jobName, payload, fields, declaration) {
|
|
138
138
|
if (fields === undefined || fields.length === 0)
|
|
139
139
|
return null;
|
|
140
140
|
const subject = {};
|
|
141
141
|
for (const field of fields) {
|
|
142
142
|
const value = payload[field] ?? null;
|
|
143
143
|
if (value !== null && typeof value === "object") {
|
|
144
|
-
throw new Error(`Job "${jobName}":
|
|
144
|
+
throw new Error(`Job "${jobName}": ${declaration}.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
|
|
145
145
|
}
|
|
146
146
|
subject[field] = value;
|
|
147
147
|
}
|
|
@@ -516,12 +516,72 @@ export function createJobRunner(options) {
|
|
|
516
516
|
}
|
|
517
517
|
return false;
|
|
518
518
|
}
|
|
519
|
+
// Only err.message, never payload or the logs array: job payloads can carry PII.
|
|
520
|
+
function logFailedAttempt(entry) {
|
|
521
|
+
errorLogger.error("job failed", {
|
|
522
|
+
job: entry.job,
|
|
523
|
+
jobId: entry.jobId,
|
|
524
|
+
attempt: entry.attempt,
|
|
525
|
+
// BullMQ never retries an UnrecoverableError, whatever attempts remain.
|
|
526
|
+
final: entry.finalAttempt || entry.err instanceof UnrecoverableError,
|
|
527
|
+
error: entry.err instanceof Error ? entry.err.message : String(entry.err),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
519
530
|
async function reportPreRunFailure(jobName, bullJob, err) {
|
|
520
531
|
const message = err instanceof Error ? err.message : String(err);
|
|
521
532
|
await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
|
|
522
533
|
{ level: "error", message, timestamp: Temporal.Now.instant() },
|
|
523
534
|
]);
|
|
524
535
|
}
|
|
536
|
+
function handlerPayloadOf(data) {
|
|
537
|
+
const payload = {};
|
|
538
|
+
for (const [k, v] of Object.entries(data)) {
|
|
539
|
+
if (!k.startsWith("_"))
|
|
540
|
+
payload[k] = v;
|
|
541
|
+
}
|
|
542
|
+
return payload;
|
|
543
|
+
}
|
|
544
|
+
function tenantIdOfJobData(data) {
|
|
545
|
+
return (data["_tenantId"] ?? // @cast-boundary dynamic-key
|
|
546
|
+
data["tenantId"] ?? // @cast-boundary dynamic-key
|
|
547
|
+
SYSTEM_TENANT_ID);
|
|
548
|
+
}
|
|
549
|
+
// Tenant-visible queued state. A callback or subject problem must never fail
|
|
550
|
+
// the enqueue; a bad subject config surfaces as a failed run instead.
|
|
551
|
+
async function announceQueued(jobName, jobDef, bullJobId, data) {
|
|
552
|
+
const declaration = jobDef.tenantVisibleRun;
|
|
553
|
+
// skip: job did not opt in, no logger wired, or BullMQ returned no id
|
|
554
|
+
if (!declaration || !options.onJobQueued || bullJobId === undefined)
|
|
555
|
+
return;
|
|
556
|
+
const tenantId = tenantIdOfJobData(data);
|
|
557
|
+
// skip: tenant-less run — no tenant-scoped query could ever read it
|
|
558
|
+
if (tenantId === SYSTEM_TENANT_ID)
|
|
559
|
+
return;
|
|
560
|
+
try {
|
|
561
|
+
const subject = jobSubjectKey(jobName, handlerPayloadOf(data), declaration.subjectFields, "tenantVisibleRun");
|
|
562
|
+
await options.onJobQueued(jobName, bullJobId, { tenantId, subject });
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
errorLogger.warn("tenant-visible queued state not recorded", {
|
|
566
|
+
job: jobName,
|
|
567
|
+
error: err instanceof Error ? err.message : String(err),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async function announceDropped(jobName, jobDef, bullJobId) {
|
|
572
|
+
// skip: job did not opt in or no logger wired
|
|
573
|
+
if (!jobDef.tenantVisibleRun || !options.onJobDropped)
|
|
574
|
+
return;
|
|
575
|
+
try {
|
|
576
|
+
await options.onJobDropped(jobName, bullJobId);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
errorLogger.warn("tenant-visible queued state not cleared", {
|
|
580
|
+
job: jobName,
|
|
581
|
+
error: err instanceof Error ? err.message : String(err),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
525
585
|
async function handleJob(bullJob) {
|
|
526
586
|
const rawName = bullJob.name;
|
|
527
587
|
// Handle perTenant dispatch jobs — fan out to one job per tenant. The
|
|
@@ -556,7 +616,8 @@ export function createJobRunner(options) {
|
|
|
556
616
|
// for that edge case instead: enqueue plainly, same as before.
|
|
557
617
|
const wrapperJobId = bullJob.id;
|
|
558
618
|
for (const tenantId of tenantIds) {
|
|
559
|
-
|
|
619
|
+
const childData = { ...bullJob.data, _tenantId: tenantId };
|
|
620
|
+
const child = await targetQueue.add(actualName, childData, {
|
|
560
621
|
...buildRetryBullOpts(actualDef),
|
|
561
622
|
// Dedup over wrapper retries only holds as long as the children
|
|
562
623
|
// stay in Redis for the wrapper's whole retry window — see the
|
|
@@ -565,13 +626,22 @@ export function createJobRunner(options) {
|
|
|
565
626
|
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
566
627
|
: {}),
|
|
567
628
|
});
|
|
629
|
+
await announceQueued(actualName, actualDef, child.id, childData);
|
|
568
630
|
}
|
|
569
631
|
}
|
|
570
632
|
catch (err) {
|
|
571
633
|
// A retry may still succeed, and a succeeding wrapper reports no completion
|
|
572
634
|
// that could clear an earlier failure, so only the final attempt is reported.
|
|
573
635
|
const retries = allJobs.get(actualName)?.retries ?? 0;
|
|
574
|
-
|
|
636
|
+
const finalAttempt = bullJob.attemptsMade + 1 >= retries + 1;
|
|
637
|
+
logFailedAttempt({
|
|
638
|
+
job: rawName,
|
|
639
|
+
jobId: bullJob.id ?? "unknown",
|
|
640
|
+
attempt: bullJob.attemptsMade + 1,
|
|
641
|
+
finalAttempt,
|
|
642
|
+
err,
|
|
643
|
+
});
|
|
644
|
+
if (finalAttempt) {
|
|
575
645
|
await reportPreRunFailure(rawName, bullJob, err);
|
|
576
646
|
}
|
|
577
647
|
throw err;
|
|
@@ -583,6 +653,14 @@ export function createJobRunner(options) {
|
|
|
583
653
|
const jobDef = allJobs.get(jobName);
|
|
584
654
|
if (!jobDef) {
|
|
585
655
|
const err = new Error(`Unknown job: ${jobName}`);
|
|
656
|
+
// Final regardless of attempts left: a retry cannot make the job exist.
|
|
657
|
+
logFailedAttempt({
|
|
658
|
+
job: jobName,
|
|
659
|
+
jobId: bullJob.id ?? "unknown",
|
|
660
|
+
attempt: bullJob.attemptsMade + 1,
|
|
661
|
+
finalAttempt: true,
|
|
662
|
+
err,
|
|
663
|
+
});
|
|
586
664
|
await reportPreRunFailure(jobName, bullJob, err);
|
|
587
665
|
throw err;
|
|
588
666
|
}
|
|
@@ -605,10 +683,15 @@ export function createJobRunner(options) {
|
|
|
605
683
|
// Attempts already spent travel in _priorAttempts so a lock conflict
|
|
606
684
|
// on a retry cannot hand out a fresh budget and exceed `retries`.
|
|
607
685
|
const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
|
|
608
|
-
|
|
686
|
+
const requeuedData = { ...bullJob.data, _priorAttempts: priorAttempts };
|
|
687
|
+
const requeued = await queues[laneForJob(jobDef)].add(jobName, requeuedData, {
|
|
609
688
|
delay: SEQUENTIAL_RETRY_DELAY_MS,
|
|
610
689
|
...buildRetryBullOpts(jobDef, priorAttempts),
|
|
611
690
|
});
|
|
691
|
+
if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
|
|
692
|
+
await announceDropped(jobName, jobDef, bullJob.id);
|
|
693
|
+
}
|
|
694
|
+
await announceQueued(jobName, jobDef, requeued.id, requeuedData);
|
|
612
695
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
613
696
|
return;
|
|
614
697
|
}
|
|
@@ -629,27 +712,31 @@ export function createJobRunner(options) {
|
|
|
629
712
|
attempt,
|
|
630
713
|
};
|
|
631
714
|
// Build handler payload (without internal meta fields)
|
|
632
|
-
const payload =
|
|
633
|
-
for (const [k, v] of Object.entries(rawData)) {
|
|
634
|
-
if (!k.startsWith("_"))
|
|
635
|
-
payload[k] = v;
|
|
636
|
-
}
|
|
715
|
+
const payload = handlerPayloadOf(rawData);
|
|
637
716
|
// Determine tenantId and triggeredBy from meta
|
|
638
|
-
const tenantId = rawData
|
|
639
|
-
payload["tenantId"] ?? // @cast-boundary dynamic-key
|
|
640
|
-
SYSTEM_TENANT_ID;
|
|
717
|
+
const tenantId = tenantIdOfJobData(rawData);
|
|
641
718
|
const triggeredById = rawData["_triggeredById"] ?? null; // @cast-boundary dynamic-key
|
|
642
719
|
// Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
|
|
643
720
|
// subject is read from the handler payload, so it is resolved here where
|
|
644
721
|
// the payload is built, not in the callbacks.
|
|
645
722
|
const tenantVisibleDecl = jobDef.tenantVisibleFailure;
|
|
723
|
+
const tenantVisibleRunDecl = jobDef.tenantVisibleRun;
|
|
646
724
|
// A bad subject config must surface as a failed run, so it is thrown from
|
|
647
725
|
// inside runInSpan below instead of here, before any run record exists.
|
|
648
726
|
let tenantVisibleSubject = null;
|
|
727
|
+
let tenantVisibleRunSubject = null;
|
|
649
728
|
let subjectConfigError;
|
|
650
|
-
if (
|
|
729
|
+
if (tenantVisibleRunDecl) {
|
|
651
730
|
try {
|
|
652
|
-
|
|
731
|
+
tenantVisibleRunSubject = jobSubjectKey(jobName, payload, tenantVisibleRunDecl.subjectFields, "tenantVisibleRun");
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
subjectConfigError = err instanceof Error ? err : new Error(String(err));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (tenantVisibleDecl && !subjectConfigError) {
|
|
738
|
+
try {
|
|
739
|
+
tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields, "tenantVisibleFailure");
|
|
653
740
|
}
|
|
654
741
|
catch (err) {
|
|
655
742
|
subjectConfigError = err instanceof Error ? err : new Error(String(err));
|
|
@@ -658,13 +745,16 @@ export function createJobRunner(options) {
|
|
|
658
745
|
// BullMQ stops retrying once attemptsMade reaches the configured attempts
|
|
659
746
|
// (`retries + 1`), so this is the attempt whose failure is final.
|
|
660
747
|
const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
|
|
661
|
-
const outcomeMeta = (messageKey) => ({
|
|
748
|
+
const outcomeMeta = (messageKey, unrecoverable = false) => ({
|
|
662
749
|
tenantId,
|
|
663
|
-
|
|
750
|
+
// BullMQ never retries an UnrecoverableError, whatever attempts are left.
|
|
751
|
+
finalAttempt: finalAttempt || unrecoverable,
|
|
664
752
|
...(tenantVisibleDecl &&
|
|
665
753
|
!subjectConfigError && {
|
|
666
754
|
tenantVisible: { subject: tenantVisibleSubject, messageKey },
|
|
667
755
|
}),
|
|
756
|
+
...(tenantVisibleRunDecl &&
|
|
757
|
+
!subjectConfigError && { tenantVisibleRun: { subject: tenantVisibleRunSubject } }),
|
|
668
758
|
});
|
|
669
759
|
// Carry `_triggerName` from rawData when set — handleEvent injects it on
|
|
670
760
|
// multi-trigger dispatch; exposed as jobContext.triggerName so handlers
|
|
@@ -693,7 +783,9 @@ export function createJobRunner(options) {
|
|
|
693
783
|
// through the same _fileProviderResolver for jobs as for write-handlers,
|
|
694
784
|
// otherwise event-triggered jobs silently get an unresolved ctx.files.
|
|
695
785
|
const fileResolver = context._fileProviderResolver;
|
|
696
|
-
const files = fileResolver
|
|
786
|
+
const files = fileResolver
|
|
787
|
+
? createFileContext(() => fileResolver(tenantId), tenantId)
|
|
788
|
+
: context.files;
|
|
697
789
|
const jobSystemUser = createSystemUser(tenantId);
|
|
698
790
|
// Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
|
|
699
791
|
// must resolve for jobs the same way they do for write-handlers, or an
|
|
@@ -813,6 +905,9 @@ export function createJobRunner(options) {
|
|
|
813
905
|
return memberReader(userId, qn, payload);
|
|
814
906
|
},
|
|
815
907
|
};
|
|
908
|
+
if (tenantVisibleRunDecl && !subjectConfigError) {
|
|
909
|
+
meta.tenantVisibleRun = { tenantId, subject: tenantVisibleRunSubject };
|
|
910
|
+
}
|
|
816
911
|
await options.onJobStart?.(jobName, jobId, meta);
|
|
817
912
|
// Cross-process trace continuation: if the enqueuing code captured a
|
|
818
913
|
// parent span, start the job.execute span as its child. Works for event
|
|
@@ -865,7 +960,8 @@ export function createJobRunner(options) {
|
|
|
865
960
|
catch (err) {
|
|
866
961
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
867
962
|
logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
|
|
868
|
-
|
|
963
|
+
logFailedAttempt({ job: jobName, jobId, attempt, finalAttempt, err });
|
|
964
|
+
await options.onJobFailed?.(jobName, jobId, errorMsg, logs, outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl), err instanceof UnrecoverableError));
|
|
869
965
|
throw err;
|
|
870
966
|
}
|
|
871
967
|
};
|
|
@@ -938,8 +1034,7 @@ export function createJobRunner(options) {
|
|
|
938
1034
|
]);
|
|
939
1035
|
}
|
|
940
1036
|
catch (err) {
|
|
941
|
-
//
|
|
942
|
-
// open would keep the event loop alive and turn the aborted boot into
|
|
1037
|
+
// Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
|
|
943
1038
|
// a hanging process instead of a non-zero exit.
|
|
944
1039
|
await worker?.close();
|
|
945
1040
|
worker = null;
|
|
@@ -965,6 +1060,9 @@ export function createJobRunner(options) {
|
|
|
965
1060
|
worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
|
|
966
1061
|
connection: redisOpts,
|
|
967
1062
|
concurrency: 5,
|
|
1063
|
+
// Not consuming until the boot gates passed: a job already queued from a
|
|
1064
|
+
// previous run must not execute against an unmet boot precondition.
|
|
1065
|
+
autorun: false,
|
|
968
1066
|
});
|
|
969
1067
|
worker.on("error", (err) => {
|
|
970
1068
|
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
@@ -989,6 +1087,11 @@ export function createJobRunner(options) {
|
|
|
989
1087
|
// Ahead of cron/runOnBoot wiring on purpose: a failed gate must not
|
|
990
1088
|
// leave recurring schedulers behind.
|
|
991
1089
|
await runBootGates(consumerLane);
|
|
1090
|
+
// Floating on purpose: run() resolves only when the worker closes.
|
|
1091
|
+
void worker.run().catch((err) => {
|
|
1092
|
+
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
1093
|
+
log("worker run loop failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1094
|
+
});
|
|
992
1095
|
// Only schedule cron + boot for jobs that belong to this lane. Jobs
|
|
993
1096
|
// assigned to the other lane get their cron/boot wiring from the
|
|
994
1097
|
// runner running on that lane. Running both here would double-fire.
|
|
@@ -1142,6 +1245,7 @@ export function createJobRunner(options) {
|
|
|
1142
1245
|
for (const j of waiting) {
|
|
1143
1246
|
if (j.name === jobName && j.id) {
|
|
1144
1247
|
await j.remove();
|
|
1248
|
+
await announceDropped(jobName, jobDef, j.id);
|
|
1145
1249
|
}
|
|
1146
1250
|
}
|
|
1147
1251
|
break;
|
|
@@ -1182,6 +1286,7 @@ export function createJobRunner(options) {
|
|
|
1182
1286
|
data["_correlationId"] = reqCtx.correlationId;
|
|
1183
1287
|
stampGatedWriteOrigin(data);
|
|
1184
1288
|
const job = await targetQueue.add(jobName, data, bullOpts);
|
|
1289
|
+
await announceQueued(jobName, jobDef, job.id, data);
|
|
1185
1290
|
return job.id ?? "unknown";
|
|
1186
1291
|
},
|
|
1187
1292
|
async handleEvent(eventName, payload, user, triggerEvent) {
|
|
@@ -1230,7 +1335,8 @@ export function createJobRunner(options) {
|
|
|
1230
1335
|
stampGatedWriteOrigin(data);
|
|
1231
1336
|
// Route to the job's declared lane, not a fixed queue — that's
|
|
1232
1337
|
// the whole reason both queues are held.
|
|
1233
|
-
await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
|
|
1338
|
+
const job = await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
|
|
1339
|
+
await announceQueued(name, jobDef, job.id, data);
|
|
1234
1340
|
}
|
|
1235
1341
|
},
|
|
1236
1342
|
attachDispatcher(ref) {
|