@cosmicdrift/kumiko-framework 0.235.4 → 0.236.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.235.4",
3
+ "version": "0.236.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.235.4",
201
+ "@cosmicdrift/kumiko-types": "0.236.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.235.4",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.236.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -0,0 +1,151 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createTestRedis, type TestRedis } from "../../stack";
3
+ import { waitFor } from "../../testing/wait-for";
4
+ import { generateId } from "../../utils";
5
+ import { createRedisSseBroker, type RedisSseBroker } from "../redis-sse-broker";
6
+ import type { SseEvent } from "../sse-broker";
7
+
8
+ let testRedis: TestRedis;
9
+ // keyPrefix on testRedis.redis does not apply to pub/sub channel names
10
+ // (stack/redis.ts), so isolation from other parallel test files comes from
11
+ // generateId()-suffixed channel/userId values below, not from the prefix.
12
+ const brokers: RedisSseBroker[] = [];
13
+
14
+ function trackedBroker(): RedisSseBroker {
15
+ const broker = createRedisSseBroker({ redisUrl: testRedis.redisUrl });
16
+ brokers.push(broker);
17
+ return broker;
18
+ }
19
+
20
+ beforeAll(async () => {
21
+ testRedis = await createTestRedis();
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await testRedis.cleanup();
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(brokers.splice(0).map((b) => b.close()));
30
+ });
31
+
32
+ describe("createRedisSseBroker", () => {
33
+ test("pushToChannel on one broker reaches a client registered on another (cross-pod fanout)", async () => {
34
+ const podA = trackedBroker();
35
+ const podB = trackedBroker();
36
+ const channel = `test-channel-${generateId()}`;
37
+ const received: SseEvent[] = [];
38
+
39
+ podA.addClient(
40
+ channel,
41
+ (event) => received.push(event),
42
+ () => {},
43
+ );
44
+
45
+ // Retry the publish inside the predicate, not just the wait: Pub/Sub
46
+ // does not buffer for a subscriber whose psubscribe ack hasn't landed
47
+ // yet (a one-time race right after broker construction, not
48
+ // reproducible once real traffic starts seconds after boot) — a single
49
+ // fire-and-check would be flaky rather than proving the real property.
50
+ await waitFor(() => {
51
+ podB.pushToChannel(channel, { type: "unit.updated", data: { id: "1" } });
52
+ return received.length >= 1;
53
+ });
54
+ expect(received[0]).toEqual({ type: "unit.updated", data: { id: "1" } });
55
+ });
56
+
57
+ test("publishAccessInvalidation on one broker fires subscribeAccessInvalidation listeners on another", async () => {
58
+ const podA = trackedBroker();
59
+ const podB = trackedBroker();
60
+ const userId = `user-${generateId()}`;
61
+ let invalidated = false;
62
+
63
+ podA.subscribeAccessInvalidation(userId, () => {
64
+ invalidated = true;
65
+ });
66
+
67
+ await waitFor(() => {
68
+ podB.publishAccessInvalidation(userId);
69
+ return invalidated;
70
+ });
71
+ expect(invalidated).toBe(true);
72
+ });
73
+
74
+ test("a client on channel A never receives an event published to channel B (no cross-tenant leak)", async () => {
75
+ const podA = trackedBroker();
76
+ const podB = trackedBroker();
77
+ const channelA = `tenant-a-${generateId()}`;
78
+ const channelB = `tenant-b-${generateId()}`;
79
+ const receivedOnA: SseEvent[] = [];
80
+ const receivedOnControl: SseEvent[] = [];
81
+
82
+ podA.addClient(
83
+ channelA,
84
+ (event) => receivedOnA.push(event),
85
+ () => {},
86
+ );
87
+ // Control channel proves the psubscribe pipe itself is alive — without
88
+ // this, a broken subscription would make the negative assertion below
89
+ // pass for the wrong reason (nothing ever arrives, on any channel).
90
+ podA.addClient(
91
+ channelB,
92
+ (event) => receivedOnControl.push(event),
93
+ () => {},
94
+ );
95
+
96
+ await waitFor(() => {
97
+ podB.pushToChannel(channelB, { type: "unit.updated", data: { id: "leak-probe" } });
98
+ return receivedOnControl.length >= 1;
99
+ });
100
+ expect(receivedOnA).toHaveLength(0);
101
+ });
102
+
103
+ test("subscribeAccessInvalidation for one user is never fired by an invalidation for another user", async () => {
104
+ const podA = trackedBroker();
105
+ const podB = trackedBroker();
106
+ const userA = `user-a-${generateId()}`;
107
+ const userB = `user-b-${generateId()}`;
108
+ let invalidatedA = false;
109
+ let invalidatedBControl = false;
110
+
111
+ podA.subscribeAccessInvalidation(userA, () => {
112
+ invalidatedA = true;
113
+ });
114
+ podA.subscribeAccessInvalidation(userB, () => {
115
+ invalidatedBControl = true;
116
+ });
117
+
118
+ await waitFor(() => {
119
+ podB.publishAccessInvalidation(userB);
120
+ return invalidatedBControl;
121
+ });
122
+ expect(invalidatedA).toBe(false);
123
+ });
124
+
125
+ test("a malformed message on the channel namespace is dropped, not thrown, and does not kill delivery", async () => {
126
+ const podA = trackedBroker();
127
+ const publisherRaw = new (await import("ioredis")).default(testRedis.redisUrl);
128
+ const channel = `test-channel-${generateId()}`;
129
+ const received: SseEvent[] = [];
130
+
131
+ podA.addClient(
132
+ channel,
133
+ (event) => received.push(event),
134
+ () => {},
135
+ );
136
+
137
+ try {
138
+ await waitFor(async () => {
139
+ // Not valid JSON — simulates a foreign/misbehaving publisher on the
140
+ // shared kumiko:sse:* namespace.
141
+ await publisherRaw.publish(`kumiko:sse:ch:${channel}`, "not-json{{{");
142
+ // Broker must still be alive: a real event right after must arrive.
143
+ podA.pushToChannel(channel, { type: "unit.updated", data: { id: "after-garbage" } });
144
+ return received.length >= 1;
145
+ });
146
+ expect(received[0]).toEqual({ type: "unit.updated", data: { id: "after-garbage" } });
147
+ } finally {
148
+ publisherRaw.disconnect();
149
+ }
150
+ });
151
+ });
package/src/api/index.ts CHANGED
@@ -41,6 +41,8 @@ export {
41
41
  export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
42
42
  export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
43
43
  export { patAllows, qnMatches } from "./pat-scope";
44
+ export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
45
+ export { createRedisSseBroker } from "./redis-sse-broker";
44
46
  export { type RequestContextData, requestContext } from "./request-context";
45
47
  export { requestIdMiddleware } from "./request-id-middleware";
46
48
  export { createApiRoutes } from "./routes";
@@ -0,0 +1,89 @@
1
+ import { createRedisPubSubSignal } from "../redis/pubsub-signal";
2
+ import { createSseBroker, type SseBroker, type SseEvent } from "./sse-broker";
3
+
4
+ // Channel namespace for cross-replica fanout (fw#2625). Every pod publishes
5
+ // here and every pod's psubscribe listens here, so a push on one instance
6
+ // reaches SSE clients connected to any other instance without a shared
7
+ // cursor — Pub/Sub for connected-client fanout, the event-store's shared
8
+ // cursor stays the mechanism for "read every event exactly once".
9
+ const CHANNEL_PREFIX = "kumiko:sse:ch:";
10
+ const INVALIDATION_PREFIX = "kumiko:sse:inval:";
11
+ const PSUBSCRIBE_PATTERN = "kumiko:sse:*";
12
+
13
+ export type RedisSseBrokerOptions = {
14
+ readonly redisUrl: string;
15
+ };
16
+
17
+ export type RedisSseBroker = SseBroker & {
18
+ // Not on SseBroker — the in-memory broker has nothing to release, but
19
+ // this one owns two live ioredis connections (via the shared PubSubSignal).
20
+ // buildServer only calls this when it created the broker itself (an
21
+ // app-injected sseBroker owns its own lifecycle).
22
+ close(): Promise<void>;
23
+ };
24
+
25
+ function isSseEvent(value: unknown): value is SseEvent {
26
+ return (
27
+ typeof value === "object" &&
28
+ value !== null &&
29
+ typeof (value as { type?: unknown }).type === "string" &&
30
+ typeof (value as { data?: unknown }).data === "object" &&
31
+ (value as { data?: unknown }).data !== null
32
+ );
33
+ }
34
+
35
+ // Transport layer around a local `createSseBroker()` — all client/listener
36
+ // state lives in `inner`, this only moves events across the Redis wire via
37
+ // the shared PubSubSignal. `pushToChannel`/`publishAccessInvalidation` never
38
+ // call `inner` directly: they publish, and the signal's message handler
39
+ // below calls `inner` for BOTH the publishing pod and every other pod, so
40
+ // there's exactly one delivery path regardless of which instance receives
41
+ // the request.
42
+ export function createRedisSseBroker(opts: RedisSseBrokerOptions): RedisSseBroker {
43
+ const inner = createSseBroker();
44
+ const signal = createRedisPubSubSignal({
45
+ redisUrl: opts.redisUrl,
46
+ channelPattern: PSUBSCRIBE_PATTERN,
47
+ label: "sse-broker",
48
+ });
49
+
50
+ signal.onMessage((channel, payload) => {
51
+ if (channel.startsWith(CHANNEL_PREFIX)) {
52
+ if (!isSseEvent(payload)) {
53
+ console.error(`[kumiko:sse-broker] dropping malformed event on "${channel}"`);
54
+ // skip: malformed payload already logged above, nothing to deliver
55
+ return;
56
+ }
57
+ inner.pushToChannel(channel.slice(CHANNEL_PREFIX.length), payload);
58
+ // skip: channel already routed to the SSE-event branch above, the
59
+ // invalidation branch below is mutually exclusive with this one
60
+ return;
61
+ }
62
+
63
+ if (channel.startsWith(INVALIDATION_PREFIX)) {
64
+ inner.publishAccessInvalidation(channel.slice(INVALIDATION_PREFIX.length));
65
+ }
66
+ });
67
+
68
+ return {
69
+ addClient: inner.addClient,
70
+ removeClient: inner.removeClient,
71
+ getClientCount: inner.getClientCount,
72
+ getTotalClientCount: inner.getTotalClientCount,
73
+ subscribeAccessInvalidation: inner.subscribeAccessInvalidation,
74
+
75
+ pushToChannel(channel, event) {
76
+ signal.publish(`${CHANNEL_PREFIX}${channel}`, event);
77
+ },
78
+
79
+ // fw#1601: this is the security-critical call — a revoked session's
80
+ // stream must close on every replica, not just the one that observed
81
+ // the revocation event. Publishing (rather than calling inner directly,
82
+ // like the in-memory broker does) is what makes that true here.
83
+ publishAccessInvalidation(userId) {
84
+ signal.publish(`${INVALIDATION_PREFIX}${userId}`, 1);
85
+ },
86
+
87
+ close: signal.close,
88
+ };
89
+ }
package/src/api/server.ts CHANGED
@@ -57,6 +57,7 @@ import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
57
57
  import { observabilityMiddleware } from "./observability-middleware";
58
58
  import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
59
59
  import { piiCiphertextResponseGuard } from "./pii-leak-guard";
60
+ import { createRedisSseBroker } from "./redis-sse-broker";
60
61
  import { requestContext } from "./request-context";
61
62
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
62
63
  import {
@@ -316,7 +317,18 @@ export function buildServer(options: ServerOptions): KumikoServer {
316
317
  options.jwtIssuer,
317
318
  options.jwtTtl ?? defaultJwtTtl,
318
319
  );
319
- const sseBroker = options.sseBroker ?? createSseBroker();
320
+ // An explicit ServerOptions.sseBroker always wins (caller owns its
321
+ // lifecycle). Otherwise, REDIS_URL decides the default: without
322
+ // cross-replica fanout, replicas > 1 silently drops SSE/access-
323
+ // invalidation events for whichever pod didn't win the shared cursor
324
+ // (fw#2625) — the safe default has to be the correct one, not the
325
+ // single-process one.
326
+ const ownedRedisSseBroker = options.sseBroker
327
+ ? undefined
328
+ : process.env["REDIS_URL"]
329
+ ? createRedisSseBroker({ redisUrl: process.env["REDIS_URL"] })
330
+ : undefined;
331
+ const sseBroker = options.sseBroker ?? ownedRedisSseBroker ?? createSseBroker();
320
332
 
321
333
  // Resolve the per-process instance identifier. Prefer explicit
322
334
  // ServerOptions.instanceId (tests, deliberate wiring), fall back to the
@@ -587,6 +599,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
587
599
  });
