@cosmicdrift/kumiko-framework 0.327.0 → 0.328.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.327.0",
3
+ "version": "0.328.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>",
@@ -202,8 +202,8 @@
202
202
  "./package.json": "./package.json"
203
203
  },
204
204
  "dependencies": {
205
- "@cosmicdrift/kumiko-http": "0.327.0",
206
- "@cosmicdrift/kumiko-types": "0.327.0",
205
+ "@cosmicdrift/kumiko-http": "0.328.0",
206
+ "@cosmicdrift/kumiko-types": "0.328.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "bun-types": "^1.3.13",
209
209
  "hono": "^4.13.1",
@@ -219,7 +219,7 @@
219
219
  "zod": "^4.4.3"
220
220
  },
221
221
  "devDependencies": {
222
- "@cosmicdrift/kumiko-dispatcher-live": "0.327.0",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.328.0",
223
223
  "bun-types": "^1.3.13",
224
224
  "pino-pretty": "^13.1.3"
225
225
  },
@@ -6,7 +6,11 @@ import postgres from "postgres";
6
6
  import { constraintOf, extractPgError, isUniqueViolation } from "../../db/pg-error";
7
7
  import { testDatabaseUrl } from "../../testing/closed-connection-error";
8
8
  import { waitFor } from "../../testing/wait-for";
9
- import { isClosedConnectionError, unsafeReadRetrying } from "../query";
9
+ import {
10
+ describeClosedConnectionFailure,
11
+ isClosedConnectionError,
12
+ unsafeReadRetrying,
13
+ } from "../query";
10
14
 
11
15
  const DATABASE_URL = testDatabaseUrl();
12
16
 
@@ -157,3 +161,92 @@ describe.each(drivers)("extractPgError — %s unique-violation SQLSTATE", (kind)
157
161
  }
158
162
  });
159
163
  });
