@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.
@@ -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.10.1-canary.gdaf6ba4",
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.10.1-canary.gdaf6ba4",
33
- "@rebasepro/types": "0.10.1-canary.gdaf6ba4",
34
- "@rebasepro/utils": "0.10.1-canary.gdaf6ba4"
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
- if (this.connectivity.shouldAttempt()) {
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
- if (this.connectivity.shouldAttempt()) {
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
- await this.replay(op);
1337
- } catch (error) {
1338
- if (isNetworkError(error)) {
1339
- // Still offline — keep the op and everything behind it.
1340
- this.connectivity.markFailure();
1341
- break;
1342
- }
1343
- op.attempts = (op.attempts ?? 0) + 1;
1344
- op.lastError = (error as Error)?.message ?? String(error);
1345
- if (isRetryableError(error) && op.attempts < this.maxRetries) {
1346
- // The server is busy, not unhappy. Keep the op — and
1347
- // its place in line, since later writes may depend on
1348
- // it — and come back after a backoff.
1349
- await this.store.enqueue(this.queueKey(op), op).catch(() => undefined);
1350
- this.connectivity.deferRetry();
1351
- this.patchStatus({ lastError: op.lastError });
1352
- break;
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
- await this.rejectMutation(op, error as Error);
1355
- continue;
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
- const row = await inner.create(op.data as AnyRow);
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[]) ?? [];