@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -1,56 +1 @@
1
- // Metric + Meter contract. Feature code interacts through ctx.metrics
2
- // (MetricsHandle); the framework wires Counter/Histogram/Gauge behind that.
3
-
4
- export type MetricLabels = Record<string, string | number | boolean>;
5
-
6
- export type MetricType = "counter" | "histogram" | "gauge";
7
-
8
- export type MetricDefinition = {
9
- // Fully-qualified name (with kumiko_<feature>_ prefix already applied).
10
- readonly name: string;
11
- readonly type: MetricType;
12
- readonly description?: string;
13
- // Declared label keys. Inc/observe calls with unknown label keys throw.
14
- readonly labels?: readonly string[];
15
- // Buckets only for histogram. If omitted, provider-default is used.
16
- readonly buckets?: readonly number[];
17
- readonly unit?: string;
18
- // If true, the framework auto-injects tenant_id from the active ctx.
19
- // Default false — adding tenant_id multiplies cardinality by tenant count.
20
- readonly tenantLabel?: boolean;
21
- };
22
-
23
- export interface Counter {
24
- inc(value?: number, labels?: MetricLabels): void;
25
- }
26
-
27
- export interface Histogram {
28
- observe(value: number, labels?: MetricLabels): void;
29
- }
30
-
31
- export interface Gauge {
32
- set(value: number, labels?: MetricLabels): void;
33
- inc(value?: number, labels?: MetricLabels): void;
34
- dec(value?: number, labels?: MetricLabels): void;
35
- }
36
-
37
- export interface Meter {
38
- // Called once per metric during boot. Duplicate names throw.
39
- registerMetric(def: MetricDefinition): void;
40
- // Lookup by fully-qualified name. Unknown names throw — typed access only.
41
- counter(name: string): Counter;
42
- histogram(name: string): Histogram;
43
- gauge(name: string): Gauge;
44
- // List of registered definitions — used by ctx.metrics to validate labels.
45
- definitions(): ReadonlyMap<string, MetricDefinition>;
46
- }
47
-
48
- // Public handle for feature code — ctx.metrics.
49
- // Name resolution uses the fully-qualified name (kumiko_<feature>_<short>).
50
- // The registrar resolves the short name from the calling feature context
51
- // at boot time; at handler time the map is ready.
52
- export interface MetricsHandle {
53
- inc(name: string, labels?: MetricLabels, value?: number): void;
54
- observe(name: string, value: number, labels?: MetricLabels): void;
55
- set(name: string, value: number, labels?: MetricLabels): void;
56
- }
1
+ export * from "@cosmicdrift/kumiko-types/observability/metric";
@@ -1,32 +1 @@
1
- // Observability provider contract — the "plug" that implementations (noop,
2
- // console, otlp, prometheus) fulfil. Configuration types that consumers
3
- // (buildServer, setupTestStack) hand in also live here.
4
-
5
- import type { Meter } from "./metric";
6
- import type { Tracer } from "./span";
7
-
8
- export type SamplingConfig = {
9
- // Base sampling rate 0..1. Default 1 in v1 (sample everything).
10
- readonly tracing?: number;
11
- readonly alwaysOnError?: boolean;
12
- readonly alwaysOnSlow?: { readonly thresholdMs: number };
13
- };
14
-
15
- export type SensitiveFilterConfig = {
16
- readonly redactedHeaders: readonly string[];
17
- readonly redactedQueryParams: readonly string[];
18
- readonly redactedAttributeKeyPatterns: readonly RegExp[];
19
- };
20
-
21
- export type ObservabilityOptions = {
22
- readonly sampling?: SamplingConfig;
23
- readonly sensitiveFilter?: Partial<SensitiveFilterConfig>;
24
- };
25
-
26
- export interface ObservabilityProvider {
27
- readonly name: string;
28
- readonly tracer: Tracer;
29
- readonly meter: Meter;
30
- // Graceful flush. Called from framework lifecycle shutdown.
31
- shutdown(): Promise<void>;
32
- }
1
+ export * from "@cosmicdrift/kumiko-types/observability/provider";
@@ -1,58 +1 @@
1
- // Span + Tracer contract. Provider implementations (noop, recording → console/otlp)
2
- // all speak this — everything else (middleware, dispatcher, db, redis, jobs)
3
- // is provider-agnostic.
4
-
5
- export type SpanAttributeValue = string | number | boolean;
6
- export type SpanAttributes = Record<string, SpanAttributeValue>;
7
-
8
- export type SpanStatus = "unset" | "ok" | "error";
9
-
10
- export type SpanKind = "internal" | "server" | "client" | "producer" | "consumer";
11
-
12
- // Serialized form of a trace context — what we pass across process boundaries
13
- // (outbox row, BullMQ job payload). Matches W3C trace-context spec loosely,
14
- // but staying minimal — a full W3C parser is v2.
15
- export type SerializedTraceContext = {
16
- readonly traceId: string;
17
- readonly spanId: string;
18
- readonly baggage?: Readonly<Record<string, string>>;
19
- };
20
-
21
- export type StartSpanOptions = {
22
- // Either a live Span (normal in-process parent) or a serialized trace
23
- // context (cross-process — outbox row, job payload). Both carry traceId
24
- // and spanId, so the tracer reads them uniformly. Omitted → fall back to
25
- // the AsyncLocalStorage active span.
26
- readonly parent?: Span | SerializedTraceContext;
27
- readonly attributes?: SpanAttributes;
28
- readonly kind?: SpanKind;
29
- readonly startTime?: number;
30
- };
31
-
32
- export interface Span {
33
- readonly traceId: string;
34
- readonly spanId: string;
35
- readonly parentSpanId: string | undefined;
36
- readonly name: string;
37
- setAttribute(key: string, value: SpanAttributeValue): void;
38
- setAttributes(attrs: SpanAttributes): void;
39
- setStatus(status: SpanStatus, message?: string): void;
40
- recordException(error: Error): void;
41
- end(endTime?: number): void;
42
- // Whether end() has been called. Idempotency guard for auto-wrappers.
43
- readonly ended: boolean;
44
- }
45
-
46
- export interface Tracer {
47
- startSpan(name: string, options?: StartSpanOptions): Span;
48
- // Runs fn inside the span context (AsyncLocalStorage), ends the span
49
- // automatically — including on thrown errors, where the error is recorded
50
- // and status set to "error" before re-throwing.
51
- withSpan<T>(
52
- name: string,
53
- optionsOrFn: StartSpanOptions | ((span: Span) => Promise<T>),
54
- fn?: (span: Span) => Promise<T>,
55
- ): Promise<T>;
56
- // Current active span from AsyncLocalStorage, or undefined.
57
- getActiveSpan(): Span | undefined;
58
- }
1
+ export * from "@cosmicdrift/kumiko-types/observability/span";
@@ -25,6 +25,17 @@ const echoFeature = defineFeature("echo", (r) => {
25
25
  { access: { openToAll: true } },
26
26
  );
27
27
 
28
+ r.streamHandler(
29
+ "item:tail",
30
+ z.object({ count: z.number().int().min(0) }),
31
+ async function* (query) {
32
+ for (let i = 0; i < query.payload.count; i++) {
33
+ yield { i };
34
+ }
35
+ },
36
+ { access: { roles: ["Admin"] } },
37
+ );
38
+
28
39
  r.hook("validation", "item:create", (data) => {
29
40
  if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
30
41
  return null;
@@ -214,6 +225,55 @@ describe("dispatcher.query", () => {
214
225
  });
215
226
  });
216
227
 
228
+ async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
229
+ const out: T[] = [];
230
+ for await (const chunk of gen) out.push(chunk);
231
+ return out;
232
+ }
233
+
234
+ describe("dispatcher.stream", () => {
235
+ test("validates and calls stream handler, yielding chunks in order", async () => {
236
+ const dispatcher = createTestDispatcher();
237
+ const chunks = await collect(
238
+ dispatcher.stream(
239
+ "echo:stream:item:tail",
240
+ { count: 3 },
241
+ createTestUser({ roles: ["Admin"] }),
242
+ ),
243
+ );
244
+
245
+ expect(chunks).toEqual([{ i: 0 }, { i: 1 }, { i: 2 }]);
246
+ });
247
+
248
+ test("gates fire on first pull, not on the stream() call itself", async () => {
249
+ const dispatcher = createTestDispatcher();
250
+ const gen = dispatcher.stream(
251
+ "echo:stream:item:tail",
252
+ { count: -1 },
253
+ createTestUser({ roles: ["Admin"] }),
254
+ );
255
+
256
+ await expect(collect(gen)).rejects.toMatchObject({ code: "validation_error", httpStatus: 400 });
257
+ });
258
+
259
+ test("rejects unauthorized user", async () => {
260
+ const dispatcher = createTestDispatcher();
261
+ const guest = createTestUser({ roles: ["Guest"] });
262
+
263
+ await expect(
264
+ collect(dispatcher.stream("echo:stream:item:tail", { count: 1 }, guest)),
265
+ ).rejects.toMatchObject({ code: "access_denied" });
266
+ });
267
+
268
+ test("throws for unknown stream handler", async () => {
269
+ const dispatcher = createTestDispatcher();
270
+
271
+ await expect(
272
+ collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
273
+ ).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
274
+ });
275
+ });
276
+
217
277
  // --- postQuery hooks on standalone (entity-less) queries ---
