@rebasepro/client 0.9.0 → 0.9.1-canary.0fce67c
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 +1 -1
- package/dist/admin.d.ts +1 -0
- package/dist/backups.d.ts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.es.js +280 -72
- package/dist/index.es.js.map +1 -1
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +57 -1
- package/package.json +8 -9
- package/src/admin.ts +1 -1
- package/src/api-keys.ts +1 -1
- package/src/backups.ts +40 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +33 -2
- package/src/realtime-optout.test.ts +119 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport.ts +34 -0
- package/src/websocket.ts +359 -71
- 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 -2484
- package/dist/index.umd.js.map +0 -1
|
@@ -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
|
}
|
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
|
/**
|