@rebasepro/client 0.13.0 → 0.13.1-canary.g18cfeb7
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 +263 -73
- package/dist/index.es.js.map +1 -1
- package/dist/offline-query.d.ts +18 -3
- package/dist/offline-store.d.ts +8 -1
- 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 +184 -50
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-query.test.ts +28 -2
- package/src/offline-query.ts +35 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +114 -1
- package/src/offline.ts +121 -7
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +22 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +18 -9
package/src/offline.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ type Row = Record<string, unknown>;
|
|
|
16
16
|
function createFakeServer() {
|
|
17
17
|
const state = { 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());
|
|
@@ -67,11 +67,30 @@ function createFakeServer() {
|
|
|
67
67
|
table(slug).set(String(id), row);
|
|
68
68
|
return row;
|
|
69
69
|
},
|
|
70
|
+
async updateMany(updates: { id: string | number; data: Partial<Row> }[]) {
|
|
71
|
+
assertOnline();
|
|
72
|
+
log.push({ collection: slug, op: "updateMany", ids: updates.map(u => u.id) });
|
|
73
|
+
return updates.map(({ id, data }) => {
|
|
74
|
+
const existing = table(slug).get(String(id));
|
|
75
|
+
if (!existing) throw new RebaseApiError("Not found", { status: 404 });
|
|
76
|
+
const row = { ...existing, ...data };
|
|
77
|
+
table(slug).set(String(id), row);
|
|
78
|
+
return row;
|
|
79
|
+
});
|
|
80
|
+
},
|
|
70
81
|
async delete(id: string | number) {
|
|
71
82
|
assertOnline();
|
|
72
83
|
log.push({ collection: slug, op: "delete", id });
|
|
73
84
|
table(slug).delete(String(id));
|
|
74
85
|
},
|
|
86
|
+
async deleteMany(ids: (string | number)[]) {
|
|
87
|
+
assertOnline();
|
|
88
|
+
log.push({ collection: slug, op: "deleteMany", ids });
|
|
89
|
+
for (const id of ids) {
|
|
90
|
+
if (!table(slug).has(String(id))) throw new RebaseApiError("Not found", { status: 404 });
|
|
91
|
+
}
|
|
92
|
+
for (const id of ids) table(slug).delete(String(id));
|
|
93
|
+
},
|
|
75
94
|
async count(_params?: FindParams) {
|
|
76
95
|
assertOnline();
|
|
77
96
|
return table(slug).size;
|
|
@@ -503,6 +522,100 @@ describe("OfflineManager", () => {
|
|
|
503
522
|
});
|
|
504
523
|
});
|
|
505
524
|
|
|
525
|
+
describe("updateMany / deleteMany", () => {
|
|
526
|
+
it("queues a bulk update offline, overlays it, and replays it once", async () => {
|
|
527
|
+
const server = createFakeServer();
|
|
528
|
+
const { manager, wrap } = createManager(server);
|
|
529
|
+
const posts = wrap("posts");
|
|
530
|
+
await posts.create({ title: "a" });
|
|
531
|
+
await posts.create({ title: "b" });
|
|
532
|
+
const seeded = (await posts.find()).data;
|
|
533
|
+
|
|
534
|
+
server.state.online = false;
|
|
535
|
+
const updated = await posts.updateMany(
|
|
536
|
+
seeded.map((r) => ({ id: r.id as string, data: { title: `${r.title}!` } }))
|
|
537
|
+
);
|
|
538
|
+
expect(updated.map((r) => r.title)).toEqual(["a!", "b!"]);
|
|
539
|
+
|
|
540
|
+
// The overlay is what a UI renders while the write is queued.
|
|
541
|
+
const overlaid = await posts.find();
|
|
542
|
+
expect(overlaid.data.map((r) => r.title).sort()).toEqual(["a!", "b!"]);
|
|
543
|
+
|
|
544
|
+
server.state.online = true;
|
|
545
|
+
await manager.sync();
|
|
546
|
+
const bulk = server.log.filter((e) => e.op === "updateMany");
|
|
547
|
+
expect(bulk).toHaveLength(1);
|
|
548
|
+
expect([...server.table("posts").values()].map((r) => r.title).sort()).toEqual(["a!", "b!"]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("queues a bulk delete offline, hides the rows, and replays it once", async () => {
|
|
552
|
+
const server = createFakeServer();
|
|
553
|
+
const { manager, wrap } = createManager(server);
|
|
554
|
+
const posts = wrap("posts");
|
|
555
|
+
await posts.create({ title: "a" });
|
|
556
|
+
await posts.create({ title: "b" });
|
|
557
|
+
await posts.create({ title: "c" });
|
|
558
|
+
const seeded = (await posts.find()).data;
|
|
559
|
+
|
|
560
|
+
server.state.online = false;
|
|
561
|
+
await posts.deleteMany(seeded.slice(0, 2).map((r) => r.id as string));
|
|
562
|
+
|
|
563
|
+
const overlaid = await posts.find();
|
|
564
|
+
expect(overlaid.data.map((r) => r.title)).toEqual(["c"]);
|
|
565
|
+
|
|
566
|
+
server.state.online = true;
|
|
567
|
+
await manager.sync();
|
|
568
|
+
const bulk = server.log.filter((e) => e.op === "deleteMany");
|
|
569
|
+
expect(bulk).toHaveLength(1);
|
|
570
|
+
expect([...server.table("posts").values()].map((r) => r.title)).toEqual(["c"]);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("queues a bulk update whose rows have writes still pending", async () => {
|
|
574
|
+
// Splitting the batch — some rows now, some later — would break the
|
|
575
|
+
// one guarantee a batch makes, and would reorder a write against a
|
|
576
|
+
// row whose own create has not landed yet.
|
|
577
|
+
const server = createFakeServer();
|
|
578
|
+
const { manager, wrap } = createManager(server);
|
|
579
|
+
const posts = wrap("posts");
|
|
580
|
+
await posts.find();
|
|
581
|
+
|
|
582
|
+
server.state.online = false;
|
|
583
|
+
const created = await posts.create({ title: "fresh" });
|
|
584
|
+
await posts.updateMany([{ id: created.id as string, data: { title: "edited" } }]);
|
|
585
|
+
|
|
586
|
+
server.state.online = true;
|
|
587
|
+
await manager.sync();
|
|
588
|
+
|
|
589
|
+
// The create ran first, then the batch — order preserved.
|
|
590
|
+
expect(server.log.map((e) => e.op)).toEqual(["create", "updateMany"]);
|
|
591
|
+
expect([...server.table("posts").values()][0].title).toBe("edited");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("goes straight to the server when online", async () => {
|
|
595
|
+
const server = createFakeServer();
|
|
596
|
+
const { wrap } = createManager(server);
|
|
597
|
+
const posts = wrap("posts");
|
|
598
|
+
const a = await posts.create({ title: "a" });
|
|
599
|
+
|
|
600
|
+
await posts.updateMany([{ id: a.id as string, data: { title: "A" } }]);
|
|
601
|
+
expect(server.table("posts").get(String(a.id))!.title).toBe("A");
|
|
602
|
+
|
|
603
|
+
await posts.deleteMany([a.id as string]);
|
|
604
|
+
expect(server.table("posts").size).toBe(0);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("treats empty input as a no-op that touches neither queue nor server", async () => {
|
|
608
|
+
const server = createFakeServer();
|
|
609
|
+
const { wrap } = createManager(server);
|
|
610
|
+
const posts = wrap("posts");
|
|
611
|
+
await posts.find();
|
|
612
|
+
|
|
613
|
+
expect(await posts.updateMany([])).toEqual([]);
|
|
614
|
+
await posts.deleteMany([]);
|
|
615
|
+
expect(server.log.filter((e) => e.op.endsWith("Many"))).toHaveLength(0);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
506
619
|
describe("concurrency", () => {
|
|
507
620
|
it("gives concurrent offline writes distinct, ordered mutation ids", async () => {
|
|
508
621
|
const server = createFakeServer();
|
package/src/offline.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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, WhereValue, 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";
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import {
|
|
16
16
|
isExactlyEvaluable,
|
|
17
17
|
matchesParams,
|
|
18
|
+
resolvePagination,
|
|
18
19
|
runLocalQuery,
|
|
19
20
|
sortRows
|
|
20
21
|
} from "./offline-query";
|
|
@@ -545,6 +546,83 @@ export class OfflineManager {
|
|
|
545
546
|
return rows;
|
|
546
547
|
},
|
|
547
548
|
|
|
549
|
+
updateMany: async (updates: { id: string | number; data: Partial<M> }[], options?: WriteOptions) => {
|
|
550
|
+
await this.ensureCollection(slug);
|
|
551
|
+
if (!Array.isArray(updates)) {
|
|
552
|
+
throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
553
|
+
}
|
|
554
|
+
if (updates.length === 0) return [];
|
|
555
|
+
// Any row in the batch with a write already queued sends the
|
|
556
|
+
// whole batch to the queue. Splitting it — some rows now, some
|
|
557
|
+
// later — would break the one guarantee a batch makes, that its
|
|
558
|
+
// rows land together, and would reorder writes against a row
|
|
559
|
+
// whose own create has not landed yet.
|
|
560
|
+
const anyPending = updates.some((u) => this.hasPending(slug, u.id));
|
|
561
|
+
if (this.connectivity.shouldAttempt() && !anyPending) {
|
|
562
|
+
try {
|
|
563
|
+
const rows = await inner.updateMany(updates, options);
|
|
564
|
+
this.connectivity.markSuccess();
|
|
565
|
+
await this.ingest(slug, rows);
|
|
566
|
+
this.notifyCollection(slug);
|
|
567
|
+
return rows;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (!isNetworkError(error)) throw error;
|
|
570
|
+
this.connectivity.markFailure();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const rollback: Record<string, AnyRow | null> = {};
|
|
574
|
+
const optimistic: M[] = [];
|
|
575
|
+
for (const { id, data } of updates) {
|
|
576
|
+
const base = this.rawLocalRow(slug, id);
|
|
577
|
+
rollback[String(id)] = base ?? null;
|
|
578
|
+
optimistic.push({ ...(base ?? {}), ...(data as AnyRow), id } as unknown as M);
|
|
579
|
+
}
|
|
580
|
+
await this.enqueue({
|
|
581
|
+
collection: slug,
|
|
582
|
+
type: "updateMany",
|
|
583
|
+
updates: updates.map((u) => ({ id: u.id,
|
|
584
|
+
data: u.data as AnyRow })),
|
|
585
|
+
rollback: { rows: rollback }
|
|
586
|
+
});
|
|
587
|
+
for (const row of optimistic) this.setLocalRow(slug, row.id as string | number, row);
|
|
588
|
+
this.notifyCollection(slug);
|
|
589
|
+
return optimistic;
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
deleteMany: async (ids: (string | number)[], options?: WriteOptions) => {
|
|
593
|
+
await this.ensureCollection(slug);
|
|
594
|
+
if (!Array.isArray(ids)) {
|
|
595
|
+
throw new TypeError("deleteMany expects an array of ids.");
|
|
596
|
+
}
|
|
597
|
+
if (ids.length === 0) return;
|
|
598
|
+
const anyPending = ids.some((id) => this.hasPending(slug, id));
|
|
599
|
+
if (this.connectivity.shouldAttempt() && !anyPending) {
|
|
600
|
+
try {
|
|
601
|
+
await inner.deleteMany(ids, options);
|
|
602
|
+
this.connectivity.markSuccess();
|
|
603
|
+
for (const id of ids) this.removeLocalRow(slug, id, true);
|
|
604
|
+
this.notifyCollection(slug);
|
|
605
|
+
this.scheduleRefresh(slug);
|
|
606
|
+
return;
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (!isNetworkError(error)) throw error;
|
|
609
|
+
this.connectivity.markFailure();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const rollback: Record<string, AnyRow | null> = {};
|
|
613
|
+
for (const id of ids) {
|
|
614
|
+
rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
|
|
615
|
+
}
|
|
616
|
+
await this.enqueue({
|
|
617
|
+
collection: slug,
|
|
618
|
+
type: "deleteMany",
|
|
619
|
+
ids,
|
|
620
|
+
rollback: { rows: rollback }
|
|
621
|
+
});
|
|
622
|
+
for (const id of ids) this.removeLocalRow(slug, id, false);
|
|
623
|
+
this.notifyCollection(slug);
|
|
624
|
+
},
|
|
625
|
+
|
|
548
626
|
update: async (id: string | number, data: Partial<M>) => {
|
|
549
627
|
await this.ensureCollection(slug);
|
|
550
628
|
// Never overtake a write already queued for this row. The
|
|
@@ -942,7 +1020,7 @@ export class OfflineManager {
|
|
|
942
1020
|
if (!state) {
|
|
943
1021
|
return {
|
|
944
1022
|
data: [],
|
|
945
|
-
meta: {
|
|
1023
|
+
meta: { ...resolvePagination(params), total: 0, hasMore: false },
|
|
946
1024
|
fromCache: true,
|
|
947
1025
|
hasPendingWrites: false,
|
|
948
1026
|
partial: true
|
|
@@ -996,9 +1074,18 @@ export class OfflineManager {
|
|
|
996
1074
|
rows.push(entry.row as M);
|
|
997
1075
|
added++;
|
|
998
1076
|
}
|
|
999
|
-
if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
|
|
1000
1077
|
}
|
|
1001
1078
|
|
|
1079
|
+
// Order is part of the query, not a detail of how the rows were
|
|
1080
|
+
// obtained. This used to sort only when a locally-created row had been
|
|
1081
|
+
// injected — every other read handed back cache order, which is
|
|
1082
|
+
// insertion order, and a caller that asked for `orderBy` got whatever
|
|
1083
|
+
// the store happened to hold. In the admin that is the collection's
|
|
1084
|
+
// `sort` being silently ignored on every list backed by this overlay:
|
|
1085
|
+
// the query carries it, the server honours it, and the answer served
|
|
1086
|
+
// from here did not.
|
|
1087
|
+
if (params?.orderBy) sortRows(rows, params.orderBy);
|
|
1088
|
+
|
|
1002
1089
|
const total = Math.max(rows.length, snapshot.total - removed + added);
|
|
1003
1090
|
return {
|
|
1004
1091
|
data: rows,
|
|
@@ -1150,12 +1237,13 @@ export class OfflineManager {
|
|
|
1150
1237
|
}
|
|
1151
1238
|
|
|
1152
1239
|
private recordSnapshot(slug: string, params: FindParams | undefined, result: FindResult<AnyRow>): QuerySnapshot {
|
|
1153
|
-
const
|
|
1240
|
+
const window = resolvePagination(params);
|
|
1241
|
+
const meta = result.meta ?? { total: result.data?.length ?? 0, ...window, hasMore: false };
|
|
1154
1242
|
const snapshot: QuerySnapshot = {
|
|
1155
1243
|
ids: (result.data ?? []).map((row) => row.id as string | number).filter((id) => id !== undefined),
|
|
1156
1244
|
total: meta.total ?? result.data?.length ?? 0,
|
|
1157
|
-
limit: meta.limit ??
|
|
1158
|
-
offset: meta.offset ??
|
|
1245
|
+
limit: meta.limit ?? window.limit,
|
|
1246
|
+
offset: meta.offset ?? window.offset,
|
|
1159
1247
|
hasMore: meta.hasMore ?? false
|
|
1160
1248
|
};
|
|
1161
1249
|
const state = this.collectionState(slug);
|
|
@@ -1466,13 +1554,39 @@ export class OfflineManager {
|
|
|
1466
1554
|
await this.adoptServerRow(op, op.id, row);
|
|
1467
1555
|
} else if (op.type === "createMany") {
|
|
1468
1556
|
const queued = (op.data as AnyRow[]) ?? [];
|
|
1469
|
-
|
|
1557
|
+
// The mutation id names this batch, exactly as it names a single
|
|
1558
|
+
// `create` above — and it matters more here. Without it, a batch
|
|
1559
|
+
// whose ACK went missing replays as a second genuine import and
|
|
1560
|
+
// duplicates every row it holds, not one. `upsert` masked that for
|
|
1561
|
+
// the callers who set it; nothing covered the ones who did not.
|
|
1562
|
+
const rows = await inner.createMany(queued, {
|
|
1563
|
+
...(op.upsert ? { upsert: true } : {}),
|
|
1564
|
+
idempotencyKey: op.mutationId
|
|
1565
|
+
});
|
|
1470
1566
|
for (let i = 0; i < rows.length; i++) {
|
|
1471
1567
|
await this.adoptServerRow(op, queued[i]?.id as string | number | undefined, rows[i]);
|
|
1472
1568
|
}
|
|
1569
|
+
} else if (op.type === "updateMany") {
|
|
1570
|
+
const queued = op.updates ?? [];
|
|
1571
|
+
// Keyed like every other replay: an update re-applied in full is
|
|
1572
|
+
// naturally idempotent, but one interleaved with another writer's is
|
|
1573
|
+
// not, and a lost ACK would otherwise re-apply a stale batch over
|
|
1574
|
+
// newer data.
|
|
1575
|
+
const rows = await inner.updateMany(
|
|
1576
|
+
queued.map(u => ({ id: u.id,
|
|
1577
|
+
data: u.data as AnyRow })),
|
|
1578
|
+
{ idempotencyKey: op.mutationId }
|
|
1579
|
+
);
|
|
1580
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1581
|
+
await this.ingestReplaced(op, queued[i].id, rows[i]);
|
|
1582
|
+
}
|
|
1473
1583
|
} else if (op.type === "update") {
|
|
1474
1584
|
const row = await inner.update(op.id!, op.data as AnyRow);
|
|
1475
1585
|
await this.ingestReplaced(op, op.id!, row);
|
|
1586
|
+
} else if (op.type === "deleteMany") {
|
|
1587
|
+
const ids = op.ids ?? [];
|
|
1588
|
+
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
1589
|
+
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
1476
1590
|
} else if (op.type === "delete") {
|
|
1477
1591
|
await inner.delete(op.id!);
|
|
1478
1592
|
this.removeLocalRow(op.collection, op.id!, true);
|
|
@@ -50,7 +50,7 @@ describe("realtime opt-out", () => {
|
|
|
50
50
|
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
51
51
|
globalThis.WebSocket = FakeWebSocket;
|
|
52
52
|
|
|
53
|
-
createRebaseClient({ baseUrl: "http://localhost:3000
|
|
53
|
+
createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
54
54
|
|
|
55
55
|
expect(opened).toHaveLength(0);
|
|
56
56
|
});
|
|
@@ -59,7 +59,7 @@ describe("realtime opt-out", () => {
|
|
|
59
59
|
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
60
60
|
globalThis.WebSocket = FakeWebSocket;
|
|
61
61
|
|
|
62
|
-
const client = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
62
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
63
63
|
|
|
64
64
|
// Asking for the channel is not using it.
|
|
65
65
|
const channel = client.realtime.channel("doc:1");
|
|
@@ -73,7 +73,7 @@ describe("realtime opt-out", () => {
|
|
|
73
73
|
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
74
74
|
globalThis.WebSocket = FakeWebSocket;
|
|
75
75
|
|
|
76
|
-
const client = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
76
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
77
77
|
expect(opened).toHaveLength(0);
|
|
78
78
|
|
|
79
79
|
client.collection("posts").listen!(undefined, () => { /* noop */ });
|
|
@@ -85,7 +85,7 @@ describe("realtime opt-out", () => {
|
|
|
85
85
|
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
86
86
|
globalThis.WebSocket = FakeWebSocket;
|
|
87
87
|
|
|
88
|
-
const client = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
88
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
89
89
|
|
|
90
90
|
client.collection("posts").listen!(undefined, () => { /* noop */ });
|
|
91
91
|
client.collection("authors").listen!(undefined, () => { /* noop */ });
|
|
@@ -102,7 +102,7 @@ describe("realtime opt-out", () => {
|
|
|
102
102
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
|
|
103
103
|
const debug = jest.spyOn(console, "debug").mockImplementation(() => { /* capture */ });
|
|
104
104
|
|
|
105
|
-
const client = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
105
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
106
106
|
void client.realtime.channel("doc:1"); // obtained, never used
|
|
107
107
|
|
|
108
108
|
expect(opened).toHaveLength(0);
|
|
@@ -118,7 +118,7 @@ describe("realtime opt-out", () => {
|
|
|
118
118
|
delete globalThis.WebSocket;
|
|
119
119
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
|
|
120
120
|
|
|
121
|
-
const client = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
121
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
122
122
|
expect(warn).not.toHaveBeenCalled();
|
|
123
123
|
|
|
124
124
|
void client.realtime.channel("doc:1").join();
|
|
@@ -134,7 +134,7 @@ describe("realtime opt-out", () => {
|
|
|
134
134
|
globalThis.WebSocket = FakeWebSocket;
|
|
135
135
|
|
|
136
136
|
const client = createRebaseClient({
|
|
137
|
-
baseUrl: "http://localhost:3000
|
|
137
|
+
baseUrl: "http://localhost:3000",
|
|
138
138
|
realtime: false
|
|
139
139
|
});
|
|
140
140
|
|
|
@@ -148,7 +148,7 @@ describe("realtime opt-out", () => {
|
|
|
148
148
|
globalThis.WebSocket = FakeWebSocket;
|
|
149
149
|
|
|
150
150
|
createRebaseClient({
|
|
151
|
-
baseUrl: "http://localhost:3000
|
|
151
|
+
baseUrl: "http://localhost:3000",
|
|
152
152
|
websocketUrl: "ws://localhost:3000",
|
|
153
153
|
realtime: false
|
|
154
154
|
});
|
|
@@ -160,7 +160,7 @@ describe("realtime opt-out", () => {
|
|
|
160
160
|
const { FakeWebSocket, opened, closed } = trackingWebSocket();
|
|
161
161
|
globalThis.WebSocket = FakeWebSocket;
|
|
162
162
|
|
|
163
|
-
const client = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
163
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
164
164
|
client.collection("posts").listen!(undefined, () => { /* noop */ });
|
|
165
165
|
expect(opened).toHaveLength(1);
|
|
166
166
|
|
|
@@ -176,7 +176,7 @@ describe("realtime opt-out", () => {
|
|
|
176
176
|
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
177
177
|
globalThis.WebSocket = FakeWebSocket;
|
|
178
178
|
|
|
179
|
-
const client = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
179
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
180
180
|
client.collection("posts").listen!(undefined, () => { /* noop */ });
|
|
181
181
|
expect(opened).toHaveLength(1);
|
|
182
182
|
|
|
@@ -190,10 +190,10 @@ describe("realtime opt-out", () => {
|
|
|
190
190
|
const { FakeWebSocket } = trackingWebSocket();
|
|
191
191
|
globalThis.WebSocket = FakeWebSocket;
|
|
192
192
|
|
|
193
|
-
const offline = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
193
|
+
const offline = createRebaseClient({ baseUrl: "http://localhost:3000", realtime: false });
|
|
194
194
|
expect(() => offline.close()).not.toThrow();
|
|
195
195
|
|
|
196
|
-
const live = createRebaseClient({ baseUrl: "http://localhost:3000
|
|
196
|
+
const live = createRebaseClient({ baseUrl: "http://localhost:3000" });
|
|
197
197
|
live.close();
|
|
198
198
|
expect(() => live.close()).not.toThrow();
|
|
199
199
|
});
|
|
@@ -205,7 +205,7 @@ describe("realtime opt-out", () => {
|
|
|
205
205
|
globalThis.WebSocket = FakeWebSocket;
|
|
206
206
|
|
|
207
207
|
const client = createRebaseClient({
|
|
208
|
-
baseUrl: "http://localhost:3000
|
|
208
|
+
baseUrl: "http://localhost:3000",
|
|
209
209
|
realtime: false
|
|
210
210
|
});
|
|
211
211
|
|
|
@@ -244,7 +244,7 @@ isAnonymous: false }
|
|
|
244
244
|
})) as unknown as typeof globalThis.fetch;
|
|
245
245
|
|
|
246
246
|
const client = createRebaseClient({
|
|
247
|
-
baseUrl: "http://localhost:3000
|
|
247
|
+
baseUrl: "http://localhost:3000",
|
|
248
248
|
fetch: fetchMock
|
|
249
249
|
});
|
|
250
250
|
|
|
@@ -265,7 +265,7 @@ isAnonymous: false }
|
|
|
265
265
|
globalThis.WebSocket = FakeWebSocket;
|
|
266
266
|
|
|
267
267
|
const client = createRebaseClient({
|
|
268
|
-
baseUrl: "http://localhost:3000
|
|
268
|
+
baseUrl: "http://localhost:3000",
|
|
269
269
|
realtime: false
|
|
270
270
|
});
|
|
271
271
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { RebaseWebSocketClient } from "./websocket";
|
|
3
|
+
import { or, cond } from "@rebasepro/common";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Two subscriptions are the same subscription only when they ask for the same
|
|
7
|
+
* rows. The key that decides this was built from a hand-listed subset of the
|
|
8
|
+
* props, so any field left off it made two different queries collide — and the
|
|
9
|
+
* second listener was handed the first one's rows, which is worse than not
|
|
10
|
+
* subscribing at all.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function fakeSocket() {
|
|
14
|
+
const sent: Record<string, unknown>[] = [];
|
|
15
|
+
|
|
16
|
+
class FakeWS {
|
|
17
|
+
static readonly OPEN = 1;
|
|
18
|
+
readyState = 1;
|
|
19
|
+
onopen: (() => void) | null = null;
|
|
20
|
+
onclose: (() => void) | null = null;
|
|
21
|
+
onerror: (() => void) | null = null;
|
|
22
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
23
|
+
|
|
24
|
+
constructor(public url: string) {
|
|
25
|
+
setTimeout(() => this.onopen?.(), 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
send(raw: string) {
|
|
29
|
+
const message = JSON.parse(raw) as Record<string, unknown>;
|
|
30
|
+
sent.push(message);
|
|
31
|
+
if (message.type === "AUTHENTICATE") {
|
|
32
|
+
setTimeout(() => this.onmessage?.({
|
|
33
|
+
data: JSON.stringify({ type: "AUTH_SUCCESS", requestId: message.requestId })
|
|
34
|
+
}), 0);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
close() { /* noop */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { FakeWS: FakeWS as unknown as typeof WebSocket, sent };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function subscribeFrames(sent: Record<string, unknown>[]) {
|
|
45
|
+
return sent.filter((m) => m.type === "subscribe_collection");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("collection subscription identity", () => {
|
|
49
|
+
beforeEach(() => jest.useFakeTimers());
|
|
50
|
+
afterEach(() => jest.useRealTimers());
|
|
51
|
+
|
|
52
|
+
async function twoSubscriptions(
|
|
53
|
+
a: Record<string, unknown>,
|
|
54
|
+
b: Record<string, unknown>
|
|
55
|
+
) {
|
|
56
|
+
const { FakeWS, sent } = fakeSocket();
|
|
57
|
+
const client = new RebaseWebSocketClient({
|
|
58
|
+
websocketUrl: "ws://localhost:1234",
|
|
59
|
+
WebSocket: FakeWS,
|
|
60
|
+
getAuthToken: async () => "token"
|
|
61
|
+
});
|
|
62
|
+
client.listenCollection({ path: "posts", ...a } as never, () => {});
|
|
63
|
+
client.listenCollection({ path: "posts", ...b } as never, () => {});
|
|
64
|
+
await jest.advanceTimersByTimeAsync(50);
|
|
65
|
+
return subscribeFrames(sent);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it("treats different offsets as different subscriptions", async () => {
|
|
69
|
+
// Page one and page two of the same live list. Sharing a subscription
|
|
70
|
+
// here shows page one's rows on page two.
|
|
71
|
+
const frames = await twoSubscriptions({ limit: 10, offset: 0 }, { limit: 10, offset: 10 });
|
|
72
|
+
expect(frames).toHaveLength(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("treats different logical groups as different subscriptions", async () => {
|
|
76
|
+
const frames = await twoSubscriptions(
|
|
77
|
+
{ logical: or(cond("status", "==", "draft")) },
|
|
78
|
+
{ logical: or(cond("status", "==", "published")) }
|
|
79
|
+
);
|
|
80
|
+
expect(frames).toHaveLength(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("still shares one subscription for genuinely identical queries", async () => {
|
|
84
|
+
// The de-duplication itself is the point of the key: two components
|
|
85
|
+
// watching the same list must not open two server subscriptions.
|
|
86
|
+
const frames = await twoSubscriptions(
|
|
87
|
+
{ limit: 10, offset: 10, logical: or(cond("status", "==", "draft")) },
|
|
88
|
+
{ limit: 10, offset: 10, logical: or(cond("status", "==", "draft")) }
|
|
89
|
+
);
|
|
90
|
+
expect(frames).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "@jest/globals";
|
|
1
|
+
import { describe, it, expect, afterEach, jest } from "@jest/globals";
|
|
2
2
|
import { createTransport } from "./transport";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -51,3 +51,51 @@ describe("transport baseUrl resolution", () => {
|
|
|
51
51
|
expect(createTransport({}).baseUrl).toBe("");
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
describe("baseUrl that already contains the apiPath", () => {
|
|
56
|
+
/**
|
|
57
|
+
* The docblock on `baseUrl` says this "silently builds `/api/api/…` and
|
|
58
|
+
* every request 404s" — a failure mode understood well enough to be written
|
|
59
|
+
* down, and still left to be discovered at runtime. This package's own
|
|
60
|
+
* tests configured it that way a dozen times over, which is about as clear
|
|
61
|
+
* a signal as a trap gets.
|
|
62
|
+
*/
|
|
63
|
+
it("warns rather than silently building /api/api", () => {
|
|
64
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
65
|
+
createTransport({ baseUrl: "http://localhost:3000/api" });
|
|
66
|
+
|
|
67
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("/api/api"));
|
|
68
|
+
warn.mockRestore();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("warns for a custom apiPath too, and tolerates a trailing slash", () => {
|
|
72
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
73
|
+
createTransport({ baseUrl: "https://api.example.com/v2/", apiPath: "/v2" });
|
|
74
|
+
|
|
75
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
76
|
+
warn.mockRestore();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("says nothing about a baseUrl that merely ends in a similar word", () => {
|
|
80
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
81
|
+
createTransport({ baseUrl: "https://rapid.example.com" });
|
|
82
|
+
createTransport({ baseUrl: "https://example.com/myapi" });
|
|
83
|
+
createTransport({ baseUrl: "https://example.com" });
|
|
84
|
+
|
|
85
|
+
expect(warn).not.toHaveBeenCalled();
|
|
86
|
+
warn.mockRestore();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("storageUrlOrigin that already contains the apiPath", () => {
|
|
91
|
+
it("warns, because storage composes it the same way baseUrl is composed", () => {
|
|
92
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
93
|
+
createTransport({
|
|
94
|
+
baseUrl: "https://example.com",
|
|
95
|
+
storageUrlOrigin: "https://files.example.com/api"
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("storageUrlOrigin"));
|
|
99
|
+
warn.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/transport.ts
CHANGED
|
@@ -259,6 +259,28 @@ function resolveBaseUrl(configured?: string): string {
|
|
|
259
259
|
export function createTransport(config: RebaseClientConfig, environment?: TransportEnvironment): Transport {
|
|
260
260
|
const fetchFn = config.fetch || globalThis.fetch;
|
|
261
261
|
const apiPath = config.apiPath || "/api";
|
|
262
|
+
|
|
263
|
+
// `apiPath` is appended to `baseUrl`, so a `baseUrl` that already ends in it
|
|
264
|
+
// builds `/api/api/…` and every request 404s. That was documented on
|
|
265
|
+
// `baseUrl` and left to be discovered at runtime — including by this
|
|
266
|
+
// package's own tests, which configured it that way a dozen times. A 404 on
|
|
267
|
+
// every call looks like a server that is down, not like a doubled path.
|
|
268
|
+
// `storageUrlOrigin` is checked alongside it because `storage.ts` composes
|
|
269
|
+
// it the same way — `${storageUrlOrigin ?? baseUrl}${apiPath}` — and its own
|
|
270
|
+
// docblock carries the same "no path" caveat.
|
|
271
|
+
for (const field of ["baseUrl", "storageUrlOrigin"] as const) {
|
|
272
|
+
const value = config[field];
|
|
273
|
+
if (!value || !apiPath) continue;
|
|
274
|
+
const trimmed = value.replace(/\/+$/, "");
|
|
275
|
+
if (!trimmed.endsWith(apiPath)) continue;
|
|
276
|
+
console.warn(
|
|
277
|
+
`[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ` +
|
|
278
|
+
`${JSON.stringify(apiPath)}, which is appended to it — requests will go to ` +
|
|
279
|
+
`${trimmed}${apiPath}/… and 404. Pass the origin only ` +
|
|
280
|
+
`(${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set ` +
|
|
281
|
+
"`apiPath` if the server really does mount the API one level deeper."
|
|
282
|
+
);
|
|
283
|
+
}
|
|
262
284
|
let token = config.token;
|
|
263
285
|
let tokenGetter: (() => Promise<string | null>) | undefined;
|
|
264
286
|
let onUnauthorizedHandler = config.onUnauthorized;
|