@cosmicdrift/kumiko-framework 0.305.0 → 0.307.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.
Files changed (87) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
  3. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  4. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  5. package/src/api/__tests__/sse-broker.test.ts +49 -0
  6. package/src/api/redis-sse-broker.ts +17 -3
  7. package/src/api/request-context.ts +29 -4
  8. package/src/api/routes.ts +26 -1
  9. package/src/api/sse-broker.ts +29 -11
  10. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  11. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  12. package/src/bun-db/query.ts +42 -18
  13. package/src/changes.json +92 -0
  14. package/src/db/__tests__/pg-error.test.ts +14 -0
  15. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  16. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  17. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  18. package/src/db/index.ts +1 -1
  19. package/src/db/pg-error.ts +13 -0
  20. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  21. package/src/db/queries/event-consumer.ts +57 -3
  22. package/src/db/queries/event-store.ts +69 -0
  23. package/src/db/tenant-db.ts +133 -18
  24. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  25. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  26. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  27. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  28. package/src/engine/extension-names.ts +55 -25
  29. package/src/engine/extensions/storage-provider.ts +14 -41
  30. package/src/engine/extensions/tenant-data.ts +4 -0
  31. package/src/engine/extensions/tenant-resource.ts +40 -0
  32. package/src/engine/extensions/user-data.ts +8 -7
  33. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  34. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  35. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  36. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  37. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  38. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  39. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  40. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  41. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  42. package/src/engine/feature-ast/index.ts +11 -1
  43. package/src/engine/feature-ast/patch.ts +338 -5
  44. package/src/engine/feature-ast/patcher.ts +2 -2
  45. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  46. package/src/engine/feature-ast/patterns.ts +22 -15
  47. package/src/engine/feature-ast/render.ts +1 -0
  48. package/src/engine/feature-ui-extensions.ts +8 -7
  49. package/src/engine/index.ts +21 -5
  50. package/src/engine/types/extension-options-map.ts +1 -0
  51. package/src/engine/types/index.ts +6 -0
  52. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  53. package/src/event-store/admin-api.ts +5 -0
  54. package/src/event-store/event-store.ts +16 -7
  55. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  56. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  57. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  58. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  59. package/src/jobs/job-runner.ts +211 -19
  60. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  61. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  62. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  63. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  64. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  65. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  66. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  67. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  68. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  69. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  70. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  71. package/src/pipeline/dispatch-batch.ts +102 -29
  72. package/src/pipeline/dispatch-stream.ts +7 -3
  73. package/src/pipeline/dispatcher-utils.ts +21 -2
  74. package/src/pipeline/dispatcher.ts +71 -6
  75. package/src/pipeline/event-consumer-state.ts +26 -0
  76. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  77. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  78. package/src/pipeline/event-dispatcher.ts +167 -50
  79. package/src/pipeline/idempotency.ts +16 -0
  80. package/src/pipeline/pending-gap-ranges.ts +72 -0
  81. package/src/pipeline/system-hooks.ts +8 -1
  82. package/src/pipeline/system-identity-switch.ts +22 -4
  83. package/src/pipeline/write-origin.ts +31 -10
  84. package/src/stack/test-stack.ts +1 -1
  85. package/src/testing/closed-connection-error.ts +62 -0
  86. package/src/testing/index.ts +1 -0
  87. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.305.0",
3
+ "version": "0.307.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,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.305.0",
202
- "@cosmicdrift/kumiko-types": "0.305.0",
201
+ "@cosmicdrift/kumiko-http": "0.307.0",
202
+ "@cosmicdrift/kumiko-types": "0.307.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.305.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.307.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -127,6 +127,72 @@ describe("createRedisSseBroker", () => {
127
127
  expect(invalidatedA).toBe(false);
128
128
  });
129
129
 
