Skip to content

Commit d154fe1

Browse files
authored
feat(fmodata,typegen): add list type override with preserved custom list options (#123)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Introduces a new `listField()` builder with non-trivial read/write validation plus updates the OData type generator’s customization-preservation logic; both could subtly change generated output and runtime coercion for list-backed fields. > > **Overview** > Adds a new `listField()` to `@proofkit/fmodata` for FileMaker return-delimited list strings, including newline normalization, optional nullability (`allowNull`), and optional per-item validation/transform (`itemValidator`), and exports it (and `ListFieldOptions`) from the public API. > > Extends OData typegen to support a new `typeOverride: "list"`, auto-import `listField`, and improves `preserveUserCustomizations` to keep user-authored `listField({...})` options when the field remains list-typed (and drop them when the type changes), with updated docs/UI/schema, snapshots, and new unit/e2e coverage; also splits Vitest config for default vs e2e runs and updates typegen test scripts. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 40db5f8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4e048d1 commit d154fe1

File tree

17 files changed

+791
-129
lines changed

17 files changed

+791
-129
lines changed

apps/docs/content/docs/typegen/config-odata.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,10 @@ Within each table's `fields` array, you can specify field-level overrides.
212212
description: "If true, this field will be excluded from generation",
213213
},
214214
typeOverride: {
215-
type: `"text" | "number" | "boolean" | "date" | "timestamp" | "container"`,
215+
type: `"text" | "number" | "boolean" | "date" | "timestamp" | "container" | "list"`,
216216
required: false,
217217
description:
218-
"Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container",
218+
"Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container, list",
219219
},
220220
}}
221221
/>
@@ -235,6 +235,7 @@ Override the inferred field type from metadata. The available options are:
235235
- `"date"`: Treats the field as a date field
236236
- `"timestamp"`: Treats the field as a timestamp field
237237
- `"container"`: Treats the field as a container field
238+
- `"list"`: Treats the field as a FileMaker return-delimited list via `listField()` (defaults to `string[]`)
238239

239240
<Callout type="info">
240241
The typegen tool will attempt to infer the correct field type from the OData metadata. Use `typeOverride` only when you need to override the inferred type.