588
600
  }
589
601
 
602
+ // Same reasoning, scoped to the broker this function created itself — an
603
+ // app-injected sseBroker (options.sseBroker) owns its own teardown.
604
+ if (options.lifecycle && ownedRedisSseBroker) {
605
+ const broker = ownedRedisSseBroker;
606
+ options.lifecycle.registerShutdownHook("redisSseBroker", async () => {
607
+ await broker.close();
608
+ });
609
+ }
610
+
590
611
  const app = new Hono();
591
612
 
592
613
  const sensitiveConfig = mergeSensitiveConfig(
@@ -29,8 +29,10 @@ export type SseBroker = {
29
29
  };
30
30
 
31
31
  export function createSseBroker(): SseBroker {
32
- // Cross-replica fanout lives one level up: the SSE + access-invalidation
33
- // consumers (system-hooks.ts) run delivery: "per-instance" (#1718).
32
+ // Purely local: no cross-replica fanout. buildServer wraps this in
33
+ // createRedisSseBroker (fw#2625) whenever REDIS_URL is set, which is what
34
+ // makes pushToChannel/publishAccessInvalidation reach every replica's
35
+ // clients — this reference implementation stays single-process only.
34
36
  const channels = new Map<string, Map<string, SseClient>>();
35
37
  // Set, not Map<listenerId, fn> — dedup key is callback reference. Every
36
38
  // subscriber must pass a distinct closure (dispatch-stream.ts does, one
@@ -0,0 +1,214 @@
1
+ // fw#2629: `list()` guarded filtering/sorting only against unknown columns,
2
+ // not per-field `access.read`. A caller who cannot read a field could still
3
+ // filter or sort by it — result count and row order become an oracle for a
4
+ // value the response itself strips out (filterReadFields in
5
+ // engine/field-access.ts). This proves the fix over real HTTP: a filter on
6
+ // an unreadable field must be unsatisfiable (not merely "field stripped from
7
+ // the response"), and a sort on an unreadable field must fall back silently
8
+ // to the default id-ASC order.
9
+
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
+ import { asRawClient } from "../../db/query";
12
+ import { defineFeature } from "../../engine/define-feature";
13
+ import { createEntity, createTextField } from "../../engine/factories";
14
+ import { from } from "../../engine/ownership";
15
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
16
+
17
+ const filterNoteEntity = createEntity({
18
+ table: "fa_filter_notes",
19
+ fields: {
20
+ title: createTextField({ required: true }),
21
+ ownerId: createTextField({ required: true }),
22
+ secret: createTextField({
23
+ filterable: true,
24
+ sortable: true,
25
+ access: { read: { Admin: "all" } },
26
+ }),
27
+ ownedNote: createTextField({
28
+ filterable: true,
29
+ access: { read: { Admin: "all", User: from("user:id", "ownerId") } },
30
+ }),
31
+ },
32
+ });
33
+
34
+ const LIST_QN = "filternotes:query:note:list";
35
+
36
+ const filterNotesFeature = defineFeature("filternotes", (r) => {
37
+ r.crud("note", filterNoteEntity, {
38
+ write: { access: { roles: ["Admin"] } },
39
+ read: { access: { openToAll: true } },
40
+ });
41
+ });
42
+
43
+ type NoteRow = {
44
+ readonly id: string;
45
+ readonly title: string;
46
+ readonly ownerId: string;
47
+ readonly secret?: string;
48
+ readonly ownedNote?: string;
49
+ };
50
+
51
+ type ListResult = {
52
+ readonly rows: readonly NoteRow[];
53
+ readonly total?: number;
54
+ };
55
+
56
+ describe("list() field-level read access gates filter/sort (fw#2629)", () => {
57
+ let stack: TestStack;
58
+
59
+ beforeAll(async () => {
60
+ stack = await setupTestStack({ features: [filterNotesFeature] });
61
+ await unsafeCreateEntityTable(stack.db, filterNoteEntity);
62
+ });
63
+
64
+ afterAll(async () => {
65
+ await stack.cleanup();
66
+ });
67
+
68
+ beforeEach(async () => {
69
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
70
+ await asRawClient(stack.db).unsafe('DELETE FROM "fa_filter_notes"');
71
+ });
72
+
73
+ const CREATE = "filternotes:write:note:create";
74
+
75
+ async function seedTwoSecrets(): Promise<void> {
76
+ await stack.http.write(
77
+ CREATE,
78
+ { title: "Note A", ownerId: TestUsers.user.id, secret: "alpha" },
79
+ TestUsers.admin,
80
+ );
81
+ await stack.http.write(
82
+ CREATE,
83
+ { title: "Note B", ownerId: TestUsers.user.id, secret: "beta" },
84
+ TestUsers.admin,
85
+ );
86
+ }
87
+
88
+ test("baseline: rows exist for a caller without read access to `secret`, but the field is stripped", async () => {
89
+ await seedTwoSecrets();
90
+ const result = await stack.http.queryOk<ListResult>(LIST_QN, { limit: 50 }, TestUsers.user);
91
+
92
+ expect(result.rows).toHaveLength(2);
93
+ expect(result.rows.every((r) => r.secret === undefined)).toBe(true);
94
+ });
95
+
96
+ test("count-channel oracle is closed: a matching and a non-matching filter on `secret` are indistinguishable", async () => {
97
+ await seedTwoSecrets();
98
+
99
+ const matching = await stack.http.queryOk<ListResult>(
100
+ LIST_QN,
101
+ { limit: 50, totalCount: true, filter: { field: "secret", op: "eq", value: "alpha" } },
102
+ TestUsers.user,
103
+ );
104
+ const nonExistent = await stack.http.queryOk<ListResult>(
105
+ LIST_QN,
106
+ {
107
+ limit: 50,
108
+ totalCount: true,
109
+ filter: { field: "secret", op: "eq", value: "does-not-exist-anywhere" },
110
+ },
111
+ TestUsers.user,
112
+ );
113
+
114
+ expect(matching.rows).toHaveLength(0);
115
+ expect(matching.total).toBe(0);
116
+ expect(nonExistent.rows).toHaveLength(0);
117
+ expect(nonExistent.total).toBe(0);
118
+ expect(matching).toEqual(nonExistent);
119
+ });
120
+
121
+ test("count-channel oracle is closed for `ne` and `in` on `secret` too", async () => {
122
+ await seedTwoSecrets();
123
+
124
+ const ne = await stack.http.queryOk<ListResult>(
125
+ LIST_QN,
126
+ { limit: 50, totalCount: true, filter: { field: "secret", op: "ne", value: "alpha" } },
127
+ TestUsers.user,
128
+ );
129
+ const inOp = await stack.http.queryOk<ListResult>(
130
+ LIST_QN,
131
+ {
132
+ limit: 50,
133
+ totalCount: true,
134
+ filters: [{ field: "secret", op: "in", value: ["alpha", "beta"] }],
135
+ },
136
+ TestUsers.user,
137
+ );
138
+
139
+ expect(ne.rows).toHaveLength(0);
140
+ expect(ne.total).toBe(0);
141
+ expect(inOp.rows).toHaveLength(0);
142
+ expect(inOp.total).toBe(0);
143
+ });
144
+
145
+ test("a legitimate filter by a caller who CAN read the field still narrows results", async () => {
146
+ await seedTwoSecrets();
147
+ const result = await stack.http.queryOk<ListResult>(
148
+ LIST_QN,
149
+ { limit: 50, totalCount: true, filter: { field: "secret", op: "eq", value: "alpha" } },
150
+ TestUsers.admin,
151
+ );
152
+
153
+ expect(result.rows).toHaveLength(1);
154
+ expect(result.rows[0]?.title).toBe("Note A");
155
+ expect(result.total).toBe(1);
156
+ });
157
+
158
+ test("ownership-typed filter binds the correct rule without corrupting later param placeholders", async () => {
159
+ await stack.http.write(
160
+ CREATE,
161
+ { title: "Mine", ownerId: TestUsers.user.id, secret: "x", ownedNote: "shared-value" },
162
+ TestUsers.admin,
163
+ );
164
+ await stack.http.write(
165
+ CREATE,
166
+ { title: "Theirs", ownerId: TestUsers.admin.id, secret: "y", ownedNote: "shared-value" },
167
+ TestUsers.admin,
168
+ );
169
+
170
+ const result = await stack.http.queryOk<ListResult>(
171
+ LIST_QN,
172
+ {
173
+ limit: 50,
174
+ totalCount: true,
175
+ filter: { field: "ownedNote", op: "eq", value: "shared-value" },
176
+ },
177
+ TestUsers.user,
178
+ );
179
+
180
+ expect(result.rows).toHaveLength(1);
181
+ expect(result.rows[0]?.title).toBe("Mine");
182
+ expect(result.total).toBe(1);
183
+ });
184
+
185
+ test("sort-channel oracle: sorting by an unreadable field falls back to id ASC", async () => {
186
+ // id ASC (creation order) is [first, second]; secret DESC must reverse
187
+ // that ("zzz" > "aaa") — the two orders disagree, so this actually
188
+ // distinguishes "sort applied" from "sort silently ignored".
189
+ const first = await stack.http.writeOk<{ id: string }>(
190
+ CREATE,
191
+ { title: "First", ownerId: TestUsers.user.id, secret: "aaa" },
192
+ TestUsers.admin,
193
+ );
194
+ const second = await stack.http.writeOk<{ id: string }>(
195
+ CREATE,
196
+ { title: "Second", ownerId: TestUsers.user.id, secret: "zzz" },
197
+ TestUsers.admin,
198
+ );
199
+
200
+ const asUser = await stack.http.queryOk<ListResult>(
201
+ LIST_QN,
202
+ { limit: 50, sort: "secret", sortDirection: "desc" },
203
+ TestUsers.user,
204
+ );
205
+ expect(asUser.rows.map((r) => r.id)).toEqual([first.id, second.id]);
206
+
207
+ const asAdmin = await stack.http.queryOk<ListResult>(
208
+ LIST_QN,
209
+ { limit: 50, sort: "secret", sortDirection: "desc" },
210
+ TestUsers.admin,
211
+ );
212
+ expect(asAdmin.rows.map((r) => r.id)).toEqual([second.id, first.id]);
213
+ });
214
+ });
@@ -2,7 +2,7 @@ import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types
2
2
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
3
3
  import { executeRawQueryRead } from "../db/queries/raw-sql";
4
4
  import { coerceRow, extractTableInfo } from "../db/query";
5
- import { buildOwnershipClause, shiftParams } from "../engine/ownership";
5
+ import { buildOwnershipClause, normalizeAccessEntry, shiftParams } from "../engine/ownership";
6
6
  import type { EntityId } from "../engine/types";
7
7
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
8
8
  import { UnprocessableError } from "../errors";
@@ -269,7 +269,15 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
269
269
  const physicalCol = (field: string): string =>
270
270
  (table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
271
271
  const colSql = (field: string): string => `"${physicalCol(field)}"`;
272
- const sortField = payload.sort && table[payload.sort] ? payload.sort : undefined;
272
+ // Field-level read access gates filtering and sorting the same way it
273
+ // gates the projected row: a caller who cannot read a field must not be
274
+ // able to probe its values through result counts or ordering (fw#2629).
275
+ const fieldReadClause = (field: string) =>
276
+ buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
277
+ const sortField =
278
+ payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
279
+ ? payload.sort
280
+ : undefined;
273
281
  const sortDescending = payload.sortDirection === "desc";
274
282
 
275
283
  // Tenant-Filter (replicates TenantDb's readWhere semantics).
@@ -317,6 +325,22 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
317
325
  // skip: unknown field — not a real column, drop the filter (injection guard)
318
326
  return;
319
327
  }
328
+ const readClause = fieldReadClause(f.field);
329
+ if (readClause.kind === "empty") {
330
+ whereSql.push("FALSE");
331
+ // skip: no role grants read on this field, so no row may be probed
332
+ // through it — an unsatisfiable filter beats an error, which would
333
+ // itself confirm the field exists (field-access.ts strips silently)
334
+ return;
335
+ }
336
+ if (readClause.kind === "sql") {
337
+ const shifted = shiftParams(
338
+ { sqlText: readClause.sqlText, params: readClause.params },
339
+ params.length,
340
+ );
341
+ whereSql.push(shifted.sqlText);
342
+ for (const p of shifted.params) params.push(p);
343
+ }
320
344
  if (entity.fields[f.field]?.type === "multiSelect") {
321
345
  applyMultiSelectFilter(colSql, whereSql, params, f);
322
346
  // skip: multiSelect already handled — don't fall through to applyScreenFilter
@@ -33,6 +33,32 @@ export async function insertConsumerIfAbsent(
33
33
  }
34
34
  }
35
35
 
36
+ // fw#2625: a consumer that moved from delivery: "per-instance" to "shared"
37
+ // leaves its old per-instance rows behind — no dispatcher will ever advance
38
+ // them again, and pruneEvents stays pinned to their stale cursor forever.
39
+ // Scoped by name + "not the shared sentinel" so a still-per-instance
40
+ // consumer's live rows (or an already-migrated __shared__ row) are never
41
+ // touched.
42
+ export async function deleteOrphanedPerInstanceConsumerRows(
43
+ db: AnyDb,
44
+ consumerNames: readonly string[],
45
+ // Passed in rather than imported from event-consumer-state.ts (which
46
+ // defines SHARED_INSTANCE_SENTINEL): that module already imports from
47
+ // this one for the DB queries it needs, so importing back would be a
48
+ // require cycle. The one caller lives in that same file and has the
49
+ // constant in scope.
50
+ sharedInstanceSentinel: string,
51
+ ): Promise<void> {
52
+ // skip: nothing to delete — an empty ANY($1) would still hit the table,
53
+ // pointlessly, on every boot for an app with no migrated consumers yet.
54
+ if (consumerNames.length === 0) return;
55
+ await asRawClient(db).unsafe(
56
+ `DELETE FROM "kumiko_event_consumers"
57
+ WHERE "name" = ANY($1) AND "instance_id" != $2`,
58
+ [consumerNames, sharedInstanceSentinel],
59
+ );
60
+ }
61
+
36
62
  export async function selectConsumerForUpdateSkipLocked(
37
63
  db: AnyDb,
38
64
  name: string,
@@ -23,12 +23,12 @@
23
23
  // registers its own shutdown hooks (eventDispatcher.stop, jobRunner.stop)
24
24
  // in the order they were built.
25
25
  //
26
- // Known limitation (tracked in uebersicht.md Offene Follow-Ups): the
27
- // built-in SSE broker is in-memory per process. In a split api/worker
28
- // deploy, system-consumers that push to SSE (new-row broadcasts) run on
29
- // the worker and therefore can't reach clients connected to the API
30
- // instances. Either run all-in-one, put the SSE consumer on the API
31
- // side explicitly, or wait for the Redis-Pub/Sub bridge.
26
+ // SSE fanout across processes (split api/worker deploy, replicas > 1):
27
+ // buildServer's default sseBroker is Redis-backed whenever REDIS_URL is
28
+ // set (fw#2625) — a push on the worker's dispatcher reaches clients
29
+ // connected to any API instance via Redis Pub/Sub, no per-instance
30
+ // cursor needed. Without REDIS_URL, the in-memory broker still only
31
+ // reaches clients on the same process.
32
32
 
33
33
  import type { Hono } from "hono";
34
34
  import type { AuthRoutesConfig } from "../api/auth-routes";
@@ -1,4 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { insertConsumerIfAbsent } from "../../db/queries/event-consumer";
3
+ import { asRawClient } from "../../db/query";
2
4
  import { createTestDb, type TestDb } from "../../stack";
3
5
  import { createEventConsumerStateTable } from "../event-consumer-state";
4
6
 
@@ -29,3 +31,51 @@ describe("createEventConsumerStateTable — concurrent boot", () => {
29
31
  ).resolves.toBeDefined();
30
32
  });
31
33
  });
