@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
|
@@ -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
|
});
|
|
@@ -202,74 +202,78 @@ describe("runPostSave", () => {
|
|
|
202
202
|
test("postSave errors don't throw — logged and continued", async () => {
|
|
203
203
|
const calls: string[] = [];
|
|
204
204
|
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
},
|
|
211
|
-
],
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
const systemHooks: SystemHooks = {
|
|
215
|
-
postSave: [
|
|
216
|
-
{
|
|
217
|
-
name: "search",
|
|
218
|
-
priority: 1000,
|
|
219
|
-
fn: async () => {
|
|
220
|
-
calls.push("search-ran");
|
|
205
|
+
try {
|
|
206
|
+
const registry = makeRegistry({
|
|
207
|
+
postSave: [
|
|
208
|
+
async () => {
|
|
209
|
+
throw new Error("feature-fail");
|
|
221
210
|
},
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
227
|
-
// Should not throw
|
|
228
|
-
await pipeline.runPostSave("test:write:user", savectx, {});
|
|
211
|
+
],
|
|
212
|
+
});
|
|
229
213
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
214
|
+
const systemHooks: SystemHooks = {
|
|
215
|
+
postSave: [
|
|
216
|
+
{
|
|
217
|
+
name: "search",
|
|
218
|
+
priority: 1000,
|
|
219
|
+
fn: async () => {
|
|
220
|
+
calls.push("search-ran");
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
227
|
+
// Should not throw
|
|
228
|
+
await pipeline.runPostSave("test:write:user", savectx, {});
|
|
229
|
+
|
|
230
|
+
// System hook still ran despite feature hook failure
|
|
231
|
+
expect(calls).toContain("search-ran");
|
|
232
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
233
|
+
} finally {
|
|
234
|
+
consoleSpy.mockRestore();
|
|
235
|
+
}
|
|
234
236
|
});
|
|
235
237
|
|
|
236
238
|
test("system hook failure doesn't block other system hooks", async () => {
|
|
237
239
|
const calls: string[] = [];
|
|
238
240
|
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
241
|
+
try {
|
|
242
|
+
const registry = makeRegistry();
|
|
243
|
+
|
|
244
|
+
const systemHooks: SystemHooks = {
|
|
245
|
+
postSave: [
|
|
246
|
+
{
|
|
247
|
+
name: "search",
|
|
248
|
+
priority: 1000,
|
|
249
|
+
fn: async () => {
|
|
250
|
+
throw new Error("meili-down");
|
|
251
|
+
},
|
|
249
252
|
},
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
{
|
|
254
|
+
name: "sse",
|
|
255
|
+
priority: 1001,
|
|
256
|
+
fn: async () => {
|
|
257
|
+
calls.push("sse-ran");
|
|
258
|
+
},
|
|
256
259
|
},
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
260
|
+
{
|
|
261
|
+
name: "audit",
|
|
262
|
+
priority: 1002,
|
|
263
|
+
fn: async () => {
|
|
264
|
+
calls.push("audit-ran");
|
|
265
|
+
},
|
|
263
266
|
},
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
};
|
|
267
|
+
],
|
|
268
|
+
};
|
|
267
269
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
271
|
+
await pipeline.runPostSave("test:write:user", savectx, {});
|
|
270
272
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
+
expect(calls).toEqual(["sse-ran", "audit-ran"]);
|
|
274
|
+
} finally {
|
|
275
|
+
consoleSpy.mockRestore();
|
|
276
|
+
}
|
|
273
277
|
});
|
|
274
278
|
});
|
|
275
279
|
|
|
@@ -336,29 +340,32 @@ describe("runPostSave phase routing", () => {
|
|
|
336
340
|
|
|
337
341
|
test("afterCommit phase: hook errors are logged, never thrown", async () => {
|
|
338
342
|
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
343
|
+
try {
|
|
344
|
+
const afterRan: string[] = [];
|
|
345
|
+
const feature = defineFeature("phases", (r) => {
|
|
346
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
347
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
348
|
+
access: { openToAll: true },
|
|
349
|
+
});
|
|
350
|
+
r.hook("postSave", "user", async () => {
|
|
351
|
+
throw new Error("afterCommit-boom");
|
|
352
|
+
});
|
|
353
|
+
r.hook("postSave", "user", async () => {
|
|
354
|
+
afterRan.push("second");
|
|
355
|
+
});
|
|
347
356
|
});
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
});
|
|
351
|
-
});
|
|
352
|
-
const registry = createRegistry([feature]);
|
|
353
|
-
const pipeline = createLifecycleHooks(registry);
|
|
357
|
+
const registry = createRegistry([feature]);
|
|
358
|
+
const pipeline = createLifecycleHooks(registry);
|
|
354
359
|
|
|
355
|
-
|
|
356
|
-
|
|
360
|
+
// Must not throw — errors are swallowed + logged
|
|
361
|
+
await pipeline.runPostSave("phases:write:user", savectx, {}, "afterCommit");
|
|
357
362
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
363
|
+
// Subsequent hooks still fire (failure in one hook doesn't block the rest)
|
|
364
|
+
expect(afterRan).toEqual(["second"]);
|
|
365
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
366
|
+
} finally {
|
|
367
|
+
consoleSpy.mockRestore();
|
|
368
|
+
}
|
|
362
369
|
});
|
|
363
370
|
|
|
364
371
|
test("system hooks respect their phase setting", async () => {
|
|
@@ -724,28 +731,31 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
|
|
|
724
731
|
],
|
|
725
732
|
])("one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async (_name, buildHooks, run) => {
|
|
726
733
|
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
+
try {
|
|
735
|
+
const calls: string[] = [];
|
|
736
|
+
const systemHooks = buildHooks([
|
|
737
|
+
{
|
|
738
|
+
name: "failing",
|
|
739
|
+
priority: 1000,
|
|
740
|
+
fn: async () => {
|
|
741
|
+
throw new Error("batch-hook-boom");
|
|
742
|
+
},
|
|
734
743
|
},
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
744
|
+
{
|
|
745
|
+
name: "ok",
|
|
746
|
+
priority: 1001,
|
|
747
|
+
fn: async () => {
|
|
748
|
+
calls.push("ok-ran");
|
|
749
|
+
},
|
|
741
750
|
},
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
751
|
+
]);
|
|
752
|
+
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
753
|
+
// Must not throw.
|
|
754
|
+
await run(pipeline);
|
|
755
|
+
expect(calls).toEqual(["ok-ran"]);
|
|
756
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
757
|
+
} finally {
|
|
758
|
+
consoleSpy.mockRestore();
|
|
759
|
+
}
|
|
750
760
|
});
|
|
751
761
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context";
|
|
2
|
+
import type { SseBroker } from "../api/sse-broker";
|
|
2
3
|
import type { DbConnection, DbRunner, DbTx } from "../db/connection";
|
|
3
4
|
import { runInSavepoint, selectMany } from "../db/query";
|
|
4
5
|
import type { buildEntityTable } from "../db/table-builder";
|
|
@@ -30,7 +31,10 @@ import {
|
|
|
30
31
|
isStreamArchived,
|
|
31
32
|
restoreStream as restoreStreamHelper,
|
|
32
33
|
} from "../event-store/archive";
|
|
33
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError,
|
|
36
|
+
VersionConflictError as EventStoreVersionConflictError,
|
|
37
|
+
} from "../event-store/errors";
|
|
34
38
|
import {
|
|
35
39
|
getStreamVersion,
|
|
36
40
|
loadAggregate,
|
|
@@ -53,6 +57,7 @@ import {
|
|
|
53
57
|
emitDispatcherHandler,
|
|
54
58
|
type getFallbackMeter,
|
|
55
59
|
getFallbackTracer,
|
|
60
|
+
observabilityContext,
|
|
56
61
|
} from "../observability";
|
|
57
62
|
import { buildBucketKey } from "../rate-limit";
|
|
58
63
|
import { createTzContext } from "../time";
|
|
@@ -92,6 +97,7 @@ export type DispatchContext = {
|
|
|
92
97
|
lifecycle: LifecycleHooks | undefined;
|
|
93
98
|
jobRunner: JobRunnerRef | undefined;
|
|
94
99
|
effectiveFeatures: EffectiveFeaturesResolver | undefined;
|
|
100
|
+
sseBroker: SseBroker | undefined;
|
|
95
101
|
tableCache: Map<string, ReturnType<typeof buildEntityTable>>;
|
|
96
102
|
transitionCache: Map<string, ReturnType<typeof defineTransitions>>;
|
|
97
103
|
tracer: ReturnType<typeof getFallbackTracer>;
|
|
@@ -208,6 +214,9 @@ export function buildHandlerContext(
|
|
|
208
214
|
// `queryAs` / `writeAs` let a handler explicitly switch identity
|
|
209
215
|
// (e.g. system-privileged lookups that bypass field-access read filters).
|
|
210
216
|
const bridgeSink = afterCommitHooks ?? [];
|
|
217
|
+
const scheduleAfterCommit = (hook: AfterCommitHook): void => {
|
|
218
|
+
bridgeSink.push(hook);
|
|
219
|
+
};
|
|
211
220
|
const bridge = {
|
|
212
221
|
query: (targetType: string, payload: unknown) =>
|
|
213
222
|
executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
|
|
@@ -263,6 +272,9 @@ export function buildHandlerContext(
|
|
|
263
272
|
if (e instanceof EventStoreVersionConflictError) {
|
|
264
273
|
return { ok: false as const, conflict: e };
|
|
265
274
|
}
|
|
275
|
+
if (e instanceof EventStoreIdempotentAppendConflictError) {
|
|
276
|
+
return { ok: false as const, conflict: e };
|
|
277
|
+
}
|
|
266
278
|
throw e;
|
|
267
279
|
}
|
|
268
280
|
},
|
|
@@ -542,6 +554,7 @@ export function buildHandlerContext(
|
|
|
542
554
|
_userId: user.id,
|
|
543
555
|
_tenantId: user.tenantId,
|
|
544
556
|
_handlerType: type,
|
|
557
|
+
scheduleAfterCommit,
|
|
545
558
|
...(includeDeleted && { includeDeleted: true }),
|
|
546
559
|
...bridge,
|
|
547
560
|
} as HandlerContext; // @cast-boundary engine-bridge
|
|
@@ -611,9 +624,13 @@ export async function runHandlerInstrumented<T>(
|
|
|
611
624
|
|
|
612
625
|
// Generator-native counterpart to runHandlerInstrumented — a stream's
|
|
613
626
|
// lifetime spans every `for await` pull the caller makes, so the span
|
|
614
|
-
// can't be scoped via withSpan's single-callback shape.
|
|
615
|
-
//
|
|
616
|
-
//
|
|
627
|
+
// can't be scoped via withSpan's single-callback shape. Drive the inner
|
|
628
|
+
// generator manually (not yield*): each pull must run inside
|
|
629
|
+
// observabilityContext.run({ activeSpan: span }) so handler-side
|
|
630
|
+
// startSpan() calls parent onto the dispatcher span. finally forwards
|
|
631
|
+
// .return() to the inner generator (yield* did that for free). Metrics
|
|
632
|
+
// land in the same finally path so success/failure/throw/abort all hit
|
|
633
|
+
// one emit, like the Promise path.
|
|
617
634
|
export async function* runStreamInstrumented<T>(
|
|
618
635
|
ctx: DispatchContext,
|
|
619
636
|
type: string,
|
|
@@ -623,25 +640,61 @@ export async function* runStreamInstrumented<T>(
|
|
|
623
640
|
const { tracer: dispatcherTracer, meter: dispatcherMeter, registry } = ctx;
|
|
624
641
|
const start = performance.now();
|
|
625
642
|
let success = true;
|
|
643
|
+
// Set only once `inner()` has drained on its own — NOT set when the
|
|
644
|
+
// consumer walks away early (generator.return(), the normal SSE-abort
|
|
645
|
+
// path in api/routes.ts). yield* forwards that .return() straight through
|
|
646
|
+
// without throwing, so without this flag an aborted stream would fall
|
|
647
|
+
// through to the success-metric branch below and get counted as a clean
|
|
648
|
+
// completion.
|
|
649
|
+
let completedNormally = false;
|
|
626
650
|
let errorClass: string | undefined;
|
|
627
651
|
const span = dispatcherTracer.startSpan("kumiko.dispatcher.handler", {
|
|
628
652
|
attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
|
|
629
653
|
});
|
|
654
|
+
const it = inner();
|
|
630
655
|
try {
|
|
631
|
-
|
|
656
|
+
let next = await observabilityContext.run({ activeSpan: span }, () => it.next());
|
|
657
|
+
while (!next.done) {
|
|
658
|
+
yield next.value;
|
|
659
|
+
next = await observabilityContext.run({ activeSpan: span }, () => it.next());
|
|
660
|
+
}
|
|
661
|
+
completedNormally = true;
|
|
662
|
+
return next.value;
|
|
632
663
|
} catch (error) {
|
|
633
664
|
success = false;
|
|
634
665
|
errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
|
|
666
|
+
if (error instanceof Error) span.recordException(error);
|
|
635
667
|
span.setStatus("error", errorClass);
|
|
636
668
|
throw error;
|
|
637
669
|
} finally {
|
|
670
|
+
// forward close so inner()'s finally still fires — yield* did this for free
|
|
671
|
+
try {
|
|
672
|
+
await observabilityContext.run({ activeSpan: span }, () => it.return?.(undefined));
|
|
673
|
+
} catch (closeError) {
|
|
674
|
+
// Only fold this in when nothing has already failed — a close-time
|
|
675
|
+
// error while an earlier error is in flight would mask the real
|
|
676
|
+
// cause reported above.
|
|
677
|
+
if (success) {
|
|
678
|
+
success = false;
|
|
679
|
+
errorClass =
|
|
680
|
+
closeError instanceof Error && closeError.name ? closeError.name : "UnknownError";
|
|
681
|
+
span.setStatus("error", errorClass);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
638
684
|
span.end();
|
|
639
685
|
if (!success && errorClass) {
|
|
640
686
|
emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
|
|
641
687
|
}
|
|
688
|
+
// Consumer-abort (generator.return(), the normal SSE Tab-close path)
|
|
689
|
+
// is not a handler failure — label it outcome:"aborted" so live-stream
|
|
690
|
+
// success rates stay meaningful. True handler throws keep success:false.
|
|
642
691
|
emitDispatcherHandler(
|
|
643
692
|
dispatcherMeter,
|
|
644
|
-
{
|
|
693
|
+
{
|
|
694
|
+
handler: type,
|
|
695
|
+
success,
|
|
696
|
+
outcome: completedNormally ? "completed" : "aborted",
|
|
697
|
+
},
|
|
645
698
|
(performance.now() - start) / 1000,
|
|
646
699
|
);
|
|
647
700
|
}
|
|
@@ -53,17 +53,49 @@ async function* executeStreamInner(
|
|
|
53
53
|
throw validationErrorFromZod(parsed.error);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// Mid-stream access revocation must also cut *idle* SSE streams (heartbeat
|
|
57
|
+
// only — no chunk). A boolean flag read only after the next chunk would
|
|
58
|
+
// leave revoked sessions open indefinitely (fw#1563). Race each pull
|
|
59
|
+
// against an invalidated Deferred instead.
|
|
60
|
+
let resolveInvalidated: (() => void) | undefined;
|
|
61
|
+
const invalidated = new Promise<void>((resolve) => {
|
|
62
|
+
resolveInvalidated = resolve;
|
|
63
|
+
});
|
|
64
|
+
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
|
|
65
|
+
resolveInvalidated?.();
|
|
66
|
+
});
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
let iterator: AsyncIterator<unknown> | undefined;
|
|
69
|
+
try {
|
|
70
|
+
const handlerContext = buildHandlerContext(ctx, type, user);
|
|
71
|
+
const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
72
|
+
iterator = chunks[Symbol.asyncIterator]();
|
|
73
|
+
|
|
74
|
+
while (true) {
|
|
75
|
+
const nextPull = iterator.next();
|
|
76
|
+
const outcome = await Promise.race([
|
|
77
|
+
nextPull.then((result) => ({ kind: "chunk" as const, result })),
|
|
78
|
+
invalidated.then(() => ({ kind: "invalidated" as const })),
|
|
79
|
+
]);
|
|
80
|
+
if (outcome.kind === "invalidated") {
|
|
81
|
+
throw new AccessDeniedError({
|
|
82
|
+
message: `access revoked mid-stream for ${type}`,
|
|
83
|
+
details: { handler: type },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (outcome.result.done) break;
|
|
87
|
+
await ensureFeatureEnabled(ctx, type, user.tenantId);
|
|
88
|
+
assertNoSecretLeak(outcome.result.value);
|
|
89
|
+
yield outcome.result.value;
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
unsubscribeAccessInvalidation?.();
|
|
93
|
+
// Consumer break / access revoke / throw — always close the handler
|
|
94
|
+
// generator so its finally (cleanup) runs (for-await would do this).
|
|
95
|
+
// Do NOT swallow return() errors — close-time cleanup failures must
|
|
96
|
+
// surface to runStreamInstrumented (#1543).
|
|
97
|
+
if (iterator !== undefined) {
|
|
98
|
+
await iterator.return?.(undefined);
|
|
99
|
+
}
|
|
68
100
|
}
|
|
69
101
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SseBroker } from "../api/sse-broker";
|
|
1
2
|
import type { buildEntityTable } from "../db/table-builder";
|
|
2
3
|
import type { defineTransitions } from "../engine/state-machine";
|
|
3
4
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
@@ -43,6 +44,10 @@ export type DispatcherOptions = {
|
|
|
43
44
|
// event-skips and a confusing operator-UI — the framework cannot
|
|
44
45
|
// enforce this contract, but the recipe-test pins the convention.
|
|
45
46
|
effectiveFeatures?: EffectiveFeaturesResolver;
|
|
47
|
+
// In-memory SSE broker — dispatch-stream.ts subscribes to a stream's
|
|
48
|
+
// user-scoped access-invalidation channel on it. Absent in setups without
|
|
49
|
+
// SSE wired up (dispatch-stream then just skips the subscription).
|
|
50
|
+
sseBroker?: SseBroker;
|
|
46
51
|
};
|
|
47
52
|
|
|
48
53
|
export type Dispatcher = {
|
|
@@ -82,7 +87,7 @@ export function createDispatcher(
|
|
|
82
87
|
context: AppContext,
|
|
83
88
|
options: DispatcherOptions = {},
|
|
84
89
|
): Dispatcher {
|
|
85
|
-
const { idempotency, lifecycle, jobRunner, effectiveFeatures } = options;
|
|
90
|
+
const { idempotency, lifecycle, jobRunner, effectiveFeatures, sseBroker } = options;
|
|
86
91
|
|
|
87
92
|
// Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
|
|
88
93
|
const tableCache = new Map<string, ReturnType<typeof buildEntityTable>>();
|
|
@@ -101,6 +106,7 @@ export function createDispatcher(
|
|
|
101
106
|
lifecycle,
|
|
102
107
|
jobRunner,
|
|
103
108
|
effectiveFeatures,
|
|
109
|
+
sseBroker,
|
|
104
110
|
tableCache,
|
|
105
111
|
transitionCache,
|
|
106
112
|
tracer: dispatcherTracer,
|
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
sql,
|
|
11
11
|
text,
|
|
12
12
|
} from "../db/dialect";
|
|
13
|
-
import { alterTableAddColumn } from "../db/queries/
|
|
14
|
-
import {
|
|
13
|
+
import { alterTableAddColumn } from "../db/queries/ddl";
|
|
14
|
+
import { tableExists } from "../db/schema-inspection";
|
|
15
15
|
import { unsafePushTables } from "../stack";
|
|
16
16
|
|
|
17
17
|
// Reserved sentinel used in the instance_id column for consumers whose
|
|
@@ -112,17 +112,20 @@ export async function createEventConsumerStateTable(db: DbConnection): Promise<v
|
|
|
112
112
|
// Still check for columns added after the table's first deploy (e.g.
|
|
113
113
|
// rearm_count) so an older DB catches up without a dedicated migration.
|
|
114
114
|
if (await tableExists(db, "public.kumiko_event_consumers")) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
115
|
+
// ADD COLUMN IF NOT EXISTS is idempotent and race-safe on its own —
|
|
116
|
+
// no need to pre-check columnNamesOf first. Two instances booting
|
|
117
|
+
// concurrently against the same DB (this table is explicitly
|
|
118
|
+
// multi-instance) both ALTER, neither crashes on "column already
|
|
119
|
+
// exists" (#1362).
|
|
120
|
+
await alterTableAddColumn(
|
|
121
|
+
db,
|
|
122
|
+
"kumiko_event_consumers",
|
|
123
|
+
"rearm_count",
|
|
124
|
+
"integer",
|
|
125
|
+
" DEFAULT 0",
|
|
126
|
+
" NOT NULL",
|
|
127
|
+
/* ifNotExists */ true,
|
|
128
|
+
);
|
|
126
129
|
// skip: table (+ any missing column) is already up to date
|
|
127
130
|
return;
|
|
128
131
|
}
|