packages/fmodata/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export {
9292
isColumnFunction,
9393
isNotNull,
9494
isNull,
95+
type ListFieldOptions,
96+
listField,
9597
lt,
9698
lte,
9799
matchesPattern,

packages/fmodata/src/orm/field-builders.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,72 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
77
*/
88
export type ContainerDbType = string & { readonly __container: true };
99

10+
const FILEMAKER_LIST_DELIMITER = "\r";
11+
const FILEMAKER_NEWLINE_REGEX = /\r\n|\n/g;
12+
13+
type ValidationResult<T> = { value: T } | { issues: readonly StandardSchemaV1.Issue[] };
14+
15+
export interface ListFieldOptions<TItem = string, TAllowNull extends boolean = false> {
16+
itemValidator?: StandardSchemaV1<unknown, TItem>;
17+
allowNull?: TAllowNull;
18+
}
19+
20+
function normalizeFileMakerNewlines(value: string): string {
21+
return value.replace(FILEMAKER_NEWLINE_REGEX, FILEMAKER_LIST_DELIMITER);
22+
}
23+
24+
function splitFileMakerList(value: string): string[] {
25+
const normalized = normalizeFileMakerNewlines(value);
26+
if (normalized === "") {
27+
return [];
28+
}
29+
return normalized.split(FILEMAKER_LIST_DELIMITER);
30+
}
31+
32+
function issue(message: string): StandardSchemaV1.Issue {
33+
return { message };
34+
}
35+
36+
function validateItemsWithSchema<TItem>(
37+
items: string[],
38+
itemValidator: StandardSchemaV1<unknown, TItem>,
39+
): ValidationResult<TItem[]> | Promise<ValidationResult<TItem[]>> {
40+
const validations = items.map((item) => itemValidator["~standard"].validate(item));
41+
const hasAsyncValidation = validations.some((result) => result instanceof Promise);
42+
43+
const finalize = (results: Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>) => {
44+
const transformed: TItem[] = [];
45+
const issues: StandardSchemaV1.Issue[] = [];
46+
47+
results.forEach((result, index) => {
48+
if ("issues" in result && result.issues) {
49+
for (const validationIssue of result.issues) {
50+
issues.push({
51+
...validationIssue,
52+
path: validationIssue.path ? [index, ...validationIssue.path] : [index],
53+
});
54+
}
55+
return;
56+
}
57+
if ("value" in result) {
58+
transformed.push(result.value);
59+
}
60+
});
61+
62+
if (issues.length > 0) {
63+
return { issues };
64+
}
65+
66+
return { value: transformed };
67+
};
68+
69+
if (hasAsyncValidation) {
70+
return Promise.all(validations).then((results) => finalize(results));
71+
}
72+
73+
return finalize(validations as Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>);
74+
}
75+
1076
/**
1177
* FieldBuilder provides a fluent API for defining table fields with type-safe metadata.
1278
* Supports chaining methods to configure primary keys, nullability, read-only status, entity IDs, and validators.
@@ -170,6 +236,140 @@ export function textField(): FieldBuilder<string | null, string | null, string |
170236
return new FieldBuilder<string | null, string | null, string | null, false>("text");
171237
}
172238

239+
type ListOutput<TItem, TAllowNull extends boolean> = TAllowNull extends true ? TItem[] | null : TItem[];
240+
type ListInput<TItem, TAllowNull extends boolean> = TAllowNull extends true ? TItem[] | null : TItem[];
241+
242+
/**
243+
* Create a text-backed FileMaker return-delimited list field.
244+
* By default, null/empty input is normalized to an empty array (`allowNull: false`).
245+
*
246+
* @example
247+
* listField() // output: string[], input: string[]
248+
* listField({ allowNull: true }) // output/input: string[] | null
249+
* listField({ itemValidator: z.coerce.number().int() }) // output/input: number[]
250+
*/
251+
export function listField(): FieldBuilder<string[], string[], string | null, false>;
252+
export function listField<TAllowNull extends boolean = false>(
253+
options: ListFieldOptions<string, TAllowNull>,
254+
): FieldBuilder<ListOutput<string, TAllowNull>, ListInput<string, TAllowNull>, string | null, false>;
255+
export function listField<TItem, TAllowNull extends boolean = false>(options: {
256+
itemValidator: StandardSchemaV1<unknown, TItem>;
257+
allowNull?: TAllowNull;
258+
}): FieldBuilder<ListOutput<TItem, TAllowNull>, ListInput<TItem, TAllowNull>, string | null, false>;
259+
export function listField<TItem = string, TAllowNull extends boolean = false>(
260+
options?: ListFieldOptions<TItem, TAllowNull>,
261+
): FieldBuilder<ListOutput<TItem, TAllowNull>, ListInput<TItem, TAllowNull>, string | null, false> {
262+
const allowNull = options?.allowNull ?? false;
263+
const itemValidator = options?.itemValidator as StandardSchemaV1<unknown, TItem> | undefined;
264+
265+
const readListSchema: StandardSchemaV1<string | null, ListOutput<TItem, TAllowNull>> = {
266+
"~standard": {
267+
version: 1,
268+
vendor: "proofkit",
269+
validate(input) {
270+
if (input === null || input === undefined || input === "") {
271+
return { value: (allowNull ? null : []) as ListOutput<TItem, TAllowNull> };
272+
}
273+
274+
if (typeof input !== "string") {
275+
return { issues: [issue("Expected a FileMaker list string or null")] };
276+
}
277+
278+
const items = splitFileMakerList(input);
279+
if (!itemValidator) {
280+
return { value: items as ListOutput<TItem, TAllowNull> };
281+
}
282+
283+
const validatedItems = validateItemsWithSchema(items, itemValidator);
284+
if (validatedItems instanceof Promise) {
285+
return validatedItems.then((result) => {
286+
if ("issues" in result) {
287+
return result;
288+
}
289+
return { value: result.value as ListOutput<TItem, TAllowNull> };
290+
});
291+
}
292+
293+
if ("issues" in validatedItems) {
294+
return validatedItems;
295+
}
296+
297+
return { value: validatedItems.value as ListOutput<TItem, TAllowNull> };
298+
},
299+
},
300+
};
301+
302+
const writeListSchema: StandardSchemaV1<ListInput<TItem, TAllowNull>, string | null> = {
303+
"~standard": {
304+
version: 1,
305+
vendor: "proofkit",
306+
validate(input) {
307+
if (input === null || input === undefined) {
308+
return { value: allowNull ? null : "" };
309+
}
310+
311+
if (!Array.isArray(input)) {
312+
return { issues: [issue("Expected an array for FileMaker list field input")] };
313+
}
314+
315+
if (!itemValidator) {
316+
const hasNonStringItem = input.some((item) => typeof item !== "string");
317+
if (hasNonStringItem) {
318+
return { issues: [issue("Expected all list items to be strings without an itemValidator")] };
319+
}
320+
const serialized = input.map((item) => normalizeFileMakerNewlines(item)).join(FILEMAKER_LIST_DELIMITER);
321+
return { value: serialized };
322+
}
323+
324+
const validateInputItems = input.map((item) => itemValidator["~standard"].validate(item));
325+
const hasAsyncValidation = validateInputItems.some((result) => result instanceof Promise);
326+
327+
const serializeValidated = (
328+
results: Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>,
329+
): { value: string } | { issues: readonly StandardSchemaV1.Issue[] } => {
330+
const validatedItems: TItem[] = [];
331+
const issues: StandardSchemaV1.Issue[] = [];
332+
333+
results.forEach((result, index) => {
334+
if ("issues" in result && result.issues) {
335+
for (const validationIssue of result.issues) {
336+
issues.push({
337+
...validationIssue,
338+
path: validationIssue.path ? [index, ...validationIssue.path] : [index],
339+
});
340+
}
341+
return;
342+
}
343+
344+
if ("value" in result) {
345+
validatedItems.push(result.value);
346+
}
347+
});
348+
349+
if (issues.length > 0) {
350+
return { issues };
351+
}
352+
353+
const serialized = validatedItems
354+
.map((item) => normalizeFileMakerNewlines(typeof item === "string" ? item : String(item)))
355+
.join(FILEMAKER_LIST_DELIMITER);
356+
return { value: serialized };
357+
};
358+
359+
if (hasAsyncValidation) {
360+
return Promise.all(validateInputItems).then((results) => serializeValidated(results));
361+
}
362+
363+
return serializeValidated(
364+
validateInputItems as Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>,
365+
);
366+
},
367+
},
368+
};
369+
370+
return textField().readValidator(readListSchema).writeValidator(writeListSchema);
371+
}
372+
173373
/**
174374
* Create a number field (Edm.Decimal in FileMaker OData).
175375
* By default, number fields are nullable.

packages/fmodata/src/orm/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export {
99
containerField,
1010
dateField,
1111
FieldBuilder,
12+
type ListFieldOptions,
13+
listField,
1214
numberField,
1315
textField,
1416
timeField,

packages/fmodata/tests/orm-api.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
gt,
99
isColumn,
1010
isColumnFunction,
11+
listField,
1112
matchesPattern,
1213
numberField,
1314
or,
@@ -67,6 +68,61 @@ describe("ORM API", () => {
6768
expect(config.outputValidator).toBe(readValidator);
6869
expect(config.inputValidator).toBe(writeValidator);
6970
});
71+
72+
it("should normalize list fields to empty arrays by default", async () => {
73+
const field = listField();
74+
const config = field._getConfig();
75+
76+
const readValidator = config.outputValidator;
77+
const writeValidator = config.inputValidator;
78+
expect(readValidator).toBeDefined();
79+
expect(writeValidator).toBeDefined();
80+
81+
const readNull = await readValidator?.["~standard"].validate(null);
82+
expect(readNull).toEqual({ value: [] });
83+
84+
const readNewlines = await readValidator?.["~standard"].validate("A\r\nB\nC");
85+
expect(readNewlines).toEqual({ value: ["A", "B", "C"] });
86+
87+
const writeArray = await writeValidator?.["~standard"].validate(["A", "B", "C"]);
88+
expect(writeArray).toEqual({ value: "A\rB\rC" });
89+
});
90+
91+
it("should allow nullable list fields via allowNull option", async () => {
92+
const field = listField({ allowNull: true });
93+
const config = field._getConfig();
94+
95+
const readNull = await config.outputValidator?.["~standard"].validate(null);
96+
expect(readNull).toEqual({ value: null });
97+
98+
const writeNull = await config.inputValidator?.["~standard"].validate(null);
99+
expect(writeNull).toEqual({ value: null });
100+
});
101+
102+
it("should validate and transform list items with itemValidator", async () => {
103+
const field = listField({ itemValidator: z.coerce.number().int() });
104+
const config = field._getConfig();
105+
106+
const readResult = await config.outputValidator?.["~standard"].validate("1\r2\r3");
107+
expect(readResult).toEqual({ value: [1, 2, 3] });
108+
109+
const writeResult = await config.inputValidator?.["~standard"].validate([1, 2, 3]);
110+
expect(writeResult).toEqual({ value: "1\r2\r3" });
111+
});
112+
113+
it("should reject undefined list items without throwing when no itemValidator is provided", async () => {
114+
const field = listField();
115+
const config = field._getConfig();
116+
117+
const writeResult = await config.inputValidator?.["~standard"].validate([
118+
"A",
119+
undefined,
120+
"C",
121+
] as unknown as string[]);
122+
expect(writeResult).toEqual({
123+
issues: [{ message: "Expected all list items to be strings without an itemValidator" }],
124+
});
125+
});
70126
});
71127

72128
describe("Table Definition", () => {

packages/fmodata/tests/typescript.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
fmTableOccurrence,
2626
getTableColumns,
2727
type InferTableSchema,
28+
listField,
2829
numberField,
2930
textField,
3031
} from "@proofkit/fmodata";
@@ -214,6 +215,27 @@ describe("fmodata", () => {
214215
expect(queryBuilder.getQueryString).toBeDefined();
215216
expect(typeof queryBuilder.getQueryString()).toBe("string");
216217
});
218+
219+
it("should infer listField nullability and item types from options", () => {
220+
const table = fmTableOccurrence("ListTypes", {
221+
tags: listField(),
222+
optionalTags: listField({ allowNull: true }),
223+
ids: listField({ itemValidator: z.coerce.number().int() }),
224+
optionalIds: listField({ itemValidator: z.coerce.number().int(), allowNull: true }),
225+
});
226+
227+
expectTypeOf(table.tags).toEqualTypeOf<typeof table.tags>();
228+
expectTypeOf(table.tags._phantomOutput).toEqualTypeOf<string[]>();
229+
expectTypeOf(table.optionalTags._phantomOutput).toEqualTypeOf<string[] | null>();
230+
expectTypeOf(table.ids._phantomOutput).toEqualTypeOf<number[]>();
231+
expectTypeOf(table.optionalIds._phantomOutput).toEqualTypeOf<number[] | null>();
232+
233+
const _typeChecks = () => {
234+
// @ts-expect-error - listField options must be an object when options are provided
235+
listField(z.string());
236+
};
237+
_typeChecks;
238+
});
217239
});
218240

219241
describe("BaseTable and TableOccurrence", () => {

packages/typegen/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
"dev": "pnpm build:watch",
99
"dev:ui": "concurrently -n \"web,api\" -c \"cyan,magenta\" \"pnpm -C web dev\" \"pnpm run dev:api\"",
1010
"dev:api": "concurrently -n \"build,server\" -c \"cyan,magenta\" \"pnpm build:watch\" \"nodemon --watch dist/esm --delay 1 --exec 'node dist/esm/cli.js ui --port 3141 --no-open'\"",
11-
"test": "vitest run",
12-
"test:watch": "vitest --watch",
13-
"test:e2e": "doppler run -- vitest run tests/e2e",
11+
"test": "vitest run --config vitest.config.ts",
12+
"test:watch": "vitest --watch --config vitest.config.ts",
13+
"test:e2e": "doppler run -- vitest run --config vitest.e2e.config.ts",
1414
"typecheck": "tsc --noEmit",
1515
"build": "pnpm -C web build && pnpm vite build && node scripts/build-copy.js && publint --strict",
1616
"build:watch": "vite build --watch",

0 commit comments

Comments
 (0)