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
112 changes: 106 additions & 6 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
import { Metadata, type ServiceError } from "@grpc/grpc-js";
import { createChannel } from "./channel.js";
import { checkVersionCompatible } from "./compat.js";
import { type Converter, convertValue, typedValueToString } from "./convert.js";
import {
type Converter,
convertValue,
type SetValue,
typedValueToString,
valueToTyped,
} from "./convert.js";
import { mapGrpcError, NotFoundError } from "./errors.js";
import {
type GetConfigRequest,
Expand Down Expand Up @@ -240,8 +246,9 @@ export class ConfigClient {
}

/**
* Set a config value. The value is sent as a string -- the server
* coerces it to the schema-defined type.
* Set a config value. The value is sent as a string — the server
* coerces it to the schema-defined type. For type-safe writes, prefer
* setNumber(), setBool(), setTime(), or setDuration().
*/
async set(
tenantId: string,
Expand Down Expand Up @@ -273,16 +280,79 @@ export class ConfigClient {
return this.withRetryAndMap(fn, codes);
}

/** Set a numeric config value. Sends the native number as a proto numberValue. */
async setNumber(
tenantId: string,
fieldPath: string,
value: number,
options?: {
timeout?: number;
idempotencyKey?: string;
signal?: AbortSignal;
expectedChecksum?: string;
},
): Promise<void> {
return this.setTyped(tenantId, fieldPath, value, options);
}

/** Set a boolean config value. Sends the native boolean as a proto boolValue. */
async setBool(
tenantId: string,
fieldPath: string,
value: boolean,
options?: {
timeout?: number;
idempotencyKey?: string;
signal?: AbortSignal;
expectedChecksum?: string;
},
): Promise<void> {
return this.setTyped(tenantId, fieldPath, value, options);
}

/** Set a timestamp config value. Sends the Date as a proto timeValue. */
async setTime(
tenantId: string,
fieldPath: string,
value: Date,
options?: {
timeout?: number;
idempotencyKey?: string;
signal?: AbortSignal;
expectedChecksum?: string;
},
): Promise<void> {
return this.setTyped(tenantId, fieldPath, value, options);
}

/**
* Set a duration config value. The value must be a duration string
* (e.g. "1h30m", "300s") — the server parses and validates the format.
*/
async setDuration(
tenantId: string,
fieldPath: string,
value: string,
options?: {
timeout?: number;
idempotencyKey?: string;
signal?: AbortSignal;
expectedChecksum?: string;
},
): Promise<void> {
return this.setTyped(tenantId, fieldPath, value, options);
}

/**
* Atomically set multiple config values.
*
* @param values - Record mapping field paths to string values.
* @param values - Record mapping field paths to typed values (string, number, boolean, or Date).
* @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,
values: Record<string, string>,
values: Record<string, SetValue>,
options?: {
description?: string;
timeout?: number;
Expand All @@ -294,7 +364,7 @@ export class ConfigClient {
const fn = async () => {
const updates = Object.entries(values).map(([fieldPath, v]) => ({
fieldPath,
value: { stringValue: v },
value: valueToTyped(v),
expectedChecksum: options?.expectedChecksums?.[fieldPath],
}));
await this.callSetFields(
Expand Down Expand Up @@ -383,6 +453,36 @@ export class ConfigClient {

// --- Private helpers ---

private async setTyped(
tenantId: string,
fieldPath: string,
value: SetValue,
options?: {
timeout?: number;
idempotencyKey?: string;
signal?: AbortSignal;
expectedChecksum?: string;
},
): Promise<void> {
const fn = async () => {
await this.callSetField(
{
tenantId,
fieldPath,
value: valueToTyped(value),
expectedChecksum: options?.expectedChecksum,
},
options?.timeout,
options?.signal,
);
};

const codes = options?.idempotencyKey
? WRITE_IDEMPOTENT_RETRYABLE_CODES
: WRITE_RETRYABLE_CODES;
return this.withRetryAndMap(fn, codes);
}

private async fetchServerInfo(): Promise<ServerInfo> {
const fn = () => this.callGetServerInfo({});
const resp = await this.withRetryAndMap(fn);
Expand Down
22 changes: 22 additions & 0 deletions src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,28 @@ import type { TypedValue } from "./generated/centralconfig/v1/types.js";
/** Runtime converter type — pass String, Number, or Boolean to get(). */
export type Converter = typeof String | typeof Number | typeof Boolean;

/** Native value types accepted by typed set methods. */
export type SetValue = string | number | boolean | Date;

/**
* Convert a native TypeScript value to a proto TypedValue for writing.
*
* Booleans are checked before numbers since typeof boolean === "boolean".
* Dates become timeValue. Numbers become numberValue. Strings become stringValue.
*/
export function valueToTyped(value: SetValue): TypedValue {
if (typeof value === "boolean") {
return { boolValue: value };
}
if (typeof value === "number") {
return { numberValue: value };
}
if (value instanceof Date) {
return { timeValue: value };
}
return { stringValue: value };
}

/**
* Convert a raw string value to the target type.
*
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export { createChannel } from "./channel.js";
// Client
export { ConfigClient } from "./client.js";
export { checkVersionCompatible, parseVersion, satisfies } from "./compat.js";
export type { Converter } from "./convert.js";
export { convertValue, typedValueToString } from "./convert.js";
export type { Converter, SetValue } from "./convert.js";
export { convertValue, typedValueToString, valueToTyped } from "./convert.js";
// Error hierarchy
export {
AlreadyExistsError,
Expand Down
140 changes: 140 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,146 @@ describe("ConfigClient", () => {
});
});

describe("setNumber()", () => {
it("calls setField with numberValue", async () => {
configStub.setField.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 1 } });
},
);

await client.setNumber("tenant-1", "payments.fee", 0.05);

const callArgs = configStub.setField.mock.calls[0];
expect(callArgs?.[0]).toEqual({
tenantId: "tenant-1",
fieldPath: "payments.fee",
value: { numberValue: 0.05 },
expectedChecksum: undefined,
});
});

it("passes expectedChecksum", async () => {
configStub.setField.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 1 } });
},
);

await client.setNumber("tenant-1", "payments.fee", 42, { expectedChecksum: "cs1" });

const callArgs = configStub.setField.mock.calls[0];
expect(callArgs?.[0].expectedChecksum).toBe("cs1");
});
});

describe("setBool()", () => {
it("calls setField with boolValue", async () => {
configStub.setField.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 1 } });
},
);

await client.setBool("tenant-1", "feature.enabled", true);

const callArgs = configStub.setField.mock.calls[0];
expect(callArgs?.[0]).toEqual({
tenantId: "tenant-1",
fieldPath: "feature.enabled",
value: { boolValue: true },
expectedChecksum: undefined,
});
});

it("sends false correctly", async () => {
configStub.setField.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 1 } });
},
);

await client.setBool("tenant-1", "feature.enabled", false);

const callArgs = configStub.setField.mock.calls[0];
expect(callArgs?.[0].value).toEqual({ boolValue: false });
});
});

describe("setTime()", () => {
it("calls setField with timeValue", async () => {
configStub.setField.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 1 } });
},
);

const d = new Date("2024-01-15T12:00:00Z");
await client.setTime("tenant-1", "expiry.date", d);

const callArgs = configStub.setField.mock.calls[0];
expect(callArgs?.[0]).toEqual({
tenantId: "tenant-1",
fieldPath: "expiry.date",
value: { timeValue: d },
expectedChecksum: undefined,
});
});
});

describe("setDuration()", () => {
it("calls setField with stringValue for duration string", async () => {
configStub.setField.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 1 } });
},
);

await client.setDuration("tenant-1", "cache.ttl", "1h30m");

const callArgs = configStub.setField.mock.calls[0];
expect(callArgs?.[0]).toEqual({
tenantId: "tenant-1",
fieldPath: "cache.ttl",
value: { stringValue: "1h30m" },
expectedChecksum: undefined,
});
});
});

describe("setMany() typed values", () => {
it("converts mixed types to typed proto values", async () => {
configStub.setFields.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 2 } });
},
);

const d = new Date("2024-06-01T00:00:00Z");
await client.setMany("tenant-1", {
"payments.fee": 0.05,
"feature.enabled": true,
"app.name": "myapp",
"expiry.date": d,
});

const callArgs = configStub.setFields.mock.calls[0];
const updates: Array<{ fieldPath: string; value: unknown }> = callArgs?.[0].updates;
expect(updates.find((u) => u.fieldPath === "payments.fee")?.value).toEqual({
numberValue: 0.05,
});
expect(updates.find((u) => u.fieldPath === "feature.enabled")?.value).toEqual({
boolValue: true,
});
expect(updates.find((u) => u.fieldPath === "app.name")?.value).toEqual({
stringValue: "myapp",
});
expect(updates.find((u) => u.fieldPath === "expiry.date")?.value).toEqual({
timeValue: d,
});
});
});

describe("expectedChecksum plumbing", () => {
it("set() passes expectedChecksum to proto", async () => {
configStub.setField.mockImplementation(
Expand Down
29 changes: 28 additions & 1 deletion test/convert.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { convertValue, typedValueToString } from "../src/convert.js";
import { convertValue, typedValueToString, valueToTyped } from "../src/convert.js";
import { TypeMismatchError } from "../src/errors.js";
import type { TypedValue } from "../src/generated/centralconfig/v1/types.js";

Expand Down Expand Up @@ -170,3 +170,30 @@ describe("typedValueToString", () => {
expect(typedValueToString(tv)).toBe("");
});
});

describe("valueToTyped", () => {
it("wraps string as stringValue", () => {
expect(valueToTyped("hello")).toEqual({ stringValue: "hello" });
});

it("wraps number as numberValue", () => {
expect(valueToTyped(3.14)).toEqual({ numberValue: 3.14 });
});

it("wraps integer as numberValue", () => {
expect(valueToTyped(42)).toEqual({ numberValue: 42 });
});

it("wraps true as boolValue", () => {
expect(valueToTyped(true)).toEqual({ boolValue: true });
});

it("wraps false as boolValue", () => {
expect(valueToTyped(false)).toEqual({ boolValue: false });
});

it("wraps Date as timeValue", () => {
const d = new Date("2024-01-15T00:00:00Z");
expect(valueToTyped(d)).toEqual({ timeValue: d });
});
});