164
+
165
+ describe("describeClosedConnectionFailure", () => {
166
+ test("describes a real postgres-js pool error without SQL text or params", async () => {
167
+ const applicationName = randomAppName();
168
+ const pool = postgres(DATABASE_URL, {
169
+ max: 2,
170
+ connection: { application_name: applicationName },
171
+ });
172
+ try {
173
+ await Promise.all([pool.unsafe("select 1"), pool.unsafe("select 1")]);
174
+ const secretParam = `secret-${crypto.randomUUID()}`;
175
+ const pending = pool
176
+ .unsafe("select $1::text as x from pg_sleep(0.3)", [secretParam])
177
+ .catch((e: unknown) => e);
178
+ const pid = await pollForActivePid(applicationName, "%pg_sleep(0.3)%");
179
+ await terminateBackendPid(pid);
180
+ const caught = await pending;
181
+
182
+ const described = describeClosedConnectionFailure(pool, caught, 3);
183
+ expect(described).toMatchObject({
184
+ driver: "postgres-js",
185
+ pooled: true,
186
+ attempts: 3,
187
+ poolMax: 2,
188
+ });
189
+ expect(described?.["errorCode"]).toBe(extractPgError(caught)?.code);
190
+ const serialized = JSON.stringify(described);
191
+ expect(serialized).not.toContain(secretParam);
192
+ expect(serialized).not.toContain("pg_sleep");
193
+ } finally {
194
+ await pool.end({ timeout: 0 });
195
+ }
196
+ });
197
+
198
+ test("describes a real postgres-js transaction handle as not pooled", async () => {
199
+ const applicationName = randomAppName();
200
+ const pool = postgres(DATABASE_URL, {
201
+ max: 2,
202
+ connection: { application_name: applicationName },
203
+ });
204
+ try {
205
+ let described: Record<string, unknown> | undefined;
206
+ await pool
207
+ .begin(async (tx) => {
208
+ const pending = tx.unsafe("select 1 from pg_sleep(0.3)").catch((e: unknown) => e);
209
+ const pid = await pollForActivePid(applicationName, "%pg_sleep(0.3)%");
210
+ await terminateBackendPid(pid);
211
+ described = describeClosedConnectionFailure(tx, await pending, 1);
212
+ })
213
+ .catch(() => undefined);
214
+ expect(described).toMatchObject({ driver: "postgres-js", pooled: false, attempts: 1 });
215
+ } finally {
216
+ await pool.end({ timeout: 0 });
217
+ }
218
+ });
219
+
220
+ test("describes a real Bun.SQL closed-pool error", async () => {
221
+ const pool = new Bun.SQL({ url: DATABASE_URL, max: 1, idleTimeout: 5, maxLifetime: 10 });
222
+ await pool.unsafe("select 1");
223
+ await pool.close();
224
+ const caught = await pool.unsafe("select 1").catch((e: unknown) => e);
225
+
226
+ expect(describeClosedConnectionFailure(pool, caught, 1)).toMatchObject({
227
+ driver: "bun-sql",
228
+ pooled: true,
229
+ poolMax: 1,
230
+ idleTimeoutMs: 5000,
231
+ maxLifetimeMs: 10000,
232
+ errorCode: "ERR_POSTGRES_CONNECTION_CLOSED",
233
+ });
234
+ });
235
+
236
+ test("returns undefined for an unrelated error", () => {
237
+ expect(describeClosedConnectionFailure({}, new Error("boom"), 1)).toBeUndefined();
238
+ expect(describeClosedConnectionFailure({}, "not an error", 1)).toBeUndefined();
239
+ });
240
+
241
+ test("matches the driver's AbortError for a closed connection", () => {
242
+ const abort = new DOMException("The connection was closed.", "AbortError");
243
+ expect(describeClosedConnectionFailure({}, abort, 2)).toMatchObject({
244
+ driver: "unknown",
245
+ attempts: 2,
246
+ errorName: "AbortError",
247
+ });
248
+ expect(
249
+ describeClosedConnectionFailure({}, new DOMException("other", "AbortError"), 1),
250
+ ).toBeUndefined();
251
+ });
252
+ });
@@ -28,6 +28,7 @@ import type {
28
28
  // Static polyfill import (not getTemporal()): coercion results stay on one
29
29
  // Temporal implementation repo-wide, even when Bun exposes a global (#1480).
30
30
  import { Temporal } from "temporal-polyfill";
31
+ import { requestContext } from "../api/request-context";
31
32
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
32
33
  import { SQL_EXPR_BRAND } from "../db/dialect";
33
34
  import type { EntityTableMeta } from "../db/entity-table-meta";
@@ -35,6 +36,7 @@ import { extractPgError } from "../db/pg-error";
35
36
  import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
36
37
  import { camelCase as envCamelCase } from "../env";
37
38
  import { InternalError } from "../errors";
39
+ import { createFallbackLogger } from "../logging";
38
40
  import { parseJsonSafe } from "../utils/safe-json";
39
41
 
40
42
  // Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
@@ -755,6 +757,120 @@ function isPooledClient(raw: unknown): boolean {
755
757
  );
756
758
  }
757
759
 