218
278
 
219
279
  describe("dispatcher.query postQuery hooks", () => {
@@ -362,6 +422,20 @@ describe("dispatcher feature-gate", () => {
362
422
  async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
363
423
  { access: { roles: ["Admin"] } },
364
424
  );
425
+ r.streamHandler(
426
+ "widget:tail",
427
+ z.object({}).passthrough(),
428
+ async function* () {
429
+ // Never completes on its own — the mid-stream toggle-off test
430
+ // relies on the generator staying open past the first yield.
431
+ let i = 0;
432
+ while (true) {
433
+ yield { i };
434
+ i++;
435
+ }
436
+ },
437
+ { access: { openToAll: true } },
438
+ );
365
439
  });
366
440
  }
367
441
 
@@ -397,6 +471,28 @@ describe("dispatcher feature-gate", () => {
397
471
  });
398
472
  });
399
473
 
474
+ test("disabling the feature mid-stream aborts an already-open stream", async () => {
475
+ const registry = createRegistry([toggled()]);
476
+ const disabled = new Set<string>();
477
+ const dispatcher = createDispatcher(
478
+ registry,
479
+ {},
480
+ {
481
+ effectiveFeatures: () => {
482
+ const all = new Set(registry.features.keys());
483
+ for (const d of disabled) all.delete(d);
484
+ return all;
485
+ },
486
+ },
487
+ );
488
+
489
+ const gen = dispatcher.stream("toggled:stream:widget:tail", {}, user);
490
+ expect((await gen.next()).value).toEqual({ i: 0 });
491
+
492
+ disabled.add("toggled");
493
+ await expect(gen.next()).rejects.toThrow(/feature toggled is disabled/);
494
+ });
495
+
400
496
  test("write of disabled feature returns WriteFailure with feature_disabled reason", async () => {
401
497
  const registry = createRegistry([toggled()]);
402
498
  const disabled = new Set<string>(["toggled"]);
@@ -509,7 +605,7 @@ describe("dispatcher feature-gate", () => {
509
605
  r.queryHandler(
510
606
  "check",
511
607
  z.object({}).passthrough(),
512
- async (_event, ctx) => ({ enabled: ctx.hasFeature("toggled") }),
608
+ async (_event, ctx) => ({ enabled: await ctx.hasFeature("toggled") }),
513
609
  { access: { openToAll: true } },
514
610
  );
515
611
  });
@@ -533,6 +629,43 @@ describe("dispatcher feature-gate", () => {
533
629
  enabled: false,
534
630
  });
535
631
  });
