@cosmicdrift/kumiko-framework 0.305.0 → 0.306.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 (60) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  3. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  4. package/src/api/request-context.ts +5 -4
  5. package/src/api/routes.ts +26 -1
  6. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  7. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  8. package/src/bun-db/query.ts +42 -18
  9. package/src/changes.json +66 -0
  10. package/src/db/__tests__/pg-error.test.ts +14 -0
  11. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  12. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  13. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  14. package/src/db/index.ts +1 -1
  15. package/src/db/pg-error.ts +13 -0
  16. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  17. package/src/db/tenant-db.ts +90 -13
  18. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  19. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  20. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  21. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  22. package/src/engine/extension-names.ts +55 -25
  23. package/src/engine/extensions/storage-provider.ts +14 -41
  24. package/src/engine/extensions/tenant-data.ts +4 -0
  25. package/src/engine/extensions/tenant-resource.ts +40 -0
  26. package/src/engine/extensions/user-data.ts +8 -7
  27. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  29. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  30. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  31. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  32. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  33. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  34. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  35. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  36. package/src/engine/feature-ast/index.ts +11 -1
  37. package/src/engine/feature-ast/patch.ts +338 -5
  38. package/src/engine/feature-ast/patcher.ts +2 -2
  39. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  40. package/src/engine/feature-ast/patterns.ts +22 -15
  41. package/src/engine/feature-ast/render.ts +1 -0
  42. package/src/engine/feature-ui-extensions.ts +8 -7
  43. package/src/engine/index.ts +21 -5
  44. package/src/engine/types/extension-options-map.ts +1 -0
  45. package/src/engine/types/index.ts +6 -0
  46. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  47. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  48. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  49. package/src/jobs/job-runner.ts +151 -14
  50. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  51. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  52. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  53. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  54. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  55. package/src/pipeline/dispatch-batch.ts +56 -13
  56. package/src/pipeline/idempotency.ts +16 -0
  57. package/src/pipeline/system-identity-switch.ts +22 -4
  58. package/src/testing/closed-connection-error.ts +62 -0
  59. package/src/testing/index.ts +1 -0
  60. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -71,7 +71,7 @@ import type {
71
71
  AgentHandlerHints,
72
72
  ClaimKeyType,
73
73
  EscapeHatchDeclaration,
74
- RateLimitOption,
74
+ RateLimitDeclaration,
75
75
  } from "../types/handlers";
76
76
  import type { HookPhase } from "../types/hooks";
77
77
  import type { HttpRouteMethod } from "../types/http-route";
@@ -81,6 +81,7 @@ import type { RelationDefinition } from "../types/relations";
81
81
  import type { ScreenDefinition } from "../types/screen";
82
82
  import type { TreeActionDef } from "../types/tree-node";
83
83
  import type { WorkspaceDefinition } from "../types/workspace";
84
+ import type { RawRefSentinel } from "./extractors/shared";
84
85
  import type { SourceLocation } from "./source-location";
85
86
 
86
87
  // =============================================================================
