@legendapp/state 2.2.0-next.74 → 2.2.0-next.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/helpers/time.d.ts +2 -2
- package/index.d.ts +1 -1
- package/index.js +82 -31
- package/index.js.map +1 -1
- package/index.mjs +81 -32
- package/index.mjs.map +1 -1
- package/package.json +16 -1
- package/persist.js +122 -129
- package/persist.js.map +1 -1
- package/persist.mjs +122 -129
- package/persist.mjs.map +1 -1
- package/react.js +5 -5
- package/react.js.map +1 -1
- package/react.mjs +6 -6
- package/react.mjs.map +1 -1
- package/src/ObservableObject.ts +34 -15
- package/src/batching.ts +9 -3
- package/src/computed.ts +4 -2
- package/src/globals.ts +17 -7
- package/src/helpers.ts +3 -3
- package/src/history/undoRedo.ts +111 -0
- package/src/is.ts +7 -0
- package/src/observableInterfaces.ts +6 -5
- package/src/observableTypes.ts +5 -0
- package/src/observe.ts +1 -1
- package/src/react/For.tsx +6 -6
- package/src/sync/activateSyncedNode.ts +9 -25
- package/src/sync/syncHelpers.ts +53 -12
- package/src/sync/syncObservable.ts +117 -101
- package/src/sync-plugins/crud.ts +384 -0
- package/src/sync-plugins/fetch.ts +57 -27
- package/src/sync-plugins/keel.ts +447 -0
- package/src/sync-plugins/supabase.ts +225 -0
- package/src/syncTypes.ts +12 -6
- package/src/when.ts +6 -1
- package/sync-plugins/crud.d.ts +40 -0
- package/sync-plugins/crud.js +275 -0
- package/sync-plugins/crud.js.map +1 -0
- package/sync-plugins/crud.mjs +271 -0
- package/sync-plugins/crud.mjs.map +1 -0
- package/sync-plugins/fetch.d.ts +8 -7
- package/sync-plugins/fetch.js +34 -12
- package/sync-plugins/fetch.js.map +1 -1
- package/sync-plugins/fetch.mjs +35 -13
- package/sync-plugins/fetch.mjs.map +1 -1
- package/sync-plugins/keel.d.ts +91 -0
- package/sync-plugins/keel.js +278 -0
- package/sync-plugins/keel.js.map +1 -0
- package/sync-plugins/keel.mjs +274 -0
- package/sync-plugins/keel.mjs.map +1 -0
- package/sync-plugins/supabase.d.ts +32 -0
- package/sync-plugins/supabase.js +134 -0
- package/sync-plugins/supabase.js.map +1 -0
- package/sync-plugins/supabase.mjs +131 -0
- package/sync-plugins/supabase.mjs.map +1 -0
- package/sync.d.ts +1 -0
- package/sync.js +157 -127
- package/sync.js.map +1 -1
- package/sync.mjs +156 -129
- package/sync.mjs.map +1 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SyncedSetParams,
|
|
3
|
+
computeSelector,
|
|
4
|
+
internal,
|
|
5
|
+
observable,
|
|
6
|
+
when,
|
|
7
|
+
type SyncedGetParams,
|
|
8
|
+
type SyncedSubscribeParams,
|
|
9
|
+
} from '@legendapp/state';
|
|
10
|
+
import {
|
|
11
|
+
CrudAsOption,
|
|
12
|
+
CrudResult,
|
|
13
|
+
SyncedCrudPropsBase,
|
|
14
|
+
SyncedCrudPropsMany,
|
|
15
|
+
SyncedCrudPropsSingle,
|
|
16
|
+
SyncedCrudReturnType,
|
|
17
|
+
syncedCrud,
|
|
18
|
+
} from '@legendapp/state/sync-plugins/crud';
|
|
19
|
+
const { clone } = internal;
|
|
20
|
+
|
|
21
|
+
// Keel types
|
|
22
|
+
export interface KeelObjectBase {
|
|
23
|
+
id: string;
|
|
24
|
+
createdAt: Date;
|
|
25
|
+
updatedAt: Date;
|
|
26
|
+
}
|
|
27
|
+
export type KeelKey = 'createdAt' | 'updatedAt';
|
|
28
|
+
export const KeelKeys: KeelKey[] = ['createdAt', 'updatedAt'];
|
|
29
|
+
export type OmitKeelBuiltins<T, T2 extends string = ''> = Omit<T, KeelKey | T2>;
|
|
30
|
+
type APIError = { type: string; message: string; requestId?: string };
|
|
31
|
+
|
|
32
|
+
type APIResult<T> = Result<T, APIError>;
|
|
33
|
+
|
|
34
|
+
type Data<T> = {
|
|
35
|
+
data: T;
|
|
36
|
+
error?: never;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type Err<U> = {
|
|
40
|
+
data?: never;
|
|
41
|
+
error: U;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Result<T, U> = NonNullable<Data<T> | Err<U>>;
|
|
45
|
+
|
|
46
|
+
// Keel plugin types
|
|
47
|
+
|
|
48
|
+
interface GetGetParams {
|
|
49
|
+
refresh: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ListGetParams {
|
|
53
|
+
where: { updatedAt?: { after: Date } };
|
|
54
|
+
refresh?: () => void;
|
|
55
|
+
after?: string;
|
|
56
|
+
first?: number;
|
|
57
|
+
maxResults?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface KeelRealtimePlugin {
|
|
61
|
+
subscribe: (realtimeKey: string, refresh: () => void) => void;
|
|
62
|
+
setLatestChange: (realtimeKey: string, time: Date) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface SyncedKeelConfiguration {
|
|
66
|
+
client: {
|
|
67
|
+
auth: { refresh: () => Promise<boolean>; isAuthenticated: () => Promise<boolean> };
|
|
68
|
+
api: { queries: Record<string, (i: any) => Promise<any>> };
|
|
69
|
+
};
|
|
70
|
+
realtimePlugin?: KeelRealtimePlugin;
|
|
71
|
+
as?: CrudAsOption;
|
|
72
|
+
enabled?: boolean;
|
|
73
|
+
onError?: (params: APIResult<any>['error']) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface PageInfo {
|
|
77
|
+
count: number;
|
|
78
|
+
endCursor: string;
|
|
79
|
+
hasNextPage: boolean;
|
|
80
|
+
startCursor: string;
|
|
81
|
+
totalCount: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface SyncedKeelPropsMany<TRemote, TLocal, AOption extends CrudAsOption>
|
|
85
|
+
extends Omit<SyncedCrudPropsMany<TRemote, TLocal, AOption>, 'list'> {
|
|
86
|
+
list?: (params: ListGetParams) => Promise<CrudResult<APIResult<{ results: TRemote[]; pageInfo: any }>>>;
|
|
87
|
+
maxResults?: number;
|
|
88
|
+
get?: never;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface SyncedKeelPropsSingle<TRemote, TLocal> extends Omit<SyncedCrudPropsSingle<TRemote, TLocal>, 'get'> {
|
|
92
|
+
get?: (params: GetGetParams) => Promise<APIResult<TRemote>>;
|
|
93
|
+
|
|
94
|
+
maxResults?: never;
|
|
95
|
+
list?: never;
|
|
96
|
+
as?: never;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface SyncedKeelPropsBase<TRemote extends { id: string }, TLocal = TRemote>
|
|
100
|
+
extends Omit<SyncedCrudPropsBase<TRemote, TLocal>, 'create' | 'update' | 'delete'> {
|
|
101
|
+
create?: (i: NoInfer<Partial<TRemote>>) => Promise<APIResult<NoInfer<TRemote>>>;
|
|
102
|
+
update?: (params: { where: any; values?: Partial<TRemote> }) => Promise<APIResult<TRemote>>;
|
|
103
|
+
delete?: (params: { id: string }) => Promise<APIResult<string>>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let _client: SyncedKeelConfiguration['client'];
|
|
107
|
+
let _asOption: CrudAsOption;
|
|
108
|
+
let _realtimePlugin: KeelRealtimePlugin;
|
|
109
|
+
let _onError: (error: APIResult<any>['error']) => void;
|
|
110
|
+
const modifiedClients = new WeakSet<Record<string, any>>();
|
|
111
|
+
const isEnabled$ = observable(true);
|
|
112
|
+
|
|
113
|
+
async function ensureAuthToken() {
|
|
114
|
+
await when(isEnabled$.get());
|
|
115
|
+
let isAuthed = await _client.auth.isAuthenticated();
|
|
116
|
+
if (!isAuthed) {
|
|
117
|
+
isAuthed = await _client.auth.refresh();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return isAuthed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleApiError(error: APIError, retry?: () => any) {
|
|
124
|
+
if (error.type === 'unauthorized' || error.type === 'forbidden') {
|
|
125
|
+
console.warn('Keel token expired, refreshing...');
|
|
126
|
+
await ensureAuthToken();
|
|
127
|
+
// Retry
|
|
128
|
+
retry?.();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function convertObjectToCreate<TRemote, TLocal>(item: TRemote) {
|
|
133
|
+
const cloned = clone(item);
|
|
134
|
+
Object.keys(cloned).forEach((key) => {
|
|
135
|
+
if (key.endsWith('Id')) {
|
|
136
|
+
if (cloned[key]) {
|
|
137
|
+
cloned[key.slice(0, -2)] = { id: cloned[key] };
|
|
138
|
+
}
|
|
139
|
+
delete cloned[key];
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
delete cloned.createdAt;
|
|
143
|
+
delete cloned.updatedAt;
|
|
144
|
+
return cloned as unknown as TLocal;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function configureSyncedKeel({
|
|
148
|
+
realtimePlugin,
|
|
149
|
+
as: asOption,
|
|
150
|
+
client,
|
|
151
|
+
enabled,
|
|
152
|
+
onError,
|
|
153
|
+
}: SyncedKeelConfiguration) {
|
|
154
|
+
if (asOption) {
|
|
155
|
+
_asOption = asOption;
|
|
156
|
+
}
|
|
157
|
+
if (client) {
|
|
158
|
+
_client = client;
|
|
159
|
+
}
|
|
160
|
+
if (enabled !== undefined) {
|
|
161
|
+
isEnabled$.set(enabled);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (realtimePlugin) {
|
|
165
|
+
_realtimePlugin = realtimePlugin;
|
|
166
|
+
if (client && !modifiedClients.has(client)) {
|
|
167
|
+
modifiedClients.add(client);
|
|
168
|
+
const queries = client.api.queries;
|
|
169
|
+
Object.keys(queries).forEach((key) => {
|
|
170
|
+
const oldFn = queries[key];
|
|
171
|
+
queries[key] = (i) => {
|
|
172
|
+
const subscribe =
|
|
173
|
+
key.startsWith('list') &&
|
|
174
|
+
i.where &&
|
|
175
|
+
(({ refresh }: SyncedSubscribeParams) => {
|
|
176
|
+
const realtimeChild = Object.values(i.where)
|
|
177
|
+
.filter((value) => value && typeof value !== 'object')
|
|
178
|
+
.join('/');
|
|
179
|
+
|
|
180
|
+
if (realtimeChild) {
|
|
181
|
+
const realtimeKey = `${key}/${realtimeChild}`;
|
|
182
|
+
|
|
183
|
+
realtimePlugin.subscribe(realtimeKey, refresh);
|
|
184
|
+
return realtimeKey;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
return oldFn(i).then((ret) => {
|
|
188
|
+
if (subscribe) {
|
|
189
|
+
ret.subscribe = subscribe;
|
|
190
|
+
}
|
|
191
|
+
return ret;
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (onError) {
|
|
199
|
+
_onError = onError;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const NumPerPage = 200;
|
|
204
|
+
async function getAllPages<TRemote>(
|
|
205
|
+
listFn: (params: ListGetParams) => Promise<
|
|
206
|
+
APIResult<{
|
|
207
|
+
results: TRemote[];
|
|
208
|
+
pageInfo: any;
|
|
209
|
+
}>
|
|
210
|
+
>,
|
|
211
|
+
params: ListGetParams,
|
|
212
|
+
): Promise<{ results: TRemote[]; subscribe: (params: { refresh: () => void }) => string }> {
|
|
213
|
+
const allData: TRemote[] = [];
|
|
214
|
+
let pageInfo: PageInfo | undefined = undefined;
|
|
215
|
+
let subscribe_;
|
|
216
|
+
|
|
217
|
+
const { maxResults } = params;
|
|
218
|
+
|
|
219
|
+
do {
|
|
220
|
+
const first = maxResults ? Math.min(maxResults - allData.length, NumPerPage) : NumPerPage;
|
|
221
|
+
if (first < 1) {
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
const pageEndCursor = pageInfo?.endCursor;
|
|
225
|
+
const paramsWithCursor: ListGetParams = pageEndCursor
|
|
226
|
+
? { first, ...params, after: pageEndCursor }
|
|
227
|
+
: { first, ...params };
|
|
228
|
+
pageInfo = undefined;
|
|
229
|
+
const ret = await listFn(paramsWithCursor);
|
|
230
|
+
|
|
231
|
+
if (ret) {
|
|
232
|
+
// @ts-expect-error TODOKEEL
|
|
233
|
+
const { data, error, subscribe } = ret;
|
|
234
|
+
|
|
235
|
+
if (subscribe) {
|
|
236
|
+
subscribe_ = subscribe;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (error) {
|
|
240
|
+
await handleApiError(error);
|
|
241
|
+
throw new Error(error.message);
|
|
242
|
+
} else if (data) {
|
|
243
|
+
pageInfo = data.pageInfo as PageInfo;
|
|
244
|
+
allData.push(...data.results);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} while (pageInfo?.hasNextPage);
|
|
248
|
+
|
|
249
|
+
return { results: allData, subscribe: subscribe_ };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function syncedKeel<TRemote extends { id: string }, TLocal = TRemote>(
|
|
253
|
+
props: SyncedKeelPropsBase<TRemote, TLocal> & SyncedKeelPropsSingle<TRemote, TLocal>,
|
|
254
|
+
): SyncedCrudReturnType<TLocal, 'first'>;
|
|
255
|
+
export function syncedKeel<TRemote extends { id: string }, TLocal = TRemote, TOption extends CrudAsOption = 'object'>(
|
|
256
|
+
props: SyncedKeelPropsBase<TRemote, TLocal> & SyncedKeelPropsMany<TRemote, TLocal, TOption>,
|
|
257
|
+
): SyncedCrudReturnType<TLocal, Exclude<TOption, 'first'>>;
|
|
258
|
+
export function syncedKeel<TRemote extends { id: string }, TLocal = TRemote, TOption extends CrudAsOption = 'object'>(
|
|
259
|
+
props: SyncedKeelPropsBase<TRemote, TLocal> &
|
|
260
|
+
(SyncedKeelPropsSingle<TRemote, TLocal> | SyncedKeelPropsMany<TRemote, TLocal, TOption>),
|
|
261
|
+
): SyncedCrudReturnType<TLocal, TOption> {
|
|
262
|
+
const {
|
|
263
|
+
get: getParam,
|
|
264
|
+
list: listParam,
|
|
265
|
+
create: createParam,
|
|
266
|
+
update: updateParam,
|
|
267
|
+
delete: deleteParam,
|
|
268
|
+
maxResults,
|
|
269
|
+
initial,
|
|
270
|
+
waitFor,
|
|
271
|
+
waitForSet,
|
|
272
|
+
...rest
|
|
273
|
+
} = props;
|
|
274
|
+
|
|
275
|
+
let asType = props.as as TOption;
|
|
276
|
+
|
|
277
|
+
if (!asType) {
|
|
278
|
+
asType = (getParam ? 'first' : _asOption || undefined) as TOption;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let realtimeKeyList: string | undefined = undefined;
|
|
282
|
+
let realtimeKeyGet: string | undefined = undefined;
|
|
283
|
+
|
|
284
|
+
const fieldCreatedAt: KeelKey = 'createdAt';
|
|
285
|
+
const fieldUpdatedAt: KeelKey = 'updatedAt';
|
|
286
|
+
|
|
287
|
+
const list = listParam
|
|
288
|
+
? async (listParams: SyncedGetParams) => {
|
|
289
|
+
const { lastSync, refresh } = listParams;
|
|
290
|
+
const queryBySync = !!lastSync;
|
|
291
|
+
const isRawRequest = (listParam || getParam).toString().includes('rawRequest');
|
|
292
|
+
const where = queryBySync ? { updatedAt: { after: new Date(+new Date(lastSync) + 1) } } : {};
|
|
293
|
+
const params: ListGetParams = isRawRequest ? { where, maxResults } : { where, refresh, maxResults };
|
|
294
|
+
|
|
295
|
+
// TODO: Error?
|
|
296
|
+
const { results, subscribe } = await getAllPages(listParam, params);
|
|
297
|
+
if (!realtimeKeyList) {
|
|
298
|
+
realtimeKeyList = subscribe?.({ refresh });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return results;
|
|
302
|
+
}
|
|
303
|
+
: undefined;
|
|
304
|
+
|
|
305
|
+
const get = getParam
|
|
306
|
+
? async (getParams: SyncedGetParams) => {
|
|
307
|
+
const { refresh } = getParams;
|
|
308
|
+
// @ts-expect-error TODOKEEL
|
|
309
|
+
const { data, error, subscribe } = await getParam({ refresh });
|
|
310
|
+
if (!realtimeKeyGet) {
|
|
311
|
+
realtimeKeyGet = subscribe?.({ refresh });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (error) {
|
|
315
|
+
throw new Error(error.message);
|
|
316
|
+
} else {
|
|
317
|
+
return data as TRemote;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
: undefined;
|
|
321
|
+
|
|
322
|
+
const onSaved = (data: TLocal, input: TRemote, isCreate: boolean): Partial<TLocal> | void => {
|
|
323
|
+
if (data) {
|
|
324
|
+
const savedOut: Partial<TLocal> = {};
|
|
325
|
+
if (isCreate) {
|
|
326
|
+
// Update with any fields that were undefined when creating
|
|
327
|
+
Object.keys(data).forEach((key) => {
|
|
328
|
+
if (input[key as keyof TRemote] === undefined) {
|
|
329
|
+
savedOut[key as keyof TLocal] = data[key as keyof TLocal];
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
// Update with any fields ending in createdAt or updatedAt
|
|
334
|
+
Object.keys(data).forEach((key) => {
|
|
335
|
+
const k = key as keyof TLocal;
|
|
336
|
+
const keyLower = key.toLowerCase();
|
|
337
|
+
if ((keyLower.endsWith('createdat') || keyLower.endsWith('updatedat')) && data[k] instanceof Date) {
|
|
338
|
+
savedOut[k] = data[k];
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const updatedAt = data[fieldUpdatedAt as keyof TLocal] as Date;
|
|
344
|
+
|
|
345
|
+
if (updatedAt && _realtimePlugin) {
|
|
346
|
+
if (realtimeKeyGet) {
|
|
347
|
+
_realtimePlugin.setLatestChange(realtimeKeyGet, updatedAt);
|
|
348
|
+
}
|
|
349
|
+
if (realtimeKeyList) {
|
|
350
|
+
_realtimePlugin.setLatestChange(realtimeKeyList, updatedAt);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return savedOut;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const handleSetError = async (error: APIError, params: SyncedSetParams<TRemote>, isCreate: boolean) => {
|
|
359
|
+
const { retryNum, cancelRetry, update } = params;
|
|
360
|
+
|
|
361
|
+
if (
|
|
362
|
+
isCreate &&
|
|
363
|
+
(error.message as string)?.includes('for the unique') &&
|
|
364
|
+
(error.message as string)?.includes('must be unique')
|
|
365
|
+
) {
|
|
366
|
+
if (__DEV__) {
|
|
367
|
+
console.log('Creating duplicate data already saved, just ignore.');
|
|
368
|
+
}
|
|
369
|
+
// This has already been saved but didn't update pending changes, so just update with {} to clear the pending state
|
|
370
|
+
update({
|
|
371
|
+
value: {},
|
|
372
|
+
mode: 'assign',
|
|
373
|
+
});
|
|
374
|
+
} else if (error.type === 'bad_request') {
|
|
375
|
+
_onError?.(error);
|
|
376
|
+
|
|
377
|
+
if (retryNum > 4) {
|
|
378
|
+
cancelRetry();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
throw new Error(error.message);
|
|
382
|
+
} else {
|
|
383
|
+
await handleApiError(error);
|
|
384
|
+
|
|
385
|
+
throw new Error(error.message);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const create = createParam
|
|
390
|
+
? async (input: TRemote, params: SyncedSetParams<TRemote>) => {
|
|
391
|
+
const { data, error } = await createParam(convertObjectToCreate(input));
|
|
392
|
+
|
|
393
|
+
if (error) {
|
|
394
|
+
handleSetError(error, params, true);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return data;
|
|
398
|
+
}
|
|
399
|
+
: undefined;
|
|
400
|
+
|
|
401
|
+
const update = updateParam
|
|
402
|
+
? async (input: TRemote, params: SyncedSetParams<TRemote>) => {
|
|
403
|
+
const id = input.id;
|
|
404
|
+
const values = input as unknown as Partial<KeelObjectBase>;
|
|
405
|
+
delete values.id;
|
|
406
|
+
delete values.createdAt;
|
|
407
|
+
delete values.updatedAt;
|
|
408
|
+
|
|
409
|
+
const { data, error } = await updateParam({ where: { id }, values: input });
|
|
410
|
+
|
|
411
|
+
if (error) {
|
|
412
|
+
handleSetError(error, params, false);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return data;
|
|
416
|
+
}
|
|
417
|
+
: undefined;
|
|
418
|
+
const deleteFn = deleteParam
|
|
419
|
+
? async (input: TRemote & { id: string }, params: SyncedSetParams<TRemote>) => {
|
|
420
|
+
const { data, error } = await deleteParam({ id: input.id });
|
|
421
|
+
|
|
422
|
+
if (error) {
|
|
423
|
+
handleSetError(error, params, false);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return data;
|
|
427
|
+
}
|
|
428
|
+
: undefined;
|
|
429
|
+
|
|
430
|
+
return syncedCrud<TRemote, TLocal, TOption>({
|
|
431
|
+
...rest,
|
|
432
|
+
as: asType,
|
|
433
|
+
list,
|
|
434
|
+
create,
|
|
435
|
+
update,
|
|
436
|
+
delete: deleteFn,
|
|
437
|
+
retry: { infinite: true },
|
|
438
|
+
waitFor: () => isEnabled$.get() && (waitFor ? computeSelector(waitFor) : true),
|
|
439
|
+
waitForSet: () => isEnabled$.get() && (waitForSet ? computeSelector(waitForSet) : true),
|
|
440
|
+
onSaved,
|
|
441
|
+
fieldCreatedAt,
|
|
442
|
+
fieldUpdatedAt,
|
|
443
|
+
initial: initial as any, // This errors because of the get/list union type
|
|
444
|
+
// @ts-expect-error This errors because of the get/list union type
|
|
445
|
+
get: get as any,
|
|
446
|
+
}) as SyncedCrudReturnType<TLocal, TOption>;
|
|
447
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Observable,
|
|
3
|
+
SyncedOptionsGlobal,
|
|
4
|
+
computeSelector,
|
|
5
|
+
getNodeValue,
|
|
6
|
+
mergeIntoObservable,
|
|
7
|
+
observable,
|
|
8
|
+
symbolDelete,
|
|
9
|
+
type SyncedGetParams,
|
|
10
|
+
type SyncedSubscribeParams,
|
|
11
|
+
} from '@legendapp/state';
|
|
12
|
+
import {
|
|
13
|
+
CrudAsOption,
|
|
14
|
+
SyncedCrudPropsBase,
|
|
15
|
+
SyncedCrudPropsMany,
|
|
16
|
+
SyncedCrudReturnType,
|
|
17
|
+
syncedCrud,
|
|
18
|
+
} from '@legendapp/state/sync-plugins/crud';
|
|
19
|
+
import type { PostgrestFilterBuilder } from '@supabase/postgrest-js';
|
|
20
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
21
|
+
|
|
22
|
+
// Unused types but maybe useful in the future so keeping them for now
|
|
23
|
+
// type DatabaseOf<TClient extends SupabaseClient> = TClient extends SupabaseClient<infer TDB> ? TDB : never;
|
|
24
|
+
// type SchemaNameOf<TClient extends SupabaseClient> = TClient extends SupabaseClient<infer _, infer TSchemaName>
|
|
25
|
+
// ? TSchemaName
|
|
26
|
+
// : never;
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
29
|
+
type SchemaOf<TClient extends SupabaseClient> = TClient extends SupabaseClient<infer _, infer __, infer TSchema>
|
|
30
|
+
? TSchema
|
|
31
|
+
: never;
|
|
32
|
+
type TableOf<TClient extends SupabaseClient> = SchemaOf<TClient>['Tables'];
|
|
33
|
+
type CollectionOf<TClient extends SupabaseClient> = keyof TableOf<TClient>;
|
|
34
|
+
type RowOf<
|
|
35
|
+
TClient extends SupabaseClient,
|
|
36
|
+
TCollection extends CollectionOf<TClient>,
|
|
37
|
+
> = TableOf<TClient>[TCollection]['Row'];
|
|
38
|
+
|
|
39
|
+
export type SyncedSupabaseConfig<T extends { id: string }> = Omit<
|
|
40
|
+
SyncedCrudPropsBase<T>,
|
|
41
|
+
'create' | 'update' | 'delete' | 'onSaved' | 'transform' | 'fieldCreatedAt' | 'updatePartial' | 'subscribe'
|
|
42
|
+
>;
|
|
43
|
+
|
|
44
|
+
export interface SyncedSupabaseGlobalConfig extends Omit<SyncedSupabaseConfig<{ id: string }>, 'persist'> {
|
|
45
|
+
persist?: SyncedOptionsGlobal;
|
|
46
|
+
enabled?: Observable<boolean>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SyncedSupabaseProps<
|
|
50
|
+
TClient extends SupabaseClient,
|
|
51
|
+
TCollection extends CollectionOf<TClient>,
|
|
52
|
+
TOption extends CrudAsOption = 'object',
|
|
53
|
+
> extends SyncedSupabaseConfig<RowOf<TClient, TCollection>>,
|
|
54
|
+
SyncedCrudPropsMany<RowOf<TClient, TCollection>, RowOf<TClient, TCollection>, TOption> {
|
|
55
|
+
supabase: TClient;
|
|
56
|
+
collection: TCollection;
|
|
57
|
+
filter?: (
|
|
58
|
+
select: PostgrestFilterBuilder<
|
|
59
|
+
SchemaOf<TClient>,
|
|
60
|
+
RowOf<TClient, TCollection>,
|
|
61
|
+
RowOf<TClient, TCollection>[],
|
|
62
|
+
TCollection,
|
|
63
|
+
[]
|
|
64
|
+
>,
|
|
65
|
+
params: SyncedGetParams,
|
|
66
|
+
) => PostgrestFilterBuilder<
|
|
67
|
+
SchemaOf<TClient>,
|
|
68
|
+
RowOf<TClient, TCollection>,
|
|
69
|
+
RowOf<TClient, TCollection>[],
|
|
70
|
+
TCollection,
|
|
71
|
+
[]
|
|
72
|
+
>;
|
|
73
|
+
actions?: ('create' | 'read' | 'update' | 'delete')[];
|
|
74
|
+
realtime?: { schema?: string; filter?: string };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let channelNum = 1;
|
|
78
|
+
const supabaseConfig: SyncedSupabaseGlobalConfig = {};
|
|
79
|
+
const isEnabled$ = observable(true);
|
|
80
|
+
|
|
81
|
+
export function configureSyncedSupabase(config: SyncedSupabaseGlobalConfig) {
|
|
82
|
+
const { enabled, ...rest } = config;
|
|
83
|
+
if (enabled !== undefined) {
|
|
84
|
+
isEnabled$.set(enabled);
|
|
85
|
+
}
|
|
86
|
+
Object.assign(supabaseConfig, rest);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function syncedSupabase<
|
|
90
|
+
Client extends SupabaseClient,
|
|
91
|
+
Collection extends CollectionOf<Client> & string,
|
|
92
|
+
AsOption extends CrudAsOption = 'object',
|
|
93
|
+
>(props: SyncedSupabaseProps<Client, Collection, AsOption>): SyncedCrudReturnType<RowOf<Client, Collection>, AsOption> {
|
|
94
|
+
mergeIntoObservable(props, supabaseConfig);
|
|
95
|
+
const {
|
|
96
|
+
supabase: client,
|
|
97
|
+
collection,
|
|
98
|
+
filter,
|
|
99
|
+
actions,
|
|
100
|
+
fieldUpdatedAt,
|
|
101
|
+
realtime,
|
|
102
|
+
changesSince,
|
|
103
|
+
waitFor,
|
|
104
|
+
waitForSet,
|
|
105
|
+
...rest
|
|
106
|
+
} = props;
|
|
107
|
+
const list =
|
|
108
|
+
!actions || actions.includes('read')
|
|
109
|
+
? async (params: SyncedGetParams) => {
|
|
110
|
+
const { lastSync } = params;
|
|
111
|
+
let select = client.from(collection).select();
|
|
112
|
+
if (changesSince === 'last-sync') {
|
|
113
|
+
select = select.neq('deleted', true);
|
|
114
|
+
if (lastSync) {
|
|
115
|
+
const date = new Date(lastSync).toISOString();
|
|
116
|
+
select = select.or(
|
|
117
|
+
`created_at.gt.${date}${fieldUpdatedAt ? `,${fieldUpdatedAt}.gt.${date}` : ''}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (filter) {
|
|
122
|
+
select = filter(select, params);
|
|
123
|
+
}
|
|
124
|
+
const { data, error } = await select;
|
|
125
|
+
if (error) {
|
|
126
|
+
throw new Error(error?.message);
|
|
127
|
+
}
|
|
128
|
+
return (data! || []) as RowOf<Client, Collection>[];
|
|
129
|
+
}
|
|
130
|
+
: undefined;
|
|
131
|
+
|
|
132
|
+
const upsert = async (input: RowOf<Client, Collection>) => {
|
|
133
|
+
const res = await client.from(collection).upsert(input).select();
|
|
134
|
+
const { data, error } = res;
|
|
135
|
+
if (data) {
|
|
136
|
+
const created = data[0];
|
|
137
|
+
return created;
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error(error?.message);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const create = !actions || actions.includes('create') ? upsert : undefined;
|
|
143
|
+
const update = !actions || actions.includes('update') ? upsert : undefined;
|
|
144
|
+
const deleteFn =
|
|
145
|
+
!actions || actions.includes('delete')
|
|
146
|
+
? async (input: RowOf<Client, Collection>) => {
|
|
147
|
+
const id = input.id;
|
|
148
|
+
const from = client.from(collection);
|
|
149
|
+
const res = await (changesSince === 'last-sync' ? from.update({ deleted: true }) : from.delete())
|
|
150
|
+
.eq('id', id)
|
|
151
|
+
.select();
|
|
152
|
+
const { data, error } = res;
|
|
153
|
+
if (data) {
|
|
154
|
+
const created = data[0];
|
|
155
|
+
return created;
|
|
156
|
+
} else {
|
|
157
|
+
throw new Error(error?.message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
: undefined;
|
|
161
|
+
const subscribe = realtime
|
|
162
|
+
? ({ node, update }: SyncedSubscribeParams) => {
|
|
163
|
+
const { filter, schema } = realtime;
|
|
164
|
+
const channel = client
|
|
165
|
+
.channel(`LS_${node.key || ''}${channelNum++}`)
|
|
166
|
+
.on(
|
|
167
|
+
'postgres_changes',
|
|
168
|
+
{
|
|
169
|
+
event: '*',
|
|
170
|
+
table: collection,
|
|
171
|
+
schema: schema || 'public',
|
|
172
|
+
filter: filter || undefined,
|
|
173
|
+
},
|
|
174
|
+
(payload) => {
|
|
175
|
+
const { eventType, new: value, old } = payload;
|
|
176
|
+
if (eventType === 'INSERT' || eventType === 'UPDATE') {
|
|
177
|
+
const cur = getNodeValue(node)?.[value.id];
|
|
178
|
+
const curDateStr = cur && (cur.updated_at || cur.created_at);
|
|
179
|
+
const valueDateStr = value.updated_at || value.created_at;
|
|
180
|
+
const valueDate = +new Date(valueDateStr);
|
|
181
|
+
// Check if new or newer than last seen locally
|
|
182
|
+
if (valueDateStr && (!curDateStr || valueDate > +new Date(curDateStr))) {
|
|
183
|
+
// Update local with the new value
|
|
184
|
+
update({
|
|
185
|
+
value: { [value.id]: value },
|
|
186
|
+
lastSync: valueDate,
|
|
187
|
+
mode: 'merge',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
} else if (eventType === 'DELETE') {
|
|
191
|
+
const { id } = old;
|
|
192
|
+
update({
|
|
193
|
+
value: { [id]: symbolDelete },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
.subscribe();
|
|
199
|
+
|
|
200
|
+
return channel.unsubscribe;
|
|
201
|
+
}
|
|
202
|
+
: undefined;
|
|
203
|
+
|
|
204
|
+
return syncedCrud<RowOf<Client, Collection>, RowOf<Client, Collection>, AsOption>({
|
|
205
|
+
...rest,
|
|
206
|
+
list,
|
|
207
|
+
create,
|
|
208
|
+
update,
|
|
209
|
+
delete: deleteFn,
|
|
210
|
+
onSaved: (saved) => {
|
|
211
|
+
// Update the local timestamps with server response
|
|
212
|
+
return {
|
|
213
|
+
id: saved.id,
|
|
214
|
+
created_at: saved.created_at,
|
|
215
|
+
updated_at: saved.updated_at,
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
subscribe,
|
|
219
|
+
fieldCreatedAt: 'created_at',
|
|
220
|
+
fieldUpdatedAt,
|
|
221
|
+
updatePartial: true,
|
|
222
|
+
waitFor: () => isEnabled$.get() && (waitFor ? computeSelector(waitFor) : true),
|
|
223
|
+
waitForSet: () => isEnabled$.get() && (waitForSet ? computeSelector(waitForSet) : true),
|
|
224
|
+
});
|
|
225
|
+
}
|