760
+ const closedConnectionLog = createFallbackLogger("bun-db");
761
+
762
+ // The driver rejects in-flight queries with this AbortError (no code) when the
763
+ // pool closes under them, so isClosedConnectionError can't see it.
764
+ const DRIVER_CLOSED_ABORT_MESSAGE = "The connection was closed.";
765
+
766
+ function isDriverClosedAbort(err: unknown): boolean {
767
+ return (
768
+ err instanceof Error && err.name === "AbortError" && err.message === DRIVER_CLOSED_ABORT_MESSAGE
769
+ );
770
+ }
771
+
772
+ function isHandleLike(raw: unknown): raw is Record<string, unknown> {
773
+ return raw !== null && (typeof raw === "object" || typeof raw === "function");
774
+ }
775
+
776
+ type ClosedConnectionDriver = "bun-sql" | "postgres-js" | "unknown";
777
+
778
+ // Bun.SQL is not instanceof-checkable (Bun.SQL.prototype is invalid) and its tx
779
+ // handles are plain function objects, so discriminate by members only one driver
780
+ // has: `flush` on Bun.SQL (pool and tx), `typed` on postgres-js (pool and tx).
781
+ function driverOf(raw: unknown): ClosedConnectionDriver {
782
+ if (!isHandleLike(raw)) return "unknown";
783
+ if (typeof raw["flush"] === "function") return "bun-sql";
784
+ if (typeof raw["typed"] === "function") return "postgres-js";
785
+ return "unknown";
786
+ }
787
+
788
+ function positiveNumber(v: unknown): number | undefined {
789
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
790
+ }
791
+
792
+ // postgres-js configures timeouts in seconds, Bun.SQL in milliseconds.
793
+ function timeoutOptionMs(
794
+ options: Record<string, unknown>,
795
+ driver: ClosedConnectionDriver,
796
+ bunName: string,
797
+ postgresJsName: string,
798
+ ): number | undefined {
799
+ if (driver === "postgres-js") {
800
+ const seconds = positiveNumber(options[postgresJsName]);
801
+ return seconds === undefined ? undefined : seconds * 1000;
802
+ }
803
+ return positiveNumber(options[bunName]);
804
+ }
805
+
806
+ function errorProp(err: unknown, name: string): unknown {
807
+ return isHandleLike(err) ? err[name] : undefined;
808
+ }
809
+
810
+ // Deliberately excludes SQL text and parameters: app SQL can carry PII.
811
+ export function describeClosedConnectionFailure(
812
+ raw: unknown,
813
+ err: unknown,
814
+ attempts: number,
815
+ ): Record<string, unknown> | undefined {
816
+ if (!isClosedConnectionError(err) && !isDriverClosedAbort(err)) return undefined;
817
+ const driver = driverOf(raw);
818
+ const rawOptions = isHandleLike(raw) ? raw["options"] : undefined;
819
+ const options = isHandleLike(rawOptions) ? rawOptions : {};
820
+ return {
821
+ driver,
822
+ pooled: isPooledClient(raw),
823
+ attempts,
824
+ poolMax: positiveNumber(options["max"]),
825
+ idleTimeoutMs: timeoutOptionMs(options, driver, "idleTimeout", "idle_timeout"),
826
+ maxLifetimeMs: timeoutOptionMs(options, driver, "maxLifetime", "max_lifetime"),
827
+ connectionTimeoutMs: timeoutOptionMs(options, driver, "connectionTimeout", "connect_timeout"),
828
+ errorName: errorProp(err, "name"),
829
+ errorCode: extractPgError(err)?.code ?? errorProp(err, "code"),
830
+ errorErrno: errorProp(err, "errno"),
831
+ errorMessage: errorProp(err, "message"),
832
+ };
833
+ }
834
+
835
+ type ClosedConnectionContext = { readonly operation: string; readonly table?: string };
836
+
837
+ function logClosedConnection(
838
+ raw: unknown,
839
+ err: unknown,
840
+ attempts: number,
841
+ context?: ClosedConnectionContext,
842
+ ): void {
843
+ const failure = describeClosedConnectionFailure(raw, err, attempts);
844
+ // skip: not a closed-connection error, nothing to diagnose
845
+ if (!failure) return;
846
+ // Request identity lets this line be joined to the route's `handler failed` log.
847
+ const request = requestContext.get();
848
+ closedConnectionLog.warn("closed connection surfaced", {
849
+ ...failure,
850
+ ...context,
851
+ requestId: request?.requestId,
852
+ correlationId: request?.correlationId,
853
+ handler: request?.handler,
854
+ });
855
+ }
856
+
857
+ // Write and other non-retried paths: log when a closed-connection error leaves
858
+ // bun-db, rethrow unchanged.
859
+ async function unsafeLoggingClosedConnection(
860
+ db: AnyDb,
861
+ sqlText: string,
862
+ params: readonly unknown[],
863
+ context: ClosedConnectionContext,
864
+ ): Promise<readonly unknown[]> {
865
+ const raw = asRawClient(db);
866
+ try {
867
+ return await raw.unsafe(sqlText, params);
868
+ } catch (err) {
869
+ logClosedConnection(raw, err, 1, context);
870
+ throw err;
871
+ }
872
+ }
873
+
758
874
  function poolMaxOf(raw: unknown): number {
759
875
  // @cast-boundary driver pool options — both postgres-js and Bun.SQL expose options.max
760
876
  const max = (raw as { options?: { max?: unknown } }).options?.max;
@@ -774,7 +890,10 @@ export async function unsafeReadRetrying<TRow>(
774
890
  try {
775
891
  return (await raw.unsafe(sqlText, params)) as readonly TRow[];
776
892
  } catch (err) {
777
- if (!isPooledClient(raw) || !isClosedConnectionError(err)) throw err;
893
+ if (!isPooledClient(raw) || !isClosedConnectionError(err)) {
894
+ logClosedConnection(raw, err, 1);
895
+ throw err;
896
+ }
778
897
  const maxAttempts = poolMaxOf(raw) + 1;
779
898
  let lastErr: unknown = err;
780
899
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
@@ -782,9 +901,13 @@ export async function unsafeReadRetrying<TRow>(
782
901
  return (await raw.unsafe(sqlText, params)) as readonly TRow[];
783
902
  } catch (retryErr) {
784
903
  lastErr = retryErr;
785
- if (!isClosedConnectionError(retryErr)) throw retryErr;
904
+ if (!isClosedConnectionError(retryErr)) {
905
+ logClosedConnection(raw, retryErr, attempt + 2);
906
+ throw retryErr;
907
+ }
786
908
  }
787
909
  }
910
+ logClosedConnection(raw, lastErr, maxAttempts + 1);
788
911
  throw lastErr;
789
912
  }
790
913
  }
