@cosmicdrift/kumiko-framework 0.290.0 → 0.292.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 (42) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
  3. package/src/api/__tests__/server-error-logging.test.ts +168 -17
  4. package/src/api/request-context.ts +3 -0
  5. package/src/api/request-id-middleware.ts +2 -1
  6. package/src/api/routes.ts +35 -3
  7. package/src/changes.json +63 -0
  8. package/src/crypto/__tests__/event-pii.test.ts +110 -9
  9. package/src/crypto/__tests__/subject-resolver.test.ts +23 -2
  10. package/src/crypto/subject-resolver.ts +25 -8
  11. package/src/db/queries/shadow-swap.ts +35 -0
  12. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +122 -0
  13. package/src/engine/__tests__/boot-validator-projection-list.test.ts +93 -0
  14. package/src/engine/__tests__/boot-validator.test.ts +226 -0
  15. package/src/engine/__tests__/build-app-schema.test.ts +18 -0
  16. package/src/engine/__tests__/engine.test.ts +87 -0
  17. package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
  18. package/src/engine/boot-validator/entity-handler.ts +44 -0
  19. package/src/engine/boot-validator/index.ts +10 -4
  20. package/src/engine/boot-validator/pii-retention.ts +8 -0
  21. package/src/engine/boot-validator/projection-list-screens.ts +52 -2
  22. package/src/engine/boot-validator/screens.ts +50 -0
  23. package/src/engine/create-app.ts +54 -0
  24. package/src/engine/extension-names.ts +10 -0
  25. package/src/engine/feature-config-events-jobs.ts +19 -0
  26. package/src/engine/index.ts +3 -0
  27. package/src/engine/screen-helpers.ts +1 -0
  28. package/src/engine/system-user.ts +3 -5
  29. package/src/event-store/__tests__/backfill-pii.integration.test.ts +30 -6
  30. package/src/files/provider-resolver.ts +9 -2
  31. package/src/i18n/required-surface-keys.ts +1 -0
  32. package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
  33. package/src/jobs/index.ts +7 -1
  34. package/src/jobs/job-runner.ts +94 -4
  35. package/src/logging/utils.ts +14 -1
  36. package/src/observability/index.ts +1 -0
  37. package/src/observability/standard-metrics.ts +20 -0
  38. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
  39. package/src/pipeline/projection-rebuild.ts +7 -0
  40. package/src/schema-cli.ts +21 -0
  41. package/src/scripts/codemod/pii-personal-migration.ts +242 -2
  42. package/src/ui-types/list-row-meta.ts +4 -1
