import type { DeleteMutationFnParams, InsertMutationFnParams, PendingMutation, Transaction, UpdateMutationFnParams, } from '@tanstack/react-db'; import { Effect } from 'effect'; import { nextUlid, type DecodedEntity } from '../../../core/index.js'; import type { AnyEntityESchema } from '../../../eschema/index.js'; import type { WriteError } from '../../domain/sync-error/index.js'; import { collectionHandlerName, type CollectionName, } from '../../domain/identity/index.js'; import { narrateOutbox, queueKey, type EntityBody, type OutboxRuntime, type Request, } from '../../outbox/outbox/index.js'; import { runOutboxTransaction } from './outbox-plan.js'; import type { CollectionItem, DeletePayload, UpdatePayload, } from '../../domain/collection-item/index.js'; import { buildPacedUpdate, coalesceStrategy, type PaceStrategyFactory, } from '../pacing/index.js'; import { stripMeta, stripMetaPartial, } from '../../domain/collection-item/index.js'; import type { EffectRunner } from '../../platform/effect-runner/index.js'; import type { CollectionFlow } from '../../flow/sync-flow/index.js'; // Per-item onUpdate / onDelete callbacks of one transaction run this many at a time. const MUTATION_CONCURRENCY = 5; export const buildKeyedMutations = < S extends AnyEntityESchema, R = never, >(args: { schema: S; collectionName: CollectionName; applyToSyncReplica: ( entities: DecodedEntity[], ) => Effect.Effect; onInsert?: ( items: ReadonlyArray, ) => Effect.Effect>, unknown, R>; onUpdate?: ( payload: UpdatePayload, ) => Effect.Effect, unknown, R>; onDelete?: ( payload: DeletePayload, ) => Effect.Effect, unknown, R>; pacing?: PaceStrategyFactory; outbox: OutboxRuntime | null; // Held closed while the Collection replays its Entries at mount. replayed: Effect.Effect; runner: EffectRunner; flow: () => CollectionFlow | null; }) => { type TItem = S['Type']; type TCollItem = CollectionItem; const { schema, collectionName, applyToSyncReplica, onInsert, onUpdate, onDelete, pacing, outbox, runner, flow, } = args; const handlerName = collectionHandlerName(collectionName); const outboxStory = () => narrateOutbox(flow()?.outbox ?? null); const withMutationSpan = ( operation: 'delete' | 'insert' | 'update', keys: readonly string[], mutation: Effect.Effect, ): Effect.Effect => { const activeFlow = flow(); return activeFlow ? mutation.pipe( activeFlow.collection.withSpan('Collection Mutation', { attributes: { collection: collectionName, ...(keys.length === 1 ? { entityKey: keys[0] } : {}), mutationCount: keys.length, operation, ...(outbox ? { outbox: true } : {}), }, }), ) : mutation; }; const runConfirmed = ( operation: 'delete' | 'insert' | 'update', keys: readonly string[], confirm: Effect.Effect>, unknown, R>, ): Promise => runner.runPromise( withMutationSpan( operation, keys, Effect.gen(function* () { const results = yield* confirm; yield* applyToSyncReplica([...results]); }), ), ); const eachConfirmed = ( effects: ReadonlyArray, unknown, R>>, ) => Effect.all(effects, { concurrency: MUTATION_CONCURRENCY }); const buildUpdatePayload = ( current: TItem, updates: Partial, ): UpdatePayload => ({ current, updates }) as UpdatePayload; const changedKeys = (changes: Partial): string[] => Object.keys(stripMetaPartial(changes)); const encode = (item: TItem) => schema.encode(item as never) as Effect.Effect; const decode = (value: unknown) => schema.decode(value) as Effect.Effect; const pick = (after: TItem, changed: ReadonlyArray): Partial => Object.fromEntries( changed .filter((field) => field !== schema.idField) .map((field) => [field, (after as Record)[field]]), ) as Partial; const entityBody = ( mutation: PendingMutation, ): Effect.Effect => Effect.gen(function* () { const key = String(mutation.key); switch (mutation.type) { case 'insert': return { kind: 'entity', op: 'insert', key, base: null, after: yield* encode(stripMeta(mutation.modified)), changed: [], }; case 'update': return { kind: 'entity', op: 'update', key, base: yield* encode( stripMeta(mutation.original as TCollItem), ), after: yield* encode(stripMeta(mutation.modified)), changed: changedKeys(mutation.changes as Partial), }; case 'delete': return { kind: 'entity', op: 'delete', key, base: yield* encode( stripMeta(mutation.original as TCollItem), ), after: null, changed: [], }; } }); const runOutbox = ( operation: 'delete' | 'insert' | 'update', transaction: Transaction, ): Promise => { const keys = transaction.mutations.map((mutation) => String(mutation.key)); return runOutboxTransaction({ runner, outbox: outbox!, story: outboxStory(), withMutationSpan: (mutation) => withMutationSpan(operation, keys, mutation), transaction, buildEntry: (mutation, id) => Effect.gen(function* () { return { id, name: handlerName, queue: queueKey(handlerName, String(mutation.key)), enqueuedAt: yield* nextUlid, body: yield* entityBody(mutation), }; }), }); }; const send = (request: Request): Effect.Effect => Effect.gen(function* () { switch (request.op) { case 'nothing': return; case 'insert': { if (!onInsert) return yield* Effect.fail(missing('onInsert')); const value = yield* decode(request.value); const results = yield* onInsert([value]); yield* applyToSyncReplica([...results]); return; } case 'update': { if (!onUpdate) return yield* Effect.fail(missing('onUpdate')); const base = yield* decode(request.base); const after = yield* decode(request.after); const result = yield* onUpdate( buildUpdatePayload(base, pick(after, request.changed)), ); yield* applyToSyncReplica([result]); return; } case 'delete': { if (!onDelete) return yield* Effect.fail(missing('onDelete')); const base = yield* decode(request.base); const result = yield* onDelete({ current: base }); yield* applyToSyncReplica([result]); return; } } }); const missing = (callback: string) => new Error(`[sync] collection "${collectionName}" has no ${callback}`); if (outbox) { outbox.registerHandler(handlerName, { kind: 'entity', flow: flow()?.outbox ?? null, send: (request) => args.replayed.pipe(Effect.andThen(runner.provide(send(request)))), }); } const insertHandler = onInsert ? async ({ transaction, }: InsertMutationFnParams): Promise => { if (outbox) return runOutbox('insert', transaction); await runConfirmed( 'insert', transaction.mutations.map((mutation) => String(mutation.key)), onInsert( transaction.mutations.map((mutation) => stripMeta(mutation.modified), ), ), ); } : undefined; const updateHandler = onUpdate ? async ({ transaction, }: UpdateMutationFnParams): Promise => { if (outbox) return runOutbox('update', transaction); await runConfirmed( 'update', transaction.mutations.map((mutation) => String(mutation.key)), eachConfirmed( transaction.mutations.map((mutation) => onUpdate( buildUpdatePayload( stripMeta(mutation.original), stripMetaPartial(mutation.changes), ), ), ), ), ); } : undefined; const deleteHandler = onDelete ? async ({ transaction, }: DeleteMutationFnParams): Promise => { if (outbox) return runOutbox('delete', transaction); await runConfirmed( 'delete', transaction.mutations.map((mutation) => String(mutation.key)), eachConfirmed( transaction.mutations.map((mutation) => onDelete({ current: stripMeta(mutation.original) }), ), ), ); } : undefined; const makePacedUpdate = onUpdate ? () => { const mutate = new Map< string, (changes: Partial) => Transaction> >(); const rows = new Map>(); return ( key: string, current: CollectionItem, changes: Partial, optimistic: (key: string, changes: Partial) => void, ): Transaction> => { rows.set(key, current); let paced = mutate.get(key); if (!paced) { paced = buildPacedUpdate>({ strategy: (pacing ?? coalesceStrategy)(), optimistic: (next) => optimistic(key, next), commit: async (merged) => { const updates = stripMetaPartial(merged); await runConfirmed( 'update', [key], eachConfirmed([ onUpdate( buildUpdatePayload(stripMeta(rows.get(key)!), updates), ), ]), ); }, }); mutate.set(key, paced); } return paced(changes); }; } : () => (): Transaction> => { throw new Error('pacedUpdate requires onUpdate to be defined'); }; return { onInsert: insertHandler, onUpdate: updateHandler, onDelete: deleteHandler, pacedUpdate: makePacedUpdate(), decode, pick, }; };