Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const fn = async () => {
await this.callSetField(
{ tenantId, fieldPath, value: { stringValue: value } },
{
tenantId,
fieldPath,
value: { stringValue: value },
expectedChecksum: options?.expectedChecksum,
},
options?.timeout,
options?.signal,
);
Expand All @@ -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,
Expand All @@ -277,12 +288,14 @@ export class ConfigClient {
timeout?: number;
idempotencyKey?: string;
signal?: AbortSignal;
expectedChecksums?: Record<string, string>;
},
): Promise<void> {
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 },
Expand All @@ -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<void> {
const fn = async () => {
await this.callSetField(
{ tenantId, fieldPath, value: undefined },
{ tenantId, fieldPath, value: undefined, expectedChecksum: options?.expectedChecksum },
options?.timeout,
options?.signal,
);
Expand Down
94 changes: 94 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down