632
+
633
+ test("issue #1233: ctx.hasFeature falls back to trialGate for a companion feature the tenant doesn't own", async () => {
634
+ // Regression for the trial-tenant bug: a handler owned by an always-on
635
+ // feature checks a DIFFERENT (trial-tier) feature via ctx.hasFeature.
636
+ // The sync resolver never carries trial features, so without the
637
+ // trialGate fallback this silently reads false for a trial tenant even
638
+ // though the tenant is genuinely in-trial for that companion feature —
639
+ // mirrors packages/bundled-features/src/managed-pages/handlers/branding.query.ts
640
+ // checking MANAGED_PAGES_CSS_FEATURE, which no handler owns.
641
+ const trialTenant = "00000000-0000-4000-8000-0000000000c5" as TenantId;
642
+ const plainTenant = "00000000-0000-4000-8000-0000000000d6" as TenantId;
643
+
644
+ const probe = defineFeature("probe", (r) => {
645
+ r.queryHandler(
646
+ "check",
647
+ z.object({}).passthrough(),
648
+ async (_event, ctx) => ({ enabled: await ctx.hasFeature("premium-companion") }),
649
+ { access: { openToAll: true } },
650
+ );
651
+ });
652
+ const registry = createRegistry([probe]);
653
+ const effectiveFeatures = Object.assign(() => new Set(["probe"]), {
654
+ trialGate: async (tenantId: TenantId, feature: string) =>
655
+ tenantId === trialTenant && feature === "premium-companion",
656
+ });
657
+ const dispatcher = createDispatcher(registry, {}, { effectiveFeatures });
658
+
659
+ const trialUser = createTestUser({ id: "u-c5", tenantId: trialTenant, roles: ["Admin"] });
660
+ const plainUser = createTestUser({ id: "u-d6", tenantId: plainTenant, roles: ["Admin"] });
661
+
662
+ await expect(dispatcher.query("probe:query:check", {}, trialUser)).resolves.toEqual({
663
+ enabled: true,
664
+ });
665
+ await expect(dispatcher.query("probe:query:check", {}, plainUser)).resolves.toEqual({
666
+ enabled: false,
667
+ });
668
+ });
536
669
  });
