@opengeni/db 0.36.0 → 0.36.1

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/src/index.ts CHANGED
@@ -45617,6 +45617,7 @@ export async function materializeGoalContinuation(
45617
45617
  if (
45618
45618
  !goalRead ||
45619
45619
  goalRead.status !== "active" ||
45620
+ session.status === "failed" ||
45620
45621
  session.status === "cancelled" ||
45621
45622
  effectiveControl.state !== "active"
45622
45623
  ) {
@@ -46974,9 +46975,14 @@ export async function claimSessionWorkForAttempt(
46974
46975
  input: ClaimSessionWorkForAttemptInput,
46975
46976
  ): Promise<ClaimSessionWorkForAttemptResult> {
46976
46977
  const { sessionId, workflowId } = input;
46977
- return await withWorkspaceSessionActivityRls(
46978
+ return await retrySessionActivityRls(
46978
46979
  db,
46979
46980
  workspaceId,
46981
+ {
46982
+ stage: "session_attempts.claim",
46983
+ eventTypes: ["session.turn.attempt_claimed"],
46984
+ maxAttempts: 3,
46985
+ },
46980
46986
  async (scopedDb) =>
46981
46987
  await withSessionActivitySavepoint(scopedDb, async (tx) => {
46982
46988
  const deliverPendingUpdates = async (
@@ -50003,6 +50009,405 @@ export async function peekSessionWork(
50003
50009
  });
50004
50010
  }
50005
50011
 
50012
+ export type FailSessionWorkBeforeAttemptClaimInput = {
50013
+ accountId: string;
50014
+ sessionId: string;
50015
+ workflowId: string;
50016
+ trigger: SessionWorkTrigger;
50017
+ error: string;
50018
+ };
50019
+
50020
+ export type FailSessionWorkBeforeAttemptClaimResult =
50021
+ | { action: "failed"; turnId: string | null; events: SessionEvent[] }
50022
+ | { action: "terminal"; turnId: null; events: [] }
50023
+ | { action: "stale"; turnId: null; events: [] };
50024
+
50025
+ /**
50026
+ * Terminally settle a permanent admission failure that happened before an
50027
+ * attempt row existed. The workflow supplies the exact trigger recorded in
50028
+ * Temporal history; this transaction re-evaluates it under the canonical
50029
+ * control -> workspace -> session -> turn lock order and refuses stale,
50030
+ * paused, capacity-blocked, or concurrently claimed work. No attempt row is
50031
+ * fabricated because provider/tool execution never started.
50032
+ */
50033
+ export async function failSessionWorkBeforeAttemptClaim(
50034
+ db: Database,
50035
+ workspaceId: string,
50036
+ input: FailSessionWorkBeforeAttemptClaimInput,
50037
+ ): Promise<FailSessionWorkBeforeAttemptClaimResult> {
50038
+ return await retrySessionActivityRls(
50039
+ db,
50040
+ workspaceId,
50041
+ {
50042
+ stage: "session_lifecycle_outbox.fail_before_attempt_claim",
50043
+ eventTypes: ["child_terminal_result", "session.status.changed", "turn.failed"],
50044
+ maxAttempts: 3,
50045
+ },
50046
+ async (scopedDb) =>
50047
+ await scopedDb.transaction(async (tx) => {
50048
+ const baseLocks = await lockChildLifecycleOutboxWriteRowsTx(
50049
+ tx as unknown as Database,
50050
+ workspaceId,
50051
+ { sessionId: input.sessionId },
50052
+ );
50053
+ const session = baseLocks.session;
50054
+ if (session.accountId !== input.accountId) {
50055
+ throw new SessionControlInvariantError(
50056
+ `Session ${input.sessionId} does not belong to account ${input.accountId}`,
50057
+ );
50058
+ }
50059
+ if (session.status === "failed" || session.status === "cancelled") {
50060
+ return { action: "terminal", turnId: null, events: [] } as const;
50061
+ }
50062
+ if (
50063
+ session.temporalWorkflowId !== null &&
50064
+ session.temporalWorkflowId !== input.workflowId
50065
+ ) {
50066
+ return { action: "stale", turnId: null, events: [] } as const;
50067
+ }
50068
+ const effectiveControl = await evaluateSessionControl(
50069
+ tx as unknown as Database,
50070
+ workspaceId,
50071
+ input.sessionId,
50072
+ { workspaceControl: baseLocks.control ?? undefined },
50073
+ );
50074
+ if (!baseLocks.workspace || effectiveControl.state !== "active") {
50075
+ return { action: "stale", turnId: null, events: [] } as const;
50076
+ }
50077
+
50078
+ // The session lock serializes this check with claim. If another worker
50079
+ // committed ownership while the activity response was ambiguous, its
50080
+ // attempt is authoritative and this terminal path must no-op.
50081
+ const [liveAttempt] = await tx
50082
+ .select({ id: schema.sessionTurnAttempts.id })
50083
+ .from(schema.sessionTurnAttempts)
50084
+ .where(
50085
+ and(
50086
+ eq(schema.sessionTurnAttempts.workspaceId, workspaceId),
50087
+ eq(schema.sessionTurnAttempts.sessionId, input.sessionId),
50088
+ inArray(schema.sessionTurnAttempts.state, ["claimed", "running"]),
50089
+ ),
50090
+ )
50091
+ .limit(1);
50092
+ if (liveAttempt) {
50093
+ return { action: "stale", turnId: null, events: [] } as const;
50094
+ }
50095
+
50096
+ let turn: typeof schema.sessionTurns.$inferSelect | null = null;
50097
+ if (session.activeTurnId) {
50098
+ const exactLocks = await lockSessionEventWriteRows(tx as unknown as Database, {
50099
+ workspaceId,
50100
+ controlLock: "already_locked",
50101
+ workspaceLock: "already_locked",
50102
+ turnIds: [session.activeTurnId],
50103
+ });
50104
+ turn = exactLocks.turns[0] ?? null;
50105
+ if (!turn || turn.accountId !== session.accountId || turn.sessionId !== input.sessionId) {
50106
+ // The claim failed on this same locked corrupt pointer. There is no
50107
+ // safe turn identity to settle, but retrying can never repair it;
50108
+ // fail the owning session and its session-level obligations below.
50109
+ turn = null;
50110
+ }
50111
+ if (turn && isTerminalSessionTurnStatus(turn.status as SessionTurnStatus)) {
50112
+ // Repair the corrupt session pointer without writing a second
50113
+ // terminal event onto an already-terminal logical turn.
50114
+ turn = null;
50115
+ } else if (turn && input.trigger.kind === "approval") {
50116
+ if (
50117
+ turn.status !== "requires_action" ||
50118
+ !(await isNewerApprovalTrigger(
50119
+ tx as unknown as Database,
50120
+ workspaceId,
50121
+ input.sessionId,
50122
+ turn.triggerEventId,
50123
+ input.trigger.triggerEventId,
50124
+ ))
50125
+ ) {
50126
+ return { action: "stale", turnId: null, events: [] } as const;
50127
+ }
50128
+ } else if (turn) {
50129
+ if (turn.status === "requires_action") {
50130
+ return { action: "stale", turnId: null, events: [] } as const;
50131
+ }
50132
+ if (turn.status === "waiting_capacity") {
50133
+ const [codexWaiter] = await tx
50134
+ .select({ id: schema.codexCapacityWaiters.id })
50135
+ .from(schema.codexCapacityWaiters)
50136
+ .where(
50137
+ and(
50138
+ eq(schema.codexCapacityWaiters.workspaceId, workspaceId),
50139
+ eq(schema.codexCapacityWaiters.sessionId, input.sessionId),
50140
+ eq(schema.codexCapacityWaiters.status, "waiting"),
50141
+ ),
50142
+ )
50143
+ .limit(1);
50144
+ const xaiWaiter = await getXaiCapacityWaitForSessionInTransaction(
50145
+ tx as unknown as Database,
50146
+ workspaceId,
50147
+ input.sessionId,
50148
+ );
50149
+ if (codexWaiter || xaiWaiter) {
50150
+ return { action: "stale", turnId: null, events: [] } as const;
50151
+ }
50152
+ }
50153
+ }
50154
+ } else {
50155
+ if (input.trigger.kind === "approval") {
50156
+ return { action: "stale", turnId: null, events: [] } as const;
50157
+ }
50158
+ const [pendingAgentSteer] = await tx
50159
+ .select({ id: schema.sessionSystemUpdates.id })
50160
+ .from(schema.sessionSystemUpdates)
50161
+ .where(
50162
+ and(
50163
+ eq(schema.sessionSystemUpdates.workspaceId, workspaceId),
50164
+ eq(schema.sessionSystemUpdates.sessionId, input.sessionId),
50165
+ eq(schema.sessionSystemUpdates.kind, "agent_steer_instruction"),
50166
+ eq(schema.sessionSystemUpdates.state, "pending"),
50167
+ ),
50168
+ )
50169
+ .orderBy(
50170
+ desc(schema.sessionSystemUpdates.createdAt),
50171
+ desc(schema.sessionSystemUpdates.id),
50172
+ )
50173
+ .limit(1);
50174
+ const queuedRows = await rawRows<{ id: string }>(
50175
+ tx as unknown as Database,
50176
+ sql`select id from session_turns
50177
+ where workspace_id = ${workspaceId} and session_id = ${input.sessionId}
50178
+ and status = 'queued' and source in ('user', 'api')
50179
+ and (
50180
+ ${Boolean(pendingAgentSteer)} = false
50181
+ or metadata->>'delivery' = 'steer'
50182
+ )
50183
+ order by position asc, created_at asc, id asc
50184
+ limit 1`,
50185
+ );
50186
+ const queuedPreview = queuedRows[0];
50187
+ if (queuedPreview) {
50188
+ const exactLocks = await lockSessionEventWriteRows(tx as unknown as Database, {
50189
+ workspaceId,
50190
+ controlLock: "already_locked",
50191
+ workspaceLock: "already_locked",
50192
+ turnIds: [queuedPreview.id],
50193
+ });
50194
+ turn = exactLocks.turns[0] ?? null;
50195
+ if (
50196
+ !turn ||
50197
+ turn.accountId !== session.accountId ||
50198
+ turn.sessionId !== input.sessionId ||
50199
+ turn.status !== "queued" ||
50200
+ turn.activeAttemptId !== null
50201
+ ) {
50202
+ return { action: "stale", turnId: null, events: [] } as const;
50203
+ }
50204
+ } else if (!session.compactRequested) {
50205
+ if (
50206
+ !pendingAgentSteer &&
50207
+ (await latestFinishedTurnHasFailureCodeTx(
50208
+ tx as unknown as Database,
50209
+ workspaceId,
50210
+ input.sessionId,
50211
+ "context_compaction_failed",
50212
+ ))
50213
+ ) {
50214
+ return { action: "stale", turnId: null, events: [] } as const;
50215
+ }
50216
+ const [pendingUpdate] = await tx
50217
+ .select({ id: schema.sessionSystemUpdates.id })
50218
+ .from(schema.sessionSystemUpdates)
50219
+ .where(
50220
+ and(
50221
+ eq(schema.sessionSystemUpdates.workspaceId, workspaceId),
50222
+ eq(schema.sessionSystemUpdates.sessionId, input.sessionId),
50223
+ eq(schema.sessionSystemUpdates.state, "pending"),
50224
+ ),
50225
+ )
50226
+ .limit(1)
50227
+ .for("update");
50228
+ if (!pendingUpdate) {
50229
+ return { action: "stale", turnId: null, events: [] } as const;
50230
+ }
50231
+ }
50232
+ }
50233
+
50234
+ const now = new Date();
50235
+ let sequence = session.lastSequence;
50236
+ let closedToolEvents: SessionEvent[] = [];
50237
+ if (turn) {
50238
+ await cancelTurnInteractionInterventionsInTransaction(tx as unknown as Database, {
50239
+ accountId: session.accountId,
50240
+ workspaceId,
50241
+ sessionId: input.sessionId,
50242
+ turnId: turn.id,
50243
+ });
50244
+ const closedTools = await closePendingSessionToolCallsInTransaction(
50245
+ tx as unknown as Database,
50246
+ {
50247
+ accountId: session.accountId,
50248
+ workspaceId,
50249
+ sessionId: input.sessionId,
50250
+ turnId: turn.id,
50251
+ reason: "turn_failed",
50252
+ sequence,
50253
+ now,
50254
+ },
50255
+ );
50256
+ sequence = closedTools.sequence;
50257
+ closedToolEvents = closedTools.events;
50258
+ await tx
50259
+ .update(schema.sessionHumanInputRequests)
50260
+ .set({
50261
+ status: "cancelled",
50262
+ response: { outcome: "cancelled" },
50263
+ respondedBy: "system:turn_failed",
50264
+ respondedAt: now,
50265
+ updatedAt: now,
50266
+ })
50267
+ .where(
50268
+ and(
50269
+ eq(schema.sessionHumanInputRequests.workspaceId, workspaceId),
50270
+ eq(schema.sessionHumanInputRequests.sessionId, input.sessionId),
50271
+ eq(schema.sessionHumanInputRequests.turnId, turn.id),
50272
+ eq(schema.sessionHumanInputRequests.status, "pending"),
50273
+ ),
50274
+ );
50275
+ }
50276
+ await settleSessionMaintenanceInTransaction(tx as unknown as Database, {
50277
+ accountId: session.accountId,
50278
+ workspaceId,
50279
+ sessionId: input.sessionId,
50280
+ });
50281
+ if (!turn) {
50282
+ await tx
50283
+ .update(schema.sessionSystemUpdates)
50284
+ .set({ state: "failed" })
50285
+ .where(
50286
+ and(
50287
+ eq(schema.sessionSystemUpdates.workspaceId, workspaceId),
50288
+ eq(schema.sessionSystemUpdates.sessionId, input.sessionId),
50289
+ eq(schema.sessionSystemUpdates.state, "pending"),
50290
+ ),
50291
+ );
50292
+ }
50293
+ const values = [
50294
+ ...(turn
50295
+ ? [
50296
+ {
50297
+ accountId: session.accountId,
50298
+ workspaceId,
50299
+ sessionId: input.sessionId,
50300
+ sequence: ++sequence,
50301
+ type: "turn.failed" as const,
50302
+ payload: {
50303
+ triggerEventId: turn.triggerEventId,
50304
+ code: "pre_claim_failure",
50305
+ error: input.error,
50306
+ },
50307
+ turnId: turn.id,
50308
+ turnGeneration: turn.executionGeneration,
50309
+ turnAttemptId: null,
50310
+ turnAssociation: "current" as const,
50311
+ occurredAt: now,
50312
+ },
50313
+ ]
50314
+ : []),
50315
+ {
50316
+ accountId: session.accountId,
50317
+ workspaceId,
50318
+ sessionId: input.sessionId,
50319
+ sequence: ++sequence,
50320
+ type: "session.status.changed" as const,
50321
+ payload: { status: "failed", code: "pre_claim_failure" },
50322
+ turnId: turn?.id ?? null,
50323
+ turnGeneration: turn?.executionGeneration ?? null,
50324
+ turnAttemptId: null,
50325
+ turnAssociation: turn ? ("current" as const) : null,
50326
+ occurredAt: now,
50327
+ },
50328
+ ];
50329
+ const inserted = await tx
50330
+ .insert(schema.sessionEvents)
50331
+ .values(withLosslessContentWriteVersion(values, "payload", "payloadCodecVersion"))
50332
+ .returning();
50333
+ if (turn) {
50334
+ const failedEvent = inserted.find((event) => event.type === "turn.failed");
50335
+ if (!failedEvent) {
50336
+ throw new SessionControlInvariantError(
50337
+ `Pre-claim failure for turn ${turn.id} has no terminal event`,
50338
+ );
50339
+ }
50340
+ await projectSessionRealtimeDelegationTerminalInTransaction(tx as unknown as Database, {
50341
+ accountId: session.accountId,
50342
+ workspaceId,
50343
+ sessionId: input.sessionId,
50344
+ turnId: turn.id,
50345
+ turnStatus: "failed",
50346
+ terminalEvent: {
50347
+ id: failedEvent.id,
50348
+ type: "turn.failed",
50349
+ payload: sessionEventPayloadRecord(
50350
+ failedEvent.payload,
50351
+ failedEvent.payloadCodecVersion,
50352
+ ),
50353
+ },
50354
+ now,
50355
+ });
50356
+ await tx
50357
+ .update(schema.sessionTurns)
50358
+ .set({
50359
+ status: "failed",
50360
+ activeAttemptId: null,
50361
+ version: turn.version + 1,
50362
+ finishedAt: now,
50363
+ updatedAt: now,
50364
+ })
50365
+ .where(
50366
+ and(
50367
+ eq(schema.sessionTurns.workspaceId, workspaceId),
50368
+ eq(schema.sessionTurns.sessionId, input.sessionId),
50369
+ eq(schema.sessionTurns.id, turn.id),
50370
+ ),
50371
+ );
50372
+ await enqueueFailedChildOutboxForTurnTx(
50373
+ tx as unknown as Database,
50374
+ workspaceId,
50375
+ session,
50376
+ turn,
50377
+ );
50378
+ } else {
50379
+ await enqueueFailedChildOutboxWithoutTurnTx(
50380
+ tx as unknown as Database,
50381
+ workspaceId,
50382
+ session,
50383
+ sequence,
50384
+ );
50385
+ }
50386
+ await tx
50387
+ .update(schema.sessions)
50388
+ .set({
50389
+ status: "failed",
50390
+ activeTurnId: null,
50391
+ compactRequested: false,
50392
+ lastSequence: sequence,
50393
+ queueVersion: session.queueVersion + 1,
50394
+ updatedAt: now,
50395
+ })
50396
+ .where(
50397
+ and(
50398
+ eq(schema.sessions.workspaceId, workspaceId),
50399
+ eq(schema.sessions.id, input.sessionId),
50400
+ ),
50401
+ );
50402
+ return {
50403
+ action: "failed",
50404
+ turnId: turn?.id ?? null,
50405
+ events: [...closedToolEvents, ...inserted.map(mapEvent)],
50406
+ } as const;
50407
+ }),
50408
+ );
50409
+ }
50410
+
50006
50411
  /**
50007
50412
  * Child lifecycle transactions discover immutable parentage only after taking
50008
50413
  * the control/workspace prefix. They must then lock child and parent together,
@@ -52804,14 +53209,16 @@ function queuedSteerReplacementAttemptId(metadata: Record<string, unknown>): str
52804
53209
  return null;
52805
53210
  }
52806
53211
 
52807
- async function enqueueFailedChildOutboxForTurnTx(
53212
+ async function enqueueFailedChildOutboxTx(
52808
53213
  tx: Database,
52809
53214
  workspaceId: string,
52810
- session: Pick<typeof schema.sessions.$inferSelect, "id" | "parentSessionId" | "parentTurnId">,
52811
- turn: Pick<typeof schema.sessionTurns.$inferSelect, "id" | "accountId" | "sessionId">,
53215
+ session: Pick<
53216
+ typeof schema.sessions.$inferSelect,
53217
+ "id" | "accountId" | "parentSessionId" | "parentTurnId"
53218
+ >,
53219
+ input: { turnId: string | null; dedupeKey: string },
52812
53220
  ): Promise<void> {
52813
53221
  if (!session.parentSessionId) return;
52814
- const dedupeKey = `child-completion:${turn.sessionId}:turn:${turn.id}`;
52815
53222
  const personalConnectionDelegations = session.parentTurnId
52816
53223
  ? await personalConnectionDelegationsForTurnInTransaction(
52817
53224
  tx,
@@ -52859,26 +53266,26 @@ async function enqueueFailedChildOutboxForTurnTx(
52859
53266
  withLosslessContentWriteVersion(
52860
53267
  withLosslessContentWriteVersion(
52861
53268
  {
52862
- accountId: turn.accountId,
53269
+ accountId: session.accountId,
52863
53270
  workspaceId,
52864
- sourceSessionId: turn.sessionId,
53271
+ sourceSessionId: session.id,
52865
53272
  targetSessionId: session.parentSessionId,
52866
- dedupeKey,
53273
+ dedupeKey: input.dedupeKey,
52867
53274
  kind: "child_terminal_result",
52868
53275
  classification: "failure",
52869
- sourceId: turn.sessionId,
53276
+ sourceId: session.id,
52870
53277
  summary: "Child session failed; inspect the durable child timeline.",
52871
53278
  payload: {
52872
53279
  type: "child_terminal_result",
52873
- childSessionId: turn.sessionId,
53280
+ childSessionId: session.id,
52874
53281
  status: "failed",
52875
- turnId: turn.id,
53282
+ ...(input.turnId ? { turnId: input.turnId } : {}),
52876
53283
  },
52877
53284
  lineage: {
52878
- childSessionId: turn.sessionId,
53285
+ childSessionId: session.id,
52879
53286
  parentSessionId: session.parentSessionId,
52880
53287
  ...(session.parentTurnId ? { parentTurnId: session.parentTurnId } : {}),
52881
- turnId: turn.id,
53288
+ ...(input.turnId ? { turnId: input.turnId } : {}),
52882
53289
  ...(xaiAuthoritySubjectId ? { xaiAuthoritySubjectId } : {}),
52883
53290
  },
52884
53291
  personalConnectionDelegations,
@@ -52899,6 +53306,41 @@ async function enqueueFailedChildOutboxForTurnTx(
52899
53306
  });
52900
53307
  }
52901
53308
 
53309
+ async function enqueueFailedChildOutboxForTurnTx(
53310
+ tx: Database,
53311
+ workspaceId: string,
53312
+ session: Pick<
53313
+ typeof schema.sessions.$inferSelect,
53314
+ "id" | "accountId" | "parentSessionId" | "parentTurnId"
53315
+ >,
53316
+ turn: Pick<typeof schema.sessionTurns.$inferSelect, "id" | "accountId" | "sessionId">,
53317
+ ): Promise<void> {
53318
+ if (turn.accountId !== session.accountId || turn.sessionId !== session.id) {
53319
+ throw new SessionControlInvariantError(
53320
+ `Failed child turn ${turn.id} lost session ${session.id} ownership`,
53321
+ );
53322
+ }
53323
+ await enqueueFailedChildOutboxTx(tx, workspaceId, session, {
53324
+ turnId: turn.id,
53325
+ dedupeKey: `child-completion:${turn.sessionId}:turn:${turn.id}`,
53326
+ });
53327
+ }
53328
+
53329
+ async function enqueueFailedChildOutboxWithoutTurnTx(
53330
+ tx: Database,
53331
+ workspaceId: string,
53332
+ session: Pick<
53333
+ typeof schema.sessions.$inferSelect,
53334
+ "id" | "accountId" | "parentSessionId" | "parentTurnId"
53335
+ >,
53336
+ terminalSequence: number,
53337
+ ): Promise<void> {
53338
+ await enqueueFailedChildOutboxTx(tx, workspaceId, session, {
53339
+ turnId: null,
53340
+ dedupeKey: `child-completion:${session.id}:pre-claim:${terminalSequence}`,
53341
+ });
53342
+ }
53343
+
52902
53344
  type ChildTerminalResultPayload = Extract<
52903
53345
  SessionSystemUpdatePayload,
52904
53346
  { type: "child_terminal_result" }
@@ -53106,10 +53548,19 @@ export async function enqueueSessionWorkflowWakeInTransaction(
53106
53548
  : sql`${schema.sessionWorkflowWakeOutbox.controlRevision}`,
53107
53549
  reason: input.reason,
53108
53550
  attempts: 0,
53109
- // Coalescing a delayed retry must never postpone an already-due wake
53110
- // owned by another producer. A later revision makes the batch richer;
53111
- // it does not revoke the earlier delivery obligation.
53112
- nextAttemptAt: sql`least(${schema.sessionWorkflowWakeOutbox.nextAttemptAt}, ${nextAttemptAt.toISOString()}::timestamptz)`,
53551
+ // Coalescing a delayed retry must never postpone an undelivered wake
53552
+ // owned by another producer. Once every prior revision is delivered,
53553
+ // however, this is a new obligation and must honor its own deadline;
53554
+ // retaining the old (usually past) deadline would defeat backoff.
53555
+ nextAttemptAt: sql`case
53556
+ when ${schema.sessionWorkflowWakeOutbox.wakeRevision}
53557
+ > ${schema.sessionWorkflowWakeOutbox.deliveredRevision}
53558
+ then least(
53559
+ ${schema.sessionWorkflowWakeOutbox.nextAttemptAt},
53560
+ ${nextAttemptAt.toISOString()}::timestamptz
53561
+ )
53562
+ else ${nextAttemptAt.toISOString()}::timestamptz
53563
+ end`,
53113
53564
  lastError: null,
53114
53565
  updatedAt: now,
53115
53566
  },
@@ -25,6 +25,26 @@ export type PersistenceFailureDetails = {
25
25
 
26
26
  const SQLSTATE_KEYS = ["sqlState", "sqlstate", "code"] as const;
27
27
  const NESTED_ERROR_KEYS = ["cause", "original", "driverError", "error", "errors"] as const;
28
+ const RETRYABLE_DATABASE_TRANSPORT_CODES = new Set([
29
+ // postgres.js connection lifecycle failures.
30
+ "CONNECTION_CLOSED",
31
+ "CONNECTION_DESTROYED",
32
+ "CONNECTION_ENDED",
33
+ "CONNECT_TIMEOUT",
34
+ // Node socket/DNS failures surfaced unchanged by postgres.js.
35
+ "EAI_AGAIN",
36
+ "ECONNABORTED",
37
+ "ECONNREFUSED",
38
+ "ECONNRESET",
39
+ "EHOSTDOWN",
40
+ "EHOSTUNREACH",
41
+ "ENETDOWN",
42
+ "ENETRESET",
43
+ "ENETUNREACH",
44
+ "ENOTFOUND",
45
+ "EPIPE",
46
+ "ETIMEDOUT",
47
+ ]);
28
48
  const DATABASE_ERROR_NAMES = new Set(["DatabaseError", "DrizzleQueryError", "PostgresError"]);
29
49
  const DATABASE_DIAGNOSTIC_KEYS = [
30
50
  "severity",
@@ -72,6 +92,9 @@ export function nestedPostgresSqlState(error: unknown): string | null {
72
92
  const value = current[key];
73
93
  if (typeof value !== "string" || !/^[0-9A-Z]{5}$/i.test(value)) continue;
74
94
  const normalized = value.toUpperCase();
95
+ // Node transport codes such as EPIPE happen to be five characters but
96
+ // are not PostgreSQL SQLSTATEs.
97
+ if (RETRYABLE_DATABASE_TRANSPORT_CODES.has(normalized)) continue;
75
98
  if (normalized === "40P01" || normalized === "40001") return normalized;
76
99
  fallback ??= normalized;
77
100
  }
@@ -94,13 +117,49 @@ export function isRetryablePersistenceSqlState(sqlState: string | null): boolean
94
117
  return sqlState === "40P01" || sqlState === "40001";
95
118
  }
96
119
 
120
+ /**
121
+ * Recognize only explicit postgres.js/Node transport codes, including nested
122
+ * driver causes. These failures can arrive as plain Errors with no SQLSTATE or
123
+ * database diagnostic fields when the connection dies before PostgreSQL can
124
+ * answer. Messages are intentionally ignored: they are unstable and may
125
+ * contain connection detail.
126
+ */
127
+ export function isRetryableDatabaseTransportFailure(error: unknown): boolean {
128
+ const queue: unknown[] = [error];
129
+ const seen = new Set<unknown>();
130
+ while (queue.length > 0 && seen.size < 64) {
131
+ const current = queue.shift();
132
+ if (!isRecord(current) || seen.has(current)) continue;
133
+ seen.add(current);
134
+
135
+ for (const key of ["code", "errno"] as const) {
136
+ const value = current[key];
137
+ if (
138
+ typeof value === "string" &&
139
+ RETRYABLE_DATABASE_TRANSPORT_CODES.has(value.toUpperCase())
140
+ ) {
141
+ return true;
142
+ }
143
+ }
144
+
145
+ for (const key of NESTED_ERROR_KEYS) {
146
+ const nested = current[key];
147
+ if (Array.isArray(nested)) queue.push(...nested);
148
+ else if (nested !== undefined) queue.push(nested);
149
+ }
150
+ }
151
+ return false;
152
+ }
153
+
97
154
  /**
98
155
  * Distinguish database/ORM failures from expected domain exceptions when a
99
156
  * driver omitted SQLSTATE. This checks shape only; callers retain the original
100
157
  * failure independently as canonical error evidence.
101
158
  */
102
159
  export function isDatabasePersistenceFailure(error: unknown): boolean {
103
- if (nestedPostgresSqlState(error) !== null) return true;
160
+ if (isRetryableDatabaseTransportFailure(error) || nestedPostgresSqlState(error) !== null) {
161
+ return true;
162
+ }
104
163
 
105
164
  const queue: unknown[] = [error];
106
165
  const seen = new Set<unknown>();