34
+
35
+ // fw#2625: sse-broadcast, access-invalidation and toggle-cache-sync moved
36
+ // from delivery: "per-instance" to "shared" — their old per-instance rows
37
+ // are orphaned and would pin pruneEvents forever if left behind.
38
+ describe("createEventConsumerStateTable — orphaned per-instance row cleanup (fw#2625)", () => {
39
+ let testDb: TestDb;
40
+
41
+ beforeAll(async () => {
42
+ testDb = await createTestDb();
43
+ await createEventConsumerStateTable(testDb.db);
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await testDb.cleanup();
48
+ });
49
+
50
+ async function rowExists(name: string, instanceId: string): Promise<boolean> {
51
+ const rows = (await asRawClient(testDb.db).unsafe(
52
+ `SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`,
53
+ [name, instanceId],
54
+ )) as ReadonlyArray<unknown>;
55
+ return rows.length > 0;
56
+ }
57
+
58
+ test("deletes only the three migrated consumers' per-instance rows, leaving unrelated and shared rows intact", async () => {
59
+ await insertConsumerIfAbsent(testDb.db, "system:consumer:sse-broadcast", "pod-a");
60
+ await insertConsumerIfAbsent(testDb.db, "system:consumer:access-invalidation", "pod-b");
61
+ await insertConsumerIfAbsent(
62
+ testDb.db,
63
+ "feature-toggles:projection:toggle-cache-sync",
64
+ "pod-c",
65
+ );
66
+ // A consumer name not in the orphan list at all — must survive.
67
+ await insertConsumerIfAbsent(testDb.db, "feature-toggles:projection:unrelated", "pod-d");
68
+ // A consumer name that happens to already run shared — must survive.
69
+ await insertConsumerIfAbsent(testDb.db, "system:consumer:sse-broadcast", "__shared__");
70
+
71
+ // The bootstrap's "table already exists" branch is what runs the
72
+ // cleanup — re-invoke it the same way a redeploying process would.
73
+ await createEventConsumerStateTable(testDb.db);
74
+
75
+ expect(await rowExists("system:consumer:sse-broadcast", "pod-a")).toBe(false);
76
+ expect(await rowExists("system:consumer:access-invalidation", "pod-b")).toBe(false);
77
+ expect(await rowExists("feature-toggles:projection:toggle-cache-sync", "pod-c")).toBe(false);
78
+ expect(await rowExists("feature-toggles:projection:unrelated", "pod-d")).toBe(true);
79
+ expect(await rowExists("system:consumer:sse-broadcast", "__shared__")).toBe(true);
80
+ });
81
+ });
@@ -11,6 +11,7 @@ import {
11
11
  text,
12
12
  } from "../db/dialect";
