@cosmicdrift/kumiko-framework 0.165.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +135 -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 +165 -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 +17 -1
- 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 +32 -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/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +37 -31
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -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,38 @@ async function* executeStreamInner(
|
|
|
53
53
|
throw validationErrorFromZod(parsed.error);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// Mid-stream access revocation: subscribed for the stream's lifetime, not
|
|
57
|
+
// just checked once like hasAccess above. A role/session/tenant change
|
|
58
|
+
// fires this via ctx.sseBroker.publishAccessInvalidation(userId) (session-
|
|
59
|
+
// revoke / tenant-membership consumers — issue #1559/#1560); the flag is
|
|
60
|
+
// read at the same per-chunk cadence as ensureFeatureEnabled below, so a
|
|
61
|
+
// stream cuts before its next chunk rather than running indefinitely.
|
|
62
|
+
let accessInvalidated = false;
|
|
63
|
+
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
|
|
64
|
+
accessInvalidated = true;
|
|
65
|
+
});
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
try {
|
|
68
|
+
const handlerContext = buildHandlerContext(ctx, type, user);
|
|
69
|
+
const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
70
|
+
|
|
71
|
+
// Consumer-driven pull (for await) is the backpressure mechanism — the
|
|
72
|
+
// handler generator only advances once the caller reads the previous
|
|
73
|
+
// chunk, no explicit buffering/throttling needed on either side.
|
|
74
|
+
for await (const chunk of chunks) {
|
|
75
|
+
// Re-checked per chunk, not just at stream-start: a feature disabled
|
|
76
|
+
// mid-stream must cut an already-open stream, not just block new ones.
|
|
77
|
+
await ensureFeatureEnabled(ctx, type, user.tenantId);
|
|
78
|
+
if (accessInvalidated) {
|
|
79
|
+
throw new AccessDeniedError({
|
|
80
|
+
message: `access revoked mid-stream for ${type}`,
|
|
81
|
+
details: { handler: type },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
assertNoSecretLeak(chunk);
|
|
85
|
+
yield chunk;
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
unsubscribeAccessInvalidation?.();
|
|
68
89
|
}
|
|
69
90
|
}
|
|
@@ -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
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// Value-only import, aliased to avoid shadowing the ambient global
|
|
2
|
+
// `Temporal` TYPE that ConsumerStateRow.updatedAt/StoredEventRow.createdAt
|
|
3
|
+
// resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
|
|
4
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
1
5
|
import { requestContext } from "../api/request-context";
|
|
2
6
|
import type { DbConnection, DbTx } from "../db/connection";
|
|
3
7
|
import {
|
|
@@ -111,8 +115,18 @@ export async function acquireConsumerState(
|
|
|
111
115
|
// a prior re-arm) gates the retry; maxRearmCount stops a poison event
|
|
112
116
|
// from looping forever (re-arm → same event fails → dead → re-arm →
|
|
113
117
|
// ...) — after the cap it stays dead until a human intervenes.
|
|
114
|
-
const cooldownDeadline =
|
|
115
|
-
|
|
118
|
+
const cooldownDeadline = TemporalPolyfill.Now.instant().subtract({
|
|
119
|
+
milliseconds: rearmCooldownMs,
|
|
120
|
+
});
|
|
121
|
+
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
|
|
122
|
+
// at runtime — state.updatedAt is DB-row-typed against the ambient
|
|
123
|
+
// global, two distinct nominal types across the two .d.ts sources (see
|
|
124
|
+
// event-store.ts).
|
|
125
|
+
const cooldownElapsed =
|
|
126
|
+
TemporalPolyfill.Instant.compare(
|
|
127
|
+
state.updatedAt as unknown as InstanceType<typeof TemporalPolyfill.Instant>,
|
|
128
|
+
cooldownDeadline,
|
|
129
|
+
) <= 0;
|
|
116
130
|
if (cooldownElapsed && state.rearmCount < maxRearmCount) {
|
|
117
131
|
const rearmed = await rearmDeadConsumer(tx, name, instanceId);
|
|
118
132
|
const rearmedState =
|
|
@@ -120,10 +134,10 @@ export async function acquireConsumerState(
|
|
|
120
134
|
(coerceRow(rearmed, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
|
|
121
135
|
if (rearmedState) return { state: rearmedState, skip: null };
|
|
122
136
|
}
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
137
|
+
// Caller (event-dispatcher.ts's processConsumer) emits
|
|
138
|
+
// kumiko_event_consumer_rearm_exhausted_total once per (consumer,
|
|
139
|
+
// instance) transition into this branch — it has the process-lifetime
|
|
140
|
+
// state to dedupe across poll passes that this pure function doesn't.
|
|
127
141
|
return { state: null, skip: "dead" };
|
|
128
142
|
}
|
|
129
143
|
return { state, skip: null };
|
|
@@ -4,6 +4,7 @@ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
|
4
4
|
import { EVENTS_PUBSUB_CHANNEL, type StoredEvent } from "../event-store";
|
|
5
5
|
import {
|
|
6
6
|
emitEventConsumerPassOutcome,
|
|
7
|
+
emitEventConsumerRearmExhausted,
|
|
7
8
|
emitEventDispatcherListenConnected,
|
|
8
9
|
getFallbackMeter,
|
|
9
10
|
getFallbackTracer,
|
|
@@ -201,6 +202,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
201
202
|
}
|
|
202
203
|
const tracer: Tracer = options.tracer ?? getFallbackTracer();
|
|
203
204
|
const meter: Meter = options.meter ?? getFallbackMeter();
|
|
205
|
+
// Tracks which (consumer, instanceId) pairs already fired
|
|
206
|
+
// kumiko_event_consumer_rearm_exhausted_total, so a consumer stuck dead
|
|
207
|
+
// across many poll passes emits the ops-signal once, not every pass
|
|
208
|
+
// (that would be log/metric spam for a state that hasn't changed).
|
|
209
|
+
// Process-lifetime only — restarting the dispatcher re-arms reporting,
|
|
210
|
+
// which is fine: a fresh process re-observing a still-dead consumer is
|
|
211
|
+
// exactly the "still needs a human" signal ops wants.
|
|
212
|
+
const reportedDeadConsumers = new Set<string>();
|
|
204
213
|
|
|
205
214
|
let running = false;
|
|
206
215
|
// Separate from `running` on purpose: pre-registration of consumer state
|
|
@@ -304,8 +313,21 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
304
313
|
// disabled/dead. Nothing to deliver this pass.
|
|
305
314
|
if (acquired.skip !== null) {
|
|
306
315
|
span.setAttribute("consumer.skip_reason", acquired.skip);
|
|
316
|
+
if (acquired.skip === "dead") {
|
|
317
|
+
const reportKey = `${consumer.name}:${instanceId}`;
|
|
318
|
+
if (!reportedDeadConsumers.has(reportKey)) {
|
|
319
|
+
reportedDeadConsumers.add(reportKey);
|
|
320
|
+
emitEventConsumerRearmExhausted(meter, { consumer: consumer.name, instanceId });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// skip: skip reason already recorded on the span above (and, for
|
|
324
|
+
// "dead", already emitted as a metric) — nothing left to deliver.
|
|
307
325
|
return;
|
|
308
326
|
}
|
|
327
|
+
// Acquired normally (including via a successful auto-rearm) — clear
|
|
328
|
+
// any prior dead-report so a future exhaustion re-emits instead of
|
|
329
|
+
// staying permanently suppressed by this process's Set.
|
|
330
|
+
reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
|
|
309
331
|
|
|
310
332
|
const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
|
|
311
333
|
// skip: nothing to deliver — no markProcessing/persistConsumerOutcome write,
|
package/src/pipeline/index.ts
CHANGED
|
@@ -57,6 +57,8 @@ export {
|
|
|
57
57
|
} from "./projection-state";
|
|
58
58
|
export { runProjectionsForEvent } from "./projections-runner";
|
|
59
59
|
export {
|
|
60
|
+
ACCESS_INVALIDATION_CONSUMER_NAME,
|
|
61
|
+
createAccessInvalidationEventConsumer,
|
|
60
62
|
createSearchEventConsumer,
|
|
61
63
|
createSseBroadcastEventConsumer,
|
|
62
64
|
SEARCH_CONSUMER_NAME,
|