import type { PendingMutation, Transaction, UpdateMutationFnParams, } from '@tanstack/react-db'; import { Effect } from 'effect'; import { nextUlid, type DecodedEntity, type DecodedSingleEntity, } from '../../../core/index.js'; import type { AnyUnkeyedESchema } 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 OutboxRuntime, type Request, } from '../../outbox/outbox/index.js'; import { runOutboxTransaction } from './outbox-plan.js'; import type { CollectionItem } from '../../domain/collection-item/index.js'; import { buildPacedUpdate, coalesceStrategy, type PaceStrategyFactory, } from '../pacing/index.js'; import { stripMeta, stripMetaPartial, toEntity, } from '../../domain/collection-item/index.js'; import type { EffectRunner } from '../../platform/effect-runner/index.js'; import type { CollectionFlow } from '../../flow/sync-flow/index.js'; export const SINGLE_ITEM_KEY = '__single__'; export const buildSingleItemMutations = < S extends AnyUnkeyedESchema, R = never, >(args: { schema: S; collectionName: CollectionName; applyToSyncReplica: ( entities: DecodedEntity[], ) => Effect.Effect; onUpdate?: | ((payload: { updates: Partial; }) => Effect.Effect, unknown, R>) | undefined; pacing?: PaceStrategyFactory | undefined; 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, onUpdate, pacing } = args; const { outbox, runner, flow } = args; const handlerName = collectionHandlerName(collectionName); const outboxStory = () => narrateOutbox(flow()?.outbox ?? null); const withMutationSpan = ( mutation: Effect.Effect, ): Effect.Effect => { const activeFlow = flow(); return activeFlow ? mutation.pipe( activeFlow.collection.withSpan('Collection Mutation', { attributes: { collection: collectionName, operation: 'update', ...(outbox ? { outbox: true } : {}), }, }), ) : mutation; }; const runUpdate = (updates: Partial): Promise => runner.runPromise( withMutationSpan( Effect.gen(function* () { const result = yield* onUpdate!({ updates }); yield* applyToSyncReplica([toEntity(result)]); }), ), ); 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.map((field) => [ field, (after as Record)[field], ]), ) as Partial; const runOutbox = (transaction: Transaction): Promise => runOutboxTransaction({ runner, outbox: outbox!, story: outboxStory(), withMutationSpan, transaction, buildEntry: (mutation: PendingMutation, id) => Effect.gen(function* () { return { id, name: handlerName, queue: queueKey(handlerName, SINGLE_ITEM_KEY), enqueuedAt: yield* nextUlid, body: { kind: 'entity' as const, op: 'update' as const, key: SINGLE_ITEM_KEY, base: yield* encode( stripMeta(mutation.original as TCollItem), ), after: yield* encode(stripMeta(mutation.modified)), changed: Object.keys( stripMetaPartial(mutation.changes as Partial), ), }, }; }), }); const send = (request: Request): Effect.Effect => Effect.gen(function* () { if (request.op !== 'update') return; if (!onUpdate) { return yield* Effect.fail( new Error(`[sync] collection "${collectionName}" has no onUpdate`), ); } const after = yield* decode(request.after); const result = yield* onUpdate({ updates: pick(after, request.changed) }); yield* applyToSyncReplica([toEntity(result)]); }); if (outbox) { outbox.registerHandler(handlerName, { kind: 'entity', flow: flow()?.outbox ?? null, send: (request) => args.replayed.pipe(Effect.andThen(runner.provide(send(request)))), }); } const updateHandler = onUpdate ? async ({ transaction, }: UpdateMutationFnParams): Promise => { if (outbox) return runOutbox(transaction); const mutation = transaction.mutations[0]!; await runUpdate(stripMetaPartial(mutation.changes)); } : undefined; const makePacedUpdate = onUpdate ? () => { let paced: | ((changes: Partial) => Transaction>) | null = null; return ( changes: Partial, optimistic: (changes: Partial) => void, ): Transaction> => { if (!paced) { paced = buildPacedUpdate>({ strategy: (pacing ?? coalesceStrategy)(), optimistic, commit: (merged) => runUpdate(stripMetaPartial(merged)), }); } return paced(changes); }; } : () => (): Transaction> => { throw new Error('pacedUpdate requires onUpdate to be defined'); }; return { onUpdate: updateHandler, pacedUpdate: makePacedUpdate(), decode, pick, }; };