13
13
  import { alterTableAddColumn } from "../db/queries/ddl";
14
+ import { deleteOrphanedPerInstanceConsumerRows } from "../db/queries/event-consumer";
14
15
  import { tableExists } from "../db/schema-inspection";
15
16
  import { unsafePushTables } from "../stack";
16
17
 
@@ -90,6 +91,21 @@ export const eventConsumerStateTable = pgTable(
90
91
  }),
91
92
  );
92
93
 
94
+ // fw#2625: these three consumers moved from delivery: "per-instance" to
95
+ // "shared" (see system-hooks.ts and bundled-features/feature-toggles/
96
+ // feature.ts). Their old per-instance rows are now orphaned — no dispatcher
97
+ // will ever advance them again — and pruneEvents stays pinned to whichever
98
+ // one has the lowest cursor forever. Named here (not imported from
99
+ // system-hooks.ts / feature.ts) to avoid a pipeline → system-hooks /
100
+ // bundled-features dependency for three string literals; those modules'
101
+ // own exported constants (or, for feature-toggles, the qualified consumer
102
+ // name qualify() produces) are the source of truth these must match.
103
+ const ORPHANED_PER_INSTANCE_CONSUMER_NAMES = [
104
+ "system:consumer:sse-broadcast",
105
+ "system:consumer:access-invalidation",
106
+ "feature-toggles:projection:toggle-cache-sync",
107
+ ] as const;
108
+
93
109
  // Object-const form lets call sites write `ConsumerStatuses.disabled` instead
