rastack 0.0.16 → 0.0.20

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +43876 -43013
  2. package/dist/.prettierrc +7 -7
  3. package/dist/compile/analyze.js +19 -11
  4. package/dist/compile/index.js +26 -17
  5. package/dist/compile/manifest.js +2 -3
  6. package/dist/compile/openapi.js +5 -8
  7. package/dist/compile/program.js +19 -10
  8. package/dist/csv-schema.js +5 -6
  9. package/dist/define/index.js +2 -2
  10. package/dist/entity-generation/delete-method.js +8 -10
  11. package/dist/entity-generation/form.js +80 -82
  12. package/dist/entity-generation/get-method.js +35 -36
  13. package/dist/entity-generation/imports.js +24 -20
  14. package/dist/entity-generation/sync-method.d.ts +1 -1
  15. package/dist/entity-generation/sync-method.js +45 -46
  16. package/dist/entity-generation/update-method.js +9 -10
  17. package/dist/rad-wasm-build.d.ts +1 -1
  18. package/dist/rad-wasm-build.js +15 -6
  19. package/dist/rad.js +9 -2
  20. package/dist/schema/camel-to-pastel.js +1 -2
  21. package/dist/schema/capitalise-first-letter.js +1 -2
  22. package/dist/schema/extract-response.js +3 -4
  23. package/dist/schema/fetch-schema.js +4 -16
  24. package/dist/schema/remove-non-model-paths.js +1 -2
  25. package/dist/schema/to-camel-case.js +1 -2
  26. package/dist/schema/to-pastel-case.js +1 -2
  27. package/dist/schema-convert.js +22 -33
  28. package/dist/schema-entities.js +177 -189
  29. package/dist/schema-fetch.js +46 -63
  30. package/dist/schema-full.js +23 -36
  31. package/dist/schema-index.js +16 -27
  32. package/dist/schema-params.js +11 -22
  33. package/dist/seed.js +95 -97
  34. package/hooks/form/form.ts +207 -207
  35. package/hooks/form/index.ts +8 -8
  36. package/hooks/form/interfaces.ts +217 -217
  37. package/hooks/form/structure.ts +39 -39
  38. package/hooks/form/validate-schema.ts +49 -49
  39. package/hooks/index.ts +3 -3
  40. package/hooks/query/api.ts +42 -42
  41. package/hooks/query/delete.ts +45 -45
  42. package/hooks/query/fetch.ts +48 -48
  43. package/hooks/query/index.ts +21 -21
  44. package/hooks/query/interfaces.ts +111 -111
  45. package/hooks/query/list.ts +286 -286
  46. package/hooks/query/update.ts +88 -88
  47. package/hooks/query/url.ts +31 -31
  48. package/hooks/real-time/index.ts +1 -1
  49. package/hooks/real-time/pusher.ts +43 -43
  50. package/jest.config.cjs +6 -6
  51. package/package.json +57 -57
  52. package/provider/index.ts +13 -13
  53. package/provider/provider.tsx +187 -187
  54. package/provider/types.ts +114 -114
  55. package/provider/warehouse.ts +167 -167
  56. package/provider/wasm.ts +124 -124
  57. package/runtime.ts +3 -3
  58. package/src/.prettierrc +7 -7
  59. package/src/compile/analyze.ts +224 -224
  60. package/src/compile/index.ts +86 -86
  61. package/src/compile/manifest.ts +10 -10
  62. package/src/compile/model.ts +69 -69
  63. package/src/compile/openapi.ts +266 -266
  64. package/src/compile/program.ts +40 -40
  65. package/src/csv-schema.ts +187 -187
  66. package/src/define/index.ts +162 -162
  67. package/src/entity-generation/delete-method.ts +37 -37
  68. package/src/entity-generation/form.ts +169 -169
  69. package/src/entity-generation/get-method.ts +80 -80
  70. package/src/entity-generation/imports.ts +110 -110
  71. package/src/entity-generation/sync-method.ts +222 -222
  72. package/src/entity-generation/update-method.ts +38 -38
  73. package/src/index.ts +77 -77
  74. package/src/rad-compile.ts +88 -88
  75. package/src/rad-wasm-build.ts +102 -102
  76. package/src/rad.ts +101 -101
  77. package/src/scan.ts +89 -89
  78. package/src/schema/camel-to-pastel.ts +3 -3
  79. package/src/schema/capitalise-first-letter.ts +3 -3
  80. package/src/schema/extract-response.ts +38 -38
  81. package/src/schema/fetch-schema.ts +12 -12
  82. package/src/schema/remove-non-model-paths.ts +13 -13
  83. package/src/schema/to-camel-case.ts +7 -7
  84. package/src/schema/to-pastel-case.ts +6 -6
  85. package/src/schema-convert.ts +61 -61
  86. package/src/schema-entities.ts +401 -401
  87. package/src/schema-fetch.ts +82 -82
  88. package/src/schema-full.ts +38 -38
  89. package/src/schema-index.ts +29 -29
  90. package/src/schema-params.ts +116 -116
  91. package/src/seed.ts +297 -297
  92. package/sync/engine.ts +392 -392
  93. package/sync/hooks.ts +450 -450
  94. package/sync/index.ts +8 -8
  95. package/sync/persistence.ts +237 -237
  96. package/sync/provider.tsx +91 -91
  97. package/sync/registry.ts +36 -36
  98. package/sync/store.ts +126 -126
  99. package/sync/transactions.ts +300 -300
  100. package/sync/types.ts +94 -94
  101. package/test/compile.spec.ts +192 -192
  102. package/test/csv-schema.spec.ts +143 -143
  103. package/test/schema-entities.spec.ts +688 -688
  104. package/tsconfig.json +24 -24
  105. package/types.ts +18 -18
package/sync/hooks.ts CHANGED
@@ -1,450 +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
- }
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
+ }