rastack 0.0.16 → 0.0.19
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 +43874 -43013
- package/dist/.prettierrc +7 -7
- package/dist/compile/analyze.js +19 -11
- package/dist/compile/index.js +26 -17
- package/dist/compile/manifest.js +2 -3
- package/dist/compile/openapi.js +5 -8
- package/dist/compile/program.js +19 -10
- package/dist/csv-schema.js +5 -6
- package/dist/define/index.js +2 -2
- package/dist/entity-generation/delete-method.js +8 -10
- package/dist/entity-generation/form.js +80 -82
- package/dist/entity-generation/get-method.js +35 -36
- package/dist/entity-generation/imports.js +24 -20
- package/dist/entity-generation/sync-method.d.ts +1 -1
- package/dist/entity-generation/sync-method.js +45 -46
- package/dist/entity-generation/update-method.js +9 -10
- package/dist/rad-wasm-build.d.ts +1 -1
- package/dist/rad-wasm-build.js +15 -6
- package/dist/rad.js +9 -2
- package/dist/schema/camel-to-pastel.js +1 -2
- package/dist/schema/capitalise-first-letter.js +1 -2
- package/dist/schema/extract-response.js +3 -4
- package/dist/schema/fetch-schema.js +4 -16
- package/dist/schema/remove-non-model-paths.js +1 -2
- package/dist/schema/to-camel-case.js +1 -2
- package/dist/schema/to-pastel-case.js +1 -2
- package/dist/schema-convert.js +22 -33
- package/dist/schema-entities.js +177 -189
- package/dist/schema-fetch.js +46 -63
- package/dist/schema-full.js +23 -36
- package/dist/schema-index.js +16 -27
- package/dist/schema-params.js +11 -22
- package/dist/seed.js +95 -97
- package/hooks/form/form.ts +207 -207
- package/hooks/form/index.ts +8 -8
- package/hooks/form/interfaces.ts +217 -217
- package/hooks/form/structure.ts +39 -39
- package/hooks/form/validate-schema.ts +49 -49
- package/hooks/index.ts +3 -3
- package/hooks/query/api.ts +42 -42
- package/hooks/query/delete.ts +45 -45
- package/hooks/query/fetch.ts +48 -48
- package/hooks/query/index.ts +21 -21
- package/hooks/query/interfaces.ts +111 -111
- package/hooks/query/list.ts +286 -286
- package/hooks/query/update.ts +88 -88
- package/hooks/query/url.ts +31 -31
- package/hooks/real-time/index.ts +1 -1
- package/hooks/real-time/pusher.ts +43 -43
- package/jest.config.cjs +6 -6
- package/package.json +57 -57
- package/provider/index.ts +13 -13
- package/provider/provider.tsx +187 -187
- package/provider/types.ts +114 -114
- package/provider/warehouse.ts +167 -167
- package/provider/wasm.ts +124 -124
- package/runtime.ts +3 -3
- package/src/.prettierrc +7 -7
- package/src/compile/analyze.ts +224 -224
- package/src/compile/index.ts +86 -86
- package/src/compile/manifest.ts +10 -10
- package/src/compile/model.ts +69 -69
- package/src/compile/openapi.ts +266 -266
- package/src/compile/program.ts +40 -40
- package/src/csv-schema.ts +187 -187
- package/src/define/index.ts +162 -162
- package/src/entity-generation/delete-method.ts +37 -37
- package/src/entity-generation/form.ts +169 -169
- package/src/entity-generation/get-method.ts +80 -80
- package/src/entity-generation/imports.ts +110 -110
- package/src/entity-generation/sync-method.ts +222 -222
- package/src/entity-generation/update-method.ts +38 -38
- package/src/index.ts +77 -77
- package/src/rad-compile.ts +88 -88
- package/src/rad-wasm-build.ts +102 -102
- package/src/rad.ts +101 -101
- package/src/scan.ts +89 -89
- package/src/schema/camel-to-pastel.ts +3 -3
- package/src/schema/capitalise-first-letter.ts +3 -3
- package/src/schema/extract-response.ts +38 -38
- package/src/schema/fetch-schema.ts +12 -12
- package/src/schema/remove-non-model-paths.ts +13 -13
- package/src/schema/to-camel-case.ts +7 -7
- package/src/schema/to-pastel-case.ts +6 -6
- package/src/schema-convert.ts +61 -61
- package/src/schema-entities.ts +401 -401
- package/src/schema-fetch.ts +82 -82
- package/src/schema-full.ts +38 -38
- package/src/schema-index.ts +29 -29
- package/src/schema-params.ts +116 -116
- package/src/seed.ts +297 -297
- package/sync/engine.ts +392 -392
- package/sync/hooks.ts +450 -450
- package/sync/index.ts +8 -8
- package/sync/persistence.ts +237 -237
- package/sync/provider.tsx +91 -91
- package/sync/registry.ts +36 -36
- package/sync/store.ts +126 -126
- package/sync/transactions.ts +300 -300
- package/sync/types.ts +94 -94
- package/test/compile.spec.ts +192 -192
- package/test/csv-schema.spec.ts +143 -143
- package/test/schema-entities.spec.ts +688 -688
- package/tsconfig.json +24 -24
- package/types.ts +18 -18
package/sync/transactions.ts
CHANGED
|
@@ -1,300 +1,300 @@
|
|
|
1
|
-
import { AxiosInstance } from "axios";
|
|
2
|
-
import { SyncPersistence } from "./persistence";
|
|
3
|
-
import { SyncStore } from "./store";
|
|
4
|
-
import {
|
|
5
|
-
EntityDescriptor,
|
|
6
|
-
EntityKey,
|
|
7
|
-
SyncTransaction,
|
|
8
|
-
TransactionOp,
|
|
9
|
-
} from "./types";
|
|
10
|
-
|
|
11
|
-
export function randomId(): string {
|
|
12
|
-
const cryptoObject =
|
|
13
|
-
typeof crypto !== "undefined" ? (crypto as Crypto) : undefined;
|
|
14
|
-
if (cryptoObject && typeof cryptoObject.randomUUID === "function") {
|
|
15
|
-
return cryptoObject.randomUUID();
|
|
16
|
-
}
|
|
17
|
-
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** RFC 4122 v4 UUID — always a server-acceptable UUID (unlike randomId's fallback). */
|
|
21
|
-
export function uuid4(): string {
|
|
22
|
-
const cryptoObject =
|
|
23
|
-
typeof crypto !== "undefined" ? (crypto as Crypto) : undefined;
|
|
24
|
-
if (cryptoObject && typeof cryptoObject.randomUUID === "function") {
|
|
25
|
-
return cryptoObject.randomUUID();
|
|
26
|
-
}
|
|
27
|
-
const bytes = new Uint8Array(16);
|
|
28
|
-
if (cryptoObject && typeof cryptoObject.getRandomValues === "function") {
|
|
29
|
-
cryptoObject.getRandomValues(bytes);
|
|
30
|
-
} else {
|
|
31
|
-
for (let i = 0; i < 16; i += 1) {
|
|
32
|
-
bytes[i] = Math.floor(Math.random() * 256);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
36
|
-
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
37
|
-
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
|
38
|
-
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
|
39
|
-
.slice(6, 8)
|
|
40
|
-
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function tempId(): string {
|
|
44
|
-
return `tmp_${randomId()}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function isTempId(id: string): boolean {
|
|
48
|
-
return id.startsWith("tmp_");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface TransactionQueueDeps {
|
|
52
|
-
api: AxiosInstance;
|
|
53
|
-
persistence: SyncPersistence;
|
|
54
|
-
store: SyncStore;
|
|
55
|
-
getEntity: (key: EntityKey) => EntityDescriptor | undefined;
|
|
56
|
-
onError?: (error: unknown, txn: SyncTransaction) => void;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface EnqueueInput {
|
|
60
|
-
entity: EntityKey;
|
|
61
|
-
op: TransactionOp;
|
|
62
|
-
id: string;
|
|
63
|
-
method: SyncTransaction["method"];
|
|
64
|
-
url: string;
|
|
65
|
-
payload: Record<string, any> | null;
|
|
66
|
-
original: Record<string, any> | null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The optimistic write path. Changes apply to the in-memory store
|
|
71
|
-
* immediately, persist to the local transaction log (so offline writes
|
|
72
|
-
* survive reload), then replay through the normal generated REST endpoints
|
|
73
|
-
* in order. Server rejection (4xx) rolls the optimistic change back;
|
|
74
|
-
* network failure retries with backoff and resumes on reconnect.
|
|
75
|
-
*/
|
|
76
|
-
export class TransactionQueue {
|
|
77
|
-
private pending: SyncTransaction[] = [];
|
|
78
|
-
private idAliases = new Map<string, string>();
|
|
79
|
-
private settlers = new Map<
|
|
80
|
-
string,
|
|
81
|
-
{ resolve: (data: any) => void; reject: (error: unknown) => void }
|
|
82
|
-
>();
|
|
83
|
-
private flushing = false;
|
|
84
|
-
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
-
private retryDelay = 1000;
|
|
86
|
-
|
|
87
|
-
constructor(private deps: TransactionQueueDeps) {}
|
|
88
|
-
|
|
89
|
-
/** Load persisted transactions after a reload and re-apply their optimistic effect. */
|
|
90
|
-
async resume(): Promise<void> {
|
|
91
|
-
const persisted = await this.deps.persistence.getTransactions();
|
|
92
|
-
for (const txn of persisted) {
|
|
93
|
-
if (txn.status === "completed") {
|
|
94
|
-
// Server already confirmed it before the reload; catch-up sync will
|
|
95
|
-
// deliver the authoritative row, so the log entry is done.
|
|
96
|
-
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
txn.status = "queued";
|
|
100
|
-
this.pending.push(txn);
|
|
101
|
-
this.applyOptimistic(txn);
|
|
102
|
-
}
|
|
103
|
-
void this.flush();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async enqueue(input: EnqueueInput): Promise<any> {
|
|
107
|
-
const txn: SyncTransaction = {
|
|
108
|
-
txnId: randomId(),
|
|
109
|
-
status: "queued",
|
|
110
|
-
createdAt: Date.now(),
|
|
111
|
-
...input,
|
|
112
|
-
};
|
|
113
|
-
this.applyOptimistic(txn);
|
|
114
|
-
this.pending.push(txn);
|
|
115
|
-
await this.deps.persistence.putTransaction(txn);
|
|
116
|
-
const settled = new Promise<any>((resolve, reject) => {
|
|
117
|
-
this.settlers.set(txn.txnId, { resolve, reject });
|
|
118
|
-
});
|
|
119
|
-
void this.flush();
|
|
120
|
-
return settled;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Pending (not yet server-confirmed) transactions touching a row — used for rebase. */
|
|
124
|
-
pendingFor(entity: EntityKey, id: string): SyncTransaction[] {
|
|
125
|
-
return this.pending.filter(
|
|
126
|
-
(txn) =>
|
|
127
|
-
txn.entity === entity &&
|
|
128
|
-
(txn.id === id || this.idAliases.get(txn.id) === id) &&
|
|
129
|
-
txn.status !== "completed",
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** A server delta touched this row: confirmed transactions for it are fully synced. */
|
|
134
|
-
async resolveForRow(entity: EntityKey, id: string): Promise<void> {
|
|
135
|
-
const done = this.pending.filter(
|
|
136
|
-
(txn) =>
|
|
137
|
-
txn.entity === entity &&
|
|
138
|
-
(txn.id === id || this.idAliases.get(txn.id) === id) &&
|
|
139
|
-
txn.status === "completed",
|
|
140
|
-
);
|
|
141
|
-
for (const txn of done) {
|
|
142
|
-
this.pending = this.pending.filter((item) => item !== txn);
|
|
143
|
-
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
hasPending(): boolean {
|
|
148
|
-
return this.pending.some((txn) => txn.status !== "completed");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
resolveAlias(id: string): string {
|
|
152
|
-
return this.idAliases.get(id) || id;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
wake(): void {
|
|
156
|
-
this.retryDelay = 1000;
|
|
157
|
-
void this.flush();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
private applyOptimistic(txn: SyncTransaction): void {
|
|
161
|
-
const { store, getEntity } = this.deps;
|
|
162
|
-
const entity = getEntity(txn.entity);
|
|
163
|
-
const idField = entity?.idField || "id";
|
|
164
|
-
if (txn.op === "create") {
|
|
165
|
-
store.upsert(txn.entity, txn.id, {
|
|
166
|
-
[idField]: txn.id,
|
|
167
|
-
...(txn.payload || {}),
|
|
168
|
-
});
|
|
169
|
-
} else if (txn.op === "update") {
|
|
170
|
-
store.merge(txn.entity, txn.id, txn.payload || {});
|
|
171
|
-
} else if (txn.op === "delete") {
|
|
172
|
-
store.remove(txn.entity, txn.id);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
private rollback(txn: SyncTransaction): void {
|
|
177
|
-
const { store } = this.deps;
|
|
178
|
-
if (txn.op === "create") {
|
|
179
|
-
store.remove(txn.entity, txn.id);
|
|
180
|
-
} else if (txn.op === "update" && txn.original) {
|
|
181
|
-
store.merge(txn.entity, txn.id, txn.original);
|
|
182
|
-
} else if (txn.op === "delete" && txn.original) {
|
|
183
|
-
store.upsert(txn.entity, txn.id, txn.original);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
private rewriteTempIds(txn: SyncTransaction): void {
|
|
188
|
-
const realId = this.idAliases.get(txn.id);
|
|
189
|
-
if (!realId) {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
txn.url = txn.url.split(txn.id).join(realId);
|
|
193
|
-
if (txn.payload) {
|
|
194
|
-
Object.keys(txn.payload).forEach((key) => {
|
|
195
|
-
if (txn.payload![key] === txn.id) {
|
|
196
|
-
txn.payload![key] = realId;
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
txn.id = realId;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
private async flush(): Promise<void> {
|
|
204
|
-
if (this.flushing) {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
this.flushing = true;
|
|
208
|
-
try {
|
|
209
|
-
for (;;) {
|
|
210
|
-
const txn = this.pending.find((item) => item.status === "queued");
|
|
211
|
-
if (!txn) {
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
215
|
-
break;
|
|
216
|
-
}
|
|
217
|
-
this.rewriteTempIds(txn);
|
|
218
|
-
txn.status = "inflight";
|
|
219
|
-
await this.deps.persistence.putTransaction(txn);
|
|
220
|
-
try {
|
|
221
|
-
const response = await this.send(txn);
|
|
222
|
-
await this.confirm(txn, response);
|
|
223
|
-
this.retryDelay = 1000;
|
|
224
|
-
} catch (error: any) {
|
|
225
|
-
const status = error?.response?.status;
|
|
226
|
-
const isRejection =
|
|
227
|
-
typeof status === "number" &&
|
|
228
|
-
status >= 400 &&
|
|
229
|
-
status < 500 &&
|
|
230
|
-
status !== 408 &&
|
|
231
|
-
status !== 429;
|
|
232
|
-
if (isRejection) {
|
|
233
|
-
// The server refused the write (validation, permissions):
|
|
234
|
-
// roll the optimistic change back and surface the error.
|
|
235
|
-
this.rollback(txn);
|
|
236
|
-
this.pending = this.pending.filter((item) => item !== txn);
|
|
237
|
-
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
238
|
-
this.settlers.get(txn.txnId)?.reject(error);
|
|
239
|
-
this.settlers.delete(txn.txnId);
|
|
240
|
-
this.deps.onError?.(error, txn);
|
|
241
|
-
} else {
|
|
242
|
-
// Network failure or server trouble: keep the optimistic state,
|
|
243
|
-
// stay queued, retry with backoff.
|
|
244
|
-
txn.status = "queued";
|
|
245
|
-
await this.deps.persistence.putTransaction(txn);
|
|
246
|
-
this.scheduleRetry();
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
} finally {
|
|
252
|
-
this.flushing = false;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private async send(txn: SyncTransaction): Promise<any> {
|
|
257
|
-
const { api } = this.deps;
|
|
258
|
-
switch (txn.method) {
|
|
259
|
-
case "post":
|
|
260
|
-
return (await api.post(txn.url, txn.payload)).data;
|
|
261
|
-
case "put":
|
|
262
|
-
return (await api.put(txn.url, txn.payload)).data;
|
|
263
|
-
case "patch":
|
|
264
|
-
return (await api.patch(txn.url, txn.payload)).data;
|
|
265
|
-
case "delete":
|
|
266
|
-
return (await api.delete(txn.url)).data;
|
|
267
|
-
default:
|
|
268
|
-
throw new Error(`Unsupported method: ${txn.method}`);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
private async confirm(txn: SyncTransaction, response: any): Promise<void> {
|
|
273
|
-
const entity = this.deps.getEntity(txn.entity);
|
|
274
|
-
const idField = entity?.idField || "id";
|
|
275
|
-
if (txn.op === "create" && response && response[idField] !== undefined) {
|
|
276
|
-
const realId = String(response[idField]);
|
|
277
|
-
if (isTempId(txn.id) && realId !== txn.id) {
|
|
278
|
-
this.idAliases.set(txn.id, realId);
|
|
279
|
-
this.deps.store.replaceId(txn.entity, txn.id, realId);
|
|
280
|
-
this.deps.store.merge(txn.entity, realId, response);
|
|
281
|
-
txn.id = realId;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
txn.status = "completed";
|
|
285
|
-
await this.deps.persistence.putTransaction(txn);
|
|
286
|
-
this.settlers.get(txn.txnId)?.resolve(response);
|
|
287
|
-
this.settlers.delete(txn.txnId);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
private scheduleRetry(): void {
|
|
291
|
-
if (this.retryTimer) {
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
this.retryTimer = setTimeout(() => {
|
|
295
|
-
this.retryTimer = null;
|
|
296
|
-
void this.flush();
|
|
297
|
-
}, this.retryDelay);
|
|
298
|
-
this.retryDelay = Math.min(this.retryDelay * 2, 30000);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
1
|
+
import { AxiosInstance } from "axios";
|
|
2
|
+
import { SyncPersistence } from "./persistence";
|
|
3
|
+
import { SyncStore } from "./store";
|
|
4
|
+
import {
|
|
5
|
+
EntityDescriptor,
|
|
6
|
+
EntityKey,
|
|
7
|
+
SyncTransaction,
|
|
8
|
+
TransactionOp,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
export function randomId(): string {
|
|
12
|
+
const cryptoObject =
|
|
13
|
+
typeof crypto !== "undefined" ? (crypto as Crypto) : undefined;
|
|
14
|
+
if (cryptoObject && typeof cryptoObject.randomUUID === "function") {
|
|
15
|
+
return cryptoObject.randomUUID();
|
|
16
|
+
}
|
|
17
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** RFC 4122 v4 UUID — always a server-acceptable UUID (unlike randomId's fallback). */
|
|
21
|
+
export function uuid4(): string {
|
|
22
|
+
const cryptoObject =
|
|
23
|
+
typeof crypto !== "undefined" ? (crypto as Crypto) : undefined;
|
|
24
|
+
if (cryptoObject && typeof cryptoObject.randomUUID === "function") {
|
|
25
|
+
return cryptoObject.randomUUID();
|
|
26
|
+
}
|
|
27
|
+
const bytes = new Uint8Array(16);
|
|
28
|
+
if (cryptoObject && typeof cryptoObject.getRandomValues === "function") {
|
|
29
|
+
cryptoObject.getRandomValues(bytes);
|
|
30
|
+
} else {
|
|
31
|
+
for (let i = 0; i < 16; i += 1) {
|
|
32
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
36
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
37
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
|
38
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
|
39
|
+
.slice(6, 8)
|
|
40
|
+
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function tempId(): string {
|
|
44
|
+
return `tmp_${randomId()}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isTempId(id: string): boolean {
|
|
48
|
+
return id.startsWith("tmp_");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TransactionQueueDeps {
|
|
52
|
+
api: AxiosInstance;
|
|
53
|
+
persistence: SyncPersistence;
|
|
54
|
+
store: SyncStore;
|
|
55
|
+
getEntity: (key: EntityKey) => EntityDescriptor | undefined;
|
|
56
|
+
onError?: (error: unknown, txn: SyncTransaction) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface EnqueueInput {
|
|
60
|
+
entity: EntityKey;
|
|
61
|
+
op: TransactionOp;
|
|
62
|
+
id: string;
|
|
63
|
+
method: SyncTransaction["method"];
|
|
64
|
+
url: string;
|
|
65
|
+
payload: Record<string, any> | null;
|
|
66
|
+
original: Record<string, any> | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The optimistic write path. Changes apply to the in-memory store
|
|
71
|
+
* immediately, persist to the local transaction log (so offline writes
|
|
72
|
+
* survive reload), then replay through the normal generated REST endpoints
|
|
73
|
+
* in order. Server rejection (4xx) rolls the optimistic change back;
|
|
74
|
+
* network failure retries with backoff and resumes on reconnect.
|
|
75
|
+
*/
|
|
76
|
+
export class TransactionQueue {
|
|
77
|
+
private pending: SyncTransaction[] = [];
|
|
78
|
+
private idAliases = new Map<string, string>();
|
|
79
|
+
private settlers = new Map<
|
|
80
|
+
string,
|
|
81
|
+
{ resolve: (data: any) => void; reject: (error: unknown) => void }
|
|
82
|
+
>();
|
|
83
|
+
private flushing = false;
|
|
84
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
private retryDelay = 1000;
|
|
86
|
+
|
|
87
|
+
constructor(private deps: TransactionQueueDeps) {}
|
|
88
|
+
|
|
89
|
+
/** Load persisted transactions after a reload and re-apply their optimistic effect. */
|
|
90
|
+
async resume(): Promise<void> {
|
|
91
|
+
const persisted = await this.deps.persistence.getTransactions();
|
|
92
|
+
for (const txn of persisted) {
|
|
93
|
+
if (txn.status === "completed") {
|
|
94
|
+
// Server already confirmed it before the reload; catch-up sync will
|
|
95
|
+
// deliver the authoritative row, so the log entry is done.
|
|
96
|
+
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
txn.status = "queued";
|
|
100
|
+
this.pending.push(txn);
|
|
101
|
+
this.applyOptimistic(txn);
|
|
102
|
+
}
|
|
103
|
+
void this.flush();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async enqueue(input: EnqueueInput): Promise<any> {
|
|
107
|
+
const txn: SyncTransaction = {
|
|
108
|
+
txnId: randomId(),
|
|
109
|
+
status: "queued",
|
|
110
|
+
createdAt: Date.now(),
|
|
111
|
+
...input,
|
|
112
|
+
};
|
|
113
|
+
this.applyOptimistic(txn);
|
|
114
|
+
this.pending.push(txn);
|
|
115
|
+
await this.deps.persistence.putTransaction(txn);
|
|
116
|
+
const settled = new Promise<any>((resolve, reject) => {
|
|
117
|
+
this.settlers.set(txn.txnId, { resolve, reject });
|
|
118
|
+
});
|
|
119
|
+
void this.flush();
|
|
120
|
+
return settled;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Pending (not yet server-confirmed) transactions touching a row — used for rebase. */
|
|
124
|
+
pendingFor(entity: EntityKey, id: string): SyncTransaction[] {
|
|
125
|
+
return this.pending.filter(
|
|
126
|
+
(txn) =>
|
|
127
|
+
txn.entity === entity &&
|
|
128
|
+
(txn.id === id || this.idAliases.get(txn.id) === id) &&
|
|
129
|
+
txn.status !== "completed",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A server delta touched this row: confirmed transactions for it are fully synced. */
|
|
134
|
+
async resolveForRow(entity: EntityKey, id: string): Promise<void> {
|
|
135
|
+
const done = this.pending.filter(
|
|
136
|
+
(txn) =>
|
|
137
|
+
txn.entity === entity &&
|
|
138
|
+
(txn.id === id || this.idAliases.get(txn.id) === id) &&
|
|
139
|
+
txn.status === "completed",
|
|
140
|
+
);
|
|
141
|
+
for (const txn of done) {
|
|
142
|
+
this.pending = this.pending.filter((item) => item !== txn);
|
|
143
|
+
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
hasPending(): boolean {
|
|
148
|
+
return this.pending.some((txn) => txn.status !== "completed");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resolveAlias(id: string): string {
|
|
152
|
+
return this.idAliases.get(id) || id;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
wake(): void {
|
|
156
|
+
this.retryDelay = 1000;
|
|
157
|
+
void this.flush();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private applyOptimistic(txn: SyncTransaction): void {
|
|
161
|
+
const { store, getEntity } = this.deps;
|
|
162
|
+
const entity = getEntity(txn.entity);
|
|
163
|
+
const idField = entity?.idField || "id";
|
|
164
|
+
if (txn.op === "create") {
|
|
165
|
+
store.upsert(txn.entity, txn.id, {
|
|
166
|
+
[idField]: txn.id,
|
|
167
|
+
...(txn.payload || {}),
|
|
168
|
+
});
|
|
169
|
+
} else if (txn.op === "update") {
|
|
170
|
+
store.merge(txn.entity, txn.id, txn.payload || {});
|
|
171
|
+
} else if (txn.op === "delete") {
|
|
172
|
+
store.remove(txn.entity, txn.id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private rollback(txn: SyncTransaction): void {
|
|
177
|
+
const { store } = this.deps;
|
|
178
|
+
if (txn.op === "create") {
|
|
179
|
+
store.remove(txn.entity, txn.id);
|
|
180
|
+
} else if (txn.op === "update" && txn.original) {
|
|
181
|
+
store.merge(txn.entity, txn.id, txn.original);
|
|
182
|
+
} else if (txn.op === "delete" && txn.original) {
|
|
183
|
+
store.upsert(txn.entity, txn.id, txn.original);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private rewriteTempIds(txn: SyncTransaction): void {
|
|
188
|
+
const realId = this.idAliases.get(txn.id);
|
|
189
|
+
if (!realId) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
txn.url = txn.url.split(txn.id).join(realId);
|
|
193
|
+
if (txn.payload) {
|
|
194
|
+
Object.keys(txn.payload).forEach((key) => {
|
|
195
|
+
if (txn.payload![key] === txn.id) {
|
|
196
|
+
txn.payload![key] = realId;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
txn.id = realId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async flush(): Promise<void> {
|
|
204
|
+
if (this.flushing) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.flushing = true;
|
|
208
|
+
try {
|
|
209
|
+
for (;;) {
|
|
210
|
+
const txn = this.pending.find((item) => item.status === "queued");
|
|
211
|
+
if (!txn) {
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
this.rewriteTempIds(txn);
|
|
218
|
+
txn.status = "inflight";
|
|
219
|
+
await this.deps.persistence.putTransaction(txn);
|
|
220
|
+
try {
|
|
221
|
+
const response = await this.send(txn);
|
|
222
|
+
await this.confirm(txn, response);
|
|
223
|
+
this.retryDelay = 1000;
|
|
224
|
+
} catch (error: any) {
|
|
225
|
+
const status = error?.response?.status;
|
|
226
|
+
const isRejection =
|
|
227
|
+
typeof status === "number" &&
|
|
228
|
+
status >= 400 &&
|
|
229
|
+
status < 500 &&
|
|
230
|
+
status !== 408 &&
|
|
231
|
+
status !== 429;
|
|
232
|
+
if (isRejection) {
|
|
233
|
+
// The server refused the write (validation, permissions):
|
|
234
|
+
// roll the optimistic change back and surface the error.
|
|
235
|
+
this.rollback(txn);
|
|
236
|
+
this.pending = this.pending.filter((item) => item !== txn);
|
|
237
|
+
await this.deps.persistence.deleteTransaction(txn.txnId);
|
|
238
|
+
this.settlers.get(txn.txnId)?.reject(error);
|
|
239
|
+
this.settlers.delete(txn.txnId);
|
|
240
|
+
this.deps.onError?.(error, txn);
|
|
241
|
+
} else {
|
|
242
|
+
// Network failure or server trouble: keep the optimistic state,
|
|
243
|
+
// stay queued, retry with backoff.
|
|
244
|
+
txn.status = "queued";
|
|
245
|
+
await this.deps.persistence.putTransaction(txn);
|
|
246
|
+
this.scheduleRetry();
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
this.flushing = false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async send(txn: SyncTransaction): Promise<any> {
|
|
257
|
+
const { api } = this.deps;
|
|
258
|
+
switch (txn.method) {
|
|
259
|
+
case "post":
|
|
260
|
+
return (await api.post(txn.url, txn.payload)).data;
|
|
261
|
+
case "put":
|
|
262
|
+
return (await api.put(txn.url, txn.payload)).data;
|
|
263
|
+
case "patch":
|
|
264
|
+
return (await api.patch(txn.url, txn.payload)).data;
|
|
265
|
+
case "delete":
|
|
266
|
+
return (await api.delete(txn.url)).data;
|
|
267
|
+
default:
|
|
268
|
+
throw new Error(`Unsupported method: ${txn.method}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async confirm(txn: SyncTransaction, response: any): Promise<void> {
|
|
273
|
+
const entity = this.deps.getEntity(txn.entity);
|
|
274
|
+
const idField = entity?.idField || "id";
|
|
275
|
+
if (txn.op === "create" && response && response[idField] !== undefined) {
|
|
276
|
+
const realId = String(response[idField]);
|
|
277
|
+
if (isTempId(txn.id) && realId !== txn.id) {
|
|
278
|
+
this.idAliases.set(txn.id, realId);
|
|
279
|
+
this.deps.store.replaceId(txn.entity, txn.id, realId);
|
|
280
|
+
this.deps.store.merge(txn.entity, realId, response);
|
|
281
|
+
txn.id = realId;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
txn.status = "completed";
|
|
285
|
+
await this.deps.persistence.putTransaction(txn);
|
|
286
|
+
this.settlers.get(txn.txnId)?.resolve(response);
|
|
287
|
+
this.settlers.delete(txn.txnId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private scheduleRetry(): void {
|
|
291
|
+
if (this.retryTimer) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.retryTimer = setTimeout(() => {
|
|
295
|
+
this.retryTimer = null;
|
|
296
|
+
void this.flush();
|
|
297
|
+
}, this.retryDelay);
|
|
298
|
+
this.retryDelay = Math.min(this.retryDelay * 2, 30000);
|
|
299
|
+
}
|
|
300
|
+
}
|