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 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/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/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/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 +} 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; 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 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..27c34bbb8 --- /dev/null +++ b/packages/sdk/src/configure/services/resolver/descriptor.ts @@ -0,0 +1,212 @@ +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; + +export 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 { + if ("kind" in entry) { + if (!isResolverFieldDescriptor(entry)) { + throw new Error( + `Unknown resolver field descriptor kind: "${String((entry as { kind: unknown }).kind)}"`, + ); + } + return false; + } + return true; +} + +export function isResolverFieldDescriptor( + entry: ResolverFieldEntry, +): entry is ResolverFieldDescriptor { + return ( + "kind" in entry && + typeof (entry as { kind: unknown }).kind === "string" && + (entry as { kind: string }).kind in kindToFieldType + ); +} + +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)) { + 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); +} + +export function resolveResolverFieldMap( + entries: Record, +): Record { + let hasDescriptor = false; + const resolved: Record = {}; + for (const [key, entry] of Object.entries(entries)) { + resolved[key] = resolveResolverField(entry); + if (!hasDescriptor && isResolverFieldDescriptor(entry)) { + hasDescriptor = true; + } + } + return hasDescriptor ? resolved : (entries as Record); +} + +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; + 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; + + 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..25aa6b310 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -732,4 +732,258 @@ 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(); + }); + + 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("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("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", + 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/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index ffa24157f..6deca9fcc 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -1,42 +1,69 @@ import { t } from "@/configure/types/type"; import { brandValue } from "@/utils/brand"; +import { + type ResolverFieldEntry, + type ResolverFieldDescriptor, + type ResolvedResolverFieldMap, + type ResolverDescriptorOutput, + type KindToFieldType, + 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 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 +75,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 +92,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 +128,45 @@ 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"; - - const normalizedOutput = isTailorField(config.output) ? config.output : t.object(config.output); + const resolvedInput = config.input + ? resolveResolverFieldMap(config.input as Record) + : undefined; + const normalizedOutput = resolveOutput(config.output); return brandValue( { ...config, + input: resolvedInput, output: normalizedOutput, } as ResolverReturn, "resolver", ); } +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 { + if (isResolverFieldDescriptor(output as ResolverFieldEntry)) { + return resolveResolverField(output as ResolverFieldDescriptor); + } + + if (isTailorField(output)) { + return output; + } + + 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..13ec0db01 --- /dev/null +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -0,0 +1,1077 @@ +import { describe, it, expectTypeOf, expect } from "vitest"; +import { createTable, timestampFields } from "./createTable"; +import { unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "./permission"; +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); + }); + + 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", + ); + }); + + 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", () => { + 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"; + }, + }, + }, + }, + ); + }); + + it("array string hooks value is typed as string[] | null", () => { + 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"); + }); + + 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", () => { + 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 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"'); + }); + + 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'); + }); + + 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", () => { + 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, + }, + }, + ); + }); +}); + +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(); + }); +}); + +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, + }, + ); + }); + + // 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", + { 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 new file mode 100644 index 000000000..54b75eae9 --- /dev/null +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -0,0 +1,534 @@ +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; + 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]]; +}; + +// 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; + 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 & + FieldOptions & + ScalarOrArrayHooks & { + kind: "string"; + vector?: boolean; + serial?: SerialConfig<"string">; + }; + +type IntDescriptor = CommonFieldOptions & + FieldOptions & + ScalarOrArrayHooks & { + kind: "int"; + serial?: SerialConfig<"integer">; + }; + +type SimpleDescriptor = CommonFieldOptions & + FieldOptions & + ScalarOrArrayHooks & { + 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 & + FieldOptions & + ScalarOrArrayHooks & { + kind: "decimal"; + scale?: number; + }; + +type UuidDescriptor = CommonFieldOptions & + FieldOptions & + ScalarOrArrayHooks & { + 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 & + FieldOptions> & + ScalarOrArrayHooks> & { + 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"; + array?: boolean; + 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 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]; +}; + +// 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; + +// 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 + : // 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]; +}; + +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)) { + 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`)", + ); + } + 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 { + 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 }), + ...(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; + + 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) { + 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; + } + + 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; + */ +// 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, + 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 b6f96af83..a8af59f05 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],