Skip to content

Commit c420e64

Browse files
zeevdrclaude
andauthored
feat(client): add expectedChecksum to set, setMany, setNull (#85)
Adds optimistic concurrency control to all write methods. Callers can pass a checksum from a previous GetField/GetConfig response to prevent lost updates when multiple actors modify the same field concurrently. The server returns ABORTED on mismatch, which the SDK maps to ChecksumMismatchError. - set/setNull: new expectedChecksum option - setMany: new expectedChecksums record option (per-field) Closes #54 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 76c10e4 commit c420e64

2 files changed

Lines changed: 117 additions & 5 deletions

File tree

src/client.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,21 @@ export class ConfigClient {
247247
tenantId: string,
248248
fieldPath: string,
249249
value: string,
250-
options?: { timeout?: number; idempotencyKey?: string; signal?: AbortSignal },
250+
options?: {
251+
timeout?: number;
252+
idempotencyKey?: string;
253+
signal?: AbortSignal;
254+
expectedChecksum?: string;
255+
},
251256
): Promise<void> {
252257
const fn = async () => {
253258
await this.callSetField(
254-
{ tenantId, fieldPath, value: { stringValue: value } },
259+
{
260+
tenantId,
261+
fieldPath,
262+
value: { stringValue: value },
263+
expectedChecksum: options?.expectedChecksum,
264+
},
255265
options?.timeout,
256266
options?.signal,
257267
);
@@ -267,7 +277,8 @@ export class ConfigClient {
267277
* Atomically set multiple config values.
268278
*
269279
* @param values - Record mapping field paths to string values.
270-
* @param options - Optional description for the audit log and idempotency key for safe DEADLINE_EXCEEDED retries.
280+
* @param options - Optional description for the audit log, idempotency key for safe DEADLINE_EXCEEDED retries,
281+
* and per-field expected checksums for optimistic concurrency control.
271282
*/
272283
async setMany(
273284
tenantId: string,
@@ -277,12 +288,14 @@ export class ConfigClient {
277288
timeout?: number;
278289
idempotencyKey?: string;
279290
signal?: AbortSignal;
291+
expectedChecksums?: Record<string, string>;
280292
},
281293
): Promise<void> {
282294
const fn = async () => {
283295
const updates = Object.entries(values).map(([fieldPath, v]) => ({
284296
fieldPath,
285297
value: { stringValue: v },
298+
expectedChecksum: options?.expectedChecksums?.[fieldPath],
286299
}));
287300
await this.callSetFields(
288301
{ tenantId, updates, description: options?.description },
@@ -303,11 +316,16 @@ export class ConfigClient {
303316
async setNull(
304317
tenantId: string,
305318
fieldPath: string,
306-
options?: { timeout?: number; idempotencyKey?: string; signal?: AbortSignal },
319+
options?: {
320+
timeout?: number;
321+
idempotencyKey?: string;
322+
signal?: AbortSignal;
323+
expectedChecksum?: string;
324+
},
307325
): Promise<void> {
308326
const fn = async () => {
309327
await this.callSetField(
310-
{ tenantId, fieldPath, value: undefined },
328+
{ tenantId, fieldPath, value: undefined, expectedChecksum: options?.expectedChecksum },
311329
options?.timeout,
312330
options?.signal,
313331
);

test/client.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Metadata, type ServiceError, status } from "@grpc/grpc-js";
22
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
33
import { ConfigClient } from "../src/client.js";
44
import {
5+
ChecksumMismatchError,
56
DecreeError,
67
IncompatibleServerError,
78
NotFoundError,
@@ -284,10 +285,103 @@ describe("ConfigClient", () => {
284285
tenantId: "tenant-1",
285286
fieldPath: "payments.fee",
286287
value: undefined,
288+
expectedChecksum: undefined,
287289
});
288290
});
289291
});
290292

293+
describe("expectedChecksum plumbing", () => {
294+
it("set() passes expectedChecksum to proto", async () => {
295+
configStub.setField.mockImplementation(
296+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
297+
cb(null, { configVersion: { version: 1 } });
298+
},
299+
);
300+
301+
await client.set("tenant-1", "payments.fee", "0.5%", { expectedChecksum: "abc123" });
302+
303+
const callArgs = configStub.setField.mock.calls[0];
304+
expect(callArgs?.[0]).toEqual({
305+
tenantId: "tenant-1",
306+
fieldPath: "payments.fee",
307+
value: { stringValue: "0.5%" },
308+
expectedChecksum: "abc123",
309+
});
310+
});
311+
312+
it("set() raises ChecksumMismatchError on ABORTED", async () => {
313+
configStub.setField.mockImplementation(
314+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
315+
cb(makeServiceError(status.ABORTED, "checksum mismatch"));
316+
},
317+
);
318+
319+
await expect(
320+
client.set("tenant-1", "payments.fee", "0.5%", { expectedChecksum: "stale" }),
321+
).rejects.toThrow(ChecksumMismatchError);
322+
});
323+
324+
it("setMany() passes per-field expectedChecksums to proto", async () => {
325+
configStub.setFields.mockImplementation(
326+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
327+
cb(null, { configVersion: { version: 2 } });
328+
},
329+
);
330+
331+
await client.setMany("tenant-1", { a: "1", b: "2" }, { expectedChecksums: { a: "cs-a" } });
332+
333+
const callArgs = configStub.setFields.mock.calls[0];
334+
const updates: Array<{ fieldPath: string; expectedChecksum?: string }> =
335+
callArgs?.[0].updates;
336+
const updateA = updates.find((u) => u.fieldPath === "a");
337+
const updateB = updates.find((u) => u.fieldPath === "b");
338+
expect(updateA?.expectedChecksum).toBe("cs-a");
339+
expect(updateB?.expectedChecksum).toBeUndefined();
340+
});
341+
342+
it("setMany() raises ChecksumMismatchError on ABORTED", async () => {
343+
configStub.setFields.mockImplementation(
344+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
345+
cb(makeServiceError(status.ABORTED, "checksum mismatch"));
346+
},
347+
);
348+
349+
await expect(
350+
client.setMany("tenant-1", { a: "1" }, { expectedChecksums: { a: "stale" } }),
351+
).rejects.toThrow(ChecksumMismatchError);
352+
});
353+
354+
it("setNull() passes expectedChecksum to proto", async () => {
355+
configStub.setField.mockImplementation(
356+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
357+
cb(null, { configVersion: { version: 3 } });
358+
},
359+
);
360+
361+
await client.setNull("tenant-1", "payments.fee", { expectedChecksum: "xyz" });
362+
363+
const callArgs = configStub.setField.mock.calls[0];
364+
expect(callArgs?.[0]).toEqual({
365+
tenantId: "tenant-1",
366+
fieldPath: "payments.fee",
367+
value: undefined,
368+
expectedChecksum: "xyz",
369+
});
370+
});
371+
372+
it("setNull() raises ChecksumMismatchError on ABORTED", async () => {
373+
configStub.setField.mockImplementation(
374+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
375+
cb(makeServiceError(status.ABORTED, "checksum mismatch"));
376+
},
377+
);
378+
379+
await expect(
380+
client.setNull("tenant-1", "payments.fee", { expectedChecksum: "stale" }),
381+
).rejects.toThrow(ChecksumMismatchError);
382+
});
383+
});
384+
291385
describe("serverInfo", () => {
292386
it("fetches and caches server info", async () => {
293387
serverStub.getServerInfo.mockImplementation(

0 commit comments

Comments
 (0)