@rebasepro/client 0.13.0 → 0.13.1-canary.g06dbe5b

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,9 +14,9 @@ type Row = Record<string, unknown>;
14
14
  * online/offline decision logic rather than a mock of it.
15
15
  */
16
16
  function createFakeServer() {
17
- const state = { online: true };
17
+ const state: { online: boolean; rejectNextCreate?: Error; rejectEveryCreate?: Error } = { online: true };
18
18
  const tables = new Map<string, Map<string, Row>>();
19
- const log: { collection: string; op: string; id?: unknown; upsert?: boolean }[] = [];
19
+ const log: { collection: string; op: string; id?: unknown; ids?: unknown[]; upsert?: boolean }[] = [];
20
20
 
21
21
  function table(slug: string): Map<string, Row> {
22
22
  if (!tables.has(slug)) tables.set(slug, new Map());
@@ -41,6 +41,13 @@ function createFakeServer() {
41
41
  },
42
42
  async create(data: Partial<Row>, id?: string | number) {
43
43
  assertOnline();
44
+ // Lets a test answer the first replay the way a server does
45
+ // when it is still holding this write's idempotency key.
46
+ const injected = state.rejectNextCreate ?? state.rejectEveryCreate;
47
+ if (injected) {
48
+ state.rejectNextCreate = undefined;
49
+ throw injected;
50
+ }
44
51
  const rowId = id ?? data.id ?? `srv-${table(slug).size + 1}`;
45
52
  log.push({ collection: slug, op: "create", id: rowId });
46
53
  const row = { ...data, id: rowId };
@@ -67,11 +74,30 @@ function createFakeServer() {
67
74
  table(slug).set(String(id), row);
68
75
  return row;
69
76
  },
77
+ async updateMany(updates: { id: string | number; data: Partial<Row> }[]) {
78
+ assertOnline();
79
+ log.push({ collection: slug, op: "updateMany", ids: updates.map(u => u.id) });
80
+ return updates.map(({ id, data }) => {
81
+ const existing = table(slug).get(String(id));
82
+ if (!existing) throw new RebaseApiError("Not found", { status: 404 });
83
+ const row = { ...existing, ...data };
84
+ table(slug).set(String(id), row);
85
+ return row;
86
+ });
87
+ },
70
88
  async delete(id: string | number) {
71
89
  assertOnline();
72
90
  log.push({ collection: slug, op: "delete", id });
73
91
  table(slug).delete(String(id));
74
92
  },
93
+ async deleteMany(ids: (string | number)[]) {
94
+ assertOnline();
95
+ log.push({ collection: slug, op: "deleteMany", ids });
96
+ for (const id of ids) {
97
+ if (!table(slug).has(String(id))) throw new RebaseApiError("Not found", { status: 404 });
98
+ }
99
+ for (const id of ids) table(slug).delete(String(id));
100
+ },
75
101
  async count(_params?: FindParams) {
76
102
  assertOnline();
77
103
  return table(slug).size;
@@ -292,6 +318,64 @@ describe("OfflineManager", () => {
292
318
  expect(server.table("posts").get("p9")).toMatchObject({ title: "kept" });
293
319
  });
294
320
 
321
+ it("keeps a write the server is still answering, and lands it on the next flush", async () => {
322
+ // `IDEMPOTENCY_KEY_IN_PROGRESS` means the server holds this write's
323
+ // key and has not answered yet — the row may not exist at all. Read
324
+ // as any other 409 it looked like "your row is already there": the
325
+ // queue looked the row up, found nothing, decided there was nothing
326
+ // left to do and deleted the write. The record survived only in the
327
+ // local store and disappeared at the next reconciliation, silently.
328
+ const server = createFakeServer();
329
+ const onSyncError = jest.fn<(error: Error, mutation: PendingMutation) => void>();
330
+ const { manager, wrap } = createManager(server, { onSyncError });
331
+
332
+ server.state.online = false;
333
+ await wrap("posts").create({ title: "queued" });
334
+
335
+ server.state.online = true;
336
+ server.state.rejectNextCreate = new RebaseApiError("still in progress", {
337
+ status: 409,
338
+ code: "IDEMPOTENCY_KEY_IN_PROGRESS"
339
+ });
340
+
341
+ const refused = await manager.sync();
342
+ expect(refused).toEqual({ flushed: 0, remaining: 1 });
343
+ expect(onSyncError).not.toHaveBeenCalled();
344
+ expect(server.table("posts").size).toBe(0);
345
+
346
+ // The claim expired (or its request finally answered): the same
347
+ // queued write goes through untouched.
348
+ const settled = await manager.sync();
349
+ expect(settled).toEqual({ flushed: 1, remaining: 0 });
350
+ expect([...server.table("posts").values()]).toMatchObject([{ title: "queued" }]);
351
+ });
352
+
353
+ it("outlasts the claim's lease rather than giving up on the default retry budget", async () => {
354
+ // Retries double from a second, so the five a busy server gets are
355
+ // spent inside half a minute — less than the lease the server gives
356
+ // a claim whose process died. Spending them here would roll back
357
+ // the write a few seconds before the key became free again.
358
+ const server = createFakeServer();
359
+ const onSyncError = jest.fn<(error: Error, mutation: PendingMutation) => void>();
360
+ const { manager, wrap } = createManager(server, { onSyncError });
361
+
362
+ server.state.online = false;
363
+ await wrap("posts").create({ title: "queued" });
364
+
365
+ server.state.online = true;
366
+ server.state.rejectEveryCreate = new RebaseApiError("still in progress", {
367
+ status: 409,
368
+ code: "IDEMPOTENCY_KEY_IN_PROGRESS"
369
+ });
370
+ for (let attempt = 0; attempt < 8; attempt++) await manager.sync();
371
+
372
+ expect(onSyncError).not.toHaveBeenCalled();
373
+ expect(await manager.api.pending()).toHaveLength(1);
374
+
375
+ server.state.rejectEveryCreate = undefined;
376
+ expect(await manager.sync()).toEqual({ flushed: 1, remaining: 0 });
377
+ });
378
+
295
379
  it("notifies queue-size listeners as writes queue and drain", async () => {
296
380
  const server = createFakeServer();
297
381
  const { manager, wrap } = createManager(server);
@@ -503,6 +587,100 @@ describe("OfflineManager", () => {
503
587
  });
504
588
  });
505
589
 
590
+ describe("updateMany / deleteMany", () => {
591
+ it("queues a bulk update offline, overlays it, and replays it once", async () => {
592
+ const server = createFakeServer();
593
+ const { manager, wrap } = createManager(server);
594
+ const posts = wrap("posts");
595
+ await posts.create({ title: "a" });
596
+ await posts.create({ title: "b" });
597
+ const seeded = (await posts.find()).data;
598
+
599
+ server.state.online = false;
600
+ const updated = await posts.updateMany(
601
+ seeded.map((r) => ({ id: r.id as string, data: { title: `${r.title}!` } }))
602
+ );
603
+ expect(updated.map((r) => r.title)).toEqual(["a!", "b!"]);
604
+
605
+ // The overlay is what a UI renders while the write is queued.
606
+ const overlaid = await posts.find();
607
+ expect(overlaid.data.map((r) => r.title).sort()).toEqual(["a!", "b!"]);
608
+
609
+ server.state.online = true;
610
+ await manager.sync();
611
+ const bulk = server.log.filter((e) => e.op === "updateMany");
612
+ expect(bulk).toHaveLength(1);
613
+ expect([...server.table("posts").values()].map((r) => r.title).sort()).toEqual(["a!", "b!"]);
614
+ });
615
+
616
+ it("queues a bulk delete offline, hides the rows, and replays it once", async () => {
617
+ const server = createFakeServer();
618
+ const { manager, wrap } = createManager(server);
619
+ const posts = wrap("posts");
620
+ await posts.create({ title: "a" });
621
+ await posts.create({ title: "b" });
622
+ await posts.create({ title: "c" });
623
+ const seeded = (await posts.find()).data;
624
+
625
+ server.state.online = false;
626
+ await posts.deleteMany(seeded.slice(0, 2).map((r) => r.id as string));
627
+
628
+ const overlaid = await posts.find();
629
+ expect(overlaid.data.map((r) => r.title)).toEqual(["c"]);
630
+
631
+ server.state.online = true;
632
+ await manager.sync();
633
+ const bulk = server.log.filter((e) => e.op === "deleteMany");
634
+ expect(bulk).toHaveLength(1);
635
+ expect([...server.table("posts").values()].map((r) => r.title)).toEqual(["c"]);
636
+ });
637
+
638
+ it("queues a bulk update whose rows have writes still pending", async () => {
639
+ // Splitting the batch — some rows now, some later — would break the
640
+ // one guarantee a batch makes, and would reorder a write against a
641
+ // row whose own create has not landed yet.
642
+ const server = createFakeServer();
643
+ const { manager, wrap } = createManager(server);
644
+ const posts = wrap("posts");
645
+ await posts.find();
646
+
647
+ server.state.online = false;
648
+ const created = await posts.create({ title: "fresh" });
649
+ await posts.updateMany([{ id: created.id as string, data: { title: "edited" } }]);
650
+
651
+ server.state.online = true;
652
+ await manager.sync();
653
+
654
+ // The create ran first, then the batch — order preserved.
655
+ expect(server.log.map((e) => e.op)).toEqual(["create", "updateMany"]);
656
+ expect([...server.table("posts").values()][0].title).toBe("edited");
657
+ });
658
+
659
+ it("goes straight to the server when online", async () => {
660
+ const server = createFakeServer();
661
+ const { wrap } = createManager(server);
662
+ const posts = wrap("posts");
663
+ const a = await posts.create({ title: "a" });
664
+
665
+ await posts.updateMany([{ id: a.id as string, data: { title: "A" } }]);
666
+ expect(server.table("posts").get(String(a.id))!.title).toBe("A");
667
+
668
+ await posts.deleteMany([a.id as string]);
669
+ expect(server.table("posts").size).toBe(0);
670
+ });
671
+
672
+ it("treats empty input as a no-op that touches neither queue nor server", async () => {
673
+ const server = createFakeServer();
674
+ const { wrap } = createManager(server);
675
+ const posts = wrap("posts");
676
+ await posts.find();
677
+
678
+ expect(await posts.updateMany([])).toEqual([]);
679
+ await posts.deleteMany([]);
680
+ expect(server.log.filter((e) => e.op.endsWith("Many"))).toHaveLength(0);
681
+ });
682
+ });
683
+
506
684
  describe("concurrency", () => {
507
685
  it("gives concurrent offline writes distinct, ordered mutation ids", async () => {
508
686
  const server = createFakeServer();
package/src/offline.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import { buildQueryString, FindParams, RebaseApiError } from "./transport";
2
- import { FindAllParams, FindResult, IterateParams, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue } from "@rebasepro/types";
2
+ import { FindAllParams, FindResult, IterateParams, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValueFor, WriteOptions } from "@rebasepro/types";
3
3
  import { collectAllPages, paginateFind } from "@rebasepro/common";
4
4
  import { CollectionClient, LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
5
5
  import { SDKQueryBuilder } from "./sdk_query_builder";
6
6
  import { dehydrateRow, hydrateRow } from "./offline-codec";
7
- import { ConnectivityMonitor, isDuplicateKeyError, isNetworkError, isRetryableError } from "./offline-connectivity";
7
+ import {
8
+ ConnectivityMonitor,
9
+ isDuplicateKeyError,
10
+ isIdempotencyInProgressError,
11
+ isNetworkError,
12
+ isRetryableError
13
+ } from "./offline-connectivity";
8
14
  import {
9
15
  IndexedDBOfflineStore,
10
16
  MemoryOfflineStore,
@@ -14,7 +20,9 @@ import {
14
20
  } from "./offline-store";
15
21
  import {
16
22
  isExactlyEvaluable,
23
+ isLocallySortable,
17
24
  matchesParams,
25
+ resolvePagination,
18
26
  runLocalQuery,
19
27
  sortRows
20
28
  } from "./offline-query";
@@ -217,6 +225,18 @@ interface Observer {
217
225
  // all 1,700 lines of it in silence.
218
226
  const MISSING = "\u0000missing";
219
227
 
228
+ /**
229
+ * Replays to spend on a mutation whose idempotency key the server is still
230
+ * holding, when the app has not asked for more.
231
+ *
232
+ * Retries double from a second and cap at the sync interval, so the default
233
+ * budget of five covers about half a minute — less than the lease a server
234
+ * gives a claim nobody came back for. This many outlast it with room for a slow
235
+ * batch, and the count is what stops a server that never releases the key from
236
+ * blocking the queue behind it indefinitely.
237
+ */
238
+ const IN_PROGRESS_MIN_RETRIES = 12;
239
+
220
240
  export class OfflineManager {
221
241
  private readonly store: OfflineStore;
222
242
  private readonly maxCachedQueries: number;
@@ -545,6 +565,83 @@ export class OfflineManager {
545
565
  return rows;
546
566
  },
547
567
 
568
+ updateMany: async (updates: { id: string | number; data: Partial<M> }[], options?: WriteOptions) => {
569
+ await this.ensureCollection(slug);
570
+ if (!Array.isArray(updates)) {
571
+ throw new TypeError("updateMany expects an array of { id, data } entries.");
572
+ }
573
+ if (updates.length === 0) return [];
574
+ // Any row in the batch with a write already queued sends the
575
+ // whole batch to the queue. Splitting it — some rows now, some
576
+ // later — would break the one guarantee a batch makes, that its
577
+ // rows land together, and would reorder writes against a row
578
+ // whose own create has not landed yet.
579
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
580
+ if (this.connectivity.shouldAttempt() && !anyPending) {
581
+ try {
582
+ const rows = await inner.updateMany(updates, options);
583
+ this.connectivity.markSuccess();
584
+ await this.ingest(slug, rows);
585
+ this.notifyCollection(slug);
586
+ return rows;
587
+ } catch (error) {
588
+ if (!isNetworkError(error)) throw error;
589
+ this.connectivity.markFailure();
590
+ }
591
+ }
592
+ const rollback: Record<string, AnyRow | null> = {};
593
+ const optimistic: M[] = [];
594
+ for (const { id, data } of updates) {
595
+ const base = this.rawLocalRow(slug, id);
596
+ rollback[String(id)] = base ?? null;
597
+ optimistic.push({ ...(base ?? {}), ...(data as AnyRow), id } as unknown as M);
598
+ }
599
+ await this.enqueue({
600
+ collection: slug,
601
+ type: "updateMany",
602
+ updates: updates.map((u) => ({ id: u.id,
603
+ data: u.data as AnyRow })),
604
+ rollback: { rows: rollback }
605
+ });
606
+ for (const row of optimistic) this.setLocalRow(slug, row.id as string | number, row);
607
+ this.notifyCollection(slug);
608
+ return optimistic;
609
+ },
610
+
611
+ deleteMany: async (ids: (string | number)[], options?: WriteOptions) => {
612
+ await this.ensureCollection(slug);
613
+ if (!Array.isArray(ids)) {
614
+ throw new TypeError("deleteMany expects an array of ids.");
615
+ }
616
+ if (ids.length === 0) return;
617
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
618
+ if (this.connectivity.shouldAttempt() && !anyPending) {
619
+ try {
620
+ await inner.deleteMany(ids, options);
621
+ this.connectivity.markSuccess();
622
+ for (const id of ids) this.removeLocalRow(slug, id, true);
623
+ this.notifyCollection(slug);
624
+ this.scheduleRefresh(slug);
625
+ return;
626
+ } catch (error) {
627
+ if (!isNetworkError(error)) throw error;
628
+ this.connectivity.markFailure();
629
+ }
630
+ }
631
+ const rollback: Record<string, AnyRow | null> = {};
632
+ for (const id of ids) {
633
+ rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
634
+ }
635
+ await this.enqueue({
636
+ collection: slug,
637
+ type: "deleteMany",
638
+ ids,
639
+ rollback: { rows: rollback }
640
+ });
641
+ for (const id of ids) this.removeLocalRow(slug, id, false);
642
+ this.notifyCollection(slug);
643
+ },
644
+
548
645
  update: async (id: string | number, data: Partial<M>) => {
549
646
  await this.ensureCollection(slug);
550
647
  // Never overtake a write already queued for this row. The
@@ -651,13 +748,14 @@ export class OfflineManager {
651
748
  return builder.where(
652
749
  columnOrCondition as keyof M & string,
653
750
  operator!,
654
- value as WhereValue<M[keyof M & string]>
751
+ value as WhereValueFor<WhereFilterOp, M[keyof M & string]>
655
752
  );
656
753
  },
657
754
  orderBy: (column, direction) => new SDKQueryBuilder<M>(wrapped).orderBy(column, direction),
658
755
  limit: (count) => new SDKQueryBuilder<M>(wrapped).limit(count),
659
756
  offset: (count) => new SDKQueryBuilder<M>(wrapped).offset(count),
660
- search: (searchString) => new SDKQueryBuilder<M>(wrapped).search(searchString),
757
+ search: (searchString, options) => new SDKQueryBuilder<M>(wrapped).search(searchString, options),
758
+ vectorSearch: (property, vector, options) => new SDKQueryBuilder<M>(wrapped).vectorSearch(property, vector, options),
661
759
  include: (...relations) => new SDKQueryBuilder<M>(wrapped).include(...relations)
662
760
  };
663
761
 
@@ -942,7 +1040,7 @@ export class OfflineManager {
942
1040
  if (!state) {
943
1041
  return {
944
1042
  data: [],
945
- meta: { total: 0, limit: params?.limit ?? 20, offset: params?.offset ?? 0, hasMore: false },
1043
+ meta: { ...resolvePagination(params), total: 0, hasMore: false },
946
1044
  fromCache: true,
947
1045
  hasPendingWrites: false,
948
1046
  partial: true
@@ -996,9 +1094,28 @@ export class OfflineManager {
996
1094
  rows.push(entry.row as M);
997
1095
  added++;
998
1096
  }
999
- if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
1000
1097
  }
1001
1098
 
1099
+ // Order is part of the query, not a detail of how the rows were
1100
+ // obtained. This used to sort only when a locally-created row had been
1101
+ // injected — every other read handed back cache order, which is
1102
+ // insertion order, and a caller that asked for `orderBy` got whatever
1103
+ // the store happened to hold. In the admin that is the collection's
1104
+ // `sort` being silently ignored on every list backed by this overlay:
1105
+ // the query carries it, the server honours it, and the answer served
1106
+ // from here did not.
1107
+ //
1108
+ // …but only when the local sort would land where the server's did.
1109
+ // `snapshot.ids` already arrived in the server's order, so re-sorting a
1110
+ // text column with `Intl.Collator` *replaces* a correct order with a
1111
+ // possibly different one — under the C collation Postgres puts
1112
+ // `Banana` before `apple` and the collator does not. When the column
1113
+ // cannot be ordered locally the snapshot's order is the better answer,
1114
+ // and the result says so rather than presenting it as the sorted page
1115
+ // that was asked for.
1116
+ const orderIsLocal = isLocallySortable(rows, params?.orderBy);
1117
+ if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
1118
+
1002
1119
  const total = Math.max(rows.length, snapshot.total - removed + added);
1003
1120
  return {
1004
1121
  data: rows,
@@ -1010,7 +1127,9 @@ export class OfflineManager {
1010
1127
  },
1011
1128
  fromCache,
1012
1129
  hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id as string | number)),
1013
- partial: !exact
1130
+ // Not the page that was asked for if either the membership
1131
+ // decision or the order could not be reproduced here.
1132
+ partial: !exact || !orderIsLocal
1014
1133
  };
1015
1134
  }
1016
1135
 
@@ -1150,12 +1269,13 @@ export class OfflineManager {
1150
1269
  }
1151
1270
 
1152
1271
  private recordSnapshot(slug: string, params: FindParams | undefined, result: FindResult<AnyRow>): QuerySnapshot {
1153
- const meta = result.meta ?? { total: result.data?.length ?? 0, limit: 20, offset: 0, hasMore: false };
1272
+ const window = resolvePagination(params);
1273
+ const meta = result.meta ?? { total: result.data?.length ?? 0, ...window, hasMore: false };
1154
1274
  const snapshot: QuerySnapshot = {
1155
1275
  ids: (result.data ?? []).map((row) => row.id as string | number).filter((id) => id !== undefined),
1156
1276
  total: meta.total ?? result.data?.length ?? 0,
1157
- limit: meta.limit ?? params?.limit ?? 20,
1158
- offset: meta.offset ?? params?.offset ?? 0,
1277
+ limit: meta.limit ?? window.limit,
1278
+ offset: meta.offset ?? window.offset,
1159
1279
  hasMore: meta.hasMore ?? false
1160
1280
  };
1161
1281
  const state = this.collectionState(slug);
@@ -1390,7 +1510,18 @@ export class OfflineManager {
1390
1510
  }
1391
1511
  op.attempts = (op.attempts ?? 0) + 1;
1392
1512
  op.lastError = (error as Error)?.message ?? String(error);
1393
- if (isRetryableError(error) && op.attempts < this.maxRetries) {
1513
+ // A key the server is still holding gets a longer
1514
+ // budget than a busy server does. The claim outlives
1515
+ // the request that took it — the process was killed
1516
+ // between the write and its answer — so it is refused
1517
+ // until the claim's lease runs out, which is longer
1518
+ // than the default five retries reach. Giving up on
1519
+ // that schedule rolls back precisely the write the key
1520
+ // exists to save.
1521
+ const limit = isIdempotencyInProgressError(error)
1522
+ ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES)
1523
+ : this.maxRetries;
1524
+ if (isRetryableError(error) && op.attempts < limit) {
1394
1525
  // The server is busy, not unhappy. Keep the op — and
1395
1526
  // its place in line, since later writes may depend on
1396
1527
  // it — and come back after a backoff.
@@ -1466,13 +1597,39 @@ export class OfflineManager {
1466
1597
  await this.adoptServerRow(op, op.id, row);
1467
1598
  } else if (op.type === "createMany") {
1468
1599
  const queued = (op.data as AnyRow[]) ?? [];
1469
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : undefined);
1600
+ // The mutation id names this batch, exactly as it names a single
1601
+ // `create` above — and it matters more here. Without it, a batch
1602
+ // whose ACK went missing replays as a second genuine import and
1603
+ // duplicates every row it holds, not one. `upsert` masked that for
1604
+ // the callers who set it; nothing covered the ones who did not.
1605
+ const rows = await inner.createMany(queued, {
1606
+ ...(op.upsert ? { upsert: true } : {}),
1607
+ idempotencyKey: op.mutationId
1608
+ });
1470
1609
  for (let i = 0; i < rows.length; i++) {
1471
1610
  await this.adoptServerRow(op, queued[i]?.id as string | number | undefined, rows[i]);
1472
1611
  }
1612
+ } else if (op.type === "updateMany") {
1613
+ const queued = op.updates ?? [];
1614
+ // Keyed like every other replay: an update re-applied in full is
1615
+ // naturally idempotent, but one interleaved with another writer's is
1616
+ // not, and a lost ACK would otherwise re-apply a stale batch over
1617
+ // newer data.
1618
+ const rows = await inner.updateMany(
1619
+ queued.map(u => ({ id: u.id,
1620
+ data: u.data as AnyRow })),
1621
+ { idempotencyKey: op.mutationId }
1622
+ );
1623
+ for (let i = 0; i < rows.length; i++) {
1624
+ await this.ingestReplaced(op, queued[i].id, rows[i]);
1625
+ }
1473
1626
  } else if (op.type === "update") {
1474
1627
  const row = await inner.update(op.id!, op.data as AnyRow);
1475
1628
  await this.ingestReplaced(op, op.id!, row);
1629
+ } else if (op.type === "deleteMany") {
1630
+ const ids = op.ids ?? [];
1631
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
1632
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
1476
1633
  } else if (op.type === "delete") {
1477
1634
  await inner.delete(op.id!);
1478
1635
  this.removeLocalRow(op.collection, op.id!, true);