diff --git a/src/client.ts b/src/client.ts index 5eb6b3a..009450e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -247,11 +247,21 @@ export class ConfigClient { tenantId: string, fieldPath: string, value: string, - options?: { timeout?: number; idempotencyKey?: string; signal?: AbortSignal }, + options?: { + timeout?: number; + idempotencyKey?: string; + signal?: AbortSignal; + expectedChecksum?: string; + }, ): Promise { const fn = async () => { await this.callSetField( - { tenantId, fieldPath, value: { stringValue: value } }, + { + tenantId, + fieldPath, + value: { stringValue: value }, + expectedChecksum: options?.expectedChecksum, + }, options?.timeout, options?.signal, ); @@ -267,7 +277,8 @@ export class ConfigClient { * Atomically set multiple config values. * * @param values - Record mapping field paths to string values. - * @param options - Optional description for the audit log and idempotency key for safe DEADLINE_EXCEEDED retries. + * @param options - Optional description for the audit log, idempotency key for safe DEADLINE_EXCEEDED retries, + * and per-field expected checksums for optimistic concurrency control. */ async setMany( tenantId: string, @@ -277,12 +288,14 @@ export class ConfigClient { timeout?: number; idempotencyKey?: string; signal?: AbortSignal; + expectedChecksums?: Record; }, ): Promise { const fn = async () => { const updates = Object.entries(values).map(([fieldPath, v]) => ({ fieldPath, value: { stringValue: v }, + expectedChecksum: options?.expectedChecksums?.[fieldPath], })); await this.callSetFields( { tenantId, updates, description: options?.description }, @@ -303,11 +316,16 @@ export class ConfigClient { async setNull( tenantId: string, fieldPath: string, - options?: { timeout?: number; idempotencyKey?: string; signal?: AbortSignal }, + options?: { + timeout?: number; + idempotencyKey?: string; + signal?: AbortSignal; + expectedChecksum?: string; + }, ): Promise { const fn = async () => { await this.callSetField( - { tenantId, fieldPath, value: undefined }, + { tenantId, fieldPath, value: undefined, expectedChecksum: options?.expectedChecksum }, options?.timeout, options?.signal, ); diff --git a/test/client.test.ts b/test/client.test.ts index 6a76c6e..ae3d646 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -2,6 +2,7 @@ import { Metadata, type ServiceError, status } from "@grpc/grpc-js"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { ConfigClient } from "../src/client.js"; import { + ChecksumMismatchError, DecreeError, IncompatibleServerError, NotFoundError, @@ -284,10 +285,103 @@ describe("ConfigClient", () => { tenantId: "tenant-1", fieldPath: "payments.fee", value: undefined, + expectedChecksum: undefined, }); }); }); + describe("expectedChecksum plumbing", () => { + it("set() passes expectedChecksum to proto", async () => { + configStub.setField.mockImplementation( + (_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => { + cb(null, { configVersion: { version: 1 } }); + }, + ); + + await client.set("tenant-1", "payments.fee", "0.5%", { expectedChecksum: "abc123" }); + + const callArgs = configStub.setField.mock.calls[0]; + expect(callArgs?.[0]).toEqual({ + tenantId: "tenant-1", + fieldPath: "payments.fee", + value: { stringValue: "0.5%" }, + expectedChecksum: "abc123", + }); + }); + + it("set() raises ChecksumMismatchError on ABORTED", async () => { + configStub.setField.mockImplementation( + (_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => { + cb(makeServiceError(status.ABORTED, "checksum mismatch")); + }, + ); + + await expect( + client.set("tenant-1", "payments.fee", "0.5%", { expectedChecksum: "stale" }), + ).rejects.toThrow(ChecksumMismatchError); + }); + + it("setMany() passes per-field expectedChecksums to proto", async () => { + configStub.setFields.mockImplementation( + (_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => { + cb(null, { configVersion: { version: 2 } }); + }, + ); + + await client.setMany("tenant-1", { a: "1", b: "2" }, { expectedChecksums: { a: "cs-a" } }); + + const callArgs = configStub.setFields.mock.calls[0]; + const updates: Array<{ fieldPath: string; expectedChecksum?: string }> = + callArgs?.[0].updates; + const updateA = updates.find((u) => u.fieldPath === "a"); + const updateB = updates.find((u) => u.fieldPath === "b"); + expect(updateA?.expectedChecksum).toBe("cs-a"); + expect(updateB?.expectedChecksum).toBeUndefined(); + }); + + it("setMany() raises ChecksumMismatchError on ABORTED", async () => { + configStub.setFields.mockImplementation( + (_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => { + cb(makeServiceError(status.ABORTED, "checksum mismatch")); + }, + ); + + await expect( + client.setMany("tenant-1", { a: "1" }, { expectedChecksums: { a: "stale" } }), + ).rejects.toThrow(ChecksumMismatchError); + }); + + it("setNull() passes expectedChecksum to proto", async () => { + configStub.setField.mockImplementation( + (_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => { + cb(null, { configVersion: { version: 3 } }); + }, + ); + + await client.setNull("tenant-1", "payments.fee", { expectedChecksum: "xyz" }); + + const callArgs = configStub.setField.mock.calls[0]; + expect(callArgs?.[0]).toEqual({ + tenantId: "tenant-1", + fieldPath: "payments.fee", + value: undefined, + expectedChecksum: "xyz", + }); + }); + + it("setNull() raises ChecksumMismatchError on ABORTED", async () => { + configStub.setField.mockImplementation( + (_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => { + cb(makeServiceError(status.ABORTED, "checksum mismatch")); + }, + ); + + await expect( + client.setNull("tenant-1", "payments.fee", { expectedChecksum: "stale" }), + ).rejects.toThrow(ChecksumMismatchError); + }); + }); + describe("serverInfo", () => { it("fetches and caches server info", async () => { serverStub.getServerInfo.mockImplementation(