@@ -876,7 +999,10 @@ export async function insertMany<TRow = any>(
876
999
  valuesClauses.push(`(${placeholders.join(", ")})`);
877
1000
  }
878
1001
  const sqlText = `INSERT INTO ${quoteIdent(info.name)} (${cols}) VALUES ${valuesClauses.join(", ")} RETURNING *`;
879
- const raw = (await asRawClient(db).unsafe(sqlText, params)) as readonly Record<string, unknown>[];
1002
+ const raw = (await unsafeLoggingClosedConnection(db, sqlText, params, {
1003
+ operation: "insertMany",
1004
+ table: info.name,
1005
+ })) as readonly Record<string, unknown>[];
880
1006
  return coerceRows(raw, info) as readonly TRow[];
881
1007
  }
882
1008
 
@@ -909,10 +1035,10 @@ export async function insertOne<TRow = any>(
909
1035
  })
910
1036
  .join(", ");
911
1037
  const sqlText = `INSERT INTO ${quoteIdent(info.name)} (${cols}) VALUES (${placeholders}) RETURNING *`;
912
- const rows = (await asRawClient(db).unsafe(sqlText, params)) as readonly Record<
913
- string,
914
- unknown
915
- >[];
1038
+ const rows = (await unsafeLoggingClosedConnection(db, sqlText, params, {
1039
+ operation: "insertOne",
1040
+ table: info.name,
1041
+ })) as readonly Record<string, unknown>[];
916
1042
  const first = rows[0];
917
1043
  if (!first) return undefined;
918
1044
  return coerceRow(first, info) as TRow;
@@ -956,7 +1082,10 @@ export async function updateMany<TRow = any>(
956
1082
  for (const v of w.values) values.push(v);
957
1083
  }
958
1084
  sqlText += " RETURNING *";
959
- const raw = (await asRawClient(db).unsafe(sqlText, values)) as readonly Record<string, unknown>[];
1085
+ const raw = (await unsafeLoggingClosedConnection(db, sqlText, values, {
1086
+ operation: "updateMany",
1087
+ table: info.name,
1088
+ })) as readonly Record<string, unknown>[];
960
1089
  return coerceRows(raw, info) as readonly TRow[];
