diff --git a/.changeset/single-ref-maxselect.md b/.changeset/single-ref-maxselect.md new file mode 100644 index 0000000..98625a7 --- /dev/null +++ b/.changeset/single-ref-maxselect.md @@ -0,0 +1,12 @@ +--- +"@karnak19/pbkit": patch +"@karnak19/pbkit-zod": patch +--- + +Fix single relation/select/file fields being typed as arrays + +PocketBase stores single-value relation, select, and file fields with `maxSelect: 0`, not `1`. The generators only treated `maxSelect === 1` as single, so these fields were incorrectly typed as arrays (`string[]`, `(...)[]`, `z.array(...)`). + +Generation now follows PocketBase's own rule — a field is multiple only when `maxSelect > 1` — via a shared `isMultipleField` helper used by the type generator, relation extraction, and the Zod plugin. + +Closes #28 diff --git a/packages/pbkit-zod/src/generate.ts b/packages/pbkit-zod/src/generate.ts index 85c2f35..6c262a1 100644 --- a/packages/pbkit-zod/src/generate.ts +++ b/packages/pbkit-zod/src/generate.ts @@ -5,7 +5,7 @@ import type { CollectionsConfig, } from "@karnak19/pbkit"; import type { PbkitPlugin, PluginContext, PluginOutputFile } from "@karnak19/pbkit"; -import { isCollectionExcluded } from "@karnak19/pbkit"; +import { isCollectionExcluded, isMultipleField } from "@karnak19/pbkit"; function pascalCase(name: string): string { return name @@ -66,25 +66,24 @@ function fieldToZod(field: CollectionField): string { schema = "z.string().datetime({ offset: true })"; break; case "select": { + const multiple = isMultipleField(field); const values = field.options.values; if (values && values.length > 0) { const union = values.map((v) => JSON.stringify(v)).join(", "); - schema = field.options.maxSelect === 1 - ? `z.enum([${union}])` - : `z.array(z.enum([${union}]))`; + schema = multiple + ? `z.array(z.enum([${union}]))` + : `z.enum([${union}])`; if (field.options.maxSelect && field.options.maxSelect > 1) { schema += `.max(${field.options.maxSelect})`; } } else { - schema = field.options.maxSelect === 1 ? "z.string()" : "z.array(z.string())"; + schema = multiple ? "z.array(z.string())" : "z.string()"; } break; } case "relation": - schema = field.options.maxSelect === 1 ? "z.string()" : "z.array(z.string())"; - break; case "file": - schema = field.options.maxSelect === 1 ? "z.string()" : "z.array(z.string())"; + schema = isMultipleField(field) ? "z.array(z.string())" : "z.string()"; break; case "json": schema = "z.unknown()"; diff --git a/packages/pbkit-zod/src/test/zod.test.ts b/packages/pbkit-zod/src/test/zod.test.ts index b488d0d..2538f7d 100644 --- a/packages/pbkit-zod/src/test/zod.test.ts +++ b/packages/pbkit-zod/src/test/zod.test.ts @@ -129,6 +129,30 @@ describe("generateZod", () => { }); }); +describe("generateZod single-value fields with maxSelect=0 (regression #28)", () => { + // PocketBase stores single relation/select/file fields with maxSelect: 0. + const ir0 = parseJson([ + { + id: "c1", + name: "things", + type: "base", + fields: [ + { name: "ref", type: "relation", system: false, required: false, maxSelect: 0, collectionId: "abc" }, + { name: "status", type: "select", system: false, required: false, maxSelect: 0, values: ["a", "b"] }, + { name: "doc", type: "file", system: false, required: false, maxSelect: 0 }, + ], + }, + ]); + const out = generateZod(ir0, { ir: ir0, typesImport: "./types.gen", sdkImport: "./sdk.gen" }); + + test("relation/select/file are single, not arrays", () => { + expect(out).toContain("ref: z.string()"); + expect(out).toContain('status: z.enum(["a", "b"])'); + expect(out).toContain("doc: z.string()"); + expect(out).not.toContain("z.array("); + }); +}); + describe("zodPlugin", () => { test("has correct name", () => { expect(zodPlugin.name).toBe("@karnak19/pbkit-zod"); diff --git a/packages/pbkit/src/index.ts b/packages/pbkit/src/index.ts index 85b6396..bbb5519 100644 --- a/packages/pbkit/src/index.ts +++ b/packages/pbkit/src/index.ts @@ -7,6 +7,7 @@ export { normalizeCollection, normalizeField, extractRelations, + isMultipleField, } from "./schema-parser"; export type { diff --git a/packages/pbkit/src/schema-parser/index.ts b/packages/pbkit/src/schema-parser/index.ts index 583a03a..ca0f2ef 100644 --- a/packages/pbkit/src/schema-parser/index.ts +++ b/packages/pbkit/src/schema-parser/index.ts @@ -2,7 +2,7 @@ export { parseJson, parseJsonFile } from "./parse-json" export { parseApi } from "./parse-api" export type { ApiParseOptions } from "./parse-api" export { parseSqlite } from "./parse-sqlite" -export { normalizeSchema, normalizeCollection, normalizeField, extractRelations } from "./normalize" +export { normalizeSchema, normalizeCollection, normalizeField, extractRelations, isMultipleField } from "./normalize" export type { CollectionType, FieldType, diff --git a/packages/pbkit/src/schema-parser/normalize.ts b/packages/pbkit/src/schema-parser/normalize.ts index f704e7f..93661f5 100644 --- a/packages/pbkit/src/schema-parser/normalize.ts +++ b/packages/pbkit/src/schema-parser/normalize.ts @@ -10,6 +10,15 @@ import type { const FIELD_CORE_KEYS = new Set(["id", "name", "type", "system", "required"]) +/** + * PocketBase treats a relation/select/file field as "multiple" only when + * maxSelect is greater than 1. A maxSelect of 0 or 1 (or unset) is a single + * value — matching PocketBase's own `IsMultiple()` (maxSelect > 1). + */ +export function isMultipleField(field: CollectionField): boolean { + return (field.options.maxSelect ?? 0) > 1 +} + export function normalizeField(raw: Record): CollectionField { const options: FieldOptions = {} for (const [key, value] of Object.entries(raw)) { @@ -57,7 +66,7 @@ export function extractRelations(collections: CollectionSchema[]): Relation[] { collectionName: collection.name, targetCollectionId: targetId, targetCollectionName: targetName, - multiple: field.options.maxSelect !== 1, + multiple: isMultipleField(field), cascadeDelete: field.options.cascadeDelete ?? false, }) } diff --git a/packages/pbkit/src/test/type-generator.test.ts b/packages/pbkit/src/test/type-generator.test.ts index 4d7ba1b..844c605 100644 --- a/packages/pbkit/src/test/type-generator.test.ts +++ b/packages/pbkit/src/test/type-generator.test.ts @@ -64,6 +64,23 @@ describe("fieldTypeToTs", () => { const f = field("file", { maxSelect: 3 }) expect(fieldTypeToTs(f, {})).toBe("string[]") }) + + // Regression (#28): PocketBase stores single relation/select/file fields with + // maxSelect: 0, not 1. These must still be treated as single values. + test("relation maxSelect=0 → string", () => { + const f = field("relation", { maxSelect: 0, collectionId: "abc" }) + expect(fieldTypeToTs(f, {})).toBe("string") + }) + + test("select maxSelect=0 → union literal (single)", () => { + const f = field("select", { maxSelect: 0, values: ["draft", "published"] }) + expect(fieldTypeToTs(f, {})).toBe('"draft" | "published"') + }) + + test("file maxSelect=0 → string", () => { + const f = field("file", { maxSelect: 0 }) + expect(fieldTypeToTs(f, {})).toBe("string") + }) }) describe("generate", () => { diff --git a/packages/pbkit/src/type-generator/generate.ts b/packages/pbkit/src/type-generator/generate.ts index d427e17..8de9b7e 100644 --- a/packages/pbkit/src/type-generator/generate.ts +++ b/packages/pbkit/src/type-generator/generate.ts @@ -1,4 +1,5 @@ import type { SchemaIR, CollectionSchema, CollectionField } from "../schema-parser" +import { isMultipleField } from "../schema-parser" import type { GenerateOptions } from "./types" import { isCollectionExcluded, type CollectionsConfig } from "../config" @@ -26,17 +27,17 @@ export function fieldTypeToTs(field: CollectionField, options: GenerateOptions): case "date": return options.dateStrings === false ? "Date" : "string" case "select": { + const multiple = isMultipleField(field) const values = field.options.values if (values && values.length > 0) { const union = values.map(v => JSON.stringify(v)).join(" | ") - return field.options.maxSelect === 1 ? union : `(${union})[]` + return multiple ? `(${union})[]` : union } - return field.options.maxSelect === 1 ? "string" : "string[]" + return multiple ? "string[]" : "string" } case "relation": - return field.options.maxSelect === 1 ? "string" : "string[]" case "file": - return field.options.maxSelect === 1 ? "string" : "string[]" + return isMultipleField(field) ? "string[]" : "string" case "json": return "unknown" case "password":