From 7c0a5676d076344f08d00de5e38dffe264a8388c Mon Sep 17 00:00:00 2001 From: dqn Date: Wed, 1 Apr 2026 18:22:01 +0900 Subject: [PATCH 01/17] feat(tailordb,resolver): add createTable object-literal API and resolver descriptor support Add createTable() and timestampFields() as an alternative to the fluent db.type() API for defining TailorDB types using plain object literals. This is a reworked version of the closed PR #645 (createType), renamed to createTable. Extend createResolver() to accept object-literal field descriptors ({ kind: "string" }) alongside the existing fluent t.string() API in both input and output parameters. Fluent and descriptor styles can be mixed freely. --- packages/sdk/src/configure/services/index.ts | 2 + .../configure/services/resolver/descriptor.ts | 190 ++++ .../services/resolver/resolver.test.ts | 195 ++++ .../configure/services/resolver/resolver.ts | 143 ++- .../services/tailordb/createTable.test.ts | 845 ++++++++++++++++++ .../services/tailordb/createTable.ts | 507 +++++++++++ .../src/configure/services/tailordb/index.ts | 1 + .../src/configure/services/tailordb/schema.ts | 4 +- packages/sdk/src/configure/types/type.ts | 3 +- 9 files changed, 1852 insertions(+), 38 deletions(-) create mode 100644 packages/sdk/src/configure/services/resolver/descriptor.ts create mode 100644 packages/sdk/src/configure/services/tailordb/createTable.test.ts create mode 100644 packages/sdk/src/configure/services/tailordb/createTable.ts diff --git a/packages/sdk/src/configure/services/index.ts b/packages/sdk/src/configure/services/index.ts index 037468709..c9b6a974d 100644 --- a/packages/sdk/src/configure/services/index.ts +++ b/packages/sdk/src/configure/services/index.ts @@ -1,6 +1,8 @@ export * from "./auth"; export { db, + createTable, + timestampFields, type TailorDBType, type TailorAnyDBType, type TailorDBField, diff --git a/packages/sdk/src/configure/services/resolver/descriptor.ts b/packages/sdk/src/configure/services/resolver/descriptor.ts new file mode 100644 index 000000000..819eb93fe --- /dev/null +++ b/packages/sdk/src/configure/services/resolver/descriptor.ts @@ -0,0 +1,190 @@ +import { type AllowedValues, type AllowedValuesOutput } from "@/configure/types/field"; +import { type TailorAnyField, type TailorField, createTailorField } from "@/configure/types/type"; +import type { InferFieldsOutput } from "@/configure/types/helpers"; +import type { TailorFieldType, TailorToTs, FieldOptions } from "@/configure/types/types"; +import type { FieldValidateInput, ValidateConfig } from "@/configure/types/validation"; + +type CommonFieldOptions = { + optional?: boolean; + array?: boolean; + description?: string; +}; + +const kindToFieldType = { + string: "string", + int: "integer", + float: "float", + bool: "boolean", + uuid: "uuid", + decimal: "decimal", + date: "date", + datetime: "datetime", + time: "time", + enum: "enum", + object: "nested", +} as const satisfies Record; + +type KindToFieldType = typeof kindToFieldType; + +type KindToTsType = { + [K in keyof KindToFieldType as K extends "enum" | "object" + ? never + : K]: TailorToTs[KindToFieldType[K]]; +}; + +type ValidatableOptions = { + validate?: FieldValidateInput | FieldValidateInput[]; +}; + +type SimpleDescriptor = CommonFieldOptions & + ValidatableOptions & { + kind: K; + }; + +type EnumDescriptor = CommonFieldOptions & + ValidatableOptions> & { + kind: "enum"; + values: V; + typeName?: string; + }; + +type ObjectDescriptor = CommonFieldOptions & { + kind: "object"; + fields: Record; + typeName?: string; +}; + +export type ResolverFieldDescriptor = + | SimpleDescriptor<"string"> + | SimpleDescriptor<"int"> + | SimpleDescriptor<"float"> + | SimpleDescriptor<"bool"> + | SimpleDescriptor<"uuid"> + | SimpleDescriptor<"decimal"> + | SimpleDescriptor<"date"> + | SimpleDescriptor<"datetime"> + | SimpleDescriptor<"time"> + | EnumDescriptor + | ObjectDescriptor; + +export type ResolverFieldEntry = ResolverFieldDescriptor | TailorAnyField; + +// --- Type-level output inference --- + +type DescriptorBaseOutput = D extends { + kind: "enum"; + values: infer V; +} + ? V extends AllowedValues + ? AllowedValuesOutput + : string + : D extends { kind: "object"; fields: infer F } + ? F extends Record + ? InferFieldsOutput> + : Record + : D["kind"] extends keyof KindToTsType + ? KindToTsType[D["kind"]] + : unknown; + +type ApplyArrayAndOptional = D extends { array: true } + ? D extends { optional: true } + ? T[] | null + : T[] + : D extends { optional: true } + ? T | null + : T; + +export type ResolverDescriptorOutput = ApplyArrayAndOptional< + DescriptorBaseOutput, + D +>; + +type DescriptorDefined = { + type: D["kind"] extends keyof KindToFieldType ? KindToFieldType[D["kind"]] : TailorFieldType; + array: D extends { array: true } ? true : false; +}; + +export type ResolvedResolverField = E extends ResolverFieldDescriptor + ? TailorField, ResolverDescriptorOutput> + : E; + +export type ResolvedResolverFieldMap> = { + [K in keyof M]: ResolvedResolverField; +}; + +// --- Runtime conversion --- + +function isPassthroughField(entry: ResolverFieldEntry): entry is TailorAnyField { + return !("kind" in entry); +} + +export function isResolverFieldDescriptor( + entry: ResolverFieldEntry, +): entry is ResolverFieldDescriptor { + return "kind" in entry; +} + +function isValidateConfig(v: unknown): v is ValidateConfig { + return Array.isArray(v) && v.length === 2 && typeof v[1] === "string"; +} + +export function resolveResolverField(entry: ResolverFieldEntry): TailorAnyField { + if (isPassthroughField(entry)) { + return entry; + } + return buildResolverField(entry); +} + +export function resolveResolverFieldMap( + entries: Record, +): Record { + // Fast path: if no descriptors are present, return the original object as-is + const hasDescriptors = Object.values(entries).some(isResolverFieldDescriptor); + if (!hasDescriptors) { + return entries as Record; + } + return Object.fromEntries( + Object.entries(entries).map(([key, entry]) => [key, resolveResolverField(entry)]), + ); +} + +function buildResolverField(descriptor: ResolverFieldDescriptor): TailorAnyField { + const fieldType = kindToFieldType[descriptor.kind]; + const options: FieldOptions = { + ...(descriptor.optional === true && { optional: true as const }), + ...(descriptor.array === true && { array: true as const }), + }; + const values = descriptor.kind === "enum" ? descriptor.values : undefined; + const nestedFields = + descriptor.kind === "object" ? resolveResolverFieldMap(descriptor.fields) : undefined; + + let field: TailorAnyField = createTailorField(fieldType, options, nestedFields, values); + + if (descriptor.description !== undefined) { + field = field.description(descriptor.description); + } + + if ( + (descriptor.kind === "enum" || descriptor.kind === "object") && + descriptor.typeName !== undefined + ) { + // oxlint-disable-next-line no-explicit-any -- typeName() is only present on enum/nested field interfaces + field = (field as any).typeName(descriptor.typeName); + } + + if (descriptor.kind === "object") { + return field; + } + + if (descriptor.validate !== undefined) { + if (Array.isArray(descriptor.validate) && !isValidateConfig(descriptor.validate)) { + // oxlint-disable-next-line no-explicit-any -- union of typed FieldValidateInput variants; widen to any + field = field.validate(...(descriptor.validate as any)); + } else { + // oxlint-disable-next-line no-explicit-any -- union of typed FieldValidateInput variants; widen to any + field = field.validate(descriptor.validate as any); + } + } + + return field; +} diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index a87e823e5..4f32672fd 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -732,4 +732,199 @@ describe("createResolver", () => { expect(resolver.description).toBeUndefined(); }); }); + + describe("descriptor-based fields", () => { + test("descriptor input fields infer correct types", () => { + const resolver = createResolver({ + name: "descriptorInput", + operation: "query", + input: { + name: { kind: "string" }, + age: { kind: "int", optional: true }, + }, + output: t.bool(), + body: () => true, + }); + expect(resolver.input!.name.type).toBe("string"); + expect(resolver.input!.name.metadata.required).toBe(true); + expect(resolver.input!.age.type).toBe("integer"); + expect(resolver.input!.age.metadata.required).toBe(false); + }); + + test("descriptor output field infers correct return type", () => { + createResolver({ + name: "descriptorOutput", + operation: "query", + input: { + a: { kind: "int" }, + b: { kind: "int" }, + }, + output: { kind: "int", description: "Sum" }, + body: ({ input }) => { + expectTypeOf(input.a).toEqualTypeOf(); + return input.a + input.b; + }, + }); + }); + + test("descriptor output Record infers correct return type", () => { + createResolver({ + name: "descriptorRecordOutput", + operation: "mutation", + input: { + id: { kind: "uuid" }, + }, + output: { + success: { kind: "bool" }, + message: { kind: "string" }, + }, + body: ({ input }) => { + expectTypeOf(input.id).toEqualTypeOf(); + return { success: true, message: "done" }; + }, + }); + }); + + test("mixed fluent and descriptor fields work together", () => { + createResolver({ + name: "mixed", + operation: "query", + input: { + a: { kind: "int" }, + b: t.int(), + }, + output: t.int(), + body: ({ input }) => { + expectTypeOf(input.a).toEqualTypeOf(); + expectTypeOf(input.b).toEqualTypeOf(); + return input.a + input.b; + }, + }); + }); + + test("enum descriptor infers literal union type", () => { + const resolver = createResolver({ + name: "enumDesc", + operation: "query", + input: { + role: { kind: "enum", values: ["ADMIN", "USER"] }, + }, + output: { kind: "string" }, + body: ({ input }) => input.role, + }); + expect(resolver.input!.role.type).toBe("enum"); + expect(resolver.input!.role.metadata.allowedValues).toEqual([ + { value: "ADMIN", description: "" }, + { value: "USER", description: "" }, + ]); + }); + + test("object descriptor infers nested type", () => { + const resolver = createResolver({ + name: "objectDesc", + operation: "query", + input: { + user: { + kind: "object", + fields: { + name: { kind: "string" }, + age: { kind: "int", optional: true }, + }, + }, + }, + output: { kind: "string" }, + body: ({ input }) => input.user.name, + }); + expect(resolver.input!.user.type).toBe("nested"); + const nestedFields = resolver.input!.user.fields; + expect(nestedFields.name.type).toBe("string"); + expect(nestedFields.age.type).toBe("integer"); + expect(nestedFields.age.metadata.required).toBe(false); + }); + + test("array descriptor infers array type", () => { + createResolver({ + name: "arrayDesc", + operation: "query", + input: { + tags: { kind: "string", array: true }, + }, + output: { kind: "int" }, + body: ({ input }) => { + expectTypeOf(input.tags).toEqualTypeOf(); + return input.tags.length; + }, + }); + }); + + test("descriptor input resolves to TailorField at runtime", () => { + const resolver = createResolver({ + name: "runtimeCheck", + operation: "query", + input: { + name: { kind: "string", description: "User name" }, + count: { kind: "int" }, + }, + output: { kind: "bool" }, + body: () => true, + }); + expect(resolver.input).toBeDefined(); + expect(resolver.input!.name.type).toBe("string"); + expect(resolver.input!.name.metadata.description).toBe("User name"); + expect(resolver.input!.count.type).toBe("integer"); + expect(resolver.output.type).toBe("boolean"); + }); + + test("descriptor with validate sets metadata correctly", () => { + const validate: [({ value }: { value: number }) => boolean, string] = [ + ({ value }) => value >= 0, + "Must be non-negative", + ]; + const resolver = createResolver({ + name: "validateCheck", + operation: "query", + input: { + age: { + kind: "int", + validate, + }, + }, + output: { kind: "bool" }, + body: () => true, + }); + expect(resolver.input!.age.metadata.validate).toBeDefined(); + expect(resolver.input!.age.metadata.validate!.length).toBe(1); + }); + + test("decimal descriptor outputs string type", () => { + createResolver({ + name: "decimalDesc", + operation: "query", + input: { + amount: { kind: "decimal" }, + }, + output: { kind: "decimal" }, + body: ({ input }) => { + expectTypeOf(input.amount).toEqualTypeOf(); + return input.amount; + }, + }); + }); + + test("all-descriptor resolver is compatible with ResolverInput", () => { + const resolver = createResolver({ + name: "allDescriptor", + operation: "query", + input: { + id: { kind: "uuid" }, + name: { kind: "string" }, + }, + output: { + found: { kind: "bool" }, + }, + body: () => ({ found: true }), + }); + expectTypeOf(resolver).toExtend(); + }); + }); }); diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index ffa24157f..cd070e09c 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -1,42 +1,82 @@ import { t } from "@/configure/types/type"; import { brandValue } from "@/utils/brand"; +import { + type ResolverFieldEntry, + type ResolverFieldDescriptor, + type ResolvedResolverFieldMap, + type ResolverDescriptorOutput, + isResolverFieldDescriptor, + resolveResolverFieldMap, + resolveResolverField, +} from "./descriptor"; import type { TailorAnyField, TailorUser } from "@/configure/types"; import type { TailorEnv } from "@/configure/types/env"; import type { InferFieldsOutput, output } from "@/configure/types/helpers"; import type { TailorField } from "@/configure/types/type"; +import type { TailorFieldType } from "@/configure/types/types"; import type { ResolverInput } from "@/types/resolver.generated"; -type Context | undefined> = { - input: Input extends Record ? InferFieldsOutput : never; +type ResolvedInput = + Input extends Record ? ResolvedResolverFieldMap : undefined; + +type Context = { + input: Input extends Record + ? InferFieldsOutput> + : never; user: TailorUser; env: TailorEnv; }; type OutputType = O extends TailorAnyField ? output - : O extends Record - ? InferFieldsOutput - : never; + : O extends ResolverFieldDescriptor + ? ResolverDescriptorOutput + : O extends Record + ? InferFieldsOutput> + : never; /** * Normalized output type that preserves generic type information. * - If Output is already a TailorField, use it as-is + * - If Output is a descriptor, resolve it to a TailorField * - If Output is a Record of fields, wrap it as a nested TailorField */ -type NormalizedOutput> = - Output extends TailorAnyField - ? Output +type KindToFieldType = { + string: "string"; + int: "integer"; + float: "float"; + bool: "boolean"; + uuid: "uuid"; + decimal: "decimal"; + date: "date"; + datetime: "datetime"; + time: "time"; + enum: "enum"; + object: "nested"; +}; + +type NormalizedOutput = Output extends TailorAnyField + ? Output + : Output extends ResolverFieldDescriptor + ? TailorField< + { + type: Output["kind"] extends keyof KindToFieldType + ? KindToFieldType[Output["kind"]] + : TailorFieldType; + array: Output extends { array: true } ? true : false; + }, + ResolverDescriptorOutput + > : TailorField< { type: "nested"; array: false }, - InferFieldsOutput>> + InferFieldsOutput< + ResolvedResolverFieldMap>> + > >; -type ResolverReturn< - Input extends Record | undefined, - Output extends TailorAnyField | Record, -> = Omit & +type ResolverReturn = Omit & Readonly<{ - input?: Input; + input?: ResolvedInput; output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; }>; @@ -48,8 +88,11 @@ type ResolverReturn< * `user` (TailorUser with id, type, workspaceId, attributes, attributeList), and `env` (TailorEnv). * The return value of `body` must match the `output` type. * - * `output` accepts either a single TailorField (e.g. `t.string()`) or a - * Record of fields (e.g. `{ name: t.string(), age: t.int() }`). + * `input` and `output` fields accept either fluent API fields (e.g. `t.string()`) + * or object-literal descriptors (e.g. `{ kind: "string" }`). Both styles can be mixed. + * + * `output` accepts either a single field (fluent or descriptor), or a + * Record of fields (e.g. `{ name: t.string(), age: { kind: "int" } }`). * * `publishEvents` enables publishing execution events for this resolver. * If not specified, this is automatically set to true when an executor uses this resolver @@ -62,26 +105,34 @@ type ResolverReturn< * @example * import { createResolver, t } from "@tailor-platform/sdk"; * + * // Fluent API style * export default createResolver({ * name: "getUser", * operation: "query", * input: { * id: t.string(), * }, - * body: async ({ input, user }) => { - * const db = getDB("tailordb"); - * const result = await db.selectFrom("User").selectAll().where("id", "=", input.id).executeTakeFirst(); - * return { name: result?.name ?? "", email: result?.email ?? "" }; + * body: async ({ input }) => ({ name: "Alice" }), + * output: t.object({ name: t.string() }), + * }); + * + * // Object-literal descriptor style + * export default createResolver({ + * name: "add", + * operation: "query", + * input: { + * a: { kind: "int", description: "First number" }, + * b: { kind: "int", description: "Second number" }, * }, - * output: t.object({ - * name: t.string(), - * email: t.string(), - * }), + * body: ({ input }) => input.a + input.b, + * output: { kind: "int", description: "Sum" }, * }); */ export function createResolver< - Input extends Record | undefined = undefined, - Output extends TailorAnyField | Record = TailorAnyField, + Input extends Record | undefined = undefined, + Output extends TailorAnyField | ResolverFieldDescriptor | Record = + | TailorAnyField + | ResolverFieldDescriptor, >( config: Omit & Readonly<{ @@ -90,26 +141,48 @@ export function createResolver< body: (context: Context) => OutputType | Promise>; }>, ): ResolverReturn { - // Check if output is already a TailorField using duck typing. - // TailorField has `type: string` (e.g., "uuid", "string"), while - // Record either lacks `type` or has TailorField as value. - const isTailorField = (obj: unknown): obj is TailorAnyField => - typeof obj === "object" && - obj !== null && - "type" in obj && - typeof (obj as { type: unknown }).type === "string"; + // Resolve input fields: convert descriptors to TailorField instances + const resolvedInput = config.input + ? resolveResolverFieldMap(config.input as Record) + : undefined; - const normalizedOutput = isTailorField(config.output) ? config.output : t.object(config.output); + // Resolve output: handle TailorField, descriptor, or Record + const normalizedOutput = resolveOutput(config.output); return brandValue( { ...config, + input: resolvedInput, output: normalizedOutput, } as ResolverReturn, "resolver", ); } +function resolveOutput( + output: TailorAnyField | ResolverFieldDescriptor | Record, +): TailorAnyField { + // Check if it's a descriptor (has `kind` property but not a TailorField) + if (isResolverFieldDescriptor(output as ResolverFieldEntry)) { + return resolveResolverField(output as ResolverFieldDescriptor); + } + + // Check if it's already a TailorField (has `type` as string for field type) + const isTailorField = (obj: unknown): obj is TailorAnyField => + typeof obj === "object" && + obj !== null && + "type" in obj && + typeof (obj as { type: unknown }).type === "string"; + + if (isTailorField(output)) { + return output; + } + + // Otherwise it's a Record of fields - resolve each and wrap in t.object() + const resolvedFields = resolveResolverFieldMap(output as Record); + return t.object(resolvedFields); +} + // A loose config alias for userland use-cases // oxlint-disable-next-line no-explicit-any export type ResolverConfig = ReturnType>; diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts new file mode 100644 index 000000000..673335abc --- /dev/null +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -0,0 +1,845 @@ +import { describe, it, expectTypeOf, expect } from "vitest"; +import { createTable, timestampFields } from "./createTable"; +import { db } from "./schema"; +import type { Hook } from "./types"; +import type { output } from "@/configure/types/helpers"; +import type { FieldValidateInput } from "@/configure/types/validation"; + +describe("createTable basic field type tests", () => { + it("string field outputs string type correctly", () => { + const result = createTable("Test", { + name: { kind: "string" }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + name: string; + }>(); + }); + + it("int field outputs number type correctly", () => { + const result = createTable("Test", { + age: { kind: "int" }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + age: number; + }>(); + }); + + it("bool field outputs boolean type correctly", () => { + const result = createTable("Test", { + active: { kind: "bool" }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + active: boolean; + }>(); + }); + + it("float field outputs number type correctly", () => { + const result = createTable("Test", { + price: { kind: "float" }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + price: number; + }>(); + }); + + it("uuid field outputs string type correctly", () => { + const result = createTable("Test", { + ref: { kind: "uuid" }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + ref: string; + }>(); + }); + + it("date field outputs string type correctly", () => { + const result = createTable("Test", { + birthDate: { kind: "date" }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + birthDate: string; + }>(); + }); + + it("datetime field outputs string | Date type correctly", () => { + const result = createTable("Test", { + timestamp: { kind: "datetime" }, + }); + expectTypeOf>().toMatchObjectType<{ + timestamp: string | Date; + }>(); + }); + + it("time field outputs string type correctly", () => { + const result = createTable("Test", { + openingTime: { kind: "time" }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + openingTime: string; + }>(); + }); + + it("decimal field outputs string type correctly", () => { + const result = createTable("Test", { + amount: { kind: "decimal" }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + amount: string; + }>(); + }); +}); + +describe("createTable optional and array tests", () => { + it("optional generates nullable type", () => { + const result = createTable("Test", { + description: { kind: "string", optional: true }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + description?: string | null; + }>(); + }); + + it("array generates array type", () => { + const result = createTable("Test", { + tags: { kind: "string", array: true }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + tags: string[]; + }>(); + }); + + it("optional array works correctly", () => { + const result = createTable("Test", { + items: { kind: "string", optional: true, array: true }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + items?: string[] | null; + }>(); + }); +}); + +describe("createTable enum tests", () => { + it("enum literal types are inferred", () => { + const result = createTable("Test", { + role: { kind: "enum", values: ["MANAGER", "STAFF"] }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + role: "MANAGER" | "STAFF"; + }>(); + }); + + it("optional enum works correctly", () => { + const result = createTable("Test", { + priority: { kind: "enum", values: ["high", "medium", "low"], optional: true }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + priority?: "high" | "medium" | "low" | null; + }>(); + }); + + it("enum metadata has correct allowedValues", () => { + const result = createTable("Test", { + status: { kind: "enum", values: ["active", "inactive"] }, + }); + expect(result.fields.status.metadata.allowedValues).toEqual([ + { value: "active", description: "" }, + { value: "inactive", description: "" }, + ]); + }); +}); + +describe("createTable runtime metadata tests", () => { + it("unique sets metadata correctly", () => { + const result = createTable("Test", { + email: { kind: "string", unique: true }, + }); + expect(result.fields.email.metadata.unique).toBe(true); + expect(result.fields.email.metadata.index).toBe(true); + }); + + it("index sets metadata correctly", () => { + const result = createTable("Test", { + name: { kind: "string", index: true }, + }); + expect(result.fields.name.metadata.index).toBe(true); + expect(result.fields.name.metadata.unique).toBeUndefined(); + }); + + it("vector sets metadata correctly", () => { + const result = createTable("Test", { + embedding: { kind: "string", vector: true }, + }); + expect(result.fields.embedding.metadata.vector).toBe(true); + }); + + it("hooks set metadata correctly", () => { + const result = createTable("Test", { + name: { kind: "string", hooks: { create: () => "default" } }, + }); + expect(result.fields.name.metadata.hooks).toBeDefined(); + expect(result.fields.name.metadata.hooks!.create).toBeDefined(); + }); + + it("validate sets metadata correctly", () => { + const result = createTable("Test", { + age: { + kind: "int", + validate: [({ value }) => value >= 0, "Must be non-negative"], + }, + }); + expect(result.fields.age.metadata.validate).toBeDefined(); + expect(result.fields.age.metadata.validate!.length).toBe(1); + }); + + it("serial sets metadata correctly", () => { + const result = createTable("Test", { + code: { kind: "string", serial: { start: 1, format: "INV-%05d" } }, + }); + expect(result.fields.code.metadata.serial).toEqual({ + start: 1, + format: "INV-%05d", + }); + }); + + it("description sets metadata correctly", () => { + const result = createTable("Test", { + name: { kind: "string", description: "The user's name" }, + }); + expect(result.fields.name.metadata.description).toBe("The user's name"); + }); + + it("decimal scale sets metadata correctly", () => { + const result = createTable("Test", { + amount: { kind: "decimal", scale: 4 }, + }); + expect(result.fields.amount.metadata.scale).toBe(4); + }); +}); + +describe("createTable relation tests", () => { + const User = db.type("User", { + name: db.string(), + }); + + it("n-1 relation sets rawRelation and index", () => { + const result = createTable("Test", { + userId: { + kind: "uuid", + relation: { + type: "n-1", + toward: { type: User }, + }, + }, + }); + expect(result.fields.userId.rawRelation).toBeDefined(); + expect(result.fields.userId.rawRelation!.type).toBe("n-1"); + expect(result.fields.userId.metadata.index).toBe(true); + expect(result.fields.userId.metadata.unique).toBeUndefined(); + }); + + it("oneToOne relation sets rawRelation, index, and unique", () => { + const result = createTable("Test", { + userId: { + kind: "uuid", + relation: { + type: "oneToOne", + toward: { type: User }, + }, + }, + }); + expect(result.fields.userId.rawRelation).toBeDefined(); + expect(result.fields.userId.rawRelation!.type).toBe("oneToOne"); + expect(result.fields.userId.metadata.index).toBe(true); + expect(result.fields.userId.metadata.unique).toBe(true); + }); + + it("self-referencing relation works", () => { + const result = createTable("Test", { + name: { kind: "string" }, + parentId: { + kind: "uuid", + optional: true, + relation: { + type: "n-1", + toward: { type: "self" as const }, + }, + }, + }); + expect(result.fields.parentId.rawRelation).toBeDefined(); + }); +}); + +describe("createTable keyOnly relation", () => { + it("keyOnly relation sets rawRelation and index", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + const result = createTable("Test", { + targetId: { + kind: "uuid", + relation: { + type: "keyOnly", + toward: { type: Target }, + }, + }, + }); + expect(result.fields.targetId.rawRelation).toBeDefined(); + expect(result.fields.targetId.rawRelation!.type).toBe("keyOnly"); + expect(result.fields.targetId.metadata.index).toBe(true); + expect(result.fields.targetId.metadata.unique).toBeUndefined(); + }); +}); + +describe("createTable type-safe options", () => { + it("permission accepts record operands typed to the type's fields", () => { + const result = createTable( + "Employee", + { + name: { kind: "string" }, + ownerId: { kind: "uuid" }, + }, + { + permission: { + create: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], + read: [{ conditions: [[{ record: "name" }, "=", "admin"]], permit: true }], + update: [{ conditions: [[{ newRecord: "ownerId" }, "=", { user: "id" }]], permit: true }], + delete: [{ conditions: [[{ record: "ownerId" }, "=", { user: "id" }]], permit: true }], + }, + }, + ); + expect(result.metadata.permissions).toBeDefined(); + }); + + it("indexes validates field names against the type's fields", () => { + const result = createTable( + "Employee", + { + name: { kind: "string" }, + department: { kind: "string" }, + }, + { + indexes: [{ fields: ["name", "department"], unique: true }], + }, + ); + expect(result.metadata.indexes).toBeDefined(); + }); + + it("files accepts keys that do not collide with field names", () => { + const result = createTable( + "Employee", + { name: { kind: "string" } }, + { files: { avatar: "image/png" } }, + ); + expect(result.metadata.files).toBeDefined(); + }); +}); + +describe("createTable array field guards", () => { + it("array fields do not get index or unique metadata", () => { + // Runtime guard: buildField skips index/unique for array fields + const result = createTable("Test", { + tags: { kind: "string", array: true }, + }); + expect(result.fields.tags.metadata.index).toBeUndefined(); + expect(result.fields.tags.metadata.unique).toBeUndefined(); + }); +}); + +describe("createTable hooks+serial mutual exclusion", () => { + it("hooks and serial cannot be combined on the same descriptor", () => { + createTable("Test", { + // @ts-expect-error hooks and serial are mutually exclusive + code: { kind: "string", hooks: { create: () => "default" }, serial: { start: 1 } }, + }); + }); + + it("hooks descriptor sets serial: false in defined", () => { + const result = createTable("Test", { + name: { kind: "string", hooks: { create: () => "default" } }, + }); + type NameDefined = (typeof result.fields.name)["_defined"]; + expectTypeOf().toEqualTypeOf(); + }); + + it("serial descriptor sets hooks: { create: false; update: false } in defined", () => { + const result = createTable("Test", { + code: { kind: "string", serial: { start: 1 } }, + }); + type CodeDefined = (typeof result.fields.code)["_defined"]; + expectTypeOf().toEqualTypeOf<{ create: false; update: false }>(); + }); +}); + +describe("createTable nested object guards", () => { + it("nested object descriptor inside object descriptor causes type error", () => { + createTable("Test", { + address: { + kind: "object", + fields: { + street: { kind: "string" }, + // @ts-expect-error Nested object inside object is not allowed + location: { + kind: "object", + fields: { lat: { kind: "float" }, lng: { kind: "float" } }, + }, + }, + }, + }); + }); + + it("nested db.object() inside object descriptor causes type error", () => { + createTable("Test", { + address: { + kind: "object", + fields: { + street: { kind: "string" }, + // @ts-expect-error Nested db.object() inside object descriptor is not allowed + location: db.object({ lat: db.float(), lng: db.float() }), + }, + }, + }); + }); + + it("flat object descriptor is allowed", () => { + const result = createTable("Test", { + address: { + kind: "object", + fields: { + street: { kind: "string" }, + city: { kind: "string" }, + }, + }, + }); + expect(result.fields.address.type).toBe("nested"); + }); +}); + +describe("createTable plugins option", () => { + it("plugins are set on the type via options", () => { + const result = createTable( + "Test", + { name: { kind: "string" } }, + { + plugins: [{ pluginId: "test-plugin", config: { enabled: true } }], + }, + ); + expect(result.plugins).toEqual([{ pluginId: "test-plugin", config: { enabled: true } }]); + }); + + it("multiple plugins are set in order", () => { + const result = createTable( + "Test", + { name: { kind: "string" } }, + { + plugins: [ + { pluginId: "plugin-a", config: { a: 1 } }, + { pluginId: "plugin-b", config: { b: 2 } }, + ], + }, + ); + expect(result.plugins).toEqual([ + { pluginId: "plugin-a", config: { a: 1 } }, + { pluginId: "plugin-b", config: { b: 2 } }, + ]); + }); +}); + +describe("createTable relation key validation", () => { + it("invalid relation key against target type causes type error", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + createTable("Test", { + // @ts-expect-error 'nonExistent' does not exist on Target fields + targetId: { + kind: "uuid", + relation: { + type: "n-1", + toward: { type: Target, key: "nonExistent" }, + }, + }, + }); + }); + + it("valid relation key matching target field name is accepted", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + const result = createTable("Test", { + targetId: { + kind: "uuid", + relation: { + type: "n-1", + toward: { type: Target, key: "name" }, + }, + }, + }); + expect(result.fields.targetId.rawRelation).toBeDefined(); + }); + + it("explicit 'id' relation key is always accepted for target types", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + const result = createTable("Test", { + targetId: { + kind: "uuid", + relation: { + type: "n-1", + toward: { type: Target, key: "id" }, + }, + }, + }); + expect(result.fields.targetId.rawRelation!.toward.key).toBe("id"); + }); + + it("explicit 'id' relation key is always accepted for self-references", () => { + const result = createTable("Test", { + parentId: { + kind: "uuid", + optional: true, + relation: { + type: "n-1", + toward: { type: "self" as const, key: "id" }, + }, + }, + }); + expect(result.fields.parentId.rawRelation!.toward.key).toBe("id"); + }); + + it("invalid self-referencing relation key causes type error", () => { + createTable("Test", { + // @ts-expect-error 'nonExistent' does not exist on own fields + parentId: { + kind: "uuid", + optional: true, + relation: { + type: "n-1", + toward: { type: "self" as const, key: "nonExistent" }, + }, + }, + }); + }); + + it("valid self-referencing relation key is accepted", () => { + const result = createTable("Test", { + name: { kind: "string" }, + parentId: { + kind: "uuid", + optional: true, + relation: { + type: "n-1", + toward: { type: "self" as const, key: "name" }, + }, + }, + }); + expect(result.fields.parentId.rawRelation).toBeDefined(); + }); + + it("relation without key is accepted", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + const result = createTable("Test", { + targetId: { + kind: "uuid", + relation: { + type: "n-1", + toward: { type: Target }, + }, + }, + }); + expect(result.fields.targetId.rawRelation).toBeDefined(); + }); +}); + +describe("createTable array+vector/serial guards", () => { + it("array + vector causes type error", () => { + createTable("Test", { + // @ts-expect-error array and vector are incompatible + tags: { kind: "string", array: true, vector: true }, + }); + }); + + it("array + serial causes type error", () => { + createTable("Test", { + // @ts-expect-error array and serial are incompatible + codes: { kind: "string", array: true, serial: { start: 1 } }, + }); + }); + + it("non-array vector is accepted", () => { + const result = createTable("Test", { + embedding: { kind: "string", vector: true }, + }); + expect(result.fields.embedding.metadata.vector).toBe(true); + }); + + it("non-array serial is accepted", () => { + const result = createTable("Test", { + code: { kind: "string", serial: { start: 1 } }, + }); + expect(result.fields.code.metadata.serial).toEqual({ start: 1 }); + }); +}); + +describe("createTable hook type validation", () => { + it("hook returning correct type is accepted", () => { + const result = createTable("Test", { + name: { kind: "string", hooks: { create: () => "default" } }, + }); + expect(result.fields.name.metadata.hooks).toBeDefined(); + }); + + it("hook returning wrong type causes type error", () => { + createTable("Test", { + // @ts-expect-error hook returns number but field expects string + name: { kind: "string", hooks: { create: () => 42 } }, + }); + }); + + it("datetime hook returning Date is accepted", () => { + const result = createTable("Test", { + createdAt: { kind: "datetime", hooks: { create: () => new Date() } }, + }); + expect(result.fields.createdAt.metadata.hooks).toBeDefined(); + }); +}); + +describe("createTable unique on many-to-one relation guard", () => { + it("unique: true on n-1 relation causes type error", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + createTable("Test", { + // @ts-expect-error unique is not allowed on n-1 relations + targetId: { + kind: "uuid", + unique: true, + relation: { + type: "n-1", + toward: { type: Target }, + }, + }, + }); + }); + + it("unique: true on manyToOne relation causes type error", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + createTable("Test", { + // @ts-expect-error unique is not allowed on manyToOne relations + targetId: { + kind: "uuid", + unique: true, + relation: { + type: "manyToOne", + toward: { type: Target }, + }, + }, + }); + }); + + it("unique: true on oneToOne relation is accepted", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + const result = createTable("Test", { + targetId: { + kind: "uuid", + unique: true, + relation: { + type: "oneToOne", + toward: { type: Target }, + }, + }, + }); + expect(result.fields.targetId.metadata.unique).toBe(true); + expect(result.fields.targetId.metadata.index).toBe(true); + }); + + it("n-1 relation without unique sets index only", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + const result = createTable("Test", { + targetId: { + kind: "uuid", + relation: { + type: "n-1", + toward: { type: Target }, + }, + }, + }); + expect(result.fields.targetId.metadata.index).toBe(true); + expect(result.fields.targetId.metadata.unique).toBeUndefined(); + }); +}); + +describe("createTable array relation index guard", () => { + it("array relation does not set index or unique metadata", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + const result = createTable("Test", { + targetIds: { + kind: "uuid", + array: true, + relation: { + type: "n-1", + toward: { type: Target }, + }, + }, + }); + expect(result.fields.targetIds.rawRelation).toBeDefined(); + expect(result.fields.targetIds.metadata.index).toBeUndefined(); + expect(result.fields.targetIds.metadata.unique).toBeUndefined(); + }); + + it("array oneToOne relation does not set index or unique metadata", () => { + const Target = createTable("Target", { name: { kind: "string" } }); + const result = createTable("Test", { + targetIds: { + kind: "uuid", + array: true, + relation: { + type: "oneToOne", + toward: { type: Target }, + }, + }, + }); + expect(result.fields.targetIds.rawRelation).toBeDefined(); + expect(result.fields.targetIds.metadata.index).toBeUndefined(); + expect(result.fields.targetIds.metadata.unique).toBeUndefined(); + }); +}); + +describe("createTable id field guard", () => { + it("defining id field causes type error", () => { + createTable("Test", { + // @ts-expect-error id is a system field and cannot be redefined + id: { kind: "uuid" }, + name: { kind: "string" }, + }); + }); +}); + +describe("createTable descriptor-level hooks value typing", () => { + it("string hooks value is typed as string | null", () => { + const hooks: Hook = { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? "default"; + }, + }; + createTable("Test", { name: { kind: "string", hooks } }); + }); + + it("int hooks value is typed as number | null", () => { + const hooks: Hook = { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? 0; + }, + }; + createTable("Test", { count: { kind: "int", hooks } }); + }); + + it("datetime hooks value is typed as string | Date | null", () => { + const hooks: Hook = { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? new Date(); + }, + }; + createTable("Test", { ts: { kind: "datetime", hooks } }); + }); + + it("enum hooks value is typed as enum union | null", () => { + createTable( + "Test", + { role: { kind: "enum", values: ["ADMIN", "USER"] } }, + { + hooks: { + role: { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<"ADMIN" | "USER" | null>(); + return value ?? "USER"; + }, + }, + }, + }, + ); + }); +}); + +describe("createTable descriptor-level validate value typing", () => { + it("string validate value is typed as string", () => { + const validate: FieldValidateInput = ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value.length > 0; + }; + createTable("Test", { name: { kind: "string", validate } }); + }); + + it("int validate value is typed as number", () => { + const validate: FieldValidateInput = ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value >= 0; + }; + createTable("Test", { count: { kind: "int", validate } }); + }); +}); + +describe("createTable mixed fluent and descriptor fields", () => { + it("accepts both db.field() and descriptor in the same type", () => { + const result = createTable("Test", { + name: db.string(), + email: { kind: "string", unique: true }, + }); + expectTypeOf>().toEqualTypeOf<{ + id: string; + name: string; + email: string; + }>(); + expect(result.fields.email.metadata.unique).toBe(true); + }); +}); + +describe("timestampFields", () => { + it("returns createdAt and updatedAt descriptors", () => { + const result = createTable("Test", { + name: { kind: "string" }, + ...timestampFields(), + }); + expect(result.fields.createdAt.metadata.hooks).toBeDefined(); + expect(result.fields.updatedAt.metadata.hooks).toBeDefined(); + }); +}); + +describe("createTable type-level hooks/validate exclusion in options", () => { + it("field with descriptor-level hooks is excluded from type-level hooks in options", () => { + createTable( + "Test", + { + name: { kind: "string", hooks: { create: () => "default" } }, + email: { kind: "string" }, + }, + { + hooks: { + // @ts-expect-error name already has hooks at descriptor level + name: { create: () => "override" }, + }, + }, + ); + }); + + it("field with descriptor-level validate is excluded from type-level validate in options", () => { + createTable( + "Test", + { + name: { kind: "string", validate: () => true }, + email: { kind: "string" }, + }, + { + validate: { + // @ts-expect-error name already has validate at descriptor level + name: () => true, + }, + }, + ); + }); +}); diff --git a/packages/sdk/src/configure/services/tailordb/createTable.ts b/packages/sdk/src/configure/services/tailordb/createTable.ts new file mode 100644 index 000000000..487fada2c --- /dev/null +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -0,0 +1,507 @@ +import { type AllowedValues, type AllowedValuesOutput } from "@/configure/types/field"; +import { + type TailorAnyDBField, + type TailorAnyDBType, + type TailorDBField, + type TailorDBType, + createTailorDBField, + createTailorDBType, +} from "./schema"; +import type { TailorTypeGqlPermission, TailorTypePermission } from "./permission"; +import type { Hook, Hooks, SerialConfig, IndexDef, TypeFeatures } from "./types"; +import type { InferredAttributeMap } from "@/configure/types"; +import type { InferFieldsOutput, output } from "@/configure/types/helpers"; +import type { TailorFieldType, TailorToTs } from "@/configure/types/types"; +import type { FieldValidateInput, ValidateConfig, Validators } from "@/configure/types/validation"; +import type { PluginAttachment } from "@/types/plugin"; +import type { RelationType } from "@/types/tailordb"; + +type CommonFieldOptions = { + optional?: boolean; + array?: boolean; + description?: string; +}; + +const kindToFieldType = { + string: "string", + int: "integer", + float: "float", + bool: "boolean", + uuid: "uuid", + decimal: "decimal", + date: "date", + datetime: "datetime", + time: "time", + enum: "enum", + object: "nested", +} as const satisfies Record; + +type KindToFieldType = typeof kindToFieldType; + +type KindToTsType = { + [K in keyof KindToFieldType as K extends "enum" | "object" + ? never + : K]: TailorToTs[KindToFieldType[K]]; +}; + +type IndexableOptions = { + unique?: boolean; + index?: boolean; + hooks?: Hook; + validate?: FieldValidateInput | FieldValidateInput[]; +}; + +type StringDescriptor = CommonFieldOptions & + IndexableOptions & { + kind: "string"; + vector?: boolean; + serial?: SerialConfig<"string">; + }; + +type IntDescriptor = CommonFieldOptions & + IndexableOptions & { + kind: "int"; + serial?: SerialConfig<"integer">; + }; + +type SimpleDescriptor = CommonFieldOptions & + IndexableOptions & { + kind: K; + }; + +type FloatDescriptor = SimpleDescriptor<"float">; +type BoolDescriptor = SimpleDescriptor<"bool">; +type DateDescriptor = SimpleDescriptor<"date">; +type DatetimeDescriptor = SimpleDescriptor<"datetime">; +type TimeDescriptor = SimpleDescriptor<"time">; +type DecimalDescriptor = CommonFieldOptions & + IndexableOptions & { + kind: "decimal"; + scale?: number; + }; + +type UuidDescriptor = CommonFieldOptions & + IndexableOptions & { + kind: "uuid"; + relation?: { + type: RelationType; + toward: { + type: TailorAnyDBType | "self"; + as?: string; + // Typed as plain `string` here (not `keyof T["fields"]`); validated + // at the createTable call site via `ValidateRelationKeys`. + key?: string; + }; + backward?: string; + }; + }; + +type EnumDescriptor = CommonFieldOptions & + IndexableOptions> & { + kind: "enum"; + values: V; + typeName?: string; + }; + +// Nested object sub-fields bypass top-level constraint types (RejectArrayCombinations, ValidateHookTypes, etc.) +// because recursive mapped-type constraints would add significant complexity. This is a shared gap +// with the fluent API (db.object() sub-fields are also unconstrained). Invalid nested combinations +// are caught at deployment time by the platform. +type ObjectDescriptor = CommonFieldOptions & { + kind: "object"; + fields: Record; + typeName?: string; +}; + +type FieldDescriptor = + | StringDescriptor + | IntDescriptor + | FloatDescriptor + | BoolDescriptor + | DateDescriptor + | DatetimeDescriptor + | TimeDescriptor + | DecimalDescriptor + | UuidDescriptor + | EnumDescriptor + | ObjectDescriptor; + +type FieldEntry = FieldDescriptor | TailorAnyDBField; + +type DescriptorBaseOutput = D extends { kind: "enum"; values: infer V } + ? V extends AllowedValues + ? AllowedValuesOutput + : string + : D extends { kind: "object"; fields: infer F } + ? F extends Record + ? InferFieldsOutput> + : Record + : D["kind"] extends keyof KindToTsType + ? KindToTsType[D["kind"]] + : unknown; + +type ApplyArrayAndOptional = D extends { array: true } + ? D extends { optional: true } + ? T[] | null + : T[] + : D extends { optional: true } + ? T | null + : T; + +type DescriptorOutput = ApplyArrayAndOptional< + DescriptorBaseOutput, + D +>; + +type DescriptorDefined = { + type: D["kind"] extends keyof KindToFieldType ? KindToFieldType[D["kind"]] : TailorFieldType; + array: D extends { array: true } ? true : false; +} & (D extends { hooks: infer H } + ? H extends object + ? { + hooks: { + create: H extends { create: unknown } ? true : false; + update: H extends { update: unknown } ? true : false; + }; + serial: false; + } + : unknown + : unknown) & + (D extends { validate: object } ? { validate: true } : unknown) & + (D extends { unique: true } + ? { unique: true; index: true } + : D extends { index: true } + ? { index: true } + : unknown) & + (D extends { serial: object } + ? { serial: true; hooks: { create: false; update: false } } + : unknown) & + (D extends { vector: true } ? { vector: true } : unknown) & + (D extends { kind: "uuid"; relation: object } + ? D extends { array: true } + ? { relation: true } + : D extends { relation: { type: "oneToOne" | "1-1" } } + ? { relation: true; unique: true; index: true } + : { relation: true; index: true } + : unknown); + +type ResolvedField = E extends FieldDescriptor + ? TailorDBField, DescriptorOutput> + : E; + +// oxlint-disable-next-line no-explicit-any +type ResolvedFieldMap> = { + [K in keyof M]: ResolvedField; +}; + +// Rejects descriptors that combine array: true with index, unique, vector, or serial +// (all unsupported by the platform). +type RejectArrayCombinations> = { + [K in keyof D]: D[K] extends + | { array: true; unique: true } + | { array: true; index: true } + | { array: true; vector: true } + | { array: true; serial: object } + ? never + : D[K]; +}; + +// Rejects descriptors that combine hooks and serial (mutually exclusive in fluent API). +// The `kind: string` guard excludes TailorDBField instances whose hooks()/serial() methods extend `object`. +type RejectHooksWithSerial> = { + [K in keyof D]: D[K] extends { kind: string; hooks: object; serial: object } ? never : D[K]; +}; + +// Rejects unique: true on non-oneToOne uuid relations (platform rejects unique on n-1 relations). +type RejectUniqueOnManyRelation> = { + [K in keyof D]: D[K] extends { + kind: "uuid"; + unique: true; + relation: { type: infer T }; + } + ? T extends "oneToOne" | "1-1" + ? D[K] + : never + : D[K]; +}; + +// Rejects nested objects inside object descriptors (matching ExcludeNestedDBFields in fluent API). +type RejectNestedSubFields> = { + [K in keyof F]: F[K] extends + | { kind: "object" } + // oxlint-disable-next-line no-explicit-any -- loose match for nested TailorDBField + | TailorDBField<{ type: "nested"; array: boolean }, any> + ? never + : F[K]; +}; + +type RejectNestedInObject> = { + [K in keyof D]: D[K] extends { kind: "object"; fields: infer F } + ? F extends Record + ? D[K] & { fields: RejectNestedSubFields } + : D[K] + : D[K]; +}; + +// Validates hook return types against the descriptor's output type at the call site. +type ValidateHookTypes> = { + [K in keyof D]: D[K] extends FieldDescriptor & { hooks: infer H } + ? H extends Hook> + ? D[K] + : never + : D[K]; +}; + +// Validates relation key against the target type's fields at the createTable call site. +// Every type implicitly has an `id` field, so `"id"` is always a valid key. +type ValidateRelationKeys> = { + [K in keyof D]: D[K] extends { + kind: "uuid"; + relation: { toward: { type: infer T; key: infer Key } }; + } + ? Key extends string + ? T extends TailorAnyDBType + ? Key extends (keyof T["fields"] & string) | "id" + ? D[K] + : never + : T extends "self" + ? Key extends (keyof D & string) | "id" + ? D[K] + : never + : D[K] + : D[K] + : D[K]; +}; + +// Combined constraint: all descriptor-level validations applied at the createTable call site. +type ValidatedDescriptors> = D & + RejectArrayCombinations & + RejectHooksWithSerial & + RejectUniqueOnManyRelation & + RejectNestedInObject & + ValidateHookTypes & + ValidateRelationKeys; + +type CreateTableOptions< + FieldNames extends string = string, + // oxlint-disable-next-line no-explicit-any + Fields extends Record = any, +> = { + description?: string; + pluralForm?: string; + features?: Omit; + indexes?: IndexDef<{ fields: Record }>[]; + files?: Record & Partial>; + permission?: TailorTypePermission>>; + gqlPermission?: TailorTypeGqlPermission; + plugins?: PluginAttachment[]; + hooks?: Hooks; + validate?: Validators; +}; + +function isPassthroughField(entry: FieldEntry): entry is TailorAnyDBField { + // All FieldDescriptor variants have `kind`; TailorAnyDBField does not. + return !("kind" in entry); +} + +function resolveField(entry: FieldEntry): TailorAnyDBField { + if (isPassthroughField(entry)) { + return entry; + } + return buildField(entry); +} + +function resolveFieldMap(entries: Record): Record { + return Object.fromEntries( + Object.entries(entries).map(([key, entry]) => [key, resolveField(entry)]), + ); +} + +function isValidateConfig(v: unknown): v is ValidateConfig { + return Array.isArray(v) && v.length === 2 && typeof v[1] === "string"; +} + +function buildField(descriptor: FieldDescriptor): TailorAnyDBField { + const fieldType = kindToFieldType[descriptor.kind]; + const options = { + ...(descriptor.optional === true && { optional: true as const }), + ...(descriptor.array === true && { array: true as const }), + }; + const values = descriptor.kind === "enum" ? descriptor.values : undefined; + const nestedFields = + descriptor.kind === "object" ? resolveFieldMap(descriptor.fields) : undefined; + + let field: TailorAnyDBField = createTailorDBField(fieldType, options, nestedFields, values); + + if (descriptor.description !== undefined) { + field = field.description(descriptor.description); + } + + if ( + (descriptor.kind === "enum" || descriptor.kind === "object") && + descriptor.typeName !== undefined + ) { + // oxlint-disable-next-line no-explicit-any -- typeName() is only present on enum/nested field interfaces + field = (field as any).typeName(descriptor.typeName); + } + + // Object descriptors only support description and typeName; skip indexable/hookable options. + if (descriptor.kind === "object") { + return field; + } + + // When a relation is present, the relation handler dictates index/unique flags. + if ( + descriptor.array !== true && + !(descriptor.kind === "uuid" && descriptor.relation !== undefined) + ) { + if (descriptor.unique === true) { + field = field.unique(); + } else if (descriptor.index === true) { + field = field.index(); + } + } + + if (descriptor.hooks !== undefined) { + // oxlint-disable-next-line no-explicit-any -- union of typed Hook variants narrows to specific O; widen to any for TailorAnyDBField + field = field.hooks(descriptor.hooks as any); + } + + if (descriptor.validate !== undefined) { + if (Array.isArray(descriptor.validate) && !isValidateConfig(descriptor.validate)) { + // oxlint-disable-next-line no-explicit-any -- union of typed FieldValidateInput variants; widen to any for TailorAnyDBField + field = field.validate(...(descriptor.validate as any)); + } else { + // oxlint-disable-next-line no-explicit-any -- union of typed FieldValidateInput variants; widen to any for TailorAnyDBField + field = field.validate(descriptor.validate as any); + } + } + + if (descriptor.kind === "string" && descriptor.vector === true && descriptor.array !== true) { + field = field.vector(); + } + + if (descriptor.kind === "decimal" && descriptor.scale !== undefined) { + // oxlint-disable-next-line no-explicit-any -- decimal scale is set via internal metadata + (field as any)._metadata.scale = descriptor.scale; + } + + if ( + (descriptor.kind === "string" || descriptor.kind === "int") && + descriptor.serial !== undefined && + descriptor.array !== true + ) { + field = field.serial(descriptor.serial); + } + + if (descriptor.kind === "uuid" && descriptor.relation !== undefined) { + // oxlint-disable-next-line no-explicit-any -- relation() is only present on uuid field interface + field = (field as any).relation(descriptor.relation); + if (descriptor.array !== true) { + const relType = descriptor.relation.type; + if (relType === "oneToOne" || relType === "1-1") { + field = field.unique(); + } else { + field = field.index(); + } + } + } + + return field; +} + +const idField = createTailorDBField("uuid"); +type IdField = typeof idField; + +type AllFields> = { id: IdField } & ResolvedFieldMap; + +/** + * Create a TailorDB type using an object-literal API. + * @param name - The name of the type, or a tuple of [name, pluralForm] + * @param descriptors - Field descriptors as an object literal + * @param options - Optional type-level options (permission, gqlPermission, features, etc.) + * @returns A new TailorDBType instance + * @example + * export const user = createTable("User", { + * name: { kind: "string" }, + * email: { kind: "string", unique: true }, + * status: { kind: "string", optional: true }, + * role: { kind: "enum", values: ["MANAGER", "STAFF"] }, + * ...timestampFields(), + * }); + * export type user = typeof user; + */ +export function createTable>( + name: string | [string, string], + descriptors: [D] extends [ValidatedDescriptors] ? D : ValidatedDescriptors, + options?: CreateTableOptions & string, AllFields>, +): TailorDBType> { + const [typeName, pluralForm] = Array.isArray(name) ? name : [name, options?.pluralForm]; + const fields = { + id: idField.clone(), + ...resolveFieldMap(descriptors), + } as AllFields; + + const dbType = createTailorDBType(typeName, fields, { + pluralForm, + description: options?.description, + }); + + if (options?.features) { + dbType.features(options.features); + } + if (options?.indexes) { + // oxlint-disable-next-line no-explicit-any -- IndexDef generic param differs structurally from TailorDBType + dbType.indexes(...(options.indexes as any)); + } + if (options?.files) { + // oxlint-disable-next-line no-explicit-any -- files() infers literal key type; pre-validated by CreateTableOptions constraint + dbType.files(options.files as any); + } + if (options?.permission) { + dbType.permission(options.permission); + } + if (options?.gqlPermission) { + dbType.gqlPermission(options.gqlPermission); + } + if (options?.plugins) { + for (const { pluginId, config } of options.plugins) { + // oxlint-disable-next-line no-explicit-any -- PluginAttachment.config is unknown; bypass PluginConfigs generic constraint + dbType.plugin({ [pluginId]: config } as any); + } + } + if (options?.hooks) { + dbType.hooks(options.hooks); + } + if (options?.validate) { + dbType.validate(options.validate); + } + + return dbType; +} + +/** + * Returns standard timestamp fields (createdAt, updatedAt) with auto-hooks. + * createdAt is set on create, updatedAt is set on update. + * @returns An object with createdAt and updatedAt field descriptors + * @example + * const model = createTable("Model", { + * name: { kind: "string" }, + * ...timestampFields(), + * }); + */ +export function timestampFields() { + return { + createdAt: { + kind: "datetime", + hooks: { create: () => new Date() }, + description: "Record creation timestamp", + }, + updatedAt: { + kind: "datetime", + optional: true, + hooks: { update: () => new Date() }, + description: "Record last update timestamp", + }, + } as const satisfies Record; +} diff --git a/packages/sdk/src/configure/services/tailordb/index.ts b/packages/sdk/src/configure/services/tailordb/index.ts index 98b09b8e8..89dc72f5e 100644 --- a/packages/sdk/src/configure/services/tailordb/index.ts +++ b/packages/sdk/src/configure/services/tailordb/index.ts @@ -6,6 +6,7 @@ export { type TailorDBType, } from "./schema"; export type { TailorDBInstance } from "./schema"; +export { createTable, timestampFields } from "./createTable"; export { unsafeAllowAllTypePermission, unsafeAllowAllGqlPermission, diff --git a/packages/sdk/src/configure/services/tailordb/schema.ts b/packages/sdk/src/configure/services/tailordb/schema.ts index 7e2555eba..8ec04a43d 100644 --- a/packages/sdk/src/configure/services/tailordb/schema.ts +++ b/packages/sdk/src/configure/services/tailordb/schema.ts @@ -284,7 +284,7 @@ export interface TailorDBField e * @param values - Allowed values for enum-like fields * @returns A new TailorDBField */ -function createTailorDBField< +export function createTailorDBField< const T extends TailorFieldType, const TOptions extends FieldOptions, const OutputBase = TailorToTs[T], @@ -980,7 +980,7 @@ export interface TailorDBType< * @param options.description - Optional description * @returns A new TailorDBType */ -function createTailorDBType< +export function createTailorDBType< // oxlint-disable-next-line no-explicit-any const Fields extends Record = any, User extends object = InferredAttributeMap, diff --git a/packages/sdk/src/configure/types/type.ts b/packages/sdk/src/configure/types/type.ts index 552c8b559..4add8b44d 100644 --- a/packages/sdk/src/configure/types/type.ts +++ b/packages/sdk/src/configure/types/type.ts @@ -127,13 +127,14 @@ export interface TailorField< /** * Creates a new TailorField instance. + * @internal * @param type - Field type * @param options - Field options * @param fields - Nested fields for object-like types * @param values - Allowed values for enum-like fields * @returns A new TailorField */ -function createTailorField< +export function createTailorField< const T extends TailorFieldType, const TOptions extends FieldOptions, const OutputBase = TailorToTs[T], From cfdcf83a5ece2d6252086a455d98947e59f50c11 Mon Sep 17 00:00:00 2001 From: dqn Date: Wed, 1 Apr 2026 18:40:19 +0900 Subject: [PATCH 02/17] fix(resolver,tailordb): strengthen descriptor discrimination and validate decimal scale - Tighten isResolverFieldDescriptor to check kind is a known string value, preventing false positives when output records contain a field named "kind" - Add decimal scale validation (integer 0-12) in createTable to match db.decimal() --- .gitignore | 1 + .../src/configure/services/resolver/descriptor.ts | 8 ++++++-- .../configure/services/resolver/resolver.test.ts | 14 ++++++++++++++ .../services/tailordb/createTable.test.ts | 12 ++++++++++++ .../src/configure/services/tailordb/createTable.ts | 3 +++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 594a6ef6a..43f918c06 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ CLAUDE.local.md llm-challenge/results/ llm-challenge/problems/*/work .claude/tmp/ +.agent/tmp/ diff --git a/packages/sdk/src/configure/services/resolver/descriptor.ts b/packages/sdk/src/configure/services/resolver/descriptor.ts index 819eb93fe..055e00c07 100644 --- a/packages/sdk/src/configure/services/resolver/descriptor.ts +++ b/packages/sdk/src/configure/services/resolver/descriptor.ts @@ -115,13 +115,17 @@ export type ResolvedResolverFieldMap { diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 4f32672fd..6473d79f7 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -926,5 +926,19 @@ describe("createResolver", () => { }); expectTypeOf(resolver).toExtend(); }); + + test("record output with a field named 'kind' is not confused with a descriptor", () => { + const resolver = createResolver({ + name: "withKindField", + operation: "query", + output: { + kind: t.string(), + name: t.string(), + }, + body: () => ({ kind: "category", name: "test" }), + }); + + expect(resolver.output.type).toBe("nested"); + }); }); }); diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index 673335abc..4233a3757 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -226,6 +226,18 @@ describe("createTable runtime metadata tests", () => { }); expect(result.fields.amount.metadata.scale).toBe(4); }); + + it("decimal scale rejects out-of-range values", () => { + expect(() => createTable("Test", { amount: { kind: "decimal", scale: -1 } })).toThrow( + "scale must be an integer between 0 and 12", + ); + expect(() => createTable("Test", { amount: { kind: "decimal", scale: 13 } })).toThrow( + "scale must be an integer between 0 and 12", + ); + expect(() => createTable("Test", { amount: { kind: "decimal", scale: 1.5 } })).toThrow( + "scale must be an integer between 0 and 12", + ); + }); }); describe("createTable relation tests", () => { diff --git a/packages/sdk/src/configure/services/tailordb/createTable.ts b/packages/sdk/src/configure/services/tailordb/createTable.ts index 487fada2c..fad2c2828 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -382,6 +382,9 @@ function buildField(descriptor: FieldDescriptor): TailorAnyDBField { } if (descriptor.kind === "decimal" && descriptor.scale !== undefined) { + if (!Number.isInteger(descriptor.scale) || descriptor.scale < 0 || descriptor.scale > 12) { + throw new Error("scale must be an integer between 0 and 12"); + } // oxlint-disable-next-line no-explicit-any -- decimal scale is set via internal metadata (field as any)._metadata.scale = descriptor.scale; } From 1f035b11de7623bd7246357476a78385f20b4d2a Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 2 Apr 2026 03:05:57 +0900 Subject: [PATCH 03/17] refactor(resolver): deduplicate KindToFieldType, optimize resolveResolverFieldMap, add boundary tests - Export KindToFieldType from descriptor.ts, remove duplicate in resolver.ts - Move isTailorField from closure to module-level function - Replace two-pass iteration in resolveResolverFieldMap with single-pass loop - Add decimal scale boundary value tests (0 and 12) for createTable --- .../configure/services/resolver/descriptor.ts | 19 ++++++----- .../configure/services/resolver/resolver.ts | 34 ++++++------------- .../services/tailordb/createTable.test.ts | 8 +++++ 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/packages/sdk/src/configure/services/resolver/descriptor.ts b/packages/sdk/src/configure/services/resolver/descriptor.ts index 055e00c07..110ac3bcd 100644 --- a/packages/sdk/src/configure/services/resolver/descriptor.ts +++ b/packages/sdk/src/configure/services/resolver/descriptor.ts @@ -24,7 +24,7 @@ const kindToFieldType = { object: "nested", } as const satisfies Record; -type KindToFieldType = typeof kindToFieldType; +export type KindToFieldType = typeof kindToFieldType; type KindToTsType = { [K in keyof KindToFieldType as K extends "enum" | "object" @@ -142,14 +142,17 @@ export function resolveResolverField(entry: ResolverFieldEntry): TailorAnyField export function resolveResolverFieldMap( entries: Record, ): Record { - // Fast path: if no descriptors are present, return the original object as-is - const hasDescriptors = Object.values(entries).some(isResolverFieldDescriptor); - if (!hasDescriptors) { - return entries as Record; + let hasDescriptor = false; + const resolved: Record = {}; + for (const [key, entry] of Object.entries(entries)) { + if (isPassthroughField(entry)) { + resolved[key] = entry; + } else { + hasDescriptor = true; + resolved[key] = buildResolverField(entry); + } } - return Object.fromEntries( - Object.entries(entries).map(([key, entry]) => [key, resolveResolverField(entry)]), - ); + return hasDescriptor ? resolved : (entries as Record); } function buildResolverField(descriptor: ResolverFieldDescriptor): TailorAnyField { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index cd070e09c..2f23c6240 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -5,6 +5,7 @@ import { type ResolverFieldDescriptor, type ResolvedResolverFieldMap, type ResolverDescriptorOutput, + type KindToFieldType, isResolverFieldDescriptor, resolveResolverFieldMap, resolveResolverField, @@ -41,20 +42,6 @@ type OutputType = O extends TailorAnyField * - If Output is a descriptor, resolve it to a TailorField * - If Output is a Record of fields, wrap it as a nested TailorField */ -type KindToFieldType = { - string: "string"; - int: "integer"; - float: "float"; - bool: "boolean"; - uuid: "uuid"; - decimal: "decimal"; - date: "date"; - datetime: "datetime"; - time: "time"; - enum: "enum"; - object: "nested"; -}; - type NormalizedOutput = Output extends TailorAnyField ? Output : Output extends ResolverFieldDescriptor @@ -159,26 +146,27 @@ export function createResolver< ); } +function isTailorField(obj: unknown): obj is TailorAnyField { + return ( + typeof obj === "object" && + obj !== null && + "type" in obj && + typeof (obj as { type: unknown }).type === "string" + ); +} + function resolveOutput( output: TailorAnyField | ResolverFieldDescriptor | Record, ): TailorAnyField { - // Check if it's a descriptor (has `kind` property but not a TailorField) if (isResolverFieldDescriptor(output as ResolverFieldEntry)) { return resolveResolverField(output as ResolverFieldDescriptor); } - // Check if it's already a TailorField (has `type` as string for field type) - const isTailorField = (obj: unknown): obj is TailorAnyField => - typeof obj === "object" && - obj !== null && - "type" in obj && - typeof (obj as { type: unknown }).type === "string"; - if (isTailorField(output)) { return output; } - // Otherwise it's a Record of fields - resolve each and wrap in t.object() + // Record of fields - resolve each and wrap in t.object() const resolvedFields = resolveResolverFieldMap(output as Record); return t.object(resolvedFields); } diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index 4233a3757..1c45226bd 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -238,6 +238,14 @@ describe("createTable runtime metadata tests", () => { "scale must be an integer between 0 and 12", ); }); + + it("decimal scale accepts boundary values 0 and 12", () => { + const low = createTable("Test", { amount: { kind: "decimal", scale: 0 } }); + expect(low.fields.amount.metadata.scale).toBe(0); + + const high = createTable("Test", { amount: { kind: "decimal", scale: 12 } }); + expect(high.fields.amount.metadata.scale).toBe(12); + }); }); describe("createTable relation tests", () => { From f378f5b76c23ccc6904cb7fb97913ce929fc0af0 Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 2 Apr 2026 16:22:16 +0900 Subject: [PATCH 04/17] fix(resolver,tailordb): reject unknown descriptor kind values at runtime Add runtime guards so that untyped callers (JS, JSON-driven schemas) get a clear error instead of silently producing fields with undefined type when passing an invalid kind like "strng". --- .../src/configure/services/resolver/descriptor.ts | 10 +++++++++- .../configure/services/resolver/resolver.test.ts | 15 +++++++++++++++ .../services/tailordb/createTable.test.ts | 11 +++++++++++ .../configure/services/tailordb/createTable.ts | 3 +++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/configure/services/resolver/descriptor.ts b/packages/sdk/src/configure/services/resolver/descriptor.ts index 110ac3bcd..f82f61aaf 100644 --- a/packages/sdk/src/configure/services/resolver/descriptor.ts +++ b/packages/sdk/src/configure/services/resolver/descriptor.ts @@ -115,7 +115,15 @@ export type ResolvedResolverFieldMap { expectTypeOf(resolver).toExtend(); }); + test("unknown kind in input throws an error", () => { + expect(() => + createResolver({ + name: "unknownKind", + operation: "query", + input: { + // @ts-expect-error testing runtime behavior with unknown kind + name: { kind: "strng" }, + }, + output: { kind: "bool" }, + body: () => true, + }), + ).toThrow('Unknown resolver field descriptor kind: "strng"'); + }); + test("record output with a field named 'kind' is not confused with a descriptor", () => { const resolver = createResolver({ name: "withKindField", diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index 1c45226bd..1102cdb1d 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -804,6 +804,17 @@ describe("createTable descriptor-level validate value typing", () => { }); }); +describe("createTable unknown descriptor kind", () => { + it("throws on unknown kind value", () => { + expect(() => + createTable("Test", { + // @ts-expect-error testing runtime behavior with unknown kind + name: { kind: "strng" }, + }), + ).toThrow('Unknown field descriptor kind: "strng"'); + }); +}); + describe("createTable mixed fluent and descriptor fields", () => { it("accepts both db.field() and descriptor in the same type", () => { const result = createTable("Test", { diff --git a/packages/sdk/src/configure/services/tailordb/createTable.ts b/packages/sdk/src/configure/services/tailordb/createTable.ts index fad2c2828..91101f603 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -322,6 +322,9 @@ function isValidateConfig(v: unknown): v is ValidateConfig { } function buildField(descriptor: FieldDescriptor): TailorAnyDBField { + if (!(descriptor.kind in kindToFieldType)) { + throw new Error(`Unknown field descriptor kind: "${String(descriptor.kind)}"`); + } const fieldType = kindToFieldType[descriptor.kind]; const options = { ...(descriptor.optional === true && { optional: true as const }), From 86dcf4856497ac21c87a6e7485ffc4ef00f4c795 Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 2 Apr 2026 16:43:55 +0900 Subject: [PATCH 05/17] fix(resolver,tailordb): validate enum descriptor values and document hook typing trade-off Reject enum descriptors that omit the required `values` array at runtime, preventing permissive fields from being silently created by untyped callers. Document the accepted trade-off that descriptor hook callbacks receive the base scalar type rather than the final output type adjusted for optional/array. --- .../src/configure/services/resolver/descriptor.ts | 3 +++ .../configure/services/resolver/resolver.test.ts | 15 +++++++++++++++ .../services/tailordb/createTable.test.ts | 9 +++++++++ .../configure/services/tailordb/createTable.ts | 7 +++++++ 4 files changed, 34 insertions(+) diff --git a/packages/sdk/src/configure/services/resolver/descriptor.ts b/packages/sdk/src/configure/services/resolver/descriptor.ts index f82f61aaf..56fa8d5f4 100644 --- a/packages/sdk/src/configure/services/resolver/descriptor.ts +++ b/packages/sdk/src/configure/services/resolver/descriptor.ts @@ -170,6 +170,9 @@ function buildResolverField(descriptor: ResolverFieldDescriptor): TailorAnyField ...(descriptor.array === true && { array: true as const }), }; const values = descriptor.kind === "enum" ? descriptor.values : undefined; + if (descriptor.kind === "enum" && (!Array.isArray(values) || values.length === 0)) { + throw new Error('Enum field descriptor requires a non-empty "values" array'); + } const nestedFields = descriptor.kind === "object" ? resolveResolverFieldMap(descriptor.fields) : undefined; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 26d7fb8d6..be1389ef0 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -942,6 +942,21 @@ describe("createResolver", () => { ).toThrow('Unknown resolver field descriptor kind: "strng"'); }); + test("enum descriptor without values throws an error", () => { + expect(() => + createResolver({ + name: "enumNoValues", + operation: "query", + input: { + // @ts-expect-error testing runtime behavior with missing values + status: { kind: "enum" }, + }, + output: { kind: "bool" }, + body: () => true, + }), + ).toThrow('Enum field descriptor requires a non-empty "values" array'); + }); + test("record output with a field named 'kind' is not confused with a descriptor", () => { const resolver = createResolver({ name: "withKindField", diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index 1102cdb1d..9cbd9a12f 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -813,6 +813,15 @@ describe("createTable unknown descriptor kind", () => { }), ).toThrow('Unknown field descriptor kind: "strng"'); }); + + it("throws on enum descriptor without values", () => { + expect(() => + createTable("Test", { + // @ts-expect-error testing runtime behavior with missing values + status: { kind: "enum" }, + }), + ).toThrow('Enum field descriptor requires a non-empty "values" array'); + }); }); describe("createTable mixed fluent and descriptor fields", () => { diff --git a/packages/sdk/src/configure/services/tailordb/createTable.ts b/packages/sdk/src/configure/services/tailordb/createTable.ts index 91101f603..395ad1e86 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -44,6 +44,10 @@ type KindToTsType = { : K]: TailorToTs[KindToFieldType[K]]; }; +// Hook and validate callbacks receive the base scalar type (e.g. `string`, `number`), not the +// final output type adjusted for `optional`/`array`. Computing the exact output type from +// descriptor flags would require a combinatorial explosion of type variants per kind; the fluent +// API achieves this through method chaining instead. Use `db.*()` when precise hook typing matters. type IndexableOptions = { unique?: boolean; index?: boolean; @@ -331,6 +335,9 @@ function buildField(descriptor: FieldDescriptor): TailorAnyDBField { ...(descriptor.array === true && { array: true as const }), }; const values = descriptor.kind === "enum" ? descriptor.values : undefined; + if (descriptor.kind === "enum" && (!Array.isArray(values) || values.length === 0)) { + throw new Error('Enum field descriptor requires a non-empty "values" array'); + } const nestedFields = descriptor.kind === "object" ? resolveFieldMap(descriptor.fields) : undefined; From a2fb4bc495ca311d041988561a8a361b1ed8746e Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 2 Apr 2026 17:07:26 +0900 Subject: [PATCH 06/17] fix(tailordb): fix array+hooks type collapse and reject malformed passthrough fields ValidateHookTypes now checks against DescriptorBaseOutput (base scalar) instead of DescriptorOutput (with array/optional applied), matching the IndexableOptions typing contract. Also reject plain objects without `kind` or `type` that would silently pass through as TailorDBField. --- .../services/tailordb/createTable.test.ts | 20 +++++++++++++++++++ .../services/tailordb/createTable.ts | 13 ++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index 9cbd9a12f..c43b91c1d 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -784,6 +784,17 @@ describe("createTable descriptor-level hooks value typing", () => { }, ); }); + + it("array descriptor with hooks does not collapse to never", () => { + const hooks: Hook = { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? ""; + }, + }; + const result = createTable("Test", { tags: { kind: "string", array: true, hooks } }); + expect(result.fields.tags.type).toBe("string"); + }); }); describe("createTable descriptor-level validate value typing", () => { @@ -822,6 +833,15 @@ describe("createTable unknown descriptor kind", () => { }), ).toThrow('Enum field descriptor requires a non-empty "values" array'); }); + + it("throws on plain object without kind or type", () => { + expect(() => + createTable("Test", { + // @ts-expect-error testing runtime behavior with malformed entry + name: { optional: true }, + }), + ).toThrow("Expected a field descriptor (with `kind`) or a db.*() field instance (with `type`)"); + }); }); describe("createTable mixed fluent and descriptor fields", () => { diff --git a/packages/sdk/src/configure/services/tailordb/createTable.ts b/packages/sdk/src/configure/services/tailordb/createTable.ts index 395ad1e86..ae7ca2e90 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -48,6 +48,8 @@ type KindToTsType = { // final output type adjusted for `optional`/`array`. Computing the exact output type from // descriptor flags would require a combinatorial explosion of type variants per kind; the fluent // API achieves this through method chaining instead. Use `db.*()` when precise hook typing matters. +// Note: inline validate lambdas may lose contextual typing due to the TS union +// `FieldValidateInput | FieldValidateInput[]`; hoist the validator if needed. type IndexableOptions = { unique?: boolean; index?: boolean; @@ -247,10 +249,12 @@ type RejectNestedInObject> = { : D[K]; }; -// Validates hook return types against the descriptor's output type at the call site. +// Validates hook return types against the descriptor's base output type (before array/optional) +// at the call site. Uses DescriptorBaseOutput to stay consistent with IndexableOptions, which +// types hooks with the base scalar (see comment above IndexableOptions). type ValidateHookTypes> = { [K in keyof D]: D[K] extends FieldDescriptor & { hooks: infer H } - ? H extends Hook> + ? H extends Hook> ? D[K] : never : D[K]; @@ -310,6 +314,11 @@ function isPassthroughField(entry: FieldEntry): entry is TailorAnyDBField { function resolveField(entry: FieldEntry): TailorAnyDBField { if (isPassthroughField(entry)) { + if (typeof (entry as { type?: unknown }).type !== "string") { + throw new Error( + "Expected a field descriptor (with `kind`) or a db.*() field instance (with `type`)", + ); + } return entry; } return buildField(entry); From 8c4b878fa6364ab7bf78e24266b048133c93aaba Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 2 Apr 2026 17:29:23 +0900 Subject: [PATCH 07/17] fix(resolver,tailordb): validate passthrough field entries have type and metadata Strengthen the passthrough field check to verify both `type` (string) and `metadata` (object) properties, catching plain objects that are neither descriptors nor real field instances. Apply the same guard to both resolver and tailordb descriptor paths. --- .../src/configure/services/resolver/descriptor.ts | 12 ++++++++++++ .../configure/services/resolver/resolver.test.ts | 15 +++++++++++++++ .../configure/services/tailordb/createTable.ts | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/configure/services/resolver/descriptor.ts b/packages/sdk/src/configure/services/resolver/descriptor.ts index 56fa8d5f4..ef7f82870 100644 --- a/packages/sdk/src/configure/services/resolver/descriptor.ts +++ b/packages/sdk/src/configure/services/resolver/descriptor.ts @@ -142,6 +142,12 @@ function isValidateConfig(v: unknown): v is ValidateConfig { export function resolveResolverField(entry: ResolverFieldEntry): TailorAnyField { if (isPassthroughField(entry)) { + const cast = entry as { type?: unknown; metadata?: unknown }; + if (typeof cast.type !== "string" || typeof cast.metadata !== "object" || !cast.metadata) { + throw new Error( + "Expected a field descriptor (with `kind`) or a t.*() field instance (with `type`)", + ); + } return entry; } return buildResolverField(entry); @@ -154,6 +160,12 @@ export function resolveResolverFieldMap( const resolved: Record = {}; for (const [key, entry] of Object.entries(entries)) { if (isPassthroughField(entry)) { + const cast = entry as { type?: unknown; metadata?: unknown }; + if (typeof cast.type !== "string" || typeof cast.metadata !== "object" || !cast.metadata) { + throw new Error( + `Expected a field descriptor (with \`kind\`) or a t.*() field instance (with \`type\`) for key "${key}"`, + ); + } resolved[key] = entry; } else { hasDescriptor = true; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index be1389ef0..25aa6b310 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -957,6 +957,21 @@ describe("createResolver", () => { ).toThrow('Enum field descriptor requires a non-empty "values" array'); }); + test("plain object without kind or type throws in input", () => { + expect(() => + createResolver({ + name: "malformed", + operation: "query", + input: { + // @ts-expect-error testing runtime behavior with malformed entry + name: { optional: true }, + }, + output: { kind: "bool" }, + body: () => true, + }), + ).toThrow("Expected a field descriptor"); + }); + test("record output with a field named 'kind' is not confused with a descriptor", () => { const resolver = createResolver({ name: "withKindField", diff --git a/packages/sdk/src/configure/services/tailordb/createTable.ts b/packages/sdk/src/configure/services/tailordb/createTable.ts index ae7ca2e90..67a3f6128 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -314,7 +314,8 @@ function isPassthroughField(entry: FieldEntry): entry is TailorAnyDBField { function resolveField(entry: FieldEntry): TailorAnyDBField { if (isPassthroughField(entry)) { - if (typeof (entry as { type?: unknown }).type !== "string") { + const cast = entry as { type?: unknown; metadata?: unknown }; + if (typeof cast.type !== "string" || typeof cast.metadata !== "object" || !cast.metadata) { throw new Error( "Expected a field descriptor (with `kind`) or a db.*() field instance (with `type`)", ); From 0478fdfc7b0cceec518f179eba2e4a6659b57c01 Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 2 Apr 2026 17:44:09 +0900 Subject: [PATCH 08/17] refactor(resolver): deduplicate resolveResolverFieldMap and remove obvious comments Delegate field resolution in resolveResolverFieldMap to resolveResolverField instead of inlining the same validation logic. Remove self-evident WHAT comments from createResolver and resolveOutput. Also fix pre-existing import order in processOrder.ts test fixture. --- .../__test_fixtures__/workflows/processOrder.ts | 2 +- .../src/configure/services/resolver/descriptor.ts | 12 ++---------- .../sdk/src/configure/services/resolver/resolver.ts | 4 ---- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/sdk/src/cli/commands/apply/__test_fixtures__/workflows/processOrder.ts b/packages/sdk/src/cli/commands/apply/__test_fixtures__/workflows/processOrder.ts index 5309b20c6..d104fa29a 100644 --- a/packages/sdk/src/cli/commands/apply/__test_fixtures__/workflows/processOrder.ts +++ b/packages/sdk/src/cli/commands/apply/__test_fixtures__/workflows/processOrder.ts @@ -1,5 +1,5 @@ -import { format } from "date-fns"; import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; +import { format } from "date-fns"; export const fetchDetails = createWorkflowJob({ name: "fetch-details", diff --git a/packages/sdk/src/configure/services/resolver/descriptor.ts b/packages/sdk/src/configure/services/resolver/descriptor.ts index ef7f82870..27c34bbb8 100644 --- a/packages/sdk/src/configure/services/resolver/descriptor.ts +++ b/packages/sdk/src/configure/services/resolver/descriptor.ts @@ -159,17 +159,9 @@ export function resolveResolverFieldMap( let hasDescriptor = false; const resolved: Record = {}; for (const [key, entry] of Object.entries(entries)) { - if (isPassthroughField(entry)) { - const cast = entry as { type?: unknown; metadata?: unknown }; - if (typeof cast.type !== "string" || typeof cast.metadata !== "object" || !cast.metadata) { - throw new Error( - `Expected a field descriptor (with \`kind\`) or a t.*() field instance (with \`type\`) for key "${key}"`, - ); - } - resolved[key] = entry; - } else { + resolved[key] = resolveResolverField(entry); + if (!hasDescriptor && isResolverFieldDescriptor(entry)) { hasDescriptor = true; - resolved[key] = buildResolverField(entry); } } return hasDescriptor ? resolved : (entries as Record); diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 2f23c6240..6deca9fcc 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -128,12 +128,9 @@ export function createResolver< body: (context: Context) => OutputType | Promise>; }>, ): ResolverReturn { - // Resolve input fields: convert descriptors to TailorField instances const resolvedInput = config.input ? resolveResolverFieldMap(config.input as Record) : undefined; - - // Resolve output: handle TailorField, descriptor, or Record const normalizedOutput = resolveOutput(config.output); return brandValue( @@ -166,7 +163,6 @@ function resolveOutput( return output; } - // Record of fields - resolve each and wrap in t.object() const resolvedFields = resolveResolverFieldMap(output as Record); return t.object(resolvedFields); } From 3e275c3b47840b1cdd85d8994eafffbba1774df9 Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 2 Apr 2026 20:21:35 +0900 Subject: [PATCH 09/17] revert: restore processOrder.ts import order to match main The import-x/order rule changed after merging main, making the original order (date-fns before @tailor-platform/sdk) correct again. --- .../commands/apply/__test_fixtures__/workflows/processOrder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/cli/commands/apply/__test_fixtures__/workflows/processOrder.ts b/packages/sdk/src/cli/commands/apply/__test_fixtures__/workflows/processOrder.ts index d104fa29a..5309b20c6 100644 --- a/packages/sdk/src/cli/commands/apply/__test_fixtures__/workflows/processOrder.ts +++ b/packages/sdk/src/cli/commands/apply/__test_fixtures__/workflows/processOrder.ts @@ -1,5 +1,5 @@ -import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; import { format } from "date-fns"; +import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; export const fetchDetails = createWorkflowJob({ name: "fetch-details", From d5be2b8df655184fd1dd5e2195e264ca859550f7 Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 2 Apr 2026 21:54:58 +0900 Subject: [PATCH 10/17] chore: add changeset for object-literal descriptor API --- .changeset/object-literal-descriptor-api.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/object-literal-descriptor-api.md diff --git a/.changeset/object-literal-descriptor-api.md b/.changeset/object-literal-descriptor-api.md new file mode 100644 index 000000000..4835d3c1d --- /dev/null +++ b/.changeset/object-literal-descriptor-api.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add object-literal descriptor API for TailorDB types (`createTable`) and resolver fields From 6910b23554448f7980f94ea39b0948054955eb7a Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 3 Apr 2026 16:18:36 +0900 Subject: [PATCH 11/17] test(tailordb): add type-level option tests for createTable Cover pluralForm (string and tuple), description, features, and gqlPermission options that were missing from the test suite. --- .../services/tailordb/createTable.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index c43b91c1d..21909ea06 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -1,5 +1,6 @@ import { describe, it, expectTypeOf, expect } from "vitest"; import { createTable, timestampFields } from "./createTable"; +import { unsafeAllowAllGqlPermission } from "./permission"; import { db } from "./schema"; import type { Hook } from "./types"; import type { output } from "@/configure/types/helpers"; @@ -903,3 +904,42 @@ describe("createTable type-level hooks/validate exclusion in options", () => { ); }); }); + +describe("createTable type-level options", () => { + it("pluralForm via options sets settings.pluralForm", () => { + const result = createTable("Person", { name: { kind: "string" } }, { pluralForm: "People" }); + expect(result.metadata.settings).toEqual({ pluralForm: "People" }); + }); + + it("pluralForm via tuple overload sets settings.pluralForm", () => { + const result = createTable(["Person", "People"], { name: { kind: "string" } }); + expect(result.metadata.settings).toEqual({ pluralForm: "People" }); + }); + + it("type-level description sets metadata.description", () => { + const result = createTable( + "Employee", + { name: { kind: "string" } }, + { description: "Company employee" }, + ); + expect(result.metadata.description).toBe("Company employee"); + }); + + it("features sets metadata.settings", () => { + const result = createTable( + "Order", + { total: { kind: "int" } }, + { features: { aggregation: true } }, + ); + expect(result.metadata.settings).toEqual({ aggregation: true }); + }); + + it("gqlPermission sets metadata.permissions.gql", () => { + const result = createTable( + "Secret", + { value: { kind: "string" } }, + { gqlPermission: unsafeAllowAllGqlPermission }, + ); + expect(result.metadata.permissions.gql).toBeDefined(); + }); +}); From 47ab17c0cf6340ddfb9fb0034372038c44923fb1 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 3 Apr 2026 16:42:00 +0900 Subject: [PATCH 12/17] docs: add createTable and descriptor syntax documentation Document the object-literal API (createTable, timestampFields) in tailordb.md and resolver field descriptors in resolver.md. Update CLAUDE.md code patterns to mention both API styles. --- CLAUDE.md | 2 +- packages/sdk/docs/services/resolver.md | 49 +++++++++++++++++++++++++- packages/sdk/docs/services/tailordb.md | 46 ++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3f4f82045..6377ef6ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ Refer to `example/` for working implementations of all patterns (config, models, Key files: - `example/tailor.config.ts` - Configuration with defineConfig, defineAuth, defineIdp, defineStaticWebSite, defineGenerators -- `example/tailordb/*.ts` - Model definitions with `db.type()` +- `example/tailordb/*.ts` - Model definitions with `db.type()` or `createTable` - `example/resolvers/*.ts` - Resolver implementations with `createResolver` - `example/executors/*.ts` - Executor implementations with `createExecutor` - `example/workflows/*.ts` - Workflow implementations with `createWorkflow` / `createWorkflowJob` diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index c3a009423..22d8f070f 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -103,7 +103,54 @@ export default createResolver({ ## Input/Output Schemas -Define input/output schemas using methods of `t` object. Basic usage and supported field types are the same as TailorDB. TailorDB-specific options (e.g., index, relation) are not supported. +Define input/output schemas using methods of `t` object or object-literal descriptors (`{ kind: "..." }`). Both styles can be mixed in the same resolver. + +### Fluent API (`t.*()`) + +```typescript +createResolver({ + input: { + name: t.string(), + age: t.int(), + }, + output: t.object({ name: t.string(), age: t.int() }), + // ... +}); +``` + +### Object-Literal Descriptors + +Use `{ kind: "..." }` syntax as a concise alternative. Supported options: `optional`, `array`, `description`, `validate`, and `typeName` (for enum/object). + +```typescript +createResolver({ + name: "addNumbers", + operation: "query", + input: { + a: { kind: "int", description: "First number" }, + b: { kind: "int", description: "Second number" }, + }, + output: { kind: "int", description: "Sum" }, + body: ({ input }) => input.a + input.b, +}); +``` + +### Mixing Styles + +Fluent and descriptor fields can be freely combined: + +```typescript +createResolver({ + input: { + name: t.string(), + status: { kind: "enum", values: ["active", "inactive"] }, + }, + output: t.object({ result: t.bool() }), + // ... +}); +``` + +### Reusing TailorDB Fields You can reuse fields defined with `db` object, but note that unsupported options will be ignored: diff --git a/packages/sdk/docs/services/tailordb.md b/packages/sdk/docs/services/tailordb.md index 99b46869d..a4fa9e6ea 100644 --- a/packages/sdk/docs/services/tailordb.md +++ b/packages/sdk/docs/services/tailordb.md @@ -25,6 +25,8 @@ Define TailorDB Types in files matching glob patterns specified in `tailor.confi - **Export both value and type**: Always export both the runtime value and TypeScript type - **Uniqueness**: Type names must be unique across all TailorDB files +### Fluent API (`db.type()`) + ```typescript import { db } from "@tailor-platform/sdk"; @@ -44,6 +46,50 @@ export const role = db.type("Role", { export type role = typeof role; ``` +### Object-Literal API (`createTable`) + +`createTable` provides an alternative syntax using plain object descriptors instead of method chaining. Each field is described with a `{ kind, ...options }` object. + +```typescript +import { createTable, timestampFields, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; + +export const order = createTable( + "Order", + { + name: { kind: "string" }, + quantity: { kind: "int", optional: true, index: true }, + status: { kind: "enum", values: ["pending", "shipped"] }, + address: { + kind: "object", + fields: { + city: { kind: "string" }, + zip: { kind: "string" }, + }, + }, + ...timestampFields(), + }, + { + permission: unsafeAllowAllTypePermission, + }, +); +export type order = typeof order; +``` + +**Signature:** `createTable(name, descriptors, options?)` + +- `name` - Type name (`string`) or `[name, pluralForm]` tuple +- `descriptors` - Field descriptors as `{ fieldName: { kind, ...options } }`. You can also mix in `db.*()` fields +- `options` - Optional type-level settings: `description`, `pluralForm`, `features`, `indexes`, `files`, `permission`, `gqlPermission`, `plugins`, `hooks`, `validate` + +Descriptor fields support all the same options as the fluent API: `optional`, `array`, `description`, `index`, `unique`, `hooks`, `validate`, `serial`, `vector`, and `relation`. + +**`timestampFields()` helper:** Returns `createdAt` (datetime, set on create) and `updatedAt` (optional datetime, set on update) descriptors. Equivalent to `db.fields.timestamps()` for the fluent API. + +**When to use which:** + +- Use `db.type()` when you need precise hook callback typing (the fluent API infers exact types for `optional`/`array` combinations) +- Use `createTable` for a more concise, declarative style when hook typing precision is not critical + Specify plural form by passing an array as first argument: ```typescript From f987b902e9df6fbc8c3ea266837faa3ed445810d Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 3 Apr 2026 16:42:06 +0900 Subject: [PATCH 13/17] feat(example): add Product type using createTable API Demonstrate the object-literal descriptor API with a Product model that includes enum, relation, timestamps, and permissions. --- example/generated/enums.ts | 7 +++++++ example/generated/tailordb.ts | 12 ++++++++++++ example/seed/data/Product.jsonl | 0 example/seed/data/Product.schema.ts | 23 +++++++++++++++++++++++ example/seed/exec.mjs | 2 ++ example/tailordb/product.ts | 28 ++++++++++++++++++++++++++++ 6 files changed, 72 insertions(+) create mode 100644 example/seed/data/Product.jsonl create mode 100644 example/seed/data/Product.schema.ts create mode 100644 example/tailordb/product.ts diff --git a/example/generated/enums.ts b/example/generated/enums.ts index a2e42e216..ea7a59624 100644 --- a/example/generated/enums.ts +++ b/example/generated/enums.ts @@ -14,6 +14,13 @@ export const InvoiceStatus = { } as const; export type InvoiceStatus = (typeof InvoiceStatus)[keyof typeof InvoiceStatus]; +export const ProductCategory = { + "electronics": "electronics", + "clothing": "clothing", + "food": "food" +} as const; +export type ProductCategory = (typeof ProductCategory)[keyof typeof ProductCategory]; + export const PurchaseOrderAttachedFilesType = { "text": "text", "image": "image" diff --git a/example/generated/tailordb.ts b/example/generated/tailordb.ts index db9ea0e47..42c16a824 100644 --- a/example/generated/tailordb.ts +++ b/example/generated/tailordb.ts @@ -60,6 +60,18 @@ export interface Namespace { updatedAt: Timestamp | null; } + Product: { + id: Generated; + name: string; + sku: string; + price: number; + stock: number; + category: "electronics" | "clothing" | "food"; + supplierId: string; + createdAt: Generated; + updatedAt: Timestamp | null; + } + PurchaseOrder: { id: Generated; supplierID: string; diff --git a/example/seed/data/Product.jsonl b/example/seed/data/Product.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/example/seed/data/Product.schema.ts b/example/seed/data/Product.schema.ts new file mode 100644 index 000000000..a4bd01ca2 --- /dev/null +++ b/example/seed/data/Product.schema.ts @@ -0,0 +1,23 @@ +import { t } from "@tailor-platform/sdk"; +import { defineSchema } from "@tailor-platform/sdk/seed"; +import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/test"; +import { product } from "../../tailordb/product"; + +const schemaType = t.object({ + ...product.pickFields(["id","createdAt"], { optional: true }), + ...product.omitFields(["id","createdAt"]), +}); + +const hook = createTailorDBHook(product); + +export const schema = defineSchema( + createStandardSchema(schemaType, hook), + { + foreignKeys: [ + {"column":"supplierId","references":{"table":"Supplier","column":"id"}}, + ], + indexes: [ + {"name":"product_sku_unique_idx","columns":["sku"],"unique":true}, + ], + } +); diff --git a/example/seed/exec.mjs b/example/seed/exec.mjs index 5daf85641..0a37435a4 100644 --- a/example/seed/exec.mjs +++ b/example/seed/exec.mjs @@ -144,6 +144,7 @@ const namespaceEntities = { "Customer", "Invoice", "NestedProfile", + "Product", "PurchaseOrder", "SalesOrder", "SalesOrderCreated", @@ -162,6 +163,7 @@ const namespaceDeps = { "Customer": [], "Invoice": ["SalesOrder"], "NestedProfile": [], + "Product": ["Supplier"], "PurchaseOrder": ["Supplier"], "SalesOrder": ["Customer", "User"], "SalesOrderCreated": [], diff --git a/example/tailordb/product.ts b/example/tailordb/product.ts new file mode 100644 index 000000000..05dc3af19 --- /dev/null +++ b/example/tailordb/product.ts @@ -0,0 +1,28 @@ +import { createTable, timestampFields } from "@tailor-platform/sdk"; +import { defaultGqlPermission, defaultPermission } from "./permissions"; +import { supplier } from "./supplier"; + +export const product = createTable( + "Product", + { + name: { kind: "string", description: "Product name" }, + sku: { kind: "string", unique: true, description: "Stock keeping unit" }, + price: { kind: "float" }, + stock: { kind: "int", index: true }, + category: { kind: "enum", values: ["electronics", "clothing", "food"] }, + supplierId: { + kind: "uuid", + relation: { + type: "n-1", + toward: { type: supplier }, + }, + }, + ...timestampFields(), + }, + { + description: "Product catalog entry", + permission: defaultPermission, + gqlPermission: defaultGqlPermission, + }, +); +export type product = typeof product; From 4f7cc0355243a2b4fa948b6d8afc52217cf052eb Mon Sep 17 00:00:00 2001 From: dqn Date: Sat, 4 Apr 2026 17:22:11 +0900 Subject: [PATCH 14/17] fix(tailordb): type array field hooks with correct output type Descriptor inline hooks now receive the array output type for array fields (e.g. Hook instead of Hook). - Introduce ScalarOrArrayHooks discriminated union that narrows hooks to Hook for scalar and Hook for array - Unify ValidatedDescriptors into a single mapped type to avoid combinatorial type explosion with the doubled descriptor union - Compute DescriptorHookOutput directly from field properties instead of intersecting with the FieldDescriptor union - Keep validate callbacks at base scalar type to preserve contextual typing for inline lambdas --- .../services/tailordb/createTable.test.ts | 18 +- .../services/tailordb/createTable.ts | 172 +++++++++--------- 2 files changed, 96 insertions(+), 94 deletions(-) diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index 21909ea06..9d111b563 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -786,16 +786,26 @@ describe("createTable descriptor-level hooks value typing", () => { ); }); - it("array descriptor with hooks does not collapse to never", () => { - const hooks: Hook = { + it("array string hooks value is typed as string[] | null", () => { + const hooks: Hook = { create: ({ value }) => { - expectTypeOf(value).toEqualTypeOf(); - return value ?? ""; + expectTypeOf(value).toEqualTypeOf(); + return value ?? []; }, }; const result = createTable("Test", { tags: { kind: "string", array: true, hooks } }); expect(result.fields.tags.type).toBe("string"); }); + + it("array int hooks value is typed as number[] | null", () => { + const hooks: Hook = { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? []; + }, + }; + createTable("Test", { counts: { kind: "int", array: true, hooks } }); + }); }); describe("createTable descriptor-level validate value typing", () => { diff --git a/packages/sdk/src/configure/services/tailordb/createTable.ts b/packages/sdk/src/configure/services/tailordb/createTable.ts index 67a3f6128..ada036d6c 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -18,7 +18,6 @@ import type { RelationType } from "@/types/tailordb"; type CommonFieldOptions = { optional?: boolean; - array?: boolean; description?: string; }; @@ -44,34 +43,43 @@ type KindToTsType = { : K]: TailorToTs[KindToFieldType[K]]; }; -// Hook and validate callbacks receive the base scalar type (e.g. `string`, `number`), not the -// final output type adjusted for `optional`/`array`. Computing the exact output type from -// descriptor flags would require a combinatorial explosion of type variants per kind; the fluent -// API achieves this through method chaining instead. Use `db.*()` when precise hook typing matters. -// Note: inline validate lambdas may lose contextual typing due to the TS union -// `FieldValidateInput | FieldValidateInput[]`; hoist the validator if needed. -type IndexableOptions = { +// Validate callbacks receive the base scalar type (e.g. `string`, `number`) +// regardless of array/optional flags. Inline validate lambdas may lose +// contextual typing due to the TS union `FieldValidateInput | +// FieldValidateInput[]`; hoist the validator if needed. +type FieldOptions = { unique?: boolean; index?: boolean; - hooks?: Hook; validate?: FieldValidateInput | FieldValidateInput[]; }; +// Hook callbacks receive the correct output type: base scalar for scalar fields, +// base scalar[] for array fields. The `optional` modifier does not affect hook +// typing because hooks always receive `TReturn | null`. +// Discriminated by `array: true` vs `array?: false` so TypeScript narrows to +// the correct hook type per field. +type ScalarOrArrayHooks = + | { array?: false; hooks?: Hook } + | { array: true; hooks?: Hook }; + type StringDescriptor = CommonFieldOptions & - IndexableOptions & { + FieldOptions & + ScalarOrArrayHooks & { kind: "string"; vector?: boolean; serial?: SerialConfig<"string">; }; type IntDescriptor = CommonFieldOptions & - IndexableOptions & { + FieldOptions & + ScalarOrArrayHooks & { kind: "int"; serial?: SerialConfig<"integer">; }; type SimpleDescriptor = CommonFieldOptions & - IndexableOptions & { + FieldOptions & + ScalarOrArrayHooks & { kind: K; }; @@ -81,13 +89,15 @@ type DateDescriptor = SimpleDescriptor<"date">; type DatetimeDescriptor = SimpleDescriptor<"datetime">; type TimeDescriptor = SimpleDescriptor<"time">; type DecimalDescriptor = CommonFieldOptions & - IndexableOptions & { + FieldOptions & + ScalarOrArrayHooks & { kind: "decimal"; scale?: number; }; type UuidDescriptor = CommonFieldOptions & - IndexableOptions & { + FieldOptions & + ScalarOrArrayHooks & { kind: "uuid"; relation?: { type: RelationType; @@ -103,7 +113,8 @@ type UuidDescriptor = CommonFieldOptions & }; type EnumDescriptor = CommonFieldOptions & - IndexableOptions> & { + FieldOptions> & + ScalarOrArrayHooks> & { kind: "enum"; values: V; typeName?: string; @@ -115,6 +126,7 @@ type EnumDescriptor = CommonFieldOption // are caught at deployment time by the platform. type ObjectDescriptor = CommonFieldOptions & { kind: "object"; + array?: boolean; fields: Record; typeName?: string; }; @@ -200,37 +212,6 @@ type ResolvedFieldMap> = { [K in keyof M]: ResolvedField; }; -// Rejects descriptors that combine array: true with index, unique, vector, or serial -// (all unsupported by the platform). -type RejectArrayCombinations> = { - [K in keyof D]: D[K] extends - | { array: true; unique: true } - | { array: true; index: true } - | { array: true; vector: true } - | { array: true; serial: object } - ? never - : D[K]; -}; - -// Rejects descriptors that combine hooks and serial (mutually exclusive in fluent API). -// The `kind: string` guard excludes TailorDBField instances whose hooks()/serial() methods extend `object`. -type RejectHooksWithSerial> = { - [K in keyof D]: D[K] extends { kind: string; hooks: object; serial: object } ? never : D[K]; -}; - -// Rejects unique: true on non-oneToOne uuid relations (platform rejects unique on n-1 relations). -type RejectUniqueOnManyRelation> = { - [K in keyof D]: D[K] extends { - kind: "uuid"; - unique: true; - relation: { type: infer T }; - } - ? T extends "oneToOne" | "1-1" - ? D[K] - : never - : D[K]; -}; - // Rejects nested objects inside object descriptors (matching ExcludeNestedDBFields in fluent API). type RejectNestedSubFields> = { [K in keyof F]: F[K] extends @@ -241,55 +222,66 @@ type RejectNestedSubFields> = { : F[K]; }; -type RejectNestedInObject> = { - [K in keyof D]: D[K] extends { kind: "object"; fields: infer F } - ? F extends Record - ? D[K] & { fields: RejectNestedSubFields } - : D[K] - : D[K]; -}; - -// Validates hook return types against the descriptor's base output type (before array/optional) -// at the call site. Uses DescriptorBaseOutput to stay consistent with IndexableOptions, which -// types hooks with the base scalar (see comment above IndexableOptions). -type ValidateHookTypes> = { - [K in keyof D]: D[K] extends FieldDescriptor & { hooks: infer H } - ? H extends Hook> - ? D[K] - : never - : D[K]; -}; +// Computes the hook output type from a descriptor's own properties (kind, +// array), without intersecting with the FieldDescriptor union. This avoids +// distributive type expansion that would produce a union of base types. +type DescriptorHookOutput = D extends { array: true } + ? D extends { kind: "enum"; values: infer V extends AllowedValues } + ? AllowedValuesOutput[] + : D extends { kind: infer K extends keyof KindToTsType } + ? KindToTsType[K][] + : unknown[] + : D extends { kind: "enum"; values: infer V extends AllowedValues } + ? AllowedValuesOutput + : D extends { kind: infer K extends keyof KindToTsType } + ? KindToTsType[K] + : unknown; -// Validates relation key against the target type's fields at the createTable call site. -// Every type implicitly has an `id` field, so `"id"` is always a valid key. -type ValidateRelationKeys> = { - [K in keyof D]: D[K] extends { - kind: "uuid"; - relation: { toward: { type: infer T; key: infer Key } }; - } - ? Key extends string - ? T extends TailorAnyDBType - ? Key extends (keyof T["fields"] & string) | "id" +// All descriptor-level validations in a single mapped type to minimize type +// evaluation passes (avoids combinatorial explosion with union descriptors). +type ValidatedDescriptors> = D & { + [K in keyof D]: D[K] extends // 1. RejectArrayCombinations: array + index/unique/vector/serial + | { array: true; unique: true } + | { array: true; index: true } + | { array: true; vector: true } + | { array: true; serial: object } + ? never + : // 2. RejectHooksWithSerial: hooks + serial are mutually exclusive + D[K] extends { kind: string; hooks: object; serial: object } + ? never + : // 3. RejectUniqueOnManyRelation: unique only allowed on oneToOne uuid relations + D[K] extends { kind: "uuid"; unique: true; relation: { type: infer T } } + ? T extends "oneToOne" | "1-1" ? D[K] : never - : T extends "self" - ? Key extends (keyof D & string) | "id" - ? D[K] - : never - : D[K] - : D[K] - : D[K]; + : // 4. RejectNestedInObject: no nested objects inside object fields + D[K] extends { kind: "object"; fields: infer F } + ? F extends Record + ? D[K] & { fields: RejectNestedSubFields } + : D[K] + : // 5. ValidateHookTypes: hook return type matches field output type. + // Infer H from D[K] directly (not via FieldDescriptor intersection) + // to avoid distributive type expansion from ScalarOrArray variants. + D[K] extends { kind: string; hooks: infer H } + ? H extends Hook> + ? D[K] + : never + : // 6. ValidateRelationKeys: relation key must exist in target type + D[K] extends { kind: "uuid"; relation: { toward: { type: infer T; key: infer Key } } } + ? Key extends string + ? T extends TailorAnyDBType + ? Key extends (keyof T["fields"] & string) | "id" + ? D[K] + : never + : T extends "self" + ? Key extends (keyof D & string) | "id" + ? D[K] + : never + : D[K] + : D[K] + : D[K]; }; -// Combined constraint: all descriptor-level validations applied at the createTable call site. -type ValidatedDescriptors> = D & - RejectArrayCombinations & - RejectHooksWithSerial & - RejectUniqueOnManyRelation & - RejectNestedInObject & - ValidateHookTypes & - ValidateRelationKeys; - type CreateTableOptions< FieldNames extends string = string, // oxlint-disable-next-line no-explicit-any From 20fe3d44d855fbbf1a67b40a0b35cda459bfe6ff Mon Sep 17 00:00:00 2001 From: dqn Date: Sun, 5 Apr 2026 07:07:14 +0900 Subject: [PATCH 15/17] fix(tailordb): add createTable overload for inline hook contextual typing TailorAnyDBField in FieldEntry union prevented TypeScript from narrowing FieldDescriptor during generic inference, causing inline hook callbacks to lose contextual typing (value resolved to any). Add a FieldDescriptor-only overload that TypeScript tries first, restoring correct type resolution for inline scalar, array, and datetime hooks. --- .../services/tailordb/createTable.test.ts | 102 +++++++++++++++++- .../services/tailordb/createTable.ts | 12 +++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index 9d111b563..23bbf64a0 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -1,6 +1,6 @@ import { describe, it, expectTypeOf, expect } from "vitest"; import { createTable, timestampFields } from "./createTable"; -import { unsafeAllowAllGqlPermission } from "./permission"; +import { unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "./permission"; import { db } from "./schema"; import type { Hook } from "./types"; import type { output } from "@/configure/types/helpers"; @@ -953,3 +953,103 @@ describe("createTable type-level options", () => { expect(result.metadata.permissions.gql).toBeDefined(); }); }); + +describe("createTable inline hook type auto-resolution", () => { + it("inline scalar string hook value is typed as string | null", () => { + createTable("Test", { + name: { + kind: "string", + hooks: { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? "default"; + }, + }, + }, + }); + }); + + it("inline array string hook value is typed as string[] | null", () => { + createTable("Test", { + tags: { + kind: "string", + array: true, + hooks: { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? []; + }, + }, + }, + }); + }); + + it("inline array int hook value is typed as number[] | null", () => { + createTable("Test", { + counts: { + kind: "int", + array: true, + hooks: { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? []; + }, + }, + }, + }); + }); + + it("type-level hook on scalar string resolves value as string | null", () => { + createTable( + "Test", + { name: { kind: "string" } }, + { + hooks: { + name: { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? "default"; + }, + }, + }, + permission: unsafeAllowAllTypePermission, + }, + ); + }); + + it("type-level hook on array string resolves value as string[] | null", () => { + createTable( + "Test", + { tags: { kind: "string", array: true } }, + { + hooks: { + tags: { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return value ?? []; + }, + }, + }, + permission: unsafeAllowAllTypePermission, + }, + ); + }); + + it("type-level hook on enum resolves value as literal union | null", () => { + createTable( + "Test", + { role: { kind: "enum", values: ["ADMIN", "USER"] } }, + { + hooks: { + role: { + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<"ADMIN" | "USER" | null>(); + return value ?? "USER"; + }, + }, + }, + permission: unsafeAllowAllTypePermission, + }, + ); + }); +}); diff --git a/packages/sdk/src/configure/services/tailordb/createTable.ts b/packages/sdk/src/configure/services/tailordb/createTable.ts index ada036d6c..54b75eae9 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -446,6 +446,18 @@ type AllFields> = { id: IdField } & Resolve * }); * export type user = typeof user; */ +// Overload 1: FieldDescriptor-only (provides full contextual typing for inline hooks) +export function createTable>( + name: string | [string, string], + descriptors: [D] extends [ValidatedDescriptors] ? D : ValidatedDescriptors, + options?: CreateTableOptions & string, AllFields>, +): TailorDBType>; +// Overload 2: mixed FieldDescriptor + TailorAnyDBField (fallback) +export function createTable>( + name: string | [string, string], + descriptors: [D] extends [ValidatedDescriptors] ? D : ValidatedDescriptors, + options?: CreateTableOptions & string, AllFields>, +): TailorDBType>; export function createTable>( name: string | [string, string], descriptors: [D] extends [ValidatedDescriptors] ? D : ValidatedDescriptors, From 4420200e7b15f746a7b1e11d1a4d6ec1b83d169f Mon Sep 17 00:00:00 2001 From: dqn Date: Sun, 5 Apr 2026 08:39:30 +0900 Subject: [PATCH 16/17] test(tailordb): document inline enum hook TS limitation with workaround tests Add tests showing that inline enum descriptor hooks cannot narrow value to the literal union (TS reverse-inference limitation), and document the two working workarounds: fluent API db.enum().hooks() and type-level options.hooks.. --- .../services/tailordb/createTable.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/sdk/src/configure/services/tailordb/createTable.test.ts b/packages/sdk/src/configure/services/tailordb/createTable.test.ts index 23bbf64a0..13ec0db01 100644 --- a/packages/sdk/src/configure/services/tailordb/createTable.test.ts +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -1035,6 +1035,28 @@ describe("createTable inline hook type auto-resolution", () => { ); }); + // Known TS limitation: inline enum hooks (descriptor-level) cannot narrow + // `value` to the literal union. The generic V in EnumDescriptor is not in + // a direct inference position when contextual-typing callbacks inside a mapped + // object parameter (TS reverse-inference limitation). The widened V causes a + // hook return-type mismatch (string vs literal union), making the descriptor + // collapse to `never`. + // + // Workarounds that correctly resolve enum literal types: + // 1. Type-level hooks: options.hooks. (tested below) + // 2. Fluent API: db.enum(...).hooks(...) (tested below) + + it("fluent enum hook value is typed as literal union | null", () => { + const role = db.enum(["ADMIN", "USER"]).hooks({ + create: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<"ADMIN" | "USER" | null>(); + return value ?? "USER"; + }, + }); + const result = createTable("Test", { role }); + expect(result.fields.role.type).toBe("enum"); + }); + it("type-level hook on enum resolves value as literal union | null", () => { createTable( "Test", From 216ef759d3f6474d719bb9731a20059e63007fc3 Mon Sep 17 00:00:00 2001 From: dqn Date: Sun, 5 Apr 2026 08:52:48 +0900 Subject: [PATCH 17/17] chore(example): generate migration for Product type --- example/migrations/0001/diff.json | 212 ++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 example/migrations/0001/diff.json diff --git a/example/migrations/0001/diff.json b/example/migrations/0001/diff.json new file mode 100644 index 000000000..9530ad042 --- /dev/null +++ b/example/migrations/0001/diff.json @@ -0,0 +1,212 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-04-04T23:52:28.003Z", + "changes": [ + { + "kind": "type_added", + "typeName": "Product", + "after": { + "name": "Product", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true, + "description": "Product name" + }, + "sku": { + "type": "string", + "required": true, + "index": true, + "unique": true, + "description": "Stock keeping unit" + }, + "price": { + "type": "float", + "required": true + }, + "stock": { + "type": "integer", + "required": true, + "index": true + }, + "category": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "electronics" + }, + { + "value": "clothing" + }, + { + "value": "food" + } + ] + }, + "supplierId": { + "type": "uuid", + "required": true, + "index": true, + "foreignKey": true, + "foreignKeyType": "Supplier", + "foreignKeyField": "id" + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + } + }, + "pluralForm": "Products", + "description": "Product catalog entry", + "settings": {}, + "forwardRelationships": { + "supplier": { + "targetType": "Supplier", + "targetField": "supplierId", + "sourceField": "id", + "isArray": false, + "description": "" + } + }, + "permissions": { + "record": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + }, + { + "conditions": [ + [ + { + "user": "_loggedIn" + }, + "eq", + true + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gql": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + }, + { + "conditions": [ + [ + { + "user": "_loggedIn" + }, + "eq", + true + ] + ], + "actions": ["read"], + "permit": "allow" + } + ] + } + } + }, + { + "kind": "relationship_added", + "typeName": "Supplier", + "relationshipName": "products", + "relationshipType": "backward", + "after": { + "targetType": "Product", + "targetField": "supplierId", + "sourceField": "id", + "isArray": true, + "description": "Product catalog entry" + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "requiresMigrationScript": false +}