diff --git a/packages/core/src/wkt/fieldmask.ts b/packages/core/src/wkt/fieldmask.ts index 9e00d450..da272ae4 100644 --- a/packages/core/src/wkt/fieldmask.ts +++ b/packages/core/src/wkt/fieldmask.ts @@ -1,49 +1,7 @@ -// True if any property of T is callable, indicating a class instance (e.g. -// Temporal.Instant, Date) rather than a plain data interface. -type HasMethods = true extends { - [K in keyof T]-?: NonNullable extends (...args: never[]) => unknown - ? true - : never; -}[keyof T] - ? true - : false; - -/** - * Utility type that derives all valid dot-separated field paths from a - * TypeScript interface. Provides compile-time path validation for - * {@link FieldMask}. - * - * Recursion stops at arrays, index signatures (Record/Map), and class - * instances with methods (e.g. Temporal.Instant, Date). These are treated - * as leaf nodes. - * - * @example - * ```ts - * interface Cluster { - * name: string; - * config: {numWorkers: number; scaling: {min: number}}; - * } - * // "name" | "config" | "config.numWorkers" | "config.scaling" | "config.scaling.min" - * type ClusterPaths = FieldPaths; - * ``` - */ -export type FieldPaths = { - [K in keyof T & string]: NonNullable extends unknown[] - ? `${Prefix}${K}` // Array field — leaf, do not recurse. - : NonNullable extends Record - ? string extends keyof NonNullable - ? `${Prefix}${K}` // Index signature (Record/Map) — leaf, do not recurse. - : HasMethods> extends true - ? `${Prefix}${K}` // Class instance with methods — leaf, do not recurse. - : `${Prefix}${K}` | FieldPaths, `${Prefix}${K}.`> - : `${Prefix}${K}`; -}[keyof T & string]; - -// Remove duplicates and paths subsumed by a parent (e.g. "config" subsumes -// "config.numWorkers"). -function normalize

