@rebasepro/client 0.17.3-canary.gdd23447 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
@@ -1,105 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
2
-
3
- /**
4
- * Server errors about channel frames used to be discarded by the client.
5
- *
6
- * Channel messages are fire-and-forget by design: the SDK deliberately
7
- * registers no `pendingRequests` waiter for them, because their answers come
8
- * back addressed by channel rather than in a response envelope. The consequence
9
- * was that an `ERROR` frame — `RATE_LIMITED`, `CHANNEL_FORBIDDEN`,
10
- * `CHANNEL_HISTORY_WRITE_FAILED` — matched no waiter, no channel and no
11
- * subscription, fell through every branch of `handleWebSocketMessage`, and was
12
- * dropped. `await channel.broadcast(...)` resolved as if it had been sent.
13
- */
14
- function fakeSocket() {
15
- const sockets: FakeWS[] = [];
16
-
17
- class FakeWS {
18
- static readonly OPEN = 1;
19
- readyState = 1;
20
- onopen: (() => void) | null = null;
21
- onclose: (() => void) | null = null;
22
- onerror: (() => void) | null = null;
23
- onmessage: ((event: { data: string }) => void) | null = null;
24
-
25
- constructor(public url: string) {
26
- sockets.push(this);
27
- setTimeout(() => this.onopen?.(), 0);
28
- }
29
-
30
- send() { /* nothing here needs the outgoing frames */ }
31
- close() { /* noop */ }
32
- }
33
-
34
- const instance = () => sockets[sockets.length - 1];
35
-
36
- return {
37
- FakeWS: FakeWS as unknown as typeof WebSocket,
38
- deliver: (frame: unknown) => instance()?.onmessage?.({ data: JSON.stringify(frame) })
39
- };
40
- }
41
-
42
- describe("unmatched realtime error frames", () => {
43
- let warn: any;
44
-
45
- beforeEach(() => {
46
- jest.useFakeTimers();
47
- warn = jest.spyOn(console, "warn").mockImplementation(() => {});
48
- });
49
-
50
- afterEach(() => {
51
- warn.mockRestore();
52
- jest.useRealTimers();
53
- });
54
-
55
- const connect = async () => {
56
- const { RebaseWebSocketClient } = await import("./websocket");
57
- const { FakeWS, deliver } = fakeSocket();
58
- const client = new RebaseWebSocketClient({
59
- websocketUrl: "ws://localhost:3000",
60
- WebSocket: FakeWS
61
- });
62
- client.ensureConnected();
63
- await jest.advanceTimersByTimeAsync(1);
64
- return { client, deliver };
65
- };
66
-
67
- it("surfaces a channel rate-limit refusal that matches no waiter", async () => {
68
- const { deliver } = await connect();
69
-
70
- deliver({
71
- type: "ERROR",
72
- requestId: "req-the-client-never-registered",
73
- payload: { error: { message: "Too many channel messages. Please slow down.", code: "RATE_LIMITED" } }
74
- });
75
-
76
- expect(warn).toHaveBeenCalledTimes(1);
77
- expect(String(warn.mock.calls[0][0])).toContain("RATE_LIMITED");
78
- expect(String(warn.mock.calls[0][0])).toContain("Too many channel messages");
79
- });
80
-
81
- it("surfaces a refused channel action", async () => {
82
- const { deliver } = await connect();
83
-
84
- // What the server now sends for a broadcast into a channel the sender
85
- // never joined — `sendError` addresses it by neither requestId nor
86
- // channel, so nothing else in the client will ever see it.
87
- deliver({
88
- type: "error",
89
- payload: { error: { message: "Refused broadcast on channel \"doc:42\"", code: "CHANNEL_FORBIDDEN" } },
90
- error: "Refused broadcast on channel \"doc:42\""
91
- });
92
-
93
- expect(warn).toHaveBeenCalledTimes(1);
94
- expect(String(warn.mock.calls[0][0])).toContain("CHANNEL_FORBIDDEN");
95
- });
96
-
97
- it("stays quiet for frames that are not errors", async () => {
98
- const { deliver } = await connect();
99
-
100
- deliver({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 } });
101
- deliver({ type: "presence_state", channel: "doc:42", presences: {} });
102
-
103
- expect(warn).not.toHaveBeenCalled();
104
- });
105
- });
@@ -1,279 +0,0 @@
1
- import { jest } from "@jest/globals";
2
- import { createRebaseClient } from "./index";
3
-
4
- /**
5
- * A WebSocket stand-in that records construction. The real socket keeps the Node
6
- * event loop alive, which is what makes a one-shot script hang; here we only
7
- * need to know whether one would have been opened at all.
8
- */
9
- function trackingWebSocket() {
10
- const opened: string[] = [];
11
- const closed: string[] = [];
12
-
13
- class FakeWebSocket {
14
- static readonly OPEN = 1;
15
- readyState = 0;
16
- onopen: (() => void) | null = null;
17
- onclose: (() => void) | null = null;
18
- onerror: (() => void) | null = null;
19
- onmessage: (() => void) | null = null;
20
-
21
- constructor(public url: string) {
22
- opened.push(url);
23
- }
24
-
25
- close() {
26
- closed.push(this.url);
27
- }
28
-
29
- send() { /* no-op */ }
30
- }
31
-
32
- return { FakeWebSocket: FakeWebSocket as unknown as typeof WebSocket, opened, closed };
33
- }
34
-
35
- describe("realtime opt-out", () => {
36
- const original = globalThis.WebSocket;
37
-
38
- afterEach(() => {
39
- globalThis.WebSocket = original;
40
- jest.restoreAllMocks();
41
- });
42
-
43
- it("opens NO socket on construction, even with realtime enabled", () => {
44
- // Changed deliberately. Constructing a client is not a statement that
45
- // the app wants a socket — `createRebaseClient` builds one whenever
46
- // realtime is not explicitly disabled, so dialling here cost a
47
- // connection on every page load of every app that merely *might*
48
- // subscribe later. Anonymous-first apps paid it on every visit to
49
- // authenticate with nothing.
50
- const { FakeWebSocket, opened } = trackingWebSocket();
51
- globalThis.WebSocket = FakeWebSocket;
52
-
53
- createRebaseClient({ baseUrl: "http://localhost:3000" });
54
-
55
- expect(opened).toHaveLength(0);
56
- });
57
-
58
- it("opens the socket on the first channel operation", () => {
59
- const { FakeWebSocket, opened } = trackingWebSocket();
60
- globalThis.WebSocket = FakeWebSocket;
61
-
62
- const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
63
-
64
- // Asking for the channel is not using it.
65
- const channel = client.realtime.channel("doc:1");
66
- expect(opened).toHaveLength(0);
67
-
68
- void channel.join();
69
- expect(opened).toHaveLength(1);
70
- });
71
-
72
- it("opens the socket on the first collection subscription", () => {
73
- const { FakeWebSocket, opened } = trackingWebSocket();
74
- globalThis.WebSocket = FakeWebSocket;
75
-
76
- const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
77
- expect(opened).toHaveLength(0);
78
-
79
- client.collection("posts").listen!(undefined, () => { /* noop */ });
80
-
81
- expect(opened).toHaveLength(1);
82
- });
83
-
84
- it("opens exactly one socket however many things subscribe", () => {
85
- const { FakeWebSocket, opened } = trackingWebSocket();
86
- globalThis.WebSocket = FakeWebSocket;
87
-
88
- const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
89
-
90
- client.collection("posts").listen!(undefined, () => { /* noop */ });
91
- client.collection("authors").listen!(undefined, () => { /* noop */ });
92
- void client.realtime.channel("doc:1").join();
93
-
94
- expect(opened).toHaveLength(1);
95
- });
96
-
97
- it("stays silent for an anonymous client that never subscribes", () => {
98
- // The motivating case: most page loads of an anonymous-first app.
99
- // No socket, and no console noise either.
100
- const { FakeWebSocket, opened } = trackingWebSocket();
101
- globalThis.WebSocket = FakeWebSocket;
102
- const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
103
- const debug = jest.spyOn(console, "debug").mockImplementation(() => { /* capture */ });
104
-
105
- const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
106
- void client.realtime.channel("doc:1"); // obtained, never used
107
-
108
- expect(opened).toHaveLength(0);
109
- expect(warn).not.toHaveBeenCalled();
110
- expect(debug).not.toHaveBeenCalled();
111
- });
112
-
113
- it("says nothing about a missing WebSocket until something needs one", () => {
114
- // The environment warning used to fire on construction, so a Node
115
- // process that never subscribed was told off for a socket it did not
116
- // want.
117
- // @ts-expect-error deliberately removing the global
118
- delete globalThis.WebSocket;
119
- const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
120
-
121
- const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
122
- expect(warn).not.toHaveBeenCalled();
123
-
124
- void client.realtime.channel("doc:1").join();
125
- expect(warn).toHaveBeenCalledWith(expect.stringMatching(/WebSocket is not defined/));
126
-
127
- // ...and only once, however many times it is asked.
128
- void client.realtime.channel("doc:2").join();
129
- expect(warn).toHaveBeenCalledTimes(1);
130
- });
131
-
132
- it("opens no socket when realtime is disabled", () => {
133
- const { FakeWebSocket, opened } = trackingWebSocket();
134
- globalThis.WebSocket = FakeWebSocket;
135
-
136
- const client = createRebaseClient({
137
- baseUrl: "http://localhost:3000",
138
- realtime: false
139
- });
140
-
141
- // The socket is what keeps a CLI / cron / ETL process alive past its work.
142
- expect(opened).toHaveLength(0);
143
- expect(client.ws).toBeUndefined();
144
- });
145
-
146
- it("opens no socket when realtime is disabled even if a websocketUrl is given", () => {
147
- const { FakeWebSocket, opened } = trackingWebSocket();
148
- globalThis.WebSocket = FakeWebSocket;
149
-
150
- createRebaseClient({
151
- baseUrl: "http://localhost:3000",
152
- websocketUrl: "ws://localhost:3000",
153
- realtime: false
154
- });
155
-
156
- expect(opened).toHaveLength(0);
157
- });
158
-
159
- it("close() releases a socket that was opened", () => {
160
- const { FakeWebSocket, opened, closed } = trackingWebSocket();
161
- globalThis.WebSocket = FakeWebSocket;
162
-
163
- const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
164
- client.collection("posts").listen!(undefined, () => { /* noop */ });
165
- expect(opened).toHaveLength(1);
166
-
167
- client.close();
168
-
169
- expect(closed).toHaveLength(1);
170
- });
171
-
172
- it("close() is final — a later subscribe does not redial", () => {
173
- // Otherwise one queued frame could reopen the socket that close() just
174
- // released, and the Node process this method exists to let exit would
175
- // stay alive anyway.
176
- const { FakeWebSocket, opened } = trackingWebSocket();
177
- globalThis.WebSocket = FakeWebSocket;
178
-
179
- const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
180
- client.collection("posts").listen!(undefined, () => { /* noop */ });
181
- expect(opened).toHaveLength(1);
182
-
183
- client.close();
184
- client.collection("authors").listen!(undefined, () => { /* noop */ });
185
-
186
- expect(opened).toHaveLength(1);
187
- });
188
-
189
- it("close() is safe when realtime was never started, and when called twice", () => {
190
- const { FakeWebSocket } = trackingWebSocket();
191
- globalThis.WebSocket = FakeWebSocket;
192
-
193
- const offline = createRebaseClient({ baseUrl: "http://localhost:3000", realtime: false });
194
- expect(() => offline.close()).not.toThrow();
195
-
196
- const live = createRebaseClient({ baseUrl: "http://localhost:3000" });
197
- live.close();
198
- expect(() => live.close()).not.toThrow();
199
- });
200
-
201
- it("still refuses channels when realtime is disabled", () => {
202
- // `realtime: false` remains a hard opt-out. Only the *default* changed
203
- // — from "connected eagerly" to "connected on use".
204
- const { FakeWebSocket, opened } = trackingWebSocket();
205
- globalThis.WebSocket = FakeWebSocket;
206
-
207
- const client = createRebaseClient({
208
- baseUrl: "http://localhost:3000",
209
- realtime: false
210
- });
211
-
212
- expect(() => client.realtime.channel("doc:1")).toThrow(/realtime: false/);
213
- expect(opened).toHaveLength(0);
214
- });
215
-
216
- it("signing in does not by itself open a socket", async () => {
217
- // Authenticating is not a request for realtime. If it dialled, every
218
- // app with a login would lose lazy connect at the moment of login.
219
- // Merely registering a listener does not show that: the SIGNED_IN
220
- // branch that could dial is only reached by an actual sign-in.
221
- const { FakeWebSocket, opened } = trackingWebSocket();
222
- globalThis.WebSocket = FakeWebSocket;
223
-
224
- const loginBody = {
225
- tokens: {
226
- accessToken: "jwt",
227
- refreshToken: "refresh",
228
- accessTokenExpiresAt: Date.now() + 3600_000
229
- },
230
- user: { uid: "usr_1",
231
- email: "u@example.com",
232
- displayName: "U",
233
- photoURL: null,
234
- roles: ["user"],
235
- providerId: "local",
236
- isAnonymous: false }
237
- };
238
- const fetchMock = jest.fn(async () => ({
239
- ok: true,
240
- status: 200,
241
- text: async () => JSON.stringify(loginBody),
242
- json: async () => loginBody,
243
- headers: new Headers()
244
- })) as unknown as typeof globalThis.fetch;
245
-
246
- const client = createRebaseClient({
247
- baseUrl: "http://localhost:3000",
248
- fetch: fetchMock
249
- });
250
-
251
- const events: string[] = [];
252
- client.auth.onAuthStateChange((event) => { events.push(event); });
253
-
254
- const result = await client.auth.signInWithEmail("u@example.com", "pw");
255
-
256
- expect(result.user.uid).toBe("usr_1");
257
- expect(events).toEqual(["SIGNED_IN"]);
258
- expect(opened).toHaveLength(0);
259
-
260
- client.close();
261
- });
262
-
263
- it("leaves listen() absent so callers can feature-detect, and says why via the query builder", () => {
264
- const { FakeWebSocket } = trackingWebSocket();
265
- globalThis.WebSocket = FakeWebSocket;
266
-
267
- const client = createRebaseClient({
268
- baseUrl: "http://localhost:3000",
269
- realtime: false
270
- });
271
-
272
- // `listen` stays undefined rather than becoming a throwing stub: the
273
- // optional type is what makes `if (client.listen)` work and what makes
274
- // TypeScript reject a bare call.
275
- expect(client.collection("posts").listen).toBeUndefined();
276
- expect(() => client.data.posts.include("author").listen(() => { /* noop */ }))
277
- .toThrow(/realtime: false/);
278
- });
279
- });
@@ -1,254 +0,0 @@
1
- import { RebaseWebSocketClient } from "./websocket";
2
-
3
- /**
4
- * Realtime patches name a row by address; the cached rows are columns only.
5
- *
6
- * The SDK deliberately holds no collection config — a BaaS caller declares
7
- * nothing — so it cannot derive an address by itself, and used to just read
8
- * `row.id`. For a table keyed on anything else that is `undefined`: deletes
9
- * matched no cached row and removed nothing, and updates matched none either,
10
- * so every edit was prepended as a duplicate of the row it was editing.
11
- *
12
- * The server now sends the key columns with the patch, and they are used here.
13
- */
14
- describe("collection_patch — cached rows are matched by derived address", () => {
15
- const SKU_PKS = [{ fieldName: "sku",
16
- type: "string" as const }];
17
-
18
- /**
19
- * A client with one collection subscription holding `cached`, wired to the
20
- * backend subscription id the patches below use.
21
- */
22
- const setup = (cached: Record<string, unknown>[]) => {
23
- const client = new RebaseWebSocketClient({ url: "ws://localhost:1234" });
24
- const updates: Record<string, unknown>[][] = [];
25
-
26
- const internals = client as unknown as {
27
- backendToCollectionKey: Map<string, string>;
28
- collectionSubscriptions: Map<string, unknown>;
29
- handleWebSocketMessage: (m: unknown) => void;
30
- };
31
-
32
- internals.backendToCollectionKey.set("backend-1", "sub-key");
33
- internals.collectionSubscriptions.set("sub-key", {
34
- backendSubscriptionId: "backend-1",
35
- callbacks: new Map([["cb", { onUpdate: (rows: Record<string, unknown>[]) => updates.push(rows) }]]),
36
- props: { path: "sku_items" },
37
- latestData: cached,
38
- isInitialDataReceived: true
39
- });
40
-
41
- const patch = (message: Record<string, unknown>) =>
42
- internals.handleWebSocketMessage({ type: "collection_patch",
43
- subscriptionId: "backend-1",
44
- ...message });
45
-
46
- const update = (rows: Record<string, unknown>[], pks?: unknown) =>
47
- internals.handleWebSocketMessage({ type: "collection_update",
48
- subscriptionId: "backend-1",
49
- rows,
50
- pks });
51
-
52
- return { patch,
53
- update,
54
- updates,
55
- sub: () => internals.collectionSubscriptions.get("sub-key") as { latestData: Record<string, unknown>[] } };
56
- };
57
-
58
- it("removes the deleted row a `sku` key names", () => {
59
- const { patch, sub } = setup([
60
- { sku: "ABC-1",
61
- label: "Widget" },
62
- { sku: "ABC-2",
63
- label: "Gadget" }
64
- ]);
65
-
66
- patch({ id: "ABC-1",
67
- row: null,
68
- pks: SKU_PKS });
69
-
70
- expect(sub().latestData).toEqual([{ sku: "ABC-2",
71
- label: "Gadget" }]);
72
- });
73
-
74
- it("replaces the row the patch names, not whichever row is first", () => {
75
- // Both sides of the old comparison stringified to "undefined", so it
76
- // matched at index 0 every time: editing the second row overwrote the
77
- // first one, in place, with the second one's values.
78
- const { patch, sub } = setup([
79
- { sku: "ABC-1",
80
- label: "Widget" },
81
- { sku: "ABC-2",
82
- label: "Gadget" }
83
- ]);
84
-
85
- patch({ id: "ABC-2",
86
- row: { sku: "ABC-2",
87
- label: "Gadget v2" },
88
- pks: SKU_PKS });
89
-
90
- expect(sub().latestData).toEqual([
91
- { sku: "ABC-1",
92
- label: "Widget" },
93
- { sku: "ABC-2",
94
- label: "Gadget v2" }
95
- ]);
96
- });
97
-
98
- it("prepends a row that really is new", () => {
99
- const { patch, sub } = setup([{ sku: "ABC-2",
100
- label: "Gadget" }]);
101
-
102
- patch({ id: "ABC-9",
103
- row: { sku: "ABC-9",
104
- label: "New" },
105
- pks: SKU_PKS });
106
-
107
- expect(sub().latestData).toEqual([
108
- { sku: "ABC-9",
109
- label: "New" },
110
- { sku: "ABC-2",
111
- label: "Gadget" }
112
- ]);
113
- });
114
-
115
- it("matches a composite key by its joined address", () => {
116
- const { patch, sub } = setup([
117
- { tenant_id: 1,
118
- user_id: 2,
119
- role: "admin" },
120
- { tenant_id: 1,
121
- user_id: 3,
122
- role: "viewer" }
123
- ]);
124
-
125
- patch({
126
- id: "1:::3",
127
- row: { tenant_id: 1,
128
- user_id: 3,
129
- role: "owner" },
130
- pks: [{ fieldName: "tenant_id",
131
- type: "number" }, { fieldName: "user_id",
132
- type: "number" }]
133
- });
134
-
135
- expect(sub().latestData).toEqual([
136
- { tenant_id: 1,
137
- user_id: 2,
138
- role: "admin" },
139
- { tenant_id: 1,
140
- user_id: 3,
141
- role: "owner" }
142
- ]);
143
- });
144
-
145
- it("carries the patch's keys into the refetch merge, preserving unchanged references", () => {
146
- // Phase 2 of every patch is a full refetch (`collection_update`), whose
147
- // merge keeps the cached object for rows that did not change so React
148
- // sees the same reference. That matching needs an address too — the
149
- // keys learned from the patch are remembered on the subscription.
150
- const unchanged = { sku: "ABC-1",
151
- label: "Widget" };
152
- const { patch, update, sub } = setup([unchanged, { sku: "ABC-2",
153
- label: "Gadget" }]);
154
-
155
- patch({ id: "ABC-2",
156
- row: { sku: "ABC-2",
157
- label: "Gadget v2" },
158
- pks: SKU_PKS });
159
- update([{ sku: "ABC-1",
160
- label: "Widget" }, { sku: "ABC-2",
161
- label: "Gadget v3" }], SKU_PKS);
162
-
163
- expect(sub().latestData[0]).toBe(unchanged);
164
- expect(sub().latestData[1]).toEqual({ sku: "ABC-2",
165
- label: "Gadget v3" });
166
- });
167
-
168
- it("learns the keys from the rows themselves, for a change that sends no patch", () => {
169
- // A write from outside the API — psql, a cron job — reaches subscribers
170
- // through CDC, which invalidates and refetches without ever sending a
171
- // patch. Keys learned only from patches would never arrive, and every
172
- // row of the refetch would be a new reference: the whole table
173
- // re-renders on any external write.
174
- const unchanged = { sku: "ABC-1",
175
- label: "Widget" };
176
- const { update, sub } = setup([unchanged, { sku: "ABC-2",
177
- label: "Gadget" }]);
178
-
179
- update([{ sku: "ABC-1",
180
- label: "Widget" }, { sku: "ABC-2",
181
- label: "Gadget v2" }], SKU_PKS);
182
-
183
- expect(sub().latestData[0]).toBe(unchanged);
184
- expect(sub().latestData[1]).toEqual({ sku: "ABC-2",
185
- label: "Gadget v2" });
186
- });
187
-
188
- it("addresses an `id`-keyed collection from the keys like any other", () => {
189
- // `id` is not special: the server reports it as the key column, and it
190
- // is derived through the same path as a `sku` or a composite.
191
- const { patch, sub } = setup([{ id: 1,
192
- name: "Camera" }, { id: 2,
193
- name: "Lens" }]);
194
-
195
- patch({ id: "1",
196
- row: null,
197
- pks: [{ fieldName: "id",
198
- type: "number" }] });
199
-
200
- expect(sub().latestData).toEqual([{ id: 2,
201
- name: "Lens" }]);
202
- });
203
-
204
- it("recognises nothing when the server resolved no keys, rather than guessing at `id`", () => {
205
- // A table with no primary key and no `id` column has no address. The
206
- // server says so by sending no keys, and inventing one from a column
207
- // that merely looks like an id would address rows that are not there.
208
- const { patch, sub } = setup([
209
- { id: "batch-42",
210
- name: "Camera" },
211
- { id: "batch-42",
212
- name: "Lens" }
213
- ]);
214
-
215
- patch({ id: "batch-42",
216
- row: null });
217
-
218
- // Nothing removed: the patch names a row this client cannot identify.
219
- expect(sub().latestData).toHaveLength(2);
220
- });
221
-
222
- it("prefers the real key over a column merely named `id`", () => {
223
- // `event_id` is the key; `id` is ordinary data, and ordinary data has no
224
- // uniqueness to borrow — both rows here carry the same external ref.
225
- // Matching on it finds the first, so editing the party rewrote the
226
- // launch with the party's values.
227
- const { patch, sub } = setup([
228
- { event_id: 7,
229
- id: "batch-42",
230
- name: "Launch" },
231
- { event_id: 8,
232
- id: "batch-42",
233
- name: "Party" }
234
- ]);
235
-
236
- patch({
237
- id: "8",
238
- row: { event_id: 8,
239
- id: "batch-42",
240
- name: "Party v2" },
241
- pks: [{ fieldName: "event_id",
242
- type: "number" }]
243
- });
244
-
245
- expect(sub().latestData).toEqual([
246
- { event_id: 7,
247
- id: "batch-42",
248
- name: "Launch" },
249
- { event_id: 8,
250
- id: "batch-42",
251
- name: "Party v2" }
252
- ]);
253
- });
254
- });