130
+ test("publishAccessInvalidation with a keptSessionId spares only the listener whose own sid matches exactly", async () => {
131
+ const podA = trackedBroker();
132
+ const podB = trackedBroker();
133
+ const userId = `user-${generateId()}`;
134
+ const sidKept = `sid-kept-${generateId()}`;
135
+ const sidAlreadyRevoked = `sid-already-revoked-${generateId()}`;
136
+ let invalidatedKept = false;
137
+ let invalidatedSidless = false;
138
+ let invalidatedAlreadyRevoked = false;
139
+
140
+ podA.subscribeAccessInvalidation(
141
+ userId,
142
+ () => {
143
+ invalidatedKept = true;
144
+ },
145
+ sidKept,
146
+ );
147
+ // No ownSid — mirrors a PAT/bearer stream, always invalidated (fail-closed).
148
+ podA.subscribeAccessInvalidation(userId, () => {
149
+ invalidatedSidless = true;
150
+ });
151
+ // Not the kept sid — mirrors a session already revoked through an
152
+ // eventless path (plain logout): the keep-list must still close it.
153
+ podA.subscribeAccessInvalidation(
154
+ userId,
155
+ () => {
156
+ invalidatedAlreadyRevoked = true;
157
+ },
158
+ sidAlreadyRevoked,
159
+ );
160
+
161
+ await waitFor(() => {
162
+ podB.publishAccessInvalidation(userId, sidKept);
163
+ return invalidatedSidless && invalidatedAlreadyRevoked;
164
+ });
165
+ // Asserted only after the control listeners above already fired for the
166
+ // same publish — proves the pipe delivered the message at all, so a
167
+ // false here means the sid really was spared, not that delivery is slow.
168
+ expect(invalidatedKept).toBe(false);
169
+ });
170
+
171
+ test("a legacy unscoped invalidation message (bare `1`, pre-scoping pod) still invalidates every listener, sid or not", async () => {
172
+ const podA = trackedBroker();
173
+ const publisherRaw = new (await import("ioredis")).default(testRedis.redisUrl);
174
+ const userId = `user-${generateId()}`;
175
+ let invalidatedWithSid = false;
176
+
177
+ podA.subscribeAccessInvalidation(
178
+ userId,
179
+ () => {
180
+ invalidatedWithSid = true;
181
+ },
182
+ `sid-${generateId()}`,
183
+ );
184
+
185
+ try {
186
+ await waitFor(async () => {
187
+ await publisherRaw.publish(`kumiko:sse:inval:${userId}`, JSON.stringify(1));
188
+ return invalidatedWithSid;
189
+ });
190
+ expect(invalidatedWithSid).toBe(true);
191
+ } finally {
192
+ publisherRaw.disconnect();
193
+ }
194
+ });
195
+
130
196
  test("a malformed message on the channel namespace is dropped, not thrown, and does not kill delivery", async () => {
131
197
  const podA = trackedBroker();
132
198
  const publisherRaw = new (await import("ioredis")).default(testRedis.redisUrl);
@@ -228,6 +228,7 @@ describe("buildServer — auth membershipQuery requires a principalStatus provid
228
228
  r.extendsRegistrar(EXT_PRINCIPAL_STATUS, {});
229
229
  r.useExtension(EXT_PRINCIPAL_STATUS, "has-principal-status", {
230
230
  resolveStatus: async () => "active" as const,
231
+ resolveProfile: async () => ({ globalRoles: [] }),
231
232
  });
232
233
  });
233
234
 
@@ -66,6 +66,16 @@ const boomFeature = defineFeature("boom", (r) => {
66
66
  async () => ({ ok: true }),
67
67
  openToAll,
68
68
  );
69
+ // Same abort path TenantDb uses (signal.throwIfAborted()), without a DB.
70
+ r.queryHandler(
71
+ "abort-probe",
72
+ z.object({}),
73
+ async (_query, ctx) => {
74
+ ctx.signal?.throwIfAborted();
75
+ return { ok: true };
76
+ },
77
+ openToAll,
78
+ );
69
79
  });
70
80
 
