@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.
- package/README.md +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- 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
|
-
});
|