@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.
Files changed (172) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/eagerload.js +2 -1
  23. package/dist/db/entity-table-meta.js +5 -3
  24. package/dist/db/event-store-executor-context.js +13 -19
  25. package/dist/db/event-store-executor-read.js +39 -36
  26. package/dist/db/event-store-executor-write.js +15 -17
  27. package/dist/db/migrate-generator.js +27 -28
  28. package/dist/db/pg-error.js +9 -5
  29. package/dist/db/queries/backfill-pii.js +15 -5
  30. package/dist/db/queries/shadow-swap.js +5 -4
  31. package/dist/db/table-builder.d.ts +1 -0
  32. package/dist/db/table-builder.js +7 -15
  33. package/dist/db/tenant-db.js +18 -2
  34. package/dist/derivatives/variant-key.js +3 -1
  35. package/dist/engine/boot-validator/access-declarations.js +12 -3
  36. package/dist/engine/boot-validator/config-deps.js +13 -0
  37. package/dist/engine/boot-validator/entity-handler.js +13 -1
  38. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  39. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  40. package/dist/engine/boot-validator/index.js +6 -6
  41. package/dist/engine/boot-validator/nav.d.ts +2 -1
  42. package/dist/engine/boot-validator/nav.js +10 -20
  43. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  44. package/dist/engine/boot-validator/ownership.js +2 -1
  45. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  46. package/dist/engine/boot-validator/pii-retention.js +44 -4
  47. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  48. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  49. package/dist/engine/boot-validator/screens.d.ts +2 -2
  50. package/dist/engine/boot-validator/screens.js +138 -36
  51. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  52. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  53. package/dist/engine/boot-validator/zod-shape.js +17 -4
  54. package/dist/engine/build-app-schema.js +65 -19
  55. package/dist/engine/build-config-feature-schema.js +106 -22
  56. package/dist/engine/changeset-changes.js +24 -10
  57. package/dist/engine/config-helpers.d.ts +2 -2
  58. package/dist/engine/entity-handlers.js +7 -2
  59. package/dist/engine/feature-ast/extractors/events.js +11 -8
  60. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  61. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  62. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  63. package/dist/engine/feature-ast/parse.d.ts +1 -0
  64. package/dist/engine/feature-ast/parse.js +18 -3
  65. package/dist/engine/feature-ast/patch.js +80 -61
  66. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  67. package/dist/engine/feature-ast/patcher.js +2 -1
  68. package/dist/engine/feature-ast/render.js +1 -1
  69. package/dist/engine/feature-entity-handlers.js +22 -0
  70. package/dist/engine/field-access.d.ts +2 -0
  71. package/dist/engine/field-access.js +40 -0
  72. package/dist/engine/index.d.ts +5 -4
  73. package/dist/engine/index.js +4 -3
  74. package/dist/engine/ownership.d.ts +1 -0
  75. package/dist/engine/ownership.js +15 -2
  76. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  77. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  78. package/dist/engine/pattern-library/shared-fields.js +17 -0
  79. package/dist/engine/registry-facade.js +3 -0
  80. package/dist/engine/role-assignment.d.ts +7 -1
  81. package/dist/engine/role-assignment.js +27 -7
  82. package/dist/engine/schema-builder.d.ts +2 -0
  83. package/dist/engine/schema-builder.js +20 -3
  84. package/dist/engine/screen-helpers.js +15 -4
  85. package/dist/engine/steps/_event-match.js +22 -3
  86. package/dist/engine/steps/_read-source.js +8 -0
  87. package/dist/engine/types/index.d.ts +1 -1
  88. package/dist/engine/where-rule-lint.js +22 -4
  89. package/dist/env/index.js +21 -1
  90. package/dist/errors/reasons.d.ts +0 -1
  91. package/dist/errors/reasons.js +2 -5
  92. package/dist/event-store/event-store.js +6 -1
  93. package/dist/event-store/provenance-append.js +4 -1
  94. package/dist/files/file-ref-entity.d.ts +6 -0
  95. package/dist/files/file-ref-table.d.ts +6 -0
  96. package/dist/files/file-routes.d.ts +0 -1
  97. package/dist/files/file-routes.js +22 -7
  98. package/dist/files/local-provider.js +13 -10
  99. package/dist/files/provider-resolver.js +20 -1
  100. package/dist/files/types.js +16 -1
  101. package/dist/i18n/request-locale.d.ts +3 -2
  102. package/dist/i18n/request-locale.js +16 -6
  103. package/dist/i18n/required-surface-keys.js +28 -35
  104. package/dist/i18n/settings-hub-keys.js +21 -4
  105. package/dist/jobs/index.d.ts +2 -2
  106. package/dist/jobs/index.js +1 -1
  107. package/dist/jobs/job-runner.d.ts +2 -0
  108. package/dist/jobs/job-runner.js +150 -59
  109. package/dist/migrations/index.d.ts +1 -1
  110. package/dist/migrations/index.js +1 -1
  111. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  112. package/dist/migrations/pending-rebuilds.js +23 -1
  113. package/dist/observability/escape-hatch-report.d.ts +1 -0
  114. package/dist/observability/escape-hatch-report.js +3 -0
  115. package/dist/observability/index.d.ts +1 -0
  116. package/dist/observability/index.js +1 -0
  117. package/dist/pipeline/dispatch-batch.js +8 -3
  118. package/dist/pipeline/dispatch-query.d.ts +1 -1
  119. package/dist/pipeline/dispatch-query.js +2 -2
  120. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  121. package/dist/pipeline/dispatch-shared.js +14 -9
  122. package/dist/pipeline/dispatch-stream.js +2 -5
  123. package/dist/pipeline/dispatch-write.js +27 -7
  124. package/dist/pipeline/dispatcher-utils.js +4 -1
  125. package/dist/pipeline/dispatcher.js +1 -2
  126. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  127. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  128. package/dist/pipeline/event-dispatcher.js +26 -11
  129. package/dist/pipeline/index.d.ts +1 -1
  130. package/dist/pipeline/index.js +1 -1
  131. package/dist/pipeline/member-read-only-transaction.js +1 -14
  132. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  133. package/dist/pipeline/msp-rebuild.js +1 -0
  134. package/dist/pipeline/system-hooks.d.ts +1 -1
  135. package/dist/pipeline/system-hooks.js +9 -7
  136. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  137. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  138. package/dist/pipeline/write-result-masking.d.ts +6 -0
  139. package/dist/pipeline/write-result-masking.js +36 -0
  140. package/dist/redis/pubsub-signal.d.ts +2 -0
  141. package/dist/redis/pubsub-signal.js +2 -2
  142. package/dist/schema-cli.js +4 -14
  143. package/dist/search/meilisearch-adapter.js +17 -6
  144. package/dist/search/purge-subject.js +8 -3
  145. package/dist/stack/drain-jobs.js +24 -12
  146. package/dist/stack/table-helpers.js +10 -8
  147. package/dist/testing/closed-connection-error.js +2 -3
  148. package/dist/testing/shared-entities.d.ts +10 -0
  149. package/dist/testing/wait-for.js +1 -0
  150. package/dist/ui-types/index.d.ts +2 -2
  151. package/dist/ui-types/index.js +1 -1
  152. package/dist/upgrade-cli.d.ts +1 -0
  153. package/dist/upgrade-cli.js +36 -15
  154. package/package.json +4 -4
  155. package/src/changes.json +218 -0
  156. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  157. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  158. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  159. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  160. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  161. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  162. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  163. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  164. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  165. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  166. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  167. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  168. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  169. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  170. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  171. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  172. 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