@@ -105,13 +105,63 @@ function validateProjectionListFilterSchemaAcceptance(
105
105
  // (fw#2165): definePagedQueryHandler doesn't auto-merge params into the
106
106
  // handler's own Zod schema, so a declared facet would 422 on every query
107
107
  // unless the author added `filters` themselves.
108
+ // A dateRange facet (fw#3104) sends its two bounds as the top-level payload
109
+ // keys it names in `params`, not as a `filters` entry — so the query has to
110
+ // accept exactly those keys. Catches the facet pointed at a field the query
111
+ // can't narrow by, which would otherwise 422 on the first date the user picks.
112
+ // Keys buildListQueryPayload owns — a facet param naming one of them would
113
+ // silently replace the list's own paging/sorting on every pick.
114
+ const RESERVED_LIST_PAYLOAD_KEYS: ReadonlySet<string> = new Set([
115
+ "limit",
116
+ "search",
117
+ "sort",
118
+ "sortDirection",
119
+ "offset",
120
+ "totalCount",
121
+ "cursor",
122
+ "filter",
123
+ "filters",
124
+ ]);
125
+
126
+ function validateProjectionListDateRangeFacets(
127
+ prefix: string,
128
+ screen: ProjectionListScreenDefinition,
129
+ schema: QueryHandlerDef["schema"] | undefined,
130
+ ): void {
131
+ for (const facet of screen.facets ?? []) {
132
+ if (facet.type !== "dateRange") continue;
133
+ if (facet.params.from === facet.params.to) {
134
+ throw new Error(
135
+ `${prefix}: dateRange facet on "${facet.field}" names the same param ` +
136
+ `"${facet.params.from}" for both bounds.`,
137
+ );
138
+ }
139
+ for (const param of [facet.params.from, facet.params.to]) {
140
+ if (RESERVED_LIST_PAYLOAD_KEYS.has(param)) {
141
+ throw new Error(
142
+ `${prefix}: dateRange facet on "${facet.field}" names "${param}" as a bound, ` +
143
+ `which is a reserved list-payload key — pick the query's own time-bound param names.`,
144
+ );
145
+ }
146
+ if (schemaAccepts(schema, param)) continue;
147
+ throw new Error(
148
+ `${prefix}: dateRange facet on "${facet.field}" sends "${param}" but query ` +
149
+ `"${screen.query}" has no "${param}" parameter in its Zod schema — add ` +
150
+ `${param}: z.iso.datetime().optional() to the handler's schema, or point ` +
151
+ `params at the keys it already accepts.`,
152
+ );
153
+ }
154
+ }
155
+ }
156
+
108
157
  function validateProjectionListFacetsSchemaAcceptance(
109
158
  prefix: string,
110
159
  screen: ProjectionListScreenDefinition,
111
160
  schema: QueryHandlerDef["schema"] | undefined,
112
161
  ): void {
113
- // skip: no facets declared — nothing to reject.
114
- if (screen.facets === undefined || screen.facets.length === 0) return;
162
+ validateProjectionListDateRangeFacets(prefix, screen, schema);
163
+ // skip: no facets that travel via `filters` — nothing to reject.
164
+ if (screen.facets === undefined || !screen.facets.some((f) => f.type !== "dateRange")) return;
115
165
  // skip: the schema already accepts filters — nothing to reject.
116
166
  if (schemaAccepts(schema, "filters")) return;
117
167
  throw new Error(
@@ -578,10 +578,59 @@ function validateFormFieldsMap(
578
578
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
579
579
  );
580
580
  }
581
+ if (ftype === "money") {
582
+ validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
583
+ }
581
584
  }
582
585
  return fieldNames;
583
586
  }
584
587
 
588
+ // Fail-closed currency-source gate (fw#2839), the runtime half of the
589
+ // narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
590
+ // stop it. An inline form screen has no entity, so a money field there can't
591
+ // borrow `entity.defaultCurrency`: without a declared source the renderer
592
+ // seeds a bare `0` and the handler's zod schema rejects the submit. Entity
593
+ // money fields are exempt — create-app already refuses an entity that holds
594
+ // money without a `defaultCurrency`.
595
+ function validateFormMoneyCurrency(
596
+ featureName: string,
597
+ screenId: string,
598
+ context: string,
599
+ fieldName: string,
600
+ fdef: unknown,
601
+ ): void {
602
+ const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) money field "${fieldName}"`;
603
+ const validForms =
604
+ `Declare \`currency: { kind: "literal", code: "EUR" }\` for a fixed currency, or ` +
605
+ `\`currency: { kind: "tenant" }\` to take the tenant-settings bundle's per-tenant currency.`;
606
+ // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
607
+ const currency = (fdef as { currency?: { kind?: unknown; code?: unknown } | null }).currency;
608
+ if (currency === undefined) {
609
+ throw new Error(
610
+ `${where} must declare where its currency comes from — this screen has no entity whose ` +
611
+ `\`defaultCurrency\` it could inherit, so the form would seed a bare \`0\` that the ` +
612
+ `handler's schema rejects. ${validForms}`,
613
+ );
614
+ }
615
+ if (typeof currency !== "object" || currency === null) {
616
+ throw new Error(`${where} has a non-object \`currency\`. ${validForms}`);
617
+ }
618
+ const kind = currency.kind;
619
+ if (kind === "literal") {
620
+ const code = currency.code;
621
+ if (typeof code !== "string" || code.trim() === "") {
622
+ throw new Error(
623
+ `${where} declares \`currency: { kind: "literal" }\` with an empty or non-string \`code\`. ` +
624
+ `Pass the ISO code, e.g. { kind: "literal", code: "EUR" }.`,
625
+ );
626
+ }
627
+ } else if (kind !== "tenant") {
628
+ throw new Error(
629
+ `${where} declares an unknown currency kind ${JSON.stringify(kind)}. ${validForms}`,
630
+ );
631
+ }
632
+ }
633
+
585
634
  // `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