(paths: P[]): P[] { +// Remove duplicates and paths subsumed by a parent (e.g. "config" subsumes "config.numWorkers"). +function normalize(paths: string[]): string[] { const unique = [...new Set(paths)].sort(); - const result: P[] = []; + const result: string[] = []; for (const path of unique) { const isSubsumed = result.some(existing => path.startsWith(existing + '.')); if (!isSubsumed) { @@ -54,33 +12,78 @@ function normalize

(paths: P[]): P[] { } /** - * A type-safe field mask implementing google.protobuf.FieldMask semantics. - * Provides compile-time path validation via {@link FieldPaths}. Paths are - * always normalized: duplicates and paths subsumed by a parent are removed. - * - * @example - * ```ts - * const mask = FieldMask.of>( - * 'displayName', - * 'config.numWorkers' - * ); - * ``` + * One field entry in a {@link FieldMaskSchema}: its wire-format name and, for message-typed fields, a lazy reference to the nested message's schema. Array, map, enum, and scalar fields omit `children`. */ -export class FieldMask { - /** The list of field paths in this mask. */ - readonly paths: TPath[]; +export interface FieldMaskSchemaField { + readonly wire: string; + readonly children?: () => FieldMaskSchema; +} - private constructor(paths: TPath[]) { - this.paths = normalize(paths); +/** + * Structural description of one message's FieldMask-reachable fields. Maps each typescript field name to its wire-format name and, for message-typed fields, a lazy `() => FieldMaskSchema` reference that lets recursive and mutually-recursive messages describe themselves. + */ +export type FieldMaskSchema = Readonly>; + +// Walk a dot-separated typescript field name path against a schema, returning the equivalent wire-format path. Returns `undefined` when any segment fails: a name that isn't a field of the current message, or a non-terminal segment that doesn't reference another message. +function walkFieldMaskPath( + schema: FieldMaskSchema, + path: string +): string | undefined { + const segments = path.split('.'); + const wireSegments: string[] = []; + let current: FieldMaskSchema = schema; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + // Existence check before lookup: `current[seg]` is typed as FieldMaskSchemaField without noUncheckedIndexedAccess, so an undefined check downstream would be flagged "unnecessary". + if (!(seg in current)) return undefined; + const field = current[seg]; + wireSegments.push(field.wire); + if (i < segments.length - 1) { + if (field.children === undefined) return undefined; + current = field.children(); + } } + return wireSegments.join('.'); +} + +/** + * A field mask implementing google.protobuf.FieldMask semantics. + */ +export class FieldMask { + // Phantom marker: keeps `FieldMask` and `FieldMask` compile-time distinct under TypeScript's otherwise-structural typing. Never set at runtime. + declare private readonly _tag: T; - /** Create a field mask from one or more paths. */ - static of

(...paths: P[]): FieldMask

{ - return new FieldMask(paths); + // Stored post-translation, normalized wire-format paths. + private readonly paths: string[]; + + private constructor(paths: string[]) { + this.paths = paths; + } + + /** + * Build a FieldMask from typescript field name paths against the target message's schema. Validates every path by walking each segment through the schema and throws Error when any segment fails. + * + * Reserved for generated per-message factories; user code should call the factory (e.g. `alertFieldMask(...)`), which supplies the schema before delegating here. + * + * @internal + */ + static build(paths: string[], schema: FieldMaskSchema): FieldMask { + const normalized = normalize(paths); + const wire: string[] = []; + for (const p of normalized) { + const w = walkFieldMaskPath(schema, p); + if (w === undefined) { + throw new Error(`Unknown field path "${p}"`); + } + wire.push(w); + } + return new FieldMask(wire); } - /** Return a new mask with additional paths appended. */ - append(...paths: TPath[]): FieldMask { - return new FieldMask([...this.paths, ...paths]); + /** + * Serialize the mask to the wire-format string. + */ + toString(): string { + return this.paths.join(','); } } diff --git a/packages/core/src/wkt/index.ts b/packages/core/src/wkt/index.ts index 8d41d967..5d71382a 100644 --- a/packages/core/src/wkt/index.ts +++ b/packages/core/src/wkt/index.ts @@ -1,3 +1,3 @@ export {FieldMask} from './fieldmask'; -export type {FieldPaths} from './fieldmask'; +export type {FieldMaskSchema, FieldMaskSchemaField} from './fieldmask'; export type {JsonValue, JsonObject} from './value'; diff --git a/packages/core/tests/wkt/fieldmask.test.ts b/packages/core/tests/wkt/fieldmask.test.ts index 3e93698f..0dba976b 100644 --- a/packages/core/tests/wkt/fieldmask.test.ts +++ b/packages/core/tests/wkt/fieldmask.test.ts @@ -1,133 +1,120 @@ import {describe, it, expect} from 'vitest'; import {FieldMask} from '../../src/wkt'; -import type {FieldPaths} from '../../src/wkt'; +import type {FieldMaskSchema} from '../../src/wkt'; -// Simulates a class instance with methods (e.g. Temporal.Instant). -interface ClassLike { - epochMilliseconds: number; - toString(): string; -} +// Alert-like non-cyclic schema with nested Condition + Operand, used to drive FieldMask through its `@internal` build entry point. +const operandSchema: FieldMaskSchema = { + column: {wire: 'column'}, + value: {wire: 'value'}, +}; +const conditionSchema: FieldMaskSchema = { + op: {wire: 'op'}, + operand: {wire: 'operand', children: () => operandSchema}, +}; +const alertSchema: FieldMaskSchema = { + displayName: {wire: 'display_name'}, + condition: {wire: 'condition', children: () => conditionSchema}, +}; -// Test interface for FieldPaths derivation. -interface Cluster { - name: string; - displayName: string; - state: string; - config: { - numWorkers: number; - scaling: { - minReplicas: number; - maxReplicas: number; - }; - }; - tags: string[]; - labels: Record; - createTime?: ClassLike; -} +// Self-referential schema used to verify cycle-safe construction. +const nodeSchema: FieldMaskSchema = { + label: {wire: 'label'}, + child: {wire: 'child', children: () => nodeSchema}, +}; -// Verify FieldPaths derives correct paths at compile time. -type ClusterPaths = FieldPaths; -const _checkPaths: ClusterPaths[] = [ - 'name', - 'displayName', - 'state', - 'config', - 'config.numWorkers', - 'config.scaling', - 'config.scaling.minReplicas', - 'config.scaling.maxReplicas', - 'tags', - 'labels', - 'createTime', // Leaf — ClassLike has methods, so no recursion. -]; -// Suppress unused variable warning. -void _checkPaths; - -// Verify that ClassLike properties are NOT included as paths. -// If FieldPaths recursed into ClassLike, "createTime.epochMilliseconds" would -// be a valid path. This assignment must fail at compile time. -// @ts-expect-error - ClassLike is a leaf; its properties are not valid paths. -const _badPath: ClusterPaths = 'createTime.epochMilliseconds'; -void _badPath; - -describe('FieldMask', () => { - describe('of', () => { - const testCases: {name: string; input: string[]; want: string[]}[] = [ - {name: 'single path', input: ['name'], want: ['name']}, +describe('FieldMask.build', () => { + describe('valid paths translate and serialize', () => { + const cases: { + name: string; + schema: FieldMaskSchema; + input: string[]; + want: string; + }[] = [ { - name: 'multiple paths', - input: ['displayName', 'name'], - want: ['displayName', 'name'], + name: 'flat path', + schema: alertSchema, + input: ['displayName'], + want: 'display_name', }, { - name: 'deduplicates paths', - input: ['name', 'name', 'state'], - want: ['name', 'state'], + name: 'nested path', + schema: alertSchema, + input: ['condition.op'], + want: 'condition.op', }, { - name: 'removes paths subsumed by a parent', - input: ['config.numWorkers', 'config', 'name'], - want: ['config', 'name'], + name: 'deeply nested path', + schema: alertSchema, + input: ['condition.operand.column'], + want: 'condition.operand.column', }, { - name: 'removes deeply subsumed paths', - input: ['config.scaling.minReplicas', 'config.scaling', 'config'], - want: ['config'], + name: 'multiple paths joined in sorted order', + schema: alertSchema, + input: ['displayName', 'condition.op'], + want: 'condition.op,display_name', }, { - name: 'does not subsume paths sharing a prefix without a dot boundary', - input: ['foo', 'foobar'], - want: ['foo', 'foobar'], + name: 'duplicates collapse before translation', + schema: alertSchema, + input: ['displayName', 'displayName'], + want: 'display_name', + }, + { + name: 'children subsumed by a parent are dropped', + schema: alertSchema, + input: ['condition.op', 'condition'], + want: 'condition', + }, + { + name: 'empty input serializes to empty string', + schema: alertSchema, + input: [], + want: '', + }, + { + name: 'arbitrary depth through a self-cycle', + schema: nodeSchema, + input: ['child.child.child.label'], + want: 'child.child.child.label', }, - {name: 'empty mask', input: [], want: []}, ]; - it.each(testCases)('$name', ({input, want}) => { - const mask = FieldMask.of(...input); - expect(mask.paths).toStrictEqual(want); + it.each(cases)('$name', ({schema, input, want}) => { + const mask = FieldMask.build(input, schema); + expect(mask.toString()).toBe(want); }); }); - describe('append', () => { - const testCases: { + describe('invalid paths throw', () => { + const cases: { name: string; - initial: string[]; - append: string[]; - want: string[]; + schema: FieldMaskSchema; + input: string[]; + msg: string; }[] = [ { - name: 'adds new paths', - initial: ['name'], - append: ['state'], - want: ['name', 'state'], + name: 'unknown top-level field', + schema: alertSchema, + input: ['bogus'], + msg: 'Unknown field path "bogus"', }, { - name: 'deduplicates when appending existing paths', - initial: ['name', 'state'], - append: ['name'], - want: ['name', 'state'], + name: 'unknown nested field', + schema: alertSchema, + input: ['condition.bogus'], + msg: 'Unknown field path "condition.bogus"', }, { - name: 'subsumes child when parent is appended', - initial: ['config.numWorkers'], - append: ['config'], - want: ['config'], + name: 'descent past a scalar leaf', + schema: alertSchema, + input: ['displayName.nope'], + msg: 'Unknown field path "displayName.nope"', }, ]; - it.each(testCases)('$name', ({initial, append, want}) => { - const mask = FieldMask.of(...initial).append(...append); - expect(mask.paths).toStrictEqual(want); - }); - }); - - describe('type safety with FieldPaths', () => { - it('works with derived FieldPaths type', () => { - const mask = FieldMask.of>( - 'displayName', - 'config.numWorkers' - ); - expect(mask.paths).toStrictEqual(['config.numWorkers', 'displayName']); + it.each(cases)('$name', ({schema, input, msg}) => { + expect(() => FieldMask.build(input, schema)).toThrowError(msg); }); }); });