rastack 0.0.16
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/CHANGELOG.md +43013 -0
- package/dist/.prettierrc +7 -0
- package/dist/compile/analyze.d.ts +12 -0
- package/dist/compile/analyze.js +233 -0
- package/dist/compile/index.d.ts +19 -0
- package/dist/compile/index.js +107 -0
- package/dist/compile/manifest.d.ts +4 -0
- package/dist/compile/manifest.js +12 -0
- package/dist/compile/model.d.ts +54 -0
- package/dist/compile/model.js +7 -0
- package/dist/compile/openapi.d.ts +11 -0
- package/dist/compile/openapi.js +233 -0
- package/dist/compile/program.d.ts +10 -0
- package/dist/compile/program.js +61 -0
- package/dist/csv-schema.d.ts +28 -0
- package/dist/csv-schema.js +162 -0
- package/dist/define/index.d.ts +117 -0
- package/dist/define/index.js +74 -0
- package/dist/entity-generation/delete-method.d.ts +1 -0
- package/dist/entity-generation/delete-method.js +31 -0
- package/dist/entity-generation/form.d.ts +13 -0
- package/dist/entity-generation/form.js +136 -0
- package/dist/entity-generation/get-method.d.ts +1 -0
- package/dist/entity-generation/get-method.js +67 -0
- package/dist/entity-generation/imports.d.ts +5 -0
- package/dist/entity-generation/imports.js +91 -0
- package/dist/entity-generation/sync-method.d.ts +30 -0
- package/dist/entity-generation/sync-method.js +158 -0
- package/dist/entity-generation/update-method.d.ts +1 -0
- package/dist/entity-generation/update-method.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +53 -0
- package/dist/rad-compile.d.ts +11 -0
- package/dist/rad-compile.js +82 -0
- package/dist/rad-wasm-build.d.ts +14 -0
- package/dist/rad-wasm-build.js +74 -0
- package/dist/rad.d.ts +17 -0
- package/dist/rad.js +88 -0
- package/dist/scan.d.ts +12 -0
- package/dist/scan.js +79 -0
- package/dist/schema/camel-to-pastel.d.ts +1 -0
- package/dist/schema/camel-to-pastel.js +7 -0
- package/dist/schema/capitalise-first-letter.d.ts +1 -0
- package/dist/schema/capitalise-first-letter.js +7 -0
- package/dist/schema/extract-response.d.ts +2 -0
- package/dist/schema/extract-response.js +30 -0
- package/dist/schema/fetch-schema.d.ts +7 -0
- package/dist/schema/fetch-schema.js +23 -0
- package/dist/schema/remove-non-model-paths.d.ts +2 -0
- package/dist/schema/remove-non-model-paths.js +15 -0
- package/dist/schema/to-camel-case.d.ts +1 -0
- package/dist/schema/to-camel-case.js +11 -0
- package/dist/schema/to-pastel-case.d.ts +1 -0
- package/dist/schema/to-pastel-case.js +10 -0
- package/dist/schema-convert.d.ts +1 -0
- package/dist/schema-convert.js +63 -0
- package/dist/schema-entities.d.ts +14 -0
- package/dist/schema-entities.js +293 -0
- package/dist/schema-fetch.d.ts +1 -0
- package/dist/schema-fetch.js +81 -0
- package/dist/schema-full.d.ts +1 -0
- package/dist/schema-full.js +45 -0
- package/dist/schema-index.d.ts +1 -0
- package/dist/schema-index.js +34 -0
- package/dist/schema-params.d.ts +1 -0
- package/dist/schema-params.js +100 -0
- package/dist/seed.d.ts +17 -0
- package/dist/seed.js +252 -0
- package/hooks/form/form.ts +207 -0
- package/hooks/form/index.ts +8 -0
- package/hooks/form/interfaces.ts +217 -0
- package/hooks/form/structure.ts +39 -0
- package/hooks/form/validate-schema.ts +49 -0
- package/hooks/index.ts +3 -0
- package/hooks/query/api.ts +42 -0
- package/hooks/query/delete.ts +45 -0
- package/hooks/query/fetch.ts +48 -0
- package/hooks/query/index.ts +21 -0
- package/hooks/query/interfaces.ts +111 -0
- package/hooks/query/list.ts +286 -0
- package/hooks/query/update.ts +88 -0
- package/hooks/query/url.ts +31 -0
- package/hooks/real-time/index.ts +1 -0
- package/hooks/real-time/pusher.ts +43 -0
- package/jest.config.cjs +6 -0
- package/package.json +57 -0
- package/provider/index.ts +13 -0
- package/provider/provider.tsx +187 -0
- package/provider/types.ts +114 -0
- package/provider/warehouse.ts +167 -0
- package/provider/wasm.ts +124 -0
- package/runtime.ts +3 -0
- package/src/.prettierrc +7 -0
- package/src/compile/analyze.ts +224 -0
- package/src/compile/index.ts +86 -0
- package/src/compile/manifest.ts +10 -0
- package/src/compile/model.ts +69 -0
- package/src/compile/openapi.ts +266 -0
- package/src/compile/program.ts +40 -0
- package/src/csv-schema.ts +187 -0
- package/src/define/index.ts +162 -0
- package/src/entity-generation/delete-method.ts +37 -0
- package/src/entity-generation/form.ts +169 -0
- package/src/entity-generation/get-method.ts +80 -0
- package/src/entity-generation/imports.ts +110 -0
- package/src/entity-generation/sync-method.ts +222 -0
- package/src/entity-generation/update-method.ts +38 -0
- package/src/index.ts +77 -0
- package/src/rad-compile.ts +88 -0
- package/src/rad-wasm-build.ts +102 -0
- package/src/rad.ts +101 -0
- package/src/scan.ts +89 -0
- package/src/schema/camel-to-pastel.ts +3 -0
- package/src/schema/capitalise-first-letter.ts +3 -0
- package/src/schema/extract-response.ts +38 -0
- package/src/schema/fetch-schema.ts +12 -0
- package/src/schema/remove-non-model-paths.ts +13 -0
- package/src/schema/to-camel-case.ts +7 -0
- package/src/schema/to-pastel-case.ts +6 -0
- package/src/schema-convert.ts +61 -0
- package/src/schema-entities.ts +401 -0
- package/src/schema-fetch.ts +82 -0
- package/src/schema-full.ts +38 -0
- package/src/schema-index.ts +29 -0
- package/src/schema-params.ts +116 -0
- package/src/seed.ts +297 -0
- package/sync/engine.ts +392 -0
- package/sync/hooks.ts +450 -0
- package/sync/index.ts +8 -0
- package/sync/persistence.ts +237 -0
- package/sync/provider.tsx +91 -0
- package/sync/registry.ts +36 -0
- package/sync/store.ts +126 -0
- package/sync/transactions.ts +300 -0
- package/sync/types.ts +94 -0
- package/test/compile.spec.ts +192 -0
- package/test/csv-schema.spec.ts +143 -0
- package/test/schema-entities.spec.ts +688 -0
- package/tsconfig.json +24 -0
- package/types.ts +19 -0
package/sync/hooks.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { API } from "../hooks/query/api";
|
|
9
|
+
import { useSyncEngine, useSyncEngineOptional } from "./provider";
|
|
10
|
+
import { tempId, uuid4 } from "./transactions";
|
|
11
|
+
import {
|
|
12
|
+
EngineStatus,
|
|
13
|
+
EntityDescriptor,
|
|
14
|
+
entityKey,
|
|
15
|
+
TransactionOp,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
export function useEngineStatus(): EngineStatus {
|
|
19
|
+
const engine = useSyncEngine();
|
|
20
|
+
return useSyncExternalStore(
|
|
21
|
+
(listener) => engine.subscribeStatus(listener),
|
|
22
|
+
() => engine.getStatus(),
|
|
23
|
+
() => engine.getStatus(),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EntityListOptions<TRow> {
|
|
28
|
+
/** Local predicate applied to every row. */
|
|
29
|
+
filter?: (row: TRow) => boolean;
|
|
30
|
+
/** Local comparator. */
|
|
31
|
+
sort?: (a: TRow, b: TRow) => number;
|
|
32
|
+
/** Local search text, matched with searchFunction (or against searchFields). */
|
|
33
|
+
search?: string;
|
|
34
|
+
searchFields?: (keyof TRow)[];
|
|
35
|
+
searchFunction?: (search: string, row: TRow) => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EntityListResult<TRow> {
|
|
39
|
+
data: TRow[] | undefined;
|
|
40
|
+
isLoading: boolean;
|
|
41
|
+
isReady: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* List read for a sync-mode entity — served from the local store: instant
|
|
46
|
+
* after bootstrap, reactive to every delta and optimistic write thereafter.
|
|
47
|
+
* Filtering, sorting and search all run locally.
|
|
48
|
+
*/
|
|
49
|
+
export function useEntityList<TRow = any>(
|
|
50
|
+
entity: EntityDescriptor<TRow>,
|
|
51
|
+
options?: EntityListOptions<TRow>,
|
|
52
|
+
): EntityListResult<TRow> {
|
|
53
|
+
const engine = useSyncEngine();
|
|
54
|
+
const key = entityKey(entity);
|
|
55
|
+
|
|
56
|
+
// Lazy entities partial-bootstrap the first time a component asks.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
void engine.ensureEntity(key).catch(() => {});
|
|
59
|
+
}, [engine, key]);
|
|
60
|
+
|
|
61
|
+
const rows = useSyncExternalStore(
|
|
62
|
+
(listener) => engine.store.subscribe(key, listener),
|
|
63
|
+
() => engine.store.getList(key),
|
|
64
|
+
() => engine.store.getList(key),
|
|
65
|
+
) as TRow[];
|
|
66
|
+
const isReady = useSyncExternalStore(
|
|
67
|
+
(listener) => engine.store.subscribe(key, listener),
|
|
68
|
+
() => engine.store.isReady(key),
|
|
69
|
+
() => engine.store.isReady(key),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const { filter, sort, search, searchFields, searchFunction } = options || {};
|
|
73
|
+
const data = useMemo(() => {
|
|
74
|
+
let result = rows;
|
|
75
|
+
if (filter) {
|
|
76
|
+
result = result.filter(filter);
|
|
77
|
+
}
|
|
78
|
+
if (search) {
|
|
79
|
+
const lowered = search.toLowerCase();
|
|
80
|
+
const matches =
|
|
81
|
+
searchFunction ||
|
|
82
|
+
((text: string, row: TRow) => {
|
|
83
|
+
const fields = searchFields || (Object.keys(row as any) as any[]);
|
|
84
|
+
return fields.some((field: any) =>
|
|
85
|
+
String((row as any)[field] ?? "")
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.includes(text.toLowerCase()),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
result = result.filter((row) => matches(lowered, row));
|
|
91
|
+
}
|
|
92
|
+
if (sort) {
|
|
93
|
+
result = [...result].sort(sort);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}, [rows, filter, sort, search, searchFields, searchFunction]);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
data: isReady ? data : undefined,
|
|
100
|
+
isLoading: !isReady,
|
|
101
|
+
isReady,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface EntityRecordResult<TRow> {
|
|
106
|
+
data: TRow | undefined;
|
|
107
|
+
isLoading: boolean;
|
|
108
|
+
isReady: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Single-record read for a sync-mode entity, served from the local store. */
|
|
112
|
+
export function useEntityRecord<TRow = any>(
|
|
113
|
+
entity: EntityDescriptor<TRow>,
|
|
114
|
+
id: string | number | undefined,
|
|
115
|
+
): EntityRecordResult<TRow> {
|
|
116
|
+
const engine = useSyncEngine();
|
|
117
|
+
const key = entityKey(entity);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
void engine.ensureEntity(key).catch(() => {});
|
|
121
|
+
}, [engine, key]);
|
|
122
|
+
|
|
123
|
+
const resolvedId =
|
|
124
|
+
id === undefined ? undefined : engine.queue.resolveAlias(String(id));
|
|
125
|
+
|
|
126
|
+
const data = useSyncExternalStore(
|
|
127
|
+
(listener) => engine.store.subscribe(key, listener),
|
|
128
|
+
() =>
|
|
129
|
+
resolvedId === undefined
|
|
130
|
+
? undefined
|
|
131
|
+
: (engine.store.get(key, resolvedId) as TRow | undefined),
|
|
132
|
+
() =>
|
|
133
|
+
resolvedId === undefined
|
|
134
|
+
? undefined
|
|
135
|
+
: (engine.store.get(key, resolvedId) as TRow | undefined),
|
|
136
|
+
);
|
|
137
|
+
const isReady = useSyncExternalStore(
|
|
138
|
+
(listener) => engine.store.subscribe(key, listener),
|
|
139
|
+
() => engine.store.isReady(key),
|
|
140
|
+
() => engine.store.isReady(key),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return { data, isLoading: !isReady && data === undefined, isReady };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface MutationState<TResult> {
|
|
147
|
+
status: "idle" | "loading" | "success" | "error";
|
|
148
|
+
data: TResult | undefined;
|
|
149
|
+
error: unknown;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface EntityMutationResult<TVariables, TResult> {
|
|
153
|
+
/**
|
|
154
|
+
* Fire-and-forget: applies locally at once and syncs in the background.
|
|
155
|
+
* This is the default write mode.
|
|
156
|
+
*/
|
|
157
|
+
mutate: (
|
|
158
|
+
variables: TVariables,
|
|
159
|
+
callbacks?: {
|
|
160
|
+
onSuccess?: (data: TResult) => void;
|
|
161
|
+
onError?: (error: unknown) => void;
|
|
162
|
+
},
|
|
163
|
+
) => void;
|
|
164
|
+
/** Awaits the server acknowledgement (the local apply already happened). */
|
|
165
|
+
mutateAsync: (variables: TVariables) => Promise<TResult>;
|
|
166
|
+
isLoading: boolean;
|
|
167
|
+
isError: boolean;
|
|
168
|
+
isSuccess: boolean;
|
|
169
|
+
data: TResult | undefined;
|
|
170
|
+
error: unknown;
|
|
171
|
+
status: MutationState<TResult>["status"];
|
|
172
|
+
reset: () => void;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function useMutationState<TResult>() {
|
|
176
|
+
const [state, setState] = useState<MutationState<TResult>>({
|
|
177
|
+
status: "idle",
|
|
178
|
+
data: undefined,
|
|
179
|
+
error: undefined,
|
|
180
|
+
});
|
|
181
|
+
const reset = useCallback(
|
|
182
|
+
() => setState({ status: "idle", data: undefined, error: undefined }),
|
|
183
|
+
[],
|
|
184
|
+
);
|
|
185
|
+
return { state, setState, reset };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface WriteVariables<TParams, TPayload> {
|
|
189
|
+
params: TParams;
|
|
190
|
+
payload: TPayload;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface EntityMutationConfig<TParams> {
|
|
194
|
+
op: Exclude<TransactionOp, "delete">;
|
|
195
|
+
method: "post" | "put" | "patch";
|
|
196
|
+
url: (params: TParams) => string;
|
|
197
|
+
/** For updates: extract the row id from the call params. */
|
|
198
|
+
id?: (params: TParams) => string | number | undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Optimistic write for a sync-mode entity. The store updates immediately,
|
|
203
|
+
* the transaction persists locally (offline writes replay on reconnect),
|
|
204
|
+
* and the change is sent through the entity's normal REST endpoint.
|
|
205
|
+
* Server rejection rolls back and reports through the error state.
|
|
206
|
+
*/
|
|
207
|
+
export function useEntityMutation<TParams, TPayload, TResult = any>(
|
|
208
|
+
entity: EntityDescriptor,
|
|
209
|
+
config: EntityMutationConfig<TParams>,
|
|
210
|
+
): EntityMutationResult<WriteVariables<TParams, TPayload>, TResult> {
|
|
211
|
+
const engine = useSyncEngine();
|
|
212
|
+
const key = entityKey(entity);
|
|
213
|
+
const { state, setState, reset } = useMutationState<TResult>();
|
|
214
|
+
|
|
215
|
+
const mutateAsync = useCallback(
|
|
216
|
+
async (variables: WriteVariables<TParams, TPayload>): Promise<TResult> => {
|
|
217
|
+
const idField = entity.idField || "id";
|
|
218
|
+
const payload = (variables.payload || {}) as Record<string, any>;
|
|
219
|
+
let id: string;
|
|
220
|
+
let original: Record<string, any> | null = null;
|
|
221
|
+
|
|
222
|
+
if (config.op === "create") {
|
|
223
|
+
if (entity.clientIds) {
|
|
224
|
+
// Client-generated UUID pk: the id is real from the start and is
|
|
225
|
+
// sent to the server with the payload.
|
|
226
|
+
id = (payload[idField] as string) || uuid4();
|
|
227
|
+
payload[idField] = id;
|
|
228
|
+
} else {
|
|
229
|
+
id = tempId();
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
const rawId =
|
|
233
|
+
config.id?.(variables.params) ??
|
|
234
|
+
(payload as any)[idField] ??
|
|
235
|
+
(variables.params as any)?.[idField];
|
|
236
|
+
if (rawId === undefined || rawId === null) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Cannot resolve the ${idField} of the ${key} row being updated`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
id = engine.queue.resolveAlias(String(rawId));
|
|
242
|
+
const current = engine.store.get(key, id);
|
|
243
|
+
if (current) {
|
|
244
|
+
original = {};
|
|
245
|
+
Object.keys(payload).forEach((field) => {
|
|
246
|
+
original![field] = current[field];
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
setState({ status: "loading", data: undefined, error: undefined });
|
|
252
|
+
try {
|
|
253
|
+
const data = (await engine.queue.enqueue({
|
|
254
|
+
entity: key,
|
|
255
|
+
op: config.op,
|
|
256
|
+
id,
|
|
257
|
+
method: config.method,
|
|
258
|
+
url: config.url(variables.params),
|
|
259
|
+
payload,
|
|
260
|
+
original,
|
|
261
|
+
})) as TResult;
|
|
262
|
+
setState({ status: "success", data, error: undefined });
|
|
263
|
+
return data;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
setState({ status: "error", data: undefined, error });
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
[engine, key, config.op, config.method], // eslint-disable-line react-hooks/exhaustive-deps
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const mutate = useCallback(
|
|
273
|
+
(
|
|
274
|
+
variables: WriteVariables<TParams, TPayload>,
|
|
275
|
+
callbacks?: {
|
|
276
|
+
onSuccess?: (data: TResult) => void;
|
|
277
|
+
onError?: (error: unknown) => void;
|
|
278
|
+
},
|
|
279
|
+
) => {
|
|
280
|
+
mutateAsync(variables)
|
|
281
|
+
.then((data) => callbacks?.onSuccess?.(data))
|
|
282
|
+
.catch((error) => {
|
|
283
|
+
if (callbacks?.onError) {
|
|
284
|
+
callbacks.onError(error);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
[mutateAsync],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
mutate,
|
|
293
|
+
mutateAsync,
|
|
294
|
+
isLoading: state.status === "loading",
|
|
295
|
+
isError: state.status === "error",
|
|
296
|
+
isSuccess: state.status === "success",
|
|
297
|
+
data: state.data,
|
|
298
|
+
error: state.error,
|
|
299
|
+
status: state.status,
|
|
300
|
+
reset,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface EntityDeleteConfig {
|
|
305
|
+
url: (id: string) => string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Optimistic delete for a sync-mode entity (server-side it's a soft delete). */
|
|
309
|
+
export function useEntityDelete<TResult = any>(
|
|
310
|
+
entity: EntityDescriptor,
|
|
311
|
+
config: EntityDeleteConfig,
|
|
312
|
+
): EntityMutationResult<{ id?: string }, TResult> {
|
|
313
|
+
const engine = useSyncEngine();
|
|
314
|
+
const key = entityKey(entity);
|
|
315
|
+
const { state, setState, reset } = useMutationState<TResult>();
|
|
316
|
+
|
|
317
|
+
const mutateAsync = useCallback(
|
|
318
|
+
async (variables: { id?: string }): Promise<TResult> => {
|
|
319
|
+
if (variables.id === undefined) {
|
|
320
|
+
throw new Error("ID is required for delete operation");
|
|
321
|
+
}
|
|
322
|
+
const id = engine.queue.resolveAlias(String(variables.id));
|
|
323
|
+
const original = engine.store.get(key, id) || null;
|
|
324
|
+
setState({ status: "loading", data: undefined, error: undefined });
|
|
325
|
+
try {
|
|
326
|
+
const data = (await engine.queue.enqueue({
|
|
327
|
+
entity: key,
|
|
328
|
+
op: "delete",
|
|
329
|
+
id,
|
|
330
|
+
method: "delete",
|
|
331
|
+
url: config.url(id),
|
|
332
|
+
payload: null,
|
|
333
|
+
original,
|
|
334
|
+
})) as TResult;
|
|
335
|
+
setState({ status: "success", data, error: undefined });
|
|
336
|
+
return data;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
setState({ status: "error", data: undefined, error });
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
[engine, key], // eslint-disable-line react-hooks/exhaustive-deps
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const mutate = useCallback(
|
|
346
|
+
(
|
|
347
|
+
variables: { id?: string },
|
|
348
|
+
callbacks?: {
|
|
349
|
+
onSuccess?: (data: TResult) => void;
|
|
350
|
+
onError?: (error: unknown) => void;
|
|
351
|
+
},
|
|
352
|
+
) => {
|
|
353
|
+
mutateAsync(variables)
|
|
354
|
+
.then((data) => callbacks?.onSuccess?.(data))
|
|
355
|
+
.catch((error) => {
|
|
356
|
+
if (callbacks?.onError) {
|
|
357
|
+
callbacks.onError(error);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
[mutateAsync],
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
mutate,
|
|
366
|
+
mutateAsync,
|
|
367
|
+
isLoading: state.status === "loading",
|
|
368
|
+
isError: state.status === "error",
|
|
369
|
+
isSuccess: state.status === "success",
|
|
370
|
+
data: state.data,
|
|
371
|
+
error: state.error,
|
|
372
|
+
status: state.status,
|
|
373
|
+
reset,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface ConfirmedMutationConfig<TParams> {
|
|
378
|
+
method: "post" | "put" | "patch" | "delete";
|
|
379
|
+
url: (params: TParams) => string;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* A confirmed ("sync") operation — e.g. a password change. No optimistic
|
|
384
|
+
* apply, no queue, online-only: resolves only when the server has accepted
|
|
385
|
+
* the write. Anything it changed flows back through the changefeed like any
|
|
386
|
+
* other write.
|
|
387
|
+
*/
|
|
388
|
+
export function useConfirmedMutation<TParams, TPayload, TResult = any>(
|
|
389
|
+
config: ConfirmedMutationConfig<TParams>,
|
|
390
|
+
): EntityMutationResult<WriteVariables<TParams, TPayload>, TResult> {
|
|
391
|
+
const engine = useSyncEngineOptional();
|
|
392
|
+
const { state, setState, reset } = useMutationState<TResult>();
|
|
393
|
+
|
|
394
|
+
const mutateAsync = useCallback(
|
|
395
|
+
async (variables: WriteVariables<TParams, TPayload>): Promise<TResult> => {
|
|
396
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
397
|
+
throw new Error("This operation requires a network connection");
|
|
398
|
+
}
|
|
399
|
+
const api = engine?.api || API;
|
|
400
|
+
const url = config.url(variables.params);
|
|
401
|
+
setState({ status: "loading", data: undefined, error: undefined });
|
|
402
|
+
try {
|
|
403
|
+
const response =
|
|
404
|
+
config.method === "delete"
|
|
405
|
+
? await api.delete<TResult>(url)
|
|
406
|
+
: await api[config.method]<TResult>(url, variables.payload);
|
|
407
|
+
setState({ status: "success", data: response.data, error: undefined });
|
|
408
|
+
// Whatever the operation changed will arrive via the changefeed;
|
|
409
|
+
// kick the engine so it lands promptly.
|
|
410
|
+
engine?.wake();
|
|
411
|
+
return response.data;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
setState({ status: "error", data: undefined, error });
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
[engine, config.method], // eslint-disable-line react-hooks/exhaustive-deps
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const mutate = useCallback(
|
|
421
|
+
(
|
|
422
|
+
variables: WriteVariables<TParams, TPayload>,
|
|
423
|
+
callbacks?: {
|
|
424
|
+
onSuccess?: (data: TResult) => void;
|
|
425
|
+
onError?: (error: unknown) => void;
|
|
426
|
+
},
|
|
427
|
+
) => {
|
|
428
|
+
mutateAsync(variables)
|
|
429
|
+
.then((data) => callbacks?.onSuccess?.(data))
|
|
430
|
+
.catch((error) => {
|
|
431
|
+
if (callbacks?.onError) {
|
|
432
|
+
callbacks.onError(error);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
},
|
|
436
|
+
[mutateAsync],
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
mutate,
|
|
441
|
+
mutateAsync,
|
|
442
|
+
isLoading: state.status === "loading",
|
|
443
|
+
isError: state.status === "error",
|
|
444
|
+
isSuccess: state.status === "success",
|
|
445
|
+
data: state.data,
|
|
446
|
+
error: state.error,
|
|
447
|
+
status: state.status,
|
|
448
|
+
reset,
|
|
449
|
+
};
|
|
450
|
+
}
|
package/sync/index.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { EntityKey, SyncTransaction } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Durable storage behind the sync engine: entity rows, the transaction
|
|
5
|
+
* queue, and cursor metadata. IndexedDB in the browser; an in-memory
|
|
6
|
+
* fallback everywhere else (SSR, tests, unsupported browsers) so the
|
|
7
|
+
* engine still runs — it just re-bootstraps on reload.
|
|
8
|
+
*/
|
|
9
|
+
export interface SyncPersistence {
|
|
10
|
+
getMeta(key: string): Promise<any | undefined>;
|
|
11
|
+
setMeta(key: string, value: any): Promise<void>;
|
|
12
|
+
getRecords(entity: EntityKey): Promise<Record<string, any>[]>;
|
|
13
|
+
putRecords(
|
|
14
|
+
entity: EntityKey,
|
|
15
|
+
rows: { id: string; data: Record<string, any> }[],
|
|
16
|
+
): Promise<void>;
|
|
17
|
+
deleteRecord(entity: EntityKey, id: string): Promise<void>;
|
|
18
|
+
replaceRecords(
|
|
19
|
+
entity: EntityKey,
|
|
20
|
+
rows: { id: string; data: Record<string, any> }[],
|
|
21
|
+
): Promise<void>;
|
|
22
|
+
getTransactions(): Promise<SyncTransaction[]>;
|
|
23
|
+
putTransaction(txn: SyncTransaction): Promise<void>;
|
|
24
|
+
deleteTransaction(txnId: string): Promise<void>;
|
|
25
|
+
clear(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const RECORDS = "records";
|
|
29
|
+
const TRANSACTIONS = "transactions";
|
|
30
|
+
const META = "meta";
|
|
31
|
+
|
|
32
|
+
class IndexedDbPersistence implements SyncPersistence {
|
|
33
|
+
private db: Promise<IDBDatabase>;
|
|
34
|
+
|
|
35
|
+
constructor(databaseName: string) {
|
|
36
|
+
this.db = new Promise((resolve, reject) => {
|
|
37
|
+
const request = indexedDB.open(databaseName, 1);
|
|
38
|
+
request.onupgradeneeded = () => {
|
|
39
|
+
const db = request.result;
|
|
40
|
+
if (!db.objectStoreNames.contains(RECORDS)) {
|
|
41
|
+
const records = db.createObjectStore(RECORDS, {
|
|
42
|
+
keyPath: ["entity", "id"],
|
|
43
|
+
});
|
|
44
|
+
records.createIndex("entity", "entity", { unique: false });
|
|
45
|
+
}
|
|
46
|
+
if (!db.objectStoreNames.contains(TRANSACTIONS)) {
|
|
47
|
+
db.createObjectStore(TRANSACTIONS, { keyPath: "txnId" });
|
|
48
|
+
}
|
|
49
|
+
if (!db.objectStoreNames.contains(META)) {
|
|
50
|
+
db.createObjectStore(META, { keyPath: "key" });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
request.onsuccess = () => resolve(request.result);
|
|
54
|
+
request.onerror = () => reject(request.error);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async transaction(
|
|
59
|
+
stores: string[],
|
|
60
|
+
mode: IDBTransactionMode,
|
|
61
|
+
): Promise<IDBTransaction> {
|
|
62
|
+
const db = await this.db;
|
|
63
|
+
return db.transaction(stores, mode);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private request<T>(request: IDBRequest<T>): Promise<T> {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
request.onsuccess = () => resolve(request.result);
|
|
69
|
+
request.onerror = () => reject(request.error);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private done(txn: IDBTransaction): Promise<void> {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
txn.oncomplete = () => resolve();
|
|
76
|
+
txn.onerror = () => reject(txn.error);
|
|
77
|
+
txn.onabort = () => reject(txn.error);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getMeta(key: string): Promise<any | undefined> {
|
|
82
|
+
const txn = await this.transaction([META], "readonly");
|
|
83
|
+
const row = await this.request(txn.objectStore(META).get(key));
|
|
84
|
+
return row ? (row as any).value : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async setMeta(key: string, value: any): Promise<void> {
|
|
88
|
+
const txn = await this.transaction([META], "readwrite");
|
|
89
|
+
txn.objectStore(META).put({ key, value });
|
|
90
|
+
await this.done(txn);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getRecords(entity: EntityKey): Promise<Record<string, any>[]> {
|
|
94
|
+
const txn = await this.transaction([RECORDS], "readonly");
|
|
95
|
+
const index = txn.objectStore(RECORDS).index("entity");
|
|
96
|
+
const rows = await this.request(index.getAll(entity));
|
|
97
|
+
return (rows as any[]).map((row) => row.data);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async putRecords(
|
|
101
|
+
entity: EntityKey,
|
|
102
|
+
rows: { id: string; data: Record<string, any> }[],
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const txn = await this.transaction([RECORDS], "readwrite");
|
|
105
|
+
const store = txn.objectStore(RECORDS);
|
|
106
|
+
rows.forEach(({ id, data }) => store.put({ entity, id, data }));
|
|
107
|
+
await this.done(txn);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async deleteRecord(entity: EntityKey, id: string): Promise<void> {
|
|
111
|
+
const txn = await this.transaction([RECORDS], "readwrite");
|
|
112
|
+
txn.objectStore(RECORDS).delete([entity, id]);
|
|
113
|
+
await this.done(txn);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async replaceRecords(
|
|
117
|
+
entity: EntityKey,
|
|
118
|
+
rows: { id: string; data: Record<string, any> }[],
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const txn = await this.transaction([RECORDS], "readwrite");
|
|
121
|
+
const store = txn.objectStore(RECORDS);
|
|
122
|
+
const index = store.index("entity");
|
|
123
|
+
const keys = await this.request(index.getAllKeys(entity));
|
|
124
|
+
(keys as IDBValidKey[]).forEach((key) => store.delete(key));
|
|
125
|
+
rows.forEach(({ id, data }) => store.put({ entity, id, data }));
|
|
126
|
+
await this.done(txn);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async getTransactions(): Promise<SyncTransaction[]> {
|
|
130
|
+
const txn = await this.transaction([TRANSACTIONS], "readonly");
|
|
131
|
+
const rows = await this.request(txn.objectStore(TRANSACTIONS).getAll());
|
|
132
|
+
return (rows as SyncTransaction[]).sort(
|
|
133
|
+
(a, b) => a.createdAt - b.createdAt,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async putTransaction(transaction: SyncTransaction): Promise<void> {
|
|
138
|
+
const txn = await this.transaction([TRANSACTIONS], "readwrite");
|
|
139
|
+
txn.objectStore(TRANSACTIONS).put(transaction);
|
|
140
|
+
await this.done(txn);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async deleteTransaction(txnId: string): Promise<void> {
|
|
144
|
+
const txn = await this.transaction([TRANSACTIONS], "readwrite");
|
|
145
|
+
txn.objectStore(TRANSACTIONS).delete(txnId);
|
|
146
|
+
await this.done(txn);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async clear(): Promise<void> {
|
|
150
|
+
const txn = await this.transaction(
|
|
151
|
+
[RECORDS, TRANSACTIONS, META],
|
|
152
|
+
"readwrite",
|
|
153
|
+
);
|
|
154
|
+
txn.objectStore(RECORDS).clear();
|
|
155
|
+
txn.objectStore(TRANSACTIONS).clear();
|
|
156
|
+
txn.objectStore(META).clear();
|
|
157
|
+
await this.done(txn);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class MemoryPersistence implements SyncPersistence {
|
|
162
|
+
private meta = new Map<string, any>();
|
|
163
|
+
private records = new Map<EntityKey, Map<string, Record<string, any>>>();
|
|
164
|
+
private transactions = new Map<string, SyncTransaction>();
|
|
165
|
+
|
|
166
|
+
private entityRecords(entity: EntityKey): Map<string, Record<string, any>> {
|
|
167
|
+
let rows = this.records.get(entity);
|
|
168
|
+
if (!rows) {
|
|
169
|
+
rows = new Map();
|
|
170
|
+
this.records.set(entity, rows);
|
|
171
|
+
}
|
|
172
|
+
return rows;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async getMeta(key: string): Promise<any | undefined> {
|
|
176
|
+
return this.meta.get(key);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async setMeta(key: string, value: any): Promise<void> {
|
|
180
|
+
this.meta.set(key, value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getRecords(entity: EntityKey): Promise<Record<string, any>[]> {
|
|
184
|
+
return Array.from(this.entityRecords(entity).values());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async putRecords(
|
|
188
|
+
entity: EntityKey,
|
|
189
|
+
rows: { id: string; data: Record<string, any> }[],
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
const store = this.entityRecords(entity);
|
|
192
|
+
rows.forEach(({ id, data }) => store.set(id, data));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async deleteRecord(entity: EntityKey, id: string): Promise<void> {
|
|
196
|
+
this.entityRecords(entity).delete(id);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async replaceRecords(
|
|
200
|
+
entity: EntityKey,
|
|
201
|
+
rows: { id: string; data: Record<string, any> }[],
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
const store = this.entityRecords(entity);
|
|
204
|
+
store.clear();
|
|
205
|
+
rows.forEach(({ id, data }) => store.set(id, data));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async getTransactions(): Promise<SyncTransaction[]> {
|
|
209
|
+
return Array.from(this.transactions.values()).sort(
|
|
210
|
+
(a, b) => a.createdAt - b.createdAt,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async putTransaction(txn: SyncTransaction): Promise<void> {
|
|
215
|
+
this.transactions.set(txn.txnId, txn);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async deleteTransaction(txnId: string): Promise<void> {
|
|
219
|
+
this.transactions.delete(txnId);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async clear(): Promise<void> {
|
|
223
|
+
this.meta.clear();
|
|
224
|
+
this.records.clear();
|
|
225
|
+
this.transactions.clear();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function createPersistence(
|
|
230
|
+
persist: boolean,
|
|
231
|
+
databaseName: string,
|
|
232
|
+
): SyncPersistence {
|
|
233
|
+
if (persist && typeof indexedDB !== "undefined") {
|
|
234
|
+
return new IndexedDbPersistence(databaseName);
|
|
235
|
+
}
|
|
236
|
+
return new MemoryPersistence();
|
|
237
|
+
}
|