diff --git a/src/sync-plugins/crud.ts b/src/sync-plugins/crud.ts index 8af64b79..015b8d07 100644 --- a/src/sync-plugins/crud.ts +++ b/src/sync-plugins/crud.ts @@ -56,7 +56,7 @@ export interface WaitForSetCrudFnParams extends WaitForSetFnParams { } export interface CrudErrorParams extends Omit { - source: 'list' | 'get' | 'create' | 'update' | 'delete'; + source: 'list' | 'get' | 'create' | 'update' | 'delete' | 'unknown'; } export type CrudOnErrorFn = (error: Error, params: CrudErrorParams) => void; diff --git a/src/sync-plugins/keel.ts b/src/sync-plugins/keel.ts index 7c53ca39..206057e4 100644 --- a/src/sync-plugins/keel.ts +++ b/src/sync-plugins/keel.ts @@ -1,13 +1,14 @@ import { batch, isEmpty, isFunction, observable, when } from '@legendapp/state'; import { - SyncedErrorParams, - SyncedGetSetSubscribeBaseParams, + createRevertChanges, type SyncedGetParams, + type SyncedGetSetSubscribeBaseParams, type SyncedSetParams, type SyncedSubscribeParams, } from '@legendapp/state/sync'; import { CrudAsOption, + CrudErrorParams, CrudResult, SyncedCrudOnSavedParams, SyncedCrudPropsBase, @@ -138,8 +139,7 @@ interface SyncedKeelPropsSingle as?: never; } -export interface KeelErrorParams extends Omit { - source: 'list' | 'get' | 'create' | 'update' | 'delete'; +export interface KeelErrorParams extends CrudErrorParams { action: string; } @@ -489,6 +489,7 @@ export function syncedKeel< source: from, action: fn.name || fn.toString(), retry: params, + revert: createRevertChanges(params.value$, params.changes), }); } } diff --git a/src/sync-plugins/supabase.ts b/src/sync-plugins/supabase.ts index e4ce94be..235da33b 100644 --- a/src/sync-plugins/supabase.ts +++ b/src/sync-plugins/supabase.ts @@ -5,6 +5,7 @@ import { SyncedOptionsGlobal, SyncedSetParams, combineTransforms, + createRevertChanges, removeNullUndefined, transformStringifyDates, type SyncedGetParams, @@ -12,6 +13,7 @@ import { } from '@legendapp/state/sync'; import { CrudAsOption, + CrudErrorParams, CrudOnErrorFn, SyncedCrudPropsBase, SyncedCrudPropsMany, @@ -128,14 +130,14 @@ export function configureSyncedSupabase(config: SyncedSupabaseConfiguration) { Object.assign(supabaseConfig, removeNullUndefined(rest)); } -function wrapSupabaseFn(fn: (...args: any) => PromiseLike) { +function wrapSupabaseFn(fn: (...args: any) => PromiseLike, source: CrudErrorParams['source']) { return async (params: SyncedGetParams, ...args: any) => { const { onError } = params; const { data, error } = await fn(params, ...args); if (error) { (onError as CrudOnErrorFn)(new Error(error.message), { getParams: params, - source: 'list', + source, type: 'get', retry: params, }); @@ -200,7 +202,7 @@ export function syncedSupabase< const list = !actions || actions.includes('read') ? listParam - ? wrapSupabaseFn(listParam) + ? wrapSupabaseFn(listParam, 'list') : async (params: SyncedGetParams) => { const { lastSync, onError } = params; const clientSchema = schema ? client.schema(schema as string) : client; @@ -232,7 +234,7 @@ export function syncedSupabase< : undefined; const create = createParam - ? wrapSupabaseFn(createParam) + ? wrapSupabaseFn(createParam, 'create') : !actions || actions.includes('create') ? async (input: SupabaseRowOf, params: SyncedSetParams) => { const { onError } = params; @@ -248,6 +250,7 @@ export function syncedSupabase< type: 'set', retry: params, input, + revert: createRevertChanges(params.value$, params.changes), }); } } @@ -256,7 +259,7 @@ export function syncedSupabase< const update = !actions || actions.includes('update') ? updateParam - ? wrapSupabaseFn(updateParam) + ? wrapSupabaseFn(updateParam, 'update') : async (input: SupabaseRowOf, params: SyncedSetParams) => { const { onError } = params; const res = await client.from(collection).update(input).eq('id', input.id).select(); @@ -271,6 +274,7 @@ export function syncedSupabase< type: 'set', retry: params, input, + revert: createRevertChanges(params.value$, params.changes), }); } } @@ -279,7 +283,7 @@ export function syncedSupabase< const deleteFn = !fieldDeleted && (!actions || actions.includes('delete')) ? deleteParam - ? wrapSupabaseFn(deleteParam) + ? wrapSupabaseFn(deleteParam, 'delete') : async ( input: { id: SupabaseRowOf['id'] }, params: SyncedSetParams, @@ -298,6 +302,7 @@ export function syncedSupabase< type: 'set', retry: params, input, + revert: createRevertChanges(params.value$, params.changes), }); } } diff --git a/src/sync/revertChanges.ts b/src/sync/revertChanges.ts new file mode 100644 index 00000000..091e33bc --- /dev/null +++ b/src/sync/revertChanges.ts @@ -0,0 +1,13 @@ +import { applyChanges, Change, internal, ObservableParam } from '@legendapp/state'; +import { onChangeRemote } from '@legendapp/state/sync'; + +const { clone } = internal; + +export function createRevertChanges(obs$: ObservableParam, changes: Change[]) { + return () => { + const previous = applyChanges(clone(obs$.peek()), changes, /*applyPrevious*/ true); + onChangeRemote(() => { + obs$.set(previous); + }); + }; +} diff --git a/src/sync/syncObservable.ts b/src/sync/syncObservable.ts index f6d03c39..ccc9d07a 100644 --- a/src/sync/syncObservable.ts +++ b/src/sync/syncObservable.ts @@ -54,6 +54,7 @@ import type { SyncedSubscribeParams, } from './syncTypes'; import { waitForSet } from './waitForSet'; +import { createRevertChanges } from './revertChanges'; const { clone, deepMerge, getNode, getNodeValue, getValueAtPath, globalState, symbolLinked, createPreviousHandler } = internal; @@ -639,10 +640,11 @@ async function doChangeRemote(changeInfo: PreppedChangeRemote | undefined) { type: 'set', input: value, retry: setParams, + revert: createRevertChanges(setParams.value$, setParams.changes), }; } state$.error.set(error); - syncOptions.onError?.(error, params); + syncOptions.onError?.(error, params!); lastErrorHandled = error; if (!noThrow) { throw error; diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index 23a2e02f..6206dda8 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -75,6 +75,7 @@ export interface SyncedErrorParams { setParams?: SyncedSetParams; subscribeParams?: SyncedSubscribeParams; input?: any; + revert?: () => void; } export interface SyncedOptions extends Omit, 'get' | 'set'> { diff --git a/sync.ts b/sync.ts index 2b6efe16..967dd793 100644 --- a/sync.ts +++ b/sync.ts @@ -6,6 +6,7 @@ export { mapSyncPlugins, onChangeRemote, syncObservable } from './src/sync/syncO export * from './src/sync/syncTypes'; export { synced } from './src/sync/synced'; export * from './src/sync/configureSynced'; +export { createRevertChanges } from './src/sync/revertChanges'; import { waitForSet } from './src/sync/waitForSet'; import { observableSyncConfiguration } from './src/sync/configureObservableSync'; diff --git a/tests/crud.test.ts b/tests/crud.test.ts index 38e4917f..99ab2dde 100644 --- a/tests/crud.test.ts +++ b/tests/crud.test.ts @@ -2917,6 +2917,39 @@ describe('Error is set', () => { expect(errorAtOnError).toEqual(new Error('test')); expect(numErrors).toEqual(1); }); + test('onError can revert if create fails', async () => { + let errorAtOnError: Error | undefined = undefined; + let numErrors = 0; + const obs$ = observable( + syncedCrud({ + list: () => promiseTimeout(0, [ItemBasicValue()]), + as: 'object', + create: async () => { + throw new Error('test'); + }, + onError: (error, params) => { + numErrors++; + errorAtOnError = error; + params.revert!(); + }, + }), + ); + expectTypeOf<(typeof obs$)['get']>().returns.toEqualTypeOf>(); + + expect(obs$.get()).toEqual(undefined); + + await promiseTimeout(1); + + expect(obs$.get()).toEqual({ id1: { id: 'id1', test: 'hi' } }); + obs$.id2.set({ id: 'id2', test: 'hi' }); + + await promiseTimeout(1); + + expect(errorAtOnError).toEqual(new Error('test')); + expect(numErrors).toEqual(1); + + expect(obs$.get()).toEqual({ id1: { id: 'id1', test: 'hi' }, id2: undefined }); + }); test('onError is called if list fails', async () => { let errorAtOnError: Error | undefined = undefined; let numErrors = 0;