@rebasepro/client 0.9.1-canary.fd3754b → 0.10.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/dist/auth.d.ts +5 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.es.js +799 -95
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +243 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +96 -2
- package/package.json +11 -10
- package/src/auth.ts +32 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +105 -2
- package/src/realtime-channel.test.ts +542 -0
- package/src/realtime-channel.ts +539 -0
- package/src/realtime-optout.test.ts +245 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport-baseurl.test.ts +53 -0
- package/src/transport.ts +59 -3
- package/src/websocket.ts +515 -88
- package/dist/collection.test.d.ts +0 -1
- package/dist/cron.test.d.ts +0 -1
- package/dist/data-proxy.test.d.ts +0 -1
- package/dist/index.umd.js +0 -2513
- package/dist/index.umd.js.map +0 -1
|
@@ -0,0 +1,245 @@
|
|
|
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/api" });
|
|
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/api" });
|
|
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/api" });
|
|
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/api" });
|
|
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/api" });
|
|
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/api" });
|
|
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/api",
|
|
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/api",
|
|
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/api" });
|
|
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/api" });
|
|
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/api", realtime: false });
|
|
194
|
+
expect(() => offline.close()).not.toThrow();
|
|
195
|
+
|
|
196
|
+
const live = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
|
|
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/api",
|
|
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", () => {
|
|
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
|
+
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
220
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
221
|
+
|
|
222
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
|
|
223
|
+
// Drive the auth-state path the way a sign-in would.
|
|
224
|
+
client.auth.onAuthStateChange(() => { /* noop */ });
|
|
225
|
+
|
|
226
|
+
expect(opened).toHaveLength(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("leaves listen() absent so callers can feature-detect, and says why via the query builder", () => {
|
|
230
|
+
const { FakeWebSocket } = trackingWebSocket();
|
|
231
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
232
|
+
|
|
233
|
+
const client = createRebaseClient({
|
|
234
|
+
baseUrl: "http://localhost:3000/api",
|
|
235
|
+
realtime: false
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// `listen` stays undefined rather than becoming a throwing stub: the
|
|
239
|
+
// optional type is what makes `if (client.listen)` work and what makes
|
|
240
|
+
// TypeScript reject a bare call.
|
|
241
|
+
expect(client.collection("posts").listen).toBeUndefined();
|
|
242
|
+
expect(() => client.data.posts.include("author").listen(() => { /* noop */ }))
|
|
243
|
+
.toThrow(/realtime: false/);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
});
|
package/src/sdk_query_builder.ts
CHANGED
|
@@ -131,7 +131,10 @@ export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string,
|
|
|
131
131
|
*/
|
|
132
132
|
listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void {
|
|
133
133
|
if (!this.collection.listen) {
|
|
134
|
-
throw new Error(
|
|
134
|
+
throw new Error(
|
|
135
|
+
"Listen is only available when RebaseClient is configured with a websocketUrl, " +
|
|
136
|
+
"and not when it was created with realtime: false."
|
|
137
|
+
);
|
|
135
138
|
}
|
|
136
139
|
return this.collection.listen(this.params, onUpdate, onError);
|
|
137
140
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "@jest/globals";
|
|
2
|
+
import { createTransport } from "./transport";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `baseUrl` is optional because the common production shape is a Rebase backend
|
|
6
|
+
* serving its own SPA: the API is the page's origin. These pin the resolution,
|
|
7
|
+
* because getting it wrong pushes apps into baking an absolute host — which
|
|
8
|
+
* breaks the moment a custom domain points at the same app, and which CORS
|
|
9
|
+
* cannot repair (a SameSite=Lax auth cookie is not sent cross-site either).
|
|
10
|
+
*/
|
|
11
|
+
const setWindow = (origin?: string) => {
|
|
12
|
+
if (origin === undefined) { delete (globalThis as never as { window?: unknown }).window; return; }
|
|
13
|
+
(globalThis as never as { window: unknown }).window = { location: { origin, href: origin + "/" } };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
afterEach(() => setWindow(undefined));
|
|
17
|
+
|
|
18
|
+
describe("transport baseUrl resolution", () => {
|
|
19
|
+
it("uses the page origin when unset in a browser", () => {
|
|
20
|
+
setWindow("https://dadaki.com");
|
|
21
|
+
expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("gives callers something they can build an absolute URL from", () => {
|
|
25
|
+
setWindow("https://dadaki.com");
|
|
26
|
+
const t = createTransport({});
|
|
27
|
+
// The empty-string version of this threw, which is what drove apps to
|
|
28
|
+
// bake a hostname into the bundle.
|
|
29
|
+
expect(() => new URL(`${t.baseUrl}/api/functions/doc-content/get`)).not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("follows the page, so a second hostname on the same app just works", () => {
|
|
33
|
+
setWindow("https://dadaki.apps.rebase.pro");
|
|
34
|
+
expect(createTransport({}).baseUrl).toBe("https://dadaki.apps.rebase.pro");
|
|
35
|
+
setWindow("https://dadaki.com");
|
|
36
|
+
expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("still honours an explicit baseUrl, which is what dev needs", () => {
|
|
40
|
+
setWindow("https://dadaki.com");
|
|
41
|
+
expect(createTransport({ baseUrl: "http://localhost:3001" }).baseUrl).toBe("http://localhost:3001");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("strips a trailing slash so joins do not double up", () => {
|
|
45
|
+
setWindow(undefined);
|
|
46
|
+
expect(createTransport({ baseUrl: "https://api.example.com/" }).baseUrl).toBe("https://api.example.com");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("stays relative off-browser, where there is no origin to borrow", () => {
|
|
50
|
+
setWindow(undefined);
|
|
51
|
+
expect(createTransport({}).baseUrl).toBe("");
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/transport.ts
CHANGED
|
@@ -9,12 +9,46 @@ export { RebaseApiError } from "@rebasepro/types";
|
|
|
9
9
|
export type { RebaseErrorInit } from "@rebasepro/types";
|
|
10
10
|
|
|
11
11
|
export interface RebaseClientConfig {
|
|
12
|
+
/**
|
|
13
|
+
* Origin of the Rebase server — scheme, host and port **only**.
|
|
14
|
+
*
|
|
15
|
+
* {@link apiPath} is appended to this, so do not include it here:
|
|
16
|
+
* `"http://localhost:3001"` is correct, while `"http://localhost:3001/api"`
|
|
17
|
+
* silently builds `/api/api/…` and every request 404s. Omit entirely for
|
|
18
|
+
* same-origin requests from the browser.
|
|
19
|
+
*/
|
|
12
20
|
baseUrl?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Bearer token sent as `Authorization` on every request.
|
|
23
|
+
*
|
|
24
|
+
* In the browser this is the signed-in user's access token, so row-level
|
|
25
|
+
* security applies. Server-side callers — scripts, cron jobs, ETL — pass the
|
|
26
|
+
* service key instead, which resolves to `{ uid: "service", roles: ["admin"] }`
|
|
27
|
+
* and **bypasses RLS**: there is no user to constrain those queries, so scope
|
|
28
|
+
* them explicitly.
|
|
29
|
+
*/
|
|
13
30
|
token?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Path the API is mounted under, appended to {@link baseUrl}.
|
|
33
|
+
* Defaults to `"/api"`; override only if the server mounts it elsewhere.
|
|
34
|
+
*/
|
|
14
35
|
apiPath?: string;
|
|
15
36
|
fetch?: typeof globalThis.fetch;
|
|
16
37
|
onUnauthorized?: () => Promise<boolean>;
|
|
17
38
|
websocketUrl?: string; // Optional real-time WebSocket connection
|
|
39
|
+
/**
|
|
40
|
+
* Open the realtime WebSocket. **Defaults to `true`.**
|
|
41
|
+
*
|
|
42
|
+
* The socket connects as soon as the client is constructed and keeps the
|
|
43
|
+
* Node event loop alive, so a one-shot script (CLI, cron job, ETL) will not
|
|
44
|
+
* exit on its own. Set this to `false` for any process that reads or writes
|
|
45
|
+
* and then terminates — `.listen()` and `.listenById()` then throw instead
|
|
46
|
+
* of silently doing nothing.
|
|
47
|
+
*
|
|
48
|
+
* Long-lived processes that do want realtime can instead call
|
|
49
|
+
* `client.close()` when shutting down.
|
|
50
|
+
*/
|
|
51
|
+
realtime?: boolean;
|
|
18
52
|
}
|
|
19
53
|
|
|
20
54
|
/**
|
|
@@ -78,6 +112,29 @@ export interface Transport {
|
|
|
78
112
|
resolveToken: () => Promise<string | null>;
|
|
79
113
|
}
|
|
80
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The base every request and every caller-built URL resolves against.
|
|
117
|
+
*
|
|
118
|
+
* `baseUrl` is optional because the common production shape is a Rebase
|
|
119
|
+
* backend serving its own SPA, where the API is simply the page's origin.
|
|
120
|
+
* Leaving it unset is therefore the *correct* configuration there — and the
|
|
121
|
+
* one that keeps working when a second hostname (a custom domain) points at
|
|
122
|
+
* the same app.
|
|
123
|
+
*
|
|
124
|
+
* When unset in a browser this resolves to the page origin rather than "".
|
|
125
|
+
* Requests behave identically either way, but the empty string is a trap for
|
|
126
|
+
* anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
|
|
127
|
+
* throws, so apps "fixed" it by baking an absolute host into their bundle —
|
|
128
|
+
* which is exactly what breaks the day a custom domain is added, and which no
|
|
129
|
+
* amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
|
|
130
|
+
* not sent cross-site either.
|
|
131
|
+
*/
|
|
132
|
+
function resolveBaseUrl(configured?: string): string {
|
|
133
|
+
if (configured) return configured.replace(/\/$/, "");
|
|
134
|
+
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
|
|
81
138
|
export function createTransport(config: RebaseClientConfig): Transport {
|
|
82
139
|
const fetchFn = config.fetch || globalThis.fetch;
|
|
83
140
|
const apiPath = config.apiPath || "/api";
|
|
@@ -94,8 +151,7 @@ export function createTransport(config: RebaseClientConfig): Transport {
|
|
|
94
151
|
}
|
|
95
152
|
|
|
96
153
|
async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
|
97
|
-
const
|
|
98
|
-
const url = base + apiPath + path;
|
|
154
|
+
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
99
155
|
|
|
100
156
|
let activeToken = token;
|
|
101
157
|
if (tokenGetter) {
|
|
@@ -208,7 +264,7 @@ headers: retryHeaders });
|
|
|
208
264
|
setToken(newToken: string | null) { token = newToken || undefined; },
|
|
209
265
|
setAuthTokenGetter(getter: () => Promise<string | null>) { tokenGetter = getter; },
|
|
210
266
|
setOnUnauthorized(handler: () => Promise<boolean>) { onUnauthorizedHandler = handler; },
|
|
211
|
-
get baseUrl() { return config.baseUrl
|
|
267
|
+
get baseUrl() { return resolveBaseUrl(config.baseUrl); },
|
|
212
268
|
get apiPath() { return apiPath; },
|
|
213
269
|
get fetchFn() { return fetchFn; },
|
|
214
270
|
getHeaders: (init?: RequestInit) => getHeaders(token, init) as Record<string, string>,
|