@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.
@@ -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: { total: 0, limit: params?.limit ?? 20, offset: params?.offset ?? 0, hasMore: false },
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 meta = result.meta ?? { total: result.data?.length ?? 0, limit: 20, offset: 0, hasMore: false };
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 ?? params?.limit ?? 20,
1158
- offset: meta.offset ?? params?.offset ?? 0,
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
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : undefined);
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/api" });
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/api" });
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/api" });
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/api" });
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/api" });
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/api" });
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/api",
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/api",
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/api" });
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/api" });
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/api", realtime: false });
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/api" });
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/api",
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/api",
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/api",
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;