71
81
  const { app, jwt } = buildServer({
@@ -178,6 +188,67 @@ describe("HTTP layer logs unexpected 5xx faults", () => {
178
188
  expect(status).toBe(404);
179
189
  expect(apiFaultLog(errors)).toBeUndefined();
180
190
  });
191
+
192
+ test("a pre-aborted client signal 499s and logs a warn, not '[api] handler failed'", async () => {
193
+ const controller = new AbortController();
194
+ controller.abort();
195
+ const warnings: unknown[][] = [];
196
+ const errors: unknown[][] = [];
197
+ const warnSpy = spyOn(console, "warn").mockImplementation((...args) => {
198
+ warnings.push(args);
199
+ });
200
+ const errorSpy = spyOn(console, "error").mockImplementation((...args) => {
201
+ errors.push(args);
202
+ });
203
+ try {
204
+ const res = await app.request(
205
+ new Request("http://test.local/api/query", {
206
+ method: "POST",
207
+ headers: await auth(),
208
+ body: JSON.stringify({ type: "boom:query:abort-probe", payload: {} }),
209
+ signal: controller.signal,
210
+ }),
211
+ );
212
+ expect(res.status).toBe(499);
213
+ expect(apiFaultLog(errors)).toBeUndefined();
214
+ const hit = warnings.find(
215
+ (args) =>
216
+ typeof args[0] === "string" && args[0].includes("[api] request aborted by client"),
217
+ );
218
+ expect(hit).toBeDefined();
219
+ const data = hit?.[1];
220
+ expect(isRecord(data)).toBe(true);
221
+ if (!isRecord(data)) return;
222
+ expect(data["status"]).toBe(499);
223
+ expect(data["type"]).toBe("boom:query:abort-probe");
224
+ } finally {
225
+ warnSpy.mockRestore();
226
+ errorSpy.mockRestore();
227
+ }
228
+ });
229
+
230
+ test("a pre-aborted client signal still 500s + logs when the handler fails for an unrelated reason", async () => {
231
+ const controller = new AbortController();
232
+ controller.abort();
233
+ const calls: unknown[][] = [];
234
+ const spy = spyOn(console, "error").mockImplementation((...args) => {
235
+ calls.push(args);
236
+ });
237
+ try {
238
+ const res = await app.request(
239
+ new Request("http://test.local/api/query", {
240
+ method: "POST",
241
+ headers: await auth(),
242
+ body: JSON.stringify({ type: "boom:query:explode", payload: {} }),
243
+ signal: controller.signal,
244
+ }),
245
+ );
246
+ expect(res.status).toBe(500);
247
+ expect(apiFaultLog(calls)).toBeDefined();
248
+ } finally {
249
+ spy.mockRestore();
250
+ }
251
+ });
181
252
  });
182
253
 
