diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3cd034..68af05ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,10 @@ Breaking: - `defineSchema` service-definition maps are replaced by `createSchema` + `service` + relationship helpers. +- Services now separate schema names from transport paths. Builders, relationships, `m`, and + `useMutating` take names; descriptor APIs, `useFeathers`, and deprecated hooks take paths. + `service({ path })` becomes `service().at(path)`; generated catalogs use + `service.from()`. - `figbird.query(desc)` → `figbird.queryDesc(desc)` - `figbird.mutate(desc)` → `figbird.mutateDesc(desc)` - `figbird.query(builder | request)` is now the non-React mirror of `useQuery` diff --git a/docs/content/_index.md b/docs/content/_index.md index 4f958c43..b00be92a 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -147,13 +147,33 @@ interface TaskService { const schema = createSchema({ services: { tasks: service(), - people: service({ path: 'api/people' }), + people: service().at('api/people'), }, relationships: {/* per-service factories — see Relations */}, }) ``` -Omitted payload types default sensibly: `Partial` for create and patch, `item` for update. Service keys are preserved as literal types, so every API narrows on the service name. The `path` option separates ergonomic schema keys from transport-level service paths. +Omitted payload types default sensibly: `Partial` for create and patch, `item` for update. Schema names and transport paths stay separate literal types. Builders, relationships, and current mutation APIs use schema names, while raw and compatibility APIs use transport paths. `.at(path)` connects the two. + +For a generated, path-keyed backend catalog, bind it once and select each contract by path: + +```ts +interface ApiSchemaTypes { + 'api/people': PersonService + 'api/tasks': TaskService +} + +const apiService = service.from() + +const schema = createSchema({ + services: { + people: apiService('api/people'), + tasks: apiService('api/tasks'), + }, +}) +``` + +The object key supplies the Figbird name, while the factory argument supplies both the transport path and the matching service definition. Unknown catalog paths are TypeScript errors. ### What flows where @@ -1261,9 +1281,9 @@ await figbird.m.tasks.confirmed.patch(id, { done: true }) // waits for the ack await figbird.m.tasks.archive([id]) // custom schema methods, typed ``` -Below that sits the **descriptor layer**: plain `{ serviceName, method }` objects, no -schema required. It's the primitive the relational engine itself is built on, and the -only surface a schema-less instance can use. +Below that sits the **descriptor layer**: plain `{ serviceName, method }` objects using +transport service paths, with no schema required. It's the primitive the relational engine +itself is built on, and the only surface a schema-less instance can use. ```ts const query = figbird.queryDesc({ @@ -1668,12 +1688,23 @@ type-check against the actual items, including both hops of a junction `many`. ## service ```ts -service<{ item: Note; query?: NoteQuery; create?; update?; patch?; methods? }>(options?) +service() +service().at('api/notes') + +const apiService = service.from() +apiService('api/notes') ``` Declares one service's types. Only `item` is required; omitted payloads default to -`Partial` for create/patch and `item` for update. `options.path` maps an ergonomic -schema key to the transport-level service path. `methods` types custom Feathers methods. +`Partial` for create/patch and `item` for update. `.at(path)` maps an ergonomic +schema key to a non-empty literal transport path. `service.from()` is the +generated-schema form: its path argument selects the matching service definition from a +path-keyed catalog. `methods` types custom Feathers methods. + +The namespaces are deliberately separate. Builder APIs (`q`, `m`, relationships, and +`useMutating`) use schema names. Raw and compatibility APIs (`queryDesc`, `mutateDesc`, +`call`, `useFeathers`, `useFind`, `useGet`, and `useMutation`) use transport paths. A schema +name may equal another service's transport path without becoming ambiguous. ## one @@ -1743,8 +1774,8 @@ const figbird = new Figbird({ | `inspect()` | Live-query snapshot — see [figbird.inspect](#figbirdinspect). | | `events` | Observability channel — see [figbird.events](#figbirdevents). | | `query(builder)` | Live query ref for non-React use — the `useQuery` mirror; also accepts a bound request or argumentless definition. See [Using outside React](#using-outside-react). | -| `queryDesc(desc, config?)` | Descriptor-layer query — no schema required. | -| `mutateDesc(desc)` / `call(service, method, ...)` | Descriptor-layer mutation / custom-method call. | +| `queryDesc(desc, config?)` | Transport-path descriptor query — no schema required. | +| `mutateDesc(desc)` / `call(service, method, ...)` | Transport-path descriptor mutation / custom-method call. | | `getState()` / `subscribeToStateChanges(fn)` | Raw internal state, including the cached entities themselves (`inspect()` omits items). Debug-grade — shapes may change between versions. | ## FeathersAdapter diff --git a/lib/adapters/feathers.ts b/lib/adapters/feathers.ts index e9d33f17..157f48cf 100644 --- a/lib/adapters/feathers.ts +++ b/lib/adapters/feathers.ts @@ -11,16 +11,7 @@ import type { QueryResponse, } from './adapter.js' import { matcher, type PrepareQueryOptions, type Query } from './matcher.js' -import type { - Schema, - ServiceNames, - ServiceItem, - ServiceCreate, - ServiceUpdate, - ServicePatch, - ServiceQuery, - ServiceMethods, -} from '../core/schema.js' +import type { Schema, ServiceDefinitionByPath, ServicePaths } from '../core/schema.js' // Helper types for field extraction type IdExtractor = (item: unknown) => string | number | undefined @@ -275,23 +266,23 @@ export type TypedFeathersService< /** * Typed Feathers client based on schema. * Uses a mapped type that creates a union of call signatures, enabling literal - * narrowing: when you call service('notes'), only the 'notes' signature matches. + * narrowing: when you call service('api/notes'), only that path's signature matches. * * @example * const client: TypedFeathersClient = ... - * const note = await client.service('notes').get('1') // note: Note - * await client.service('notes').archive(['1']) // Custom method typed! + * const note = await client.service('api/notes').get('1') // note: Note + * await client.service('api/notes').archive(['1']) // Custom method typed! */ export type TypedFeathersClient = { - service>( - serviceName: N, + service

>( + servicePath: P, ): TypedFeathersService< - ServiceItem, - ServiceCreate, - ServiceUpdate, - ServicePatch, - ServiceQuery, - ServiceMethods + ServiceDefinitionByPath['item'], + ServiceDefinitionByPath['create'], + ServiceDefinitionByPath['update'], + ServiceDefinitionByPath['patch'], + ServiceDefinitionByPath['query'], + ServiceDefinitionByPath['methods'] > } diff --git a/lib/core/figbird.ts b/lib/core/figbird.ts index ccb9eb8b..8b35eb63 100644 --- a/lib/core/figbird.ts +++ b/lib/core/figbird.ts @@ -69,12 +69,9 @@ export type { InspectedRelationalQuery } from './relationalQuery.js' import type { AnySchema, Schema, - ServiceCreate, - ServiceItem, + ServiceDefinitionByPath, ServiceNames, - ServicePatch, - ServiceQuery, - ServiceUpdate, + ServicePaths, } from './schema.js' import { resolveServicePath } from './schema.js' @@ -157,10 +154,10 @@ export { RelationalQueryRef } from './relationalQuery.js' export type { RelationalPaginationState, RelationalQueryState } from './relationalQuery.js' // Helper to specialize adapter params' `query` by service-level domain query -type ParamsWithServiceQuery, A extends Adapter> = Omit< +type ParamsWithServiceQuery, A extends Adapter> = Omit< AdapterParams, 'query' -> & { query?: ServiceQuery } +> & { query?: ServiceDefinitionByPath['query'] } const KEYED_MUTATION_QUEUE_RETENTION_MS = 5 * 60_000 @@ -586,34 +583,40 @@ export class Figbird< // Descriptor layer — the primitive the relational engine (and the deprecated // useFind/useGet path) is built on. Speaks plain `{ serviceName, method }` - // descriptors, requires no schema, and resolves service path aliases centrally. + // descriptors in the transport-path namespace and requires no schema. // Prefer `figbird.query(builder)` in app code. // Strongly-typed overloads for inference from serviceName and method /** Create a typed `find` query reference from a descriptor. */ - queryDesc>( - desc: { serviceName: N; method: 'find'; params?: ParamsWithServiceQuery }, - config?: QueryConfig[], ServiceQuery>, + queryDesc

>( + desc: { serviceName: P; method: 'find'; params?: ParamsWithServiceQuery }, + config?: QueryConfig< + ServiceDefinitionByPath['item'][], + ServiceDefinitionByPath['query'] + >, ): QueryRef< - ServiceItem[], - ServiceQuery, + ServiceDefinitionByPath['item'][], + ServiceDefinitionByPath['query'], S, AdapterParams, AdapterFindMeta, AdapterQuery > /** Create a typed `get` query reference from a descriptor. */ - queryDesc>( + queryDesc

>( desc: { - serviceName: N + serviceName: P method: 'get' resourceId: string | number - params?: ParamsWithServiceQuery + params?: ParamsWithServiceQuery }, - config?: QueryConfig, ServiceQuery>, + config?: QueryConfig< + ServiceDefinitionByPath['item'], + ServiceDefinitionByPath['query'] + >, ): QueryRef< - ServiceItem, - ServiceQuery, + ServiceDefinitionByPath['item'], + ServiceDefinitionByPath['query'], S, AdapterParams, AdapterFindMeta, @@ -642,14 +645,9 @@ export class Figbird< config?: QueryConfig, // oxlint-disable-next-line @typescript-eslint/no-explicit-any ): any { - const resolvedDesc = { - ...desc, - serviceName: resolveServicePath(this.schema, desc.serviceName), - } - return new QueryRef, AdapterFindMeta, AdapterQuery>( { - desc: resolvedDesc as QueryDescriptor, + desc: desc as QueryDescriptor, config: normalizeQueryConfig(config), queryStore: this.queryStore, }, @@ -663,61 +661,58 @@ export class Figbird< // Strongly-typed mutation overloads /** Create a single new item. */ - mutateDesc>(desc: { - serviceName: N + mutateDesc

>(desc: { + serviceName: P method: 'create' - data: ServiceCreate + data: ServiceDefinitionByPath['create'] params?: AdapterParams - optimistic?: boolean | ServiceItem - }): Promise> + optimistic?: boolean | ServiceDefinitionByPath['item'] + }): Promise['item']> /** Create multiple new items (batch). */ - mutateDesc>(desc: { - serviceName: N + mutateDesc

>(desc: { + serviceName: P method: 'create' - data: ServiceCreate[] + data: ServiceDefinitionByPath['create'][] params?: AdapterParams - optimistic?: boolean | ServiceItem[] - }): Promise[]> + optimistic?: boolean | ServiceDefinitionByPath['item'][] + }): Promise['item'][]> /** Update an existing item by ID (full replacement). */ - mutateDesc>( + mutateDesc

>( desc: { - serviceName: N + serviceName: P method: 'update' id: string | number - data: ServiceUpdate + data: ServiceDefinitionByPath['update'] params?: AdapterParams - } & DescriptorWriteProjection>, - ): Promise> + } & DescriptorWriteProjection['item']>, + ): Promise['item']> /** Patch an existing item by ID (partial update). */ - mutateDesc>( + mutateDesc

>( desc: { - serviceName: N + serviceName: P method: 'patch' id: string | number - data: ServicePatch + data: ServiceDefinitionByPath['patch'] params?: AdapterParams - } & DescriptorWriteProjection>, - ): Promise> + } & DescriptorWriteProjection['item']>, + ): Promise['item']> /** Remove an item by ID. */ - mutateDesc>(desc: { - serviceName: N + mutateDesc

>(desc: { + serviceName: P method: 'remove' id: string | number params?: AdapterParams optimistic?: boolean - }): Promise> + }): Promise['item']> // Implementation // oxlint-disable-next-line @typescript-eslint/no-explicit-any mutateDesc(desc: MutationDescriptor): Promise { - return this.queryStore.mutate({ - ...desc, - serviceName: resolveServicePath(this.schema, desc.serviceName), - }) + return this.queryStore.mutate(desc) } /** @@ -729,8 +724,8 @@ export class Figbird< * Prefer the typed methods on an `m` handle in app code; this is the * underlying primitive. */ - call(serviceName: string, method: string, ...args: unknown[]): Promise { - return this.queryStore.call(resolveServicePath(this.schema, serviceName), method, args) + call(servicePath: string, method: string, ...args: unknown[]): Promise { + return this.queryStore.call(servicePath, method, args) } #mutationsProxy: MutationsProxy | null = null diff --git a/lib/core/queryTypes.ts b/lib/core/queryTypes.ts index 26e1329a..d7246192 100644 --- a/lib/core/queryTypes.ts +++ b/lib/core/queryTypes.ts @@ -1,4 +1,4 @@ -import type { AnySchema, Schema, ServiceItem, ServiceNames } from './schema.js' +import type { AnySchema, Schema, ServiceDefinitionByPath, ServicePaths } from './schema.js' import type { StoredQueryClass } from './queryClassification.js' import type { PageInfo, PageRequest } from '../adapters/adapter.js' @@ -351,12 +351,12 @@ export type ItemMatcher = (item: T) => boolean */ export type InferQueryData = S extends AnySchema ? UntypedData - : D extends { serviceName: infer N extends string; method: infer M } - ? N extends ServiceNames + : D extends { serviceName: infer P extends string; method: infer M } + ? P extends ServicePaths ? M extends 'find' - ? ServiceItem[] + ? ServiceDefinitionByPath['item'][] : M extends 'get' - ? ServiceItem + ? ServiceDefinitionByPath['item'] : UntypedData : UntypedData : UntypedData @@ -431,13 +431,13 @@ export type MutationDescriptor = */ export type InferMutationData = S extends AnySchema ? UntypedData - : D extends { serviceName: infer N extends string; data: readonly unknown[] } - ? N extends ServiceNames - ? ServiceItem[] + : D extends { serviceName: infer P extends string; data: readonly unknown[] } + ? P extends ServicePaths + ? ServiceDefinitionByPath['item'][] : UntypedData - : D extends { serviceName: infer N extends string } - ? N extends ServiceNames - ? ServiceItem + : D extends { serviceName: infer P extends string } + ? P extends ServicePaths + ? ServiceDefinitionByPath['item'] : UntypedData : UntypedData diff --git a/lib/core/schema.ts b/lib/core/schema.ts index 13e3931e..815b37fc 100644 --- a/lib/core/schema.ts +++ b/lib/core/schema.ts @@ -38,22 +38,13 @@ export interface ResolvedServiceDef { export interface Service< TDef extends ResolvedServiceDef = ResolvedServiceDef, TName extends string = string, + TPath extends string = string, > { readonly name: TName - readonly path: string + readonly path: TPath readonly [$phantom]?: TDef } -export interface ServiceOptions { - /** - * Transport-level service name. When omitted, the schema key is used. - * - * This lets app code use ergonomic schema keys (`people`) while adapters still - * call the real backend service path (`api/people`). - */ - path?: TPath -} - // Helper types to derive payload types from service definition type DeriveCreate = TServiceDef['create'] extends undefined ? Partial : TServiceDef['create'] @@ -84,14 +75,67 @@ type ResolveDef = { methods: DeriveMethods } -// Phase 1: Create a service definition (no name yet) -export function service< - TServiceDef extends ServiceTypeDefinition, - const TPath extends string = string, ->(options: ServiceOptions = {}): Service> { - return { name: '', path: options.path ?? '' } as Service> +type ExplicitServicePath = string extends TPath + ? never + : '' extends TPath + ? never + : TPath + +/** A path-bound service definition waiting to be named by `createSchema`. */ +export interface ServiceDeclaration< + TDef extends ResolvedServiceDef = ResolvedServiceDef, + TPath extends string = string, +> { + readonly path: TPath + readonly [$phantom]?: TDef +} + +/** A service definition whose transport path will default to its schema name. */ +export interface DefaultServiceDeclaration { + readonly [$phantom]?: TDef + at(path: ExplicitServicePath): ServiceDeclaration +} + +type AnyServiceDeclaration = + | ServiceDeclaration + | DefaultServiceDeclaration + +type ServiceDefinitionCatalog = { + [TPath in keyof TDefinitions]: ServiceTypeDefinition +} + +interface ServiceFactory { + (): DefaultServiceDeclaration> + + /** + * Create a path-bound service declaration factory from a path-keyed service catalog. + * The selected path determines both the runtime transport path and its service type. + */ + from>(): < + const TPath extends keyof TDefinitions & string, + >( + path: ExplicitServicePath, + ) => ServiceDeclaration, TPath> +} + +function declareService(): DefaultServiceDeclaration< + ResolveDef +> { + return { + at: path => ({ path }), + } +} + +function serviceFromCatalog>() { + return ( + path: ExplicitServicePath, + ): ServiceDeclaration, TPath> => ({ path }) } +export const service: ServiceFactory = Object.assign(declareService, { + from: serviceFromCatalog, +}) + // Base schema interface - flexible to preserve specific service types export interface Schema { services: Record @@ -342,8 +386,18 @@ function embed( return { ...normalizeHop(def), cardinality: 'embedded' } } +/** Extract the resolved definition carried by a Service value's phantom slot. */ +type ServiceDefinitionOf = + TService extends Service + ? TDef + : TService extends ServiceDeclaration + ? TDef + : TService extends DefaultServiceDeclaration + ? TDef + : never + /** Extract the item type carried by a Service value's phantom slot. */ -type ServiceItemOf = S extends Service ? TDef['item'] : unknown +type ServiceItemOf = ServiceDefinitionOf['item'] /** * Relationship helpers passed to a per-service relationships factory. Scoped to both @@ -405,9 +459,13 @@ export type RelationshipsConfig = { ) => Record> } -// Helper type to re-key a service with its literal schema name -type ExtractServiceWithName = - S extends Service ? Service : never +// Materialize a declaration with its literal schema name and transport path. +type MaterializeService = + TDeclaration extends ServiceDeclaration + ? Service + : TDeclaration extends DefaultServiceDeclaration + ? Service + : never // Phase 2: Create a schema with services object map (preserves literal keys + typed // relationships so downstream hooks can infer related item types at call sites). @@ -422,7 +480,7 @@ type ResolvedRelationships = { } export function createSchema< - const TServiceMap extends Record, + const TServiceMap extends Record, const TRelFactories extends RelationshipsConfig = {}, >(config: { services: TServiceMap @@ -433,18 +491,30 @@ export function createSchema< relationships?: TRelFactories & RelationshipsConfig }): { services: { - readonly [K in keyof TServiceMap]: ExtractServiceWithName + readonly [K in keyof TServiceMap]: MaterializeService } relationships: ResolvedRelationships } { - // Assign names to services based on their keys in the map + const paths = new Map() const serviceMap = Object.fromEntries( - Object.entries(config.services).map(([name, service]) => [ - name, - { ...service, name, path: service.path || name }, - ]), + Object.entries(config.services).map(([name, declaration]) => { + const path = 'path' in declaration ? declaration.path : name + if (path === '') { + throw new Error(`Service "${name}" has an empty transport path`) + } + + const existingName = paths.get(path) + if (existingName !== undefined) { + throw new Error( + `Services "${existingName}" and "${name}" use the same transport path "${path}"`, + ) + } + paths.set(path, name) + + return [name, { name, path }] + }), ) as { - readonly [K in keyof TServiceMap]: ExtractServiceWithName + readonly [K in keyof TServiceMap]: MaterializeService } // Invoke each per-service factory with the (runtime-identical) helpers @@ -470,25 +540,46 @@ export type ServiceNames = keyof S['services'] & string export type ServiceByName> = S['services'][N] -export type ServiceItem> = - ServiceByName extends { [$phantom]?: { item: infer I } } ? I : Record +type ServicePath = + TService extends Service ? TPath : never + +export type ServicePaths = { + [N in ServiceNames]: ServicePath> +}[ServiceNames] + +export type ServiceByPath> = { + [N in ServiceNames]: P extends ServicePath> ? ServiceByName : never +}[ServiceNames] + +/** The resolved service definition selected specifically through its transport path. */ +export type ServiceDefinitionByPath< + S extends Schema, + P extends ServicePaths, +> = ServiceDefinitionOf> + +export type ServiceItem> = ServiceDefinitionOf< + ServiceByName +>['item'] -export type ServiceCreate> = - ServiceByName extends { [$phantom]?: { create: infer C } } ? C : Record +export type ServiceCreate> = ServiceDefinitionOf< + ServiceByName +>['create'] -export type ServiceUpdate> = - ServiceByName extends { [$phantom]?: { update: infer U } } ? U : Record +export type ServiceUpdate> = ServiceDefinitionOf< + ServiceByName +>['update'] -export type ServicePatch> = - ServiceByName extends { [$phantom]?: { patch: infer P } } ? P : Record +export type ServicePatch> = ServiceDefinitionOf< + ServiceByName +>['patch'] -export type ServiceQuery> = - ServiceByName extends { [$phantom]?: { query: infer Q } } ? Q : Record +export type ServiceQuery> = ServiceDefinitionOf< + ServiceByName +>['query'] -export type ServiceMethods> = - ServiceByName extends { [$phantom]?: { methods: infer M extends AnyMethodsType } } - ? M - : Record +export type ServiceMethods> = ServiceDefinitionOf< + ServiceByName +>['methods'] // Helper to find service by name string (for runtime lookup) export function findServiceByName( diff --git a/lib/index.ts b/lib/index.ts index c042c1f7..f0ec1161 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -67,10 +67,14 @@ export type { SchemaRelationships, Service, ServiceByName, + ServiceByPath, ServiceCreate, + ServiceDeclaration, + ServiceDefinitionByPath, ServiceItem, ServiceMethods, ServiceNames, + ServicePaths, ServicePatch, ServiceQuery, ServiceTypeDefinition, diff --git a/lib/react/createHooks.ts b/lib/react/createHooks.ts index c1efe740..fde6c7a6 100644 --- a/lib/react/createHooks.ts +++ b/lib/react/createHooks.ts @@ -1,10 +1,5 @@ -import { useMemo } from 'react' import type { Adapter, AdapterFindMeta, AdapterParams } from '../adapters/adapter.js' -import type { - FeathersClient, - TypedFeathersClient, - TypedFeathersService, -} from '../adapters/feathers.js' +import type { FeathersClient, TypedFeathersClient } from '../adapters/feathers.js' import { defineQuery as baseDefineQuery, type DefineQuery, @@ -17,17 +12,7 @@ import { type AnyQueryBuilder, type QueryBuilderProxy, } from '../core/queryBuilder.js' -import type { - Schema, - ServiceCreate, - ServiceItem, - ServiceMethods, - ServiceNames, - ServicePatch, - ServiceQuery, - ServiceUpdate, -} from '../core/schema.js' -import { resolveServicePath } from '../core/schema.js' +import type { Schema, ServiceDefinitionByPath, ServiceNames, ServicePaths } from '../core/schema.js' import { useFigbird as useContextFigbird } from './context.js' import { useAction, type UseActionHook } from './useAction.js' import { useMutatingImpl, type UseMutatingFilter } from './useMutating.js' @@ -40,48 +25,43 @@ import { useQuery, type UseQueryHook } from './useQuery.js' import { useWindowQuery, type UseWindowQueryHook } from './useWindowQuery.js' /** - * Strongly-typed call signatures per service name. + * Strongly-typed legacy call signatures per transport path. * Using a union of call signatures (one per service) gives the best inference: - * passing a literal service name narrows the return type to that service. + * passing a literal service path narrows the return type to that service. */ -type WithServiceQuery, TParams> = Omit< +type WithServiceQuery, TParams> = Omit< TParams, 'query' -> & { query?: ServiceQuery } +> & { query?: ServiceDefinitionByPath['query'] } -type UseGetForSchema = >( - serviceName: N, +type UseGetForSchema =

>( + servicePath: P, resourceId: string | number, - params?: WithServiceQuery & - Partial, ServiceQuery>>, -) => QueryResult> + params?: WithServiceQuery & + Partial< + QueryConfig['item'], ServiceDefinitionByPath['query']> + >, +) => QueryResult['item']> type UseFindForSchema< S extends Schema, TParams = unknown, TMeta extends Record = Record, -> = >( - serviceName: N, - params?: WithServiceQuery & - Partial[], ServiceQuery>>, -) => QueryResult[], TMeta> - -type UseMutationForSchema = >( - serviceName: N, +> =

>( + servicePath: P, + params?: WithServiceQuery & + Partial< + QueryConfig['item'][], ServiceDefinitionByPath['query']> + >, +) => QueryResult['item'][], TMeta> + +type UseMutationForSchema =

>( + servicePath: P, ) => UseMutationResult< - ServiceItem, - ServiceCreate, - ServiceUpdate, - ServicePatch -> - -type TypedServiceForSchema> = TypedFeathersService< - ServiceItem, - ServiceCreate, - ServiceUpdate, - ServicePatch, - ServiceQuery, - ServiceMethods + ServiceDefinitionByPath['item'], + ServiceDefinitionByPath['create'], + ServiceDefinitionByPath['update'], + ServiceDefinitionByPath['patch'] > type UseMutatingForSchema = ( @@ -172,37 +152,20 @@ export function createHooks( function useTypedMutationQueue(definition?: MutationQueueDefinition, key?: string) { return useMutationQueueImpl(useBoundFigbird(), definition, key) } - function useTypedFeathers() { + function useTypedFeathers(): TypedFeathersClient { const adapter = useBoundFigbird().adapter as { feathers?: FeathersClient } if (!adapter.feathers) { throw new Error('useFeathers must be used with a Feathers adapter') } - const { feathers } = adapter - - return useMemo( - () => - new Proxy(feathers, { - get(target, prop, receiver) { - if (prop === 'service') { - return >(serviceName: N) => - target.service( - resolveServicePath(schema, serviceName), - ) as unknown as TypedServiceForSchema - } - - const value = Reflect.get(target, prop, receiver) - return typeof value === 'function' ? value.bind(target) : value - }, - }) as unknown as TypedFeathersClient, - [feathers], - ) + + return adapter.feathers as unknown as TypedFeathersClient } return { useGet: useGet as unknown as UseGetForSchema, useFind: useFind as unknown as UseFindForSchema, useMutation: useMutation as unknown as UseMutationForSchema, - useFeathers: useTypedFeathers as UseFeathersForSchema, + useFeathers: useTypedFeathers, useFigbird: useBoundFigbird, useQuery: useQuery as UseQueryHook, useWindowQuery: useWindowQuery as UseWindowQueryHook, diff --git a/lib/react/useMutating.ts b/lib/react/useMutating.ts index ca66d5ee..37b2ce19 100644 --- a/lib/react/useMutating.ts +++ b/lib/react/useMutating.ts @@ -8,7 +8,7 @@ import { useFigbird } from './context.js' * filter on it. Omit the whole filter to ask "is anything mutating at all". */ export interface UseMutatingFilter { - /** Schema key or service path — aliases resolve like everywhere else. */ + /** Figbird service name. Schemaless instances use transport paths as their names. */ service?: string /** * Target entity id. Note: `create` calls without a client-generated id and diff --git a/lib/react/useMutation.ts b/lib/react/useMutation.ts index f68897ba..6bb683dd 100644 --- a/lib/react/useMutation.ts +++ b/lib/react/useMutation.ts @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' -import { resolveServicePath, type Schema } from '../core/schema.js' import { useFigbird } from './context.js' // Public untyped mutation hook intentionally returns `any` for backwards compatibility. @@ -66,7 +65,6 @@ export function useMutation( /** The slice of a Figbird instance the mutation hook needs. @internal */ interface MutatingFigbird { - schema: Schema | undefined queryStore: { mutateConfirmedDirect(desc: UntypedData): Promise } @@ -117,7 +115,7 @@ export function useMutationImpl( (desc: UntypedData) => figbird.queryStore.mutateConfirmedDirect({ ...desc, - serviceName: resolveServicePath(figbird.schema, serviceName), + serviceName, }), [figbird, serviceName], ) diff --git a/lib/react/useQueryByDesc.ts b/lib/react/useQueryByDesc.ts index ae5fe09d..46a75098 100644 --- a/lib/react/useQueryByDesc.ts +++ b/lib/react/useQueryByDesc.ts @@ -67,8 +67,7 @@ export function useFind( /** * The single spot the legacy get call shape (`serviceName, id, params+config`) is - * assembled into a descriptor. Service path aliases are resolved centrally by - * figbird.queryDesc(). @internal + * assembled into a transport-path descriptor. @internal */ export function useGetImpl, TQuery>( figbird: DescFigbirdLike, diff --git a/test/custom-operators.test.tsx b/test/custom-operators.test.tsx index bf4d6c28..ac6a99b4 100644 --- a/test/custom-operators.test.tsx +++ b/test/custom-operators.test.tsx @@ -201,9 +201,9 @@ interface ScopedItem { const scopedSchema = createSchema({ services: { - people: service<{ item: ScopedItem }>({ path: 'api/people' }), - jobRoles: service<{ item: ScopedItem }>({ path: 'api/job-roles' }), - compensations: service<{ item: ScopedItem }>({ path: 'api/compensations' }), + people: service<{ item: ScopedItem }>().at('api/people'), + jobRoles: service<{ item: ScopedItem }>().at('api/job-roles'), + compensations: service<{ item: ScopedItem }>().at('api/compensations'), }, relationships: { people: ({ many }) => ({ @@ -309,7 +309,7 @@ test('legacy useFind and builder useQuery share scoped matching and classificati let builderIds: number[] = [] function Probe() { - const legacy = useFind('jobRoles', { query: { $asOf: 'current' } }) + const legacy = useFind('api/job-roles', { query: { $asOf: 'current' } }) const builder = useQuery(figbird.q.jobRoles.where({ $asOf: 'current' }), { suspense: false, }) @@ -457,9 +457,9 @@ test('relations use the destination service registration at runtime and in expla test('explain classifies junction and destination services independently', t => { const junctionSchema = createSchema({ services: { - roles: service<{ item: ScopedItem }>({ path: 'api/roles' }), - memberships: service<{ item: ScopedItem }>({ path: 'api/memberships' }), - users: service<{ item: ScopedItem }>({ path: 'api/users' }), + roles: service<{ item: ScopedItem }>().at('api/roles'), + memberships: service<{ item: ScopedItem }>().at('api/memberships'), + users: service<{ item: ScopedItem }>().at('api/users'), }, relationships: { roles: ({ many }) => ({ diff --git a/test/fixtures/multi-service-inference.ts b/test/fixtures/multi-service-inference.ts index 167cb012..4484706f 100644 --- a/test/fixtures/multi-service-inference.ts +++ b/test/fixtures/multi-service-inference.ts @@ -1,5 +1,17 @@ import type { FeathersClient } from '../../lib' -import { createHooks, createSchema, FeathersAdapter, service, type ServiceItem } from '../../lib' +import { + createHooks, + createSchema, + FeathersAdapter, + Figbird, + service, + type ServiceByName, + type ServiceByPath, + type ServiceDefinitionByPath, + type ServiceItem, + type ServiceNames, + type ServicePaths, +} from '../../lib' // Test multi-service schema type inference with distinct types interface Person { @@ -25,26 +37,135 @@ interface TaskService { item: Task } +interface ApiSchemaTypes { + 'api/people': PersonService + 'api/tasks': TaskService +} + +const apiService = service.from() + export const schema = createSchema({ services: { - 'api/people': service(), - 'api/tasks': service(), + people: apiService('api/people'), + tasks: apiService('api/tasks'), }, }) +service().at('api/people') +// @ts-expect-error A declaration's transport path can only be bound once. +service().at('api/people').at('api/other-people') +// @ts-expect-error Explicit transport paths must be non-empty literals. +service().at('') +declare const dynamicPath: string +// @ts-expect-error A broad string would erase the finite transport-path namespace. +service().at(dynamicPath) +// @ts-expect-error Catalog factories only accept paths present in the catalog. +apiService('api/missing') + type AppSchema = typeof schema +type Equal = + (() => T extends TLeft ? 1 : 2) extends () => T extends TRight ? 1 : 2 ? true : false +type Assert = T + const feathers = {} as FeathersClient const adapter = new FeathersAdapter(feathers) -const { useFind } = createHooks(schema) +const { q, useFeathers, useFind, useGet, useMutation, useMutations } = createHooks< + typeof schema, + typeof adapter +>(schema) + +export const peopleQuery = q.people.all() +export const person = useGet('api/people', 'person-id') +export const peopleMutation = useMutation('api/people') +export const namedMutations = useMutations().people +export const peopleFeathersService = useFeathers().service('api/people') + +// @ts-expect-error Builder APIs use schema names, not transport paths. +q['api/people'].all() +// @ts-expect-error Legacy descriptor hooks use transport paths, not schema names. +useFind('people') +// @ts-expect-error Legacy mutation hooks use transport paths, not schema names. +useMutation('people') +// @ts-expect-error The current mutation proxy uses schema names, not transport paths. +void useMutations()['api/people'] +// @ts-expect-error Direct Feathers access uses transport paths, not schema names. +useFeathers().service('people') // Debug types - these will be inspected by the test -export type PersonServiceByName = AppSchema['services']['api/people'] -export type TaskServiceByName = AppSchema['services']['api/tasks'] -export type PersonServiceItem = ServiceItem -export type TaskServiceItem = ServiceItem +export type SchemaServiceNames = ServiceNames +export type SchemaServicePaths = ServicePaths +export type ServiceNamesArePreserved = Assert> +export type ServicePathsArePreserved = Assert> +export type ServiceLookupByName = Assert< + Equal, AppSchema['services']['people']> +> +export type ServiceLookupByPath = Assert< + Equal, AppSchema['services']['people']> +> +export type PersonServiceByName = AppSchema['services']['people'] +export type TaskServiceByName = AppSchema['services']['tasks'] +export type PersonServiceItemByName = ServiceItem +export type PersonServiceItem = ServiceDefinitionByPath['item'] +export type TaskServiceItem = ServiceDefinitionByPath['item'] // Test the actual hooks - these types will be checked by the test export const people = useFind('api/people') export const tasks = useFind('api/tasks') + +interface NameCollisionService { + item: { kind: 'schema-name' } +} + +interface PathCollisionService { + item: { kind: 'transport-path' } +} + +const collisionSchema = createSchema({ + services: { + users: service().at('api/users'), + legacy: service().at('users'), + }, +}) + +const collisionHooks = createHooks(collisionSchema) +export const collisionPathResult = collisionHooks.useFind('users') +const collisionFeathersService = collisionHooks.useFeathers().service('users') +const collisionFigbird = new Figbird({ schema: collisionSchema, adapter }) +const collisionFind = collisionFigbird.queryDesc({ serviceName: 'users', method: 'find' }) +const collisionCreate = collisionFigbird.mutateDesc({ + serviceName: 'users', + method: 'create', + data: { kind: 'transport-path' }, +}) + +export type CollisionPathItem = NonNullable[number] +export type CollisionFeathersItem = Awaited> +export type CollisionNameItem = ServiceItem +export type CollisionPathItemFromUtility = ServiceDefinitionByPath< + typeof collisionSchema, + 'users' +>['item'] +type CollisionFindState = NonNullable> +type CollisionDescriptorItem = NonNullable[number] +type CollisionMutationItem = Awaited + +export type PathHookUsesTransportNamespace = Assert< + Equal +> +export type FeathersUsesTransportNamespace = Assert< + Equal +> +export type DescriptorUsesTransportNamespace = Assert< + Equal +> +export type DescriptorMutationUsesTransportNamespace = Assert< + Equal +> +export type NameUtilityUsesNameNamespace = Assert< + Equal +> +export type PathUtilityUsesPathNamespace = Assert< + Equal +> diff --git a/test/fixtures/typed-feathers-client.ts b/test/fixtures/typed-feathers-client.ts index 7a994cd5..059f7ae0 100644 --- a/test/fixtures/typed-feathers-client.ts +++ b/test/fixtures/typed-feathers-client.ts @@ -46,7 +46,7 @@ interface TaskService { export const schema = createSchema({ services: { - notes: service(), + notes: service().at('api/notes'), tasks: service(), }, }) @@ -59,7 +59,7 @@ const typedFeathers = useFeathers() // Get notes service // oxlint-disable-next-line @typescript-eslint/no-unused-vars -const _notesService = typedFeathers.service('notes') +const _notesService = typedFeathers.service('api/notes') // Get tasks service // oxlint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/test/mutation-test-helpers.ts b/test/mutation-test-helpers.ts index 4c9ff5bf..6d00c016 100644 --- a/test/mutation-test-helpers.ts +++ b/test/mutation-test-helpers.ts @@ -27,7 +27,7 @@ interface NoteService { export const schema = createSchema({ services: { notes: service(), - people: service<{ item: { id: number; name: string } }>({ path: 'api/people' }), + people: service<{ item: { id: number; name: string } }>().at('api/people'), }, }) diff --git a/test/schema.test.tsx b/test/schema.test.tsx index 401fa3df..7828de61 100644 --- a/test/schema.test.tsx +++ b/test/schema.test.tsx @@ -365,6 +365,102 @@ test('schema with array of services', t => { }) }) +test('schema validates transport paths while materializing declarations', t => { + const apiService = service.from<{ + 'api/people': PersonService + }>() + const generatedSchema = createSchema({ + services: { + people: apiService('api/people'), + }, + }) + t.deepEqual(generatedSchema.services.people, { name: 'people', path: 'api/people' }) + + t.throws( + () => + createSchema({ + services: { + people: service().at('api/people'), + archivedPeople: service().at('api/people'), + }, + }), + { + message: 'Services "people" and "archivedPeople" use the same transport path "api/people"', + }, + ) + + t.throws( + () => + createSchema({ + services: { + '': service(), + }, + }), + { message: 'Service "" has an empty transport path' }, + ) +}) + +test('path APIs do not reinterpret transport paths as schema names', async t => { + const { render, unmount, flush, $ } = dom() + + interface NamedUser { + id: string + kind: 'schema-name' + } + + interface LegacyUser { + id: string + kind: 'transport-path' | 'transport-patched' + } + + const collisionSchema = createSchema({ + services: { + users: service<{ item: NamedUser }>().at('api/users'), + legacy: service<{ item: LegacyUser }>().at('users'), + }, + }) + t.deepEqual(collisionSchema.services.users, { name: 'users', path: 'api/users' }) + t.deepEqual(collisionSchema.services.legacy, { name: 'legacy', path: 'users' }) + const feathers = mockFeathers({ + 'api/users': { data: { '1': { id: '1', kind: 'schema-name' } } }, + users: { data: { '1': { id: '1', kind: 'transport-path' } } }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ adapter, schema: collisionSchema }) + const { useFeathers, useFind, useMutation } = createHooks(collisionSchema) + + let directClient: unknown + let directService!: { get(id: string | number): Promise } + let patch!: (id: string | number, data: Partial) => Promise + + function App() { + const result = useFind('users') + const client = useFeathers() + directClient = client + directService = client.service('users') + patch = useMutation('users').patch + + return

+ } + + render( + + + , + ) + + await flush() + t.is(directClient, feathers) + t.is($('.kind')?.textContent, 'transport-path') + t.is((await directService.get('1')).kind, 'transport-path') + let patched!: LegacyUser + await flush(async () => { + patched = await patch('1', { kind: 'transport-patched' }) + }) + t.is(patched.kind, 'transport-patched') + unmount() +}) + test('backward compatibility - untyped usage still works', t => { const { render, unmount, flush, $ } = dom() diff --git a/test/type-inference.test.ts b/test/type-inference.test.ts index 1ad974dc..32bf969e 100644 --- a/test/type-inference.test.ts +++ b/test/type-inference.test.ts @@ -89,6 +89,7 @@ test('type narrowing works correctly with multiple services', t => { // Check Person service types const personServiceType = getTypeAtPosition(fixturePath, 'PersonServiceByName') const personItemType = getTypeAtPosition(fixturePath, 'PersonServiceItem') + const personItemByNameType = getTypeAtPosition(fixturePath, 'PersonServiceItemByName') const peopleType = getTypeAtPosition(fixturePath, 'people') // Check Task service types @@ -96,20 +97,21 @@ test('type narrowing works correctly with multiple services', t => { const taskItemType = getTypeAtPosition(fixturePath, 'TaskServiceItem') const tasksType = getTypeAtPosition(fixturePath, 'tasks') - // Service: the resolved definition in one slot, keyed by the schema name + // Service: both schema name and transport path stay literal. t.true( personServiceType.startsWith('import("figbird").Service<{ item: Person;') && - personServiceType.includes('"api/people"'), - `Expected personServiceType to be Service<{ item: Person; ... }, "api/people">, got: ${personServiceType}`, + personServiceType.includes('"people", "api/people"'), + `Expected personServiceType to retain name "people" and path "api/people", got: ${personServiceType}`, ) t.true( taskServiceType.startsWith('import("figbird").Service<{ item: Task;') && - taskServiceType.includes('"api/tasks"'), - `Expected taskServiceType to be Service<{ item: Task; ... }, "api/tasks">, got: ${taskServiceType}`, + taskServiceType.includes('"tasks", "api/tasks"'), + `Expected taskServiceType to retain name "tasks" and path "api/tasks", got: ${taskServiceType}`, ) - // Test that ServiceItem extraction is working + // Names and paths remain separate projections with explicit utility types. t.is(personItemType, 'Person') + t.is(personItemByNameType, 'Person') t.is(taskItemType, 'Task') // Test that useFind correctly narrows to specific types (no more unions!)