diff --git a/.changeset/object-literal-descriptor-api.md b/.changeset/object-literal-descriptor-api.md new file mode 100644 index 000000000..ee4b511c7 --- /dev/null +++ b/.changeset/object-literal-descriptor-api.md @@ -0,0 +1,13 @@ +--- +"@tailor-platform/sdk": major +--- + +TailorDB API refactor: object-literal descriptor API and record-level hooks/validate + +- **New**: `createTable(name, fields, options?)` accepts object-literal field descriptors alongside the existing fluent API. +- **New**: Resolver fields accept object-literal descriptors. +- **Breaking**: Removed field-level `.hooks()` and `.validate()` from the TailorDB field builder (`db.string().hooks(...)`, `db.int().validate(...)`, etc.) and from field descriptors passed to `createTable`. +- **Breaking**: `createTable` type-level `hooks` / `validate` options are now **record-level** callbacks that receive the full record via `({ data, user }) => ...`. Hooks must return a complete record (spread incoming `data` to keep unchanged fields: `{ ...data, field: newValue }`). `validate` accepts a single function, a `[fn, message]` tuple, or an array of either. +- **Breaking**: `db.fields.timestamps()` / `timestampFields()` now returns fields only — it no longer installs automatic `create` / `update` hooks. Define record-level hooks explicitly to populate `createdAt` / `updatedAt`. + +Migration: move field-level hook/validate logic into record-level callbacks on the type. 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/e2e/executor.test.ts b/example/e2e/executor.test.ts index 0eec3e402..fcf1972b7 100644 --- a/example/e2e/executor.test.ts +++ b/example/e2e/executor.test.ts @@ -177,6 +177,7 @@ describe("dataplane", () => { email: "customer@example.com" country: "USA" postalCode: "12345" + fullAddress: "12345" state: "California" } ) { diff --git a/example/e2e/tailordb.test.ts b/example/e2e/tailordb.test.ts index 91499ca17..c15d05497 100644 --- a/example/e2e/tailordb.test.ts +++ b/example/e2e/tailordb.test.ts @@ -236,6 +236,7 @@ describe("dataplane", () => { email: "customer-${randomUUID()}@example.com" country: "USA" postalCode: "12345" + fullAddress: "12345" state: "California" } ) { @@ -419,6 +420,7 @@ describe("dataplane", () => { email: "customer@example.com" country: "USA" postalCode: "12345" + fullAddress: "12345" state: "California" } ) { @@ -535,6 +537,8 @@ describe("dataplane", () => { }); }); + // TODO(record-level-hooks): once the platform supports record-level hooks, + // remove the explicit fullAddress input and verify the hook computes it. test("custom hooks execute correctly", async () => { const query = gql` mutation { @@ -546,6 +550,7 @@ describe("dataplane", () => { postalCode: "12345" address: "123 Main St" city: "Los Angeles" + fullAddress: "12345 123 Main St Los Angeles" state: "California" } ) { @@ -577,6 +582,7 @@ describe("dataplane", () => { email: "bob@example.com" country: "USA" postalCode: "12345" + fullAddress: "12345" state: "California" } ) { diff --git a/example/executors/userRecordLog.ts b/example/executors/userRecordLog.ts index 9ee855bda..804506358 100644 --- a/example/executors/userRecordLog.ts +++ b/example/executors/userRecordLog.ts @@ -15,6 +15,8 @@ export default async ({ newRecord }: { newRecord: t.infer }) => { .values({ userID: newRecord.id, message: `User created: ${record?.name} (${record?.email})`, + createdAt: new Date(), + updatedAt: new Date(), }) .execute(); }; 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..adfdc9f3e 100644 --- a/example/generated/tailordb.ts +++ b/example/generated/tailordb.ts @@ -24,10 +24,10 @@ export interface Namespace { postalCode: string; address: string | null; city: string | null; - fullAddress: Generated; + fullAddress: string; state: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Invoice: { @@ -38,7 +38,7 @@ export interface Namespace { sequentialId: Serial; status: "draft" | "sent" | "paid" | "cancelled" | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } NestedProfile: { @@ -57,7 +57,19 @@ export interface Namespace { }>; archived: boolean | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; + } + + Product: { + id: Generated; + name: string; + sku: string; + price: number; + stock: number; + category: "electronics" | "clothing" | "food"; + supplierId: string; + createdAt: Generated; + updatedAt: Generated; } PurchaseOrder: { @@ -73,7 +85,7 @@ export interface Namespace { type: "text" | "image"; }[]; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrder: { @@ -86,7 +98,7 @@ export interface Namespace { cancelReason: string | null; canceledAt: Timestamp | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrderCreated: { @@ -115,7 +127,7 @@ export interface Namespace { state: "Alabama" | "Alaska"; city: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -126,7 +138,7 @@ export interface Namespace { department: string | null; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserLog: { @@ -134,7 +146,7 @@ export interface Namespace { userID: string; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserSetting: { @@ -142,7 +154,7 @@ export interface Namespace { language: "jp" | "en"; userID: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } }, "analyticsdb": { @@ -150,7 +162,7 @@ export interface Namespace { id: Generated; name: "CLICK" | "VIEW" | "PURCHASE"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } 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/migrations/0002/diff.json b/example/migrations/0002/diff.json new file mode 100644 index 000000000..9d3fe0f2f --- /dev/null +++ b/example/migrations/0002/diff.json @@ -0,0 +1,554 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-04-15T02:51:59.393Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "name", + "before": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value.length>5)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + }, + "after": { + "type": "string", + "required": true + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "city", + "before": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({value})=>value?value.length>1:true)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value?value.length>1:true`" + }, + { + "script": { + "expr": "(({value})=>value?value.length<100:true)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value?value.length<100:true`" + } + ] + }, + "after": { + "type": "string", + "required": false + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "fullAddress", + "before": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "update": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "string", + "required": true + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "Product", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "Product", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "attachedFiles", + "before": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value>0)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value>0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + }, + "after": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "createdAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "updatedAt", + "before": { + "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 } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "requiresMigrationScript": false +} diff --git a/example/migrations/0003/diff.json b/example/migrations/0003/diff.json new file mode 100644 index 000000000..a0fdfa1e3 --- /dev/null +++ b/example/migrations/0003/diff.json @@ -0,0 +1,410 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-04-15T03:25:31.444Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Product", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Product", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp" + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "new Date()" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp" + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "new Date()" + } + } + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "requiresMigrationScript": false +} diff --git a/example/migrations/0004/diff.json b/example/migrations/0004/diff.json new file mode 100644 index 000000000..2fe1338c5 --- /dev/null +++ b/example/migrations/0004/diff.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-04-15T04:08:49.948Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "name", + "before": { + "type": "string", + "required": true + }, + "after": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({data})=>data.name.length>5)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "Name must be longer than 5 characters" + }, + { + "script": { + "expr": "(({data})=>data.city?data.city.length>1&&data.city.length<100:true)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({data})=>data.city?data.city.length>1&&data.city.length<100:true`" + } + ] + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "requiresMigrationScript": false +} diff --git a/example/resolvers/insertNestedProfileWithDate.ts b/example/resolvers/insertNestedProfileWithDate.ts index 8ca0e1101..4add73cce 100644 --- a/example/resolvers/insertNestedProfileWithDate.ts +++ b/example/resolvers/insertNestedProfileWithDate.ts @@ -30,6 +30,8 @@ export default createResolver({ created: new Date(), version: 1, }, + createdAt: new Date(), + updatedAt: new Date(), }) .returning("id") .executeTakeFirstOrThrow(); diff --git a/example/seed/data/Customer.jsonl b/example/seed/data/Customer.jsonl index bbb6aacc5..1d7f20fcb 100644 --- a/example/seed/data/Customer.jsonl +++ b/example/seed/data/Customer.jsonl @@ -1,5 +1,5 @@ -{"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","name":"Acme Corporation","email":"contact@acme.com","phone":"03-1234-5678","country":"Japan","postalCode":"100-0001","address":"Chiyoda-ku","city":"Tokyo","state":"Tokyo"} -{"id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","name":"Global Tech Inc","email":"info@globaltech.com","phone":"03-9876-5432","country":"Japan","postalCode":"150-0002","address":"Shibuya-ku","city":"Tokyo","state":"Tokyo"} -{"id":"cccccccc-cccc-cccc-cccc-cccccccccccc","name":"Enterprise Solutions Ltd","email":"sales@enterprise.com","phone":"06-1111-2222","country":"Japan","postalCode":"530-0001","address":"Kita-ku","city":"Osaka","state":"Osaka"} -{"id":"dddddddd-dddd-dddd-dddd-dddddddddddd","name":"Digital Services Co","email":"hello@digital.com","country":"Japan","postalCode":"810-0001","address":"Chuo-ku","city":"Fukuoka","state":"Fukuoka"} -{"id":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee","name":"Innovation Partners","email":"contact@innovation.com","phone":"011-3333-4444","country":"Japan","postalCode":"060-0001","address":"Chuo-ku","city":"Sapporo","state":"Hokkaido"} +{"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","name":"Acme Corporation","email":"contact@acme.com","phone":"03-1234-5678","country":"Japan","postalCode":"100-0001","address":"Chiyoda-ku","city":"Tokyo","state":"Tokyo","fullAddress":"100-0001 Chiyoda-ku Tokyo"} +{"id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","name":"Global Tech Inc","email":"info@globaltech.com","phone":"03-9876-5432","country":"Japan","postalCode":"150-0002","address":"Shibuya-ku","city":"Tokyo","state":"Tokyo","fullAddress":"150-0002 Shibuya-ku Tokyo"} +{"id":"cccccccc-cccc-cccc-cccc-cccccccccccc","name":"Enterprise Solutions Ltd","email":"sales@enterprise.com","phone":"06-1111-2222","country":"Japan","postalCode":"530-0001","address":"Kita-ku","city":"Osaka","state":"Osaka","fullAddress":"530-0001 Kita-ku Osaka"} +{"id":"dddddddd-dddd-dddd-dddd-dddddddddddd","name":"Digital Services Co","email":"hello@digital.com","country":"Japan","postalCode":"810-0001","address":"Chuo-ku","city":"Fukuoka","state":"Fukuoka","fullAddress":"810-0001 Chuo-ku Fukuoka"} +{"id":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee","name":"Innovation Partners","email":"contact@innovation.com","phone":"011-3333-4444","country":"Japan","postalCode":"060-0001","address":"Chuo-ku","city":"Sapporo","state":"Hokkaido","fullAddress":"060-0001 Chuo-ku Sapporo"} diff --git a/example/seed/data/Customer.schema.ts b/example/seed/data/Customer.schema.ts index 756c2f29e..759fb7af7 100644 --- a/example/seed/data/Customer.schema.ts +++ b/example/seed/data/Customer.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { customer } from "../../tailordb/customer"; const schemaType = t.object({ - ...customer.pickFields(["id","fullAddress","createdAt"], { optional: true }), - ...customer.omitFields(["id","fullAddress","createdAt"]), + ...customer.pickFields(["id","createdAt"], { optional: true }), + ...customer.omitFields(["id","createdAt"]), }); const hook = createTailorDBHook(customer); 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/customer.ts b/example/tailordb/customer.ts index efde41cb1..65a61303e 100644 --- a/example/tailordb/customer.ts +++ b/example/tailordb/customer.ts @@ -9,22 +9,26 @@ export const customer = db country: db.string(), postalCode: db.string(), address: db.string({ optional: true }), - city: db.string({ optional: true }).validate( - ({ value }) => (value ? value.length > 1 : true), - ({ value }) => (value ? value.length < 100 : true), - ), + city: db.string({ optional: true }), fullAddress: db.string(), state: db.string(), ...db.fields.timestamps(), }) .hooks({ - fullAddress: { - create: ({ data }) => `${data.postalCode} ${data.address} ${data.city}`, - update: ({ data }) => `${data.postalCode} ${data.address} ${data.city}`, - }, - }) - .validate({ - name: [({ value }) => value.length > 5, "Name must be longer than 5 characters"], + create: ({ data }) => ({ + ...data, + fullAddress: `${data.postalCode} ${data.address ?? ""} ${data.city ?? ""}`, + createdAt: new Date(), + }), + update: ({ data }) => ({ + ...data, + fullAddress: `${data.postalCode} ${data.address ?? ""} ${data.city ?? ""}`, + updatedAt: new Date(), + }), }) + .validate([ + [({ data }) => data.name.length > 5, "Name must be longer than 5 characters"], + ({ data }) => (data.city ? data.city.length > 1 && data.city.length < 100 : true), + ]) .permission(defaultPermission) .gqlPermission(defaultGqlPermission); diff --git a/example/tailordb/file.ts b/example/tailordb/file.ts index 38726367c..5c21c6458 100644 --- a/example/tailordb/file.ts +++ b/example/tailordb/file.ts @@ -1,10 +1,14 @@ import { db } from "@tailor-platform/sdk"; +// NOTE: field-level `.validate()` has been removed from the public API. +// Nested object sub-fields can no longer carry inline validators; enforce +// constraints at the record level on the enclosing type via +// `db.type(...).validate(...)` instead. export const attachedFiles = db.object( { id: db.uuid(), name: db.string(), - size: db.int().validate(({ value }) => value > 0), + size: db.int(), type: db.enum(["text", "image"]), }, { array: true }, 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/example/tests/bundled_execution.test.ts b/example/tests/bundled_execution.test.ts index cdcfa09fd..828f366fe 100644 --- a/example/tests/bundled_execution.test.ts +++ b/example/tests/bundled_execution.test.ts @@ -140,8 +140,14 @@ describe("bundled execution tests", () => { expect(executedQueries).toEqual([ { query: 'select * from "User" where "id" = $1', params: ["user-1"] }, { - query: 'insert into "UserLog" ("userID", "message") values ($1, $2)', - params: ["user-1", "User created: undefined (undefined)"], + query: + 'insert into "UserLog" ("userID", "message", "createdAt", "updatedAt") values ($1, $2, $3, $4)', + params: [ + "user-1", + "User created: undefined (undefined)", + new Date("2025-10-06T12:34:56.000Z"), + new Date("2025-10-06T12:34:56.000Z"), + ], }, ]); expect(createdClients).toMatchObject([{ namespace: "tailordb" }]); diff --git a/packages/create-sdk/templates/executor/src/executor/shared.ts b/packages/create-sdk/templates/executor/src/executor/shared.ts index 0483a944b..0a90f8159 100644 --- a/packages/create-sdk/templates/executor/src/executor/shared.ts +++ b/packages/create-sdk/templates/executor/src/executor/shared.ts @@ -9,5 +9,8 @@ interface AuditLogInput { export async function createAuditLog(input: AuditLogInput): Promise { const db = getDB("main-db"); - await db.insertInto("AuditLog").values(input).execute(); + await db + .insertInto("AuditLog") + .values({ ...input, createdAt: new Date(), updatedAt: new Date() }) + .execute(); } diff --git a/packages/create-sdk/templates/executor/src/generated/db.ts b/packages/create-sdk/templates/executor/src/generated/db.ts index 1bdcc0d1f..637fef2a7 100644 --- a/packages/create-sdk/templates/executor/src/generated/db.ts +++ b/packages/create-sdk/templates/executor/src/generated/db.ts @@ -20,7 +20,7 @@ export interface Namespace { entityId: string; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Notification: { @@ -30,7 +30,7 @@ export interface Namespace { body: string; isRead: boolean; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -39,7 +39,7 @@ export interface Namespace { email: string; role: "ADMIN" | "MEMBER"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/generators/src/generated/db.ts b/packages/create-sdk/templates/generators/src/generated/db.ts index 0da558bfe..7db4b82e5 100644 --- a/packages/create-sdk/templates/generators/src/generated/db.ts +++ b/packages/create-sdk/templates/generators/src/generated/db.ts @@ -28,7 +28,7 @@ export interface Namespace { totalPrice: number; status: "PENDING" | "CONFIRMED" | "SHIPPED" | "DELIVERED" | "CANCELLED"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Product: { @@ -39,7 +39,7 @@ export interface Namespace { status: "DRAFT" | "ACTIVE" | "DISCONTINUED"; categoryId: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -48,7 +48,7 @@ export interface Namespace { email: string; role: "ADMIN" | "MEMBER" | "VIEWER"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts b/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts index c42a19651..b4ea6f9d6 100644 --- a/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts +++ b/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts @@ -19,7 +19,7 @@ export interface Namespace { email: string; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/inventory-management/src/db/inventory.ts b/packages/create-sdk/templates/inventory-management/src/db/inventory.ts index 6deb5d780..77e0ddd9e 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/inventory.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/inventory.ts @@ -8,11 +8,9 @@ export const inventory = db .uuid() .description("ID of the product") .relation({ type: "1-1", toward: { type: product } }), - quantity: db - .int() - .description("Quantity of the product in inventory") - .validate(({ value }) => value >= 0), + quantity: db.int().description("Quantity of the product in inventory"), ...db.fields.timestamps(), }) + .validate(({ data }) => data.quantity >= 0) .permission(permissionLoggedIn) .gqlPermission(gqlPermissionLoggedIn); diff --git a/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts b/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts index 2fc8c572e..cee6986ff 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts @@ -13,22 +13,24 @@ export const orderItem = db .uuid() .description("ID of the product") .relation({ type: "n-1", toward: { type: product } }), - quantity: db - .int() - .description("Quantity of the product") - .validate(({ value }) => value >= 0), - unitPrice: db - .float() - .description("Unit price of the product") - .validate(({ value }) => value >= 0), + quantity: db.int().description("Quantity of the product"), + unitPrice: db.float().description("Unit price of the product"), totalPrice: db.float({ optional: true }).description("Total price of the order item"), ...db.fields.timestamps(), }) .hooks({ - totalPrice: { - create: ({ data }) => (data?.quantity ?? 0) * (data.unitPrice ?? 0), - update: ({ data }) => (data?.quantity ?? 0) * (data.unitPrice ?? 0), - }, + create: ({ data }) => ({ + ...data, + totalPrice: data.quantity * data.unitPrice, + createdAt: new Date(), + updatedAt: new Date(), + }), + update: ({ data }) => ({ + ...data, + totalPrice: data.quantity * data.unitPrice, + updatedAt: new Date(), + }), }) + .validate([({ data }) => data.quantity >= 0, ({ data }) => data.unitPrice >= 0]) .permission(permissionLoggedIn) .gqlPermission(gqlPermissionLoggedIn); diff --git a/packages/create-sdk/templates/inventory-management/src/executor/checkInventory.ts b/packages/create-sdk/templates/inventory-management/src/executor/checkInventory.ts index 93bc9d1cf..78ff705da 100644 --- a/packages/create-sdk/templates/inventory-management/src/executor/checkInventory.ts +++ b/packages/create-sdk/templates/inventory-management/src/executor/checkInventory.ts @@ -19,6 +19,8 @@ export default createExecutor({ .insertInto("Notification") .values({ message: `Inventory for product ${newRecord.productId} is below threshold. Current quantity: ${newRecord.quantity}`, + createdAt: new Date(), + updatedAt: new Date(), }) .execute(); }, diff --git a/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts b/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts index 001ea023e..04068b73b 100644 --- a/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts +++ b/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts @@ -18,7 +18,7 @@ export interface Namespace { name: string; description: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Contact: { @@ -28,7 +28,7 @@ export interface Namespace { phone: string | null; address: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Inventory: { @@ -36,14 +36,14 @@ export interface Namespace { productId: string; quantity: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Notification: { id: Generated; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Order: { @@ -54,7 +54,7 @@ export interface Namespace { orderType: "PURCHASE" | "SALES"; contactId: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } OrderItem: { @@ -63,9 +63,9 @@ export interface Namespace { productId: string; quantity: number; unitPrice: number; - totalPrice: Generated; + totalPrice: number | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Product: { @@ -74,7 +74,7 @@ export interface Namespace { description: string | null; categoryId: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -83,7 +83,7 @@ export interface Namespace { email: string; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts b/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts index ee1f332a7..416d9febd 100644 --- a/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts +++ b/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts @@ -16,7 +16,7 @@ const insertOrder = async (db: DB<"main-db">, input: Input) => { // Insert Order const order = await db .insertInto("Order") - .values(input.order) + .values({ ...input.order, createdAt: new Date() }) .returning("id") .executeTakeFirstOrThrow(); @@ -27,6 +27,7 @@ const insertOrder = async (db: DB<"main-db">, input: Input) => { input.items.map((item) => ({ ...item, orderId: order.id, + createdAt: new Date(), })), ) .execute(); @@ -63,6 +64,8 @@ const updateInventory = async (db: DB<"main-db">, input: Input) => { .values({ productId: item.productId, quantity: item.quantity, + createdAt: new Date(), + updatedAt: new Date(), }) .execute(); } else { diff --git a/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts b/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts index b3f5997c3..07057ce64 100644 --- a/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts +++ b/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts @@ -8,9 +8,21 @@ export const adminNote = db .type("AdminNote", { title: db.string(), content: db.string(), - authorId: db.uuid().hooks({ create: ({ user }) => user.id }), + authorId: db.uuid(), ...db.fields.timestamps(), }) + .hooks({ + create: ({ data, user }) => ({ + ...data, + authorId: user.id, + createdAt: new Date(), + updatedAt: new Date(), + }), + update: ({ data }) => ({ + ...data, + updatedAt: new Date(), + }), + }) // NOTE: This permits all operations for simplicity. // In production, configure proper permissions based on your requirements. .permission(unsafeAllowAllTypePermission) diff --git a/packages/create-sdk/templates/resolver/src/generated/db.ts b/packages/create-sdk/templates/resolver/src/generated/db.ts index e36ba8aa9..b1467d05b 100644 --- a/packages/create-sdk/templates/resolver/src/generated/db.ts +++ b/packages/create-sdk/templates/resolver/src/generated/db.ts @@ -19,7 +19,7 @@ export interface Namespace { email: string; age: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/tailordb/src/db/comment.ts b/packages/create-sdk/templates/tailordb/src/db/comment.ts index 5f8f18067..f88168120 100644 --- a/packages/create-sdk/templates/tailordb/src/db/comment.ts +++ b/packages/create-sdk/templates/tailordb/src/db/comment.ts @@ -5,7 +5,7 @@ import { user } from "./user"; export const comment = db .type("Comment", "A comment on a task", { - body: db.string().validate([({ value }) => value.length >= 1, "Comment must not be empty"]), + body: db.string(), taskId: db.uuid().relation({ type: "n-1", toward: { type: task }, @@ -22,5 +22,6 @@ export const comment = db ...db.fields.timestamps(), }) .indexes({ fields: ["taskId", "createdAt"], unique: false }) + .validate([({ data }) => data.body.length >= 1, "Comment must not be empty"]) .permission(allPermission) .gqlPermission(allGqlPermission); diff --git a/packages/create-sdk/templates/tailordb/src/db/task.ts b/packages/create-sdk/templates/tailordb/src/db/task.ts index 4e4a56143..970d506d0 100644 --- a/packages/create-sdk/templates/tailordb/src/db/task.ts +++ b/packages/create-sdk/templates/tailordb/src/db/task.ts @@ -5,12 +5,7 @@ import { user } from "./user"; export const task = db .type("Task", "A task with comprehensive features", { - title: db - .string() - .validate( - [({ value }) => value.length >= 3, "Title must be at least 3 characters"], - [({ value }) => value.length <= 200, "Title must be at most 200 characters"], - ), + title: db.string(), description: db.string({ optional: true }), status: db.enum([ { value: "TODO", description: "Not started" }, @@ -18,12 +13,7 @@ export const task = db { value: "DONE", description: "Completed" }, { value: "CANCELLED", description: "No longer needed" }, ]), - priority: db - .int() - .validate( - [({ value }) => value >= 0, "Priority must be non-negative"], - [({ value }) => value <= 4, "Priority must be at most 4"], - ), + priority: db.int(), dueDate: db.datetime({ optional: true }), assigneeId: db.uuid({ optional: true }).relation({ type: "n-1", @@ -37,22 +27,30 @@ export const task = db ...db.fields.timestamps(), }) .hooks({ - isArchived: { - create: () => false, - }, + create: ({ data }) => ({ + ...data, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }), + update: ({ data }) => ({ + ...data, + updatedAt: new Date(), + }), }) .indexes( { fields: ["status", "priority"], unique: false }, { fields: ["assigneeId", "status"], unique: false, name: "task_assignee_status_idx" }, ) - .validate({ - status: [ - ({ value, data }) => { - const d = data as { dueDate: string | null }; - return !(value === "DONE" && d.dueDate === null); - }, + .validate([ + [({ data }) => data.title.length >= 3, "Title must be at least 3 characters"], + [({ data }) => data.title.length <= 200, "Title must be at most 200 characters"], + [({ data }) => data.priority >= 0, "Priority must be non-negative"], + [({ data }) => data.priority <= 4, "Priority must be at most 4"], + [ + ({ data }) => !(data.status === "DONE" && data.dueDate === null), "Completed tasks must have a due date", ], - }) + ]) .permission(rolePermission) .gqlPermission(roleGqlPermission); diff --git a/packages/create-sdk/templates/tailordb/src/generated/db.ts b/packages/create-sdk/templates/tailordb/src/generated/db.ts index f68627d27..7c0bc0301 100644 --- a/packages/create-sdk/templates/tailordb/src/generated/db.ts +++ b/packages/create-sdk/templates/tailordb/src/generated/db.ts @@ -32,7 +32,7 @@ export interface Namespace { isInternal: boolean; }>; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Task: { @@ -44,9 +44,9 @@ export interface Namespace { dueDate: Timestamp | null; assigneeId: string | null; categoryId: string | null; - isArchived: Generated; + isArchived: boolean; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -56,7 +56,7 @@ export interface Namespace { role: "ADMIN" | "MEMBER" | "VIEWER"; bio: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/workflow/src/generated/db.ts b/packages/create-sdk/templates/workflow/src/generated/db.ts index 51e0e14cb..f01175f35 100644 --- a/packages/create-sdk/templates/workflow/src/generated/db.ts +++ b/packages/create-sdk/templates/workflow/src/generated/db.ts @@ -19,7 +19,7 @@ export interface Namespace { amount: number; status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -28,7 +28,7 @@ export interface Namespace { email: string; age: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/workflow/src/workflow/sync-profile.ts b/packages/create-sdk/templates/workflow/src/workflow/sync-profile.ts index f0825f133..94603a3be 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/sync-profile.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/sync-profile.ts @@ -29,7 +29,7 @@ function createDbOperations(db: DB<"main-db">): DbOperations { createUser: async (input: UserProfile) => { return await db .insertInto("User") - .values(input) + .values({ ...input, createdAt: new Date(), updatedAt: new Date() }) .returning(["id", "name", "email", "age", "createdAt", "updatedAt"]) .executeTakeFirstOrThrow(); }, diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index b9ff644a2..cbdeeaf21 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/scripts/perf/features/tailordb-hooks.ts b/packages/sdk/scripts/perf/features/tailordb-hooks.ts index 4247896b3..2d9889ed3 100644 --- a/packages/sdk/scripts/perf/features/tailordb-hooks.ts +++ b/packages/sdk/scripts/perf/features/tailordb-hooks.ts @@ -1,66 +1,116 @@ /** * TailorDB Hooks Performance Test * - * Tests type inference cost for field hooks (create, update) + * Tests type inference cost for record-level hooks (create, update) */ import { db } from "../../../src/configure"; -export const type0 = db.type("Type0", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type0 = db + .type("Type0", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type1 = db.type("Type1", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type1 = db + .type("Type1", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type2 = db.type("Type2", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type2 = db + .type("Type2", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type3 = db.type("Type3", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type3 = db + .type("Type3", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type4 = db.type("Type4", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type4 = db + .type("Type4", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type5 = db.type("Type5", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type5 = db + .type("Type5", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type6 = db.type("Type6", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type6 = db + .type("Type6", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type7 = db.type("Type7", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type7 = db + .type("Type7", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type8 = db.type("Type8", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type8 = db + .type("Type8", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); -export const type9 = db.type("Type9", { - name: db.string().hooks({ create: () => "default" }), - createdAt: db.datetime().hooks({ create: () => new Date() }), - updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), -}); +export const type9 = db + .type("Type9", { + name: db.string(), + createdAt: db.datetime(), + updatedAt: db.datetime({ optional: true }), + }) + .hooks({ + create: ({ data }) => ({ ...data, createdAt: new Date() }), + update: ({ data }) => ({ ...data, updatedAt: new Date() }), + }); diff --git a/packages/sdk/scripts/perf/features/tailordb-validate.ts b/packages/sdk/scripts/perf/features/tailordb-validate.ts index e4eacf505..e531ab994 100644 --- a/packages/sdk/scripts/perf/features/tailordb-validate.ts +++ b/packages/sdk/scripts/perf/features/tailordb-validate.ts @@ -1,66 +1,126 @@ /** * TailorDB Validation Rules Performance Test * - * Tests type inference cost for field validation + * Tests type inference cost for record-level validation */ import { db } from "../../../src/configure"; -export const type0 = db.type("Type0", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type0 = db + .type("Type0", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type1 = db.type("Type1", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type1 = db + .type("Type1", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type2 = db.type("Type2", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type2 = db + .type("Type2", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type3 = db.type("Type3", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type3 = db + .type("Type3", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type4 = db.type("Type4", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type4 = db + .type("Type4", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type5 = db.type("Type5", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type5 = db + .type("Type5", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type6 = db.type("Type6", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type6 = db + .type("Type6", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type7 = db.type("Type7", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type7 = db + .type("Type7", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type8 = db.type("Type8", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type8 = db + .type("Type8", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); -export const type9 = db.type("Type9", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), -}); +export const type9 = db + .type("Type9", { + name: db.string(), + email: db.string(), + age: db.int(), + }) + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Must be valid email"], + ({ data }) => data.age >= 0, + ]); diff --git a/packages/sdk/src/cli/commands/apply/tailordb/index.ts b/packages/sdk/src/cli/commands/apply/tailordb/index.ts index 3b68c333f..0baca5c8e 100644 --- a/packages/sdk/src/cli/commands/apply/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/apply/tailordb/index.ts @@ -1687,6 +1687,16 @@ function generateTailorDBTypeManifest( ? protoPermission(type.permissions.record) : defaultPermission; + // TODO(record-level-hooks): emit record-level hooks (`type.hooks`) and + // validators (`type.validate`) here once the platform protobuf surface for + // TailorDBType supports them. Today only field-level hooks/validators are + // mapped via `toProtoFieldHooks` / `toProtoFieldValidate`, so the + // record-level callbacks collected by the configure layer are silently + // dropped during apply. Wiring requires (1) new fields on + // `TailorDBTypeSchema`/`TailorDBType_SchemaSchema`, (2) the + // `hooks-validate-bundler` populating record-level precompiled expressions, + // and (3) the parser schema round-tripping those values. + return { name: type.name, schema: { diff --git a/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts b/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts index 2d2dc348b..ed20dba12 100644 --- a/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts +++ b/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts @@ -89,6 +89,27 @@ function toScriptFunction(value: unknown): ScriptFunction | undefined { function collectScriptTargets(type: TailorDBTypeSchemaOutput): ScriptTarget[] { const targets: ScriptTarget[] = []; + // Collect record-level hooks + const recordCreateHook = toScriptFunction(type.metadata.hooks?.create); + if (recordCreateHook) { + targets.push({ fn: recordCreateHook, kind: "hooks" }); + } + const recordUpdateHook = toScriptFunction(type.metadata.hooks?.update); + if (recordUpdateHook) { + targets.push({ fn: recordUpdateHook, kind: "hooks" }); + } + + // Collect record-level validators + for (const validateInput of type.metadata.validate ?? []) { + if (typeof validateInput === "function") { + const validateFn = toScriptFunction(validateInput); + if (validateFn) targets.push({ fn: validateFn, kind: "validate" }); + } else { + const validateFn = toScriptFunction(validateInput[0]); + if (validateFn) targets.push({ fn: validateFn, kind: "validate" }); + } + } + const collectFieldTargets = (field: TailorDBTypeSchemaOutput["fields"][string]) => { const metadata = field.metadata; 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 f9dc0d182..6a5819067 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -1,44 +1,74 @@ 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 { AuthInvoker } from "@/configure/services/auth"; import type { TailorAnyField, TailorUser } from "@/configure/types"; import type { TailorEnv } from "@/configure/types/env"; import type { InferFieldsOutput, output } from "@/configure/types/helpers"; import type { MachineUserName } from "@/configure/types/machine-user"; 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< + ResolverInput, + "input" | "output" | "body" | "authInvoker" +> & Readonly<{ - input?: Input; + input?: ResolvedInput; output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; @@ -51,8 +81,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 @@ -65,26 +98,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<{ @@ -94,26 +135,45 @@ export function createResolver< authInvoker?: AuthInvoker | MachineUserName; }>, ): 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..64f179f50 --- /dev/null +++ b/packages/sdk/src/configure/services/tailordb/createTable.test.ts @@ -0,0 +1,831 @@ +import { describe, it, expectTypeOf, expect } from "vitest"; +import { createTable, timestampFields } from "./createTable"; +import { unsafeAllowAllGqlPermission } from "./permission"; +import { db } from "./schema"; +import type { output } from "@/configure/types/helpers"; + +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("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 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 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 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).toBeDefined(); + expect(result.fields.updatedAt).toBeDefined(); + expect(result.fields.createdAt.metadata.required).toBe(true); + expect(result.fields.updatedAt.metadata.required).toBe(false); + }); +}); + +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 record-level hooks/validate options", () => { + it("options.hooks accepts record-level create/update with full data typing", () => { + const result = createTable( + "Test", + { + name: { kind: "string" }, + score: { kind: "int" }, + }, + { + hooks: { + create: ({ data }) => { + expectTypeOf(data).toEqualTypeOf< + Readonly<{ id: string; name: string; score: number }> + >(); + return { ...data, score: data.score + 1 }; + }, + update: ({ data }) => ({ ...data, score: data.score + 1 }), + }, + }, + ); + expect(result.metadata.hooks).toBeDefined(); + expect(result.metadata.hooks?.create).toBeDefined(); + expect(result.metadata.hooks?.update).toBeDefined(); + }); + + it("options.validate accepts single function", () => { + const result = createTable( + "Test", + { name: { kind: "string" } }, + { + validate: ({ data }) => data.name.length > 0, + }, + ); + expect(result.metadata.validate).toHaveLength(1); + }); + + it("options.validate accepts single [fn, message] tuple", () => { + const result = createTable( + "Test", + { name: { kind: "string" } }, + { + validate: [({ data }) => data.name.length > 0, "Name must not be empty"], + }, + ); + expect(result.metadata.validate).toHaveLength(1); + }); + + it("options.validate accepts mixed array of fns and tuples", () => { + const result = createTable( + "Test", + { + name: { kind: "string" }, + age: { kind: "int" }, + }, + { + validate: [ + ({ data }) => data.name.length > 0, + [({ data }) => data.age >= 0, "Age must be non-negative"], + ], + }, + ); + expect(result.metadata.validate).toHaveLength(2); + }); +}); 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..563037c45 --- /dev/null +++ b/packages/sdk/src/configure/services/tailordb/createTable.ts @@ -0,0 +1,492 @@ +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 { RecordHook, 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 { RecordValidators } from "@/configure/types/validation"; +import type { PluginAttachment } from "@/types/plugin"; +import type { RelationType } from "@/types/tailordb"; + +type CommonFieldOptions = { + optional?: boolean; + description?: string; + generated?: boolean; +}; + +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]]; +}; + +// Field-level options. +// NOTE: field-level `hooks` and `validate` have been removed. Configure them at +// record level via the third `options` argument of `createTable` instead. +type FieldOptions = { + unique?: boolean; + index?: boolean; +}; + +type StringDescriptor = CommonFieldOptions & + FieldOptions & { + kind: "string"; + array?: boolean; + vector?: boolean; + serial?: SerialConfig<"string">; + }; + +type IntDescriptor = CommonFieldOptions & + FieldOptions & { + kind: "int"; + array?: boolean; + serial?: SerialConfig<"integer">; + }; + +type SimpleDescriptor = CommonFieldOptions & + FieldOptions & { + kind: K; + array?: boolean; + }; + +type FloatDescriptor = SimpleDescriptor<"float">; +type BoolDescriptor = SimpleDescriptor<"bool">; +type DateDescriptor = SimpleDescriptor<"date">; +type DatetimeDescriptor = SimpleDescriptor<"datetime">; +type TimeDescriptor = SimpleDescriptor<"time">; +type DecimalDescriptor = CommonFieldOptions & + FieldOptions & { + kind: "decimal"; + array?: boolean; + scale?: number; + }; + +type UuidDescriptor = CommonFieldOptions & + FieldOptions & { + kind: "uuid"; + array?: boolean; + 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 & { + kind: "enum"; + array?: boolean; + values: V; + typeName?: string; + }; + +// Nested object sub-fields bypass top-level constraint types (RejectArrayCombinations, 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 { 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]; +}; + +// 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. 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 + : // 3. RejectNestedInObject: no nested objects inside object fields + D[K] extends { kind: "object"; fields: infer F } + ? F extends Record + ? D[K] & { fields: RejectNestedSubFields } + : D[K] + : // 4. 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[]; + /** + * Record-level create/update hooks. Each callback receives `{ data, user }` + * (the entire record as a partial) and must return a complete record. + * Use `{ ...data, field: newValue }` to satisfy required fields. + */ + hooks?: RecordHook>; + /** + * Record-level validators. Each callback receives `{ data, user }` and must + * return `true` for a valid record. Use the tuple form `[fn, message]` for + * diagnosable error messages. + */ + validate?: RecordValidators>; +}; + +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 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.generated === true) { + field._metadata.generated = true; + } + + 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 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.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 field descriptors (createdAt, updatedAt). + * Hooks for auto-populating these timestamps must be configured at the record + * level via `options.hooks` (see `createTable`). + * @returns An object with createdAt and updatedAt field descriptors + * @example + * const model = createTable( + * "Model", + * { + * name: { kind: "string" }, + * ...timestampFields(), + * }, + * { + * hooks: { + * create: ({ data }) => ({ ...data, createdAt: new Date() }), + * update: ({ data }) => ({ ...data, updatedAt: new Date() }), + * }, + * }, + * ); + */ +export function timestampFields() { + return { + createdAt: { + kind: "datetime", + description: "Record creation timestamp", + generated: true, + }, + updatedAt: { + kind: "datetime", + optional: true, + description: "Record last update timestamp", + generated: true, + }, + } 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..43a4be090 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, @@ -16,6 +17,7 @@ export { export type { DBFieldMetadata, Hook, + RecordHook, GqlOperationsConfig, TailorDBMigrationConfig, TailorDBServiceConfig, diff --git a/packages/sdk/src/configure/services/tailordb/schema.test.ts b/packages/sdk/src/configure/services/tailordb/schema.test.ts index d99991c67..7498d18d2 100644 --- a/packages/sdk/src/configure/services/tailordb/schema.test.ts +++ b/packages/sdk/src/configure/services/tailordb/schema.test.ts @@ -1,10 +1,10 @@ import { describe, it, expectTypeOf, expect } from "vitest"; import { t } from "@/configure/types"; import { db } from "./schema"; -import type { Hook } from "./types"; +import type { RecordHook } from "./types"; import type { TailorUser } from "@/configure/types"; import type { output } from "@/configure/types/helpers"; -import type { FieldValidateInput, ValidateConfig } from "@/configure/types/validation"; +import type { RecordValidators } from "@/configure/types/validation"; describe("TailorDBField basic field type tests", () => { it("string field outputs string type correctly", () => { @@ -414,102 +414,26 @@ describe("TailorDBField relation modifier tests", () => { }); }); -describe("TailorDBField hooks modifier tests", () => { - it("hooks modifier does not affect output type", () => { - const _hookType = db.type("Test", { - name: db.string().hooks({ - create: () => "created", - update: () => "updated", - }), - }); - expectTypeOf>().toEqualTypeOf<{ - id: string; - name: string; - }>(); - }); - - it("setting hooks on nested field causes type error", () => { - // @ts-expect-error hooks() cannot be called on nested fields - db.object({ - first: db.string(), - last: db.string(), - }).hooks({ create: () => ({ first: "A", last: "B" }) }); - }); - - it("hooks modifier on string field receives string", () => { - const _hooks = db.string().hooks; - expectTypeOf[0]>().toEqualTypeOf>(); - }); - - it("hooks modifier on optional field receives null", () => { - const _hooks = db.string({ optional: true }).hooks; - expectTypeOf[0]>().toEqualTypeOf>(); - }); -}); - -describe("TailorDBField validate modifier tests", () => { - it("validate modifier does not affect type", () => { - const _validateType = db.type("Test", { - email: db.string().validate(() => true), - }); - expectTypeOf>().toEqualTypeOf<{ - id: string; - email: string; - }>(); - }); - - it("validate modifier can receive object with message", () => { - const _validateType = db.type("Test", { - email: db.string().validate([({ value }) => value.includes("@"), "Email must contain @"]), - }); - expectTypeOf>().toEqualTypeOf<{ - id: string; - email: string; - }>(); - - // Validate that the validation is stored correctly in metadata - const fieldMetadata = _validateType.fields.email.metadata; - expect(fieldMetadata.validate).toBeDefined(); - expect(fieldMetadata.validate).toHaveLength(1); - // Error message is part of the tuple [fn, message] - expect(fieldMetadata.validate?.[0]).toEqual([expect.any(Function), "Email must contain @"]); - }); - - it("validate modifier can receive multiple validators", () => { - const _validateType = db.type("Test", { - password: db - .string() - .validate( - ({ value }) => value.length >= 8, - [({ value }) => /[A-Z]/.test(value), "Password must contain uppercase letter"], - ), - }); - - const fieldMetadata = _validateType.fields.password.metadata; - expect(fieldMetadata.validate).toHaveLength(2); - // Second validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[1] as [unknown, string])[1]).toBe( - "Password must contain uppercase letter", - ); - }); - - it("calling validate modifier more than once causes type error", () => { - // @ts-expect-error validate() cannot be called after validate() has already been called - db.string() - .validate(() => true) - .validate(() => true); - }); - - it("validate modifier on string field receives string", () => { - const _validate = db.string().validate; - expectTypeOf[1]>().toEqualTypeOf>(); - }); - - it("validate modifier on optional field receives null", () => { - const _validate = db.string({ optional: true }).validate; - expectTypeOf[1]>().toEqualTypeOf< - FieldValidateInput - >(); +describe("TailorDBField field-level hooks/validate removal", () => { + it("TailorDBField does not expose a field-level hooks method", () => { + // Type-level assertion only (do not invoke at runtime) + const field = db.string(); + // @ts-expect-error `hooks` has been removed from the field-level API + type _Hooks = typeof field.hooks; + }); + + it("TailorDBField validate is typed as `this: never` to block field-level calls", () => { + // The `validate` method is declared as + // validate(this: never, ...args: never[]): never; + // so calling it on a concrete field instance is a type error. Pattern- + // match on the function signature to assert both the `this` type and the + // return type are `never`. + type FieldValidate = ReturnType["validate"]; + type _AssertShape = FieldValidate extends (this: never, ...args: never[]) => never + ? true + : false; + const _check: _AssertShape = true; + expect(_check).toBe(true); }); }); @@ -837,18 +761,17 @@ describe("TailorDBType plural form tests", () => { name: db.string(), email: db.string(), }) - .validate({ - name: [({ value }) => value.length > 0], - email: [({ value }) => value.includes("@"), "Invalid email format"], - }); + .validate([ + ({ data }) => data.name.length > 0, + [({ data }) => data.email.includes("@"), "Invalid email format"], + ]); expect(_userType.name).toBe("User"); expect(_userType.metadata.settings?.pluralForm).toBe("Users"); - // Validate that the validation function is stored correctly in metadata - const emailMetadata = _userType.fields.email.metadata; - expect(emailMetadata.validate).toBeDefined(); - expect(emailMetadata.validate).toHaveLength(1); + // Record-level validators are stored on the type metadata + expect(_userType.metadata.validate).toBeDefined(); + expect(_userType.metadata.validate).toHaveLength(2); }); it("plural form works correctly for types with relations", () => { @@ -877,17 +800,15 @@ describe("TailorDBType plural form tests", () => { }); }); -describe("TailorDBType hooks modifier tests", () => { +describe("TailorDBType record-level hooks modifier tests", () => { it("hooks modifier does not affect output type", () => { const _hookType = db .type("Test", { name: db.string(), }) .hooks({ - name: { - create: () => "created", - update: () => "updated", - }, + create: ({ data }) => ({ ...data, name: "created" }), + update: ({ data }) => ({ ...data, name: "updated" }), }); expectTypeOf>().toEqualTypeOf<{ id: string; @@ -895,154 +816,120 @@ describe("TailorDBType hooks modifier tests", () => { }>(); }); - it("setting hooks on id causes type error", () => { + it("hooks create/update receive the full record as readonly data", () => { db.type("Test", { name: db.string(), + score: db.int(), }).hooks({ - // @ts-expect-error hooks() cannot be called on the "id" field - id: { - create: () => "created", + create: ({ data }) => { + expectTypeOf(data).toEqualTypeOf>(); + return { ...data, score: data.score + 1 }; }, + update: ({ data }) => ({ ...data, score: data.score + 1 }), }); }); - it("setting hooks on nested field causes type error", () => { + it("hooks must return a complete record (spread required)", () => { db.type("Test", { - name: db.object({ - first: db.string(), - last: db.string(), - }), - // @ts-expect-error hooks() cannot be called on nested fields + name: db.string(), + score: db.int(), }).hooks({ - name: { - create: () => "created", - }, + // @ts-expect-error missing required fields from the returned record + create: () => ({ name: "created" }), }); }); - it("hooks modifier on string field receives string", () => { + it("hooks modifier accepts RecordHook parameter", () => { const testType = db.type("Test", { name: db.string() }); - const _hooks = testType.hooks; - type ExpectedHooksParam = Parameters[0]; - type ActualNameType = Exclude; - - expectTypeOf().toEqualTypeOf< - Hook< - { - id: string; - readonly name: string; - }, - string - > - >(); + type HooksParam = Parameters[0]; + expectTypeOf().toEqualTypeOf>(); }); - it("hooks modifier on optional field receives null", () => { - const testType = db.type("Test", { - name: db.string({ optional: true }), + it("hooks modifier stores hooks on type metadata", () => { + const createHook = ({ data }: { data: Readonly<{ id: string; name: string }> }) => ({ + ...data, + name: "c", + }); + const updateHook = ({ data }: { data: Readonly<{ id: string; name: string }> }) => ({ + ...data, + name: "u", }); - const _hooks = testType.hooks; - type ExpectedHooksParam = Parameters[0]; - type ActualNameType = Exclude; + const hookType = db + .type("Test", { + name: db.string(), + }) + .hooks({ create: createHook, update: updateHook }); - expectTypeOf().toEqualTypeOf< - Hook< - { - id: string; - name?: string | null; - }, - string | null - > - >(); + expect(hookType.metadata.hooks).toBeDefined(); + expect(hookType.metadata.hooks?.create).toBe(createHook); + expect(hookType.metadata.hooks?.update).toBe(updateHook); }); }); -describe("TailorDBType validate modifier tests", () => { - it("validate modifier can receive function", () => { +describe("TailorDBType record-level validate modifier tests", () => { + it("validate modifier can receive a single function", () => { const _validateType = db .type("Test", { email: db.string(), }) - .validate({ - email: () => true, - }); + .validate(({ data }) => data.email.includes("@")); expectTypeOf>().toEqualTypeOf<{ id: string; email: string; }>(); - const fieldMetadata = _validateType.fields.email.metadata; - expect(fieldMetadata.validate).toHaveLength(1); + expect(_validateType.metadata.validate).toHaveLength(1); }); - it("validate modifier can receive object with message", () => { + it("validate modifier can receive a single [fn, message] tuple", () => { const _validateType = db .type("Test", { email: db.string(), }) - .validate({ - email: [({ value }) => value.includes("@"), "Email must contain @"], - }); + .validate([({ data }) => data.email.includes("@"), "Email must contain @"]); - const fieldMetadata = _validateType.fields.email.metadata; - expect(fieldMetadata.validate).toHaveLength(1); - // Validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[0] as [unknown, string])[1]).toBe("Email must contain @"); + expect(_validateType.metadata.validate).toHaveLength(1); + expect((_validateType.metadata.validate?.[0] as [unknown, string])[1]).toBe( + "Email must contain @", + ); }); - it("validate modifier can receive multiple validators", () => { + it("validate modifier can receive an array of validators", () => { const _validateType = db .type("Test", { password: db.string(), }) - .validate({ - password: [ - ({ value }) => value.length >= 8, - [({ value }) => /[A-Z]/.test(value), "Password must contain uppercase letter"], - ], - }); + .validate([ + ({ data }) => data.password.length >= 8, + [({ data }) => /[A-Z]/.test(data.password), "Password must contain uppercase letter"], + ]); - const fieldMetadata = _validateType.fields.password.metadata; - expect(fieldMetadata.validate).toHaveLength(2); + expect(_validateType.metadata.validate).toHaveLength(2); // Second validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[1] as [unknown, string])[1]).toBe( + expect((_validateType.metadata.validate?.[1] as [unknown, string])[1]).toBe( "Password must contain uppercase letter", ); }); - it("type error occurs when validate is already set on TailorDBField", () => { - db.type("Test", { - name: db.string().validate(() => true), - // @ts-expect-error validate() cannot be called after validate() has already been called - }).validate({ - name: () => true, - }); + it("validate modifier accepts RecordValidators parameter", () => { + const testType = db.type("Test", { name: db.string() }); + type ValidatorsParam = Parameters[0]; + expectTypeOf().toEqualTypeOf>(); }); - it("setting validate on id causes type error", () => { + it("validate fn receives the full record as data", () => { db.type("Test", { name: db.string(), - }).validate({ - // @ts-expect-error validate() cannot be called on the "id" field - id: () => true, + age: db.int({ optional: true }), + }).validate(({ data }) => { + expectTypeOf(data).toEqualTypeOf<{ + id: string; + name: string; + age?: number | null; + }>(); + return data.name.length > 0; }); }); - - it("validate modifier on string field receives string", () => { - const _validate = db.type("Test", { name: db.string() }).validate; - expectTypeOf>().toExtend< - Parameters[0]["name"] - >(); - }); - - it("validate modifier on optional field receives null", () => { - const _validate = db.type("Test", { - name: db.string({ optional: true }), - }).validate; - expectTypeOf>().toExtend< - Parameters[0]["name"] - >(); - }); }); describe("db.object tests", () => { @@ -1282,11 +1169,7 @@ describe("TailorDBField fluent API type preservation", () => { }); it("multiple method chain preserves type", () => { - const _field = db - .string() - .description("Email address") - .index() - .validate(({ value }) => value.includes("@")); + const _field = db.string().description("Email address").index().unique(); expectTypeOf>().toEqualTypeOf(); }); @@ -1604,27 +1487,6 @@ describe("TailorDBType gqlOperations alias tests", () => { }); describe("TailorDBField immutability", () => { - it("field.hooks() returns a new field without mutating the original", () => { - const original = db.string(); - const withHooks = original.hooks({ create: () => "created" }); - - // hooks() should return a NEW field - expect(withHooks).not.toBe(original); - // Original should NOT have hooks - expect(original.metadata.hooks).toBeUndefined(); - // New field should have hooks - expect(withHooks.metadata.hooks?.create).toBeDefined(); - }); - - it("field.validate() returns a new field without mutating the original", () => { - const original = db.string(); - const withValidate = original.validate(({ value }) => value.length > 0); - - expect(withValidate).not.toBe(original); - expect(original.metadata.validate).toBeUndefined(); - expect(withValidate.metadata.validate).toHaveLength(1); - }); - it("field.description() returns a new field without mutating the original", () => { const original = db.string(); const withDesc = original.description("desc"); @@ -1681,62 +1543,43 @@ describe("TailorDBField immutability", () => { }); it("chained fluent calls produce correct result", () => { - const field = db - .string() - .description("name") - .index() - .hooks({ create: () => "x" }); + const field = db.string().description("name").index().unique(); expect(field.metadata.description).toBe("name"); expect(field.metadata.index).toBe(true); - expect(field.metadata.hooks?.create).toBeDefined(); + expect(field.metadata.unique).toBe(true); }); }); -describe("TailorDBType does not mutate shared fields", () => { - it("type.hooks() does not mutate the shared field", () => { +describe("TailorDBType record-level hooks/validate storage", () => { + it("type.hooks() stores hooks on the owning type only", () => { const sharedField = db.string(); - const typeA = db.type("TypeA", { name: sharedField }).hooks({ name: { create: () => "A" } }); + const typeA = db.type("TypeA", { name: sharedField }).hooks({ + create: ({ data }) => ({ ...data, name: "A" }), + }); const typeB = db.type("TypeB", { name: sharedField }); - expect(typeA.fields.name.metadata.hooks).toBeDefined(); - expect(typeB.fields.name.metadata.hooks).toBeUndefined(); + expect(typeA.metadata.hooks).toBeDefined(); + expect(typeB.metadata.hooks).toBeUndefined(); + // Shared field metadata is untouched expect(sharedField.metadata.hooks).toBeUndefined(); }); - it("type.validate() does not mutate the shared field", () => { + it("type.validate() stores validators on the owning type only", () => { const sharedField = db.string(); const typeA = db .type("TypeA", { email: sharedField }) - .validate({ email: ({ value }) => value.includes("@") }); + .validate(({ data }) => data.email.includes("@")); const typeB = db.type("TypeB", { email: sharedField }); - expect(typeA.fields.email.metadata.validate).toBeDefined(); - expect(typeB.fields.email.metadata.validate).toBeUndefined(); + expect(typeA.metadata.validate).toBeDefined(); + expect(typeA.metadata.validate).toHaveLength(1); + expect(typeB.metadata.validate).toBeUndefined(); + // Shared field metadata is untouched expect(sharedField.metadata.validate).toBeUndefined(); }); - - it("hooks() does not replace entries in the original fields record", () => { - const nameField = db.string(); - const fields = { name: nameField }; - - db.type("TypeA", fields).hooks({ name: { create: () => "hooked" } }); - - // The fields record should still reference the original field instance - expect(fields.name).toBe(nameField); - }); - - it("validate() does not replace entries in the original fields record", () => { - const emailField = db.string(); - const fields = { email: emailField }; - - db.type("TypeA", fields).validate({ email: ({ value }) => value.includes("@") }); - - // The fields record should still reference the original field instance - expect(fields.email).toBe(emailField); - }); }); describe("TailorDBField clone tests", () => { @@ -1814,44 +1657,6 @@ describe("TailorDBField clone tests", () => { expect(cloned.rawRelation?.toward).not.toBe(original.rawRelation?.toward); }); - it("clones hooks correctly", () => { - const createHook = () => "created"; - const original = db.string().hooks({ create: createHook }); - const cloned = original.clone(); - - expect(cloned.metadata.hooks).toBeDefined(); - expect(cloned.metadata.hooks?.create).toBe(createHook); - - // Verify deep copy (different reference) - expect(cloned.metadata.hooks).not.toBe(original.metadata.hooks); - }); - - it("clones validate correctly", () => { - const validator = ({ value }: { value: string }) => value.length > 0; - const original = db.string().validate(validator); - const cloned = original.clone(); - - expect(cloned.metadata.validate).toBeDefined(); - expect(cloned.metadata.validate).toHaveLength(1); - - // Verify deep copy (different reference) - expect(cloned.metadata.validate).not.toBe(original.metadata.validate); - }); - - it("clones validate with tuple format correctly", () => { - const validator = ({ value }: { value: string }) => value.length > 0; - const original = db.string().validate([validator, "Value must not be empty"]); - const cloned = original.clone(); - - expect(cloned.metadata.validate).toBeDefined(); - expect(cloned.metadata.validate).toHaveLength(1); - expect(cloned.metadata.validate?.[0]).toEqual([validator, "Value must not be empty"]); - - // Verify deep copy (different reference for array and tuple) - expect(cloned.metadata.validate).not.toBe(original.metadata.validate); - expect(cloned.metadata.validate?.[0]).not.toBe(original.metadata.validate?.[0]); - }); - it("clones serial config correctly", () => { const original = db.int().serial({ start: 100 }); const cloned = original.clone(); diff --git a/packages/sdk/src/configure/services/tailordb/schema.ts b/packages/sdk/src/configure/services/tailordb/schema.ts index 7e2555eba..c0511aae4 100644 --- a/packages/sdk/src/configure/services/tailordb/schema.ts +++ b/packages/sdk/src/configure/services/tailordb/schema.ts @@ -4,7 +4,7 @@ import { type AllowedValuesOutput, mapAllowedValues, } from "@/configure/types/field"; -import { type TailorField, type TailorAnyField } from "@/configure/types/type"; +import { type TailorField } from "@/configure/types/type"; import { type FieldOptions, type FieldOutput, @@ -16,8 +16,7 @@ import { type TailorTypeGqlPermission, type TailorTypePermission } from "./permi import { type DBFieldMetadata, type DefinedDBFieldMetadata, - type Hooks, - type Hook, + type RecordHook, type SerialConfig, type IndexDef, type TypeFeatures, @@ -25,7 +24,7 @@ import { } from "./types"; import type { InferredAttributeMap, TailorUser } from "@/configure/types"; import type { Prettify, output, InferFieldsOutput } from "@/configure/types/helpers"; -import type { FieldValidateInput, ValidateConfig, Validators } from "@/configure/types/validation"; +import type { RecordValidateInput, RecordValidators } from "@/configure/types/validation"; import type { PluginAttachment, PluginConfigs } from "@/types/plugin"; import type { TailorDBTypeMetadata, RawRelationConfig, RelationType } from "@/types/tailordb"; import type { RawPermissions } from "@/types/tailordb.generated"; @@ -58,6 +57,16 @@ function isRelationSelfConfig( return config.toward.type === "self"; } +/** + * Distinguishes a single `[fn, message]` tuple from an array of record validators. + * A config tuple has exactly 2 elements where the second is a string. + * @param value - Potential validators array or tuple + * @returns True if the value is a single `[fn, message]` tuple + */ +function isRecordValidateConfig(value: readonly unknown[]): boolean { + return value.length === 2 && typeof value[1] === "string" && typeof value[0] === "function"; +} + // Helper alias: DB fields can be arbitrarily nested, so we intentionally keep this loose. // oxlint-disable-next-line no-explicit-any export type TailorAnyDBField = TailorDBField; @@ -99,12 +108,26 @@ type FieldParseInternalArgs = { /** * TailorDBField interface representing a database field with extended metadata. - * Extends TailorField with database-specific features like relations, indexes, and hooks. + * Extends TailorField with database-specific features like relations and indexes. + * + * NOTE: Field-level `hooks` and `validate` have been removed from the public API. + * Configure them at the record level via `db.type(...).hooks(...) / .validate(...)` + * or via the third `options` argument of `createTable`. */ export interface TailorDBField extends Omit< TailorField, - "description" | "validate" + "description" | "fields" > { + /** Nested fields for object-like DB types */ + readonly fields: Record; + + /** + * Field-level `validate` has been removed from the public TailorDB API. + * Configure validation at the record level via + * `db.type(...).validate(...)` or the third `options` argument of `createTable`. + */ + validate(this: never, ...args: never[]): never; + /** * typeName is not available on TailorDB fields. * Use typeName on pipeline fields (t.enum / t.object) instead. @@ -122,7 +145,7 @@ export interface TailorDBField e description( this: CurrentDefined extends { description: unknown } ? never - : TailorField, + : TailorDBField, description: string, ): TailorDBField, Output>; @@ -193,50 +216,6 @@ export interface TailorDBField e : never, ): TailorDBField, Output>; - /** - * Add hooks for create/update operations on this field. - * The hook function receives `{ value, data, user }` and returns the computed value. - * @example db.string().hooks({ create: ({ data }) => data.firstName + " " + data.lastName }) - * @example db.datetime().hooks({ create: () => new Date(), update: () => new Date() }) - */ - hooks>( - this: CurrentDefined extends { hooks: unknown } - ? never - : CurrentDefined extends { type: "nested" } - ? never - : TailorDBField, - hooks: H, - ): TailorDBField< - Prettify< - CurrentDefined & { - hooks?: { - create: H extends { create: unknown } ? true : false; - update: H extends { update: unknown } ? true : false; - }; - serial: false; - } - >, - Output - >; - - /** - * Add validation functions to the field. - * Accepts a function or a tuple of [function, errorMessage]. - * Prefer the tuple form for diagnosable errors. - * @example - * // Function form (default error message): - * db.int().validate(({ value }) => value >= 0) - * @example - * // Tuple form with custom error message (recommended): - * db.string().validate([({ value }) => value.length >= 8, "Must be at least 8 characters"]) - */ - validate( - this: CurrentDefined extends { validate: unknown } - ? never - : TailorDBField, - ...validate: FieldValidateInput[] - ): TailorDBField, Output>; - /** * Configure serial/auto-increment behavior */ @@ -284,7 +263,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], @@ -447,24 +426,6 @@ function createTailorDBField< break; } - // Custom validation functions - const validateFns = field._metadata.validate; - if (validateFns && validateFns.length > 0) { - for (const validateInput of validateFns) { - const { fn, message } = - typeof validateInput === "function" - ? { fn: validateInput, message: "Validation failed" } - : { fn: validateInput[0], message: validateInput[1] }; - - if (!fn({ value, data, user })) { - issues.push({ - message, - path: pathArray.length > 0 ? pathArray : undefined, - }); - } - } - } - return issues; } @@ -546,7 +507,7 @@ function createTailorDBField< const field: FieldType = { type, - fields: (fields ?? {}) as Record, + fields: fields ?? {}, _defined: undefined as unknown as { type: T; array: TOptions extends { array: true } ? true : false; @@ -570,10 +531,15 @@ function createTailorDBField< // oxlint-disable-next-line no-explicit-any typeName: ((typeName: string) => cloneWith({ typeName })) as any, - validate(...validateInputs: FieldValidateInput>[]) { + // Field-level `validate` has been removed. The stub throws to surface the mistake + // at runtime even though the `this: never` signature prevents type-level calls. + // oxlint-disable-next-line no-explicit-any + validate: (() => { + throw new Error( + "Field-level `.validate()` has been removed. Use `db.type(...).validate(...)` or the third `options` argument of `createTable` instead.", + ); // oxlint-disable-next-line no-explicit-any - return cloneWith({ validate: validateInputs }) as any; - }, + }) as any, parse(args: FieldParseArgs): StandardSchemaV1.Result> { return parseInternal({ @@ -619,11 +585,6 @@ function createTailorDBField< return cloneWith({ vector: true }) as any; }, - hooks(hooks: Hook>) { - // oxlint-disable-next-line no-explicit-any - return cloneWith({ hooks }) as any; - }, - serial(config: SerialConfig) { // oxlint-disable-next-line no-explicit-any return cloneWith({ serial: config }) as any; @@ -847,29 +808,32 @@ export interface TailorDBType< readonly metadata: TailorDBTypeMetadata; /** - * Add hooks for fields at the type level. - * Each key is a field name, and the value defines create/update hooks. + * Add record-level create/update hooks. Each callback receives `{ data, user }` + * (the entire record as a partial) and must return a complete record. + * Spread the incoming data (`{ ...data, field: newValue }`) to satisfy required fields. * @example * db.type("Order", { * total: db.float(), * tax: db.float(), * ...db.fields.timestamps(), * }).hooks({ - * tax: { create: ({ data }) => data.total * 0.1, update: ({ data }) => data.total * 0.1 }, + * create: ({ data }) => ({ ...data, tax: (data.total ?? 0) * 0.1 }), + * update: ({ data }) => ({ ...data, tax: (data.total ?? 0) * 0.1 }), * }) */ - hooks(hooks: Hooks): TailorDBType; + hooks(hooks: RecordHook>): TailorDBType; /** - * Add validators for fields at the type level. - * Each key is a field name, and the value is a validator or array of validators. - * Prefer the tuple form [function, message] for diagnosable errors. + * Add record-level validators. Each callback receives `{ data, user }` and must + * return `true` for a valid record. Use the tuple form `[fn, message]` for + * diagnosable error messages. * @example - * db.type("User", { email: db.string() }).validate({ - * email: [({ value }) => value.includes("@"), "Email must contain @"], - * }) + * db.type("User", { email: db.string() }).validate([ + * ({ data }) => data.email.includes("@"), + * "Email must contain @", + * ]) */ - validate(validators: Validators): TailorDBType; + validate(validators: RecordValidators>): TailorDBType; /** * Configure type features @@ -980,7 +944,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, @@ -995,6 +959,8 @@ function createTailorDBType< const _permissions: RawPermissions = {}; let _files: Record = {}; const _plugins: PluginAttachment[] = []; + let _recordHooks: RecordHook> | undefined; + let _recordValidators: RecordValidateInput>[] | undefined; if (options.pluralForm) { if (name === options.pluralForm) { @@ -1030,43 +996,21 @@ function createTailorDBType< permissions: _permissions, files: _files, ...(Object.keys(indexes).length > 0 && { indexes }), + ...(_recordHooks && { hooks: _recordHooks }), + ...(_recordValidators && { validate: _recordValidators }), }; }, - hooks(hooks: Hooks) { - // `Hooks` is strongly typed, but `Object.entries()` loses that information. - // oxlint-disable-next-line no-explicit-any - Object.entries(hooks).forEach(([fieldName, fieldHooks]: [string, any]) => { - (this.fields as Record)[fieldName] = - this.fields[fieldName].hooks(fieldHooks); - }); + hooks(hooks: RecordHook>) { + _recordHooks = hooks; return this; }, - validate(validators: Validators) { - Object.entries(validators).forEach(([fieldName, fieldValidators]) => { - const field = this.fields[fieldName] as TailorAnyDBField; - - const validators = fieldValidators as - | FieldValidateInput - | FieldValidateInput[]; - - const isValidateConfig = (v: unknown): v is ValidateConfig => { - return Array.isArray(v) && v.length === 2 && typeof v[1] === "string"; - }; - - let updatedField: TailorAnyDBField; - if (Array.isArray(validators)) { - if (isValidateConfig(validators)) { - updatedField = field.validate(validators); - } else { - updatedField = field.validate(...validators); - } - } else { - updatedField = field.validate(validators); - } - (this.fields as Record)[fieldName] = updatedField; - }); + validate(validators: RecordValidators>) { + _recordValidators = + Array.isArray(validators) && !isRecordValidateConfig(validators) + ? (validators as RecordValidateInput>[]) + : [validators as RecordValidateInput>]; return this; }, @@ -1247,22 +1191,25 @@ export const db = { object, fields: { /** - * Creates standard timestamp fields (createdAt, updatedAt) with auto-hooks. - * createdAt is set on create, updatedAt is set on update. + * Creates standard timestamp fields (createdAt, updatedAt). + * Users must populate these via record-level hooks on `db.type(...).hooks(...)` + * or via the third `options` argument of `createTable`. * @returns An object with createdAt and updatedAt fields * @example * const model = db.type("Model", { * name: db.string(), * ...db.fields.timestamps(), + * }).hooks({ + * create: ({ data }) => ({ ...data, createdAt: new Date() }), + * update: ({ data }) => ({ ...data, updatedAt: new Date() }), * }); */ - timestamps: () => ({ - createdAt: datetime() - .hooks({ create: () => new Date() }) - .description("Record creation timestamp"), - updatedAt: datetime({ optional: true }) - .hooks({ update: () => new Date() }) - .description("Record last update timestamp"), - }), + timestamps: () => { + const createdAt = datetime().description("Record creation timestamp"); + createdAt._metadata.generated = true; + const updatedAt = datetime({ optional: true }).description("Record last update timestamp"); + updatedAt._metadata.generated = true; + return { createdAt, updatedAt }; + }, }, }; diff --git a/packages/sdk/src/configure/services/tailordb/types.ts b/packages/sdk/src/configure/services/tailordb/types.ts index 4478965b8..b929ee8dc 100644 --- a/packages/sdk/src/configure/services/tailordb/types.ts +++ b/packages/sdk/src/configure/services/tailordb/types.ts @@ -1,5 +1,5 @@ import { type TailorUser } from "@/configure/types"; -import { type output, type Prettify } from "@/configure/types/helpers"; +import { type Prettify } from "@/configure/types/helpers"; import { type DefinedFieldMetadata, type FieldMetadata } from "@/configure/types/types"; import { type TailorAnyDBField, type TailorDBField } from "./schema"; export type { TailorDBServiceConfig } from "@/types/tailordb.generated"; @@ -9,7 +9,6 @@ export type { TailorDBServiceInput, } from "@/types/tailordb"; import type { GqlOperationsInput } from "@/types/tailordb.generated"; -import type { NonEmptyObject } from "type-fest"; export type SerialConfig = Prettify< { @@ -35,6 +34,7 @@ export interface DBFieldMetadata extends FieldMetadata { serial?: SerialConfig; relation?: boolean; scale?: number; + generated?: boolean; } export interface DefinedDBFieldMetadata extends DefinedFieldMetadata { @@ -50,6 +50,7 @@ export interface DefinedDBFieldMetadata extends DefinedFieldMetadata { }; serial?: boolean; relation?: boolean; + generated?: boolean; } export type ExcludeNestedDBFields> = { @@ -73,18 +74,30 @@ export type Hook = { update?: HookFn; }; -export type Hooks< - F extends Record, - TData = { [K in keyof F]: output }, -> = NonEmptyObject<{ - [K in Exclude as F[K]["_defined"] extends { - hooks: unknown; - } - ? never - : F[K]["_defined"] extends { type: "nested" } - ? never - : K]?: Hook>; -}>; +/** + * Record-level hook function arguments. + * `data` is the full record snapshot at hook time; spread it to satisfy required fields. + */ +type RecordHookFnArgs = { + readonly data: Readonly; + readonly user: TailorUser; +}; + +/** + * Record-level hook function. + * Receives the entire record `data` and must return a complete record to persist. + * Spread the incoming data (`{ ...data, field: newValue }`) to satisfy required fields. + */ +type RecordHookFn = (args: RecordHookFnArgs) => TData; + +/** + * Record-level hooks for create/update operations. + * Each callback receives `{ data, user }` and must return a full record matching the type shape. + */ +export type RecordHook = { + create?: RecordHookFn; + update?: RecordHookFn; +}; export type IndexDef }> = { fields: [keyof T["fields"], keyof T["fields"], ...(keyof T["fields"])[]]; 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], diff --git a/packages/sdk/src/configure/types/validation.ts b/packages/sdk/src/configure/types/validation.ts index 5b5d072f1..5a1774840 100644 --- a/packages/sdk/src/configure/types/validation.ts +++ b/packages/sdk/src/configure/types/validation.ts @@ -1,6 +1,4 @@ import { type TailorUser } from "@/configure/types"; -import type { output, InferFieldsOutput } from "./helpers"; -import type { NonEmptyObject } from "type-fest"; /** * Validation function type @@ -28,35 +26,22 @@ type FieldValidateConfig = ValidateConfig; export type FieldValidateInput = FieldValidateFn | FieldValidateConfig; /** - * Base validators type for field collections - * @template F - Record of fields - * @template ExcludeKeys - Keys to exclude from validation (default: "id" for TailorDB) - */ -type ValidatorsBase< - // Structural constraint only - // oxlint-disable-next-line no-explicit-any - F extends Record, - ExcludeKeys extends string = "id", -> = NonEmptyObject<{ - [K in Exclude as F[K]["_defined"] extends { - validate: unknown; - } - ? never - : K]?: - | ValidateFn, InferFieldsOutput> - | ValidateConfig, InferFieldsOutput> - | ( - | ValidateFn, InferFieldsOutput> - | ValidateConfig, InferFieldsOutput> - )[]; -}>; - -/** - * Validators type (by default excludes "id" field for TailorDB compatibility) - * Can be used with both TailorField and TailorDBField - */ -export type Validators< - // Structural constraint only - // oxlint-disable-next-line no-explicit-any - F extends Record, -> = ValidatorsBase; + * Record-level validation function. + * Receives the entire record `data` and returns `true` if valid. + */ +export type RecordValidateFn = (args: { data: TData; user: TailorUser }) => boolean; + +/** + * Record-level validation configuration with a custom error message. + */ +export type RecordValidateConfig = [RecordValidateFn, string]; + +/** + * Single record-level validation input: either a function or `[function, message]` tuple. + */ +export type RecordValidateInput = RecordValidateFn | RecordValidateConfig; + +/** + * Record-level validators: single input or an array of inputs. + */ +export type RecordValidators = RecordValidateInput | RecordValidateInput[]; diff --git a/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts b/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts deleted file mode 100644 index 95471707e..000000000 --- a/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { db } from "@/configure/services/tailordb/schema"; -import { toSchemaOutputs } from "@/utils/test/internal"; -import { parseFieldConfig } from "./field"; -import { setPrecompiledScriptExpr } from "./hooks-validate-precompiled-expr"; - -describe("parseFieldConfig precompiled expressions", () => { - it("uses precompiled hook expression when attached", () => { - const createHook = ({ value }: { value: string | null }) => value ?? "fallback"; - setPrecompiledScriptExpr(createHook, "PRECOMPILED_HOOK_EXPR"); - - const type = db.type("User", { - email: db.string().hooks({ create: createHook }), - }); - - const schema = toSchemaOutputs({ User: type }); - const field = parseFieldConfig(schema.User.fields.email); - - expect(field.hooks?.create?.expr).toBe("PRECOMPILED_HOOK_EXPR"); - }); - - it("uses precompiled validate expression when attached", () => { - const validator = ({ value }: { value: string }) => value.length > 0; - setPrecompiledScriptExpr(validator, "PRECOMPILED_VALIDATE_EXPR"); - - const type = db.type("User", { - email: db.string().validate(validator), - }); - - const schema = toSchemaOutputs({ User: type }); - const field = parseFieldConfig(schema.User.fields.email); - - expect(field.validate?.[0]?.script.expr).toBe("PRECOMPILED_VALIDATE_EXPR"); - }); -}); diff --git a/packages/sdk/src/parser/service/tailordb/field.test.ts b/packages/sdk/src/parser/service/tailordb/field.test.ts new file mode 100644 index 000000000..53576e601 --- /dev/null +++ b/packages/sdk/src/parser/service/tailordb/field.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { db } from "@/configure/services/tailordb/schema"; +import { parseFieldConfig } from "./field"; + +describe("parseFieldConfig", () => { + describe("generated datetime hooks", () => { + it("generates create hook for required generated datetime (createdAt)", () => { + const { createdAt } = db.fields.timestamps(); + const config = parseFieldConfig(createdAt); + + expect(config.hooks).toBeDefined(); + expect(config.hooks?.create).toEqual({ expr: "new Date()" }); + expect(config.hooks?.update).toBeUndefined(); + }); + + it("generates update hook for optional generated datetime (updatedAt)", () => { + const { updatedAt } = db.fields.timestamps(); + const config = parseFieldConfig(updatedAt); + + expect(config.hooks).toBeDefined(); + expect(config.hooks?.create).toBeUndefined(); + expect(config.hooks?.update).toEqual({ expr: "new Date()" }); + }); + + it("does not generate hooks for non-generated datetime", () => { + const field = db.datetime(); + const config = parseFieldConfig(field); + + expect(config.hooks).toBeUndefined(); + }); + + it("does not generate hooks for generated non-datetime field", () => { + const field = db.string(); + // Manually set generated to simulate a non-datetime generated field + (field as unknown as { _metadata: { generated: boolean } })._metadata.generated = true; + const config = parseFieldConfig(field); + + expect(config.hooks).toBeUndefined(); + }); + }); +}); diff --git a/packages/sdk/src/parser/service/tailordb/field.ts b/packages/sdk/src/parser/service/tailordb/field.ts index d95dc2aa1..580967f2f 100644 --- a/packages/sdk/src/parser/service/tailordb/field.ts +++ b/packages/sdk/src/parser/service/tailordb/field.ts @@ -109,7 +109,15 @@ export function parseFieldConfig( } : undefined, } - : undefined, + : metadata.generated && fieldType === "datetime" + ? { + // Auto-generate timestamp hooks for fields created by db.fields.timestamps(). + // Required datetime (createdAt) gets a create hook; + // optional datetime (updatedAt) gets an update hook. + create: metadata.required !== false ? { expr: "new Date()" } : undefined, + update: metadata.required === false ? { expr: "new Date()" } : undefined, + } + : undefined, serial: metadata.serial ? { start: metadata.serial.start, diff --git a/packages/sdk/src/parser/service/tailordb/schema.ts b/packages/sdk/src/parser/service/tailordb/schema.ts index efa72bf1c..f38cfcb49 100644 --- a/packages/sdk/src/parser/service/tailordb/schema.ts +++ b/packages/sdk/src/parser/service/tailordb/schema.ts @@ -100,6 +100,10 @@ export const DBFieldMetadataSchema = z.object({ .max(12) .optional() .describe("Decimal scale (number of digits after decimal point, 0-12)"), + generated: z + .boolean() + .optional() + .describe("Whether the field value is auto-generated (e.g. timestamps)"), }); const RelationTypeSchema = z.enum(relationTypesKeys); @@ -266,6 +270,17 @@ export const TailorDBTypeSchema = z.object({ }), ) .optional(), + validate: z + .array(z.union([functionSchema, z.tuple([functionSchema, z.string()])])) + .optional() + .describe("Record-level validation functions"), + hooks: z + .object({ + create: functionSchema.optional().describe("Record-level hook called on record creation"), + update: functionSchema.optional().describe("Record-level hook called on record update"), + }) + .optional() + .describe("Record-level hooks for create/update"), }), }); diff --git a/packages/sdk/src/parser/service/tailordb/type-parser.ts b/packages/sdk/src/parser/service/tailordb/type-parser.ts index dac0e0a65..b74c9e380 100644 --- a/packages/sdk/src/parser/service/tailordb/type-parser.ts +++ b/packages/sdk/src/parser/service/tailordb/type-parser.ts @@ -1,6 +1,7 @@ import * as inflection from "inflection"; import { isPluginGeneratedType } from "@/types/tailordb"; -import { parseFieldConfig } from "./field"; +import { parseFieldConfig, tailorUserMap } from "./field"; +import { getPrecompiledScriptExpr } from "./hooks-validate-precompiled-expr"; import { parsePermissions } from "./permission"; import { validateRelationConfig, @@ -14,6 +15,7 @@ import type { ParsedField, ParsedRelationship, TailorDBType, + OperatorValidateConfig, } from "@/types/tailordb"; import type { TailorDBTypeRaw as TailorDBTypeSchemaOutput } from "@/types/tailordb.generated"; @@ -120,6 +122,19 @@ function parseTailorDBType( fields[fieldName] = parsedField; } + // Distribute record-level validators to the first non-id field so they are + // sent to the platform via the existing field-level validate pipeline. + // The platform only supports per-field validators in protobuf, and the + // auto-generated `id` field does not evaluate validators, so we skip it. + if (metadata.validate && metadata.validate.length > 0) { + const recordValidate = convertRecordValidators(metadata.validate); + const targetFieldName = Object.keys(fields).find((name) => name !== "id"); + if (targetFieldName) { + const targetField = fields[targetFieldName]; + targetField.config.validate = [...(targetField.config.validate || []), ...recordValidate]; + } + } + return { name: type.name, pluralForm, @@ -134,6 +149,34 @@ function parseTailorDBType( }; } +/** + * Convert record-level validators to OperatorValidateConfig[]. + * Record-level validators use { data, user } signature (no field-specific value). + * The platform provides _data as the full record, so the same expression template works. + * @param validators - Record-level validator definitions + * @returns Parsed validate configs ready for the apply pipeline + */ +function convertRecordValidators( + validators: NonNullable, +): OperatorValidateConfig[] { + return validators.map((v) => { + const { fn, message } = + typeof v === "function" + ? { fn: v, message: `failed by \`${v.toString().trim()}\`` } + : { fn: v[0], message: v[1] as string }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const fnRef = fn as Function; + return { + script: { + expr: + getPrecompiledScriptExpr(fnRef as (...args: never[]) => unknown) ?? + `(${fnRef.toString().trim()})({ value: _value, data: _data, user: ${tailorUserMap} })`, + }, + errorMessage: message, + }; + }); +} + /** * Build backward relationships between parsed types. * Also validates that backward relation names are unique within each type. diff --git a/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts b/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts index f13c27b23..c1605dfdf 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts @@ -81,7 +81,7 @@ describe("KyselyTypePlugin integration tests", () => { expect(result.typeDef).toContain("lastLogin: Timestamp | null;"); expect(result.typeDef).toContain("tags: string[];"); expect(result.typeDef).toContain("createdAt: Generated;"); - expect(result.typeDef).toContain("updatedAt: Timestamp | null;"); + expect(result.typeDef).toContain("updatedAt: Generated;"); }); it("should have correct id and description", () => { diff --git a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts index 9637f144a..6cd74b04f 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts @@ -284,7 +284,7 @@ describe("Kysely TypeProcessor", () => { expect(result.typeDef).toContain("UserWithTimestamp: {"); expect(result.typeDef).toContain("name: string"); expect(result.typeDef).toContain("createdAt: Generated;"); - expect(result.typeDef).toContain("updatedAt: Timestamp | null;"); + expect(result.typeDef).toContain("updatedAt: Generated;"); }); it("should always include Generated for id field", async () => { diff --git a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts index 6c77b4497..ec19a4034 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts @@ -149,7 +149,7 @@ function generateFieldType(fieldConfig: OperatorFieldConfig): FieldTypeResult { usedUtilityTypes.Serial = true; finalType = `Serial<${finalType}>`; } - if (fieldConfig.hooks?.create) { + if (fieldConfig.generated || fieldConfig.hooks?.create) { finalType = `Generated<${finalType}>`; } diff --git a/packages/sdk/src/types/tailordb.generated.ts b/packages/sdk/src/types/tailordb.generated.ts index 6809b288d..c4b67ab60 100644 --- a/packages/sdk/src/types/tailordb.generated.ts +++ b/packages/sdk/src/types/tailordb.generated.ts @@ -68,6 +68,8 @@ export type DBFieldMetadata = { | undefined; /** Decimal scale (number of digits after decimal point, 0-12) */ scale?: number | undefined; + /** Whether the field value is auto-generated (e.g. timestamps) */ + generated?: boolean | undefined; }; export type DBFieldMetadataInput = DBFieldMetadata; @@ -513,6 +515,13 @@ export type TailorDBTypeRawInput = { }; } | undefined; + validate?: (Function | (string | Function)[])[] | undefined; + hooks?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; }; }; @@ -554,6 +563,7 @@ export type TailorDBTypeRaw = { } | undefined; scale?: number | undefined | undefined; + generated?: boolean | undefined | undefined; }; rawRelation?: | { @@ -584,6 +594,13 @@ export type TailorDBTypeRaw = { }; } | undefined; + validate?: (Function | (string | Function)[])[] | undefined; + hooks?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; }; }; diff --git a/packages/sdk/src/types/tailordb.ts b/packages/sdk/src/types/tailordb.ts index cc90f646b..bd7e50192 100644 --- a/packages/sdk/src/types/tailordb.ts +++ b/packages/sdk/src/types/tailordb.ts @@ -7,7 +7,8 @@ import type { TailorDBServiceConfigInput, TailorDBTypeParsedSettings, } from "./tailordb.generated"; -import type { GqlOperationsConfig } from "@/configure/services/tailordb"; +import type { GqlOperationsConfig, RecordHook } from "@/configure/services/tailordb"; +import type { RecordValidateInput } from "@/configure/types/validation"; // Re-exports from configure layer (needed because parser cannot import from configure) export type { @@ -16,6 +17,7 @@ export type { TailorDBField, DBFieldMetadata, Hook, + RecordHook, TailorTypePermission, TailorTypeGqlPermission, GqlOperationsConfig, @@ -85,7 +87,7 @@ export interface EnumValue { description?: string; } -interface OperatorValidateConfig { +export interface OperatorValidateConfig { script: Script; errorMessage: string; } @@ -126,6 +128,7 @@ export interface OperatorFieldConfig { format?: string; }; scale?: number; + generated?: boolean; fields?: Record; } @@ -206,6 +209,18 @@ export interface TailorDBTypeMetadata { unique?: boolean; } >; + /** + * Record-level create/update hooks. + * TODO(platform): end-to-end wiring depends on protobuf support for record-level hooks. + */ + // oxlint-disable-next-line no-explicit-any + hooks?: RecordHook; + /** + * Record-level validators. + * TODO(platform): end-to-end wiring depends on protobuf support for record-level validators. + */ + // oxlint-disable-next-line no-explicit-any + validate?: RecordValidateInput[]; } export interface ParsedField { diff --git a/packages/sdk/src/utils/test/index.ts b/packages/sdk/src/utils/test/index.ts index c2651a9ef..6ccbd1b63 100644 --- a/packages/sdk/src/utils/test/index.ts +++ b/packages/sdk/src/utils/test/index.ts @@ -32,7 +32,7 @@ export const unauthenticatedTailorUser = { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createTailorDBHook>(type: T) { return (data: unknown) => { - return Object.entries(type.fields).reduce( + let result = Object.entries(type.fields).reduce( (hooked, [key, value]) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const field = value as TailorField; @@ -55,13 +55,31 @@ export function createTailorDBHook>(type: T) { if (hooked[key] instanceof Date) { hooked[key] = hooked[key].toISOString(); } + } else if (field.metadata.generated && field.type === "datetime") { + hooked[key] = new Date().toISOString(); } else if (data && typeof data === "object") { hooked[key] = (data as Record)[key]; } return hooked; }, {} as Record, - ) as Partial>; + ); + + // Apply record-level hooks (e.g., computed fields like fullAddress) + const recordHook = type.metadata?.hooks?.create; + if (recordHook) { + result = recordHook({ data: result, user: unauthenticatedTailorUser }) as Record< + string, + unknown + >; + for (const [key, val] of Object.entries(result)) { + if (val instanceof Date) { + result[key] = val.toISOString(); + } + } + } + + return result as Partial>; }; }