diff --git a/src/client.ts b/src/client.ts index 009450e..f08103d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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, @@ -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, @@ -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 { + 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 { + 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 { + 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 { + 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, + values: Record, options?: { description?: string; timeout?: number; @@ -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( @@ -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 { + 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 { const fn = () => this.callGetServerInfo({}); const resp = await this.withRetryAndMap(fn); diff --git a/src/convert.ts b/src/convert.ts index 153120e..4ccb01e 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -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. * diff --git a/src/index.ts b/src/index.ts index cadb252..e080e85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/test/client.test.ts b/test/client.test.ts index ae3d646..8b270c5 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -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( diff --git a/test/convert.test.ts b/test/convert.test.ts index 5281d2a..9e6cbc8 100644 --- a/test/convert.test.ts +++ b/test/convert.test.ts @@ -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"; @@ -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 }); + }); +});