183
254
  describe("HTTP layer logs 4xx client faults on warn (#3077)", () => {
@@ -121,4 +121,53 @@ describe("SSE broker", () => {
121
121
  const { publish } = requireAccessInvalidation(createSseBroker());
122
122
  expect(() => publish("nobody-listening")).not.toThrow();
123
123
  });
124
+
125
+ test("publishAccessInvalidation with a keptSessionId spares only the listener whose own sid matches exactly", () => {
126
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
127
+ const spared = mock();
128
+ const unrelated = mock();
129
+
130
+ subscribe("user-a", spared, "sid-kept");
131
+ subscribe("user-a", unrelated, "sid-other");
132
+ publish("user-a", "sid-kept");
133
+
134
+ expect(spared).not.toHaveBeenCalled();
135
+ expect(unrelated).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ test("publishAccessInvalidation with a keptSessionId still closes a listener whose sid was already revoked through an eventless path (keep-list, not a kill-list)", () => {
139
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
140
+ const alreadyRevoked = mock();
141
+
142
+ // Simulates a stream from a session logged out earlier via a path that
143
+ // never appended session-revoked — the keep-list must not accidentally
144
+ // exempt it just because its sid isn't the freshly-kept one.
145
+ subscribe("user-a", alreadyRevoked, "sid-logged-out-earlier");
146
+ publish("user-a", "sid-kept");
147
+
148
+ expect(alreadyRevoked).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ test("publishAccessInvalidation with a keptSessionId still fires a listener with no sid of its own (fail-closed, e.g. a PAT/bearer stream)", () => {
152
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
153
+ const sidless = mock();
154
+
155
+ subscribe("user-a", sidless);
156
+ publish("user-a", "sid-kept");
157
+
158
+ expect(sidless).toHaveBeenCalledTimes(1);
159
+ });
160
+
161
+ test("publishAccessInvalidation with no keptSessionId (unscoped) still invalidates every listener, matching pre-scoping behavior", () => {
162
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
163
+ const first = mock();
164
+ const second = mock();
165
+
166
+ subscribe("user-a", first, "sid-1");
167
+ subscribe("user-a", second, "sid-2");
168
+ publish("user-a");
169
+
170
+ expect(first).toHaveBeenCalledTimes(1);
171
+ expect(second).toHaveBeenCalledTimes(1);
172
+ });
124
173
  });
@@ -60,6 +60,17 @@ function isSseEvent(value: unknown): value is SseEvent {
60
60
  );
61
61
  }
62
62
 
63
+ // Invalidation payload: `1` means userwide, `{ keptSessionId }` spares that one
64
+ // session. Anything else falls back to userwide. Older pods never read the
65
+ // payload and invalidate userwide, which keeps a mixed rolling deploy safe.
66
+ function extractInvalidationKeptSessionId(payload: unknown): string | undefined {
67
+ if (typeof payload !== "object" || payload === null || !("keptSessionId" in payload)) {
68
+ return undefined;
69
+ }
70
+ const { keptSessionId } = payload;
71
+ return typeof keptSessionId === "string" && keptSessionId.length > 0 ? keptSessionId : undefined;
72
+ }
73
+
63
74
  // Transport layer around a local `createSseBroker()` — all client/listener
64
75
  // state lives in `inner`, this only moves events across the Redis wire via
65
76
  // the shared PubSubSignal. `pushToChannel`/`publishAccessInvalidation` never
@@ -89,7 +100,10 @@ export function createRedisSseBroker(opts: RedisSseBrokerOptions): RedisSseBroke
89
100
  }
90
101
 
91
102
  if (channel.startsWith(INVALIDATION_PREFIX)) {
92
- inner.publishAccessInvalidation(channel.slice(INVALIDATION_PREFIX.length));
103
+ inner.publishAccessInvalidation(
104
+ channel.slice(INVALIDATION_PREFIX.length),
105
+ extractInvalidationKeptSessionId(payload),
106
+ );
93
107
  }
94
108
  });
95
109
 
@@ -108,8 +122,8 @@ export function createRedisSseBroker(opts: RedisSseBrokerOptions): RedisSseBroke
108
122
  // stream must close on every replica, not just the one that observed
109
123
  // the revocation event. Publishing (rather than calling inner directly,
110
124
  // like the in-memory broker does) is what makes that true here.
