@rebasepro/client 0.10.1-canary.gdaf6ba4 → 0.11.0
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/dist/index.es.js +73 -26
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline.d.ts +19 -0
- package/package.json +4 -4
- package/src/collection.ts +7 -3
- package/src/offline-connectivity.ts +16 -0
- package/src/offline-sync-engine.test.ts +130 -0
- package/src/offline.ts +100 -28
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
export declare function isNetworkError(error: unknown): boolean;
|
|
15
15
|
/** Is this failure worth another attempt later? */
|
|
16
16
|
export declare function isRetryableError(error: unknown): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Did this write fail because the row is already there?
|
|
19
|
+
*
|
|
20
|
+
* Matched on the SQLSTATE the server passes through (`23505`, unique_violation)
|
|
21
|
+
* and on 409, never on the message — a duplicate-key message names the
|
|
22
|
+
* constraint and the values, so it is neither stable nor safe to parse.
|
|
23
|
+
*
|
|
24
|
+
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
25
|
+
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
26
|
+
* the server can only be rejecting it because the first attempt actually landed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isDuplicateKeyError(error: unknown): boolean;
|
|
17
29
|
export interface ConnectivityOptions {
|
|
18
30
|
/** First retry delay after a failure. Defaults to 1 000 ms. */
|
|
19
31
|
initialBackoffMs?: number;
|
package/dist/offline.d.ts
CHANGED
|
@@ -137,6 +137,25 @@ export declare class OfflineManager {
|
|
|
137
137
|
private collections;
|
|
138
138
|
/** In-memory mirror of the current scope's queue, in replay order. */
|
|
139
139
|
private queue;
|
|
140
|
+
/**
|
|
141
|
+
* The mutation currently on the wire, if any.
|
|
142
|
+
*
|
|
143
|
+
* `flush` awaits `replay(op)` with `op` still at the head of `queue`, so for
|
|
144
|
+
* the whole duration of that request the in-flight op is also the queue's
|
|
145
|
+
* *tail* whenever it is the only entry. Both shortcuts in `enqueue` reach
|
|
146
|
+
* for the tail, and neither may touch an op the server is already reading:
|
|
147
|
+
*
|
|
148
|
+
* - Coalescing an update into it mutates a payload that has already been
|
|
149
|
+
* serialized and sent, and `drop` then removes the whole entry on ACK —
|
|
150
|
+
* so the second edit is neither sent nor kept. A silently lost write.
|
|
151
|
+
* - Cancelling it out against a delete assumes the server never saw the
|
|
152
|
+
* create. It is seeing it right now, so the row would be created and the
|
|
153
|
+
* delete never queued — an orphan row nothing will ever remove.
|
|
154
|
+
*
|
|
155
|
+
* Guarding on the id rather than on a boolean keeps this correct if the
|
|
156
|
+
* flush loop ever sends more than one op at a time.
|
|
157
|
+
*/
|
|
158
|
+
private inFlightId;
|
|
140
159
|
private queueLoad?;
|
|
141
160
|
/** Serializes enqueues so concurrent writes keep the order the app made them. */
|
|
142
161
|
private enqueueChain;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"description": "HTTP SDK client for the Rebase custom backend",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"./package.json": "./package.json"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@rebasepro/common": "0.
|
|
33
|
-
"@rebasepro/types": "0.
|
|
34
|
-
"@rebasepro/utils": "0.
|
|
32
|
+
"@rebasepro/common": "0.11.0",
|
|
33
|
+
"@rebasepro/types": "0.11.0",
|
|
34
|
+
"@rebasepro/utils": "0.11.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@jest/globals": "^30.4.1",
|
package/src/collection.ts
CHANGED
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
SDKCollectionClient,
|
|
7
7
|
SDKQueryBuilderInterface,
|
|
8
8
|
WhereFilterOp,
|
|
9
|
-
WhereValue
|
|
9
|
+
WhereValue,
|
|
10
|
+
WriteOptions
|
|
10
11
|
} from "@rebasepro/types";
|
|
11
12
|
|
|
12
13
|
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
@@ -125,14 +126,17 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
125
126
|
}
|
|
126
127
|
},
|
|
127
128
|
|
|
128
|
-
async create(data: Partial<M>, id?: string | number) {
|
|
129
|
+
async create(data: Partial<M>, id?: string | number, options?: WriteOptions) {
|
|
129
130
|
const body: Record<string, unknown> = { ...data };
|
|
130
131
|
if (id !== undefined) {
|
|
131
132
|
body.id = id;
|
|
132
133
|
}
|
|
133
134
|
const raw = await transport.request<Record<string, unknown>>(basePath, {
|
|
134
135
|
method: "POST",
|
|
135
|
-
body: JSON.stringify(body)
|
|
136
|
+
body: JSON.stringify(body),
|
|
137
|
+
...(options?.idempotencyKey
|
|
138
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
139
|
+
: {})
|
|
136
140
|
});
|
|
137
141
|
return raw as M;
|
|
138
142
|
},
|
|
@@ -45,6 +45,22 @@ export function isRetryableError(error: unknown): boolean {
|
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Did this write fail because the row is already there?
|
|
50
|
+
*
|
|
51
|
+
* Matched on the SQLSTATE the server passes through (`23505`, unique_violation)
|
|
52
|
+
* and on 409, never on the message — a duplicate-key message names the
|
|
53
|
+
* constraint and the values, so it is neither stable nor safe to parse.
|
|
54
|
+
*
|
|
55
|
+
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
56
|
+
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
57
|
+
* the server can only be rejecting it because the first attempt actually landed.
|
|
58
|
+
*/
|
|
59
|
+
export function isDuplicateKeyError(error: unknown): boolean {
|
|
60
|
+
if (!(error instanceof RebaseApiError)) return false;
|
|
61
|
+
return error.code === "23505" || error.status === 409;
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
export interface ConnectivityOptions {
|
|
49
65
|
/** First retry delay after a failure. Defaults to 1 000 ms. */
|
|
50
66
|
initialBackoffMs?: number;
|
|
@@ -31,6 +31,14 @@ function createFakeServer() {
|
|
|
31
31
|
return tables.get(slug)!;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Requests parked mid-flight, keyed `${op}:${id}`. Holding a request open is
|
|
36
|
+
* the only way to reproduce anything that goes wrong *while* an op is on the
|
|
37
|
+
* wire — the queue treats an in-flight op differently from a queued one, and
|
|
38
|
+
* a fake that returns immediately can never be in that state.
|
|
39
|
+
*/
|
|
40
|
+
const gates = new Map<string, Promise<void>>();
|
|
41
|
+
|
|
34
42
|
function guard(slug: string, op: string, id?: unknown) {
|
|
35
43
|
calls.push({ collection: slug, op, id });
|
|
36
44
|
if (!state.online) throw new TypeError("fetch failed");
|
|
@@ -38,6 +46,11 @@ function createFakeServer() {
|
|
|
38
46
|
if (rejection) throw rejection;
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
/** Await after `guard`, so a parked request has already been counted. */
|
|
50
|
+
function hold(op: string, id?: unknown): Promise<void> | undefined {
|
|
51
|
+
return gates.get(`${op}:${String(id)}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
function client(slug: string): CollectionClient<Row> {
|
|
42
55
|
const fake = {
|
|
43
56
|
async find(params?: FindParams) {
|
|
@@ -51,6 +64,7 @@ function createFakeServer() {
|
|
|
51
64
|
async create(data: Row, id?: string | number) {
|
|
52
65
|
const wanted = id ?? data.id;
|
|
53
66
|
guard(slug, "create", wanted);
|
|
67
|
+
await hold("create", wanted);
|
|
54
68
|
const rowId = idRewrites.get(String(wanted)) ?? wanted ?? `srv-${table(slug).size + 1}`;
|
|
55
69
|
const row = { ...data, id: rowId };
|
|
56
70
|
table(slug).set(String(rowId), row);
|
|
@@ -92,6 +106,7 @@ function createFakeServer() {
|
|
|
92
106
|
client,
|
|
93
107
|
rejections,
|
|
94
108
|
idRewrites,
|
|
109
|
+
gates,
|
|
95
110
|
countCalls: (op: string) => calls.filter((c) => c.op === op).length
|
|
96
111
|
};
|
|
97
112
|
}
|
|
@@ -725,3 +740,118 @@ describe("counts", () => {
|
|
|
725
740
|
expect(await posts.count()).toBe(1);
|
|
726
741
|
});
|
|
727
742
|
});
|
|
743
|
+
|
|
744
|
+
describe("a write made while another is on the wire", () => {
|
|
745
|
+
/**
|
|
746
|
+
* `flush` awaits `replay(op)` with `op` still sitting at the head of the
|
|
747
|
+
* queue, so for the whole duration of that request the in-flight op is also
|
|
748
|
+
* the tail — and both of `enqueue`'s shortcuts reach for the tail.
|
|
749
|
+
*/
|
|
750
|
+
it("does not swallow an edit that lands mid-request", async () => {
|
|
751
|
+
const server = createFakeServer();
|
|
752
|
+
const { wrap, manager } = createManager(server);
|
|
753
|
+
const posts = wrap("posts");
|
|
754
|
+
|
|
755
|
+
server.state.online = false;
|
|
756
|
+
await posts.create({ title: "first" }, "p1");
|
|
757
|
+
|
|
758
|
+
// Park the create so the edit below arrives while it is in flight.
|
|
759
|
+
let release!: () => void;
|
|
760
|
+
server.gates.set("create:p1", new Promise<void>((resolve) => { release = resolve; }));
|
|
761
|
+
|
|
762
|
+
server.state.online = true;
|
|
763
|
+
const syncing = manager.sync();
|
|
764
|
+
await settle();
|
|
765
|
+
|
|
766
|
+
await posts.update("p1", { title: "edited" });
|
|
767
|
+
release();
|
|
768
|
+
await syncing;
|
|
769
|
+
await manager.sync();
|
|
770
|
+
|
|
771
|
+
// Coalesced into the in-flight create, this edit was dropped with it on
|
|
772
|
+
// ACK: never sent, and gone from the queue. The user's second keystroke
|
|
773
|
+
// vanished with no error anywhere.
|
|
774
|
+
expect(server.table("posts").get("p1")).toMatchObject({ title: "edited" });
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("does not strand a row when a delete cancels out an in-flight create", async () => {
|
|
778
|
+
const server = createFakeServer();
|
|
779
|
+
const { wrap, manager } = createManager(server);
|
|
780
|
+
const posts = wrap("posts");
|
|
781
|
+
|
|
782
|
+
server.state.online = false;
|
|
783
|
+
const created = await posts.create({ title: "doomed" });
|
|
784
|
+
const id = String((created as Row).id);
|
|
785
|
+
|
|
786
|
+
let release!: () => void;
|
|
787
|
+
server.gates.set(`create:${id}`, new Promise<void>((resolve) => { release = resolve; }));
|
|
788
|
+
|
|
789
|
+
server.state.online = true;
|
|
790
|
+
const syncing = manager.sync();
|
|
791
|
+
await settle();
|
|
792
|
+
|
|
793
|
+
// Cancel-out assumes the server never saw the create. It is reading it
|
|
794
|
+
// right now, so the row lands — and if the delete is discarded with it,
|
|
795
|
+
// nothing will ever remove it.
|
|
796
|
+
await posts.delete(id);
|
|
797
|
+
release();
|
|
798
|
+
await syncing;
|
|
799
|
+
await manager.sync();
|
|
800
|
+
|
|
801
|
+
expect(server.table("posts").has(id)).toBe(false);
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
describe("replaying a create whose response was lost", () => {
|
|
806
|
+
it("keeps the row when the server says it is already there", async () => {
|
|
807
|
+
const server = createFakeServer();
|
|
808
|
+
const errors: Error[] = [];
|
|
809
|
+
const { wrap, manager } = createManager(server, {
|
|
810
|
+
onSyncError: (error) => errors.push(error)
|
|
811
|
+
});
|
|
812
|
+
const posts = wrap("posts");
|
|
813
|
+
|
|
814
|
+
server.state.online = false;
|
|
815
|
+
const created = await posts.create({ title: "saved" });
|
|
816
|
+
const id = String((created as Row).id);
|
|
817
|
+
|
|
818
|
+
// The first attempt committed; only the ACK was lost. The replay
|
|
819
|
+
// therefore finds the row already present.
|
|
820
|
+
server.table("posts").set(id, { id, title: "saved" });
|
|
821
|
+
server.rejections.set(`create:${id}`, new RebaseApiError("duplicate key", { status: 409, code: "23505" }));
|
|
822
|
+
|
|
823
|
+
server.state.online = true;
|
|
824
|
+
await manager.sync();
|
|
825
|
+
|
|
826
|
+
// Read with the network down, or this falls through to the server and
|
|
827
|
+
// passes on the row seeded above no matter what the local store did —
|
|
828
|
+
// which is exactly how this test first passed against the bug.
|
|
829
|
+
server.state.online = false;
|
|
830
|
+
// The old behaviour rolled the write back and deleted the local row —
|
|
831
|
+
// the one case where the row genuinely exists on the server.
|
|
832
|
+
expect(await posts.findById(id)).toMatchObject({ title: "saved" });
|
|
833
|
+
expect(errors).toHaveLength(0);
|
|
834
|
+
expect(manager.api.status().pending).toBe(0);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it("still reports a real conflict on a caller-supplied id", async () => {
|
|
838
|
+
const server = createFakeServer();
|
|
839
|
+
const errors: Error[] = [];
|
|
840
|
+
const { wrap, manager } = createManager(server, {
|
|
841
|
+
onSyncError: (error) => errors.push(error)
|
|
842
|
+
});
|
|
843
|
+
const posts = wrap("posts");
|
|
844
|
+
|
|
845
|
+
server.state.online = false;
|
|
846
|
+
// The caller chose this id, so a duplicate may well be someone else's
|
|
847
|
+
// row. Swallowing that would hide a genuine collision.
|
|
848
|
+
await posts.create({ title: "mine" }, "chosen-1");
|
|
849
|
+
server.rejections.set("create:chosen-1", new RebaseApiError("duplicate key", { status: 409, code: "23505" }));
|
|
850
|
+
|
|
851
|
+
server.state.online = true;
|
|
852
|
+
await manager.sync();
|
|
853
|
+
|
|
854
|
+
expect(errors).toHaveLength(1);
|
|
855
|
+
expect(manager.api.status().pending).toBe(0);
|
|
856
|
+
});
|
|
857
|
+
});
|
package/src/offline.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { FindResult, LogicalCondition, SDKCollectionClient, WhereFilterOp, Where
|
|
|
3
3
|
import { CollectionClient, LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
|
|
4
4
|
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
5
5
|
import { dehydrateRow, hydrateRow } from "./offline-codec";
|
|
6
|
-
import { ConnectivityMonitor, isNetworkError, isRetryableError } from "./offline-connectivity";
|
|
6
|
+
import { ConnectivityMonitor, isDuplicateKeyError, isNetworkError, isRetryableError } from "./offline-connectivity";
|
|
7
7
|
import {
|
|
8
8
|
IndexedDBOfflineStore,
|
|
9
9
|
MemoryOfflineStore,
|
|
@@ -231,6 +231,25 @@ export class OfflineManager {
|
|
|
231
231
|
private collections = new Map<string, CollectionState>();
|
|
232
232
|
/** In-memory mirror of the current scope's queue, in replay order. */
|
|
233
233
|
private queue: PendingMutation[] = [];
|
|
234
|
+
/**
|
|
235
|
+
* The mutation currently on the wire, if any.
|
|
236
|
+
*
|
|
237
|
+
* `flush` awaits `replay(op)` with `op` still at the head of `queue`, so for
|
|
238
|
+
* the whole duration of that request the in-flight op is also the queue's
|
|
239
|
+
* *tail* whenever it is the only entry. Both shortcuts in `enqueue` reach
|
|
240
|
+
* for the tail, and neither may touch an op the server is already reading:
|
|
241
|
+
*
|
|
242
|
+
* - Coalescing an update into it mutates a payload that has already been
|
|
243
|
+
* serialized and sent, and `drop` then removes the whole entry on ACK —
|
|
244
|
+
* so the second edit is neither sent nor kept. A silently lost write.
|
|
245
|
+
* - Cancelling it out against a delete assumes the server never saw the
|
|
246
|
+
* create. It is seeing it right now, so the row would be created and the
|
|
247
|
+
* delete never queued — an orphan row nothing will ever remove.
|
|
248
|
+
*
|
|
249
|
+
* Guarding on the id rather than on a boolean keeps this correct if the
|
|
250
|
+
* flush loop ever sends more than one op at a time.
|
|
251
|
+
*/
|
|
252
|
+
private inFlightId: string | null = null;
|
|
234
253
|
private queueLoad?: Promise<void>;
|
|
235
254
|
/** Serializes enqueues so concurrent writes keep the order the app made them. */
|
|
236
255
|
private enqueueChain: Promise<unknown> = Promise.resolve();
|
|
@@ -520,7 +539,13 @@ export class OfflineManager {
|
|
|
520
539
|
|
|
521
540
|
update: async (id: string | number, data: Partial<M>) => {
|
|
522
541
|
await this.ensureCollection(slug);
|
|
523
|
-
|
|
542
|
+
// Never overtake a write already queued for this row. The
|
|
543
|
+
// reads already respect the queue; the writes did not, so an
|
|
544
|
+
// edit made while the row's own create was still pending went
|
|
545
|
+
// straight to a server that had never heard of the row and came
|
|
546
|
+
// back 404 — the caller's edit failing on a row they could see.
|
|
547
|
+
// Queuing keeps the order the app issued the writes in.
|
|
548
|
+
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) {
|
|
524
549
|
try {
|
|
525
550
|
const row = await inner.update(id, data);
|
|
526
551
|
this.connectivity.markSuccess();
|
|
@@ -548,7 +573,10 @@ export class OfflineManager {
|
|
|
548
573
|
|
|
549
574
|
delete: async (id: string | number) => {
|
|
550
575
|
await this.ensureCollection(slug);
|
|
551
|
-
|
|
576
|
+
// As in `update`: a delete must not overtake this row's own
|
|
577
|
+
// queued create, or it 404s and the create then lands behind
|
|
578
|
+
// it, leaving the row the caller just deleted.
|
|
579
|
+
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) {
|
|
552
580
|
try {
|
|
553
581
|
await inner.delete(id);
|
|
554
582
|
this.connectivity.markSuccess();
|
|
@@ -1212,6 +1240,7 @@ export class OfflineManager {
|
|
|
1212
1240
|
if (mutation.type === "update") {
|
|
1213
1241
|
const tail = this.queue[this.queue.length - 1];
|
|
1214
1242
|
if (tail
|
|
1243
|
+
&& tail.mutationId !== this.inFlightId
|
|
1215
1244
|
&& tail.collection === mutation.collection
|
|
1216
1245
|
&& (tail.type === "create" || tail.type === "update")
|
|
1217
1246
|
&& tail.id === mutation.id) {
|
|
@@ -1233,14 +1262,19 @@ export class OfflineManager {
|
|
|
1233
1262
|
// the delete must still remove), and neither do rows queued inside
|
|
1234
1263
|
// a createMany (the bulk op replays first, then the delete).
|
|
1235
1264
|
if (mutation.type === "delete") {
|
|
1265
|
+
// An in-flight create disqualifies the shortcut entirely: the
|
|
1266
|
+
// server is being told about the row as we speak, so "it never
|
|
1267
|
+
// saw it" is false and the delete has to replay after it.
|
|
1236
1268
|
const hasPendingCreate = this.queue.some((m) =>
|
|
1237
1269
|
m.collection === mutation.collection && m.type === "create"
|
|
1238
|
-
&& m.id === mutation.id && m.generatedId === true
|
|
1270
|
+
&& m.id === mutation.id && m.generatedId === true
|
|
1271
|
+
&& m.mutationId !== this.inFlightId);
|
|
1239
1272
|
if (hasPendingCreate) {
|
|
1240
1273
|
const doomed = this.queue.filter((m) =>
|
|
1241
1274
|
m.collection === mutation.collection
|
|
1242
1275
|
&& m.id === mutation.id
|
|
1243
|
-
&& (m.type === "create" || m.type === "update")
|
|
1276
|
+
&& (m.type === "create" || m.type === "update")
|
|
1277
|
+
&& m.mutationId !== this.inFlightId);
|
|
1244
1278
|
for (const op of doomed) await this.store.dequeue(this.queueKey(op));
|
|
1245
1279
|
this.queue = this.queue.filter((m) => !doomed.includes(m));
|
|
1246
1280
|
this.afterQueueChange();
|
|
@@ -1332,31 +1366,40 @@ export class OfflineManager {
|
|
|
1332
1366
|
while (this.queue.length > 0 && !this.disposed) {
|
|
1333
1367
|
const op = this.queue[0];
|
|
1334
1368
|
touched.add(op.collection);
|
|
1369
|
+
// Held across `drop` as well as `replay`: between the ACK and
|
|
1370
|
+
// the dequeue the op is still in `queue`, still the tail, and
|
|
1371
|
+
// still about to be removed — coalescing into it there loses
|
|
1372
|
+
// the write exactly as coalescing during the request does.
|
|
1373
|
+
this.inFlightId = op.mutationId;
|
|
1335
1374
|
try {
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1375
|
+
try {
|
|
1376
|
+
await this.replay(op);
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
if (isNetworkError(error)) {
|
|
1379
|
+
// Still offline — keep the op and everything behind it.
|
|
1380
|
+
this.connectivity.markFailure();
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
op.attempts = (op.attempts ?? 0) + 1;
|
|
1384
|
+
op.lastError = (error as Error)?.message ?? String(error);
|
|
1385
|
+
if (isRetryableError(error) && op.attempts < this.maxRetries) {
|
|
1386
|
+
// The server is busy, not unhappy. Keep the op — and
|
|
1387
|
+
// its place in line, since later writes may depend on
|
|
1388
|
+
// it — and come back after a backoff.
|
|
1389
|
+
await this.store.enqueue(this.queueKey(op), op).catch(() => undefined);
|
|
1390
|
+
this.connectivity.deferRetry();
|
|
1391
|
+
this.patchStatus({ lastError: op.lastError });
|
|
1392
|
+
break;
|
|
1393
|
+
}
|
|
1394
|
+
await this.rejectMutation(op, error as Error);
|
|
1395
|
+
continue;
|
|
1353
1396
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1397
|
+
this.connectivity.markSuccess();
|
|
1398
|
+
await this.drop(op);
|
|
1399
|
+
flushed++;
|
|
1400
|
+
} finally {
|
|
1401
|
+
this.inFlightId = null;
|
|
1356
1402
|
}
|
|
1357
|
-
this.connectivity.markSuccess();
|
|
1358
|
-
await this.drop(op);
|
|
1359
|
-
flushed++;
|
|
1360
1403
|
}
|
|
1361
1404
|
} finally {
|
|
1362
1405
|
this.patchStatus({ syncing: false });
|
|
@@ -1382,7 +1425,36 @@ export class OfflineManager {
|
|
|
1382
1425
|
const inner = this.innerFor(op.collection);
|
|
1383
1426
|
if (op.type === "create") {
|
|
1384
1427
|
// The queued row already carries its (client-generated) id.
|
|
1385
|
-
|
|
1428
|
+
let row: AnyRow | undefined;
|
|
1429
|
+
try {
|
|
1430
|
+
// The mutation id names this write, so a server that stores keys
|
|
1431
|
+
// recognises a replay instead of inserting a second row. This is
|
|
1432
|
+
// the only defence for a table with a server-assigned id: the id
|
|
1433
|
+
// the client chose was never used, so a duplicate is invisible
|
|
1434
|
+
// from here. Ignored by servers that do not support it.
|
|
1435
|
+
row = await inner.create(op.data as AnyRow, undefined, { idempotencyKey: op.mutationId });
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
// A lost response, not a rejection. The request reached the
|
|
1438
|
+
// server and committed; only the ACK went missing, so the
|
|
1439
|
+
// replay finds the row already there.
|
|
1440
|
+
//
|
|
1441
|
+
// Restricted to ids the SDK minted: a fresh uuid cannot name a
|
|
1442
|
+
// row anyone else created, so a duplicate under it is
|
|
1443
|
+
// necessarily this mutation's own first attempt. A
|
|
1444
|
+
// caller-supplied id carries no such guarantee — it may well
|
|
1445
|
+
// collide with a row that was already there, which is a real
|
|
1446
|
+
// conflict the caller has to hear about.
|
|
1447
|
+
//
|
|
1448
|
+
// Without this, `rejectMutation` rolled the write back and
|
|
1449
|
+
// DELETED the local row — the one case where the row does exist
|
|
1450
|
+
// on the server. The user watched their own saved record vanish.
|
|
1451
|
+
if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
|
|
1452
|
+
row = await inner.findById(op.id!).catch(() => undefined) as AnyRow | undefined;
|
|
1453
|
+
// The read can fail on its own (offline again, RLS). The row is
|
|
1454
|
+
// known to exist, so keep the local copy rather than rolling
|
|
1455
|
+
// back; the next refresh reconciles it.
|
|
1456
|
+
if (!row) return;
|
|
1457
|
+
}
|
|
1386
1458
|
await this.adoptServerRow(op, op.id, row);
|
|
1387
1459
|
} else if (op.type === "createMany") {
|
|
1388
1460
|
const queued = (op.data as AnyRow[]) ?? [];
|