@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +452 -84
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline-query.d.ts +59 -3
- package/dist/offline-store.d.ts +8 -1
- package/dist/query-contract.types.d.ts +144 -0
- package/dist/sdk_query_builder.d.ts +46 -4
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth-refresh-overflow.test.ts +89 -0
- package/src/auth.ts +30 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +200 -57
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-connectivity.test.ts +35 -1
- package/src/offline-connectivity.ts +35 -3
- package/src/offline-query.test.ts +116 -2
- package/src/offline-query.ts +123 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +180 -2
- package/src/offline.ts +169 -12
- package/src/query-contract.types.ts +206 -0
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/sdk_query_builder.ts +56 -4
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +34 -0
- package/src/vector-search-query.test.ts +55 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +34 -9
package/src/offline.test.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
1158
|
-
offset: meta.offset ??
|
|
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
|
-
|
|
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
|
-
|
|
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);
|