537
670
 
538
671
  describe("write-handler shape guard", () => {
@@ -0,0 +1,126 @@
1
+ // EventConsumerErrorPolicy.maxAttempts lets a consumer override the
2
+ // dispatcher-wide default (kumiko-framework#1349) — a consumer depending on
3
+ // infra that may still be provisioning at boot (search adapter) needs more
4
+ // retry headroom before it gets dead-lettered. deliverEvents is a pure
5
+ // free function, so this is unit-level: no DB, no dispatcher lifecycle.
6
+
7
+ import { beforeAll, describe, expect, test } from "bun:test";
8
+ import type { AppContext } from "../../engine/types";
9
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
10
+ import type { EventConsumer } from "../event-dispatcher";
11
+ import {
12
+ type ConsumerStateRow,
13
+ deliverEvents,
14
+ type StoredEventRow,
15
+ } from "../event-dispatcher-delivery";
16
+ import { createSearchEventConsumer } from "../system-hooks";
17
+
18
+ beforeAll(async () => {
19
+ await ensureTemporalPolyfill();
20
+ });
21
+
22
+ function stubContext(): AppContext {
23
+ return {
24
+ db: {} as unknown as AppContext["db"],
25
+ redis: {} as unknown as AppContext["redis"],
26
+ registry: {} as unknown as AppContext["registry"],
27
+ } as AppContext;
28
+ }
29
+
30
+ function stubEvent(id: bigint): StoredEventRow {
31
+ return {
32
+ id,
33
+ aggregateId: "agg-1",
34
+ aggregateType: "widget",
35
+ tenantId: "tenant-1",
36
+ version: 1,
37
+ type: "widget.created",
38
+ eventVersion: 1,
39
+ payload: {},
40
+ metadata: { userId: "system" },
41
+ createdAt: Temporal.Now.instant(),
42
+ createdBy: "system",
43
+ };
44
+ }
45
+
46
+ function stubState(): ConsumerStateRow {
47
+ return {
48
+ name: "consumer",
49
+ instanceId: "shared",
50
+ lastProcessedEventId: 0n,
51
+ status: "idle",
52
+ attempts: 0,
53
+ rearmCount: 0,
54
+ lastError: null,
55
+ updatedAt: Temporal.Now.instant(),
56
+ };
57
+ }
58
+
59
+ describe("deliverEvents — per-consumer maxAttempts override", () => {
60
+ test("stays alive past the dispatcher-wide default when errorPolicy.maxAttempts is higher", async () => {
61
+ const consumer: EventConsumer = {
62
+ name: "search",
63
+ errorPolicy: { maxAttempts: 20 },
64
+ handler: async () => {
65
+ throw new Error("meilisearch not provisioned yet");
66
+ },
67
+ };
68
+
69
+ const outcome = await deliverEvents(
70
+ consumer,
71
+ [stubEvent(1n)],
72
+ stubContext(),
73
+ /* dispatcher-wide default */ 10,
74
+ { ...stubState(), attempts: 10 },
75
+ );
76
+
77
+ expect(outcome.attempts).toBe(11);
78
+ expect(outcome.deadLettered).toBe(false);
79
+ });
80
+
81
+ test("dead-letters at the override once attempts reach it", async () => {
82
+ const consumer: EventConsumer = {
83
+ name: "search",
84
+ errorPolicy: { maxAttempts: 20 },
85
+ handler: async () => {
86
+ throw new Error("meilisearch not provisioned yet");
87
+ },
88
+ };
89
+
90
+ const outcome = await deliverEvents(consumer, [stubEvent(1n)], stubContext(), 10, {
91
+ ...stubState(),
92
+ attempts: 19,
93
+ });
94
+
95
+ expect(outcome.attempts).toBe(20);
96
+ expect(outcome.deadLettered).toBe(true);
97
+ });
98
+
99
+ test("a consumer without the override still dies at the dispatcher-wide default", async () => {
100
+ const consumer: EventConsumer = {
101
+ name: "plain",
102
+ handler: async () => {
103
+ throw new Error("boom");
104
+ },
105
+ };
106
+
107
+ const outcome = await deliverEvents(consumer, [stubEvent(1n)], stubContext(), 10, {
108
+ ...stubState(),
109
+ attempts: 9,
110
+ });
111
+
112
+ expect(outcome.attempts).toBe(10);
113
+ expect(outcome.deadLettered).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("createSearchEventConsumer — errorPolicy wiring", () => {
118
+ test("wires more retry headroom than the dispatcher-wide default", () => {
119
+ const consumer = createSearchEventConsumer(
120
+ {} as unknown as Parameters<typeof createSearchEventConsumer>[0],
121
+ {} as unknown as Parameters<typeof createSearchEventConsumer>[1],
122
+ );
123
+
124
+ expect(consumer.errorPolicy?.maxAttempts).toBeGreaterThan(10);
125
+ });
126
+ });
@@ -0,0 +1,180 @@
1
+ // Issue #1350 — bounded auto-revival of a dead consumer.
2
+ //
3
+ // acquireConsumerState no longer parks a dead consumer forever: once its
4
+ // last write is older than rearmCooldownMs it gets reset to idle and
5
+ // retried, up to maxRearmCount times. A poison event (permanent failure)
6
+ // still ends up permanently dead once the budget is exhausted — the
7
+ // mechanism is a bounded number of extra chances, not unlimited retry.
8
+
9
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
10
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
11
+ import { asRawClient } from "../../db/query";
12
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
13
+ import { defineFeature } from "../../engine";
14
+ import { getConsumerState } from "../../pipeline";
15
+ import {
16
+ resetEventStore,
17
+ setupTestStack,
18
+ type TestStack,
19
+ TestUsers,
20
+ unsafeCreateEntityTable,
21
+ } from "../../stack";
22
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
23
+ import { SHARED_INSTANCE_SENTINEL } from "../event-consumer-state";
24
+
25
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
26
+ entityName: "widget",
27
+ });
28
+
29
+ let poisonNames = new Set<string>();
30
+ let observed: Array<{ name: string }> = [];
31
+
32
+ const rearmFeature = defineFeature("rearmtest", (r) => {
33
+ r.entity("widget", sharedWidgetEntity);
34
+
35
+ r.multiStreamProjection({
36
+ name: "observer",
37
+ apply: {
38
+ "widget.created": async (event) => {
39
+ const name = event.payload["name"] as string;
40
+ if (poisonNames.has(name)) {
41
+ throw new Error(`poisoned: ${name}`);
42
+ }
43
+ observed.push({ name });
44
+ },
45
+ },
46
+ });
47
+ });
48
+
49
+ const admin = TestUsers.admin;
50
+ const qn = "rearmtest:projection:observer";
51
+ let stack: TestStack;
52
+ let tdb: TenantDb;
53
+
54
+ beforeAll(async () => {
55
+ stack = await setupTestStack({
56
+ features: [rearmFeature],
57
+ systemHooks: [],
58
+ });
59
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
60
+ tdb = createTenantDb(stack.db, admin.tenantId);
61
+ });
62
+
63
+ afterEach(async () => {
64
+ poisonNames = new Set();
65
+ observed = [];
66
+ await resetEventStore(stack, ["read_widgets"]);
67
+ });
68
+
69
+ async function appendWidget(name: string): Promise<void> {
70
+ await executor.create({ name }, admin, tdb);
71
+ }
72
+
73
+ async function driveUntilDead(): Promise<void> {
74
+ for (let i = 0; i < 10; i++) {
75
+ await stack.eventDispatcher?.runOnce();
76
+ }
77
+ }
78
+
79
+ // Simulates cooldown elapsed without waiting real wall-clock time.
80
+ async function backdateUpdatedAt(minutesAgo: number): Promise<void> {
81
+ await asRawClient(stack.db).unsafe(
82
+ `UPDATE "kumiko_event_consumers" SET "updated_at" = now() - make_interval(mins => $1)
83
+ WHERE "name" = $2 AND "instance_id" = $3`,
84
+ [minutesAgo, qn, SHARED_INSTANCE_SENTINEL],
85
+ );
86
+ }
87
+
88
+ describe("issue #1350 — bounded dead-consumer re-arm", () => {
89
+ test("stays dead within the cooldown window", async () => {
90
+ poisonNames.add("poison");
91
+ await appendWidget("poison");
92
+ await driveUntilDead();
93
+
94
+ const dead = await getConsumerState(stack.db, qn);
95
+ expect(dead?.status).toBe("dead");
96
+ expect(dead?.rearmCount).toBe(0);
97
+
98
+ // No backdate — cooldown (5min default) hasn't elapsed.
99
+ await stack.eventDispatcher?.runOnce();
100
+ const stillDead = await getConsumerState(stack.db, qn);
101
+ expect(stillDead?.status).toBe("dead");
102
+ expect(stillDead?.rearmCount).toBe(0);
103
+ });
104
+
105
+ test("auto-revives once cooldown elapses, and re-dies on the same poison event", async () => {
106
+ poisonNames.add("poison");
107
+ await appendWidget("poison");
108
+ await driveUntilDead();
109
+
110
+ await backdateUpdatedAt(10);
111
+ await stack.eventDispatcher?.runOnce();
112
+
113
+ const revived = await getConsumerState(stack.db, qn);
114
+ expect(revived?.status).toBe("idle");
115
+ expect(revived?.rearmCount).toBe(1);
116
+ // Same pass that revives it also retries the pending poisoned event —
117
+ // acquireConsumerState's reset (attempts=0) is followed immediately by
118
+ // one failed delivery attempt within this runOnce() call.
119
+ expect(revived?.attempts).toBe(1);
120
+
121
+ // Handler still throws on "poison" — climbs back to dead.
122
+ await driveUntilDead();
123
+ const deadAgain = await getConsumerState(stack.db, qn);
124
+ expect(deadAgain?.status).toBe("dead");
125
+ expect(deadAgain?.rearmCount).toBe(1);
126
+ });
127
+
128
+ test("a re-arm that leads to a real delivery resets rearmCount to 0", async () => {
129
+ poisonNames.add("poison");
130
+ await appendWidget("poison");
131
+ await driveUntilDead();
132
+
133
+ // Cause is gone by the time the cooldown elapses — the retried event
134
+ // now succeeds instead of re-poisoning the consumer.
135
+ poisonNames.delete("poison");
136
+ await backdateUpdatedAt(10);
137
+ await stack.eventDispatcher?.runOnce();
138
+
139
+ const recovered = await getConsumerState(stack.db, qn);
140
+ expect(recovered?.status).toBe("idle");
141
+ expect(recovered?.rearmCount).toBe(0);
142
+ expect(observed.map((o) => o.name)).toEqual(["poison"]);
143
+
144
+ // A later, unrelated poison event gets its own fresh maxRearmCount
145
+ // budget instead of inheriting the spent counter from the resolved one.
146
+ poisonNames.add("later-poison");
147
+ await appendWidget("later-poison");
148
+ await driveUntilDead();
149
+ const deadAgain = await getConsumerState(stack.db, qn);
150
+ expect(deadAgain?.status).toBe("dead");
151
+ expect(deadAgain?.rearmCount).toBe(0);
152
+ });
153
+
154
+ test("permanently dead after maxRearmCount cycles, even once cooldown elapses again", async () => {
155
+ poisonNames.add("poison");
156
+ await appendWidget("poison");
157
+ await driveUntilDead();
158
+
159
+ // Cycles 1..3: each backdate+drive re-arms once, then re-dies on the
160
+ // same poison event. Default maxRearmCount is 3.
161
+ for (let cycle = 1; cycle <= 3; cycle++) {
162
+ await backdateUpdatedAt(10);
163
+ await stack.eventDispatcher?.runOnce();
164
+ const revived = await getConsumerState(stack.db, qn);
165
+ expect(revived?.rearmCount).toBe(cycle);
166
+ await driveUntilDead();
167
+ }
168
+
169
+ const exhausted = await getConsumerState(stack.db, qn);
170
+ expect(exhausted?.status).toBe("dead");
171
+ expect(exhausted?.rearmCount).toBe(3);
172
+
173
+ // Cooldown elapsed again, but the budget is spent — stays dead.
174
+ await backdateUpdatedAt(10);
175
+ await stack.eventDispatcher?.runOnce();
176
+ const stillExhausted = await getConsumerState(stack.db, qn);
177
+ expect(stillExhausted?.status).toBe("dead");
178
+ expect(stillExhausted?.rearmCount).toBe(3);
179
+ });
180
+ });