diff --git a/packages/clients/client-helpers/src/constants.ts b/packages/clients/client-helpers/src/constants.ts index ced31e94b..f18c68ec2 100644 --- a/packages/clients/client-helpers/src/constants.ts +++ b/packages/clients/client-helpers/src/constants.ts @@ -1,4 +1,9 @@ /** - * The default query endpoint. + * Route segment used for custom procedures. */ -export const DEFAULT_QUERY_ENDPOINT = '/api/model'; +export const CUSTOM_PROC_ROUTE_NAME = '$procs'; + +/** + * Route prefix used for transactions. + */ +export const TRANSACTION_ROUTE_PREFIX = '$transaction'; diff --git a/packages/clients/client-helpers/src/index.ts b/packages/clients/client-helpers/src/index.ts index cc420d519..f920d3684 100644 --- a/packages/clients/client-helpers/src/index.ts +++ b/packages/clients/client-helpers/src/index.ts @@ -7,4 +7,5 @@ export * from './nested-read-visitor'; export * from './nested-write-visitor'; export * from './optimistic'; export * from './query-analysis'; +export * from './transaction'; export * from './types'; diff --git a/packages/clients/client-helpers/src/transaction.ts b/packages/clients/client-helpers/src/transaction.ts new file mode 100644 index 000000000..dfbd69e6d --- /dev/null +++ b/packages/clients/client-helpers/src/transaction.ts @@ -0,0 +1,70 @@ +import type { + CoreCrudOperations, + CrudArgsMap, + CrudReturnMap, + ExtQueryArgsBase, + ExtResultBase, + GetSlicedOperations, + ModelAllowsCreate, + OperationsRequiringCreate, + QueryOptions, +} from '@zenstackhq/orm'; +import type { GetModels, SchemaDef } from '@zenstackhq/schema'; + +/** + * Operations available in a sequential transaction. + */ +type AllowedTransactionOps< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = + ModelAllowsCreate extends true + ? GetSlicedOperations & CoreCrudOperations + : Exclude & CoreCrudOperations, OperationsRequiringCreate>; + +/** + * Represents a single operation to execute within a sequential transaction. + * + * The `model`, `op`, and `args` fields are correlated: `op` is constrained to + * the CRUD operations available on `model` (respecting `Options['slicing']`), and + * `args` is typed accordingly. + */ +export type TransactionOperation< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = { + [Model in GetModels]: { + [Op in AllowedTransactionOps]: {} extends CrudArgsMap< + Schema, + Model, + Options, + ExtQueryArgs, + ExtResult + >[Op] + ? { model: Model; op: Op; args?: CrudArgsMap[Op] } + : { model: Model; op: Op; args: CrudArgsMap[Op] }; + }[AllowedTransactionOps]; +}[GetModels]; + +/** + * Maps each operation in a transaction tuple to its precise result type, preserving + * per-position typing. + */ +export type TransactionResults< + Schema extends SchemaDef, + Ops extends readonly TransactionOperation[], + Options extends QueryOptions = QueryOptions, + ExtResult extends ExtResultBase = {}, +> = { + [K in keyof Ops]: Ops[K] extends { model: infer M; op: infer O; args?: infer A } + ? M extends GetModels + ? O extends keyof CrudReturnMap + ? CrudReturnMap[O] + : never + : never + : never; +}; + diff --git a/packages/clients/fetch-client/eslint.config.js b/packages/clients/fetch-client/eslint.config.js new file mode 100644 index 000000000..5698b9910 --- /dev/null +++ b/packages/clients/fetch-client/eslint.config.js @@ -0,0 +1,4 @@ +import config from '@zenstackhq/eslint-config/base.js'; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json new file mode 100644 index 000000000..efe556e65 --- /dev/null +++ b/packages/clients/fetch-client/package.json @@ -0,0 +1,51 @@ +{ + "name": "@zenstackhq/fetch-client", + "displayName": "ZenStack Fetch Client", + "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", + "version": "3.6.4", + "type": "module", + "author": { + "name": "ZenStack Team", + "email": "contact@zenstack.dev" + }, + "homepage": "https://zenstack.dev", + "repository": { + "type": "git", + "url": "https://github.com/zenstackhq/zenstack" + }, + "license": "MIT", + "scripts": { + "build": "tsc --noEmit && tsdown && pnpm test:generate && pnpm test:typecheck", + "watch": "tsdown --watch", + "lint": "eslint src --ext ts", + "test": "vitest run", + "test:generate": "tsx ../../../scripts/test-generate.ts test --lite-only", + "test:typecheck": "tsc --noEmit --project tsconfig.test.json", + "pack": "pnpm pack" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "dependencies": { + "@zenstackhq/client-helpers": "workspace:*", + "@zenstackhq/common-helpers": "workspace:*", + "@zenstackhq/orm": "workspace:*", + "@zenstackhq/schema": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "@zenstackhq/cli": "workspace:*", + "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/tsdown-config": "workspace:*", + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*", + "decimal.js": "catalog:" + }, + "funding": "https://github.com/sponsors/zenstackhq" +} diff --git a/packages/clients/fetch-client/src/index.ts b/packages/clients/fetch-client/src/index.ts new file mode 100644 index 000000000..92a327099 --- /dev/null +++ b/packages/clients/fetch-client/src/index.ts @@ -0,0 +1,311 @@ +import { + CUSTOM_PROC_ROUTE_NAME, + TRANSACTION_ROUTE_PREFIX, + type InferExtQueryArgs, + type InferExtResult, + type InferOptions, + type InferSchema, + type TransactionOperation, + type TransactionResults, +} from '@zenstackhq/client-helpers'; +import { fetcher, makeUrl, marshal, type FetchFn } from '@zenstackhq/client-helpers/fetch'; +import { lowerCaseFirst } from '@zenstackhq/common-helpers'; +import type { + AllModelOperations, + ClientContract, + ExtQueryArgsBase, + ExtResultBase, + GetProcedure, + GetProcedureNames, + GetSlicedModels, + GetSlicedOperations, + GetSlicedProcedures, + ProcedureEnvelope, + ProcedureFunc, + QueryOptions, +} from '@zenstackhq/orm'; +import type { GetModels, SchemaDef } from '@zenstackhq/schema'; + +export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; +export type { TransactionOperation, TransactionResults }; + +/** + * Error codes raised by {@link CrudError}. + */ +export enum CrudErrorCode { + /** A `*OrThrow` operation found no matching entity. */ + NotFound = 'NotFound', +} + +/** + * Error thrown by CRUD operations on the fetch client. + */ +export class CrudError extends Error { + readonly code: CrudErrorCode; + + /** Name of the model that caused the error, if applicable. */ + readonly model?: string; + + constructor(code: CrudErrorCode, message: string, model?: string) { + super(message); + this.name = 'CrudError'; + this.code = code; + this.model = model; + } +} + +/** + * Options for configuring the fetch client. + */ +export type FetchClientOptions = { + /** + * The base endpoint for the CRUD API. Must be a fully qualified URL, + * e.g. `https://example.com/api/model`. + */ + endpoint: string; + + /** + * A custom fetch function. Defaults to the global `fetch`. + */ + fetch?: FetchFn; +}; + +type ProcedureFn< + Schema extends SchemaDef, + ProcName extends GetProcedureNames, + Input = ProcedureEnvelope, +> = { args: undefined } extends Input + ? (input?: Input) => Promise> + : (input: Input) => Promise>; + +type ProcedureReturn> = Awaited< + ReturnType> +>; + +type ProcedureGroup> = { + [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } + ? { mutate: ProcedureFn } + : { query: ProcedureFn }; +}; + +/** + * Procedures accessor type. Exists on client only when schema has procedures. + */ +export type ProcedureOperations> = + Schema['procedures'] extends Record + ? { $procs: ProcedureGroup } + : Record; + +/** + * CRUD operations available on each model. Derived from the ORM's + * {@link AllModelOperations}, then trimmed by the model's slicing options. + * + * The mapped type below uses `T[K]` directly (no `infer A` / `infer R`), which + * preserves each method's per-call generics intact. + */ +export type ModelOperations< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = { + [K in keyof AllModelOperations as K extends GetSlicedOperations< + Schema, + Model, + Options + > + ? K + : never]: AllModelOperations[K]; +}; + +/** + * The full typed client containing per-model operations, optional procedure operations, + * and sequential transaction support. + */ +export type FetchClient< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = { + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelOperations< + Schema, + Model, + Options, + ExtQueryArgs, + ExtResult + >; +} & ProcedureOperations & { + /** + * Executes an array of operations atomically as a sequential transaction. + * + * Each operation is a typed `{ model, op, args }` object. The result tuple is typed + * per-position based on each operation's return type. + * + * @example + * ```typescript + * const [user, post] = await client.$transaction([ + * { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + * { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + * ]); + * ``` + */ + $transaction[]>( + operations: Ops, + ): Promise>; + }; + +function normalizeEndpoint(endpoint: string): string { + if (typeof endpoint !== 'string' || endpoint.length === 0) { + throw new Error('`endpoint` is required and must be a non-empty string'); + } + try { + new URL(endpoint); + } catch { + throw new Error(`\`endpoint\` must be a fully qualified URL, got: ${endpoint}`); + } + // strip trailing slash so we can safely concatenate `/model/op` + return endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint; +} + +function makeGetRequest(endpoint: string, model: string, operation: string, args: unknown, customFetch?: FetchFn) { + return fetcher(makeUrl(endpoint, model, operation, args), undefined, customFetch); +} + +function makeWriteRequest( + endpoint: string, + model: string, + method: 'POST' | 'PUT' | 'DELETE', + operation: string, + args: unknown, + customFetch?: FetchFn, +) { + const url = method === 'DELETE' ? makeUrl(endpoint, model, operation, args) : makeUrl(endpoint, model, operation); + const fetchInit: RequestInit = { + method, + ...(method !== 'DELETE' && { + headers: { 'content-type': 'application/json' }, + body: marshal(args), + }), + }; + return fetcher(url, fetchInit, customFetch); +} + +function buildModelOperations>( + modelName: string, + endpoint: string, + customFetch?: FetchFn, +): ModelOperations { + const get = (op: string, args?: unknown) => makeGetRequest(endpoint, modelName, op, args, customFetch); + const write = (method: 'POST' | 'PUT' | 'DELETE', op: string, args?: unknown) => + makeWriteRequest(endpoint, modelName, method, op, args, customFetch); + + const findUnique = (args: any) => get('findUnique', args); + const findFirst = (args?: any) => get('findFirst', args); + const orThrow = async (op: 'findUnique' | 'findFirst', args: any) => { + const result = await (op === 'findUnique' ? findUnique(args) : findFirst(args)); + if (result == null) { + throw new CrudError(CrudErrorCode.NotFound, `No ${modelName} found`, modelName); + } + return result; + }; + + return { + findUnique, + findUniqueOrThrow: (args: any) => orThrow('findUnique', args), + findFirst, + findFirstOrThrow: (args?: any) => orThrow('findFirst', args), + findMany: (args?: any) => get('findMany', args), + exists: (args?: any) => get('exists', args), + count: (args?: any) => get('count', args), + aggregate: (args: any) => get('aggregate', args), + groupBy: (args: any) => get('groupBy', args), + create: (args: any) => write('POST', 'create', args), + createMany: (args: any) => write('POST', 'createMany', args), + createManyAndReturn: (args: any) => write('POST', 'createManyAndReturn', args), + update: (args: any) => write('PUT', 'update', args), + updateMany: (args: any) => write('PUT', 'updateMany', args), + updateManyAndReturn: (args: any) => write('PUT', 'updateManyAndReturn', args), + upsert: (args: any) => write('POST', 'upsert', args), + delete: (args: any) => write('DELETE', 'delete', args), + deleteMany: (args?: any) => write('DELETE', 'deleteMany', args), + } as ModelOperations; +} + +/** + * Creates a fetch-based client that consumes ZenStack's RPC-style auto CRUD API. + * + * Accepts either a raw `SchemaDef` or a `ClientContract` type (e.g. `typeof db`) as the + * generic parameter. When a `ClientContract` type is provided, computed fields from plugins + * are reflected in the result types. + * + * @example + * ```typescript + * import { schema } from '~/lib/schema'; + * const client = createClient(schema, { endpoint: 'https://example.com/api/model' }); + * + * const users = await client.user.findMany(); + * const post = await client.post.create({ data: { title: 'Hello' } }); + * + * const [user, newPost] = await client.$transaction([ + * { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + * { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + * ]); + * ``` + * + * @param schema The ZModel schema definition. + * @param options Client configuration options. + */ +export function createClient>( + schema: InferSchema, + options: FetchClientOptions, +): FetchClient< + InferSchema, + InferOptions>, + InferExtQueryArgs extends ExtQueryArgsBase ? InferExtQueryArgs : {}, + InferExtResult extends ExtResultBase> + ? InferExtResult + : {} +> { + const endpoint = normalizeEndpoint(options.endpoint); + const customFetch = options.fetch; + + const result = Object.values(schema.models).reduce((acc, modelDef) => { + (acc as any)[lowerCaseFirst(modelDef.name)] = buildModelOperations(modelDef.name, endpoint, customFetch); + return acc; + }, {} as any); + + const procedures = (schema as any).procedures as Record | undefined; + if (procedures) { + const procsObj: Record = {}; + for (const [name, procDef] of Object.entries(procedures)) { + if (procDef?.mutation) { + procsObj[name] = { + mutate: (input?: any) => + makeWriteRequest(endpoint, CUSTOM_PROC_ROUTE_NAME, 'POST', name, input, customFetch), + }; + } else { + procsObj[name] = { + query: (input?: any) => makeGetRequest(endpoint, CUSTOM_PROC_ROUTE_NAME, name, input, customFetch), + }; + } + } + result[CUSTOM_PROC_ROUTE_NAME] = procsObj; + } + + result.$transaction = (operations: readonly TransactionOperation[]) => { + const reqUrl = `${endpoint}/${TRANSACTION_ROUTE_PREFIX}/sequential`; + return fetcher( + reqUrl, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: marshal(operations), + }, + customFetch, + ); + }; + + return result as any; +} diff --git a/packages/clients/fetch-client/test/fetch-client.test.ts b/packages/clients/fetch-client/test/fetch-client.test.ts new file mode 100644 index 000000000..d7f416141 --- /dev/null +++ b/packages/clients/fetch-client/test/fetch-client.test.ts @@ -0,0 +1,602 @@ +import { serialize } from '@zenstackhq/client-helpers/fetch'; +import Decimal from 'decimal.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createClient, CrudError, CrudErrorCode } from '../src/index'; +import { schema } from './schemas/basic/schema-lite'; +import { schema as noProcSchema } from './schemas/no-procs/schema-lite'; + +const ENDPOINT = 'http://localhost/api/model'; + +function makeResponseText(data: unknown) { + return JSON.stringify({ data }); +} + +function makeSerializedResponseText(data: unknown) { + const { data: serializedData, meta } = serialize(data); + return JSON.stringify({ data: serializedData, meta: { serialization: meta } }); +} + +describe('createClient', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('read operations use GET', () => { + it('findUnique - sends GET with args in query string', async () => { + const data = { id: '1', email: 'alice@example.com', name: 'Alice' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(data) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.findUnique({ where: { id: '1' } }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/findUnique?q=`); + expect(init).toBeUndefined(); + expect(result).toEqual(data); + }); + + it('findFirst - sends GET', async () => { + const data = { id: '1', email: 'bob@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(data) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.findFirst({ where: { name: 'Bob' } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/findFirst?q=`); + }); + + it('findFirst - can be called with no args', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(null) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.findFirst(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/findFirst`); + }); + + it('findMany - sends GET', async () => { + const data = [ + { id: '1', email: 'a@test.com' }, + { id: '2', email: 'b@test.com' }, + ]; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(data) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.findMany(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/findMany`); + expect(result).toEqual(data); + }); + + it('exists - sends GET', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(true) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.exists({ where: { id: '1' } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/exists?q=`); + expect(result).toBe(true); + }); + + it('count - sends GET', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(42) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.count(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/count`); + expect(result).toBe(42); + }); + + it('aggregate - sends GET with args', async () => { + const aggResult = { _count: { id: 5 } }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(aggResult) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.aggregate({ _count: { id: true } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/aggregate?q=`); + expect(result).toEqual(aggResult); + }); + + it('groupBy - sends GET with args', async () => { + const groupResult = [{ name: 'Alice', _count: { id: 1 } }]; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(groupResult) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.groupBy({ by: ['name'], _count: { id: true } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/groupBy?q=`); + expect(result).toEqual(groupResult); + }); + }); + + describe('findUniqueOrThrow / findFirstOrThrow', () => { + it('findUniqueOrThrow returns the entity when found', async () => { + const data = { id: '1', email: 'alice@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(data) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.findUniqueOrThrow({ where: { id: '1' } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/findUnique?q=`); + expect(result).toEqual(data); + }); + + it('findUniqueOrThrow throws CrudError(NotFound) when not found', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(null) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findUniqueOrThrow({ where: { id: 'missing' } })).rejects.toMatchObject({ + name: 'CrudError', + code: CrudErrorCode.NotFound, + message: 'No User found', + model: 'User', + }); + }); + + it('findUniqueOrThrow rejects with a CrudError instance', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(null) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findUniqueOrThrow({ where: { id: 'missing' } })).rejects.toBeInstanceOf(CrudError); + }); + + it('findFirstOrThrow throws CrudError(NotFound) when not found', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(null) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findFirstOrThrow({ where: { name: 'Bob' } })).rejects.toMatchObject({ + name: 'CrudError', + code: CrudErrorCode.NotFound, + }); + }); + }); + + describe('write operations use correct HTTP methods', () => { + it('create - sends POST with body', async () => { + const created = { id: '1', email: 'new@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(created) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const args = { data: { email: 'new@example.com' } }; + const result = await client.user.create(args); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/create`); + expect(init.method).toBe('POST'); + expect(init.headers['content-type']).toBe('application/json'); + expect(JSON.parse(init.body)).toMatchObject({ data: { email: 'new@example.com' } }); + expect(result).toEqual(created); + }); + + it('createMany - sends POST', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 2 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.createMany({ data: [{ email: 'a@test.com' }, { email: 'b@test.com' }] }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/createMany`); + expect(init.method).toBe('POST'); + }); + + it('createManyAndReturn - sends POST', async () => { + const created = [{ id: '1', email: 'a@test.com' }]; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(created) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.createManyAndReturn({ data: [{ email: 'a@test.com' }] }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/createManyAndReturn`); + expect(init.method).toBe('POST'); + }); + + it('update - sends PUT', async () => { + const updated = { id: '1', email: 'updated@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(updated) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.update({ where: { id: '1' }, data: { email: 'updated@example.com' } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/update`); + expect(init.method).toBe('PUT'); + expect(init.headers['content-type']).toBe('application/json'); + }); + + it('updateMany - sends PUT', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 3 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.updateMany({ where: {}, data: { name: 'Updated' } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/updateMany`); + expect(init.method).toBe('PUT'); + }); + + it('updateManyAndReturn - sends PUT', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.updateManyAndReturn({ where: {}, data: { name: 'X' } }); + + const [, init] = mockFetch.mock.calls[0] ?? []; + expect(init.method).toBe('PUT'); + }); + + it('upsert - sends POST', async () => { + const upserted = { id: '1', email: 'u@test.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(upserted) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.upsert({ + where: { id: '1' }, + create: { email: 'u@test.com' }, + update: { name: 'U' }, + }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/upsert`); + expect(init.method).toBe('POST'); + }); + + it('delete - sends DELETE with args in query string', async () => { + const deleted = { id: '1', email: 'gone@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(deleted) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.delete({ where: { id: '1' } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/delete?q=`); + expect(init.method).toBe('DELETE'); + expect(init.body).toBeUndefined(); + }); + + it('deleteMany - sends DELETE with args in query string', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 5 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.deleteMany({ where: { name: null } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/deleteMany?q=`); + expect(init.method).toBe('DELETE'); + }); + + it('deleteMany - can be called with no args', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 0 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.deleteMany(); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/deleteMany`); + expect(init.method).toBe('DELETE'); + }); + }); + + describe('model name casing', () => { + it('lowercases the first letter of the model in the URL', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.post.findMany(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/post/findMany`); + }); + }); + + describe('endpoint validation', () => { + it('throws when endpoint is missing', () => { + expect(() => createClient(schema, {} as any)).toThrow(/required/); + }); + + it('throws when endpoint is empty string', () => { + expect(() => createClient(schema, { endpoint: '' })).toThrow(/required/); + }); + + it('throws when endpoint is not a fully qualified URL', () => { + expect(() => createClient(schema, { endpoint: '/api/model' })).toThrow(/fully qualified URL/); + expect(() => createClient(schema, { endpoint: 'not a url' })).toThrow(/fully qualified URL/); + }); + + it('accepts a fully qualified http(s) URL', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: 'https://example.com/api/model' }); + await client.user.findMany(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe('https://example.com/api/model/user/findMany'); + }); + + it('strips trailing slash from endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: 'http://localhost/api/model/' }); + await client.user.findMany(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe('http://localhost/api/model/user/findMany'); + }); + }); + + describe('custom fetch function', () => { + it('uses custom fetch instead of global fetch', async () => { + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => makeResponseText({ id: '1', email: 'a@test.com' }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT, fetch: customFetch }); + await client.user.findUnique({ where: { id: '1' } }); + + expect(customFetch).toHaveBeenCalledOnce(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('throws QueryError with status and info on non-ok response', async () => { + const errorInfo = { message: 'Not found', code: 'NOT_FOUND' }; + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + text: async () => JSON.stringify({ error: errorInfo }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findUnique({ where: { id: 'missing' } })).rejects.toMatchObject({ + status: 404, + info: errorInfo, + }); + }); + + it('throws on 403 access denied', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + text: async () => + JSON.stringify({ + error: { message: 'Forbidden', rejectedByPolicy: true, rejectReason: 'access-denied' }, + }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findMany()).rejects.toThrow(); + }); + + it('returns undefined for cannot-read-back policy rejection', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + text: async () => + JSON.stringify({ error: { rejectedByPolicy: true, rejectReason: 'cannot-read-back' } }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.create({ data: { email: 'x@test.com' } }); + expect(result).toBeUndefined(); + }); + + it('throws on 500 server error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: async () => JSON.stringify({ error: { message: 'Internal server error' } }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findMany()).rejects.toMatchObject({ status: 500 }); + }); + }); + + describe('SuperJSON serialization', () => { + it('deserializes Date values from response', async () => { + const date = new Date('2024-01-15T12:00:00Z'); + mockFetch.mockResolvedValue({ + ok: true, + text: async () => makeSerializedResponseText({ id: '1', createdAt: date }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = (await client.user.findUnique({ where: { id: '1' } })) as any; + + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.createdAt.toISOString()).toBe(date.toISOString()); + }); + + it('deserializes Decimal values from response', async () => { + const price = new Decimal('123.456'); + mockFetch.mockResolvedValue({ + ok: true, + text: async () => makeSerializedResponseText({ id: '1', price }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = (await client.user.findUnique({ where: { id: '1' } })) as any; + + expect(result.price).toBeInstanceOf(Decimal); + expect(result.price.toString()).toBe('123.456'); + }); + + it('serializes args with special types into query string', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.findMany({ where: { id: '1' } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain('?q='); + }); + + it('marshals args with Decimal into POST body', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 1 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.createMany({ data: [{ email: 'x@test.com' }] }); + + const [, init] = mockFetch.mock.calls[0] ?? []; + const body = JSON.parse(init.body); + expect(body).toMatchObject({ data: [{ email: 'x@test.com' }] }); + }); + }); + + describe('procedures', () => { + it('query procedure - sends GET request', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(42) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await (client as any).$procs.getStats.query(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/$procs/getStats`); + expect(result).toBe(42); + }); + + it('mutation procedure - sends POST request', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(true) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await (client as any).$procs.sendNotification.mutate({ args: { message: 'hello' } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/$procs/sendNotification`); + expect(init.method).toBe('POST'); + expect(result).toBe(true); + }); + + it('query procedure has query property, mutation has mutate', async () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + const procs = (client as any).$procs; + expect(typeof procs.getStats.query).toBe('function'); + expect(procs.getStats.mutate).toBeUndefined(); + expect(typeof procs.sendNotification.mutate).toBe('function'); + expect(procs.sendNotification.query).toBeUndefined(); + }); + }); + + describe('$transaction', () => { + it('POSTs to /$transaction/sequential with the operations array', async () => { + const results = [ + { id: '1', email: 'alice@example.com' }, + { id: '2', title: 'Hello' }, + ]; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(results) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const [user, post] = await client.$transaction([ + { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + ]); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/$transaction/sequential`); + expect(init.method).toBe('POST'); + expect(init.headers['content-type']).toBe('application/json'); + + const body = JSON.parse(init.body); + expect(body).toEqual([ + { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + ]); + + expect(user).toEqual(results[0]); + expect(post).toEqual(results[1]); + }); + + it('preserves operation order in the result tuple', async () => { + const userResult = { id: '1', email: 'alice@example.com' }; + const postResult = { id: '2', title: 'Hello' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([postResult, userResult]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const [post, user] = await client.$transaction([ + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + ]); + + expect(post).toEqual(postResult); + expect(user).toEqual(userResult); + }); + + it('includes all op types in the request body', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([{ count: 2 }, null]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.$transaction([ + { model: 'User', op: 'updateMany', args: { where: {}, data: { name: 'X' } } }, + { model: 'Post', op: 'delete', args: { where: { id: '1' } } }, + ]); + + const body = JSON.parse((mockFetch.mock.calls[0] ?? [])[1].body); + expect(body[0]).toMatchObject({ model: 'User', op: 'updateMany' }); + expect(body[1]).toMatchObject({ model: 'Post', op: 'delete' }); + }); + + it('marshals args with SuperJSON when special types are present', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([{ id: '1' }]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.$transaction([{ model: 'User', op: 'findMany', args: { where: { id: '1' } } }]); + + // Plain args – no meta expected + const body = JSON.parse((mockFetch.mock.calls[0] ?? [])[1].body); + expect(body[0].args).toEqual({ where: { id: '1' } }); + }); + + it('uses custom fetch in transaction', async () => { + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => makeResponseText([{ id: '1', email: 'a@test.com' }]), + }); + + const client = createClient(schema, { endpoint: ENDPOINT, fetch: customFetch }); + await client.$transaction([{ model: 'User', op: 'findMany' }]); + + expect(customFetch).toHaveBeenCalledOnce(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('throws QueryError when server returns error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + text: async () => JSON.stringify({ error: { message: 'Bad request' } }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect( + client.$transaction([{ model: 'User', op: 'create', args: { data: { email: 'x@test.com' } } }]), + ).rejects.toMatchObject({ status: 400 }); + }); + }); + + describe('$procs absent when schema has no procedures', () => { + it('does not add $procs for schema without procedures', async () => { + const client = createClient(noProcSchema, { endpoint: ENDPOINT }); + expect((client as any).$procs).toBeUndefined(); + }); + }); +}); diff --git a/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts b/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts new file mode 100644 index 000000000..39cbdc765 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts @@ -0,0 +1,96 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + default: ExpressionUtils.call("cuid") as FieldDefault + }, + email: { + name: "email", + type: "String", + unique: true + }, + name: { + name: "name", + type: "String", + optional: true + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + default: ExpressionUtils.call("cuid") as FieldDefault + }, + title: { + name: "title", + type: "String" + }, + author: { + name: "author", + type: "User", + optional: true, + relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "String", + optional: true, + foreignKeyFor: [ + "author" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + authType = "User" as const; + procedures = { + getStats: { + params: {}, + returnType: "Int" + }, + sendNotification: { + params: { + message: { name: "message", type: "String" } + }, + returnType: "Boolean", + mutation: true + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/packages/clients/fetch-client/test/schemas/basic/schema.zmodel b/packages/clients/fetch-client/test/schemas/basic/schema.zmodel new file mode 100644 index 000000000..677819fe7 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/basic/schema.zmodel @@ -0,0 +1,21 @@ +datasource db { + provider = 'sqlite' +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + author User? @relation(fields: [authorId], references: [id]) + authorId String? +} + +procedure getStats(): Int + +mutation procedure sendNotification(message: String): Boolean diff --git a/packages/clients/fetch-client/test/schemas/no-procs/schema-lite.ts b/packages/clients/fetch-client/test/schemas/no-procs/schema-lite.ts new file mode 100644 index 000000000..6c42484a0 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/no-procs/schema-lite.ts @@ -0,0 +1,32 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + Item: { + name: "Item", + fields: { + id: { + name: "id", + type: "String", + id: true, + default: ExpressionUtils.call("cuid") as FieldDefault + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/packages/clients/fetch-client/test/schemas/no-procs/schema.zmodel b/packages/clients/fetch-client/test/schemas/no-procs/schema.zmodel new file mode 100644 index 000000000..274423f47 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/no-procs/schema.zmodel @@ -0,0 +1,7 @@ +datasource db { + provider = 'sqlite' +} + +model Item { + id String @id @default(cuid()) +} diff --git a/packages/clients/fetch-client/test/typing.test-d.ts b/packages/clients/fetch-client/test/typing.test-d.ts new file mode 100644 index 000000000..5c143c8d3 --- /dev/null +++ b/packages/clients/fetch-client/test/typing.test-d.ts @@ -0,0 +1,219 @@ +import type { ClientContract, ClientOptions } from '@zenstackhq/orm'; +import { ZenStackClient } from '@zenstackhq/orm'; +import { describe, expectTypeOf, it } from 'vitest'; +import { createClient } from '../src/index'; +import { schema } from './schemas/basic/schema-lite'; + +const ENDPOINT = 'http://localhost/api/model'; + +describe('Result narrowing through AllModelOperations', () => { + it('full row shape with no select', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + expectTypeOf(client.user.findMany()).resolves.toEqualTypeOf< + Array<{ id: string; email: string; name: string | null }> + >(); + }); + + it('select narrows the result row', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + const promise = client.user.findMany({ select: { id: true } }); + expectTypeOf(promise).resolves.toEqualTypeOf>(); + }); + + it('findUniqueOrThrow returns non-null', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + expectTypeOf(client.user.findUniqueOrThrow({ where: { id: '1' } })).resolves.toEqualTypeOf<{ + id: string; + email: string; + name: string | null; + }>(); + }); +}); + +describe('Slicing', () => { + it('trims models', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + procedures: {} as any, + slicing: { includedModels: ['User'] }, + }); + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.user.findMany(); + // @ts-expect-error – 'post' was sliced away + client.post; + }); + + it('trims operations', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + procedures: {} as any, + slicing: { + models: { + user: { includedOperations: ['findUnique', 'findMany'] }, + }, + }, + }); + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.user.findUnique({ where: { id: '1' } }); + client.user.findMany(); + // @ts-expect-error – 'create' was sliced away + client.user.create({ data: { email: 'a@b.com' } }); + // @ts-expect-error – 'delete' was sliced away + client.user.delete({ where: { id: '1' } }); + }); + + it('trims filters', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + procedures: {} as any, + slicing: { + models: { + user: { + fields: { + $all: { includedFilterKinds: ['Equality'] }, + }, + }, + }, + }, + }); + const client = createClient(schema, { endpoint: ENDPOINT }); + + // Equality filter is allowed + client.user.findMany({ where: { name: { equals: 'test' } } }); + + // @ts-expect-error – `contains` is not allowed when only Equality is included + client.user.findMany({ where: { name: { contains: 'test' } } }); + }); + + it('respects slicing in transaction op union', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + procedures: {} as any, + slicing: { + models: { + user: { includedOperations: ['findUnique', 'findMany', 'count'] }, + }, + }, + }); + const client = createClient(schema, { endpoint: ENDPOINT }); + + void async function () { + // included read ops are allowed + await client.$transaction([ + { model: 'User', op: 'findMany' }, + { model: 'User', op: 'findUnique', args: { where: { id: '1' } } }, + { model: 'User', op: 'count' }, + ] as const); + + await client.$transaction([ + // @ts-expect-error 'create' was sliced away + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }, + ] as const); + + await client.$transaction([ + // @ts-expect-error 'delete' was sliced away + { model: 'User', op: 'delete', args: { where: { id: '1' } } }, + ] as const); + }; + }); +}); + +describe('Extended query args (ExtQueryArgs)', () => { + type DbType = ClientContract< + typeof schema, + ClientOptions, + // $read adds a `cache` filter to all read ops; $create adds a `bust` flag to creates + { + $read: { cache?: { ttl?: number } }; + $create: { cache?: { bust?: boolean } }; + } + >; + + it('flows through read ops', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.user.findMany({ cache: { ttl: 1000 } }); + client.user.findUnique({ where: { id: '1' }, cache: { ttl: 1000 } }); + client.user.count({ cache: { ttl: 1000 } }); + + // @ts-expect-error – $read's cache shape doesn't accept `bust` + client.user.findMany({ cache: { bust: true } }); + }); + + it('flows through create', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.user.create({ data: { email: 'a@b.com' }, cache: { bust: true } }); + + // @ts-expect-error – $create's cache shape doesn't accept `ttl` + client.user.create({ data: { email: 'a@b.com' }, cache: { ttl: 1000 } }); + }); + + it('flows through transaction args', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + void async function () { + await client.$transaction([ + { model: 'User', op: 'findMany', args: { cache: { ttl: 500 } } }, + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' }, cache: { bust: true } } }, + ] as const); + + await client.$transaction([ + // @ts-expect-error – $create has no `ttl` + { model: 'User', op: 'create', args: { data: { email: 'a' }, cache: { ttl: 1 } } }, + ] as const); + }; + }); +}); + +describe('Extended result fields (ExtResult)', () => { + type DbType = ClientContract< + typeof schema, + ClientOptions, + {}, + {}, + // User gains a computed `displayName` field + { + user: { + displayName: { + needs: { email: true }; + compute: (data: { email: string }) => string; + }; + }; + } + >; + + it('adds the computed field to read results', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + expectTypeOf(client.user.findUnique({ where: { id: '1' } })).resolves.toMatchTypeOf< + { displayName: string } | null + >(); + + expectTypeOf(client.user.findMany()).resolves.toMatchTypeOf>(); + }); + + it('adds the computed field to mutation results', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + expectTypeOf(client.user.create({ data: { email: 'a@b.com' } })).resolves.toMatchTypeOf<{ + displayName: string; + }>(); + }); + + it('flows through transaction return positions', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + void async function () { + const r = await client.$transaction([ + { model: 'User', op: 'findMany' }, + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }, + ] as const); + + expectTypeOf(r[0]).toMatchTypeOf>(); + expectTypeOf(r[1]).toMatchTypeOf<{ displayName: string }>(); + }; + }); +}); diff --git a/packages/clients/fetch-client/tsconfig.json b/packages/clients/fetch-client/tsconfig.json new file mode 100644 index 000000000..a129f8b7f --- /dev/null +++ b/packages/clients/fetch-client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/clients/fetch-client/tsconfig.test.json b/packages/clients/fetch-client/tsconfig.test.json new file mode 100644 index 000000000..e88e9202a --- /dev/null +++ b/packages/clients/fetch-client/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "include": ["src/**/*.ts", "test/**/*.ts"], + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": ["node"] + } +} diff --git a/packages/clients/fetch-client/tsdown.config.ts b/packages/clients/fetch-client/tsdown.config.ts new file mode 100644 index 000000000..c13a48310 --- /dev/null +++ b/packages/clients/fetch-client/tsdown.config.ts @@ -0,0 +1,8 @@ +import { createConfig } from '@zenstackhq/tsdown-config'; + +export default createConfig({ + entry: { + index: 'src/index.ts', + }, + format: ['esm'], +}); diff --git a/packages/clients/fetch-client/vitest.config.ts b/packages/clients/fetch-client/vitest.config.ts new file mode 100644 index 000000000..6a5eba157 --- /dev/null +++ b/packages/clients/fetch-client/vitest.config.ts @@ -0,0 +1,14 @@ +import base from '@zenstackhq/vitest-config/base'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig( + base, + defineConfig({ + test: { + typecheck: { + enabled: true, + tsconfig: 'tsconfig.test.json', + }, + }, + }), +); diff --git a/packages/clients/tanstack-query/src/common/constants.ts b/packages/clients/tanstack-query/src/common/constants.ts index f1dba8534..79bc335ac 100644 --- a/packages/clients/tanstack-query/src/common/constants.ts +++ b/packages/clients/tanstack-query/src/common/constants.ts @@ -1,5 +1,6 @@ -/** Route segment for custom procedures. */ -export const CUSTOM_PROC_ROUTE_NAME = '$procs'; +export { CUSTOM_PROC_ROUTE_NAME, TRANSACTION_ROUTE_PREFIX } from '@zenstackhq/client-helpers'; -/** Route prefix for transaction endpoints. */ -export const TRANSACTION_ROUTE_PREFIX = '$transaction'; +/** + * The default query endpoint. + */ +export const DEFAULT_QUERY_ENDPOINT = '/api/model'; diff --git a/packages/clients/tanstack-query/src/common/transaction.ts b/packages/clients/tanstack-query/src/common/transaction.ts index afb6dd5c4..396c5b3d4 100644 --- a/packages/clients/tanstack-query/src/common/transaction.ts +++ b/packages/clients/tanstack-query/src/common/transaction.ts @@ -1,11 +1,9 @@ import type { Logger } from '@zenstackhq/client-helpers'; -import { createInvalidator, type InvalidateFunc } from '@zenstackhq/client-helpers'; -import type { FetchFn } from '@zenstackhq/client-helpers/fetch'; -import { fetcher, marshal } from '@zenstackhq/client-helpers/fetch'; +import { createInvalidator, TRANSACTION_ROUTE_PREFIX, type InvalidateFunc } from '@zenstackhq/client-helpers'; +import { fetcher, marshal, type FetchFn } from '@zenstackhq/client-helpers/fetch'; +import type { TransactionOperation } from '@zenstackhq/client-helpers'; import { CoreReadOperations } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/schema'; -import { TRANSACTION_ROUTE_PREFIX } from './constants.js'; -import type { TransactionOperation } from './types.js'; /** * Builds the mutation function for a sequential transaction request. diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts index 867234397..ffaad961f 100644 --- a/packages/clients/tanstack-query/src/common/types.ts +++ b/packages/clients/tanstack-query/src/common/types.ts @@ -1,11 +1,6 @@ import type { Logger, OptimisticDataProvider } from '@zenstackhq/client-helpers'; import type { FetchFn } from '@zenstackhq/client-helpers/fetch'; import type { - CoreCrudOperations, - CrudArgsMap, - CrudReturnMap, - ExtQueryArgsBase, - ExtResultBase, GetProcedureNames, GetSlicedOperations, ModelAllowsCreate, @@ -15,6 +10,8 @@ import type { } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; +export type { TransactionOperation, TransactionResults } from '@zenstackhq/client-helpers'; + /** * Context type for configuring the hooks. */ @@ -105,60 +102,3 @@ export type WithOptimistic = T extends Array ? Array> = Awaited< ReturnType> >; - -/** - * Operations available in a sequential transaction. - */ -type AllowedTransactionOps< - Schema extends SchemaDef, - Model extends GetModels, - Options extends QueryOptions = QueryOptions, -> = - ModelAllowsCreate extends true - ? GetSlicedOperations & CoreCrudOperations - : Exclude & CoreCrudOperations, OperationsRequiringCreate>; - -/** - * Represents a single operation to execute within a sequential transaction. - * - * The `model`, `op`, and `args` fields are correlated: `op` is constrained to - * the CRUD operations available on `model` (respecting `Options['slicing']`), and - * `args` is typed accordingly. - */ -export type TransactionOperation< - Schema extends SchemaDef, - Options extends QueryOptions = QueryOptions, - ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, -> = { - [Model in GetModels]: { - [Op in AllowedTransactionOps]: {} extends CrudArgsMap< - Schema, - Model, - Options, - ExtQueryArgs, - ExtResult - >[Op] - ? { model: Model; op: Op; args?: CrudArgsMap[Op] } - : { model: Model; op: Op; args: CrudArgsMap[Op] }; - }[AllowedTransactionOps]; -}[GetModels]; - -/** - * Maps each operation in a transaction tuple to its precise result type, preserving - * per-position typing. - */ -export type TransactionResults< - Schema extends SchemaDef, - Ops extends readonly TransactionOperation[], - Options extends QueryOptions = QueryOptions, - ExtResult extends ExtResultBase = {}, -> = { - [K in keyof Ops]: Ops[K] extends { model: infer M; op: infer O; args?: infer A } - ? M extends GetModels - ? O extends keyof CrudReturnMap - ? CrudReturnMap[O] - : never - : never - : never; -}; diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index 2f8413dc7..d7bbb6c6c 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -22,7 +22,6 @@ import { import { createInvalidator, createOptimisticUpdater, - DEFAULT_QUERY_ENDPOINT, type InferExtQueryArgs, type InferExtResult, type InferOptions, @@ -68,7 +67,7 @@ import type { import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { createContext, useContext } from 'react'; import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from './common/client.js'; -import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js'; +import { CUSTOM_PROC_ROUTE_NAME, DEFAULT_QUERY_ENDPOINT } from './common/constants.js'; import { getQueryKey } from './common/query-key.js'; import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js'; import type { diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index 5e0467d28..fbfa2ad04 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -18,7 +18,6 @@ import { import { createInvalidator, createOptimisticUpdater, - DEFAULT_QUERY_ENDPOINT, type InferExtQueryArgs, type InferExtResult, type InferOptions, @@ -65,7 +64,7 @@ import type { import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { getContext, setContext } from 'svelte'; import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from '../common/client.js'; -import { CUSTOM_PROC_ROUTE_NAME } from '../common/constants.js'; +import { CUSTOM_PROC_ROUTE_NAME, DEFAULT_QUERY_ENDPOINT } from '../common/constants.js'; import { getQueryKey } from '../common/query-key.js'; import { makeTransactionMutationFn, makeTransactionOnSuccess } from '../common/transaction.js'; import type { diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index bfffa2481..687a43c4d 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -16,7 +16,6 @@ import { import { createInvalidator, createOptimisticUpdater, - DEFAULT_QUERY_ENDPOINT, type InferExtQueryArgs, type InferExtResult, type InferOptions, @@ -63,7 +62,7 @@ import type { import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { computed, inject, provide, toValue, unref, type MaybeRefOrGetter, type Ref, type UnwrapRef } from 'vue'; import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from './common/client.js'; -import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js'; +import { CUSTOM_PROC_ROUTE_NAME, DEFAULT_QUERY_ENDPOINT } from './common/constants.js'; import { getQueryKey } from './common/query-key.js'; import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js'; import type { diff --git a/packages/orm/package.json b/packages/orm/package.json index 43a28d681..27a48a34e 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -15,7 +15,7 @@ }, "license": "MIT", "scripts": { - "build": "tsc --noEmit && tsdown && pnpm test:generate", + "build": "tsc --noEmit && tsdown", "watch": "tsdown --watch", "lint": "eslint src --ext ts", "pack": "pnpm pack", diff --git a/packages/schema/package.json b/packages/schema/package.json index 3bc70a1ea..ca0f9323f 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -15,7 +15,7 @@ }, "license": "MIT", "scripts": { - "build": "tsc --noEmit && tsdown && pnpm test:generate", + "build": "tsc --noEmit && tsdown", "watch": "tsdown --watch", "lint": "eslint src --ext ts", "test": "vitest run", diff --git a/packages/zod/package.json b/packages/zod/package.json index 1961848c1..358514c43 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -15,7 +15,7 @@ }, "license": "MIT", "scripts": { - "build": "tsc --noEmit && tsdown && pnpm test:generate", + "build": "tsc --noEmit && tsdown", "watch": "tsdown --watch", "lint": "eslint src --ext ts", "test": "vitest run", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b88ed8ff1..a01f92fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,43 @@ importers: specifier: workspace:* version: link:../../config/vitest-config + packages/clients/fetch-client: + dependencies: + '@zenstackhq/client-helpers': + specifier: workspace:* + version: link:../client-helpers + '@zenstackhq/common-helpers': + specifier: workspace:* + version: link:../../common-helpers + '@zenstackhq/orm': + specifier: workspace:* + version: link:../../orm + '@zenstackhq/schema': + specifier: workspace:* + version: link:../../schema + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.24 + '@zenstackhq/cli': + specifier: workspace:* + version: link:../../cli + '@zenstackhq/eslint-config': + specifier: workspace:* + version: link:../../config/eslint-config + '@zenstackhq/tsdown-config': + specifier: workspace:* + version: link:../../config/tsdown-config + '@zenstackhq/typescript-config': + specifier: workspace:* + version: link:../../config/typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../../config/vitest-config + decimal.js: + specifier: 'catalog:' + version: 10.6.0 + packages/clients/tanstack-query: dependencies: '@zenstackhq/client-helpers': @@ -13853,7 +13890,7 @@ snapshots: eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.29.0(jiti@2.6.1)) @@ -13886,7 +13923,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -13901,7 +13938,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9