586
635
  // caller only ever passes true together with an actually-empty fields map
587
636
  // (an input-less secretMint declares BOTH `fields: {}` and
@@ -1265,6 +1314,7 @@ export function validateScreens(
1265
1314
  `(writeForm) has zero fields — drop the section or add fields to it.`,
1266
1315
  );
1267
1316
  }
1317
+ // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
1268
1318
  for (const f of section.fields) {
1269
1319
  const fieldName = normalizeEditField(f).field;
1270
1320
  if (section.fieldDefs[fieldName] === undefined) {
@@ -1,3 +1,4 @@
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-types/screen";
1
2
  import { type ValidateBootOptions, validateBoot } from "./boot-validator";
2
3
  import { dedupeFeatures } from "./dedupe-features";
3
4
  import { createRegistry } from "./registry";
@@ -20,6 +21,37 @@ export type App = {
20
21
  currencies: readonly string[];
21
22
  };
22
23
 
24
+ // Every field map an entity-less inline form screen renders: actionForm's and
25
+ // secretMint's own fields, plus a secretMint's separate `confirm` step.
26
+ function inlineFormFieldMaps(
27
+ screen: ScreenDefinition,
28
+ ): readonly Readonly<Record<string, unknown>>[] {
29
+ if (screen.type === "actionForm") return [screen.fields];
30
+ if (screen.type !== "secretMint") return [];
31
+ return screen.confirm !== undefined ? [screen.fields, screen.confirm.fields] : [screen.fields];
32
+ }
33
+
34
+ // `currency: { kind: "literal", code }` (fw#2839) is only meaningful for a
35
+ // code the app knows — a typo would otherwise render and submit amounts in a
36
+ // currency no formatter or rate table covers.
37
+ function validateLiteralCurrencyCode(
38
+ where: string,
39
+ field: unknown,
40
+ currencies: readonly string[],
41
+ ): void {
42
+ // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
43
+ const shape = field as { type?: unknown; currency?: { kind?: unknown; code?: unknown } };
44
+ if (shape.type === "money" && shape.currency?.kind === "literal") {
45
+ const code = shape.currency.code;
46
+ if (typeof code !== "string" || !currencies.includes(code)) {
47
+ throw new Error(
48
+ `${where} declares currency: { kind: "literal", code: ${JSON.stringify(code)} } which is ` +
49
+ `not in the currencies list. Available: ${currencies.join(", ")}`,
50
+ );
51
+ }
52
+ }
53
+ }
54
+
23
55
  export function createApp(config: AppConfig): App {
24
56
  const features = dedupeFeatures(config.features);
25
57
  const validRoles = new Set(config.roles);
@@ -108,6 +140,28 @@ export function createApp(config: AppConfig): App {
108
140
  `Entity "${entityName}" in feature "${feature.name}" has money fields but no defaultCurrency. Set defaultCurrency on the entity definition.`,
109
141
  );
110
142
  }
143
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
144
+ validateLiteralCurrencyCode(
145
+ `Entity "${entityName}" in feature "${feature.name}", money field "${fieldName}"`,
146
+ field,
147
+ currencies,
148
+ );
149
+ }
150
+ }
151
+ // A money field on an entity-less form screen names its own currency
152
+ // source (fw#2839) — a literal code has to be one the app actually knows,
153
+ // same rule the entity `defaultCurrency` check above applies.
154
+ for (const [screenId, screen] of Object.entries(feature.screens ?? {})) {
155
+ const inlineFields = inlineFormFieldMaps(screen);
156
+ for (const fields of inlineFields) {
157
+ for (const [fieldName, field] of Object.entries(fields)) {
158
+ validateLiteralCurrencyCode(
159
+ `Screen "${screenId}" in feature "${feature.name}", money field "${fieldName}"`,
160
+ field,
161
+ currencies,
162
+ );
163
+ }
164
+ }
111
165
  }