111
- publishAccessInvalidation(userId) {
112
- signal.publish(`${INVALIDATION_PREFIX}${userId}`, 1);
125
+ publishAccessInvalidation(userId, keptSessionId) {
126
+ signal.publish(`${INVALIDATION_PREFIX}${userId}`, keptSessionId ? { keptSessionId } : 1);
113
127
  },
114
128
 
115
129
  close: signal.close,
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
2
3
  import { generateId } from "../utils";
3
4
 
4
5
  // Request-scoped propagation. Populated by the HTTP middleware and by the
@@ -17,10 +18,11 @@ import { generateId } from "../utils";
17
18
  // with correlationId, forms a causal DAG across streams.
18
19
  // signal — AbortSignal from the underlying HTTP request. Aborts
19
20
  // when the client disconnects (mobile back-press, tab
20
- // close). Long-running framework code (event streaming,
21
- // projection rebuild) checks signal.aborted at chunk
22
- // boundaries; short queries don't pay the overhead.
23
- // Undefined for non-HTTP entry-points (jobs, MSP-applies).
21
+ // close). Query/stream handlers check signal.aborted at
22
+ // chunk or query boundaries; runBatch (write dispatch)
23
+ // strips it before executing so a disconnect can't abort
24
+ // a transaction mid-commit. Undefined for non-HTTP
25
+ // entry-points (jobs, MSP-applies) and inside write batches.
24
26
  export type RequestContextData = {
25
27
  readonly requestId: string;
26
28
  readonly correlationId: string;
@@ -47,6 +49,9 @@ export type RequestContextData = {
47
49
  // performance.now() at request entry, so a failing request can report how
48
50
  // long it ran. Monotonic — a wall-clock step cannot make it negative.
49
51
  readonly startedAt?: number;
52
+ // Only ever a gated origin. Read by job enqueue and event-store.append();
53
+ // dispatch roots never narrow from it, only jobs inherit explicitly.
54
+ readonly writeOrigin?: WriteOrigin;
50
55
  };
51
56
 
52
57
  const storage = new AsyncLocalStorage<RequestContextData>();
@@ -86,3 +91,23 @@ export function runWithOrigin<T>(
86
91
  fn,
87
92
  );
88
93
  }
94
+
95
+ // An ungated origin never mints a scope: seeds and boot writes keep their missing context.
96
+ export function runWithWriteOrigin<T>(origin: WriteOrigin, fn: () => T): T {
97
+ const current = requestContext.get();
98
+ if (!isPersonalDataGated(origin)) {
99
+ if (!current?.writeOrigin) return fn();
100
+ const { writeOrigin: _writeOrigin, ...rest } = current;
101
+ return requestContext.run(rest, fn);
102
+ }
103
+ const requestId = current?.requestId ?? requestContext.generateId();
104
+ return requestContext.run(
105
+ {
106
+ ...current,
107
+ requestId,
108
+ correlationId: current?.correlationId ?? requestId,
109
+ writeOrigin: origin,
110
+ },
111
+ fn,
112
+ );
113
+ }
package/src/api/routes.ts CHANGED
@@ -29,6 +29,10 @@ export const StreamFrame = {
29
29
  error: "error",
30
30
  } as const;
31
31
 
32
+ // Non-standard but widely used for "client hung up" — distinct from a real
33
+ // 5xx so a disconnect never gets logged as a server fault.
34
+ const CLIENT_CLOSED_REQUEST_STATUS = 499;
35
+
32
36
  export type ApiRoutesOptions = {
33
37
  // Override the SSE heartbeat interval (ms). Default SSE_HEARTBEAT_INTERVAL_MS.
34
38
  // Deployment-tunable for proxies with different idle timeouts — also used
@@ -203,7 +207,7 @@ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOption
203
207
  // in-flight .next(), so awaiting here would block the response until
204
208
  // that pending pull resolves (which may be never for an idle stream).
205
209
  void generator.return(undefined).catch(() => {});
206
- return c.body(null, 499 as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
210
+ return c.body(null, CLIENT_CLOSED_REQUEST_STATUS as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
207
211
  }
208
212
 
209
213
  return streamSSE(c, async (stream) => {
@@ -364,6 +368,13 @@ function logClientFault(err: KumikoError, requestId: string | undefined, type?:
364
368
  });
365
369
  }
366
370
 
371
+ // Identity-checked against THIS request's signal — signal.aborted alone
372
+ // would also match a real 5xx that happens to race a disconnect.
373
+ function isClientAbort(err: KumikoError): boolean {
374
+ const signal = requestContext.get()?.signal;
375
+ return signal?.aborted === true && err.cause !== undefined && err.cause === signal.reason;
376
+ }
377
+
367
378
  // Unexpected server faults (5xx) carry their diagnostic stack only on the
368
379
  // in-process error — serializeError strips cause/details from the wire body.
369
380
  // Without this a wrapped throw (InternalError{cause}) returns a 500 with zero
@@ -401,6 +412,20 @@ function writeErrorResponse(c: Context, err: KumikoError, type?: string) {
401
412
  // keep the same lean shape on failure — only the `error` key.
402
413
  function queryErrorResponse(c: Context, err: KumikoError, type?: string) {
403
414
  const requestId = requestContext.get()?.requestId;
415
+ if (isClientAbort(err)) {
416
+ if (clientFaultLoggingEnabled()) {
417
+ const startedAt = requestContext.get()?.startedAt;
418
+ createFallbackLogger("api").warn("request aborted by client", {
419
+ requestId,
420
+ type: type?.slice(0, MAX_LOGGED_TYPE_LENGTH),
421
+ status: CLIENT_CLOSED_REQUEST_STATUS,
422
+ ...(startedAt === undefined
423
+ ? {}
424
+ : { durationMs: Math.round(performance.now() - startedAt) }),
425
+ });
426
+ }
427
+ return c.body(null, CLIENT_CLOSED_REQUEST_STATUS as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
428
+ }
404
429
  logServerFault(err, requestId, type);
405
430
  const body = serializeError(err, requestId);
406
431
  return c.json(body, err.httpStatus as ContentfulStatusCode); // @cast-boundary engine-payload
@@ -24,21 +24,38 @@ export type SseBroker = {
24
24
  // access-teardown security control (#1561) into a no-op — a revoked
25
25
  // session keeps receiving live SSE data with no error or log. A no-op
26
26
  // stub is one line for a broker that genuinely doesn't need it.
27
- subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
28
- publishAccessInvalidation(userId: string): void;
27
+ subscribeAccessInvalidation(
28
+ userId: string,
29
+ onInvalidate: () => void,
30
+ ownSid?: string,
31
+ ): () => void;
32
+ // `keptSessionId` spares exactly one stream: the caller's own session on a
33
+ // "revoke all others" write. It is a keep-list, not a list of revoked
34
+ // sessions, so a stream of a session already revoked without an event
35
+ // (plain logout) still closes. Every other reason stays userwide.
36
+ publishAccessInvalidation(userId: string, keptSessionId?: string): void;
29
37
  };
30
38
 
39
+ // Fail-closed: without a kept sid, or for a listener without its own sid
40
+ // (PAT/bearer), nothing is spared.
41
+ function isSparedByKeptSessionId(
42
+ ownSid: string | undefined,
43
+ keptSessionId: string | undefined,
44
+ ): boolean {
45
+ return keptSessionId !== undefined && ownSid === keptSessionId;
46
+ }
47
+
31
48
  export function createSseBroker(): SseBroker {
32
49
  // Purely local: no cross-replica fanout. buildServer wraps this in
33
50
  // createRedisSseBroker (fw#2625) whenever REDIS_URL is set, which is what
34
51
  // makes pushToChannel/publishAccessInvalidation reach every replica's
35
52
  // clients — this reference implementation stays single-process only.
36
53
  const channels = new Map<string, Map<string, SseClient>>();
37
- // Set, not Map<listenerId, fn> — dedup key is callback reference. Every
38
- // subscriber must pass a distinct closure (dispatch-stream.ts does, one
39
- // per stream). Two subscribes with the SAME reference for the same user
54
+ // Keyed by callback reference, value is the subscriber's own sid. Every
55
+ // subscriber must pass a distinct closure (dispatch-stream.ts does, one per
56
+ // stream). Two subscribes with the SAME reference for the same user
40
57
  // collapse into one listener, and the first unsubscribe kills both.
41
- const accessInvalidationListeners = new Map<string, Set<() => void>>();
58
+ const accessInvalidationListeners = new Map<string, Map<() => void, string | undefined>>();
42
59
 
43
60
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
44
61
  let clients = channels.get(channel);
@@ -86,14 +103,14 @@ export function createSseBroker(): SseBroker {
86
103
  return total;
87
104
  },
88
105
 
89
- subscribeAccessInvalidation(userId, onInvalidate) {
106
+ subscribeAccessInvalidation(userId, onInvalidate, ownSid) {
90
107
  const channel = userAccessChannel(userId);
91
108
  let listeners = accessInvalidationListeners.get(channel);
92
109
  if (!listeners) {
93
- listeners = new Set();
110
+ listeners = new Map();
94
111
  accessInvalidationListeners.set(channel, listeners);
95
112
  }
96
- listeners.add(onInvalidate);
113
+ listeners.set(onInvalidate, ownSid);
97
114
  return () => {
98
115
  const current = accessInvalidationListeners.get(channel);
99
116
  // skip: already unsubscribed (e.g. stream ended after a publish already fired)
@@ -103,14 +120,15 @@ export function createSseBroker(): SseBroker {
103
120
  };
104
121
  },
105
122
 
106
- publishAccessInvalidation(userId) {
123
+ publishAccessInvalidation(userId, keptSessionId) {
107
124
  const channel = userAccessChannel(userId);
108
125
  const listeners = accessInvalidationListeners.get(channel);
109
126
  // skip: no live stream is watching this user right now
110
127
  if (!listeners) return;
111
128
  // Snapshot before iterating — a fired listener unsubscribes itself,
112
129
  // which would mutate `listeners` mid-iteration otherwise.
113
- for (const onInvalidate of [...listeners]) {
130
+ for (const [onInvalidate, ownSid] of [...listeners]) {
131
+ if (isSparedByKeptSessionId(ownSid, keptSessionId)) continue;
114
132
  onInvalidate();
115
133
  }
116
134
  },
@@ -0,0 +1,159 @@
1
+ // Proves the closed-connection retry against a real postgres-js driver error,
2
+ // plus the Bun.SQL matcher and both drivers' extractPgError — see query.ts.
3
+
4
+ import { afterAll, describe, expect, test } from "bun:test";
5
+ import postgres from "postgres";
6
+ import { constraintOf, extractPgError, isUniqueViolation } from "../../db/pg-error";
7
+ import { testDatabaseUrl } from "../../testing/closed-connection-error";
8
+ import { waitFor } from "../../testing/wait-for";
9
+ import { isClosedConnectionError, unsafeReadRetrying } from "../query";
10
+
11
+ const DATABASE_URL = testDatabaseUrl();
12
+
13
+ // Terminate targets only the backend running the slow query so the retry
14
+ // lands on the pool's idle connection instead of reconnecting — a postgres-js
15
+ // reconnect under Bun can hang until connect_timeout (30s).
16
+ const adminClient = postgres(DATABASE_URL, { max: 1 });
17
+ afterAll(async () => {
18
+ await adminClient.end({ timeout: 0 });
19
+ });
20
+
21
+ type UnsafeFn = (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
22
+ type Pool = { unsafe: UnsafeFn };
23
+
24
+ // Counts unsafe() calls while forwarding everything (including begin/
25
+ // savepoint/options) to the real handle — no mocked behavior, only counting.
26
+ type CountingClient<T> = T & { readonly calls: number };
27
+ function countingClient<T extends Pool>(real: T): CountingClient<T> {
28
+ let calls = 0;
29
+ return new Proxy(real, {
30
+ get(target, prop, receiver) {
31
+ if (prop === "calls") return calls;
32
+ if (prop === "unsafe") {
33
+ return async (...args: Parameters<UnsafeFn>) => {
34
+ calls++;
35
+ return target.unsafe(...args);
36
+ };
37
+ }
38
+ const value = Reflect.get(target, prop, receiver);
39
+ return typeof value === "function" ? value.bind(target) : value;
40
+ },
41
+ }) as CountingClient<T>;
42
+ }
43
+
44
+ function randomAppName(): string {
45
+ return `kumiko-cc-retry-${crypto.randomUUID()}`;
46
+ }
47
+
48
+ // Waits for the slow query's backend to show up as the single active
49
+ // session before terminating exactly that one — the pg_sleep(0.3) window
50
+ // bounds this, so short fixed delays instead of waitFor's default backoff.
51
+ async function pollForActivePid(applicationName: string, queryLike: string): Promise<number> {
52
+ let pid: number | undefined;
53
+ await waitFor(
54
+ async () => {
55
+ const rows = (await adminClient.unsafe(
56
+ "select pid from pg_stat_activity where application_name = $1 and state = 'active' and query like $2",
57
+ [applicationName, queryLike],
58
+ )) as readonly { pid: number }[];
59
+ if (rows.length === 1 && rows[0]?.pid !== undefined) {
60
+ pid = rows[0].pid;
61
+ return true;
62
+ }
63
+ return false;
64
+ },
65
+ { delays: Array(100).fill(10) },
66
+ );
67
+ if (pid === undefined) {
68
+ throw new Error(`pollForActivePid: no single active backend found for ${applicationName}`);
69
+ }
70
+ return pid;
71
+ }
72
+
73
+ async function terminateBackendPid(pid: number): Promise<void> {
74
+ await adminClient.unsafe("select pg_terminate_backend($1)", [pid]);
75
+ }
76
+
77
+ type DriverKind = "postgres-js" | "bun-sql";
78
+
79
+ function makePool(kind: DriverKind, max: number, applicationName: string): Pool {
80
+ if (kind === "postgres-js") {
81
+ return postgres(DATABASE_URL, {
82
+ max,
83
+ connection: { application_name: applicationName },
84
+ }) as unknown as Pool;
85
+ }
86
+ return new Bun.SQL({
87
+ url: DATABASE_URL,
88
+ max,
89
+ connection: { application_name: applicationName },
90
+ }) as unknown as Pool;
91
+ }
92
+
93
+ async function closePool(kind: DriverKind, pool: unknown): Promise<void> {
94
+ if (kind === "postgres-js") {
95
+ await (pool as { end: (opts?: { timeout?: number }) => Promise<void> }).end({ timeout: 0 });
96
+ } else {
97
+ await (pool as { close: () => Promise<void> }).close();
98
+ }
99
+ }
100
+
101
+ const drivers: DriverKind[] = ["postgres-js", "bun-sql"];
102
+
103
+ // postgres-js only: Bun.SQL sporadically emits an unhandled reject from its
104
+ // internal handleClose in this terminate window, and isn't the prod driver.
105
+ // Multi-retry (≥3 calls) is covered deterministically by the fake-client
106
+ // test in select-many-retry.integration.test.ts instead of a second live test.
107
+ test("retries through a real server-side connection close", async () => {
108
+ const applicationName = randomAppName();
109
+ const pool = postgres(DATABASE_URL, {
110
+ max: 2,
111
+ connection: { application_name: applicationName },
112
+ });
113
+ try {
114
+ await Promise.all([pool.unsafe("select 1"), pool.unsafe("select 1")]);
115
+ const counted = countingClient(pool as unknown as Pool);
116
+ const pending = unsafeReadRetrying(counted as never, "select 1 as x from pg_sleep(0.3)", []);
117
+ const pid = await pollForActivePid(applicationName, "%pg_sleep(0.3)%");
118
+ await terminateBackendPid(pid);
119
+ const rows = await pending;
120
+ expect([...rows]).toEqual([{ x: 1 }]);
121
+ expect(counted.calls).toBe(2);
122
+ } finally {
123
+ await pool.end({ timeout: 0 });
124
+ }
125
+ });
126
+
127
+ test("Bun.SQL closed-connection error matches isClosedConnectionError", async () => {
128
+ const pool = new Bun.SQL({ url: DATABASE_URL, max: 1 });
129
+ await pool.unsafe("select 1");
130
+ await pool.close();
131
+ const caught = await pool.unsafe("select 1").catch((e: unknown) => e);
132
+ // @cast-boundary error-details — asserting the real driver error shape
133
+ expect((caught as { code?: string }).code).toBe("ERR_POSTGRES_CONNECTION_CLOSED");
134
+ expect(isClosedConnectionError(caught)).toBe(true);
135
+ });
136
+
137
+ describe.each(drivers)("extractPgError — %s unique-violation SQLSTATE", (kind) => {
138
+ test("isUniqueViolation and constraintOf resolve the real driver error", async () => {
139
+ const applicationName = randomAppName();
140
+ const pool = makePool(kind, 1, applicationName);
141
+ const tableName = `cc_retry_uniq_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
142
+ await pool.unsafe(
143
+ `create table "${tableName}" (id uuid not null, constraint "${tableName}_pk" primary key (id))`,
144
+ );
145
+ try {
146
+ const fixedId = crypto.randomUUID();
147
+ await pool.unsafe(`insert into "${tableName}" (id) values ($1)`, [fixedId]);
148
+ const caught = await pool
149
+ .unsafe(`insert into "${tableName}" (id) values ($1)`, [fixedId])
150
+ .catch((e: unknown) => e);
151
+ expect(isUniqueViolation(caught)).toBe(true);
152
+ expect(extractPgError(caught)?.code).toBe("23505");
153
+ expect(constraintOf(caught)).toBeDefined();
154
+ } finally {
155
+ await pool.unsafe(`drop table if exists "${tableName}"`);
156
+ await closePool(kind, pool);
157
+ }
158
+ });
159
+ });