- return usage.options.build(ctx, tenantId);
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
@@ -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
- return `exports/${tenantId}/`;
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 primary subtag to lowercase
4
- * so registry lookups (`mailT`, `hasMailTranslations`) hit registered keys. */
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 primary subtag to lowercase
18
- * so registry lookups (`mailT`, `hasMailTranslations`) hit registered keys. */
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
- const dash = tag.indexOf("-");
21
- if (dash === -1)
22
- return tag.toLowerCase();
23
- return `${tag.slice(0, dash).toLowerCase()}${tag.slice(dash)}`;
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
- for (const fieldName of Object.keys(mint.fields)) {
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
- for (const fieldName of Object.keys(mint.confirm.fields)) {
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" && Array.isArray(def.options)) {
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: "Save the provider selection to show its settings below.",
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.required": { en: "Required" },
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: "Delete" },
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
  };
@@ -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";
@@ -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>;
@@ -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/escape-hatch-report.js";
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 entries = [];
133
- for (const field of [...fields].sort()) {
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
- entries.push([field, value]);
145
+ subject[field] = value;
139
146
  }
140
- return JSON.stringify(entries);
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
- // @cast-boundary engine-payload — generic query-handler return for typed convention
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
- if (!getActiveTenantIds) {
467
- throw new Error(`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
468
- `tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
469
- "framework uses to resolve active tenants on its own)");
470
- }
471
- const actualDef = allJobs.get(actualName);
472
- if (!actualDef) {
473
- throw new Error(`Unknown job: ${actualName}`);
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
- const tenantIds = await getActiveTenantIds();
476
- const targetQueue = queues[laneForJob(actualDef)];
477
- // wrapperJobId is only absent for a Job BullMQ somehow never assigned
478
- // an id to (not observed in practice) — falling back to a constant
479
- // there would collide every such run's children onto the same ids
480
- // and silently swallow later runs as "duplicates", which is worse
481
- // than the duplicate this is meant to prevent. Skip id-based dedup
482
- // for that edge case instead: enqueue plainly, same as before.
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
- throw new Error(`Unknown job: ${jobName}`);
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
- // The re-enqueued job starts with a full retry budget; a remaining
520
- // budget is deliberately not carried over, since finalAttempt/
521
- // tenantVisibleFailure are computed from jobDef.retries against
522
- // attemptsMade, not against some inherited remainder.
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
- const tenantVisibleSubject = tenantVisibleDecl
562
- ? jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields)
563
- : null;
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
- // Undefined only for a runner built without a db; JobContext requires db non-optional.
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
- emitJobLastSuccess(context.meter, jobName);
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({ id: bootJobIdForJobName(name), name, data: {}, attemptsMade: 0 });
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 readinessTimeout = timeoutReject(bootRedisTimeoutMs, `job-runner: Queue not ready within ${bootRedisTimeoutMs}ms during stop()`);
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";