94
110
  // of the raw string, which keeps status checks refactor-safe. The runtime
95
111
  // value on each field is the same string the DB stores — no mapping needed.
@@ -126,8 +142,23 @@ export async function createEventConsumerStateTable(db: DbConnection): Promise<v
126
142
  " NOT NULL",
127
143
  /* ifNotExists */ true,
128
144
  );
145
+ // Runs on every boot, including mid-rolling-deploy: a new pod can delete
146
+ // an old pod's still-in-use per-instance row out from under it. That old
147
+ // pod's next acquireConsumer then finds no row, delivers no more SSE/
148
+ // access-invalidation events for the rest of its lifetime — a one-time,
149
+ // few-second gap that resolves itself as clients reconnect onto new pods
150
+ // during the same rollout. Not a replay risk: preRegisterConsumers only
151
+ // (re-)inserts a row at dispatcher startup, so the deleted pod won't
152
+ // silently get a fresh cursor-0 row and replay history mid-flight.
153
+ await deleteOrphanedPerInstanceConsumerRows(
154
+ db,
155
+ ORPHANED_PER_INSTANCE_CONSUMER_NAMES,
156
+ SHARED_INSTANCE_SENTINEL,
157
+ );
129
158
  // skip: table (+ any missing column) is already up to date
130
159
  return;
