Skip to content

Commit 16ae127

Browse files
vedanshujainclaude
authored andcommitted
[Plugin] updateIf: trap serialization failures, fix all-undefined guard bypass, doc isolation
Three follow-ups on the guarded PluginStorageRepository.updateIf primitive: 1. Trap Postgres serialization failures. Under an isolation level stricter than READ COMMITTED the losing concurrent updateIf writers abort with SQLSTATE 40001 (serialization_failure) / 40P01 (deadlock_detected) instead of resolving to { applied: false }. A new exported StorageSerializationError (in storage-query.ts, alongside StorageQueryError) carries the SQLSTATE and cause and explains the READ COMMITTED assumption + retry guidance. A pure, unit-testable mapSerializationFailure(err) helper wraps 40001/40P01 and rethrows everything else unchanged; updateIf's catch is `throw mapSerializationFailure(err)`. SQLSTATE is read from err.code (and err.cause.code defensively) — confirmed empirically that Kysely propagates node-pg's DatabaseError.code unwrapped. 2. Fix the all-undefined delta/set guard bypass (both reviewers). Presence is now derived from DEFINED entries: undefined-valued set fields are filtered and undefined delta specs skipped BEFORE the "at least one of set/delta" check, so an all-undefined payload throws instead of doing a no-op write that bumped updated_at and returned { applied: true }. undefined values in set/delta are documented as ignored. 3. Doc the isolation contract on the updateIf doc-comment in both types.ts (interface) and plugin-storage.ts (impl): { applied: false } assumes READ COMMITTED; under REPEATABLE READ / SERIALIZABLE losing writers throw StorageSerializationError — the no-oversell SAFETY invariant holds either way. Tests: all-undefined delta/set throw (integration, both dialects); deterministic mapSerializationFailure unit tests (40001, 40P01, .cause.code nesting, 23505 pass-through, plain Error pass-through). Full core plugin suites green on both dialects (804 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TX9YciGFRZX9aF2UcQ6rUW
1 parent a042f50 commit 16ae127

5 files changed

Lines changed: 193 additions & 7 deletions

File tree

packages/core/src/database/repositories/plugin-storage.ts

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
jsonOrderExtract,
1919
StorageQueryError,
2020
isInFilter,
21+
StorageSerializationError,
2122
} from "../../plugins/storage-query.js";
2223
import type {
2324
StorageCollection,
@@ -40,6 +41,59 @@ function isDeltaLike(value: unknown): value is { inc?: unknown; dec?: unknown }
4041
return typeof value === "object" && value !== null;
4142
}
4243

