@cosmicdrift/kumiko-framework 0.158.2 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createEntity,
|
|
5
5
|
createRegistry,
|
|
6
6
|
createTextField,
|
|
7
|
+
type DeleteContext,
|
|
7
8
|
defineFeature,
|
|
8
9
|
type PostSaveHookFn,
|
|
9
10
|
type PreSaveHookFn,
|
|
@@ -528,3 +529,210 @@ describe("buildEventId — dedup key construction", () => {
|
|
|
528
529
|
expect(buildEventId("handler", { id: 0 }, "phase")).toBeNull();
|
|
529
530
|
});
|
|
530
531
|
});
|
|
532
|
+
|
|
533
|
+
// --- PreDelete / PostDelete pipeline ---
|
|
534
|
+
|
|
535
|
+
const deletectx: DeleteContext = {
|
|
536
|
+
kind: "delete",
|
|
537
|
+
id: 1,
|
|
538
|
+
data: { email: "test@test.de" },
|
|
539
|
+
entityName: "user",
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
describe("runPreDelete", () => {
|
|
543
|
+
test("runs feature + entity + system hooks, all inTransaction (throw on error)", async () => {
|
|
544
|
+
const calls: string[] = [];
|
|
545
|
+
const feature = defineFeature("test", (r) => {
|
|
546
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
547
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
548
|
+
access: { openToAll: true },
|
|
549
|
+
});
|
|
550
|
+
r.hook("preDelete", "user", async () => {
|
|
551
|
+
calls.push("handler");
|
|
552
|
+
});
|
|
553
|
+
r.hook("preDelete", { allOf: "user" }, async () => {
|
|
554
|
+
calls.push("entity");
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
const registry = createRegistry([feature]);
|
|
558
|
+
const systemHooks: SystemHooks = {
|
|
559
|
+
preDelete: [
|
|
560
|
+
{
|
|
561
|
+
name: "sys",
|
|
562
|
+
priority: 1000,
|
|
563
|
+
fn: async () => {
|
|
564
|
+
calls.push("system");
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
570
|
+
await pipeline.runPreDelete("test:write:user", deletectx, {});
|
|
571
|
+
expect(calls).toEqual(["handler", "entity", "system"]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("a hook throwing aborts the delete (rejects, not swallowed)", async () => {
|
|
575
|
+
const feature = defineFeature("test", (r) => {
|
|
576
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
577
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
578
|
+
access: { openToAll: true },
|
|
579
|
+
});
|
|
580
|
+
r.hook("preDelete", "user", async () => {
|
|
581
|
+
throw new Error("blocked-delete");
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
const registry = createRegistry([feature]);
|
|
585
|
+
const pipeline = createLifecycleHooks(registry);
|
|
586
|
+
await expect(pipeline.runPreDelete("test:write:user", deletectx, {})).rejects.toThrow(
|
|
587
|
+
"blocked-delete",
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("system hook with a non-inTransaction phase is skipped", async () => {
|
|
592
|
+
const registry = makeRegistry();
|
|
593
|
+
const calls: string[] = [];
|
|
594
|
+
const systemHooks: SystemHooks = {
|
|
595
|
+
preDelete: [
|
|
596
|
+
{
|
|
597
|
+
name: "sys",
|
|
598
|
+
priority: 1000,
|
|
599
|
+
phase: "afterCommit",
|
|
600
|
+
fn: async () => {
|
|
601
|
+
calls.push("system");
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
};
|
|
606
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
607
|
+
await pipeline.runPreDelete("test:write:user", deletectx, {});
|
|
608
|
+
expect(calls).toEqual([]);
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe("runPostDelete", () => {
|
|
613
|
+
test("runs feature then system hooks (best-effort by default phase)", async () => {
|
|
614
|
+
const calls: string[] = [];
|
|
615
|
+
const feature = defineFeature("test", (r) => {
|
|
616
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
617
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
618
|
+
access: { openToAll: true },
|
|
619
|
+
});
|
|
620
|
+
r.hook("postDelete", "user", async () => {
|
|
621
|
+
calls.push("feature");
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
const registry = createRegistry([feature]);
|
|
625
|
+
const systemHooks: SystemHooks = {
|
|
626
|
+
postDelete: [
|
|
627
|
+
{
|
|
628
|
+
name: "sys",
|
|
629
|
+
priority: 1000,
|
|
630
|
+
fn: async () => {
|
|
631
|
+
calls.push("system");
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
};
|
|
636
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
637
|
+
await pipeline.runPostDelete("test:write:user", deletectx, {});
|
|
638
|
+
expect(calls).toEqual(["feature", "system"]);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("inTransaction phase: hook errors throw", async () => {
|
|
642
|
+
const feature = defineFeature("test", (r) => {
|
|
643
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
644
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
645
|
+
access: { openToAll: true },
|
|
646
|
+
});
|
|
647
|
+
r.hook(
|
|
648
|
+
"postDelete",
|
|
649
|
+
"user",
|
|
650
|
+
async () => {
|
|
651
|
+
throw new Error("postDelete-inTx-boom");
|
|
652
|
+
},
|
|
653
|
+
{ phase: "inTransaction" },
|
|
654
|
+
);
|
|
655
|
+
});
|
|
656
|
+
const registry = createRegistry([feature]);
|
|
657
|
+
const pipeline = createLifecycleHooks(registry);
|
|
658
|
+
await expect(
|
|
659
|
+
pipeline.runPostDelete("test:write:user", deletectx, {}, "inTransaction"),
|
|
660
|
+
).rejects.toThrow("postDelete-inTx-boom");
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// --- Batch hooks ---
|
|
665
|
+
|
|
666
|
+
describe("runPostSaveBatch / runPostDeleteBatch", () => {
|
|
667
|
+
test("no batch hooks registered → resolves without throwing", async () => {
|
|
668
|
+
const pipeline = createLifecycleHooks(makeRegistry());
|
|
669
|
+
await expect(pipeline.runPostSaveBatch([savectx], {})).resolves.toBeUndefined();
|
|
670
|
+
await expect(pipeline.runPostDeleteBatch([deletectx], {})).resolves.toBeUndefined();
|
|
671
|
+
// Should not throw — nothing registered.
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("runPostSaveBatch runs all system hooks concurrently with the batch payload", async () => {
|
|
675
|
+
const seen: (readonly SaveContext[])[] = [];
|
|
676
|
+
const systemHooks: SystemHooks = {
|
|
677
|
+
postSaveBatch: [
|
|
678
|
+
{
|
|
679
|
+
name: "a",
|
|
680
|
+
priority: 1000,
|
|
681
|
+
fn: async (results) => {
|
|
682
|
+
seen.push(results);
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
};
|
|
687
|
+
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
688
|
+
await pipeline.runPostSaveBatch([savectx], {});
|
|
689
|
+
expect(seen).toEqual([[savectx]]);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("runPostDeleteBatch runs all system hooks with the batch payload", async () => {
|
|
693
|
+
const seen: (readonly DeleteContext[])[] = [];
|
|
694
|
+
const systemHooks: SystemHooks = {
|
|
695
|
+
postDeleteBatch: [
|
|
696
|
+
{
|
|
697
|
+
name: "a",
|
|
698
|
+
priority: 1000,
|
|
699
|
+
fn: async (payloads) => {
|
|
700
|
+
seen.push(payloads);
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
],
|
|
704
|
+
};
|
|
705
|
+
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
706
|
+
await pipeline.runPostDeleteBatch([deletectx], {});
|
|
707
|
+
expect(seen).toEqual([[deletectx]]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("one batch hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async () => {
|
|
711
|
+
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
712
|
+
const calls: string[] = [];
|
|
713
|
+
const systemHooks: SystemHooks = {
|
|
714
|
+
postSaveBatch: [
|
|
715
|
+
{
|
|
716
|
+
name: "failing",
|
|
717
|
+
priority: 1000,
|
|
718
|
+
fn: async () => {
|
|
719
|
+
throw new Error("batch-hook-boom");
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
name: "ok",
|
|
724
|
+
priority: 1001,
|
|
725
|
+
fn: async () => {
|
|
726
|
+
calls.push("ok-ran");
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
};
|
|
731
|
+
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
732
|
+
// Must not throw.
|
|
733
|
+
await pipeline.runPostSaveBatch([savectx], {});
|
|
734
|
+
expect(calls).toEqual(["ok-ran"]);
|
|
735
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
736
|
+
consoleSpy.mockRestore();
|
|
737
|
+
});
|
|
738
|
+
});
|
|
@@ -484,8 +484,18 @@ export function buildHandlerContext(
|
|
|
484
484
|
// When the feature-toggles or tier-engine feature isn't wired (no
|
|
485
485
|
// effectiveFeatures callback), always returns true — apps without
|
|
486
486
|
// tier-cuts treat all features on.
|
|
487
|
-
|
|
488
|
-
|
|
487
|
+
//
|
|
488
|
+
// Falls back to the live trialGate when the sync set says the feature
|
|
489
|
+
// is off — the sync set never contains trial-tier features (time-
|
|
490
|
+
// derived, can't boot-cache), so without this a trial tenant checking
|
|
491
|
+
// a companion feature's toggle would silently read `false` even though
|
|
492
|
+
// the dispatch gate already lets trial-tier handlers run.
|
|
493
|
+
hasFeature: async (featureName: string): Promise<boolean> => {
|
|
494
|
+
if (!effectiveFeatures) return true;
|
|
495
|
+
if (effectiveFeatures(user.tenantId).has(featureName)) return true;
|
|
496
|
+
if (!effectiveFeatures.trialGate) return false;
|
|
497
|
+
return effectiveFeatures.trialGate(user.tenantId, featureName);
|
|
498
|
+
},
|
|
489
499
|
};
|
|
490
500
|
|
|
491
501
|
// Registry is always the dispatcher's registry — injecting it here lets
|
|
@@ -549,7 +559,7 @@ export function buildHandlerContext(
|
|
|
549
559
|
export async function runHandlerInstrumented<T>(
|
|
550
560
|
ctx: DispatchContext,
|
|
551
561
|
type: string,
|
|
552
|
-
operation: "query" | "write",
|
|
562
|
+
operation: "query" | "write" | "stream",
|
|
553
563
|
user: SessionUser,
|
|
554
564
|
inner: () => Promise<T>,
|
|
555
565
|
): Promise<T> {
|
|
@@ -599,6 +609,44 @@ export async function runHandlerInstrumented<T>(
|
|
|
599
609
|
}
|
|
600
610
|
}
|
|
601
611
|
|
|
612
|
+
// Generator-native counterpart to runHandlerInstrumented — a stream's
|
|
613
|
+
// lifetime spans every `for await` pull the caller makes, so the span
|
|
614
|
+
// can't be scoped via withSpan's single-callback shape. startSpan/end
|
|
615
|
+
// bracket the whole yield* instead; metrics land in the same finally
|
|
616
|
+
// path so success/failure/throw all hit one emit, like the Promise path.
|
|
617
|
+
export async function* runStreamInstrumented<T>(
|
|
618
|
+
ctx: DispatchContext,
|
|
619
|
+
type: string,
|
|
620
|
+
user: SessionUser,
|
|
621
|
+
inner: () => AsyncGenerator<T>,
|
|
622
|
+
): AsyncGenerator<T> {
|
|
623
|
+
const { tracer: dispatcherTracer, meter: dispatcherMeter, registry } = ctx;
|
|
624
|
+
const start = performance.now();
|
|
625
|
+
let success = true;
|
|
626
|
+
let errorClass: string | undefined;
|
|
627
|
+
const span = dispatcherTracer.startSpan("kumiko.dispatcher.handler", {
|
|
628
|
+
attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
|
|
629
|
+
});
|
|
630
|
+
try {
|
|
631
|
+
yield* inner();
|
|
632
|
+
} catch (error) {
|
|
633
|
+
success = false;
|
|
634
|
+
errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
|
|
635
|
+
span.setStatus("error", errorClass);
|
|
636
|
+
throw error;
|
|
637
|
+
} finally {
|
|
638
|
+
span.end();
|
|
639
|
+
if (!success && errorClass) {
|
|
640
|
+
emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
|
|
641
|
+
}
|
|
642
|
+
emitDispatcherHandler(
|
|
643
|
+
dispatcherMeter,
|
|
644
|
+
{ handler: type, success },
|
|
645
|
+
(performance.now() - start) / 1000,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
602
650
|
// Feature-toggle gate. Returns the error to fold into a WriteFailure in the
|
|
603
651
|
// write path, or throws for the query path (where throws flow through the
|
|
604
652
|
// same outer instrumentation wrapper as other dispatcher errors).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { DbTx } from "../db/connection";
|
|
2
|
+
import { hasAccess } from "../engine/access";
|
|
3
|
+
import type { SessionUser } from "../engine/types";
|
|
4
|
+
import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
|
|
5
|
+
import { assertNoSecretLeak } from "../secrets";
|
|
6
|
+
import {
|
|
7
|
+
buildHandlerContext,
|
|
8
|
+
type DispatchContext,
|
|
9
|
+
enforceRateLimit,
|
|
10
|
+
ensureFeatureEnabled,
|
|
11
|
+
runStreamInstrumented,
|
|
12
|
+
} from "./dispatch-shared";
|
|
13
|
+
|
|
14
|
+
// Standalone stream execution — used by the public dispatcher.stream().
|
|
15
|
+
// Chunk-by-chunk analog of executeQuery: same gate order (feature → rate-
|
|
16
|
+
// limit → access → validation → handler), but yields incrementally instead
|
|
17
|
+
// of returning a single response. streamHandler never entity-maps (unlike
|
|
18
|
+
// write/queryHandler — see feature-entity-handlers.ts), so there's no
|
|
19
|
+
// field-access filter or postQuery-hook stage to run here.
|
|
20
|
+
export async function* executeStream(
|
|
21
|
+
ctx: DispatchContext,
|
|
22
|
+
type: string,
|
|
23
|
+
payload: unknown,
|
|
24
|
+
user: SessionUser,
|
|
25
|
+
tx?: DbTx,
|
|
26
|
+
): AsyncGenerator<unknown> {
|
|
27
|
+
yield* runStreamInstrumented(ctx, type, user, () =>
|
|
28
|
+
executeStreamInner(ctx, type, payload, user, tx),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function* executeStreamInner(
|
|
33
|
+
ctx: DispatchContext,
|
|
34
|
+
type: string,
|
|
35
|
+
payload: unknown,
|
|
36
|
+
user: SessionUser,
|
|
37
|
+
tx?: DbTx,
|
|
38
|
+
): AsyncGenerator<unknown> {
|
|
39
|
+
const { registry } = ctx;
|
|
40
|
+
const handler = registry.getStreamHandler(type);
|
|
41
|
+
if (!handler) throw new NotFoundError("handler", type);
|
|
42
|
+
|
|
43
|
+
await ensureFeatureEnabled(ctx, type, user.tenantId);
|
|
44
|
+
|
|
45
|
+
if (handler.rateLimit !== undefined) {
|
|
46
|
+
await enforceRateLimit(ctx, handler.rateLimit, type, user);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!hasAccess(user, handler.access)) {
|
|
50
|
+
throw new AccessDeniedError({
|
|
51
|
+
message: `access denied for ${type}`,
|
|
52
|
+
details: { handler: type },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const parsed = handler.schema.safeParse(payload);
|
|
57
|
+
if (!parsed.success) {
|
|
58
|
+
throw validationErrorFromZod(parsed.error);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handlerContext = buildHandlerContext(ctx, type, user, tx);
|
|
62
|
+
const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
63
|
+
|
|
64
|
+
// Consumer-driven pull (for await) is the backpressure mechanism — the
|
|
65
|
+
// handler generator only advances once the caller reads the previous
|
|
66
|
+
// chunk, no explicit buffering/throttling needed on either side.
|
|
67
|
+
for await (const chunk of chunks) {
|
|
68
|
+
// Re-checked per chunk, not just at stream-start: a feature disabled
|
|
69
|
+
// mid-stream must cut an already-open stream, not just block new ones.
|
|
70
|
+
await ensureFeatureEnabled(ctx, type, user.tenantId);
|
|
71
|
+
assertNoSecretLeak(chunk);
|
|
72
|
+
yield chunk;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -8,6 +8,7 @@ import { runBatch, unwrapSingle } from "./dispatch-batch";
|
|
|
8
8
|
import { executeQuery } from "./dispatch-query";
|
|
9
9
|
import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
|
|
10
10
|
import { resolveAuthClaimsFn } from "./dispatch-shared";
|
|
11
|
+
import { executeStream } from "./dispatch-stream";
|
|
11
12
|
import { type HandlerType, resolveType } from "./dispatcher-utils";
|
|
12
13
|
import type { IdempotencyGuard } from "./idempotency";
|
|
13
14
|
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
@@ -52,6 +53,10 @@ export type Dispatcher = {
|
|
|
52
53
|
requestId?: string,
|
|
53
54
|
): Promise<WriteResult>;
|
|
54
55
|
query(type: HandlerType, payload: unknown, user: SessionUser): Promise<unknown>;
|
|
56
|
+
// AsyncGenerator, not Promise — gates (feature/rate-limit/access/
|
|
57
|
+
// validation) fire on the consumer's first `.next()` pull, not on this
|
|
58
|
+
// call, since they live inside the underlying async function*.
|
|
59
|
+
stream(type: HandlerType, payload: unknown, user: SessionUser): AsyncGenerator<unknown>;
|
|
55
60
|
command(type: HandlerType, payload: unknown, user: SessionUser): Promise<void>;
|
|
56
61
|
// Atomic multi-command write: all commands run in a single DB transaction.
|
|
57
62
|
// On any failure, the transaction rolls back and afterCommit hooks do NOT fire.
|
|
@@ -114,6 +119,8 @@ export function createDispatcher(
|
|
|
114
119
|
|
|
115
120
|
query: (typeOrRef, payload, user) => executeQuery(ctx, resolveType(typeOrRef), payload, user),
|
|
116
121
|
|
|
122
|
+
stream: (typeOrRef, payload, user) => executeStream(ctx, resolveType(typeOrRef), payload, user),
|
|
123
|
+
|
|
117
124
|
async command(typeOrRef, payload, user) {
|
|
118
125
|
const type = resolveType(typeOrRef);
|
|
119
126
|
const batchResult = await runBatch(ctx, [{ type, payload }], user);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EntityCache } from "@cosmicdrift/kumiko-types/entity-cache";
|
|
1
2
|
import type Redis from "ioredis";
|
|
2
3
|
import type { EntityId, TenantId } from "../engine/types/identifiers";
|
|
3
4
|
import { RedisKeys } from "./redis-keys";
|
|
@@ -23,39 +24,7 @@ function parseCached(raw: string): Record<string, unknown> | null {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export type EntityCache
|
|
27
|
-
/** Get a single cached entity. Returns null on miss. */
|
|
28
|
-
get(
|
|
29
|
-
tenantId: TenantId,
|
|
30
|
-
entityName: string,
|
|
31
|
-
id: EntityId,
|
|
32
|
-
): Promise<Record<string, unknown> | null>;
|
|
33
|
-
|
|
34
|
-
/** Get multiple cached entities. Returns a Map of id → data (misses are absent). */
|
|
35
|
-
mget(
|
|
36
|
-
tenantId: TenantId,
|
|
37
|
-
entityName: string,
|
|
38
|
-
ids: readonly EntityId[],
|
|
39
|
-
): Promise<Map<EntityId, Record<string, unknown>>>;
|
|
40
|
-
|
|
41
|
-
/** Cache a single entity. */
|
|
42
|
-
set(
|
|
43
|
-
tenantId: TenantId,
|
|
44
|
-
entityName: string,
|
|
45
|
-
id: EntityId,
|
|
46
|
-
data: Record<string, unknown>,
|
|
47
|
-
): Promise<void>;
|
|
48
|
-
|
|
49
|
-
/** Cache multiple entities at once. */
|
|
50
|
-
mset(
|
|
51
|
-
tenantId: TenantId,
|
|
52
|
-
entityName: string,
|
|
53
|
-
entries: ReadonlyArray<{ id: EntityId; data: Record<string, unknown> }>,
|
|
54
|
-
): Promise<void>;
|
|
55
|
-
|
|
56
|
-
/** Invalidate a single cached entity. */
|
|
57
|
-
del(tenantId: TenantId, entityName: string, id: EntityId): Promise<void>;
|
|
58
|
-
};
|
|
27
|
+
export type { EntityCache } from "@cosmicdrift/kumiko-types/entity-cache";
|
|
59
28
|
|
|
60
29
|
export type EntityCacheOptions = {
|
|
61
30
|
ttlSeconds?: number;
|
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
sql,
|
|
11
11
|
text,
|
|
12
12
|
} from "../db/dialect";
|
|
13
|
-
import {
|
|
13
|
+
import { alterTableAddColumn } from "../db/queries/test-stack";
|
|
14
|
+
import { columnNamesOf, tableExists } from "../db/schema-inspection";
|
|
14
15
|
import { unsafePushTables } from "../stack";
|
|
15
16
|
|
|
16
17
|
// Reserved sentinel used in the instance_id column for consumers whose
|
|
@@ -72,6 +73,14 @@ export const eventConsumerStateTable = pgTable(
|
|
|
72
73
|
.default(sql`0`),
|
|
73
74
|
status: text("status").notNull().default("idle"),
|
|
74
75
|
attempts: integer("attempts").notNull().default(0),
|
|
76
|
+
// Counts automatic dead→idle revivals since the last delivery that
|
|
77
|
+
// advanced the cursor (event-dispatcher.ts's cooldown re-arm, see
|
|
78
|
+
// acquireConsumerState). A poison event never advances the cursor, so
|
|
79
|
+
// this climbs to maxRearmCount and then stays dead permanently. A
|
|
80
|
+
// successful delivery resets it to 0 (proof the consumer isn't
|
|
81
|
+
// poisoned), as does a manual restartConsumer()/enableConsumer()/
|
|
82
|
+
// skipPoisonEvent() — an operator vouching the consumer is healthy.
|
|
83
|
+
rearmCount: integer("rearm_count").notNull().default(0),
|
|
75
84
|
lastError: text("last_error"),
|
|
76
85
|
updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
|
|
77
86
|
},
|
|
@@ -99,7 +108,23 @@ export type ConsumerStatus = (typeof ConsumerStatuses)[keyof typeof ConsumerStat
|
|
|
99
108
|
//
|
|
100
109
|
// guard:dup-ok — intentionale Parallele zu createProjectionStateTable; symmetrische State-Tabellen by design
|
|
101
110
|
export async function createEventConsumerStateTable(db: DbConnection): Promise<void> {
|
|
102
|
-
// skip: table already exists — bootstrap is called from multiple paths
|
|
103
|
-
|
|
111
|
+
// skip: table already exists — bootstrap is called from multiple paths.
|
|
112
|
+
// Still check for columns added after the table's first deploy (e.g.
|
|
113
|
+
// rearm_count) so an older DB catches up without a dedicated migration.
|
|
114
|
+
if (await tableExists(db, "public.kumiko_event_consumers")) {
|
|
115
|
+
const cols = await columnNamesOf(db, "kumiko_event_consumers");
|
|
116
|
+
if (!cols.has("rearm_count")) {
|
|
117
|
+
await alterTableAddColumn(
|
|
118
|
+
db,
|
|
119
|
+
"kumiko_event_consumers",
|
|
120
|
+
"rearm_count",
|
|
121
|
+
"integer",
|
|
122
|
+
" DEFAULT 0",
|
|
123
|
+
" NOT NULL",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
// skip: table (+ any missing column) is already up to date
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
104
129
|
await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
|
|
105
130
|
}
|
|
@@ -34,6 +34,7 @@ function normalizeConsumerState(row: ConsumerStateRowShape): ConsumerRecoverySta
|
|
|
34
34
|
status: row.status,
|
|
35
35
|
lastProcessedEventId: row.lastProcessedEventId,
|
|
36
36
|
attempts: row.attempts,
|
|
37
|
+
rearmCount: row.rearmCount,
|
|
37
38
|
lastError: row.lastError,
|
|
38
39
|
updatedAt: row.updatedAt,
|
|
39
40
|
};
|
|
@@ -45,6 +46,7 @@ export type ConsumerRecoveryState = {
|
|
|
45
46
|
readonly status: string;
|
|
46
47
|
readonly lastProcessedEventId: bigint;
|
|
47
48
|
readonly attempts: number;
|
|
49
|
+
readonly rearmCount: number;
|
|
48
50
|
readonly lastError: string | null;
|
|
49
51
|
readonly updatedAt: Temporal.Instant;
|
|
50
52
|
};
|
|
@@ -170,6 +172,7 @@ export async function getConsumerState(
|
|
|
170
172
|
readonly status: string;
|
|
171
173
|
readonly lastProcessedEventId: bigint;
|
|
172
174
|
readonly attempts: number;
|
|
175
|
+
readonly rearmCount: number;
|
|
173
176
|
readonly lastError: string | null;
|
|
174
177
|
readonly updatedAt: Temporal.Instant;
|
|
175
178
|
} | null> {
|
|
@@ -184,6 +187,7 @@ export async function getConsumerState(
|
|
|
184
187
|
status: row.status,
|
|
185
188
|
lastProcessedEventId: row.lastProcessedEventId,
|
|
186
189
|
attempts: row.attempts,
|
|
190
|
+
rearmCount: row.rearmCount,
|
|
187
191
|
lastError: row.lastError,
|
|
188
192
|
updatedAt: row.updatedAt,
|
|
189
193
|
};
|
|
@@ -3,6 +3,7 @@ import type { DbConnection, DbTx } from "../db/connection";
|
|
|
3
3
|
import {
|
|
4
4
|
insertConsumerIfAbsent,
|
|
5
5
|
markConsumerProcessing,
|
|
6
|
+
rearmDeadConsumer,
|
|
6
7
|
selectConsumerForUpdateSkipLocked,
|
|
7
8
|
updateConsumerDeliveryOutcome,
|
|
8
9
|
} from "../db/queries/event-consumer";
|
|
@@ -38,12 +39,13 @@ export type ConsumerStateRowShape = {
|
|
|
38
39
|
readonly lastProcessedEventId: bigint;
|
|
39
40
|
readonly status: string;
|
|
40
41
|
readonly attempts: number;
|
|
42
|
+
readonly rearmCount: number;
|
|
41
43
|
readonly lastError: string | null;
|
|
42
44
|
readonly updatedAt: Temporal.Instant;
|
|
43
45
|
};
|
|
44
46
|
export type ConsumerStateRow = ConsumerStateRowShape;
|
|
45
47
|
|
|
46
|
-
type StoredEventRow = {
|
|
48
|
+
export type StoredEventRow = {
|
|
47
49
|
readonly id: bigint;
|
|
48
50
|
readonly aggregateId: string;
|
|
49
51
|
readonly aggregateType: string;
|
|
@@ -80,6 +82,8 @@ export async function acquireConsumerState(
|
|
|
80
82
|
tx: DbTx,
|
|
81
83
|
name: string,
|
|
82
84
|
instanceId: string,
|
|
85
|
+
rearmCooldownMs: number,
|
|
86
|
+
maxRearmCount: number,
|
|
83
87
|
): Promise<AcquireOutcome> {
|
|
84
88
|
const rawState = await selectConsumerForUpdateSkipLocked(tx, name, instanceId);
|
|
85
89
|
|
|
@@ -100,7 +104,28 @@ export async function acquireConsumerState(
|
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
|
|
103
|
-
if (state.status === ConsumerStatuses.dead)
|
|
107
|
+
if (state.status === ConsumerStatuses.dead) {
|
|
108
|
+
// Bounded auto-revival: a transient failure (e.g. a Meilisearch blip)
|
|
109
|
+
// shouldn't need an operator to notice and run restartConsumer() once
|
|
110
|
+
// the cause is long gone. Cooldown since the last write (the death or
|
|
111
|
+
// a prior re-arm) gates the retry; maxRearmCount stops a poison event
|
|
112
|
+
// from looping forever (re-arm → same event fails → dead → re-arm →
|
|
113
|
+
// ...) — after the cap it stays dead until a human intervenes.
|
|
114
|
+
const cooldownDeadline = Temporal.Now.instant().subtract({ milliseconds: rearmCooldownMs });
|
|
115
|
+
const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
|
|
116
|
+
if (cooldownElapsed && state.rearmCount < maxRearmCount) {
|
|
117
|
+
const rearmed = await rearmDeadConsumer(tx, name, instanceId);
|
|
118
|
+
const rearmedState =
|
|
119
|
+
rearmed &&
|
|
120
|
+
(coerceRow(rearmed, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
|
|
121
|
+
if (rearmedState) return { state: rearmedState, skip: null };
|
|
122
|
+
}
|
|
123
|
+
// ponytail: no log/metric fires when the rearm budget is exhausted here
|
|
124
|
+
// (the exact "braucht manuellen Eingriff" moment) — queryable via
|
|
125
|
+
// getConsumerState but silent otherwise. Add an emitDispatcherError-style
|
|
126
|
+
// signal if ops needs a push instead of a dead+lag poll.
|
|
127
|
+
return { state: null, skip: "dead" };
|
|
128
|
+
}
|
|
104
129
|
return { state, skip: null };
|
|
105
130
|
}
|
|
106
131
|
|
|
@@ -183,6 +208,7 @@ export async function deliverEvents(
|
|
|
183
208
|
let attempts = state.attempts;
|
|
184
209
|
let lastError: string | null = state.lastError ?? null;
|
|
185
210
|
let deadLettered = false;
|
|
211
|
+
const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
|
|
186
212
|
let processed = 0;
|
|
187
213
|
let failed = 0;
|
|
188
214
|
|
|
@@ -231,7 +257,7 @@ export async function deliverEvents(
|
|
|
231
257
|
attempts += 1;
|
|
232
258
|
lastError = errMessage;
|
|
233
259
|
failed += 1;
|
|
234
|
-
if (attempts >=
|
|
260
|
+
if (attempts >= effectiveMaxAttempts) deadLettered = true;
|
|
235
261
|
break;
|
|
236
262
|
}
|
|
237
263
|
}
|
|
@@ -66,6 +66,11 @@ export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Prom
|
|
|
66
66
|
// the EventConsumer (see api/server.ts MSP wiring).
|
|
67
67
|
export type EventConsumerErrorPolicy = {
|
|
68
68
|
readonly skipApplyErrors?: boolean;
|
|
69
|
+
// Per-consumer override of EventDispatcherOptions.maxAttempts. A consumer
|
|
70
|
+
// that depends on infra which may still be provisioning at boot (search
|
|
71
|
+
// adapter, external APIs) needs more retry headroom than the dispatcher-
|
|
72
|
+
// wide default before it gets dead-lettered.
|
|
73
|
+
readonly maxAttempts?: number;
|
|
69
74
|
};
|
|
70
75
|
|
|
71
76
|
export type EventConsumer = {
|
|
@@ -129,6 +134,17 @@ export type EventDispatcherOptions = {
|
|
|
129
134
|
readonly batchSize?: number;
|
|
130
135
|
readonly pollIntervalMs?: number;
|
|
131
136
|
readonly maxAttempts?: number;
|
|
137
|
+
// Bounded auto-revival of a "dead" consumer. A dead consumer whose last
|
|
138
|
+
// write is older than rearmCooldownMs gets reset to idle and retried.
|
|
139
|
+
// rearmCount tracks re-arms since the last delivery that actually
|
|
140
|
+
// advanced the cursor — a poison event never advances it, so it climbs
|
|
141
|
+
// 1→maxRearmCount and then stays dead permanently; a transient failure
|
|
142
|
+
// that later delivers successfully resets it back to 0, so an unrelated
|
|
143
|
+
// future outage gets its own fresh budget. restartConsumer()/
|
|
144
|
+
// enableConsumer()/skipPoisonEvent() also reset it (an operator vouching
|
|
145
|
+
// the consumer is healthy again).
|
|
146
|
+
readonly rearmCooldownMs?: number;
|
|
147
|
+
readonly maxRearmCount?: number;
|
|
132
148
|
readonly tracer?: Tracer;
|
|
133
149
|
readonly meter?: Meter;
|
|
134
150
|
// Identifies THIS dispatcher process in the consumer-state table. Used as
|
|
@@ -150,6 +166,8 @@ export type EventDispatcherOptions = {
|
|
|
150
166
|
const DEFAULT_BATCH_SIZE = 200;
|
|
151
167
|
const DEFAULT_POLL_MS = 100;
|
|
152
168
|
const DEFAULT_MAX_ATTEMPTS = 10;
|
|
169
|
+
const DEFAULT_REARM_COOLDOWN_MS = 5 * 60_000;
|
|
170
|
+
const DEFAULT_MAX_REARM_COUNT = 3;
|
|
153
171
|
|
|
154
172
|
export function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher {
|
|
155
173
|
const {
|
|
@@ -159,6 +177,8 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
159
177
|
batchSize = DEFAULT_BATCH_SIZE,
|
|
160
178
|
pollIntervalMs = DEFAULT_POLL_MS,
|
|
161
179
|
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
180
|
+
rearmCooldownMs = DEFAULT_REARM_COOLDOWN_MS,
|
|
181
|
+
maxRearmCount = DEFAULT_MAX_REARM_COUNT,
|
|
162
182
|
} = options;
|
|
163
183
|
|
|
164
184
|
// Fail-fast on misconfigured per-instance wiring. Catching this at
|
|
@@ -273,7 +293,13 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
273
293
|
|
|
274
294
|
try {
|
|
275
295
|
await db.begin(async (tx: DbTx) => {
|
|
276
|
-
const acquired = await acquireConsumerState(
|
|
296
|
+
const acquired = await acquireConsumerState(
|
|
297
|
+
tx,
|
|
298
|
+
consumer.name,
|
|
299
|
+
instanceId,
|
|
300
|
+
rearmCooldownMs,
|
|
301
|
+
maxRearmCount,
|
|
302
|
+
);
|
|
277
303
|
// skip: another instance holds the lock, or the consumer is
|
|
278
304
|
// disabled/dead. Nothing to deliver this pass.
|
|
279
305
|
if (acquired.skip !== null) {
|