961
1090
  }
962
1091
 
@@ -973,7 +1102,10 @@ export async function deleteMany(
973
1102
  const w = buildWhereClause(info, where, 1);
974
1103
  let sqlText = `DELETE FROM ${quoteIdent(info.name)}`;
975
1104
  if (w.sqlText) sqlText += ` WHERE ${w.sqlText}`;
976
- await asRawClient(db).unsafe(sqlText, w.values);
1105
+ await unsafeLoggingClosedConnection(db, sqlText, w.values, {
1106
+ operation: "deleteMany",
1107
+ table: info.name,
1108
+ });
977
1109
  }
978
1110
 
979
1111
  type InsertEntry = {
@@ -1106,10 +1238,10 @@ export async function upsertOnConflict<TRow = any>(
1106
1238
 
1107
1239
  const conflictList = conflictCols.map((c) => quoteIdent(c)).join(", ");
1108
1240
  const sqlText = `${sqlPrefix} ON CONFLICT (${conflictList}) DO UPDATE SET ${updateParts.join(", ")} RETURNING *`;
1109
- const rows = (await asRawClient(db).unsafe(sqlText, params)) as readonly Record<
1110
- string,
1111
- unknown
1112
- >[];
1241
+ const rows = (await unsafeLoggingClosedConnection(db, sqlText, params, {
1242
+ operation: "upsertOnConflict",
1243
+ table: info.name,
1244
+ })) as readonly Record<string, unknown>[];
1113
1245
  const first = rows[0];
1114
1246
  if (!first) return undefined;
1115
1247
  return coerceRow(first, info) as TRow;
@@ -1188,10 +1320,10 @@ export async function incrementCounter<TRow = any>(
1188
1320
 
1189
1321
  const conflictList = conflictCols.map((c) => quoteIdent(c)).join(", ");
1190
1322
  const sqlText = `${sqlPrefix} ON CONFLICT (${conflictList}) DO UPDATE SET ${updateParts.join(", ")} RETURNING *`;
1191
- const rows = (await asRawClient(db).unsafe(sqlText, params)) as readonly Record<
1192
- string,
1193
- unknown
1194
- >[];
1323
+ const rows = (await unsafeLoggingClosedConnection(db, sqlText, params, {
1324
+ operation: "incrementCounter",
1325
+ table: info.name,
1326
+ })) as readonly Record<string, unknown>[];
1195
1327
  const first = rows[0];
1196
1328
  if (!first) return undefined;
1197
1329
  return coerceRow(first, info) as TRow;
@@ -1227,7 +1359,10 @@ export async function deleteManyBatched(
1227
1359
  const sqlText = `DELETE FROM ${tableQ} WHERE ${idQ} IN (
1228
1360
  SELECT ${idQ} FROM ${tableQ} WHERE ${w.sqlText} LIMIT $${limitIdx}
1229
1361
  ) RETURNING ${idQ}`;
1230
- const rows = (await asRawClient(db).unsafe(sqlText, params)) as readonly unknown[];
1362
+ const rows = (await unsafeLoggingClosedConnection(db, sqlText, params, {
1363
+ operation: "deleteManyBatched",
1364
+ table: info.name,
1365
+ })) as readonly unknown[];
1231
1366
  batches++;
1232
1367
  deleted += rows.length;
1233
1368
  if (rows.length < limit) break;
package/src/changes.json CHANGED
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "0.328.0",
4
+ "type": "improvement",
5
+ "title": "bun-db logs a diagnostic warning when a closed-connection error surfaces",
6
+ "detail": "When a closed-connection error leaves bun-db (retries exhausted, a transaction/reserved handle, or a write path), a `[bun-db] closed connection surfaced` warning records driver, pooled vs. transaction handle, attempts, pool size, timeouts and the error code. It never includes SQL text or parameters. Behaviour, retries and timeouts are unchanged."
7
+ },
8
+ {
9
+ "version": "0.328.0",
10
+ "type": "improvement",
11
+ "title": "runProdApp and runWorkerApp re-encrypt plaintext events after a field gains a PII annotation",
12
+ "detail": "When an event field is annotated as PII after events were already stored, the stored payloads stay plaintext because a projection rebuild copies them unchanged. After start, `runProdApp` and `runWorkerApp` now run `runPiiEventBackfill` in the background: it encrypts those events in place once per annotation state, queues the affected projections (including multi-stream projections with their own table) in `kumiko_pending_rebuilds` and rebuilds them, so ciphertext and blind-index columns get materialized. Later boots only run a cheap catch-up over the newest events. Replicas are serialized by an advisory lock, each batch commits on its own, and failed events are logged and retried on the next boot. Progress lives in the new table `kumiko_pii_backfill_state`, created on first run. Behavior change: the first boot after the bump rewrites `kumiko_events` payloads for annotated fields; set `KUMIKO_SKIP_PII_BACKFILL=1` to opt out. Calling `backfillEventPiiEncryption` by hand stays necessary for `eraseUnresolvableSubjects`, which the boot run never sets. `backfillEventPiiEncryptionBatch` is the new single-batch primitive both build on. `runPendingRebuilds` (the schema-apply queue drain) now also rebuilds queued multi-stream projection tables instead of failing on them; a failed multi-stream rebuild in either path stays queued and leaves the live consumer running."
13
+ },
14
+ {
15
+ "version": "0.328.0",
16
+ "type": "fix",
17
+ "title": "The projection-rebuild job also rebuilds multi-stream projections",
18
+ "detail": "`enqueueProjectionRebuild` and the `jobs:job:projection-rebuild` worker used to call the single-stream rebuild only, so a multi-stream projection name failed with \"not registered\". Both now go through `rebuildProjectionOrMultiStream` (exported from `@cosmicdrift/kumiko-framework/migrations`), the same helper the pending-rebuild queue uses. For multi-stream projections the skip mode comes from the projection's declared `errorMode`; the job payload's `skipApplyErrors` only applies to single-stream projections."
19
+ },
20
+ {
21
+ "version": "0.328.0",
22
+ "type": "improvement",
23
+ "title": "SearchAdapter gains an optional dropAllIndexes() for rebuilds on a persistent index",
24
+ "detail": "`dropAllIndexes()` deletes every tenant index the adapter owns and returns the count. The in-memory adapter clears all tenants; the Meilisearch adapter deletes all indexes under its prefix (and refuses an empty prefix) and resets its configured-tenant state so the next write recreates a filterable index."
25
+ },
26
+ {
27
+ "version": "0.328.0",
28
+ "type": "improvement",
29
+ "title": "setupTestStack can wire job run-logger callbacks, and setupTestStackFromFeatures does so by default",
30
+ "detail": "`TestStackOptions.jobs.runLogger` receives `{ registry, db }` and returns `onJobStart`/`onJobComplete`/`onJobFailed`. The stack awaits them before drainJobs() observes the outcome, so a log row is always written before drainJobs() resolves or rejects. `setupTestStackFromFeatures` (and with it `setupAppTestStack`) defaults to the same `jobRunLoggerCallbacks` prod uses whenever `jobs` is set and the jobs feature is mounted, so `tenantVisibleFailure` rows and `jobs:query:failures` work in tests without a hand-built harness. Such suites now write `job_runs` and tenant failure rows for every job run; a suite that counts those rows, or that wants the old behavior, passes its own `jobs.runLogger` (for example one returning `undefined`)."
31
+ },
32
+ {
33
+ "version": "0.328.0",
34
+ "type": "improvement",
35
+ "title": "build-app-schema carries optionTones and collects the new screen i18n keys",
36
+ "detail": "Select `optionTones` reach the client schema. `createLabel`, `searchPlaceholder`, `summary`, related list `description` and `itemNoun` are collected as i18n keys of the screen, so they are translated like other screen text."
37
+ },
2
38
  {
3
39
  "version": "0.327.0",
4
40
  "type": "improvement",