131
160
  }
161
+ // A freshly created table has no rows to clean up — the DELETE only
162
+ // matters for a DB that already ran the old per-instance consumers.
132
163
  await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
133
164
  }
@@ -294,15 +294,15 @@ export const SSE_BROADCAST_CONSUMER_NAME = "system:consumer:sse-broadcast";
294
294
  export function createSseBroadcastEventConsumer(sseBroker: SseBroker): EventConsumer {
295
295
  return {
296
296
  name: SSE_BROADCAST_CONSUMER_NAME,
297
- // Per-instance delivery: each API process has its own pool of SSE
298
- // clients and its own cursor. Every instance reads every event
299
- // independently and pushes to its local clients. Without this, in a
300
- // split-deploy (API-1/API-2 + Worker, Welle 2.5), only ONE API
301
- // instance would pick up each event (shared-cursor SKIP LOCKED) and
302
- // the other instance's clients would never see updates. SSE clients
303
- // pin to a specific API process via the HTTP long-poll; cross-process
304
- // delivery isn't solvable by sharing a cursor.
305
- delivery: "per-instance",
297
+ // Shared delivery: one cursor reads every event exactly once (fw#2625).
298
+ // Fanout to every process's own SSE clients happens below, through
299
+ // sseBroker.pushToChannel — a Redis-backed broker (the default once
300
+ // REDIS_URL is set) republishes across replicas, so every pod's
301
+ // clients still see the event without each pod needing its own cursor.
302
+ delivery: "shared",
303
+ // History is meaningless here — an SSE push for a client that didn't
304
+ // exist yet has no audience to replay it to.
305
+ startFrom: "now",
306
306
  handler: async (event) => {
307
307
  sseBroker.pushToChannel(tenantChannel(event.tenantId), {
308
308
  type: event.type,
@@ -424,11 +424,16 @@ function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): strin
424
424
  export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
425
425
  return {
426
426
  name: ACCESS_INVALIDATION_CONSUMER_NAME,
427
- // Per-instance, same reasoning as SSE broadcast: subscribeAccessInvalidation
428
- // listeners live in this process's in-memory sseBroker only. A shared
429
- // cursor would deliver the event to exactly one instance and leave
430
- // every other instance's open streams for that user un-invalidated.
431
- delivery: "per-instance",
427
+ // Shared delivery, same reasoning as SSE broadcast (fw#2625): one
428
+ // cursor reads every event once and calls sseBroker.publishAccessInvalidation
429
+ // below, which a Redis-backed broker republishes to every replica's
430
+ // subscribeAccessInvalidation listeners — no per-instance cursor needed
431
+ // to reach every process's open streams for that user.
432
+ delivery: "shared",
433
+ // A revocation for a session/membership from before this instance
434
+ // existed has nothing to invalidate — replaying it would be a no-op at
435
+ // best and a spurious stream-close at worst.
436
+ startFrom: "now",
432
437
  handler: async (event) => {
433
438
  if (event.type === SESSION_REVOKED_EVENT_TYPE) {
434
439
  const userId = event.payload["userId"];
@@ -0,0 +1,77 @@
1
+ // Thin factory for ioredis clients with Prod-sensible defaults.
2
+ // ioredis itself doesn't enforce a command-timeout — an unreachable Redis
3
+ // will silently hang a request forever unless the caller sets
4
+ // `commandTimeout`. Same story for connectTimeout: the default is 10s,
5
+ // which is too long for a /health/ready probe with a 2s budget.
6
+ //
7
+ // Apps are expected to build their Redis clients via this helper so
8
+ // production defaults stay consistent and env-var wiring lives in one
9
+ // place. Tests / one-off scripts can still `new Redis()` directly.
10
+ //
11
+ // Split out of index.ts (fw#2625) so pubsub-signal.ts can import this
12
+ // without a same-directory cycle back through the barrel.
13
+
14
+ import IoRedis, { type Redis, type RedisOptions } from "ioredis";
15
+ import { readPositiveIntEnv } from "../utils/env-parse";
16
+
17
+ // Connection-tuning options. The fields mirror the most consequential
18
+ // ioredis settings; anything else can be passed through via `extra` to
19
+ // keep the helper from re-declaring the full ioredis surface.
20
+ export type RedisClientOptions = {
21
+ // Milliseconds to wait when opening the TCP connection. Default 5s —
22
+ // long enough for a cold cache/standby to handshake, short enough that
23
+ // /health/ready can surface "Redis is gone" inside its 2s-ish probe
24
+ // window (probe runs in parallel, so 5s here + 2s timeout on the probe
25
+ // side gives the probe a real answer instead of a hang).
26
+ readonly connectTimeoutMs?: number;
27
+ // Milliseconds to wait for a single command to round-trip. Default 10s.
28
+ // Without this, ioredis waits forever on a stalled connection — a
29
+ // single bad network partition quietly exhausts the whole command queue.
30
+ readonly commandTimeoutMs?: number;
31
+ // Retries per request before bubbling up as an error. Default 3 — enough
32
+ // to ride out a transient blip but not so many that a dead Redis keeps
33
+ // requests stuck waiting for retries.
34
+ readonly maxRetriesPerRequest?: number;
35
+ // Escape hatch for any ioredis option the helper doesn't expose
36
+ // explicitly (TLS config, Sentinel, ReadyCheck toggles, …).
37
+ readonly extra?: Readonly<
38
+ Omit<RedisOptions, "connectTimeout" | "commandTimeout" | "maxRetriesPerRequest">
39
+ >;
40
+ };
41
+
42
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
43
+ const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
44
+ const DEFAULT_MAX_RETRIES_PER_REQUEST = 3;
45
+
46
+ export function createRedisClient(url: string, options: RedisClientOptions = {}): Redis {
47
+ const connectTimeout = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
48
+ const commandTimeout = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
49
+ const maxRetriesPerRequest = options.maxRetriesPerRequest ?? DEFAULT_MAX_RETRIES_PER_REQUEST;
50
+
51
+ return new IoRedis(url, {
52
+ ...options.extra,
53
+ connectTimeout,
54
+ commandTimeout,
55
+ maxRetriesPerRequest,
56
+ });
57
+ }
58
+
59
+ // Env-var reader — mirrors dbConnectionOptionsFromEnv's contract. Strict:
60
+ // malformed values throw at boot rather than silently falling back to
61
+ // defaults.
62
+ export function redisClientOptionsFromEnv(
63
+ env: Readonly<Record<string, string | undefined>> = process.env,
64
+ ): RedisClientOptions {
65
+ const opts: RedisClientOptions & {
66
+ connectTimeoutMs?: number;
67
+ commandTimeoutMs?: number;
68
+ maxRetriesPerRequest?: number;
69
+ } = {};
70
+ const connect = readPositiveIntEnv(env, "REDIS_CONNECT_TIMEOUT_MS");
71
+ const command = readPositiveIntEnv(env, "REDIS_COMMAND_TIMEOUT_MS");
72
+ const retries = readPositiveIntEnv(env, "REDIS_MAX_RETRIES_PER_REQUEST");
73
+ if (connect !== undefined) opts.connectTimeoutMs = connect;
74
+ if (command !== undefined) opts.commandTimeoutMs = command;
75
+ if (retries !== undefined) opts.maxRetriesPerRequest = retries;
76
+ return opts;
77
+ }
@@ -1,74 +1,4 @@
1
- // Thin factory for ioredis clients with Prod-sensible defaults.
2
- // ioredis itself doesn't enforce a command-timeout — an unreachable Redis
3
- // will silently hang a request forever unless the caller sets
4
- // `commandTimeout`. Same story for connectTimeout: the default is 10s,
5
- // which is too long for a /health/ready probe with a 2s budget.
6
- //
7
- // Apps are expected to build their Redis clients via this helper so
8
- // production defaults stay consistent and env-var wiring lives in one
9
- // place. Tests / one-off scripts can still `new Redis()` directly.
10
-
11
- import IoRedis, { type Redis, type RedisOptions } from "ioredis";
12
- import { readPositiveIntEnv } from "../utils/env-parse";
13
-
14
- // Connection-tuning options. The fields mirror the most consequential
15
- // ioredis settings; anything else can be passed through via `extra` to
16
- // keep the helper from re-declaring the full ioredis surface.
17
- export type RedisClientOptions = {
18
- // Milliseconds to wait when opening the TCP connection. Default 5s —
19
- // long enough for a cold cache/standby to handshake, short enough that
20
- // /health/ready can surface "Redis is gone" inside its 2s-ish probe
21
- // window (probe runs in parallel, so 5s here + 2s timeout on the probe
22
- // side gives the probe a real answer instead of a hang).
23
- readonly connectTimeoutMs?: number;
24
- // Milliseconds to wait for a single command to round-trip. Default 10s.
25
- // Without this, ioredis waits forever on a stalled connection — a
26
- // single bad network partition quietly exhausts the whole command queue.
27
- readonly commandTimeoutMs?: number;
28
- // Retries per request before bubbling up as an error. Default 3 — enough
29
- // to ride out a transient blip but not so many that a dead Redis keeps
30
- // requests stuck waiting for retries.
31
- readonly maxRetriesPerRequest?: number;
32
- // Escape hatch for any ioredis option the helper doesn't expose
33
- // explicitly (TLS config, Sentinel, ReadyCheck toggles, …).
34
- readonly extra?: Readonly<
35
- Omit<RedisOptions, "connectTimeout" | "commandTimeout" | "maxRetriesPerRequest">
36
- >;
37
- };
38
-
39
- const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
40
- const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
41
- const DEFAULT_MAX_RETRIES_PER_REQUEST = 3;
42
-
43
- export function createRedisClient(url: string, options: RedisClientOptions = {}): Redis {
44
- const connectTimeout = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
45
- const commandTimeout = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
46
- const maxRetriesPerRequest = options.maxRetriesPerRequest ?? DEFAULT_MAX_RETRIES_PER_REQUEST;
47
-
48
- return new IoRedis(url, {
49
- ...options.extra,
50
- connectTimeout,
51
- commandTimeout,
52
- maxRetriesPerRequest,
53
- });
54
- }
55
-
56
- // Env-var reader — mirrors dbConnectionOptionsFromEnv's contract. Strict:
57
- // malformed values throw at boot rather than silently falling back to
58
- // defaults.
59
- export function redisClientOptionsFromEnv(
60
- env: Readonly<Record<string, string | undefined>> = process.env,
61
- ): RedisClientOptions {
62
- const opts: RedisClientOptions & {
63
- connectTimeoutMs?: number;
64
- commandTimeoutMs?: number;
65
- maxRetriesPerRequest?: number;
66
- } = {};
67
- const connect = readPositiveIntEnv(env, "REDIS_CONNECT_TIMEOUT_MS");
68
- const command = readPositiveIntEnv(env, "REDIS_COMMAND_TIMEOUT_MS");
69
- const retries = readPositiveIntEnv(env, "REDIS_MAX_RETRIES_PER_REQUEST");
70
- if (connect !== undefined) opts.connectTimeoutMs = connect;
71
- if (command !== undefined) opts.commandTimeoutMs = command;
72
- if (retries !== undefined) opts.maxRetriesPerRequest = retries;
73
- return opts;
74
- }
1
+ export type { RedisClientOptions } from "./client";
2
+ export { createRedisClient, redisClientOptionsFromEnv } from "./client";
3
+ export type { PubSubSignal, PubSubSignalOptions } from "./pubsub-signal";
4
+ export { createRedisPubSubSignal } from "./pubsub-signal";
@@ -0,0 +1,107 @@
1
+ import { createRedisClient } from "./client";
2
+
3
+ // Generic Redis Pub/Sub transport for cross-replica fanout (fw#2625).
4
+ // Shared by the SSE broker (api/redis-sse-broker.ts, several logical
5
+ // channels multiplexed under one prefix) and feature-toggles' cache-sync
6
+ // signal (bundled-features, a single fixed channel) — both need the same
7
+ // mechanics: two connections (ioredis forbids normal commands on a
8
+ // subscriber connection), one psubscribe per instance (no per-channel
9
+ // subscribe/unsubscribe), JSON payloads, and defensive parsing so a
10
+ // malformed or foreign message on the pattern never crashes the listener.
11
+ export type PubSubSignal = {
12
+ publish(channel: string, payload: unknown): void;
13
+ onMessage(listener: (channel: string, payload: unknown) => void): void;
14
+ close(): Promise<void>;
15
+ };
16
+
17
+ export type PubSubSignalOptions = {
18
+ readonly redisUrl: string;
19
+ // psubscribe pattern: a literal channel name is a valid (exact-match)
20
+ // pattern for a caller with a single fixed channel; a "prefix:*" glob
21
+ // is for a caller that multiplexes several logical channels under one
22
+ // subscription (e.g. the SSE broker's per-tenant/per-user channels).
23
+ readonly channelPattern: string;
24
+ // Included in every log line so two signals running in the same process
25
+ // (sse-broker, feature-toggles cache-sync) can be told apart in output.
26
+ readonly label: string;
27
+ };
28
+
29
+ // Redis emits 'error' on connection blips (reconnects transparently); an
30
+ // unhandled listener crashes the process (fw#1805 — job-runner hit the same
31
+ // class with BullMQ's internal client).
32
+ function logConnectionError(label: string, role: string): (err: Error) => void {
33
+ return (err) => {
34
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback — no logger is threaded through PubSubSignalOptions.
35
+ console.error(`[kumiko:${label}] ${role} connection error:`, err.message);
36
+ };
37
+ }
38
+
39
+ export function createRedisPubSubSignal(opts: PubSubSignalOptions): PubSubSignal {
40
+ const publisher = createRedisClient(opts.redisUrl);
41
+ const subscriber = createRedisClient(opts.redisUrl);
42
+ publisher.on("error", logConnectionError(opts.label, "publisher"));
43
+ subscriber.on("error", logConnectionError(opts.label, "subscriber"));
44
+
45
+ const listeners = new Set<(channel: string, payload: unknown) => void>();
46
+ // Sole purpose: stop close() from racing the initial psubscribe (see
47
+ // below) and silence its logging once a shutdown is already underway —
48
+ // not a defense against ioredis auto-reconnecting later (quit() below
49
+ // never reconnects once it resolves).
50
+ let closed = false;
51
+
52
+ subscriber.on("pmessage", (_pattern: string, channel: string, message: string) => {
53
+ let payload: unknown;
54
+ try {
55
+ payload = JSON.parse(message);
56
+ } catch (err) {
57
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
58
+ console.error(`[kumiko:${opts.label}] dropping unparseable message on "${channel}":`, err);
59
+ // skip: unparseable payload already logged above, nothing to deliver
60
+ return;
61
+ }
62
+ for (const listener of listeners) listener(channel, payload);
63
+ });
64
+
65
+ // ponytail: psubscribe on one pattern rather than per-channel
66
+ // subscribe/unsubscribe — every instance receives every message that
67
+ // matches, filtered by the caller. That's strictly no worse than the
68
+ // pre-fw#2625 per-instance-cursor model (which already read every event
69
+ // from Postgres in every process) and sidesteps the subscribe-ack/
70
+ // first-publish race a per-channel scheme would have. Revisit with
71
+ // per-channel subscribe only if fanout volume becomes the bottleneck.
72
+ //
73
+ // Awaited by close() below — quitting while this is still in flight (a
74
+ // shutdown landing immediately after construction, e.g. a test closing
75
+ // right after building the signal) makes ioredis rejects the queued
76
+ // psubscribe with "Connection is closed" instead of it completing
77
+ // cleanly first. Not caught here or logged when a close is already
78
+ // underway (`closed`), so an intentional shutdown never surfaces this
79
+ // as an ops-visible error.
80
+ const initialSubscribe = subscriber.psubscribe(opts.channelPattern).catch((err: unknown) => {
81
+ // skip: a close() already underway intentionally quit both connections —
82
+ // this rejection is the expected side effect, not an ops-visible error.
83
+ if (closed) return;
84
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
85
+ console.error(`[kumiko:${opts.label}] psubscribe failed:`, err);
86
+ });
87
+
88
+ return {
89
+ publish(channel, payload) {
90
+ publisher.publish(channel, JSON.stringify(payload)).catch((err: unknown) => {
91
+ // skip: a close() already underway intentionally quit both
92
+ // connections — this rejection is the expected side effect.
93
+ if (closed) return;
94
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
95
+ console.error(`[kumiko:${opts.label}] publish failed:`, err);
96
+ });
97
+ },
98
+ onMessage(listener) {
99
+ listeners.add(listener);
100
+ },
101
+ async close(): Promise<void> {
102
+ closed = true;
103
+ await initialSubscribe;
104
+ await Promise.all([publisher.quit(), subscriber.quit()]);
105
+ },
106
+ };
107
+ }