import { withOptimisticAggregates } from 'orez-lite/aggregate' import { getAuthData } from '../state' import { mapObject } from './mapObject' import { runWithContext } from './mutatorContext' import { time } from './time' import type { AuthData, Can, GenericModels, GetZeroMutators, MutatorContext, Transaction, } from '../types' import type { AggregateSet } from 'orez-lite/aggregate' export type ValidateMutationFn = (args: { authData: AuthData | null mutatorName: string tableName: string args: unknown }) => void | Promise export type { ValidateMutationFn as CreateMutatorsValidateFn } export function createMutators({ environment, authData, createServerActions, enqueueTask = () => {}, enqueueAction = () => {}, bindCan, models, validateMutation, mutationValidators, resolveAuthData, aggregates, }: { environment: 'server' | 'client' authData: AuthData | null bindCan: (tx: Transaction, authData: AuthData | null) => Can models: Models enqueueTask?: NonNullable['enqueueTask'] enqueueAction?: NonNullable['enqueueAction'] createServerActions?: () => Record validateMutation?: ValidateMutationFn /** valibot schemas keyed by model.mutationName, auto-validates args before running */ mutationValidators?: Record> resolveAuthData?: () => AuthData | null aggregates?: AggregateSet }): GetZeroMutators { const serverActions = createServerActions?.() const modelMutators = mapObject(models, (val) => val.mutate || {}) as Record< string, Record > function withContext(fn: (...args: Args) => Promise) { return async (tx: Transaction, ...args: Args): Promise => { const transaction = environment === 'client' && aggregates ? withOptimisticAggregates(tx, aggregates) : tx // on client, read authData dynamically to avoid stale closure during auth // transitions (ZeroProvider recreates Zero instance in useEffect, but // mutations can run before that) const contextAuthData = environment === 'client' ? getAuthData() : (resolveAuthData?.() ?? authData) const mutationContext: MutatorContext = { tx: transaction, authData: contextAuthData, environment, // bound to THIS transaction. a permission check must run against the // mutator that asked for it, and ambient lookup cannot promise that. can: bindCan(transaction, contextAuthData), server: environment === 'server' ? ({ actions: serverActions || {}, enqueueTask, enqueueAction, } as MutatorContext['server']) : undefined, } // eslint-disable-next-line typescript-eslint/return-await return await runWithContext(mutationContext, () => { // @ts-expect-error type shenanigan // map to our mutations() helper return fn(mutationContext, ...args) }) } } function withDevelopmentLogging( name: string, fn: (...args: Args) => Promise ) { if (process.env.NODE_ENV !== 'development' && !process.env.IS_TESTING) { return fn } const debug = process.env.DEBUG return async (...args: Args): Promise => { const startTime = performance.now() try { if (debug && environment === 'server') { console.info(`[mutator] ${name} start`) } const result = await fn(...args) const duration = (performance.now() - startTime).toFixed(2) if (debug) { if (environment === 'client') { console.groupCollapsed(`[mutator] ${name} completed in ${duration}ms`) console.info('→', args[1]) console.info('←', result) console.trace() console.groupEnd() } else { console.info(`[mutator] ${name} completed in ${duration}ms`) } } return result } catch (error) { if (debug) { const duration = (performance.now() - startTime).toFixed(2) if ((error as any)?.name === 'PermissionError') { console.info(`[mutator] ${name} denied (${duration}ms)`) } else { console.groupCollapsed(`[mutator] ${name} failed after ${duration}ms`) console.error('error:', error) console.info('arguments:', JSON.stringify(args[1], null, 2)) console.groupEnd() } } throw error } } } function withTimeoutGuard( name: string, fn: (...args: Args) => Promise, // don't want this too high - zero runs mutations in order and waits for the last to finish it seems // so if one mutation gets stuck it will just sit there timeoutMs: number = time.ms.minutes(1) ) { return async (...args: Args): Promise => { let timeoutId: ReturnType | undefined const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`[mutator] ${name} timeout after ${timeoutMs}ms`)) }, timeoutMs) }) try { return await Promise.race([fn(...args), timeoutPromise]) } finally { if (timeoutId !== undefined) clearTimeout(timeoutId) } } } function withValidation( tableName: string, mutatorName: string, fn: (...args: Args) => Promise ) { const validator = mutationValidators?.[tableName]?.[mutatorName] if (!validateMutation && !validator) { return fn } return async (...args: Args): Promise => { // args[0] is tx, args[1] is the mutation args // auto-validate with generated valibot schema first. // // the skip is keyed on the SCHEMA, never on the value. skipping whenever // args were null keyed the only shape check at this boundary on something // the caller chooses, so a mutator declaring required args could be // reached unvalidated by sending null, and the handler ran on whatever // was left. the case the skip exists for is a mutation that declares no // args at all, which zero calls with null, so ask whether the validator // declares anything instead: a void schema, or an object with no entries. const declaresNothing = (validator as { type?: string } | undefined)?.type === 'void' || ((validator as { type?: string } | undefined)?.type === 'object' && Object.keys((validator as { entries?: object }).entries ?? {}).length === 0) if (validator && !declaresNothing) { const valibot = await import('valibot') valibot.parse(validator, args[1]) } // then run user-provided validation hook as escape hatch if (validateMutation) { await validateMutation({ authData: environment === 'client' ? getAuthData() : (resolveAuthData?.() ?? authData), tableName, mutatorName, args: args[1], }) } return fn(...args) } } function decorateMutators>>(modules: T) { const result: any = {} for (const [moduleName, moduleExports] of Object.entries(modules)) { result[moduleName] = {} for (const [name] of Object.entries(moduleExports)) { const fullName = `${moduleName}.${name}` // look up function dynamically to support HMR // modules[moduleName] is a proxy that returns updated implementations const getDynamicFn = () => modules[moduleName][name] result[moduleName][name] = withDevelopmentLogging( fullName, withTimeoutGuard( fullName, withValidation( moduleName, name, withContext((...args: any[]) => getDynamicFn()(...args)) ) ) ) } } return result } return decorateMutators(modelMutators) }