Skip to content

Commit c5b93ca

Browse files
zeevdrclaude
andauthored
feat(client): add typed set methods and update setMany to accept SetValue (#86)
Adds setNumber(), setBool(), setTime(), and setDuration() methods to ConfigClient, each sending the appropriate proto TypedValue variant (numberValue, boolValue, timeValue, stringValue) rather than coercing to stringValue. The existing set() is kept as a string escape hatch. setMany() now accepts Record<string, SetValue> (string | number | boolean | Date), using the new valueToTyped() converter to build the correct TypedValue per entry. This is backward-compatible — existing callers that pass Record<string, string> continue to work. Exports SetValue type and valueToTyped() from the public index. Closes #55 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c420e64 commit c5b93ca

5 files changed

Lines changed: 298 additions & 9 deletions

File tree

src/client.ts

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
import { Metadata, type ServiceError } from "@grpc/grpc-js";
99
import { createChannel } from "./channel.js";
1010
import { checkVersionCompatible } from "./compat.js";
11-
import { type Converter, convertValue, typedValueToString } from "./convert.js";
11+
import {
12+
type Converter,
13+
convertValue,
14+
type SetValue,
15+
typedValueToString,
16+
valueToTyped,
17+
} from "./convert.js";
1218
import { mapGrpcError, NotFoundError } from "./errors.js";
1319
import {
1420
type GetConfigRequest,
@@ -240,8 +246,9 @@ export class ConfigClient {
240246
}
241247

242248
/**
243-
* Set a config value. The value is sent as a string -- the server
244-
* coerces it to the schema-defined type.
249+
* Set a config value. The value is sent as a string — the server
250+
* coerces it to the schema-defined type. For type-safe writes, prefer
251+
* setNumber(), setBool(), setTime(), or setDuration().
245252
*/
246253
async set(
247254
tenantId: string,
@@ -273,16 +280,79 @@ export class ConfigClient {
273280
return this.withRetryAndMap(fn, codes);
274281
}
275282

283+
/** Set a numeric config value. Sends the native number as a proto numberValue. */
284+
async setNumber(
285+
tenantId: string,
286+
fieldPath: string,
287+
value: number,
288+
options?: {
289+
timeout?: number;
290+
idempotencyKey?: string;
291+
signal?: AbortSignal;
292+
expectedChecksum?: string;
293+
},
294+
): Promise<void> {
295+
return this.setTyped(tenantId, fieldPath, value, options);
296+
}
297+
298+
/** Set a boolean config value. Sends the native boolean as a proto boolValue. */
299+
async setBool(
300+
tenantId: string,
301+
fieldPath: string,
302+
value: boolean,
303+
options?: {
304+
timeout?: number;
305+
idempotencyKey?: string;
306+
signal?: AbortSignal;
307+
expectedChecksum?: string;
308+
},
309+
): Promise<void> {
310+
return this.setTyped(tenantId, fieldPath, value, options);
311+
}
312+
313+
/** Set a timestamp config value. Sends the Date as a proto timeValue. */
314+
async setTime(
315+
tenantId: string,
316+
fieldPath: string,
317+
value: Date,
318+
options?: {
319+
timeout?: number;
320+
idempotencyKey?: string;
321+
signal?: AbortSignal;
322+
expectedChecksum?: string;
323+
},
324+
): Promise<void> {
325+
return this.setTyped(tenantId, fieldPath, value, options);
326+
}
327+
328+
/**
329+
* Set a duration config value. The value must be a duration string
330+
* (e.g. "1h30m", "300s") — the server parses and validates the format.
331+
*/
332+
async setDuration(
333+
tenantId: string,
334+
fieldPath: string,
335+
value: string,
336+
options?: {
337+
timeout?: number;
338+
idempotencyKey?: string;
339+
signal?: AbortSignal;
340+
expectedChecksum?: string;
341+
},
342+
): Promise<void> {
343+
return this.setTyped(tenantId, fieldPath, value, options);
344+
}
345+
276346
/**
277347
* Atomically set multiple config values.
278348
*
279-
* @param values - Record mapping field paths to string values.
349+
* @param values - Record mapping field paths to typed values (string, number, boolean, or Date).
280350
* @param options - Optional description for the audit log, idempotency key for safe DEADLINE_EXCEEDED retries,
281351
* and per-field expected checksums for optimistic concurrency control.
282352
*/
283353
async setMany(
284354
tenantId: string,
285-
values: Record<string, string>,
355+
values: Record<string, SetValue>,
286356
options?: {
287357
description?: string;
288358
timeout?: number;
@@ -294,7 +364,7 @@ export class ConfigClient {
294364
const fn = async () => {
295365
const updates = Object.entries(values).map(([fieldPath, v]) => ({
296366
fieldPath,
297-
value: { stringValue: v },
367+
value: valueToTyped(v),
298368
expectedChecksum: options?.expectedChecksums?.[fieldPath],
299369
}));
300370
await this.callSetFields(
@@ -383,6 +453,36 @@ export class ConfigClient {
383453

384454
// --- Private helpers ---
385455

456+
private async setTyped(
457+
tenantId: string,
458+
fieldPath: string,
459+
value: SetValue,
460+
options?: {
461+
timeout?: number;
462+
idempotencyKey?: string;
463+
signal?: AbortSignal;
464+
expectedChecksum?: string;
465+
},
466+
): Promise<void> {
467+
const fn = async () => {
468+
await this.callSetField(
469+
{
470+
tenantId,
471+
fieldPath,
472+
value: valueToTyped(value),
473+
expectedChecksum: options?.expectedChecksum,
474+
},
475+
options?.timeout,
476+
options?.signal,
477+
);
478+
};
479+
480+
const codes = options?.idempotencyKey
481+
? WRITE_IDEMPOTENT_RETRYABLE_CODES
482+
: WRITE_RETRYABLE_CODES;
483+
return this.withRetryAndMap(fn, codes);
484+
}
485+
386486
private async fetchServerInfo(): Promise<ServerInfo> {
387487
const fn = () => this.callGetServerInfo({});
388488
const resp = await this.withRetryAndMap(fn);

src/convert.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,28 @@ import type { TypedValue } from "./generated/centralconfig/v1/types.js";
1111
/** Runtime converter type — pass String, Number, or Boolean to get(). */
1212
export type Converter = typeof String | typeof Number | typeof Boolean;
1313

14+
/** Native value types accepted by typed set methods. */
15+
export type SetValue = string | number | boolean | Date;
16+
17+
/**
18+
* Convert a native TypeScript value to a proto TypedValue for writing.
19+
*
20+
* Booleans are checked before numbers since typeof boolean === "boolean".
21+
* Dates become timeValue. Numbers become numberValue. Strings become stringValue.
22+
*/
23+
export function valueToTyped(value: SetValue): TypedValue {
24+
if (typeof value === "boolean") {
25+
return { boolValue: value };
26+
}
27+
if (typeof value === "number") {
28+
return { numberValue: value };
29+
}
30+
if (value instanceof Date) {
31+
return { timeValue: value };
32+
}
33+
return { stringValue: value };
34+
}
35+
1436
/**
1537
* Convert a raw string value to the target type.
1638
*

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export { createChannel } from "./channel.js";
1212
// Client
1313
export { ConfigClient } from "./client.js";
1414
export { checkVersionCompatible, parseVersion, satisfies } from "./compat.js";
15-
export type { Converter } from "./convert.js";
16-
export { convertValue, typedValueToString } from "./convert.js";
15+
export type { Converter, SetValue } from "./convert.js";
16+
export { convertValue, typedValueToString, valueToTyped } from "./convert.js";
1717
// Error hierarchy
1818
export {
1919
AlreadyExistsError,

test/client.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,146 @@ describe("ConfigClient", () => {
290290
});
291291
});
292292

293+
describe("setNumber()", () => {
294+
it("calls setField with numberValue", 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.setNumber("tenant-1", "payments.fee", 0.05);
302+
303+
const callArgs = configStub.setField.mock.calls[0];
304+
expect(callArgs?.[0]).toEqual({
305+
tenantId: "tenant-1",
306+
fieldPath: "payments.fee",
307+
value: { numberValue: 0.05 },
308+
expectedChecksum: undefined,
309+
});
310+
});
311+
312+
it("passes expectedChecksum", async () => {
313+
configStub.setField.mockImplementation(
314+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
315+
cb(null, { configVersion: { version: 1 } });
316+
},
317+
);
318+
319+
await client.setNumber("tenant-1", "payments.fee", 42, { expectedChecksum: "cs1" });
320+
321+
const callArgs = configStub.setField.mock.calls[0];
322+
expect(callArgs?.[0].expectedChecksum).toBe("cs1");
323+
});
324+
});
325+
326+
describe("setBool()", () => {
327+
it("calls setField with boolValue", async () => {
328+
configStub.setField.mockImplementation(
329+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
330+
cb(null, { configVersion: { version: 1 } });
331+
},
332+
);
333+
334+
await client.setBool("tenant-1", "feature.enabled", true);
335+
336+
const callArgs = configStub.setField.mock.calls[0];
337+
expect(callArgs?.[0]).toEqual({
338+
tenantId: "tenant-1",
339+
fieldPath: "feature.enabled",
340+
value: { boolValue: true },
341+
expectedChecksum: undefined,
342+
});
343+
});
344+
345+
it("sends false correctly", async () => {
346+
configStub.setField.mockImplementation(
347+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
348+
cb(null, { configVersion: { version: 1 } });
349+
},
350+
);
351+
352+
await client.setBool("tenant-1", "feature.enabled", false);
353+
354+
const callArgs = configStub.setField.mock.calls[0];
355+
expect(callArgs?.[0].value).toEqual({ boolValue: false });
356+
});
357+
});
358+
359+
describe("setTime()", () => {
360+
it("calls setField with timeValue", async () => {
361+
configStub.setField.mockImplementation(
362+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
363+
cb(null, { configVersion: { version: 1 } });
364+
},
365+
);
366+
367+
const d = new Date("2024-01-15T12:00:00Z");
368+
await client.setTime("tenant-1", "expiry.date", d);
369+
370+
const callArgs = configStub.setField.mock.calls[0];
371+
expect(callArgs?.[0]).toEqual({
372+
tenantId: "tenant-1",
373+
fieldPath: "expiry.date",
374+
value: { timeValue: d },
375+
expectedChecksum: undefined,
376+
});
377+
});
378+
});
379+
380+
describe("setDuration()", () => {
381+
it("calls setField with stringValue for duration string", async () => {
382+
configStub.setField.mockImplementation(
383+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
384+
cb(null, { configVersion: { version: 1 } });
385+
},
386+
);
387+
388+
await client.setDuration("tenant-1", "cache.ttl", "1h30m");
389+
390+
const callArgs = configStub.setField.mock.calls[0];
391+
expect(callArgs?.[0]).toEqual({
392+
tenantId: "tenant-1",
393+
fieldPath: "cache.ttl",
394+
value: { stringValue: "1h30m" },
395+
expectedChecksum: undefined,
396+
});
397+
});
398+
});
399+
400+
describe("setMany() typed values", () => {
401+
it("converts mixed types to typed proto values", async () => {
402+
configStub.setFields.mockImplementation(
403+
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
404+
cb(null, { configVersion: { version: 2 } });
405+
},
406+
);
407+
408+
const d = new Date("2024-06-01T00:00:00Z");
409+
await client.setMany("tenant-1", {
410+
"payments.fee": 0.05,
411+
"feature.enabled": true,
412+
"app.name": "myapp",
413+
"expiry.date": d,
414+
});
415+
416+
const callArgs = configStub.setFields.mock.calls[0];
417+
const updates: Array<{ fieldPath: string; value: unknown }> = callArgs?.[0].updates;
418+
expect(updates.find((u) => u.fieldPath === "payments.fee")?.value).toEqual({
419+
numberValue: 0.05,
420+
});
421+
expect(updates.find((u) => u.fieldPath === "feature.enabled")?.value).toEqual({
422+
boolValue: true,
423+
});
424+
expect(updates.find((u) => u.fieldPath === "app.name")?.value).toEqual({
425+
stringValue: "myapp",
426+
});
427+
expect(updates.find((u) => u.fieldPath === "expiry.date")?.value).toEqual({
428+
timeValue: d,
429+
});
430+
});
431+
});
432+
293433
describe("expectedChecksum plumbing", () => {
294434
it("set() passes expectedChecksum to proto", async () => {
295435
configStub.setField.mockImplementation(

test/convert.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { convertValue, typedValueToString } from "../src/convert.js";
2+
import { convertValue, typedValueToString, valueToTyped } from "../src/convert.js";
33
import { TypeMismatchError } from "../src/errors.js";
44
import type { TypedValue } from "../src/generated/centralconfig/v1/types.js";
55

@@ -170,3 +170,30 @@ describe("typedValueToString", () => {
170170
expect(typedValueToString(tv)).toBe("");
171171
});
172172
});
173+
174+
describe("valueToTyped", () => {
175+
it("wraps string as stringValue", () => {
176+
expect(valueToTyped("hello")).toEqual({ stringValue: "hello" });
177+
});
178+
179+
it("wraps number as numberValue", () => {
180+
expect(valueToTyped(3.14)).toEqual({ numberValue: 3.14 });
181+
});
182+
183+
it("wraps integer as numberValue", () => {
184+
expect(valueToTyped(42)).toEqual({ numberValue: 42 });
185+
});
186+
187+
it("wraps true as boolValue", () => {
188+
expect(valueToTyped(true)).toEqual({ boolValue: true });
189+
});
190+
191+
it("wraps false as boolValue", () => {
192+
expect(valueToTyped(false)).toEqual({ boolValue: false });
193+
});
194+
195+
it("wraps Date as timeValue", () => {
196+
const d = new Date("2024-01-15T00:00:00Z");
197+
expect(valueToTyped(d)).toEqual({ timeValue: d });
198+
});
199+
});

0 commit comments

Comments
 (0)