diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3cd034..3b4c1b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ scheduling, retries, flush, and discard. `defineMutationQueue()` gives reconnect queues a stable policy and key namespace. Queue writes and ordinary writes share the same per-record ordering. +Adapters can opt into atomic multi-service transactions. `figbird.transaction()` collects +schema-typed CRUD calls, projects and rolls them back as one cache update, and coordinates the +affected record lanes before making one adapter request. The Feathers adapter includes an +opt-in `feathersBatchTransactions()` transport for `api/batch`-style services; adapters without +an atomic capability never fall back to sequential requests. + Writes can use `optimisticPatch` when the local projection differs from the server payload. Relational filters apply projected changes locally, then reconcile once after the record's writes settle. diff --git a/docs/content/_index.md b/docs/content/_index.md index 50ac70fb..a6e3bbdd 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -577,6 +577,47 @@ queries update immediately. Relations assemble from cached data and fetch missin When the server must decide query membership or ordering, Figbird waits for the record's writes to settle before reconciling the query. +### Adapter-backed transactions + +Use `figbird.transaction()` when several CRUD writes must commit or roll back as one server +operation. Transactions are a capability, not an emulation: Figbird throws if the adapter +does not provide an atomic transport and never falls back to sequential requests. + +```ts +await figbird.transaction(tx => { + tx.m.tasks.patch(taskId, { columnId: nextColumnId }) + tx.m.columns.patch(previousColumnId, { count: previousCount - 1 }) + tx.m.columns.patch(nextColumnId, { count: nextCount + 1 }) +}) +``` + +The callback collects synchronously; its methods return `void`, and the outer promise settles +when the transaction commits. Payloads, patches, service names, and `confirmed` options retain +the same schema inference as `m`. Optimistic operations project as one observer-visible cache +update and roll back together. `tx.m.columns.confirmed.patch(...)` keeps that operation hidden +until commit. + +Transaction operations wait behind earlier writes to every affected record, then reserve those +record lanes until the adapter transaction settles. Each entity can appear only once in a +transaction. Creates must be collected one at a time and carry a stable id, including confirmed +creates, because multi-record ordering needs an identity before dispatch. The transaction DSL is +CRUD-only; custom methods have adapter-specific argument and cache semantics. + +For Feathers, opt into the `api/batch` tuple contract explicitly: + +```ts +import { FeathersAdapter, feathersBatchTransactions } from 'figbird' + +const adapter = new FeathersAdapter(feathers, { + transactions: feathersBatchTransactions(), // defaults to api/batch +}) +``` + +The batch service must return one ordered `{ status: 'fulfilled', value }` or +`{ status: 'rejected', reason }` entry per call and guarantee that any rejection rolls back the +whole batch. `feathersBatchTransactions()` rejects the Figbird transaction if any entry rejects. +Use `serviceName` and `params` options when the batch endpoint differs. + ### Ordered autosave with mutation queues `m` sends independent records in parallel. A feature such as a workflow editor may instead @@ -1663,6 +1704,7 @@ const figbird = new Figbird({ | `prefetch(request, opts?)` | Idempotent speculative warming. Argumentless definitions can be passed directly. See [figbird.prefetch](#figbirdprefetch). | | `refetch(service?)` | Manual refetch escape hatch for changes Figbird can’t observe, such as custom methods without events or out-of-band writes. Call `figbird.refetch(...)`. | | `m` | The instance’s write proxy: `figbird.m.issues.patch(...)`, or `figbird.m(service)` for dynamic names. In React, access the provider instance through `useMutations()`. See [m](#m). | +| `transaction(fn)` | Adapter-backed atomic CRUD collector. Optimistic projection, commit, and rollback are grouped across services. See [Adapter-backed transactions](#adapter-backed-transactions). | | `createMutationQueue(config?)` | Explicitly owned serial writes across records or services. See [figbird.createMutationQueue](#figbirdcreatemutationqueue). | | `mutating` | Synchronous active-mutation tracker (`subscribe`/`getSnapshot`) — `useMutating` is its React binding. | | `explain(...)` | Static classification report — see [figbird.explain](#figbirdexplain). | @@ -1691,6 +1733,7 @@ const adapter = new FeathersAdapter(feathers, options) - `defaultPageSizeWhenFetchingAll` — default `query.$limit` when fetching with `allPages` - `pagination` — cursor strategies keyed by service path; see [Cursor pagination](#cursor-pagination) - `operators` — custom query operators the client can evaluate (`{ $asOf: asOf => item => boolean }`); queries using them stay realtime-mergeable. See [Teaching the client custom operators](#teaching-the-client-custom-operators) + - `transactions` — optional atomic transaction transport; use `feathersBatchTransactions()` for an `api/batch`-style service Meta behavior: `find` returns `{ data, meta }` (`FindMeta`: `{ total, limit, skip }`); `get` returns only the item. diff --git a/lib/adapters/adapter.ts b/lib/adapters/adapter.ts index b84e3548..1397c336 100644 --- a/lib/adapters/adapter.ts +++ b/lib/adapters/adapter.ts @@ -66,6 +66,13 @@ export interface MatcherContext { serviceName: string } +/** One mutation inside an adapter-backed atomic transaction. */ +export interface AdapterTransactionOperation { + serviceName: string + method: 'create' | 'update' | 'patch' | 'remove' + args: readonly unknown[] +} + /** * Unified adapter interface * The adapter is service-agnostic and works with unknown items @@ -92,6 +99,16 @@ export interface Adapter< mutate(serviceName: string, method: string, args: unknown[]): Promise + /** + * Atomically commit several mutations, returning one result per operation in + * the same order. Omit this capability when the backend cannot guarantee + * all-or-nothing commit semantics; Figbird never falls back to sequential + * requests. + */ + transaction?: + | ((operations: readonly AdapterTransactionOperation[]) => Promise) + | undefined + /** Return false when retrying a failed query cannot help. Errors retry by default. */ isRetryableError?(error: Error): boolean diff --git a/lib/adapters/feathers.ts b/lib/adapters/feathers.ts index bc7b6045..fbb73e1b 100644 --- a/lib/adapters/feathers.ts +++ b/lib/adapters/feathers.ts @@ -1,5 +1,6 @@ import type { Adapter, + AdapterTransactionOperation, EventHandlers, MatcherContext, PageCursor, @@ -298,6 +299,77 @@ export type CustomOperatorRegistration = byService: Record } +/** Feathers-specific transport for an adapter-backed atomic transaction. */ +export type FeathersTransaction = ( + feathers: FeathersClient, + operations: readonly AdapterTransactionOperation[], +) => Promise + +export interface FeathersBatchTransactionsOptions { + /** Feathers service implementing the batch contract. Defaults to `api/batch`. */ + serviceName?: string + /** Params passed to the batch service's `create` call. */ + params?: FeathersParams +} + +interface FeathersBatchResult { + data: Array<{ status: 'fulfilled'; value?: unknown } | { status: 'rejected'; reason: unknown }> +} + +/** Error returned when an atomic Feathers batch contains rejected operations. */ +export class FeathersTransactionError extends Error { + readonly result: FeathersBatchResult + + constructor(result: FeathersBatchResult) { + const rejected = result.data.filter(entry => entry.status === 'rejected').length + super(`Feathers transaction rejected ${rejected} operation${rejected === 1 ? '' : 's'}`) + this.name = 'FeathersTransactionError' + this.result = result + } +} + +/** + * Adapt a Feathers `api/batch`-style service to Figbird's atomic transaction + * capability. The service must return ordered `{ status, value/reason }` + * entries and roll the entire batch back when any entry rejects. + */ +export function feathersBatchTransactions({ + serviceName = 'api/batch', + params, +}: FeathersBatchTransactionsOptions = {}): FeathersTransaction { + return async (feathers, operations) => { + const result = await feathers.service(serviceName).create( + { + calls: operations.map(operation => [ + operation.method, + operation.serviceName, + ...operation.args, + ]), + }, + params, + ) + if (!isFeathersBatchResult(result) || result.data.length !== operations.length) { + throw new Error(`Feathers transaction service "${serviceName}" returned an invalid result`) + } + if (result.data.some(entry => entry.status === 'rejected')) { + throw new FeathersTransactionError(result) + } + return result.data.map(entry => (entry.status === 'fulfilled' ? entry.value : undefined)) + } +} + +function isFeathersBatchResult(value: unknown): value is FeathersBatchResult { + if (!value || typeof value !== 'object' || !Array.isArray((value as FeathersBatchResult).data)) { + return false + } + return (value as FeathersBatchResult).data.every( + entry => + entry && + typeof entry === 'object' && + (entry.status === 'fulfilled' || entry.status === 'rejected'), + ) +} + export interface FeathersAdapterOptions { idField?: IdFieldType updatedAtField?: UpdatedAtFieldType @@ -326,6 +398,8 @@ export interface FeathersAdapterOptions { defaultPagination?: FeathersPagination /** Pagination overrides selected by Feathers service path. */ pagination?: Record + /** Opt-in atomic transaction transport. Omit when the backend has no such capability. */ + transactions?: FeathersTransaction } /** @@ -353,6 +427,7 @@ export class FeathersAdapter> implements Adapte #operators: Record #defaultPagination: FeathersPagination | undefined #pagination: Record + transaction?: Adapter['transaction'] /** Names of custom operators registered for every service. */ get customOperators(): readonly string[] { @@ -400,6 +475,7 @@ export class FeathersAdapter> implements Adapte operators = {}, defaultPagination, pagination = {}, + transactions, }: FeathersAdapterOptions = {}, ) { this.feathers = feathers @@ -410,6 +486,9 @@ export class FeathersAdapter> implements Adapte this.#operators = operators this.#defaultPagination = defaultPagination this.#pagination = pagination + if (transactions) { + this.transaction = operations => transactions(this.feathers, operations) + } } #paginationFor(serviceName: string): FeathersPagination | undefined { diff --git a/lib/core/figbird.ts b/lib/core/figbird.ts index 834b1e36..9a78191a 100644 --- a/lib/core/figbird.ts +++ b/lib/core/figbird.ts @@ -59,6 +59,7 @@ import type { ServiceUpdate, } from './schema.js' import { resolveServicePath } from './schema.js' +import { createTransactionContext, type TransactionContext } from './transactions.js' type DescriptorWriteProjection = | { @@ -91,6 +92,11 @@ export type { MutationsProxy, WriteMutationOptions, } from './mutations.js' +export type { + TransactionContext, + TransactionMutationsHandle, + TransactionMutationsProxy, +} from './transactions.js' export { defineMutationQueue, MutationQueueDiscardedError, @@ -664,6 +670,44 @@ export class Figbird< return this.#mutationsProxy } + /** + * Atomically commit several typed CRUD mutations through the configured + * adapter. The callback is a synchronous collector: its calls project as one + * cache update, wait for affected record lanes, and are dispatched together. + * No sequential-request fallback is provided. + */ + transaction(collect: (transaction: TransactionContext) => undefined): Promise { + if (!this.queryStore.supportsTransactions) { + throw new Error('figbird: the configured adapter does not support transactions') + } + const transaction = createTransactionContext() + let returned: unknown + try { + returned = collect(transaction.context) + } catch (error) { + // Close on failure too, so a leaked handle cannot append work later. + transaction.close() + throw error + } + if ( + returned !== null && + (typeof returned === 'object' || typeof returned === 'function') && + 'then' in returned && + typeof (returned as { then?: unknown }).then === 'function' + ) { + transaction.close() + // The callback may continue after its first await and hit the closed + // collector. Observe that misuse promise so it cannot become unhandled. + void Promise.resolve(returned).catch(() => {}) + throw new Error('figbird: transaction callbacks must be synchronous') + } + const descs = transaction.close().map(desc => ({ + ...desc, + serviceName: resolveServicePath(this.schema, desc.serviceName), + })) + return this.queryStore.transaction(descs) + } + /** * Create an explicitly owned serial mutation queue. Calls made through the * queue's `m` proxy project immediately, preserve queue order across records, diff --git a/lib/core/mutationLanes.ts b/lib/core/mutationLanes.ts index 8e385a33..2cfa8042 100644 --- a/lib/core/mutationLanes.ts +++ b/lib/core/mutationLanes.ts @@ -139,6 +139,22 @@ export class MutationLanes { lane: MutationLane, entry: TEntry, outcome: MutationOutcome, + ): LaneSettlement | null { + return this.#complete(lane, entry, outcome) + } + + /** + * Remove an entry that will never reach the adapter while preserving the + * create/remove lifetime rules that apply to an ordinary failed mutation. + */ + abort(lane: MutationLane, entry: TEntry, error: Error): LaneSettlement | null { + return this.#complete(lane, entry, { ok: false, error }) + } + + #complete( + lane: MutationLane, + entry: TEntry, + outcome: MutationOutcome, ): LaneSettlement | null { const state = this.#lanes.get(lane.key) if (state !== lane) return null @@ -147,18 +163,22 @@ export class MutationLanes { if (index === -1) return null state.entries.splice(index, 1) - state.running = false + if (index === 0) state.running = false let cancelled: TEntry[] = [] if (!outcome.ok && entry.desc.method === 'create') { - cancelled = state.entries.splice(0) + cancelled = state.entries.splice(index) } else if (!outcome.ok && entry.desc.method === 'remove') { - const nextCreate = state.entries.findIndex(queued => queued.desc.method === 'create') + const nextCreate = state.entries.findIndex( + (queued, queuedIndex) => queuedIndex >= index && queued.desc.method === 'create', + ) if (nextCreate !== -1) cancelled = state.entries.splice(nextCreate) } else if (outcome.ok && entry.desc.method === 'remove') { - const nextCreate = state.entries.findIndex(queued => queued.desc.method === 'create') + const nextCreate = state.entries.findIndex( + (queued, queuedIndex) => queuedIndex >= index && queued.desc.method === 'create', + ) const end = nextCreate === -1 ? state.entries.length : nextCreate - cancelled = state.entries.splice(0, end) + cancelled = state.entries.splice(index, end - index) } let authoritativeEvent: ProcessedRealtimeEvent | null = null diff --git a/lib/core/mutations.ts b/lib/core/mutations.ts index 2667a887..6fac8dc5 100644 --- a/lib/core/mutations.ts +++ b/lib/core/mutations.ts @@ -96,11 +96,11 @@ type CustomMethods> = { ) => Promise[M]>> } -type CreateOptionsFor = TOptimistic extends true +export type CreateOptionsFor = TOptimistic extends true ? CreateMutationOptions : MutationParamsOptions -type WriteOptionsFor = TOptimistic extends true +export type WriteOptionsFor = TOptimistic extends true ? WriteMutationOptions : MutationParamsOptions @@ -164,7 +164,7 @@ export interface MutationsHost { call(serviceName: string, method: string, args: unknown[]): Promise } -interface HandleConfig { +export interface CrudHandleConfig { /** false → confirmed variant: the cache updates only after the server acks. */ optimistic: boolean } @@ -174,7 +174,19 @@ type RuntimeMutationOptions = MutationParamsOptions & { optimisticPatch?: unknown } -function createHandle(host: MutationsHost, serviceName: string, config: HandleConfig): object { +type CrudHandleDecorator = (base: Record) => object + +/** + * Build the canonical CRUD descriptor surface shared by ordinary mutations and + * transaction collectors. A decorator may add custom-method behavior without + * duplicating descriptor construction or confirmed-handle policy. @internal + */ +export function createCrudHandle( + dispatch: (desc: MutationDescriptor) => unknown, + serviceName: string, + config: CrudHandleConfig, + decorate: CrudHandleDecorator = base => base, +): object { const { optimistic } = config const resolveOptimistic = (options?: RuntimeMutationOptions) => @@ -182,7 +194,7 @@ function createHandle(host: MutationsHost, serviceName: string, config: HandleCo const base: Record = { create: (data: unknown, options?: RuntimeMutationOptions) => - host.mutate({ + dispatch({ serviceName, method: 'create', data, @@ -190,7 +202,7 @@ function createHandle(host: MutationsHost, serviceName: string, config: HandleCo optimistic: resolveOptimistic(options), }), update: (id: string | number, data: unknown, options?: RuntimeMutationOptions) => - host.mutate({ + dispatch({ serviceName, method: 'update', id, @@ -202,7 +214,7 @@ function createHandle(host: MutationsHost, serviceName: string, config: HandleCo : {}), }), patch: (id: string | number, data: unknown, options?: RuntimeMutationOptions) => - host.mutate({ + dispatch({ serviceName, method: 'patch', id, @@ -214,7 +226,7 @@ function createHandle(host: MutationsHost, serviceName: string, config: HandleCo : {}), }), remove: (id: string | number, options?: RuntimeMutationOptions) => - host.mutate({ + dispatch({ serviceName, method: 'remove', id, @@ -222,7 +234,6 @@ function createHandle(host: MutationsHost, serviceName: string, config: HandleCo // remove has no payload to synthesize — optimistic is boolean-only here optimistic, }), - call: (method: string, ...args: unknown[]) => host.call(serviceName, method, args), } if (optimistic) { @@ -230,49 +241,58 @@ function createHandle(host: MutationsHost, serviceName: string, config: HandleCo let confirmedVariant: object | null = null Object.defineProperty(base, 'confirmed', { enumerable: false, - get: () => (confirmedVariant ??= createHandle(host, serviceName, { optimistic: false })), + get: () => + (confirmedVariant ??= createCrudHandle( + dispatch, + serviceName, + { optimistic: false }, + decorate, + )), }) } - return new Proxy(base, { - get(target, prop, receiver) { - // `in` includes the prototype chain, so Object.prototype members resolve - // normally instead of becoming calls. - if (typeof prop === 'symbol' || prop in target) { - return Reflect.get(target, prop, receiver) - } - // A callable `then` makes the handle thenable: returning one from an async - // function would make the `await` invoke it and hang forever, unsettled. - if (prop === 'then') return undefined - // A callable `toJSON` would turn JSON.stringify(handle) — logging, error - // reporting — into a phantom network write. - if (prop === 'toJSON') return undefined - return (...args: unknown[]) => host.call(serviceName, prop, args) + return decorate(base) +} + +function createHandle(host: MutationsHost, serviceName: string, config: CrudHandleConfig): object { + return createCrudHandle( + desc => host.mutate(desc), + serviceName, + config, + base => { + base.call = (method: string, ...args: unknown[]) => host.call(serviceName, method, args) + return new Proxy(base, { + get(target, prop, receiver) { + // `in` includes the prototype chain, so Object.prototype members resolve + // normally instead of becoming calls. + if (typeof prop === 'symbol' || prop in target) { + return Reflect.get(target, prop, receiver) + } + // A callable `then` makes the handle thenable: returning one from an async + // function would make the `await` invoke it and hang forever, unsettled. + if (prop === 'then') return undefined + // A callable `toJSON` would turn JSON.stringify(handle) — logging, error + // reporting — into a phantom network write. + if (prop === 'toJSON') return undefined + return (...args: unknown[]) => host.call(serviceName, prop, args) + }, + }) }, - }) + ) } -/** - * Build the untyped runtime `m` proxy: services as properties, handles interned - * per service. Custom methods are phantom types — they do not exist at runtime — - * so each handle is itself a Proxy: any property that is not a reserved key or a - * known protocol prop becomes a call to that custom method. @internal - */ -export function createMutationsProxy(host: MutationsHost): object { +/** Build a callable, property-addressable proxy with interned service handles. @internal */ +export function createServiceHandleProxy(createHandle: (serviceName: string) => object): object { const handles = new Map() const handleFor = (serviceName: string): object => { let handle = handles.get(serviceName) if (!handle) { - handle = createHandle(host, serviceName, { optimistic: true }) + handle = createHandle(serviceName) handles.set(serviceName, handle) } return handle } - // No protocol guards needed at this level: every string property resolves to a - // handle OBJECT (not a function) — including function-target props like `call` - // or `name` — so probes like `then` or `toJSON` are never callable here and - // `await m` / JSON.stringify(m) behave inertly. const callable = (serviceName: string) => handleFor(serviceName) return new Proxy(callable as object, { apply(_target, _thisArg, [serviceName]: [string]) { @@ -284,3 +304,19 @@ export function createMutationsProxy(host: MutationsHost): object { }, }) } + +/** + * Build the untyped runtime `m` proxy: services as properties, handles interned + * per service. Custom methods are phantom types — they do not exist at runtime — + * so each handle is itself a Proxy: any property that is not a reserved key or a + * known protocol prop becomes a call to that custom method. @internal + */ +export function createMutationsProxy(host: MutationsHost): object { + // No protocol guards needed at this level: every string property resolves to a + // handle OBJECT (not a function) — including function-target props like `call` + // or `name` — so probes like `then` or `toJSON` are never callable here and + // `await m` / JSON.stringify(m) behave inertly. + return createServiceHandleProxy(serviceName => + createHandle(host, serviceName, { optimistic: true }), + ) +} diff --git a/lib/core/queryStore.ts b/lib/core/queryStore.ts index 724b7e9e..1d7a7d92 100644 --- a/lib/core/queryStore.ts +++ b/lib/core/queryStore.ts @@ -1,6 +1,7 @@ import { locallySupportedOperators, type Adapter, + type AdapterTransactionOperation, type PageResponse, type QueryResponse, } from '../adapters/adapter.js' @@ -26,7 +27,9 @@ import { ABSENT, MUTATION_EVENT_TYPE, MutationLanes, + type LaneSettlement, type MutationLane, + type MutationOutcome, type ProjectionChange, } from './mutationLanes.js' import { @@ -35,7 +38,7 @@ import { applyVisibleEventToQuery, createServiceState, diffCompleteSet, - groupQueuedEvents, + groupEventsByService, isUnfilteredFindQuery, replayFetchedQueryFromEvents, reapplyQueryFromEntities, @@ -124,6 +127,13 @@ interface QueuedMutation { args: unknown[] optimistic: boolean attempt: GatedMutationAttempt + transaction?: QueuedTransaction +} + +interface QueuedTransaction { + entries: Array<{ lane: MutationLane; entry: QueuedMutation }> + readyLaneKeys: Set + status: 'waiting' | 'running' | 'settled' | 'aborted' } interface AppliedEventEffect { @@ -204,6 +214,8 @@ export class QueryStore< #warnedMissingIdServices: Set = new Set() #eventQueue: QueuedEvent[] = [] + // Lane bases already contain these acknowledgements; only query publication remains. + #appliedEventQueue: ProcessedRealtimeEvent[] = [] #eventBatchProcessingTimer: ReturnType | null = null #eventBatchInterval: number | undefined = 100 #processingEventQueue = false @@ -509,6 +521,104 @@ export class QueryStore< return this.registerMutation(desc).promise as Promise> } + /** Whether the configured adapter promises atomic multi-mutation commits. */ + get supportsTransactions(): boolean { + return this.#adapter.transaction !== undefined + } + + /** Commit several keyed CRUD mutations through the adapter's atomic capability. */ + transaction(descs: readonly MutationDescriptor[]): Promise { + if (!this.#adapter.transaction) { + throw new Error('figbird: the configured adapter does not support transactions') + } + if (descs.length === 0) return Promise.resolve() + + const keys = new Set() + const planned = descs.map(desc => { + if (desc.method === 'create' && Array.isArray(desc.data)) { + throw new Error( + 'figbird: transaction create calls accept one item; collect multiple create calls instead', + ) + } + const optimisticItem = + desc.method === 'create' ? resolveCreateOptimisticItem(desc) : undefined + const id = desc.method === 'create' ? this.#peekId(optimisticItem) : desc.id + if (id === undefined || id === null) { + throw new Error( + `figbird: transaction ${desc.method} on "${desc.serviceName}" requires a stable entity id`, + ) + } + const key = JSON.stringify([desc.serviceName, entityKey(id)]) + if (keys.has(key)) { + throw new Error( + `figbird: a transaction can mutate "${desc.serviceName}"/${String(id)} only once`, + ) + } + keys.add(key) + return { + desc, + id, + args: this.#buildMutationArgs(desc), + optimistic: desc.optimistic != null && desc.optimistic !== false, + lane: this.#mutationLanes.ensure( + desc.serviceName, + id, + this.#getEntity(desc.serviceName, id), + ), + } + }) + + const transaction: QueuedTransaction = { + entries: [], + readyLaneKeys: new Set(), + status: 'waiting', + } + const projections: ProjectionChange[] = [] + const promises: Promise[] = [] + + for (const operation of planned) { + const entry: QueuedMutation = { + desc: operation.desc, + args: operation.args, + optimistic: operation.optimistic, + attempt: new GatedMutationAttempt(), + transaction, + } + transaction.entries.push({ lane: operation.lane, entry }) + const tracked = this.#trackMutation( + { + serviceName: operation.desc.serviceName, + method: operation.desc.method, + id: operation.id, + optimistic: operation.optimistic, + args: operation.args, + }, + () => entry.attempt.promise, + { + onError: (_error, mutationId) => { + if (!operation.optimistic) return + this.#events.emit({ + kind: 'mutate:rollback', + mutationId, + serviceName: operation.desc.serviceName, + method: operation.desc.method, + id: operation.id, + }) + }, + }, + ) + promises.push(tracked.promise) + projections.push(this.#mutationLanes.enqueue(operation.lane, entry)) + } + + // All affected services are projected before observers are notified. + for (const projection of projections) this.#applyProjection(projection, false) + this.#processQueuedEvents() + for (const { lane } of transaction.entries) this.#drainMutationLane(lane) + + return Promise.all(promises).then(() => undefined) + } + /** * Run one confirmed mutation without record-lane scheduling. This preserves the * transport behavior of deprecated `useMutation`: a caller may time out a hung @@ -732,6 +842,13 @@ export class QueryStore< this.#releaseMutationLane(lane) return } + if (entry.transaction) { + entry.transaction.readyLaneKeys.add(lane.key) + if (entry.transaction.readyLaneKeys.size === entry.transaction.entries.length) { + this.#startTransaction(entry.transaction) + } + return + } entry.attempt.start(() => this.#runControlledAttempt(entry.attempt.control, () => this.#adapter.mutate(lane.serviceName, entry.desc.method, [...entry.args]), @@ -739,6 +856,83 @@ export class QueryStore< ) } + #startTransaction(transaction: QueuedTransaction): void { + if (transaction.status !== 'waiting') return + transaction.status = 'running' + + const operations: AdapterTransactionOperation[] = transaction.entries.map( + ({ lane, entry }) => ({ + serviceName: lane.serviceName, + method: entry.desc.method, + args: [...entry.args], + }), + ) + let transport: Promise + try { + transport = Promise.resolve(this.#adapter.transaction!(operations)) + } catch (error) { + transport = Promise.reject(error) + } + + const checked = transport.then(results => { + if (!Array.isArray(results) || results.length !== transaction.entries.length) { + throw new Error( + `figbird: adapter transaction returned ${Array.isArray(results) ? results.length : 'an invalid number of'} results for ${transaction.entries.length} operations`, + ) + } + return results + }) + const settled = checked.then( + results => { + this.#settleTransaction(transaction, { ok: true, results }) + return results + }, + (err: unknown) => { + const error = err instanceof Error ? err : new Error(String(err)) + this.#settleTransaction(transaction, { ok: false, error }) + throw error + }, + ) + + transaction.entries.forEach(({ entry }, index) => { + entry.attempt.start(() => settled.then(results => results[index])) + }) + } + + #abortTransaction(transaction: QueuedTransaction, error: Error, lanes: Set): void { + if (transaction.status !== 'waiting') return + transaction.status = 'aborted' + + for (const { lane, entry } of transaction.entries) { + const outcome = { ok: false, error } as const + const settlement = this.#mutationLanes.abort(lane, entry, error) + if (settlement) { + this.#applyLaneSettlement(lane, entry, outcome, settlement, lanes) + } + entry.attempt.cancel(error) + } + } + + #settleTransaction( + transaction: QueuedTransaction, + outcome: { ok: true; results: readonly unknown[] } | { ok: false; error: Error }, + ): void { + if (transaction.status !== 'running') return + transaction.status = 'settled' + const lanes = new Set() + transaction.entries.forEach(({ lane, entry }, index) => { + const entryOutcome = outcome.ok + ? ({ ok: true, item: outcome.results[index] } as const) + : ({ ok: false, error: outcome.error } as const) + const settlement = this.#mutationLanes.settle(lane, entry, entryOutcome) + if (!settlement) return + this.#applyLaneSettlement(lane, entry, entryOutcome, settlement, lanes) + }) + + // Success and rollback are each one observer-visible cache transition across services. + this.#finishLaneSettlements(lanes) + } + #expediteMutationPredecessors(lane: MutationLane, entry: QueuedMutation): void { for (const predecessor of this.#mutationLanes.predecessors(lane, entry)) { const control = predecessor.attempt.control @@ -780,6 +974,20 @@ export class QueryStore< const settlement = this.#mutationLanes.settle(lane, entry, outcome) if (!settlement) return + const lanes = new Set() + this.#applyLaneSettlement(lane, entry, outcome, settlement, lanes) + this.#finishLaneSettlements(lanes) + } + + #applyLaneSettlement( + lane: MutationLane, + entry: QueuedMutation, + outcome: MutationOutcome, + settlement: LaneSettlement, + lanes: Set, + ): void { + lanes.add(lane) + // A mutation acknowledgement is authoritative even when remaining overlays // keep the visible projection unchanged. Recording it protects fetches that // began before the acknowledgement from replacing the newer server state. @@ -787,27 +995,49 @@ export class QueryStore< this.#fetchEventJournal.record([settlement.authoritativeEvent]) } - const projected = this.#applyProjection(settlement.projection, true) + const projected = this.#applyProjection(settlement.projection, false) if (!projected && settlement.authoritativeEvent && !this.#mutationLanes.peekNext(lane)) { - this.#publishAppliedEvent(settlement.authoritativeEvent) + this.#appliedEventQueue.push(settlement.authoritativeEvent) } - if (settlement.cancelled.length > 0) { - const reason = outcome.ok - ? 'because the record was removed' - : entry.desc.method === 'create' - ? 'because its create mutation failed' - : 'because the preceding remove mutation failed' - for (const queued of settlement.cancelled) { - queued.attempt.cancel( + this.#cancelSettledDependants(lane, entry, outcome, settlement.cancelled, lanes) + } + + #finishLaneSettlements(lanes: ReadonlySet): void { + this.#processQueuedEvents() + for (const lane of lanes) this.#drainMutationLane(lane) + } + + #cancelSettledDependants( + lane: MutationLane, + entry: QueuedMutation, + outcome: { ok: true; item: unknown } | { ok: false; error: Error }, + cancelled: readonly QueuedMutation[], + lanes: Set, + ): void { + if (cancelled.length === 0) return + const reason = outcome.ok + ? 'because the record was removed' + : entry.desc.method === 'create' + ? 'because its create mutation failed' + : 'because the preceding remove mutation failed' + for (const queued of cancelled) { + if (queued.transaction) { + this.#abortTransaction( + queued.transaction, new MutationSupersededError( - `figbird: cancelled queued mutations for "${lane.serviceName}"/${String(lane.id)} ${reason}`, + `figbird: cancelled transaction for "${lane.serviceName}"/${String(lane.id)} ${reason}`, ), + lanes, ) + continue } + queued.attempt.cancel( + new MutationSupersededError( + `figbird: cancelled queued mutations for "${lane.serviceName}"/${String(lane.id)} ${reason}`, + ), + ) } - - this.#drainMutationLane(lane) } #applyProjection(change: ProjectionChange, immediate: boolean): boolean { @@ -1774,33 +2004,21 @@ export class QueryStore< return { reconcileQueryIds: immediateReconciles, refetchService } } - /** Publish an authoritative transition whose entity-cache effect already happened. */ - #publishAppliedEvent(event: ProcessedRealtimeEvent): void { - let effects: AppliedEventEffect[] = [] - const touched = this.#transactOverServiceByName(event.serviceName, (service, touch) => { - effects = this.#updateQueriesForEvents({ - service, - serviceName: event.serviceName, - processedEvents: [event], - touch, - }) - }) - this.#notify(touched) - const published = this.#publishServiceEventEffects(event.serviceName, effects, 'realtime') - for (const queryId of published.reconcileQueryIds) this.#requestReconcile(queryId) - if (published.refetchService) this.#refetchRefetchableQueries(event.serviceName) - } - #processQueuedEvents(): void { - if (this.#processingEventQueue || this.#eventQueue.length === 0) { + if ( + this.#processingEventQueue || + (this.#eventQueue.length === 0 && this.#appliedEventQueue.length === 0) + ) { return } this.#processingEventQueue = true try { - while (this.#eventQueue.length > 0) { - const eventsByService = groupQueuedEvents(this.#eventQueue) + while (this.#eventQueue.length > 0 || this.#appliedEventQueue.length > 0) { + const eventsByService = groupEventsByService(this.#eventQueue) + const appliedEventsByService = groupEventsByService(this.#appliedEventQueue) this.#eventQueue = [] + this.#appliedEventQueue = [] const touchedQueryIds = new Set() const followups: Array<{ @@ -1813,16 +2031,33 @@ export class QueryStore< // query spanning services A and B compute a wasted intermediate snapshot // after A's events but before B's, and non-React subscribers would observe // the intermediate state. - for (const [serviceName, events] of Object.entries(eventsByService)) { + const serviceNames = new Set([ + ...Object.keys(eventsByService), + ...Object.keys(appliedEventsByService), + ]) + for (const serviceName of serviceNames) { + const events = eventsByService[serviceName] ?? [] + const appliedEvents = appliedEventsByService[serviceName] ?? [] let effects: AppliedEventEffect[] = [] + let appliedEffects: AppliedEventEffect[] = [] const modifiedQueries = this.#transactOverServiceByName(serviceName, (service, touch) => { - effects = this.#applyServiceEvents({ - service, - serviceName, - events, - touch, - }) + if (events.length > 0) { + effects = this.#applyServiceEvents({ + service, + serviceName, + events, + touch, + }) + } + if (appliedEvents.length > 0) { + appliedEffects = this.#updateQueriesForEvents({ + service, + serviceName, + processedEvents: appliedEvents, + touch, + }) + } }) // Record only events that actually changed the entity cache. The fetch @@ -1832,7 +2067,7 @@ export class QueryStore< for (const queryId of modifiedQueries) { touchedQueryIds.add(queryId) } - followups.push({ serviceName, effects }) + followups.push({ serviceName, effects: [...effects, ...appliedEffects] }) } // Notify once per batch, after all services have applied. diff --git a/lib/core/transactions.ts b/lib/core/transactions.ts new file mode 100644 index 00000000..5f753b20 --- /dev/null +++ b/lib/core/transactions.ts @@ -0,0 +1,82 @@ +import type { MutationDescriptor } from './queryTypes.js' +import type { + Schema, + ServiceCreate, + ServiceItem, + ServiceNames, + ServicePatch, + ServiceUpdate, +} from './schema.js' +import type { CreateOptionsFor, MutationParamsOptions, WriteOptionsFor } from './mutations.js' +import { createCrudHandle, createServiceHandleProxy } from './mutations.js' + +interface TransactionHandleVerbs< + S extends Schema, + N extends ServiceNames, + TOptimistic extends boolean, +> { + create( + data: ServiceCreate, + options?: CreateOptionsFor, TOptimistic>, + ): void + update( + id: string | number, + data: ServiceUpdate, + options?: WriteOptionsFor, TOptimistic>, + ): void + patch( + id: string | number, + data: ServicePatch, + options?: WriteOptionsFor, TOptimistic>, + ): void + remove(id: string | number, options?: MutationParamsOptions): void +} + +/** Typed CRUD collector for one service inside a transaction. */ +export type TransactionMutationsHandle< + S extends Schema, + N extends ServiceNames, +> = TransactionHandleVerbs & { + /** Collect a mutation without projecting it before the transaction commits. */ + readonly confirmed: TransactionHandleVerbs +} + +/** The transaction-scoped counterpart of `figbird.m`. Calls collect work and return void. */ +export type TransactionMutationsProxy = { + >(serviceName: N): TransactionMutationsHandle + (serviceName: string): TransactionMutationsHandle> +} & { + readonly [N in ServiceNames]: TransactionMutationsHandle +} + +/** Context passed to `figbird.transaction()`. */ +export interface TransactionContext { + readonly m: TransactionMutationsProxy +} + +/** Build a transaction-scoped collector and expose the descriptors after the callback. @internal */ +export function createTransactionContext(): { + context: TransactionContext + close(): readonly MutationDescriptor[] +} { + const descriptors: MutationDescriptor[] = [] + let active = true + + const collect = (desc: MutationDescriptor): void => { + if (!active) { + throw new Error('figbird: transaction mutations can only be collected synchronously') + } + descriptors.push(desc) + } + const m = createServiceHandleProxy(serviceName => + createCrudHandle(collect, serviceName, { optimistic: true }), + ) as TransactionMutationsProxy + + return { + context: { m }, + close: () => { + active = false + return descriptors + }, + } +} diff --git a/lib/core/windowMaintenance.ts b/lib/core/windowMaintenance.ts index 9b43be10..98b0d5cf 100644 --- a/lib/core/windowMaintenance.ts +++ b/lib/core/windowMaintenance.ts @@ -140,8 +140,10 @@ function findInsertIndex( return lo } -export function groupQueuedEvents(events: QueuedEvent[]): Record { - const eventsByService: Record = {} +export function groupEventsByService( + events: readonly TEvent[], +): Record { + const eventsByService: Record = {} for (const event of events) { if (!eventsByService[event.serviceName]) { eventsByService[event.serviceName] = [] diff --git a/lib/index.ts b/lib/index.ts index 4e4df370..ae7cb171 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -44,6 +44,9 @@ export type { ReconnectJitter, RetryDelay, StandardSchemaV1, + TransactionContext, + TransactionMutationsHandle, + TransactionMutationsProxy, VisibilitySource, WriteMutationOptions, } from './core/figbird.js' @@ -90,16 +93,24 @@ export { RelationalQueryRef } from './core/figbird.js' export type { RelationalQueryState } from './core/figbird.js' // adapters -export { cursorPagination, FeathersAdapter, offsetPagination } from './adapters/feathers.js' +export { + cursorPagination, + FeathersAdapter, + feathersBatchTransactions, + FeathersTransactionError, + offsetPagination, +} from './adapters/feathers.js' export type { CursorPaginationOptions, CustomOperator, CustomOperatorContext, CustomOperatorRegistration, FeathersAdapterOptions, + FeathersBatchTransactionsOptions, FeathersCursorPagination, FeathersOffsetPagination, FeathersPagination, + FeathersTransaction, } from './adapters/feathers.js' export { matcher } from './adapters/matcher.js' @@ -109,6 +120,7 @@ export type { AdapterFindMeta, AdapterParams, AdapterQuery, + AdapterTransactionOperation, EventHandlers, MatcherContext, PageCursor, diff --git a/test/fixtures/mutations-inference.ts b/test/fixtures/mutations-inference.ts index 067c1543..e5aa4051 100644 --- a/test/fixtures/mutations-inference.ts +++ b/test/fixtures/mutations-inference.ts @@ -25,6 +25,7 @@ interface SendDocumentResult { interface EsignInstanceService { item: EsignInstance + patch: Partial methods: { requestSendDocument: (id: string, options?: SendDocumentOptions) => Promise voidDocument: (id: string, reason: string) => Promise<{ id: string; voided: true }> @@ -65,6 +66,21 @@ export type ConfirmedResult = Awaited const patchPromise = m['api/esign-instances'].patch('esign_1', { status: 'sent' }) export type PatchResult = Awaited +// Transactions reuse the schema-typed CRUD surface but collect synchronously. +export const transactionPromise = figbird.transaction(tx => { + tx.m['api/esign-instances'].patch('esign_1', { status: 'sent' }) + tx.m['api/messages'].confirmed.create({ id: 'message_1', body: 'Ready' }) + // @ts-expect-error - transaction patches retain the service patch type + tx.m['api/esign-instances'].patch('esign_1', { status: 'void' }) + // @ts-expect-error - the transaction DSL does not imply custom methods are atomic + tx.m['api/esign-instances'].requestSendDocument('esign_1') +}) + +// @ts-expect-error - transaction callbacks must collect synchronously +figbird.transaction(async tx => { + tx.m['api/esign-instances'].patch('esign_1', { status: 'sent' }) +}) + // Projection options are available only where the runtime applies them. m['api/esign-instances'].create( { id: 'esign_2', status: 'draft' }, diff --git a/test/mutations.test.ts b/test/mutations.test.ts index 53c53791..de14968e 100644 --- a/test/mutations.test.ts +++ b/test/mutations.test.ts @@ -1,5 +1,5 @@ import test from 'ava' -import type { QueryState } from '../lib' +import { feathersBatchTransactions, type QueryState } from '../lib' import { createTestApp } from './helpers' import { collectEvents, @@ -14,7 +14,10 @@ import { // ----- the m proxy ----- test('m: writes are optimistic by default; confirmed opts out per handle or inline', async t => { - const { figbird } = createTestApp(schema, services()) + const { figbird, adapter, feathers } = createTestApp(schema, { + ...services(), + 'api/batch': { data: {} }, + }) const { m } = figbird const events = collectEvents(figbird, 'mutate:') @@ -26,12 +29,150 @@ test('m: writes are optimistic by default; confirmed opts out per handle or inli const policies = m.notes.confirmed // named surface handle await policies.patch(1, { content: 'third' }) + let transactionOperations: readonly unknown[] = [] + let transactionCalls = 0 + let batchCalls: readonly unknown[] = [] + feathers.service('api/batch').create = ((data: { calls: readonly unknown[] }) => { + batchCalls = data.calls + return Promise.resolve({ + id: 'batch_1', + data: [ + { status: 'fulfilled', value: { id: 1, content: 'transactional' } }, + { status: 'fulfilled', value: { id: 1, name: 'Grace' } }, + ], + }) + }) as never + const transact = feathersBatchTransactions() + adapter.transaction = operations => { + transactionCalls += 1 + transactionOperations = operations + return transact(feathers, operations) + } + await figbird.transaction(tx => { + tx.m.notes.patch(1, { content: 'transactional' }) + tx.m.people.confirmed.patch(1, { name: 'Grace' }) + }) + t.deepEqual(transactionOperations, [ + { serviceName: 'notes', method: 'patch', args: [1, { content: 'transactional' }] }, + { serviceName: 'api/people', method: 'patch', args: [1, { name: 'Grace' }] }, + ]) + t.deepEqual(batchCalls, [ + ['patch', 'notes', 1, { content: 'transactional' }], + ['patch', 'api/people', 1, { name: 'Grace' }], + ]) + await Promise.resolve() const starts = events.filter(e => e.kind === 'mutate:start') t.deepEqual( starts.map(e => e.optimistic), - [true, false, false], + [true, false, false, true, false], + ) + + // If one lane invalidates a transaction before another lane reaches the + // barrier, an aborted create must still cancel mutations queued behind it. + const notePredecessorGate = deferred() + const failingPersonCreateGate = deferred<{ id: number; name: string }>() + feathers.service('notes').patch = (() => notePredecessorGate.promise) as never + feathers.service('api/people').create = (() => failingPersonCreateGate.promise) as never + + const notePredecessor = m.notes.confirmed.patch(99, { content: 'predecessor' }) + const failingPersonCreate = m.people.create({ id: 77, name: 'draft' }) + const abortedTransaction = figbird.transaction(tx => { + tx.m.notes.create({ id: 99, content: 'transaction create' }) + tx.m.people.patch(77, { name: 'transaction patch' }) + }) + const dependentNotePatch = m.notes.patch(99, { content: 'dependent' }) + + const createError = t.throwsAsync(failingPersonCreate, { message: 'create failed' }) + const transactionError = t.throwsAsync(abortedTransaction, { message: /cancelled transaction/ }) + const dependentError = t.throwsAsync(dependentNotePatch, { + message: /cancelled queued mutations/, + }) + failingPersonCreateGate.reject(new Error('create failed')) + await Promise.all([createError, transactionError, dependentError]) + t.is(transactionCalls, 1, 'the invalidated transaction never reaches the adapter') + + notePredecessorGate.resolve({ id: 99, content: 'predecessor' }) + await notePredecessor +}) + +test('transactions: canonical entity ids cannot reserve the same lane twice', t => { + const { figbird, adapter } = createTestApp(schema, services()) + let transactionCalls = 0 + adapter.transaction = () => { + transactionCalls += 1 + return Promise.resolve([]) + } + + const error = t.throws(() => + figbird.transaction(tx => { + tx.m.notes.patch(1, { content: 'numeric id' }) + tx.m.notes.patch('1', { content: 'string id' }) + }), ) + + t.regex(error!.message, /can mutate "notes"\/1 only once/) + t.is(transactionCalls, 0) +}) + +test('transactions: cascading cancellation publishes one final settlement', async t => { + const { figbird, adapter } = createTestApp(schema, services()) + const notesRef = figbird.queryDesc({ serviceName: 'notes', method: 'find' }) + const peopleRef = figbird.queryDesc({ serviceName: 'api/people', method: 'find' }) + const unsubscribeNotes = notesRef.subscribe(() => {}) + const unsubscribePeople = peopleRef.subscribe(() => {}) + await new Promise(resolve => setTimeout(resolve, 10)) + + const transactionGate = deferred() + let transactionCalls = 0 + adapter.transaction = () => { + transactionCalls += 1 + return transactionGate.promise + } + + const committing = figbird.transaction(tx => { + tx.m.notes.remove(1) + tx.m.people.patch(1, { name: 'optimistic name' }) + }) + const cancelled = figbird.transaction(tx => { + tx.m.notes.patch(1, { content: 'old lifetime' }) + tx.m.notes.patch(2, { content: 'doomed sibling' }) + }) + + const snapshots: Array<{ + hasRemovedNote: boolean + siblingContent: string | undefined + personName: string | undefined + }> = [] + const unsubscribeState = figbird.subscribeToStateChanges(state => { + const notes = state.get('notes')?.entities + const people = state.get('api/people')?.entities + snapshots.push({ + hasRemovedNote: notes?.has('1') ?? false, + siblingContent: (notes?.get('2') as Note | undefined)?.content, + personName: (people?.get('1') as { name: string } | undefined)?.name, + }) + }) + + const cancelledError = t.throwsAsync(cancelled, { message: /cancelled transaction/ }) + transactionGate.resolve([ + { id: 1, content: 'hello' }, + { id: 1, name: 'server name' }, + ]) + await Promise.all([committing, cancelledError]) + + t.is(transactionCalls, 1, 'the cancelled transaction never reaches the adapter') + t.deepEqual(snapshots, [ + { + hasRemovedNote: false, + siblingContent: 'world', + personName: 'server name', + }, + ]) + + unsubscribeState() + unsubscribePeople() + unsubscribeNotes() }) test('m: handles are interned and the confirmed variant is stable', t => {