Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>({ path })` becomes `service<T>().at(path)`; generated catalogs use
`service.from<TCatalog>()`.
- `figbird.query(desc)` → `figbird.queryDesc(desc)`
- `figbird.mutate(desc)` → `figbird.mutateDesc(desc)`
- `figbird.query(builder | request)` is now the non-React mirror of `useQuery`
Expand Down
51 changes: 41 additions & 10 deletions docs/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,33 @@ interface TaskService {
const schema = createSchema({
services: {
tasks: service<TaskService>(),
people: service<PersonService>({ path: 'api/people' }),
people: service<PersonService>().at('api/people'),
},
relationships: {/* per-service factories — see Relations */},
})
```

Omitted payload types default sensibly: `Partial<item>` 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<item>` 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<ApiSchemaTypes>()

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

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<NoteService>()
service<NoteService>().at('api/notes')

const apiService = service.from<ApiSchemaTypes>()
apiService('api/notes')
```

Declares one service's types. Only `item` is required; omitted payloads default to
`Partial<item>` 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<item>` for create/patch and `item` for update. `.at(path)` maps an ergonomic
schema key to a non-empty literal transport path. `service.from<TCatalog>()` 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

Expand Down Expand Up @@ -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
Expand Down
33 changes: 12 additions & 21 deletions lib/adapters/feathers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof schema> = ...
* 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<S extends Schema> = {
service<N extends ServiceNames<S>>(
serviceName: N,
service<P extends ServicePaths<S>>(
servicePath: P,
): TypedFeathersService<
ServiceItem<S, N>,
ServiceCreate<S, N>,
ServiceUpdate<S, N>,
ServicePatch<S, N>,
ServiceQuery<S, N>,
ServiceMethods<S, N>
ServiceDefinitionByPath<S, P>['item'],
ServiceDefinitionByPath<S, P>['create'],
ServiceDefinitionByPath<S, P>['update'],
ServiceDefinitionByPath<S, P>['patch'],
ServiceDefinitionByPath<S, P>['query'],
ServiceDefinitionByPath<S, P>['methods']
>
}

Expand Down
103 changes: 49 additions & 54 deletions lib/core/figbird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<S extends Schema, N extends ServiceNames<S>, A extends Adapter> = Omit<
type ParamsWithServiceQuery<S extends Schema, P extends ServicePaths<S>, A extends Adapter> = Omit<
AdapterParams<A>,
'query'
> & { query?: ServiceQuery<S, N> }
> & { query?: ServiceDefinitionByPath<S, P>['query'] }

const KEYED_MUTATION_QUEUE_RETENTION_MS = 5 * 60_000

Expand Down Expand Up @@ -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<N extends ServiceNames<S>>(
desc: { serviceName: N; method: 'find'; params?: ParamsWithServiceQuery<S, N, A> },
config?: QueryConfig<ServiceItem<S, N>[], ServiceQuery<S, N>>,
queryDesc<P extends ServicePaths<S>>(
desc: { serviceName: P; method: 'find'; params?: ParamsWithServiceQuery<S, P, A> },
config?: QueryConfig<
ServiceDefinitionByPath<S, P>['item'][],
ServiceDefinitionByPath<S, P>['query']
>,
): QueryRef<
ServiceItem<S, N>[],
ServiceQuery<S, N>,
ServiceDefinitionByPath<S, P>['item'][],
ServiceDefinitionByPath<S, P>['query'],
S,
AdapterParams<A>,
AdapterFindMeta<A>,
AdapterQuery<A>
>
/** Create a typed `get` query reference from a descriptor. */
queryDesc<N extends ServiceNames<S>>(
queryDesc<P extends ServicePaths<S>>(
desc: {
serviceName: N
serviceName: P
method: 'get'
resourceId: string | number
params?: ParamsWithServiceQuery<S, N, A>
params?: ParamsWithServiceQuery<S, P, A>
},
config?: QueryConfig<ServiceItem<S, N>, ServiceQuery<S, N>>,
config?: QueryConfig<
ServiceDefinitionByPath<S, P>['item'],
ServiceDefinitionByPath<S, P>['query']
>,
): QueryRef<
ServiceItem<S, N>,
ServiceQuery<S, N>,
ServiceDefinitionByPath<S, P>['item'],
ServiceDefinitionByPath<S, P>['query'],
S,
AdapterParams<A>,
AdapterFindMeta<A>,
Expand Down Expand Up @@ -642,14 +645,9 @@ export class Figbird<
config?: QueryConfig<unknown, unknown>,
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
): any {
const resolvedDesc = {
...desc,
serviceName: resolveServicePath(this.schema, desc.serviceName),
}

return new QueryRef<unknown, unknown, S, AdapterParams<A>, AdapterFindMeta<A>, AdapterQuery<A>>(
{
desc: resolvedDesc as QueryDescriptor,
desc: desc as QueryDescriptor,
config: normalizeQueryConfig(config),
queryStore: this.queryStore,
},
Expand All @@ -663,61 +661,58 @@ export class Figbird<
// Strongly-typed mutation overloads

/** Create a single new item. */
mutateDesc<N extends ServiceNames<S>>(desc: {
serviceName: N
mutateDesc<P extends ServicePaths<S>>(desc: {
serviceName: P
method: 'create'
data: ServiceCreate<S, N>
data: ServiceDefinitionByPath<S, P>['create']
params?: AdapterParams<A>
optimistic?: boolean | ServiceItem<S, N>
}): Promise<ServiceItem<S, N>>
optimistic?: boolean | ServiceDefinitionByPath<S, P>['item']
}): Promise<ServiceDefinitionByPath<S, P>['item']>

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

/** Update an existing item by ID (full replacement). */
mutateDesc<N extends ServiceNames<S>>(
mutateDesc<P extends ServicePaths<S>>(
desc: {
serviceName: N
serviceName: P
method: 'update'
id: string | number
data: ServiceUpdate<S, N>
data: ServiceDefinitionByPath<S, P>['update']
params?: AdapterParams<A>
} & DescriptorWriteProjection<ServiceItem<S, N>>,
): Promise<ServiceItem<S, N>>
} & DescriptorWriteProjection<ServiceDefinitionByPath<S, P>['item']>,
): Promise<ServiceDefinitionByPath<S, P>['item']>

/** Patch an existing item by ID (partial update). */
mutateDesc<N extends ServiceNames<S>>(
mutateDesc<P extends ServicePaths<S>>(
desc: {
serviceName: N
serviceName: P
method: 'patch'
id: string | number
data: ServicePatch<S, N>
data: ServiceDefinitionByPath<S, P>['patch']
params?: AdapterParams<A>
} & DescriptorWriteProjection<ServiceItem<S, N>>,
): Promise<ServiceItem<S, N>>
} & DescriptorWriteProjection<ServiceDefinitionByPath<S, P>['item']>,
): Promise<ServiceDefinitionByPath<S, P>['item']>

/** Remove an item by ID. */
mutateDesc<N extends ServiceNames<S>>(desc: {
serviceName: N
mutateDesc<P extends ServicePaths<S>>(desc: {
serviceName: P
method: 'remove'
id: string | number
params?: AdapterParams<A>
optimistic?: boolean
}): Promise<ServiceItem<S, N>>
}): Promise<ServiceDefinitionByPath<S, P>['item']>

// Implementation
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
mutateDesc(desc: MutationDescriptor): Promise<any> {
return this.queryStore.mutate({
...desc,
serviceName: resolveServicePath(this.schema, desc.serviceName),
})
return this.queryStore.mutate(desc)
}

/**
Expand All @@ -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<unknown> {
return this.queryStore.call(resolveServicePath(this.schema, serviceName), method, args)
call(servicePath: string, method: string, ...args: unknown[]): Promise<unknown> {
return this.queryStore.call(servicePath, method, args)
}

#mutationsProxy: MutationsProxy<S> | null = null
Expand Down
22 changes: 11 additions & 11 deletions lib/core/queryTypes.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -351,12 +351,12 @@ export type ItemMatcher<T> = (item: T) => boolean
*/
export type InferQueryData<S extends Schema, D extends QueryDescriptor> = S extends AnySchema
? UntypedData
: D extends { serviceName: infer N extends string; method: infer M }
? N extends ServiceNames<S>
: D extends { serviceName: infer P extends string; method: infer M }
? P extends ServicePaths<S>
? M extends 'find'
? ServiceItem<S, N>[]
? ServiceDefinitionByPath<S, P>['item'][]
: M extends 'get'
? ServiceItem<S, N>
? ServiceDefinitionByPath<S, P>['item']
: UntypedData
: UntypedData
: UntypedData
Expand Down Expand Up @@ -431,13 +431,13 @@ export type MutationDescriptor =
*/
export type InferMutationData<S extends Schema, D extends MutationDescriptor> = S extends AnySchema
? UntypedData
: D extends { serviceName: infer N extends string; data: readonly unknown[] }
? N extends ServiceNames<S>
? ServiceItem<S, N>[]
: D extends { serviceName: infer P extends string; data: readonly unknown[] }
? P extends ServicePaths<S>
? ServiceDefinitionByPath<S, P>['item'][]
: UntypedData
: D extends { serviceName: infer N extends string }
? N extends ServiceNames<S>
? ServiceItem<S, N>
: D extends { serviceName: infer P extends string }
? P extends ServicePaths<S>
? ServiceDefinitionByPath<S, P>['item']
: UntypedData
: UntypedData

Expand Down
Loading