@@ -367,6 +368,10 @@ export type ScreenPattern = {
367
368
  // overrides that derivation explicitly (force-show or force-hide);
368
369
  // `agent.risk` ("low" | "mid" | "high") classifies the action's blast
369
370
  // radius, defaulting to "mid" for a write handler.
371
+ //
372
+ // access/rateLimit/escapeHatch/agent additionally accept a RawRefSentinel
373
+ // for a non-literal value (imported/same-file const, or a sub-value like
374
+ // `personalData: PD`) that would otherwise lose the reference on render.
370
375
  export type WriteHandlerPattern = {
371
376
  readonly kind: "writeHandler";
372
377
  readonly source: SourceLocation;
@@ -384,12 +389,12 @@ export type WriteHandlerPattern = {
384
389
  // handlerBody: the closure body as source text. Always opaque — AI
385
390
  // generates raw TypeScript, no DSL interpretation.
386
391
  readonly handlerBody?: SourceLocation;
387
- readonly access?: AccessRule;
392
+ readonly access?: AccessRule | RawRefSentinel;
388
393
  readonly description?: string;
389
- readonly agent?: AgentHandlerHints;
390
- readonly rateLimit?: RateLimitOption;
394
+ readonly agent?: AgentHandlerHints | RawRefSentinel;
395
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
391
396
  readonly unsafeSkipTransitionGuard?: boolean;
392
- readonly escapeHatch?: EscapeHatchDeclaration;
397
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
393
398
  };
394
399
 
395
400
  // `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
@@ -408,25 +413,27 @@ export type QueryHandlerPattern = {
408
413
  readonly handlerName?: string;
409
414
  readonly schemaSource?: SourceLocation;
410
415
  readonly handlerBody?: SourceLocation;
411
- readonly access?: AccessRule;
416
+ readonly access?: AccessRule | RawRefSentinel;
412
417
  readonly description?: string;
413
- readonly agent?: AgentHandlerHints;
414
- readonly rateLimit?: RateLimitOption;
415
- readonly escapeHatch?: EscapeHatchDeclaration;
418
+ readonly agent?: AgentHandlerHints | RawRefSentinel;
419
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
420
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
416
421
  };
417
422
 
418
423
  // `r.streamHandler(...)` — registers a streaming read handler: name, Zod
419
- // input schema, async-generator handler closure, plus optional `access` and
420
- // `rateLimit` rules. Same header/body split and opaque single-reference
421
- // case as QueryHandlerPattern.
424
+ // input schema, async-generator handler closure, plus optional `access`,
425
+ // `rateLimit`, and `escapeHatch` rules (StreamHandlerDef carries all three
426
+ // at runtime). Same header/body split and opaque single-reference case as
427
+ // QueryHandlerPattern.
422
428
  export type StreamHandlerPattern = {
423
429
  readonly kind: "streamHandler";
424
430
  readonly source: SourceLocation;
425
431
  readonly handlerName?: string;
426
432
  readonly schemaSource?: SourceLocation;
427
433
  readonly handlerBody?: SourceLocation;
428
- readonly access?: AccessRule;
429
- readonly rateLimit?: RateLimitOption;
434
+ readonly access?: AccessRule | RawRefSentinel;
435
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
436
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
430
437
  };
431
438
 
432
439
  // `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
@@ -447,7 +454,7 @@ export type HookPattern = {
447
454
  readonly target: string | readonly string[] | { readonly allOf: string };
448
455
  readonly fnBody: SourceLocation;
449
456
  readonly phase?: HookPhase;
450
- readonly escapeHatch?: EscapeHatchDeclaration;
457
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
451
458
  };
452
459
 
453
460
  // `r.job(name, options, handler)` — registers a background job, qualified
@@ -436,6 +436,7 @@ function renderStreamHandler(p: StreamHandlerPattern): string {
436
436
  lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
437
437
  if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
438
438
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
439
+ if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
439
440
  lines.push("});");
440
441
  return lines.join("\n");
441
442
  }
@@ -229,9 +229,9 @@ export function buildUiExtensionsMethods<TName extends string>(
229
229
  useExtension(
230
230
  extensionNameOrDefinition:
231
231
  | string
232
- | ({ readonly name: string; readonly entity: NameOrRef } & Record<string, unknown>),
232
+ | ({ readonly name: string; readonly entity: NameOrRef } & object),
233
233
  entityRef?: NameOrRef,
234
- options?: Record<string, unknown>,
234
+ options?: object,
235
235
  ): void {
236
236
  const [extensionName, resolvedEntityRef, resolvedOptions] =
237
237
  typeof extensionNameOrDefinition === "string"
@@ -241,11 +241,12 @@ export function buildUiExtensionsMethods<TName extends string>(
241
241
  return [name, entity, rest] as const;
242
242
  })();
243
243
  const resolvedEntityName = resolveName(resolvedEntityRef);
244
+ // @cast-boundary engine-bridge — typed per-extension options → erased registration bag
245
+ const optionsBag = resolvedOptions as Record<string, unknown> | undefined;
244
246
  // fw#2914 — cross-cutting escapeHatch convention for hook-context db
245
- // access (mirrors r.hook's validation above). Not part of a typed
246
- // per-extension options shape: useExtension's bag stays generic, but
247
- // this one key is validated for every extension the same way.
248
- const escapeHatch = resolvedOptions?.["escapeHatch"];
247
+ // access (mirrors r.hook's validation above). Validated the same way
248
+ // for every extension regardless of its typed hook shape.
249
+ const escapeHatch = optionsBag?.["escapeHatch"];
249
250
  if (escapeHatch !== undefined) {
250
251
  const reason =
251
252
  typeof escapeHatch === "object" && escapeHatch !== null
@@ -261,7 +262,7 @@ export function buildUiExtensionsMethods<TName extends string>(
261
262
  state.extensionUsages.push({
262
263
  extensionName,
263
264
  entityName: resolvedEntityName,
264
- options: resolvedOptions,
265
+ options: optionsBag,
265
266
  });
266
267
  },
267
268
  extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
@@ -98,7 +98,7 @@ export {
98
98
  export { declareEscapeHatch } from "./escape-hatch-declaration";
99
99
  export type { EmitCtx } from "./event-helpers";
100
100
  export { emitEvent, typedPayload } from "./event-helpers";
101
- export type { KumikoExtensionName } from "./extension-names";
101
+ export type { KumikoExtensionName, TenantResourceExtensionName } from "./extension-names";
102
102
  export {
103
103
  EXT_DERIVATIVE_OVERLAY_RESOLVER,
104
104
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
@@ -124,11 +124,18 @@ export type {
124
124
  StorageProviderExtensionHooks,
125
125
  StorageProviderHookCtx,
126
126
  } from "./extensions/storage-provider";
127
- export type {
128
- TenantDataDestroyHook,
129
- TenantDataExtensionHooks,
130
- TenantDataHookCtx,
127
+ export {
128
+ isTenantDataExtensionHooks,
129
+ type TenantDataDestroyHook,
130
+ type TenantDataExtensionHooks,
131
+ type TenantDataHookCtx,
131
132
  } from "./extensions/tenant-data";
133
+ export {
134
+ isTenantResourceExtensionHooks,
135
+ type TenantResourceDestroyHook,
136
+ type TenantResourceExtensionHooks,
137
+ type TenantResourceHookCtx,
138
+ } from "./extensions/tenant-resource";
132
139
  export type {
133
140
  TenantUserModel,
134
141
  UserDataDeleteHook,
@@ -136,6 +143,7 @@ export type {
136
143
  UserDataExportHook,
137
144
  UserDataExportSnippet,
138
145
  UserDataExtensionHooks,
146
+ UserDataExtensionOptions,
139
147
  UserDataHookCtx,
140
148
  UserDataStorageProvider,
141
149
  } from "./extensions/user-data";
@@ -178,10 +186,13 @@ export type {
178
186
  FormFieldLabel,
179
187
  FormFieldSpec,
180
188
  FormInputType,
189
+ HandlerHeaderUpdate,
181
190
  ParseError,
182
191
  ParseResult,
183
192
  PatternCategory,
184
193
  PatternChange,
194
+ PatternChangeIssue,
195
+ PatternChangesParseResult,
185
196
  PatternFormSchema,
186
197
  PatternId,
187
198
  RenderFeatureFileInput,
@@ -195,11 +206,13 @@ export {
195
206
  groupByCategory,
196
207
  PATTERN_LIBRARY,
197
208
  parseFeatureFile,
209
+ parsePatternChanges,
198
210
  parseSourceFile,
199
211
  removePattern,
200
212
  renderFeatureFile,
201
213
  renderPattern,
202
214
  replacePattern,
215
+ updatePattern,
203
216
  VERSION_HEADER,
204
217
  } from "./feature-ast";
205
218
  export {
@@ -392,6 +405,8 @@ export type {
392
405
  EscapeHatchTarget,
393
406
  EscapeHatchUseEvent,
394
407
  EventDef,
408
+ ExtensionOptionsArgs,
409
+ ExtensionOptionsFor,
395
410
  FeatureDefinition,
396
411
  FeatureRegistrar,
397
412
  FieldAccess,
@@ -419,6 +434,7 @@ export type {
419
434
  JsonbFieldDef,
420
435
  KumikoEntityTypeMap,
421
436
  KumikoEventTypeMap,
437
+ KumikoExtensionOptionsMap,
422
438
  KumikoHandlerPayloadMap,
423
439
  KumikoHandlerResultMap,
424
440
  LifecycleHookType,
@@ -0,0 +1 @@
1
+ export type * from "@cosmicdrift/kumiko-types/extension-options-map";
@@ -72,6 +72,12 @@ export type {
72
72
  KumikoHandlerPayloadMap,
73
73
  KumikoHandlerResultMap,
74
74
  } from "@cosmicdrift/kumiko-types/event-type-map";
75
+ // Cross-Feature Compile-Time-Type-Map for r.useExtension options — mirrors KumikoEventTypeMap above.
76
+ export type {
77
+ ExtensionOptionsArgs,
78
+ ExtensionOptionsFor,
79
+ KumikoExtensionOptionsMap,
80
+ } from "@cosmicdrift/kumiko-types/extension-options-map";
75
81
  export type {
76
82
  BootCheckContext,
77
83
  BootCheckFn,
@@ -0,0 +1,316 @@
1
+ // BullMQ's moveToFinished lua sweeps a queue's WHOLE
2
+ // completed/failed zset whenever any job finishes with keepJobs set, not
3
+ // just the finishing job, and only lazily (on a later finish into the same
4
+ // set). These tests exercise the real sweep against a real Redis, on every
5
+ // enqueue path, plus the runOnBoot marker and the perTenant invariant that
6
+ // keeps the sweep from breaking wrapper-retry child dedup.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { Queue } from "bullmq";
10
+ import { z } from "zod";
11
+ import { createRegistry, defineFeature } from "../../engine";
12
+ import type { AppContext, TenantId } from "../../engine/types";
13
+ import { createTestRedis, type TestRedis } from "../../stack";
14
+ import { sleep, waitFor } from "../../testing";
15
+ import { bootJobIdForJobName, createJobRunner } from "../job-runner";
16
+
17
+ let testRedis: TestRedis;
18
+ let redisUrl: string;
19
+
20
+ beforeAll(async () => {
21
+ testRedis = await createTestRedis();
22
+ redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
23
+ });
24
+
25
+ afterAll(async () => {
26
+ await testRedis.cleanup();
27
+ });
28
+
29
+ function uniquePrefix(tag: string): string {
30
+ return `kumiko-test-retention-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
31
+ }
32
+
33
+ async function purgeQueueKeys(queueNamePrefix: string): Promise<void> {
34
+ const workerKeys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
35
+ if (workerKeys.length > 0) await testRedis.redis.del(...workerKeys);
36
+ const apiKeys = await testRedis.redis.keys(`bull:${queueNamePrefix}-api:*`);
37
+ if (apiKeys.length > 0) await testRedis.redis.del(...apiKeys);
38
+ }
39
+
40
+ function rawWorkerQueue(queueNamePrefix: string): Queue {
41
+ const queue = new Queue(`${queueNamePrefix}-worker`, {
42
+ connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
43
+ });
44
+ // A post-close 'error' here is otherwise unhandled and bun:test attributes
45
+ // it to whichever test runs next (fw#1805).
46
+ queue.on("error", () => {});
47
+ return queue;
48
+ }
49
+
50
+ describe("bounded job retention (fw#3199)", () => {
51
+ test("completed jobs from every enqueue path are swept after the age once a later job completes", async () => {
52
+ const queueNamePrefix = uniquePrefix("completed");
53
+ const tenants = ["ret-a", "ret-b"] as TenantId[];
54
+ const completed: Array<{ name: string; jobId: string }> = [];
55
+
56
+ const feature = defineFeature("retention", (r) => {
57
+ const someEvent = r.defineEvent("some-event", z.object({}), { piiFields: "none" });
58
+ r.job("tick", { trigger: { manual: true } }, async () => {});
59
+ r.job("onEvent", { trigger: { on: someEvent.name } }, async () => {});
60
+ r.job("fanout", { trigger: { manual: true }, perTenant: true }, async () => {});
61
+ r.job("cronJob", { trigger: { cron: "* * * * * *" } }, async () => {});
62
+ r.job("sweeper", { trigger: { manual: true } }, async () => {});
63
+ });
64
+
65
+ const registry = createRegistry([feature]);
66
+ const context: AppContext = {};
67
+ const runner = createJobRunner({
68
+ registry,
69
+ context,
70
+ redisUrl,
71
+ consumerLane: "worker",
72
+ queueNamePrefix,
73
+ getActiveTenantIds: async () => tenants,
74
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
75
+ onJobComplete: (name, jobId) => {
76
+ completed.push({ name, jobId });
77
+ },
78
+ });
79
+
80
+ const rawQueue = rawWorkerQueue(queueNamePrefix);
81
+ try {
82
+ await runner.start();
83
+ const tickId = await runner.dispatch("retention:job:tick");
84
+ await runner.handleEvent("retention:event:some-event", {});
85
+ const wrapperId = await runner.dispatch("retention:job:fanout");
86
+
87
+ await waitFor(() => {
88
+ expect(completed.some((c) => c.jobId === tickId)).toBe(true);
89
+ expect(completed.some((c) => c.name === "retention:job:on-event")).toBe(true);
90
+ expect(completed.filter((c) => c.name === "retention:job:fanout").length).toBe(2);
91
+ expect(completed.some((c) => c.name === "retention:job:cron-job")).toBe(true);
92
+ });
93
+
94
+ const cronId = completed.find((c) => c.name === "retention:job:cron-job")?.jobId;
95
+ const eventId = completed.find((c) => c.name === "retention:job:on-event")?.jobId;
96
+ const childIds = completed
97
+ .filter((c) => c.name === "retention:job:fanout")
98
+ .map((c) => c.jobId);
99
+
100
+ await sleep(1200);
101
+
102
+ await runner.dispatch("retention:job:sweeper");
103
+ await waitFor(() => {
104
+ expect(completed.some((c) => c.name === "retention:job:sweeper")).toBe(true);
105
+ });
106
+
107
+ const idsToCheck = [tickId, wrapperId, eventId, cronId, ...childIds].filter(
108
+ (id): id is string => id !== undefined,
109
+ );
110
+ for (const id of idsToCheck) {
111
+ await waitFor(async () => {
112
+ expect(await rawQueue.getJob(id)).toBeUndefined();
113
+ });
114
+ }
115
+ } finally {
116
+ await rawQueue.close();
117
+ await runner.stop();
118
+ await purgeQueueKeys(queueNamePrefix);
119
+ }
120
+ });
121
+
122
+ test("failed jobs are swept after the age once a later failure completes", async () => {
123
+ const queueNamePrefix = uniquePrefix("failed");
124
+ const failed: Array<{ name: string; jobId: string }> = [];
125
+
126
+ const feature = defineFeature("retentionfail", (r) => {
127
+ r.job("boom", { trigger: { manual: true } }, async () => {
128
+ throw new Error("always fails");
129
+ });
130
+ });
131
+
132
+ const registry = createRegistry([feature]);
133
+ const context: AppContext = {};
134
+ const runner = createJobRunner({
135
+ registry,
136
+ context,
137
+ redisUrl,
138
+ consumerLane: "worker",
139
+ queueNamePrefix,
140
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
141
+ onJobFailed: (name, jobId) => {
142
+ failed.push({ name, jobId });
143
+ },
144
+ });
145
+
146
+ const rawQueue = rawWorkerQueue(queueNamePrefix);
147
+ try {
148
+ await runner.start();
149
+ const firstId = await runner.dispatch("retentionfail:job:boom");
150
+ await waitFor(() => {
151
+ expect(failed.some((f) => f.jobId === firstId)).toBe(true);
152
+ });
153
+
154
+ await sleep(1200);
155
+
156
+ const secondId = await runner.dispatch("retentionfail:job:boom");
157
+ await waitFor(() => {
158
+ expect(failed.some((f) => f.jobId === secondId)).toBe(true);
159
+ });
160
+
161
+ await waitFor(async () => {
162
+ expect(await rawQueue.getJob(firstId)).toBeUndefined();
163
+ });
164
+ } finally {
165
+ await rawQueue.close();
166
+ await runner.stop();
167
+ await purgeQueueKeys(queueNamePrefix);
168
+ }
169
+ });
170
+
171
+ test("runOnBoot marker survives the retention sweep and still dedupes across restarts", async () => {
172
+ const queueNamePrefix = uniquePrefix("boot");
173
+ let bootRuns = 0;
174
+ let tickRuns = 0;
175
+
176
+ const feature = defineFeature("retentionboot", (r) => {
177
+ r.job("boot", { trigger: { manual: true }, runOnBoot: true }, async () => {
178
+ bootRuns += 1;
179
+ });
180
+ r.job("tick", { trigger: { manual: true } }, async () => {
181
+ tickRuns += 1;
182
+ });
183
+ });
184
+
185
+ const registry1 = createRegistry([feature]);
186
+ const context: AppContext = {};
187
+ const runner1 = createJobRunner({
188
+ registry: registry1,
189
+ context,
190
+ redisUrl,
191
+ consumerLane: "worker",
192
+ queueNamePrefix,
193
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
194
+ });
195
+
196
+ const rawQueue = rawWorkerQueue(queueNamePrefix);
197
+ const bootJobId = bootJobIdForJobName("retentionboot:job:boot");
198
+ try {
199
+ await runner1.start();
200
+ await waitFor(() => {
201
+ expect(bootRuns).toBe(1);
202
+ });
203
+
204
+ await sleep(1200);
205
+ await runner1.dispatch("retentionboot:job:tick");
206
+ await waitFor(() => {
207
+ expect(tickRuns).toBe(1);
208
+ });
209
+
210
+ // Proves the sweep actually ran — without it this assertion (and the
211
+ // regression it guards) would pass on the old fixed-job-id dedup too.
212
+ await waitFor(async () => {
213
+ expect(await rawQueue.getJob(bootJobId)).toBeUndefined();
214
+ });
215
+
216
+ await runner1.stop();
217
+
218
+ const registry2 = createRegistry([feature]);
219
+ const runner2 = createJobRunner({
220
+ registry: registry2,
221
+ context,
222
+ redisUrl,
223
+ consumerLane: "worker",
224
+ queueNamePrefix,
225
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
226
+ });
227
+ try {
228
+ await runner2.start();
229
+ await sleep(300);
230
+ expect(bootRuns).toBe(1);
231
+ } finally {
232
+ await runner2.stop();
233
+ }
234
+ } finally {
235
+ await rawQueue.close();
236
+ await purgeQueueKeys(queueNamePrefix);
237
+ }
238
+ });
239
+
240
+ test("createJobRunner throws for a perTenant job whose retry window reaches the completed retention", () => {
241
+ const feature = defineFeature("retentioninvariant", (r) => {
242
+ r.job(
243
+ "risky",
244
+ {
245
+ trigger: { manual: true },
246
+ perTenant: true,
247
+ retries: 2,
248
+ backoff: { type: "fixed", delayMs: 1000 },
249
+ },
250
+ async () => {},
251
+ );
252
+ });
253
+ const registry = createRegistry([feature]);
254
+ expect(() =>
255
+ createJobRunner({
256
+ registry,
257
+ context: {},
258
+ redisUrl,
259
+ consumerLane: "worker",
260
+ queueNamePrefix: uniquePrefix("invariant"),
261
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
262
+ }),
263
+ ).toThrow(/retry window/);
264
+ });
265
+
266
+ test("createJobRunner does not throw for a non-perTenant job with the same retry config", async () => {
267
+ const feature = defineFeature("retentionnonpertenant", (r) => {
268
+ r.job(
269
+ "risky",
270
+ { trigger: { manual: true }, retries: 2, backoff: { type: "fixed", delayMs: 1000 } },
271
+ async () => {},
272
+ );
273
+ });
274
+ const registry = createRegistry([feature]);
275
+ const queueNamePrefix = uniquePrefix("nonpertenant");
276
+ let ran = false;
277
+ const runner = createJobRunner({
278
+ registry,
279
+ context: {},
280
+ redisUrl,
281
+ consumerLane: "worker",
282
+ queueNamePrefix,
283
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
284
+ onJobComplete: () => {
285
+ ran = true;
286
+ },
287
+ });
288
+ try {
289
+ await runner.start();
290
+ await runner.dispatch("retentionnonpertenant:job:risky");
291
+ await waitFor(() => {
292
+ expect(ran).toBe(true);
293
+ });
294
+ } finally {
295
+ await runner.stop();
296
+ await purgeQueueKeys(queueNamePrefix);
297
+ }
298
+ });
299
+
300
+ test.each([0, 1.5])("createJobRunner throws for jobRetention.completedAgeSec = %p", (value) => {
301
+ const feature = defineFeature("retentionbadvalue", (r) => {
302
+ r.job("noop", { trigger: { manual: true } }, async () => {});
303
+ });
304
+ const registry = createRegistry([feature]);
305
+ expect(() =>
306
+ createJobRunner({
307
+ registry,
308
+ context: {},
309
+ redisUrl,
310
+ consumerLane: "worker",
311
+ queueNamePrefix: uniquePrefix("badvalue"),
312
+ jobRetention: { completedAgeSec: value },
313
+ }),
314
+ ).toThrow();
315
+ });
316
+ });