112
166
  }
113
167
 
@@ -87,6 +87,16 @@ export const EXT_FILE_PROVIDER = "fileProvider" as const;
87
87
  // des file-foundation-Features MUSS diese Konstante mitziehen.
88
88
  export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as const;
89
89
 
90
+ // Two roles: boot gate (validateBoot requires its presence once file/image
91
+ // fields are in use) AND the ENV source of the config key above, bridged via
92
+ // keyDef.env. Tenant rows keep overriding the bridged value.
93
+ export const FILE_STORAGE_PROVIDER_ENV = "FILE_STORAGE_PROVIDER" as const;
94
+
95
+ // Presence placeholder runDevApp writes when an explicitly wired provider
96
+ // (options.files) already satisfies the boot gate. It names no plugin, so
97
+ // provider resolution treats it like an unset key.
98
+ export const FILE_STORAGE_PROVIDER_BOOT_SENTINEL = "configured" as const;
99
+
90
100
  /**
91
101
  * `derivativeRenderer` — File-Derivative-Renderer-Plugin-Selection
92
102
  * (file-derivatives).
@@ -30,6 +30,17 @@ import type {
30
30
  TranslationsDef,
31
31
  } from "./types";
32
32
 
33
+ // A payload field that still holds null/undefined after parsing — the case
34
+ // where an event-PII owner field yields no subject at append time (fw#2776).
35
+ // A `.default(...)` accepts undefined but parses to a value, so it is not
36
+ // absentable.
37
+ function isAbsentable(field: ZodType): boolean {
38
+ return [null, undefined].some((candidate) => {
39
+ const parsed = field.safeParse(candidate);
40
+ return parsed.success && (parsed.data === null || parsed.data === undefined);
41
+ });
42
+ }
43
+
33
44
  // Builds config/secrets/claims/events/jobs/notifications registrar methods.
34
45
  export function buildConfigEventsJobsMethods<TName extends string>(
35
46
  state: FeatureBuilderState,
@@ -130,6 +141,14 @@ export function buildConfigEventsJobsMethods<TName extends string>(
130
141
  );
131
142
  }
132
143
  }
144
+ const owner = shape?.[normalized.ownerField];
145
+ if (normalized.whenAbsent === undefined && owner !== undefined && isAbsentable(owner)) {
146
+ throw new Error(
147
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${normalized.ownerField}", which the payload schema allows to be null/undefined. ` +
148
+ `Declare what happens then: { personal: { of: "${normalized.ownerField}", whenAbsent: "tenant" } } encrypts under the envelope tenant key, ` +
149
+ `whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`,
150
+ );
151
+ }
133
152
  }
134
153
  }
135
154
 
@@ -13,6 +13,7 @@ export {
13
13
  export {
14
14
  collectWriteHandlerQns,
15
15
  SECURITY_BASELINE_FEATURE_NAMES,
16
+ type ValidateBootOptions,
16
17
  validateAppCustomScreenWriteQns,
17
18
  validateBoot,
18
19
  } from "./boot-validator";
@@ -112,6 +113,8 @@ export {
112
113
  EXT_USER_DATA,
113
114
  EXT_USER_DATA_ORDER,
114
115
  FILE_PROVIDER_CONFIG_KEY,
116
+ FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
117
+ FILE_STORAGE_PROVIDER_ENV,
115
118
  TENANT_MEMBERSHIPS_QUERY,
116
119
  } from "./extension-names";
117
120
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage";
@@ -112,6 +112,7 @@ export type FieldsOrGroupsSection = {
112
112
  // one runs after the fields-XOR-groups check, collectors run without it and must
113
113
  // not drop a source (fw#2986).
114
114
  export function sectionFieldSpecs(section: FieldsOrGroupsSection): readonly EditFieldSpec[] {
115
+ // kumiko-lint-ignore section-fields-raw this IS the union reader — the groups source is added on the same line
115
116
  return [...section.fields, ...(section.groups?.flatMap((group) => group.fields) ?? [])];
116
117
  }
117
118
 
@@ -1,10 +1,8 @@
1
1
  import type { SessionUser } from "./types";
2
- import type { TenantId } from "./types/identifiers";
2
+ import { SYSTEM_USER_ID, type TenantId } from "./types/identifiers";
3
+
4
+ export { SYSTEM_USER_ID };
3
5
 
4
- // Stringified so it round-trips through SessionUser.id (string UUID-shape).
5
- // Not a real UUID — SYSTEM acts as an alias for "no human caller" and event-
6
- // store createdBy is text, so the literal suffices.
7
- export const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
8
6
  export const SYSTEM_ROLE = "system" as const;
9
7
 
10
8
  // extraRoles: hasAccess kennt keinen System-Bypass — Handler gaten auf
@@ -56,12 +56,20 @@ const crmFeature = defineFeature("crm", (r) => {
56
56
  // hand-written, so a camelCase event name (tenantNote → tenant-note) can
57
57
  // never drift from what the catalog is actually keyed by (fw#2801).
58
58
  let PING_EVENT_TYPE: string;
59
+ let PING_TENANT_EVENT_TYPE: string;
60
+ const pingSchema = z.object({
61
+ targetId: z.string().nullable(),
62
+ address: z.string().nullable(),
63
+ });
59
64
  const mailerFeature = defineFeature("mailer", (r) => {
60
- PING_EVENT_TYPE = r.defineEvent(
61
- "ping",
62
- z.object({ targetId: z.string().nullable(), address: z.string().nullable() }),
63
- { piiFields: { address: { subjectField: "targetId" } } },
64
- ).name;
65
+ // targetId is nullable, so the stance has to say what happens without an
66
+ // owner — the two events below cover both declarations (fw#2776).
67
+ PING_EVENT_TYPE = r.defineEvent("ping", pingSchema, {
68
+ piiFields: { address: { personal: { of: "targetId", whenAbsent: "plaintext" } } },
69
+ }).name;
70
+ PING_TENANT_EVENT_TYPE = r.defineEvent("pingTenant", pingSchema, {
71
+ piiFields: { address: { personal: { of: "targetId", whenAbsent: "tenant" } } },
72
+ }).name;
65
73
  });
66
74
 
67
75
  // fw#2801: custom events declaring a tenant/self subject — no entity,
@@ -272,7 +280,7 @@ describe("backfillEventPiiEncryption", () => {
272
280
  >;
273
281
  expect(erased["address"]).toBe(PII_ERASED_SENTINEL);
274
282
 
275
- // No subject → no key to shred; stays plaintext (documented rollout gap).
283
+ // No subject and whenAbsent: "plaintext" → declared, stays plaintext.
276
284
  const system = (await loadAggregate(testDb.db, p3, TENANT))[0]?.payload as Record<
277
285
  string,
278
286
  unknown
@@ -280,6 +288,22 @@ describe("backfillEventPiiEncryption", () => {
280
288
  expect(system["address"]).toBe("ops@x.com");
281
289
  });
282
290
 
291
+ test('an absent owner with whenAbsent: "tenant" backfills under the tenant key (fw#2776)', async () => {
292
+ const p4 = generateId();
293
+ await appendPlain(p4, "ping", PING_TENANT_EVENT_TYPE, {
294
+ targetId: null,
295
+ address: "ops@x.com",
296
+ });
297
+
298
+ armKms();
299
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
300
+ expect(result.failures).toEqual([]);
301
+
302
+ const row = (await loadAggregate(testDb.db, p4, TENANT))[0]?.payload as Record<string, unknown>;
303
+ expect(isPiiCiphertext(row["address"])).toBe(true);
304
+ expect(String(row["address"])).toContain(`tenant:${TENANT}`);
305
+ });
306
+
283
307
  test("idempotent: second run updates nothing; dryRun writes nothing", async () => {
284
308
  const c1 = generateId();
285
309
  await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
@@ -15,7 +15,11 @@
15
15
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
16
16
  import type { DbConnection } from "../db/connection";
17
17
  import type { TenantDb } from "../db/tenant-db";
18
- import { EXT_FILE_PROVIDER, FILE_PROVIDER_CONFIG_KEY } from "../engine/extension-names";
18
+ import {
19
+ EXT_FILE_PROVIDER,
20
+ FILE_PROVIDER_CONFIG_KEY,
21
+ FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
22
+ } from "../engine/extension-names";
19
23
  import { SYSTEM_USER_ID } from "../engine/system-user";
20
24
  import type { ConfigAccessor, ConfigAccessorFactory, Registry } from "../engine/types";
21
25
  import type { SecretsContext } from "../secrets";
@@ -93,7 +97,10 @@ export async function createFileProviderForTenant(
93
97
 
94
98
  const raw = await ctxConfig(FILE_PROVIDER_CONFIG_KEY);
95
99
  const provider = typeof raw === "string" ? raw : raw == null ? "" : String(raw);
96
- if (provider.length === 0) {
100
+ // The boot-gate placeholder names no plugin: treated as a provider name it
101
+ // would let FILE_STORAGE_PROVIDER=configured fake a selection through the
102
+ // ENV bridge.
103
+ if (provider.length === 0 || provider === FILE_STORAGE_PROVIDER_BOOT_SENTINEL) {
97
104
  const usages = ctx.registry.getExtensionUsages(EXT_FILE_PROVIDER);
98
105
  const known = usages.map((u) => u.entityName).join(", ") || "<none>";
99
106
  throw new Error(
@@ -328,6 +328,7 @@ export function requiredKeysFromScreen(
328
328
  if (isWriteFormEditSection(section)) {
329
329
  pushKey(out, section.title);
330
330
  pushKey(out, section.submitLabel);
331
+ // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
331
332
  for (const f of section.fields) {
332
333
  const fieldName = editFieldName(f);
333
334
  out.add(fieldLabelKey(featureName, WRITE_FORM_SECTION_ENTITY, fieldName));
@@ -0,0 +1,135 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { buildServer } from "../../api/server";
3
+ import { createRegistry, defineFeature } from "../../engine";
4
+ import type { AppContext, Registry } from "../../engine/types";
5
+ import {
6
+ createNoopProvider,
7
+ createPrometheusMeter,
8
+ registerStandardMetrics,
9
+ } from "../../observability";
10
+ import { createTestRedis, type TestRedis } from "../../stack";
11
+ import { waitFor } from "../../testing";
12
+ import { createJobRunner } from "../job-runner";
13
+
14
+ const JWT = "job-last-success-test-secret-minimum-32-chars!!";
15
+ const SUCCEEDS = "liveness:job:succeeds";
16
+ const FAILS = "liveness:job:fails-always";
17
+
18
+ let testRedis: TestRedis;
19
+ let redisUrl: string;
20
+
21
+ const livenessFeature = defineFeature("liveness", (r) => {
22
+ r.job("succeeds", { trigger: { manual: true } }, async () => {});
23
+ r.job("failsAlways", { trigger: { manual: true } }, async () => {
24
+ throw new Error("intentional failure");
25
+ });
26
+ });
27
+
28
+ beforeAll(async () => {
29
+ testRedis = await createTestRedis();
30
+ redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await testRedis.cleanup();
35
+ });
36
+
37
+ function slotFor(meter: ReturnType<typeof createPrometheusMeter>, job: string) {
38
+ return meter
39
+ .snapshot()
40
+ .get("kumiko_job_last_success_timestamp_seconds")
41
+ ?.slots.find((s) => s.labels?.["job"] === job);
42
+ }
43
+
44
+ async function withRunner(
45
+ meter: ReturnType<typeof createPrometheusMeter>,
46
+ fn: (runner: ReturnType<typeof createJobRunner>, failures: string[]) => Promise<void>,
47
+ ): Promise<void> {
48
+ const registry: Registry = createRegistry([livenessFeature]);
49
+ const context: AppContext = { meter };
50
+ const failures: string[] = [];
51
+ const queueNamePrefix = `kumiko-test-ls-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
52
+ const runner = createJobRunner({
53
+ registry,
54
+ context,
55
+ redisUrl,
56
+ consumerLane: "worker",
57
+ queueNamePrefix,
58
+ onJobFailed: (jobName) => {
59
+ failures.push(jobName);
60
+ },
61
+ });
62
+ await runner.start();
63
+ try {
64
+ await fn(runner, failures);
65
+ } finally {
66
+ await runner.stop();
67
+ const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
68
+ if (keys.length > 0) await testRedis.redis.del(...keys);
69
+ }
70
+ }
71
+
72
+ describe("job-runner — kumiko_job_last_success_timestamp_seconds", () => {
73
+ test("a successful run stamps the gauge, a failing run leaves no series", async () => {
74
+ const meter = createPrometheusMeter();
75
+ registerStandardMetrics(meter);
76
+
77
+ await withRunner(meter, async (runner, failures) => {
78
+ const before = Date.now() / 1000;
79
+ await runner.dispatch(SUCCEEDS, {});
80
+ await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
81
+
82
+ const stamped = slotFor(meter, SUCCEEDS) as { value: number };
83
+ expect(stamped.value).toBeGreaterThanOrEqual(before);
84
+ expect(stamped.value).toBeLessThanOrEqual(Date.now() / 1000 + 1);
85
+
86
+ // Separate job name, so the "failure must not stamp" assertion cannot
87
+ // pass just because a prior success left a value inside the same second.
88
+ await runner.dispatch(FAILS, {});
89
+ await waitFor(() => failures.includes(FAILS));
90
+ expect(slotFor(meter, FAILS)).toBeUndefined();
91
+ });
92
+ });
93
+
94
+ test("a later success advances the stamp", async () => {
95
+ const meter = createPrometheusMeter();
96
+ registerStandardMetrics(meter);
97
+
98
+ await withRunner(meter, async (runner) => {
99
+ await runner.dispatch(SUCCEEDS, {});
100
+ await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
101
+ const first = (slotFor(meter, SUCCEEDS) as { value: number }).value;
102
+
103
+ await runner.dispatch(SUCCEEDS, {});
104
+ await waitFor(() => (slotFor(meter, SUCCEEDS) as { value: number }).value > first, {
105
+ delays: [250, 1000, 3000],
106
+ });
107
+ expect((slotFor(meter, SUCCEEDS) as { value: number }).value).toBeGreaterThan(first);
108
+ });
109
+ });
110
+
111
+ test("the stamp reaches the real /metrics scrape output", async () => {
112
+ const meter = createPrometheusMeter();
113
+ registerStandardMetrics(meter);
114
+
115
+ await withRunner(meter, async (runner) => {
116
+ await runner.dispatch(SUCCEEDS, {});
117
+ await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
118
+
119
+ // Same meter instance the runner wrote into — that sharing is what
120
+ // buildServer + job-runner do in a real process (fw#1046).
121
+ const { app } = buildServer({
122
+ registry: createRegistry([livenessFeature]),
123
+ context: {},
124
+ jwtSecret: JWT,
125
+ observability: { ...createNoopProvider(), meter },
126
+ metrics: {},
127
+ });
128
+ const res = await app.request("/metrics");
129
+ expect(res.status).toBe(200);
130
+ const body = await res.text();
131
+ expect(body).toContain("# TYPE kumiko_job_last_success_timestamp_seconds gauge");
132
+ expect(body).toContain(`kumiko_job_last_success_timestamp_seconds{job="${SUCCEEDS}"} `);
133
+ });
134
+ });
135
+ });
package/src/jobs/index.ts CHANGED
@@ -1,2 +1,8 @@
1
- export type { JobLogEntry, JobMeta, JobRunner, JobRunnerOptions } from "./job-runner";
1
+ export type {
2
+ JobLogEntry,
3
+ JobMeta,
4
+ JobOutcomeMeta,
5
+ JobRunner,
6
+ JobRunnerOptions,
7
+ } from "./job-runner";
2
8
  export { createJobRunner } from "./job-runner";