@cosmicdrift/kumiko-framework 0.165.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 +32 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +267 -19
- 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 +2 -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__/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 +103 -35
- package/src/api/server.ts +26 -2
- package/src/api/sse-broker.ts +39 -0
- 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 +14 -1
- 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 -1
- 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 -5
- 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__/engine.test.ts +30 -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/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-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/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/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 +32 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +19 -4
- 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 +151 -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__/lifecycle-pipeline.test.ts +107 -97
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +43 -11
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +20 -6
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +87 -0
- 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/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Queue } from "bullmq";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { requestContext } from "../../api/request-context";
|
|
4
5
|
import { createRegistry, defineFeature } from "../../engine";
|
|
5
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
AppContext,
|
|
8
|
+
ConfigAccessor,
|
|
9
|
+
ConfigAccessorFactory,
|
|
10
|
+
NotifyFactory,
|
|
11
|
+
Registry,
|
|
12
|
+
TenantId,
|
|
13
|
+
} from "../../engine/types";
|
|
14
|
+
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
6
15
|
import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
7
16
|
import { sleep, waitFor } from "../../testing";
|
|
8
17
|
import {
|
|
9
18
|
createJobRunner,
|
|
19
|
+
type JobLogEntry,
|
|
10
20
|
type JobMeta,
|
|
11
21
|
type JobRunner,
|
|
12
22
|
type JobRunnerOptions,
|
|
@@ -167,6 +177,30 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
167
177
|
log.child({ probe: true }).info("log-probe-child");
|
|
168
178
|
jobLog.push({ name: "test:job:log-probe", payload, timestamp: Date.now() });
|
|
169
179
|
});
|
|
180
|
+
// job-runner must build ctx.files the same way dispatch-shared.ts does for
|
|
181
|
+
// write-handlers/MSPs — regression guard for #1520 (ctx.files was never
|
|
182
|
+
// materialized for jobs, so ctx.files.ref(...).read() threw for every job).
|
|
183
|
+
r.job("filesProbe", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
184
|
+
const content = await ctx.files!.ref(payload["key"] as string).read();
|
|
185
|
+
jobLog.push({
|
|
186
|
+
name: "test:job:files-probe",
|
|
187
|
+
payload: { text: new TextDecoder().decode(content) },
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// job-runner must build ctx.notify/ctx.config the same way dispatch-shared.ts
|
|
193
|
+
// does for write-handlers — regression guard for #1532 (only ctx.files got
|
|
194
|
+
// the same fix; ctx.notify/ctx.config stayed undefined for every job).
|
|
195
|
+
r.job("notifyProbe", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
196
|
+
await ctx.notify!("test-notification", { to: payload["toUserId"] as string });
|
|
197
|
+
jobLog.push({ name: "test:job:notify-probe", payload, timestamp: Date.now() });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
r.job("configProbe", { trigger: { manual: true } }, async (_payload, ctx) => {
|
|
201
|
+
const value = await ctx.config!("test:config:probe-key");
|
|
202
|
+
jobLog.push({ name: "test:job:config-probe", payload: { value }, timestamp: Date.now() });
|
|
203
|
+
});
|
|
170
204
|
});
|
|
171
205
|
|
|
172
206
|
beforeAll(async () => {
|
|
@@ -278,6 +312,75 @@ describe("scenario 3: manual trigger", () => {
|
|
|
278
312
|
});
|
|
279
313
|
});
|
|
280
314
|
|
|
315
|
+
describe("scenario 3b: ctx.files resolves through the file provider", () => {
|
|
316
|
+
test("job handler can read back a file via ctx.files.ref(key)", async () => {
|
|
317
|
+
clearLog();
|
|
318
|
+
const provider = createInMemoryFileProvider();
|
|
319
|
+
await provider.write("probe/hello.txt", new TextEncoder().encode("hello from files probe"));
|
|
320
|
+
|
|
321
|
+
await withRunner(
|
|
322
|
+
async (runner) => {
|
|
323
|
+
await runner.dispatch("test:job:files-probe", { key: "probe/hello.txt" });
|
|
324
|
+
await waitFor(() => {
|
|
325
|
+
const entries = jobLog.filter((e) => e.name === "test:job:files-probe");
|
|
326
|
+
expect(entries.length).toBe(1);
|
|
327
|
+
expect(entries[0]?.payload["text"]).toBe("hello from files probe");
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
{ context: { _fileProviderResolver: () => Promise.resolve(provider) } },
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("scenario 3c: ctx.notify resolves through the notify factory (#1532)", () => {
|
|
336
|
+
test("job handler can call ctx.notify and the factory is bound to the job's system user + tenant", async () => {
|
|
337
|
+
clearLog();
|
|
338
|
+
const calls: Array<{ readonly notifierTenantId: string; readonly notificationType: string }> =
|
|
339
|
+
[];
|
|
340
|
+
const notifyFactory: NotifyFactory = (_user, tenantId) => async (notificationType) => {
|
|
341
|
+
calls.push({ notifierTenantId: tenantId, notificationType });
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
await withRunner(
|
|
345
|
+
async (runner) => {
|
|
346
|
+
await runner.dispatch("test:job:notify-probe", { toUserId: "u1" });
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
const entries = jobLog.filter((e) => e.name === "test:job:notify-probe");
|
|
349
|
+
expect(entries.length).toBe(1);
|
|
350
|
+
});
|
|
351
|
+
expect(calls).toHaveLength(1);
|
|
352
|
+
expect(calls[0]?.notificationType).toBe("test-notification");
|
|
353
|
+
},
|
|
354
|
+
{ context: { _notifyFactory: notifyFactory } },
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("scenario 3d: ctx.config resolves through the config accessor factory (#1532)", () => {
|
|
360
|
+
test("job handler can call ctx.config and the factory receives a tenant-scoped db", async () => {
|
|
361
|
+
clearLog();
|
|
362
|
+
let receivedDb: unknown;
|
|
363
|
+
const configAccessorFactory: ConfigAccessorFactory = (deps) => {
|
|
364
|
+
receivedDb = deps.db;
|
|
365
|
+
// Stub accessor: only the string-key overload is exercised here.
|
|
366
|
+
return (async () => "probe-value") as ConfigAccessor;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
await withRunner(
|
|
370
|
+
async (runner) => {
|
|
371
|
+
await runner.dispatch("test:job:config-probe", {});
|
|
372
|
+
await waitFor(() => {
|
|
373
|
+
const entries = jobLog.filter((e) => e.name === "test:job:config-probe");
|
|
374
|
+
expect(entries.length).toBe(1);
|
|
375
|
+
expect(entries[0]?.payload["value"]).toBe("probe-value");
|
|
376
|
+
});
|
|
377
|
+
expect(receivedDb).toBeDefined();
|
|
378
|
+
},
|
|
379
|
+
{ context: { _configAccessorFactory: configAccessorFactory, db: {} as AppContext["db"] } },
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
281
384
|
// --- Concurrency modes ---
|
|
282
385
|
|
|
283
386
|
describe("concurrency: parallel", () => {
|
|
@@ -464,6 +567,7 @@ describe("concurrency: replace", () => {
|
|
|
464
567
|
test("enqueuer-only: each dispatch removes prior waiting peers", async () => {
|
|
465
568
|
// No consumer → jobs stay waiting. The replace branch walks getWaiting()
|
|
466
569
|
// and removes same-name peers before add — so three dispatches leave one.
|
|
570
|
+
const queueNamePrefix = `kumiko-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
467
571
|
await withRunner(
|
|
468
572
|
async (runner) => {
|
|
469
573
|
const id1 = await runner.dispatch("test:job:replace-job", { n: 1 });
|
|
@@ -474,8 +578,21 @@ describe("concurrency: replace", () => {
|
|
|
474
578
|
expect(id3).toBeDefined();
|
|
475
579
|
expect(id1).not.toBe("skipped");
|
|
476
580
|
expect(id3).not.toBe(id1);
|
|
581
|
+
|
|
582
|
+
const queue = new Queue(`${queueNamePrefix}-worker`, {
|
|
583
|
+
connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
|
|
584
|
+
});
|
|
585
|
+
try {
|
|
586
|
+
const waiting = (await queue.getWaiting()).filter(
|
|
587
|
+
(job) => job.name === "test:job:replace-job",
|
|
588
|
+
);
|
|
589
|
+
expect(waiting).toHaveLength(1);
|
|
590
|
+
expect(waiting[0]?.data.n).toBe(3);
|
|
591
|
+
} finally {
|
|
592
|
+
await queue.close();
|
|
593
|
+
}
|
|
477
594
|
},
|
|
478
|
-
{ consumerLane: undefined },
|
|
595
|
+
{ consumerLane: undefined, queueNamePrefix },
|
|
479
596
|
);
|
|
480
597
|
});
|
|
481
598
|
});
|
|
@@ -553,6 +670,36 @@ describe("job logger", () => {
|
|
|
553
670
|
});
|
|
554
671
|
});
|
|
555
672
|
});
|
|
673
|
+
|
|
674
|
+
// Inspects the actual createJobLogger output instead of only asserting the
|
|
675
|
+
// handler ran — a child() that drops its context or a warn() mapped to
|
|
676
|
+
// debug would previously stay green.
|
|
677
|
+
test("info/warn/error land with the right level; debug is dropped; child merges its context", async () => {
|
|
678
|
+
clearLog();
|
|
679
|
+
let capturedLogs: JobLogEntry[] = [];
|
|
680
|
+
await withRunner(
|
|
681
|
+
async (runner) => {
|
|
682
|
+
await runner.dispatch("test:job:log-probe", { n: 1 });
|
|
683
|
+
await waitFor(() => {
|
|
684
|
+
expect(capturedLogs.length).toBeGreaterThan(0);
|
|
685
|
+
});
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
onJobComplete: (jobName, _id, _duration, logs) => {
|
|
689
|
+
if (jobName === "test:job:log-probe") capturedLogs = logs;
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// debug() is a no-op by design (JobLogEntry.level has no "debug" slot) —
|
|
695
|
+
// info/warn/error from the handler body, plus one from child().info(...).
|
|
696
|
+
expect(capturedLogs).toHaveLength(4);
|
|
697
|
+
expect(capturedLogs.map((l) => l.level)).toEqual(["info", "warn", "error", "info"]);
|
|
698
|
+
expect(capturedLogs[0]?.message).toContain("log-probe-info");
|
|
699
|
+
expect(capturedLogs[3]?.message).toContain("log-probe-child");
|
|
700
|
+
expect(capturedLogs[3]?.message).toContain("probe");
|
|
701
|
+
expect(capturedLogs[3]?.message).toContain("true");
|
|
702
|
+
});
|
|
556
703
|
});
|
|
557
704
|
|
|
558
705
|
describe("correlation propagation", () => {
|
|
@@ -671,7 +818,8 @@ describe("error handling", () => {
|
|
|
671
818
|
|
|
672
819
|
test("perTenant with getActiveTenantIds fans out one run per tenant", async () => {
|
|
673
820
|
clearLog();
|
|
674
|
-
|
|
821
|
+
// @cast-boundary test fixture: TenantId is a branded string.
|
|
822
|
+
const tenants = ["per-tenant-fanout-a", "per-tenant-fanout-b"] as TenantId[];
|
|
675
823
|
await withRunner(
|
|
676
824
|
async (runner) => {
|
|
677
825
|
await runner.dispatch("test:job:per-tenant-fanout", { n: 7 });
|
|
@@ -693,11 +841,23 @@ describe("handleEvent maxPerTenant", () => {
|
|
|
693
841
|
const user = { ...TestUsers.admin, tenantId: "cap-tenant-evt" };
|
|
694
842
|
await runner.handleEvent("test:write:capped-event", { n: 1 }, user);
|
|
695
843
|
await runner.handleEvent("test:write:capped-event", { n: 2 }, user);
|
|
696
|
-
|
|
844
|
+
await waitFor(() => {
|
|
845
|
+
const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
|
|
846
|
+
expect(started.length).toBe(2);
|
|
847
|
+
});
|
|
848
|
+
// Third must be skipped by the maxPerTenant guard. handleEvent's cap
|
|
849
|
+
// check runs synchronously before this call resolves, but a wrongly
|
|
850
|
+
// enqueued job still needs the worker to pick it up and push its
|
|
851
|
+
// jobLog entry — polling several times (failing at the first sign of
|
|
852
|
+
// a 3rd start) is more CI-load-tolerant than a single fixed sleep(200)
|
|
853
|
+
// racing against the handler's own 500ms sleep, which could land
|
|
854
|
+
// outside the window on a starved runner and silently pass.
|
|
697
855
|
await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
856
|
+
for (const delayMs of [50, 100, 200, 400]) {
|
|
857
|
+
await sleep(delayMs);
|
|
858
|
+
const startedNow = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
|
|
859
|
+
expect(startedNow.length).toBe(2);
|
|
860
|
+
}
|
|
701
861
|
});
|
|
702
862
|
});
|
|
703
863
|
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type Job, Queue, Worker } from "bullmq";
|
|
2
2
|
import { Redis } from "ioredis";
|
|
3
3
|
import { requestContext } from "../api/request-context";
|
|
4
|
-
import type { DbRow } from "../db/connection";
|
|
4
|
+
import type { DbConnection, DbRow } from "../db/connection";
|
|
5
|
+
import { createTenantDb } from "../db/tenant-db";
|
|
5
6
|
import { createSystemUser } from "../engine/system-user";
|
|
6
7
|
import {
|
|
7
8
|
type AppContext,
|
|
@@ -9,7 +10,9 @@ import {
|
|
|
9
10
|
type Registry,
|
|
10
11
|
type SessionUser,
|
|
11
12
|
SYSTEM_TENANT_ID,
|
|
13
|
+
type TenantId,
|
|
12
14
|
} from "../engine/types";
|
|
15
|
+
import { createFileContext } from "../files/file-handle";
|
|
13
16
|
import type { Logger } from "../logging/types";
|
|
14
17
|
import {
|
|
15
18
|
emitJobQueueDepth,
|
|
@@ -37,9 +40,10 @@ export type JobLogEntry = {
|
|
|
37
40
|
timestamp: Temporal.Instant;
|
|
38
41
|
};
|
|
39
42
|
|
|
40
|
-
function createJobLogger(logs: JobLogEntry[]): Logger {
|
|
43
|
+
function createJobLogger(logs: JobLogEntry[], baseContext: Record<string, unknown> = {}): Logger {
|
|
41
44
|
function push(level: "info" | "warn" | "error", msg: string, data?: Record<string, unknown>) {
|
|
42
|
-
const
|
|
45
|
+
const merged = { ...baseContext, ...data };
|
|
46
|
+
const message = Object.keys(merged).length > 0 ? `${msg} ${JSON.stringify(merged)}` : msg;
|
|
43
47
|
logs.push({ level, message, timestamp: Temporal.Now.instant() });
|
|
44
48
|
}
|
|
45
49
|
const logger: Logger = {
|
|
@@ -52,9 +56,13 @@ function createJobLogger(logs: JobLogEntry[]): Logger {
|
|
|
52
56
|
error(msg, data) {
|
|
53
57
|
push("error", msg, data);
|
|
54
58
|
},
|
|
59
|
+
// No-op by design, not missing coverage: JobLogEntry.level (and the
|
|
60
|
+
// persisted run-completed/-failed event schema in bundled-features/
|
|
61
|
+
// jobs/events.ts) only has info|warn|error — a job's debug() calls have
|
|
62
|
+
// nowhere durable to land.
|
|
55
63
|
debug() {},
|
|
56
|
-
child() {
|
|
57
|
-
return
|
|
64
|
+
child(context) {
|
|
65
|
+
return createJobLogger(logs, { ...baseContext, ...context });
|
|
58
66
|
},
|
|
59
67
|
};
|
|
60
68
|
return logger;
|
|
@@ -100,7 +108,7 @@ export type JobRunnerOptions = {
|
|
|
100
108
|
// Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
|
|
101
109
|
// two parallel test-runners never see each other's jobs.
|
|
102
110
|
queueNamePrefix?: string | undefined;
|
|
103
|
-
getActiveTenantIds?: () => Promise<
|
|
111
|
+
getActiveTenantIds?: () => Promise<TenantId[]>;
|
|
104
112
|
onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
|
|
105
113
|
onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
|
|
106
114
|
onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[]) => void;
|
|
@@ -331,13 +339,35 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
331
339
|
SYSTEM_TENANT_ID;
|
|
332
340
|
const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null; // @cast-boundary dynamic-key
|
|
333
341
|
|
|
334
|
-
// _triggerName
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
// im rohen Payload kramen muss.
|
|
342
|
+
// Carry `_triggerName` from rawData when set — handleEvent injects it on
|
|
343
|
+
// multi-trigger dispatch; exposed as jobContext.triggerName so handlers
|
|
344
|
+
// don't dig through the raw payload themselves.
|
|
338
345
|
const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
|
|
346
|
+
// Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
|
|
347
|
+
// through the same _fileProviderResolver for jobs as for write-handlers,
|
|
348
|
+
// otherwise event-triggered jobs silently get an unresolved ctx.files.
|
|
349
|
+
const fileResolver = context._fileProviderResolver;
|
|
350
|
+
const files = fileResolver ? createFileContext(() => fileResolver(tenantId)) : context.files;
|
|
351
|
+
const jobSystemUser = createSystemUser(tenantId);
|
|
352
|
+
// Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
|
|
353
|
+
// must resolve for jobs the same way they do for write-handlers, or an
|
|
354
|
+
// app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
|
|
355
|
+
// at runtime the write-handler path never would (framework#1532).
|
|
356
|
+
const notify = context._notifyFactory?.(jobSystemUser, tenantId);
|
|
357
|
+
const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
|
|
358
|
+
const config =
|
|
359
|
+
context._configAccessorFactory && configDb
|
|
360
|
+
? context._configAccessorFactory({
|
|
361
|
+
user: { id: jobSystemUser.id, tenantId },
|
|
362
|
+
db: createTenantDb(configDb, tenantId, "system"),
|
|
363
|
+
secrets: context.secrets,
|
|
364
|
+
})
|
|
365
|
+
: undefined;
|
|
339
366
|
const jobContext: AppContext = {
|
|
340
367
|
...context,
|
|
368
|
+
files,
|
|
369
|
+
...(notify !== undefined && { notify }),
|
|
370
|
+
...(config !== undefined && { config }),
|
|
341
371
|
// The runner owns the registry it resolved this job from — expose it so
|
|
342
372
|
// workers can reach projections/jobs without the app author duplicating
|
|
343
373
|
// it into `context` (the JobContext contract guarantees `registry`).
|
|
@@ -345,7 +375,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
345
375
|
// Expose the runner so handlers can chain a follow-up job. Symmetric with
|
|
346
376
|
// how the command-dispatcher hands write-handlers their jobRunner.
|
|
347
377
|
...(selfRunner !== undefined && { jobRunner: selfRunner }),
|
|
348
|
-
systemUser:
|
|
378
|
+
systemUser: jobSystemUser,
|
|
349
379
|
triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
|
|
350
380
|
log: createJobLogger(logs),
|
|
351
381
|
...(triggerName !== undefined && { triggerName }),
|
package/src/logging/types.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/logger";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/logger";
|
|
@@ -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";
|
|
@@ -4,6 +4,7 @@ import { createEntity, createRegistry, createTextField, defineFeature } from "..
|
|
|
4
4
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
5
5
|
import { createSecret } from "../../secrets/types";
|
|
6
6
|
import { createTestUser } from "../../stack";
|
|
7
|
+
import { createRecordingProvider } from "../../testing";
|
|
7
8
|
import { createDispatcher } from "../dispatcher";
|
|
8
9
|
|
|
9
10
|
const streamCleanupState = { closed: false };
|
|
@@ -324,6 +325,156 @@ describe("dispatcher.stream", () => {
|
|
|
324
325
|
|
|
325
326
|
expect(streamCleanupState.closed).toBe(true);
|
|
326
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
|
+
});
|
|
327
478
|
});
|
|
328
479
|
|
|
329
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
|
+
});
|