@cosmicdrift/kumiko-framework 0.333.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/eagerload.js +2 -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/config-deps.js +13 -0
- 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 +44 -4
- 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 +138 -36
- 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 +65 -19
- package/dist/engine/build-config-feature-schema.js +106 -22
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/config-helpers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +7 -2
- 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/feature-entity-handlers.js +22 -0
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +40 -0
- package/dist/engine/index.d.ts +5 -4
- package/dist/engine/index.js +4 -3
- 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 +2 -0
- package/dist/engine/schema-builder.js +20 -3
- 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/types/index.d.ts +1 -1
- 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-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- 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 +28 -35
- package/dist/i18n/settings-hub-keys.js +21 -4
- 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-batch.js +8 -3
- 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 +27 -7
- package/dist/pipeline/dispatcher-utils.js +4 -1
- 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/pipeline/write-result-masking.d.ts +6 -0
- package/dist/pipeline/write-result-masking.js +36 -0
- 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/shared-entities.d.ts +10 -0
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +2 -2
- 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 +218 -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
|
@@ -50,8 +50,27 @@ export async function createFileProviderForTenant(ctx, tenantId, handlerName = "
|
|
|
50
50
|
throw new Error(`${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
|
|
51
51
|
`extension options must be a FileProviderPlugin.`);
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
const built = await usage.options.build(ctx, tenantId);
|
|
54
|
+
// Extension payloads are never type-checked, so a plugin predating a
|
|
55
|
+
// required method (list) would otherwise surface as a misleading
|
|
56
|
+
// "missing s3:ListBucket permission" hint during erasure.
|
|
57
|
+
for (const method of REQUIRED_PROVIDER_METHODS) {
|
|
58
|
+
if (typeof built[method] !== "function") {
|
|
59
|
+
throw new Error(`${FEATURE_NAME}: provider "${provider}" built without the required method ${method}() — ` +
|
|
60
|
+
`update the file-provider plugin to the current FileStorageProvider contract.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return built;
|
|
54
64
|
}
|
|
65
|
+
const REQUIRED_PROVIDER_METHODS = [
|
|
66
|
+
"write",
|
|
67
|
+
"writeStream",
|
|
68
|
+
"read",
|
|
69
|
+
"readStream",
|
|
70
|
+
"delete",
|
|
71
|
+
"exists",
|
|
72
|
+
"list",
|
|
73
|
+
];
|
|
55
74
|
// Builds the resolver from the ambient AppContext fields — the framework-side
|
|
56
75
|
// equivalent of the GDPR `makeTenantStorageProviderResolver`. The config
|
|
57
76
|
// accessor (and therefore the s3.secretAccessKey secret-read) runs under
|
package/dist/files/types.js
CHANGED
|
@@ -207,6 +207,15 @@ export function validateFileContent(fileName, content, accept) {
|
|
|
207
207
|
const ext = extractExtension(fileName);
|
|
208
208
|
if (!ext)
|
|
209
209
|
return { kind: "ok" };
|
|
210
|
+
// validateFile accepts aliased declared MIMEs (csv as vnd.ms-excel) on
|
|
211
|
+
// metadata alone, so the byte check has to live on this exported path too:
|
|
212
|
+
// a text-format extension with an alias must never carry a binary signature.
|
|
213
|
+
if (Object.hasOwn(EXTENSION_MIME_ALIASES, ext) && sniffMimeType(content) !== null) {
|
|
214
|
+
return {
|
|
215
|
+
kind: "rejected",
|
|
216
|
+
error: `content_mismatch: ".${ext}" upload bytes are a binary format`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
210
219
|
const signatureMimeTypes = signatureMimeTypesForExtension(ext);
|
|
211
220
|
if (signatureMimeTypes.length === 0)
|
|
212
221
|
return { kind: "ok" };
|
|
@@ -261,9 +270,15 @@ extensionOverride) {
|
|
|
261
270
|
const ext = /^[A-Za-z0-9]+$/.test(rawExt) ? rawExt.toLowerCase() : "bin";
|
|
262
271
|
return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
|
|
263
272
|
}
|
|
273
|
+
const EXPORTS_PREFIX_SEGMENT = "exports";
|
|
264
274
|
/** Fixed leading segment so one S3 lifecycle rule (Prefix: "exports/") can expire export bundles for every tenant without matching a buildStorageKey() upload. */
|
|
265
275
|
export function tenantExportPrefix(tenantId) {
|
|
266
|
-
|
|
276
|
+
// A tenant named like the reserved segment would make the sweep prefix
|
|
277
|
+
// "exports/" match every tenant's export bundles.
|
|
278
|
+
if (tenantId === EXPORTS_PREFIX_SEGMENT) {
|
|
279
|
+
throw new Error(`tenantId "${tenantId}" collides with the reserved storage segment`);
|
|
280
|
+
}
|
|
281
|
+
return `${EXPORTS_PREFIX_SEGMENT}/${tenantId}/`;
|
|
267
282
|
}
|
|
268
283
|
/**
|
|
269
284
|
* Every storage-key prefix a tenant's binaries can live under. A tenant-destroy
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export declare const DEFAULT_LOCALE = "en";
|
|
2
2
|
export declare function isValidLocaleTag(value: string): boolean;
|
|
3
|
-
/** BCP-47 is case-insensitive; canonicalize the
|
|
4
|
-
* so registry lookups (`mailT`, `hasMailTranslations`)
|
|
3
|
+
/** BCP-47 is case-insensitive; canonicalize to the RFC 5646 conventional form
|
|
4
|
+
* (`de-AT`, `zh-Hant-TW`) so registry lookups (`mailT`, `hasMailTranslations`)
|
|
5
|
+
* hit registered keys regardless of header casing. */
|
|
5
6
|
export declare function canonicalizeLocaleTag(tag: string): string;
|
|
6
7
|
/**
|
|
7
8
|
* Picks the best well-formed tag from an Accept-Language header (RFC 9110
|
|
@@ -14,13 +14,23 @@ const MAX_LOCALE_TAG_LENGTH = 35;
|
|
|
14
14
|
export function isValidLocaleTag(value) {
|
|
15
15
|
return value.length <= MAX_LOCALE_TAG_LENGTH && LOCALE_TAG_RE.test(value);
|
|
16
16
|
}
|
|
17
|
-
/** BCP-47 is case-insensitive; canonicalize the
|
|
18
|
-
* so registry lookups (`mailT`, `hasMailTranslations`)
|
|
17
|
+
/** BCP-47 is case-insensitive; canonicalize to the RFC 5646 conventional form
|
|
18
|
+
* (`de-AT`, `zh-Hant-TW`) so registry lookups (`mailT`, `hasMailTranslations`)
|
|
19
|
+
* hit registered keys regardless of header casing. */
|
|
19
20
|
export function canonicalizeLocaleTag(tag) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
return tag
|
|
22
|
+
.split("-")
|
|
23
|
+
.map((subtag, index) => {
|
|
24
|
+
if (index === 0)
|
|
25
|
+
return subtag.toLowerCase();
|
|
26
|
+
if (/^[A-Za-z]{2}$/.test(subtag))
|
|
27
|
+
return subtag.toUpperCase();
|
|
28
|
+
if (/^[A-Za-z]{4}$/.test(subtag)) {
|
|
29
|
+
return subtag.charAt(0).toUpperCase() + subtag.slice(1).toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
return subtag.toLowerCase();
|
|
32
|
+
})
|
|
33
|
+
.join("-");
|
|
24
34
|
}
|
|
25
35
|
/**
|
|
26
36
|
* Picks the best well-formed tag from an Accept-Language header (RFC 9110
|
|
@@ -46,6 +46,24 @@ function pushSectionTitles(out, section, treatAsKey = false) {
|
|
|
46
46
|
for (const group of section.groups ?? [])
|
|
47
47
|
pushKey(out, group.title, treatAsKey);
|
|
48
48
|
}
|
|
49
|
+
// Field labels and section titles of a step rendered through the action-form shim.
|
|
50
|
+
function pushActionFormShapedKeys(out, featureName, step) {
|
|
51
|
+
for (const fieldName of Object.keys(step.fields)) {
|
|
52
|
+
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
53
|
+
}
|
|
54
|
+
for (const section of step.layout.sections) {
|
|
55
|
+
if (isExtensionEditSection(section)) {
|
|
56
|
+
pushKey(out, section.title);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (section.kind === "relatedList")
|
|
60
|
+
continue; // rejected at boot, unreachable here
|
|
61
|
+
pushSectionTitles(out, section);
|
|
62
|
+
for (const f of sectionFieldSpecs(section)) {
|
|
63
|
+
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, editFieldName(f)));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
49
67
|
function pushRowActionKeys(out, action) {
|
|
50
68
|
pushKey(out, action.label);
|
|
51
69
|
if (action.kind === "writeHandler" || action.kind === undefined) {
|
|
@@ -242,26 +260,9 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
242
260
|
break;
|
|
243
261
|
}
|
|
244
262
|
case "secretMint": {
|
|
245
|
-
// Mirrors the actionForm case above (submitLabel, fields/section titles
|
|
246
|
-
// via ACTION_FORM_ENTITY) plus the reveal-phase's own i18n surface.
|
|
247
263
|
const mint = screen;
|
|
248
264
|
pushKey(out, mint.submitLabel);
|
|
249
|
-
|
|
250
|
-
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
251
|
-
}
|
|
252
|
-
for (const section of mint.layout.sections) {
|
|
253
|
-
if (isExtensionEditSection(section)) {
|
|
254
|
-
pushKey(out, section.title);
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (section.kind === "relatedList")
|
|
258
|
-
continue; // rejected at boot, unreachable here
|
|
259
|
-
pushSectionTitles(out, section);
|
|
260
|
-
for (const f of sectionFieldSpecs(section)) {
|
|
261
|
-
const fieldName = editFieldName(f);
|
|
262
|
-
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
+
pushActionFormShapedKeys(out, featureName, mint);
|
|
265
266
|
pushKey(out, mint.reveal.title);
|
|
266
267
|
pushKey(out, mint.reveal.warning);
|
|
267
268
|
pushKey(out, mint.reveal.confirmLabel);
|
|
@@ -271,28 +272,17 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
271
272
|
if (mint.confirm !== undefined) {
|
|
272
273
|
pushKey(out, mint.confirm.submitLabel);
|
|
273
274
|
pushKey(out, mint.confirm.doneMessage);
|
|
274
|
-
|
|
275
|
-
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
276
|
-
}
|
|
277
|
-
for (const section of mint.confirm.layout.sections) {
|
|
278
|
-
if (isExtensionEditSection(section)) {
|
|
279
|
-
pushKey(out, section.title);
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
if (section.kind === "relatedList")
|
|
283
|
-
continue; // rejected at boot, unreachable here
|
|
284
|
-
pushSectionTitles(out, section);
|
|
285
|
-
for (const f of sectionFieldSpecs(section)) {
|
|
286
|
-
const fieldName = editFieldName(f);
|
|
287
|
-
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
288
|
-
}
|
|
289
|
-
}
|
|
275
|
+
pushActionFormShapedKeys(out, featureName, mint.confirm);
|
|
290
276
|
}
|
|
291
277
|
break;
|
|
292
278
|
}
|
|
293
279
|
case "configEdit": {
|
|
294
280
|
const config = screen;
|
|
295
281
|
pushKey(out, config.submitLabel);
|
|
282
|
+
pushKey(out, config.description, treatDotFormAsKey);
|
|
283
|
+
for (const key of Object.values(config.fieldDescriptions ?? {})) {
|
|
284
|
+
pushKey(out, key, treatDotFormAsKey);
|
|
285
|
+
}
|
|
296
286
|
for (const fieldName of Object.keys(config.fields)) {
|
|
297
287
|
const override = config.fieldLabels?.[fieldName];
|
|
298
288
|
if (override !== undefined)
|
|
@@ -308,6 +298,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
308
298
|
if (section.kind === "relatedList")
|
|
309
299
|
continue; // rejected at boot, unreachable here
|
|
310
300
|
pushSectionTitles(out, section, treatDotFormAsKey);
|
|
301
|
+
pushKey(out, section.description, treatDotFormAsKey);
|
|
311
302
|
for (const f of sectionFieldSpecs(section)) {
|
|
312
303
|
const fieldName = editFieldName(f);
|
|
313
304
|
const override = config.fieldLabels?.[fieldName];
|
|
@@ -348,6 +339,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
348
339
|
}
|
|
349
340
|
if (isWriteFormEditSection(section)) {
|
|
350
341
|
pushKey(out, section.title);
|
|
342
|
+
pushKey(out, section.description);
|
|
351
343
|
pushKey(out, section.submitLabel);
|
|
352
344
|
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
353
345
|
for (const f of section.fields) {
|
|
@@ -399,7 +391,8 @@ function collectEntityListFilterKeys(feature, out) {
|
|
|
399
391
|
out.add(key);
|
|
400
392
|
}
|
|
401
393
|
}
|
|
402
|
-
else if (def.type === "select"
|
|
394
|
+
else if ((def.type === "select" || def.type === "multiSelect") &&
|
|
395
|
+
Array.isArray(def.options)) {
|
|
403
396
|
for (const value of def.options) {
|
|
404
397
|
out.add(selectFacetOptionKey(feature.name, screen.entity, fieldName, value));
|
|
405
398
|
}
|
|
@@ -16,14 +16,31 @@ export const SETTINGS_HUB_I18N = {
|
|
|
16
16
|
"config.settings.tenant": { en: "Tenant" },
|
|
17
17
|
"config.settings.user": { en: "Personal" },
|
|
18
18
|
"config.settings.extensionSelectorHint": {
|
|
19
|
-
en: "
|
|
19
|
+
en: "Choose a provider and save. Its settings appear below.",
|
|
20
20
|
},
|
|
21
|
+
"config.settings.provider": { en: "Provider" },
|
|
22
|
+
"config.settings.saveProvider": { en: "Save provider" },
|
|
23
|
+
"config.settings.audience.system": {
|
|
24
|
+
en: "Applies to every tenant unless a tenant sets its own value.",
|
|
25
|
+
},
|
|
26
|
+
"config.settings.audience.tenant": {
|
|
27
|
+
en: "Applies to everyone in this tenant. Platform defaults are shown where nothing is set here.",
|
|
28
|
+
},
|
|
29
|
+
"config.settings.audience.user": { en: "Only applies to you." },
|
|
21
30
|
"config.secrets.title": { en: "Secrets" },
|
|
22
31
|
"config.secrets.notSet": { en: "Not set" },
|
|
23
32
|
"config.secrets.set": { en: "Set" },
|
|
24
|
-
"config.secrets.
|
|
33
|
+
"config.secrets.saved": { en: "Saved" },
|
|
34
|
+
"config.secrets.section": { en: "Secrets" },
|
|
35
|
+
"config.secrets.description": {
|
|
36
|
+
en: "Stored encrypted. Saved values are never shown again.",
|
|
37
|
+
},
|
|
38
|
+
"config.secrets.stored": { en: "Stored: {preview}" },
|
|
25
39
|
"config.secrets.placeholder": { en: "Enter a value" },
|
|
26
|
-
"config.secrets.replacePlaceholder": { en: "Enter a new value to replace" },
|
|
27
|
-
"config.secrets.delete": { en: "
|
|
40
|
+
"config.secrets.replacePlaceholder": { en: "Enter a new value to replace it" },
|
|
41
|
+
"config.secrets.delete": { en: "Remove" },
|
|
42
|
+
"config.secrets.deleteConfirm": {
|
|
43
|
+
en: "Remove this secret? Anything using it stops working, and it cannot be restored.",
|
|
44
|
+
},
|
|
28
45
|
"screen:secrets.title": { en: "Secrets" },
|
|
29
46
|
};
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, } from "./job-runner.js";
|
|
2
|
-
export { createJobRunner } from "./job-runner.js";
|
|
1
|
+
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
2
|
+
export { createJobRunner, serializeJobSubject } from "./job-runner.js";
|
package/dist/jobs/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createJobRunner } from "./job-runner.js";
|
|
1
|
+
export { createJobRunner, serializeJobSubject } from "./job-runner.js";
|
|
@@ -28,6 +28,8 @@ export type JobOutcomeMeta = {
|
|
|
28
28
|
readonly messageKey: string | null;
|
|
29
29
|
} | undefined;
|
|
30
30
|
};
|
|
31
|
+
export type JobSubjectValue = string | number | boolean | null;
|
|
32
|
+
export declare function serializeJobSubject(subject: Readonly<Record<string, JobSubjectValue>>): string;
|
|
31
33
|
export type JobRunner = {
|
|
32
34
|
start(): Promise<void>;
|
|
33
35
|
stop(): Promise<void>;
|
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";
|