44+
/**
45+
* SQLSTATEs a losing concurrent `updateIf` writer aborts with under an
46+
* isolation level stricter than READ COMMITTED: `40001` (serialization_failure)
47+
* and `40P01` (deadlock_detected).
48+
*/
49+
const SERIALIZATION_SQLSTATES = new Set(["40001", "40P01"]);
50+
51+
/**
52+
* Best-effort extraction of a Postgres SQLSTATE from a thrown driver error.
53+
*
54+
* node-pg sets `.code` on its `DatabaseError`, and Kysely propagates the driver
55+
* error unwrapped (confirmed: a 23505 raised through Kysely surfaces `err.code`
56+
* === "23505"). We also check `.cause.code` defensively so a future wrapping
57+
* layer that nests the driver error as `cause` keeps working.
58+
*/
59+
function errSqlState(err: unknown): string | undefined {
60+
if (typeof err !== "object" || err === null) return undefined;
61+
const code = (err as { code?: unknown }).code;
62+
if (typeof code === "string") return code;
63+
const cause = (err as { cause?: unknown }).cause;
64+
if (typeof cause === "object" && cause !== null) {
65+
const causeCode = (cause as { code?: unknown }).code;
66+
if (typeof causeCode === "string") return causeCode;
67+
}
68+
return undefined;
69+
}
70+
71+
/**
72+
* Map a thrown `updateIf` error to a {@link StorageSerializationError} when it
73+
* is a Postgres serialization failure (`40001`) or deadlock (`40P01`);
74+
* otherwise return it UNCHANGED.
75+
*
76+
* Pure and synchronous so it is unit-testable without provoking a live race —
77+
* `updateIf`'s catch is simply `throw mapSerializationFailure(err)`. See
78+
* {@link StorageSerializationError} for why these aborts happen only under an
79+
* isolation level stricter than READ COMMITTED.
80+
*/
81+
export function mapSerializationFailure(err: unknown): unknown {
82+
const sqlState = errSqlState(err);
83+
if (sqlState !== undefined && SERIALIZATION_SQLSTATES.has(sqlState)) {
84+
return new StorageSerializationError(
85+
`updateIf lost a concurrent race (SQLSTATE ${sqlState}). Its ` +
86+
`{ applied: false } contract assumes READ COMMITTED (the default); under ` +
87+
`REPEATABLE READ / SERIALIZABLE the losing writer aborts instead of ` +
88+
`resolving to { applied: false }. Retry the call, or run it at READ ` +
89+
`COMMITTED. The no-oversell safety invariant still holds — a losing ` +
90+
`writer never applies.`,
91+
{ cause: err, sqlState },
92+
);
93+
}
94+
return err;
95+
}
96+
4397
/**
4498
* Interleave a `?`-placeholder SQL string with its params into a single
4599
* boolean raw expression. Used as a WHERE predicate directly — wrapping it
@@ -398,13 +452,33 @@ export class PluginStorageRepository<T = unknown> implements StorageCollection<T
398452
* rows-affected > 0). A missing row and a failed guard both yield 0 rows →
399453
* `{ applied: false }`; the two are intentionally indistinguishable. Never
400454
* inserts.
455+
*
456+
* `undefined` values in `set`/`delta` are IGNORED (they carry no write);
457+
* presence is derived from the DEFINED entries, so an all-`undefined` payload
458+
* (e.g. `{ set: { name: undefined } }`) hits the "at least one of set/delta"
459+
* error rather than doing a no-op write that bumps `updated_at`.
460+
*
461+
* ISOLATION: the `{ applied: false }` contract assumes READ COMMITTED (the
462+
* default). Under REPEATABLE READ / SERIALIZABLE the losing concurrent
463+
* writers throw {@link StorageSerializationError} (SQLSTATE `40001` / `40P01`)
464+
* instead of resolving to `{ applied: false }`. The no-oversell SAFETY
465+
* invariant holds either way — a losing writer never applies.
401466
*/
402467
async updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>> {
403468
const { where, set, delta } = args;
404469

405-
const setEntries: Array<[string, unknown]> = set ? Object.entries(set) : [];
470+
// Derive presence from DEFINED entries — `undefined` values carry no write
471+
// and must not satisfy the "at least one of set/delta" requirement below
472+
// (otherwise an all-`undefined` payload would do a no-op write that still
473+
// bumps `updated_at` and returns `{ applied: true }`).
474+
const setEntries: Array<[string, unknown]> = set
475+
? Object.entries(set).filter(([, value]) => value !== undefined)
476+
: [];
406477
const hasSet = setEntries.length > 0;
407-
const hasDelta = delta !== undefined && Object.keys(delta).length > 0;
478+
479+
const definedDeltaEntries: Array<[string, unknown]> =
480+
delta !== undefined ? Object.entries(delta).filter(([, spec]) => spec !== undefined) : [];
481+
const hasDelta = definedDeltaEntries.length > 0;
408482

409483
if (!hasSet && !hasDelta) {
410484
throw new Error("updateIf requires at least one of `set` or `delta`.");
@@ -414,8 +488,7 @@ export class PluginStorageRepository<T = unknown> implements StorageCollection<T
414488
const deltaEntries: Array<[string, number]> = [];
415489
if (hasDelta) {
416490
const setFieldSet = new Set(setEntries.map(([field]) => field));
417-
for (const [field, spec] of Object.entries(delta)) {
418-
if (spec === undefined) continue;
491+
for (const [field, spec] of definedDeltaEntries) {
419492
if (setFieldSet.has(field)) {
420493
throw new Error(`updateIf: field "${field}" appears in both \`set\` and \`delta\`.`);
421494
}
@@ -469,7 +542,16 @@ export class PluginStorageRepository<T = unknown> implements StorageCollection<T
469542
query = query.where(rawWhereExpr(whereResult.sql, whereResult.params));
470543
}
471544

472-
const row = await query.returning("data").executeTakeFirst();
545+
// The `{ applied: false }` contract holds only under READ COMMITTED. Under
546+
// a stricter isolation level the losing concurrent writers abort with a
547+
// serialization failure / deadlock instead; translate those to a typed,
548+
// retryable error and rethrow everything else unchanged.
549+
let row: { data: string } | undefined;
550+
try {
551+
row = await query.returning("data").executeTakeFirst();
552+
} catch (err) {
553+
throw mapSerializationFailure(err);
554+
}
473555
if (!row) return { applied: false };
474556
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse returns any; generic callers provide T
475557
const data = JSON.parse(row.data) as T;

packages/core/src/plugins/storage-query.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,33 @@ export class StorageQueryError extends Error {
2525
}
2626
}
2727

28+
/**
29+
* Error thrown when a guarded `updateIf` loses a concurrent race under an
30+
* isolation level stricter than READ COMMITTED.
31+
*
32+
* `updateIf`'s `{ applied: false }` contract (row absent OR guard failed)
33+
* assumes READ COMMITTED — the default. There, a losing concurrent writer
34+
* re-evaluates the guard against the winner's freshly committed row and
35+
* cleanly resolves to `{ applied: false }`. Under REPEATABLE READ /
36+
* SERIALIZABLE the loser cannot re-read against a newer snapshot, so it aborts
37+
* with SQLSTATE `40001` (serialization_failure) or `40P01` (deadlock_detected)
38+
* instead. This error surfaces that abort so the caller can retry the write
39+
* (or run it at READ COMMITTED).
40+
*
41+
* The no-oversell SAFETY invariant holds either way: a losing writer NEVER
42+
* applies its update — it either sees `{ applied: false }` or throws here.
43+
*/
44+
export class StorageSerializationError extends Error {
45+
/** The Postgres SQLSTATE that triggered this error (`40001` / `40P01`). */
46+
readonly sqlState?: string;
47+
48+
constructor(message: string, options?: { cause?: unknown; sqlState?: string }) {
49+
super(message, { cause: options?.cause });
50+
this.name = "StorageSerializationError";
51+
this.sqlState = options?.sqlState;
52+
}
53+
}
54+
2855
/**
2956
* Check if a value is a range filter
3057
*/

packages/core/src/plugins/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,13 @@ export interface StorageCollection<T = unknown> {
216216
* arithmetic live in one statement, so N concurrent guarded decrements
217217
* serialize correctly. `applied: false` means the row was absent OR the
218218
* guard failed (the two are intentionally indistinguishable). Never inserts.
219+
*
220+
* ISOLATION: the `applied: false` contract assumes READ COMMITTED (the
221+
* default). Under REPEATABLE READ / SERIALIZABLE the losing concurrent
222+
* writers throw `StorageSerializationError` (SQLSTATE `40001` / `40P01`)
223+
* instead of resolving to `applied: false` — the caller should retry (or run
224+
* at READ COMMITTED). The no-oversell SAFETY invariant holds either way: a
225+
* losing writer never applies.
219226
*/
220227
updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>;
221228
}

packages/core/tests/integration/plugins/storage-updateif.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,27 @@ describeEachDialect("Plugin storage updateIf", (dialect) => {
179179
await expect(repo.updateIf("p1", { where: { sku: "A" } })).rejects.toThrow(/set.*delta/i);
180180
});
181181

182+
it("updateIf() with an all-`undefined` delta (no set) throws and never writes", async () => {
183+
// `{ stock: undefined }` has a key but no DEFINED entry — presence is
184+
// derived from defined entries, so this hits the "at least one" guard
185+
// instead of doing a no-op write that bumps updated_at (#reviewer flag).
186+
const repo = productsRepo();
187+
await repo.put("p1", { sku: "A", stock: 5, tier: 1, name: "Alpha" });
188+
await expect(
189+
repo.updateIf("p1", { where: { sku: "A" }, delta: { stock: undefined } }),
190+
).rejects.toThrow(/set.*delta/i);
191+
expect((await repo.get("p1"))?.stock).toBe(5);
192+
});
193+
194+
it("updateIf() with an all-`undefined` set (no delta) throws and never writes", async () => {
195+
const repo = productsRepo();
196+
await repo.put("p1", { sku: "A", stock: 5, tier: 1, name: "Alpha" });
197+
await expect(
198+
repo.updateIf("p1", { where: { sku: "A" }, set: { name: undefined } }),
199+
).rejects.toThrow(/set.*delta/i);
200+
expect((await repo.get("p1"))?.name).toBe("Alpha");
201+
});
202+
182203
// ── guard operator coverage ─────────────────────────────────────────────
183204

184205
it("updateIf() guard covers equality, multi-digit gte, in, startsWith, and a non-indexed field", async () => {

packages/core/tests/unit/plugins/plugin-storage.test.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import type { Kysely } from "kysely";
22
import { describe, it, expect, beforeEach, afterEach } from "vitest";
33

4-
import { PluginStorageRepository } from "../../../src/database/repositories/plugin-storage.js";
4+
import {
5+
PluginStorageRepository,
6+
mapSerializationFailure,
7+
} from "../../../src/database/repositories/plugin-storage.js";
58
import type { Database } from "../../../src/database/types.js";
69
import { IdentifierError } from "../../../src/database/validate.js";
7-
import { StorageQueryError } from "../../../src/plugins/storage-query.js";
10+
import {
11+
StorageQueryError,
12+
StorageSerializationError,
13+
} from "../../../src/plugins/storage-query.js";
814
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
915

1016
interface TestDocument {
@@ -441,3 +447,46 @@ describe("PluginStorageRepository", () => {
441447
});
442448
});
443449
});
450+
451+
describe("mapSerializationFailure", () => {
452+
// Pure/deterministic: proves the SQLSTATE trap without provoking a live race.
453+
// node-pg sets `.code` on its DatabaseError and Kysely propagates it
454+
// unwrapped, so a fake `{ code }` faithfully stands in for the real throw.
455+
456+
it("wraps a 40001 (serialization_failure) into StorageSerializationError, preserving cause", () => {
457+
const original = { code: "40001", message: "could not serialize access" };
458+
const mapped = mapSerializationFailure(original);
459+
expect(mapped).toBeInstanceOf(StorageSerializationError);
460+
const err = mapped as StorageSerializationError;
461+
expect(err.sqlState).toBe("40001");
462+
expect(err.cause).toBe(original);
463+
expect(err.message).toMatch(/40001/);
464+
expect(err.message).toMatch(/READ COMMITTED/);
465+
});
466+
467+
it("wraps a 40P01 (deadlock_detected) into StorageSerializationError, preserving cause", () => {
468+
const original = { code: "40P01", message: "deadlock detected" };
469+
const mapped = mapSerializationFailure(original);
470+
expect(mapped).toBeInstanceOf(StorageSerializationError);
471+
const err = mapped as StorageSerializationError;
472+
expect(err.sqlState).toBe("40P01");
473+
expect(err.cause).toBe(original);
474+
});
475+
476+
it("detects the SQLSTATE nested on `.cause.code` too", () => {
477+
const original = { cause: { code: "40001" } };
478+
const mapped = mapSerializationFailure(original);
479+
expect(mapped).toBeInstanceOf(StorageSerializationError);
480+
expect((mapped as StorageSerializationError).sqlState).toBe("40001");
481+
});
482+
483+
it("passes a non-serialization SQLSTATE (23505) through unchanged", () => {
484+
const original = { code: "23505", message: "duplicate key" };
485+
expect(mapSerializationFailure(original)).toBe(original);
486+
});
487+
488+
it("passes a plain Error (no code) through unchanged", () => {
489+
const original = new Error("boom");
490+
expect(mapSerializationFailure(original)).toBe(original);
491+
});
492+
});

0 commit comments

Comments
 (0)