@cosmicdrift/kumiko-framework 0.164.0 → 0.165.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +62 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +326 -18
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -1
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +146 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/auth-routes.ts +178 -33
- package/src/api/index.ts +1 -0
- package/src/api/jwt.ts +22 -1
- package/src/api/routes.ts +117 -30
- package/src/api/server.ts +39 -7
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/connection.ts +3 -3
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +19 -13
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/connection.ts +6 -3
- package/src/db/dialect.ts +1 -6
- package/src/db/entity-table-meta-types.ts +1 -1
- package/src/db/event-store-executor-context.ts +2 -3
- package/src/db/event-store-executor-read.ts +2 -3
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/index.ts +8 -3
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +8 -0
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-store.ts +97 -21
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +114 -12
- package/src/db/tenant-db.ts +2 -4
- package/src/engine/__tests__/boot-validator.test.ts +14 -0
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/registry.test.ts +36 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +2 -2
- package/src/engine/boot-validator/entity-handler.ts +14 -25
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/extensions/user-data.ts +12 -4
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
- package/src/engine/feature-ast/extractors/handlers.ts +13 -18
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/hook-helpers.ts +3 -1
- package/src/engine/index.ts +1 -1
- package/src/engine/ownership.ts +4 -3
- package/src/engine/registry-ingest.ts +14 -14
- package/src/engine/registry-state.ts +4 -1
- package/src/engine/registry-validate.ts +7 -2
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/types/config.ts +1 -1
- package/src/engine/types/define-handler.ts +1 -1
- package/src/engine/types/entity-handlers.ts +1 -1
- package/src/engine/types/event-type-map.ts +1 -1
- package/src/engine/types/feature.ts +1 -1
- package/src/engine/types/fields.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +1 -1
- package/src/engine/types/http-route.ts +1 -1
- package/src/engine/types/index.ts +0 -2
- package/src/engine/types/nav.ts +1 -1
- package/src/engine/types/ownership.ts +1 -1
- package/src/engine/types/projection.ts +1 -1
- package/src/engine/types/relations.ts +1 -1
- package/src/engine/types/screen.ts +1 -1
- package/src/engine/types/step.ts +1 -1
- package/src/engine/types/target-ref.ts +1 -1
- package/src/engine/types/tree-node.ts +1 -1
- package/src/engine/types/workspace.ts +1 -1
- package/src/engine/validate-projection-allowlist.ts +5 -5
- package/src/errors/classes.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/write-error-info.ts +6 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +60 -14
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +27 -12
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +20 -21
- package/src/event-store/index.ts +0 -1
- package/src/event-store/types.ts +1 -1
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/write-stream.test.ts +3 -3
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +6 -1
- package/src/files/types.ts +8 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
- package/src/jobs/job-runner.ts +41 -11
- package/src/logging/types.ts +1 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +35 -2
- package/src/observability/types/index.ts +1 -1
- package/src/observability/types/metric.ts +1 -1
- package/src/observability/types/provider.ts +1 -1
- package/src/observability/types/span.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +203 -0
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +111 -88
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +44 -17
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +22 -7
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +137 -1
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +6 -2
- package/src/schema-cli.ts +24 -12
- package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
- package/src/search/reindex-entity.ts +31 -2
- package/src/search/types.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +2 -1
- package/src/stack/request-helper.ts +20 -1
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +18 -15
- package/src/testing/__tests__/late-bound.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +6 -0
- package/src/testing/file-provider-contract.ts +26 -6
- package/src/testing/index.ts +1 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +28 -39
- package/src/time/tz-context.ts +30 -24
- package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
- package/src/utils/safe-json.ts +3 -2
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -45
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -23,7 +23,7 @@ export const STANDARD_METRIC_DEFS: readonly MetricDefinition[] = [
|
|
|
23
23
|
name: "kumiko_dispatcher_handler_duration_seconds",
|
|
24
24
|
type: "histogram",
|
|
25
25
|
description: "Dispatcher handler latency in seconds.",
|
|
26
|
-
labels: ["handler", "success"],
|
|
26
|
+
labels: ["handler", "success", "outcome"],
|
|
27
27
|
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
|
|
28
28
|
},
|
|
29
29
|
{
|
|
@@ -83,6 +83,20 @@ export const STANDARD_METRIC_DEFS: readonly MetricDefinition[] = [
|
|
|
83
83
|
"Event deliveries that threw. Repeated failures on the same event lead to dead-letter.",
|
|
84
84
|
labels: ["consumer", "instance_id"],
|
|
85
85
|
},
|
|
86
|
+
// Fires once per (consumer, instance) transition into "stuck dead" —
|
|
87
|
+
// the rearm-cooldown/rearm-budget in event-dispatcher-delivery.ts gave up
|
|
88
|
+
// and the consumer now needs an operator (restartConsumer()) to recover.
|
|
89
|
+
// Without this, "dead + auto-rearm exhausted" is only visible via a
|
|
90
|
+
// getConsumerState poll — ops has no push signal that a consumer crossed
|
|
91
|
+
// from "will self-heal" to "needs a human". Deliberately a counter, not a
|
|
92
|
+
// gauge: emitted once per transition (event-dispatcher.ts tracks which
|
|
93
|
+
// consumers already fired this), not once per still-dead poll pass.
|
|
94
|
+
{
|
|
95
|
+
name: "kumiko_event_consumer_rearm_exhausted_total",
|
|
96
|
+
type: "counter",
|
|
97
|
+
description: "Consumer transitioned to dead with its auto-rearm budget exhausted.",
|
|
98
|
+
labels: ["consumer", "instance_id"],
|
|
99
|
+
},
|
|
86
100
|
// LISTEN-subscription health. 1 = active, 0 = dropped. Drops to 0 while
|
|
87
101
|
// the dispatcher is running signal that delivery latency has regressed
|
|
88
102
|
// from sub-millisecond (LISTEN) to pollIntervalMs (timer fallback); ops
|
|
@@ -141,12 +155,18 @@ export function emitHttpRequest(
|
|
|
141
155
|
|
|
142
156
|
export function emitDispatcherHandler(
|
|
143
157
|
meter: Meter,
|
|
144
|
-
labels: {
|
|
158
|
+
labels: {
|
|
159
|
+
readonly handler: string;
|
|
160
|
+
readonly success: boolean;
|
|
161
|
+
/** completed = drained normally; aborted = consumer walked away (SSE). */
|
|
162
|
+
readonly outcome?: "completed" | "aborted";
|
|
163
|
+
},
|
|
145
164
|
durationSeconds: number,
|
|
146
165
|
): void {
|
|
147
166
|
meter.histogram("kumiko_dispatcher_handler_duration_seconds").observe(durationSeconds, {
|
|
148
167
|
handler: labels.handler,
|
|
149
168
|
success: String(labels.success),
|
|
169
|
+
outcome: labels.outcome ?? "completed",
|
|
150
170
|
});
|
|
151
171
|
}
|
|
152
172
|
|
|
@@ -203,6 +223,19 @@ export function emitEventConsumerLag(
|
|
|
203
223
|
});
|
|
204
224
|
}
|
|
205
225
|
|
|
226
|
+
// Caller (event-dispatcher.ts) is responsible for calling this exactly
|
|
227
|
+
// once per (consumer, instanceId) transition into exhausted-dead — not
|
|
228
|
+
// once per poll pass while it stays dead.
|
|
229
|
+
export function emitEventConsumerRearmExhausted(
|
|
230
|
+
meter: Meter,
|
|
231
|
+
labels: { readonly consumer: string; readonly instanceId: string },
|
|
232
|
+
): void {
|
|
233
|
+
meter.counter("kumiko_event_consumer_rearm_exhausted_total").inc(1, {
|
|
234
|
+
consumer: labels.consumer,
|
|
235
|
+
instance_id: labels.instanceId,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
206
239
|
export function emitEventDispatcherListenConnected(meter: Meter, connected: boolean): void {
|
|
207
240
|
meter.gauge("kumiko_event_dispatcher_listen_connected").set(connected ? 1 : 0);
|
|
208
241
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/observability";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/observability";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/observability/metric";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/observability/metric";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/observability/provider";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/observability/provider";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/observability/span";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/observability/span";
|
|
@@ -2,9 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
4
4
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
5
|
+
import { createSecret } from "../../secrets/types";
|
|
5
6
|
import { createTestUser } from "../../stack";
|
|
7
|
+
import { createRecordingProvider } from "../../testing";
|
|
6
8
|
import { createDispatcher } from "../dispatcher";
|
|
7
9
|
|
|
10
|
+
const streamCleanupState = { closed: false };
|
|
11
|
+
|
|
8
12
|
const echoFeature = defineFeature("echo", (r) => {
|
|
9
13
|
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
10
14
|
|
|
@@ -36,6 +40,29 @@ const echoFeature = defineFeature("echo", (r) => {
|
|
|
36
40
|
{ access: { roles: ["Admin"] } },
|
|
37
41
|
);
|
|
38
42
|
|
|
43
|
+
r.streamHandler(
|
|
44
|
+
"item:tail-leak",
|
|
45
|
+
z.object({}),
|
|
46
|
+
async function* () {
|
|
47
|
+
yield { apiKey: createSecret("leak-me") };
|
|
48
|
+
},
|
|
49
|
+
{ access: { roles: ["Admin"] } },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
r.streamHandler(
|
|
53
|
+
"item:tail-cleanup",
|
|
54
|
+
z.object({}),
|
|
55
|
+
async function* () {
|
|
56
|
+
try {
|
|
57
|
+
yield { i: 0 };
|
|
58
|
+
yield { i: 1 };
|
|
59
|
+
} finally {
|
|
60
|
+
streamCleanupState.closed = true;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{ access: { roles: ["Admin"] } },
|
|
64
|
+
);
|
|
65
|
+
|
|
39
66
|
r.hook("validation", "item:create", (data) => {
|
|
40
67
|
if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
|
|
41
68
|
return null;
|
|
@@ -272,6 +299,182 @@ describe("dispatcher.stream", () => {
|
|
|
272
299
|
collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
|
|
273
300
|
).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
|
|
274
301
|
});
|
|
302
|
+
|
|
303
|
+
test("a chunk containing a Secret<> value aborts the stream instead of leaking it", async () => {
|
|
304
|
+
const dispatcher = createTestDispatcher();
|
|
305
|
+
|
|
306
|
+
await expect(
|
|
307
|
+
collect(
|
|
308
|
+
dispatcher.stream("echo:stream:item:tail-leak", {}, createTestUser({ roles: ["Admin"] })),
|
|
309
|
+
),
|
|
310
|
+
).rejects.toMatchObject({ message: expect.stringContaining("Secret<> leaked") });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("consumer breaking out of for-await runs the handler generator's finally block", async () => {
|
|
314
|
+
streamCleanupState.closed = false;
|
|
315
|
+
const dispatcher = createTestDispatcher();
|
|
316
|
+
const gen = dispatcher.stream(
|
|
317
|
+
"echo:stream:item:tail-cleanup",
|
|
318
|
+
{},
|
|
319
|
+
createTestUser({ roles: ["Admin"] }),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
for await (const _chunk of gen) {
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
expect(streamCleanupState.closed).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("activates the dispatcher span in observabilityContext for every pull, not just the first", async () => {
|
|
330
|
+
// Regression for #1516: runStreamInstrumented used to start the span but
|
|
331
|
+
// never run the stream inside observabilityContext.run(...), so a
|
|
332
|
+
// handler-side startSpan() picked up whatever ALS context happened to be
|
|
333
|
+
// active at that pull (or none) instead of the stream's dispatcher span.
|
|
334
|
+
const provider = createRecordingProvider();
|
|
335
|
+
const nestedFeature = defineFeature("nested", (r) => {
|
|
336
|
+
r.streamHandler(
|
|
337
|
+
"tail",
|
|
338
|
+
z.object({ count: z.number().int().min(0) }),
|
|
339
|
+
async function* (query, handlerCtx) {
|
|
340
|
+
for (let i = 0; i < query.payload.count; i++) {
|
|
341
|
+
const child = handlerCtx.tracer.startSpan("nested.child");
|
|
342
|
+
child.end();
|
|
343
|
+
yield { i };
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
{ access: { openToAll: true } },
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
const dispatcher = createDispatcher(createRegistry([nestedFeature]), {
|
|
350
|
+
tracer: provider.tracer,
|
|
351
|
+
meter: provider.meter,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await collect(dispatcher.stream("nested:stream:tail", { count: 3 }, createTestUser()));
|
|
355
|
+
|
|
356
|
+
const dispatcherSpan = provider.spansByName("kumiko.dispatcher.handler")[0]!;
|
|
357
|
+
const childSpans = provider.spansByName("nested.child");
|
|
358
|
+
expect(childSpans).toHaveLength(3);
|
|
359
|
+
for (const child of childSpans) {
|
|
360
|
+
expect(child.parentSpanId).toBe(dispatcherSpan.spanId);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("a close-time error on an otherwise-clean stream is reported, not swallowed", async () => {
|
|
365
|
+
// Regression for #1543: runStreamInstrumented's finally used to
|
|
366
|
+
// catch+discard any error from generator.return() unconditionally, even
|
|
367
|
+
// when nothing else had failed — an abandoned stream whose handler's
|
|
368
|
+
// own cleanup throws (closing a cursor/unsubscribing) was invisible to
|
|
369
|
+
// both the error-counter and the span status.
|
|
370
|
+
const provider = createRecordingProvider();
|
|
371
|
+
const closeFailFeature = defineFeature("closefail", (r) => {
|
|
372
|
+
r.streamHandler(
|
|
373
|
+
"tail",
|
|
374
|
+
z.object({}),
|
|
375
|
+
async function* () {
|
|
376
|
+
try {
|
|
377
|
+
yield { i: 0 };
|
|
378
|
+
yield { i: 1 };
|
|
379
|
+
} finally {
|
|
380
|
+
// biome-ignore lint/correctness/noUnsafeFinally: deliberately simulating a handler whose close-time cleanup fails
|
|
381
|
+
throw new Error("close-boom");
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
{ access: { openToAll: true } },
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
const dispatcher = createDispatcher(createRegistry([closeFailFeature]), {
|
|
388
|
+
tracer: provider.tracer,
|
|
389
|
+
meter: provider.meter,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const gen = dispatcher.stream("closefail:stream:tail", {}, createTestUser());
|
|
393
|
+
for await (const _chunk of gen) {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const errorCounters = provider.metricEvents.filter(
|
|
398
|
+
(e) => e.type === "counter.inc" && e.name === "kumiko_dispatcher_handler_errors_total",
|
|
399
|
+
);
|
|
400
|
+
expect(errorCounters).toHaveLength(1);
|
|
401
|
+
const dispatcherSpan = provider.spansByName("kumiko.dispatcher.handler")[0]!;
|
|
402
|
+
expect(dispatcherSpan.status).toBe("error");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("draining a stream to completion emits outcome:completed", async () => {
|
|
406
|
+
// completedNormally=true only when the inner generator finishes on its
|
|
407
|
+
// own — pins the success-path label so a refactor that drops the flag
|
|
408
|
+
// cannot silently reclassify clean streams as aborted (#1551).
|
|
409
|
+
const provider = createRecordingProvider();
|
|
410
|
+
const feature = defineFeature("complete", (r) => {
|
|
411
|
+
r.streamHandler(
|
|
412
|
+
"tail",
|
|
413
|
+
z.object({}),
|
|
414
|
+
async function* () {
|
|
415
|
+
yield { i: 0 };
|
|
416
|
+
yield { i: 1 };
|
|
417
|
+
},
|
|
418
|
+
{ access: { openToAll: true } },
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
const dispatcher = createDispatcher(createRegistry([feature]), {
|
|
422
|
+
tracer: provider.tracer,
|
|
423
|
+
meter: provider.meter,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await collect(dispatcher.stream("complete:stream:tail", {}, createTestUser()));
|
|
427
|
+
|
|
428
|
+
const duration = provider.metricEvents.find(
|
|
429
|
+
(e) =>
|
|
430
|
+
e.type === "histogram.observe" && e.name === "kumiko_dispatcher_handler_duration_seconds",
|
|
431
|
+
);
|
|
432
|
+
expect(duration).toBeDefined();
|
|
433
|
+
expect(duration?.labels).toMatchObject({
|
|
434
|
+
handler: "complete:stream:tail",
|
|
435
|
+
success: "true",
|
|
436
|
+
outcome: "completed",
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("generator.return() mid-stream emits outcome:aborted, not completed", async () => {
|
|
441
|
+
// Consumer walk-away (SSE tab-close) must not count as a clean
|
|
442
|
+
// completion — completedNormally stays false when return() interrupts
|
|
443
|
+
// the loop (#1551).
|
|
444
|
+
const provider = createRecordingProvider();
|
|
445
|
+
const feature = defineFeature("abort", (r) => {
|
|
446
|
+
r.streamHandler(
|
|
447
|
+
"tail",
|
|
448
|
+
z.object({}),
|
|
449
|
+
async function* () {
|
|
450
|
+
yield { i: 0 };
|
|
451
|
+
yield { i: 1 };
|
|
452
|
+
yield { i: 2 };
|
|
453
|
+
},
|
|
454
|
+
{ access: { openToAll: true } },
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
const dispatcher = createDispatcher(createRegistry([feature]), {
|
|
458
|
+
tracer: provider.tracer,
|
|
459
|
+
meter: provider.meter,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const gen = dispatcher.stream("abort:stream:tail", {}, createTestUser());
|
|
463
|
+
const first = await gen.next();
|
|
464
|
+
expect(first.value).toEqual({ i: 0 });
|
|
465
|
+
await gen.return(undefined);
|
|
466
|
+
|
|
467
|
+
const duration = provider.metricEvents.find(
|
|
468
|
+
(e) =>
|
|
469
|
+
e.type === "histogram.observe" && e.name === "kumiko_dispatcher_handler_duration_seconds",
|
|
470
|
+
);
|
|
471
|
+
expect(duration).toBeDefined();
|
|
472
|
+
expect(duration?.labels).toMatchObject({
|
|
473
|
+
handler: "abort:stream:tail",
|
|
474
|
+
success: "true",
|
|
475
|
+
outcome: "aborted",
|
|
476
|
+
});
|
|
477
|
+
});
|
|
275
478
|
});
|
|
276
479
|
|
|
277
480
|
// --- postQuery hooks on standalone (entity-less) queries ---
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTestDb, type TestDb } from "../../stack";
|
|
3
|
+
import { createEventConsumerStateTable } from "../event-consumer-state";
|
|
4
|
+
|
|
5
|
+
// #1362: two dispatcher instances booting concurrently against the same DB
|
|
6
|
+
// both see kumiko_event_consumers already present but rearm_count missing,
|
|
7
|
+
// and both ALTER TABLE. Without IF NOT EXISTS the loser crashes on boot
|
|
8
|
+
// with "column already exists" — a real TOCTOU since this table is
|
|
9
|
+
// explicitly multi-instance.
|
|
10
|
+
describe("createEventConsumerStateTable — concurrent boot", () => {
|
|
11
|
+
let testDb: TestDb;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
testDb = await createTestDb();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await testDb.cleanup();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("running it twice concurrently does not throw", async () => {
|
|
22
|
+
await createEventConsumerStateTable(testDb.db);
|
|
23
|
+
|
|
24
|
+
await expect(
|
|
25
|
+
Promise.all([
|
|
26
|
+
createEventConsumerStateTable(testDb.db),
|
|
27
|
+
createEventConsumerStateTable(testDb.db),
|
|
28
|
+
]),
|
|
29
|
+
).resolves.toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -11,6 +11,13 @@ import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
|
11
11
|
import { asRawClient } from "../../db/query";
|
|
12
12
|
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
13
13
|
import { defineFeature } from "../../engine";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_SENSITIVE_CONFIG,
|
|
16
|
+
type MetricEvent,
|
|
17
|
+
type ObservabilityProvider,
|
|
18
|
+
RecordingMeter,
|
|
19
|
+
RecordingTracer,
|
|
20
|
+
} from "../../observability";
|
|
14
21
|
import { getConsumerState } from "../../pipeline";
|
|
15
22
|
import {
|
|
16
23
|
resetEventStore,
|
|
@@ -20,6 +27,7 @@ import {
|
|
|
20
27
|
unsafeCreateEntityTable,
|
|
21
28
|
} from "../../stack";
|
|
22
29
|
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
30
|
+
import { withoutAmbientTemporal } from "../../testing/without-ambient-temporal";
|
|
23
31
|
import { SHARED_INSTANCE_SENTINEL } from "../event-consumer-state";
|
|
24
32
|
|
|
25
33
|
const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
|
|
@@ -151,6 +159,23 @@ describe("issue #1350 — bounded dead-consumer re-arm", () => {
|
|
|
151
159
|
expect(deadAgain?.rearmCount).toBe(0);
|
|
152
160
|
});
|
|
153
161
|
|
|
162
|
+
test("kumiko-framework#1525: cooldown-elapsed re-arm check works without relying on globalThis.Temporal", async () => {
|
|
163
|
+
poisonNames.add("poison");
|
|
164
|
+
await appendWidget("poison");
|
|
165
|
+
await driveUntilDead();
|
|
166
|
+
await backdateUpdatedAt(10);
|
|
167
|
+
|
|
168
|
+
// ensureTemporalPolyfill re-installs if the ambient global was torn down,
|
|
169
|
+
// so runOnce stays safe if a nested path calls it during this window.
|
|
170
|
+
await withoutAmbientTemporal(async () => {
|
|
171
|
+
await stack.eventDispatcher?.runOnce();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const revived = await getConsumerState(stack.db, qn);
|
|
175
|
+
expect(revived?.status).toBe("idle");
|
|
176
|
+
expect(revived?.rearmCount).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
154
179
|
test("permanently dead after maxRearmCount cycles, even once cooldown elapses again", async () => {
|
|
155
180
|
poisonNames.add("poison");
|
|
156
181
|
await appendWidget("poison");
|
|
@@ -177,4 +202,62 @@ describe("issue #1350 — bounded dead-consumer re-arm", () => {
|
|
|
177
202
|
expect(stillExhausted?.status).toBe("dead");
|
|
178
203
|
expect(stillExhausted?.rearmCount).toBe(3);
|
|
179
204
|
});
|
|
205
|
+
|
|
206
|
+
test("kumiko_event_consumer_rearm_exhausted_total fires once, not per still-dead pass (#1362)", async () => {
|
|
207
|
+
// Dedicated stack with a RecordingMeter — module-level `stack` above is
|
|
208
|
+
// shared across every test in this file and has no metric capture.
|
|
209
|
+
const metricEvents: MetricEvent[] = [];
|
|
210
|
+
const meter = new RecordingMeter((e) => metricEvents.push(e));
|
|
211
|
+
const tracer = new RecordingTracer({
|
|
212
|
+
sensitiveConfig: DEFAULT_SENSITIVE_CONFIG,
|
|
213
|
+
onSpanEnd: () => {},
|
|
214
|
+
});
|
|
215
|
+
const recordingProvider: ObservabilityProvider = {
|
|
216
|
+
name: "recording",
|
|
217
|
+
meter,
|
|
218
|
+
tracer,
|
|
219
|
+
shutdown: async () => {},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const recStack = await setupTestStack({
|
|
223
|
+
features: [rearmFeature],
|
|
224
|
+
systemHooks: [],
|
|
225
|
+
observability: recordingProvider,
|
|
226
|
+
});
|
|
227
|
+
try {
|
|
228
|
+
await unsafeCreateEntityTable(recStack.db, sharedWidgetEntity, "widget");
|
|
229
|
+
const recTdb = createTenantDb(recStack.db, admin.tenantId);
|
|
230
|
+
|
|
231
|
+
poisonNames.add("poison-metric");
|
|
232
|
+
await executor.create({ name: "poison-metric" }, admin, recTdb);
|
|
233
|
+
for (let i = 0; i < 10; i++) {
|
|
234
|
+
await recStack.eventDispatcher?.runOnce();
|
|
235
|
+
}
|
|
236
|
+
// The 10th pass is the one whose failed delivery flips status to
|
|
237
|
+
// "dead" (persisted for the NEXT pass to see) — acquireConsumerState's
|
|
238
|
+
// dead-branch (where the exhausted-metric fires) only runs starting
|
|
239
|
+
// from the pass AFTER that transition, same as the "stays dead within
|
|
240
|
+
// cooldown window" test above needing one extra runOnce().
|
|
241
|
+
await recStack.eventDispatcher?.runOnce();
|
|
242
|
+
|
|
243
|
+
const exhaustedEvents = () =>
|
|
244
|
+
metricEvents.filter(
|
|
245
|
+
(e) =>
|
|
246
|
+
e.type === "counter.inc" && e.name === "kumiko_event_consumer_rearm_exhausted_total",
|
|
247
|
+
);
|
|
248
|
+
expect(exhaustedEvents().length).toBe(1);
|
|
249
|
+
|
|
250
|
+
// Several more passes while it's still dead — must stay at 1, not
|
|
251
|
+
// re-fire on every pass that observes "still dead" (log/metric spam
|
|
252
|
+
// the finding flagged).
|
|
253
|
+
for (let i = 0; i < 5; i++) {
|
|
254
|
+
await recStack.eventDispatcher?.runOnce();
|
|
255
|
+
}
|
|
256
|
+
expect(exhaustedEvents().length).toBe(1);
|
|
257
|
+
expect(exhaustedEvents()[0]?.labels?.["consumer"]).toBe(qn);
|
|
258
|
+
} finally {
|
|
259
|
+
await resetEventStore(recStack, ["read_widgets"]);
|
|
260
|
+
await recStack.cleanup();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
180
263
|
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// createJobTriggerEventConsumer — proves r.job's trigger.on can fire on an
|
|
2
|
+
// r.defineEvent QN appended by a multiStreamProjection's unsafeAppendEvent
|
|
3
|
+
// (kumiko-framework#1505). Mirrors document-ingest-foundation's actual
|
|
4
|
+
// request-ingest MSP (upload → fileRef.created → an owned defineEvent),
|
|
5
|
+
// the motivating case for this fix — fileRef.created itself never reaches
|
|
6
|
+
// jobRunner.handleEvent because the upload route appends it via the raw
|
|
7
|
+
// event-store executor, not a write-handler dispatch (see #1505).
|
|
8
|
+
//
|
|
9
|
+
// Not covered here: a job triggered on a write/query-handler QN still
|
|
10
|
+
// firing exactly once (unaffected by the new consumer). The full suite
|
|
11
|
+
// stays green (e.g. the lane-routing sample), but that's not a positive
|
|
12
|
+
// test of the partition guard — no stored event's `type` is ever a
|
|
13
|
+
// handler QN in practice (entity events are "entity.verb"; custom
|
|
14
|
+
// write-handlers like lane-routing's don't append to the store at all),
|
|
15
|
+
// so `getWriteHandler`/`getQueryHandler` in the new consumer's handler is
|
|
16
|
+
// defense-in-depth for an input shape the framework doesn't currently
|
|
17
|
+
// produce, not something exercised end-to-end by any test today.
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { entityEventName } from "../../db";
|
|
22
|
+
import { defineFeature } from "../../engine";
|
|
23
|
+
import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
|
|
24
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
25
|
+
import { waitFor } from "../../testing";
|
|
26
|
+
|
|
27
|
+
const ITEM_REQUESTED_EVENT_QN = "job-trigger-fixture:event:item-requested";
|
|
28
|
+
const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
29
|
+
|
|
30
|
+
const processedItems: Array<{ readonly fileRefId: string }> = [];
|
|
31
|
+
|
|
32
|
+
const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
33
|
+
r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }));
|
|
34
|
+
|
|
35
|
+
// Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
|
|
36
|
+
// to fileRef.created, appends a NEW event via unsafeAppendEvent — no
|
|
37
|
+
// write-handler behind the appended event itself.
|
|
38
|
+
r.multiStreamProjection({
|
|
39
|
+
name: "request-item",
|
|
40
|
+
apply: {
|
|
41
|
+
[FILE_REF_CREATED]: async (event, _tx, ctx) => {
|
|
42
|
+
await ctx.unsafeAppendEvent({
|
|
43
|
+
aggregateId: event.aggregateId,
|
|
44
|
+
aggregateType: "job-trigger-fixture-request",
|
|
45
|
+
type: ITEM_REQUESTED_EVENT_QN,
|
|
46
|
+
payload: { fileRefId: event.aggregateId },
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Under test: only reachable via createJobTriggerEventConsumer, since
|
|
53
|
+
// ITEM_REQUESTED_EVENT_QN is an r.defineEvent QN, not a handler QN.
|
|
54
|
+
r.job(
|
|
55
|
+
"process-item",
|
|
56
|
+
{ trigger: { on: ITEM_REQUESTED_EVENT_QN }, runIn: "worker" },
|
|
57
|
+
async (payload) => {
|
|
58
|
+
processedItems.push({ fileRefId: payload["fileRefId"] as string });
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let stack: TestStack;
|
|
64
|
+
let provider: InMemoryFileProvider;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
provider = createInMemoryFileProvider();
|
|
68
|
+
stack = await setupTestStack({
|
|
69
|
+
features: [jobTriggerFixtureFeature],
|
|
70
|
+
files: { storageProvider: provider },
|
|
71
|
+
jobs: { consumerLane: "worker" },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await stack.cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
processedItems.length = 0;
|
|
81
|
+
provider.clear();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("job-trigger event consumer", () => {
|
|
85
|
+
test("a job triggers on an r.defineEvent QN appended by an MSP's unsafeAppendEvent", async () => {
|
|
86
|
+
const token = await stack.jwt.sign(TestUsers.admin);
|
|
87
|
+
const formData = new FormData();
|
|
88
|
+
formData.append("file", new File([Buffer.from("hello")], "note.txt", { type: "text/plain" }));
|
|
89
|
+
const res = await stack.app.request("/api/files", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
92
|
+
body: formData,
|
|
93
|
+
});
|
|
94
|
+
expect(res.status).toBe(201);
|
|
95
|
+
|
|
96
|
+
await waitFor(async () => {
|
|
97
|
+
// Drives both the MSP (appends item-requested off fileRef.created)
|
|
98
|
+
// and the new job-trigger consumer (reacts to it) — may need more
|
|
99
|
+
// than one pass since the MSP's append happens mid-drain.
|
|
100
|
+
await stack.eventDispatcher?.runOnce();
|
|
101
|
+
expect(processedItems).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(processedItems[0]?.fileRefId).toBeTruthy();
|
|
105
|
+
});
|
|
106
|
+
});
|