@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.
@@ -2,12 +2,46 @@ import { FindParams as TypesFindParams, FindResponse as TypesFindResponse } from
2
2
  export { RebaseApiError } from "@rebasepro/types";
3
3
  export type { RebaseErrorInit } from "@rebasepro/types";
4
4
  export interface RebaseClientConfig {
5
+ /**
6
+ * Origin of the Rebase server — scheme, host and port **only**.
7
+ *
8
+ * {@link apiPath} is appended to this, so do not include it here:
9
+ * `"http://localhost:3001"` is correct, while `"http://localhost:3001/api"`
10
+ * silently builds `/api/api/…` and every request 404s. Omit entirely for
11
+ * same-origin requests from the browser.
12
+ */
5
13
  baseUrl?: string;
14
+ /**
15
+ * Bearer token sent as `Authorization` on every request.
16
+ *
17
+ * In the browser this is the signed-in user's access token, so row-level
18
+ * security applies. Server-side callers — scripts, cron jobs, ETL — pass the
19
+ * service key instead, which resolves to `{ uid: "service", roles: ["admin"] }`
20
+ * and **bypasses RLS**: there is no user to constrain those queries, so scope
21
+ * them explicitly.
22
+ */
6
23
  token?: string;
24
+ /**
25
+ * Path the API is mounted under, appended to {@link baseUrl}.
26
+ * Defaults to `"/api"`; override only if the server mounts it elsewhere.
27
+ */
7
28
  apiPath?: string;
8
29
  fetch?: typeof globalThis.fetch;
9
30
  onUnauthorized?: () => Promise<boolean>;
10
31
  websocketUrl?: string;
32
+ /**
33
+ * Open the realtime WebSocket. **Defaults to `true`.**
34
+ *
35
+ * The socket connects as soon as the client is constructed and keeps the
36
+ * Node event loop alive, so a one-shot script (CLI, cron job, ETL) will not
37
+ * exit on its own. Set this to `false` for any process that reads or writes
38
+ * and then terminates — `.listen()` and `.listenById()` then throw instead
39
+ * of silently doing nothing.
40
+ *
41
+ * Long-lived processes that do want realtime can instead call
42
+ * `client.close()` when shutting down.
43
+ */
44
+ realtime?: boolean;
11
45
  }
12
46
  /**
13
47
  * Re-export from `@rebasepro/types` for backward compatibility.
@@ -14,7 +14,7 @@ export interface RebaseWebSocketConfig {
14
14
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
15
15
  * manages this internally (exposed as `client.ws`, typed by the minimal
16
16
  * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
17
- * package root only because the `@rebasepro/client-postgresql` driver
17
+ * package root only because the `@rebasepro/client-postgres` driver
18
18
  * instantiates it directly; its surface may change without a major bump.
19
19
  */
20
20
  export declare class RebaseWebSocketClient {
@@ -35,6 +35,7 @@ export declare class RebaseWebSocketClient {
35
35
  private isConnected;
36
36
  private messageQueue;
37
37
  private requestTimeoutMs;
38
+ private subscriptionTimeoutMs;
38
39
  private reconnectTimeout;
39
40
  private isAuthenticated;
40
41
  private authPromise;
@@ -92,6 +93,19 @@ export declare class RebaseWebSocketClient {
92
93
  */
93
94
  private deepEqual;
94
95
  private normalizeForComparison;
96
+ /**
97
+ * The address of a row, for matching it against another copy of itself.
98
+ *
99
+ * A row is exactly its columns and carries no address, so it is derived
100
+ * from the key columns the server named — including the ordinary case where
101
+ * that key is `id`, which the server reports like any other.
102
+ *
103
+ * Undefined when there are no keys, which means the server could not
104
+ * resolve any: such rows genuinely cannot be recognised, and guessing at a
105
+ * column called `id` would be inventing an identity for a table that has
106
+ * none.
107
+ */
108
+ private rowAddress;
95
109
  /**
96
110
  * Merge incoming rows with cached data, preserving cached references
97
111
  * for rows whose values haven't changed. This avoids unnecessary
@@ -101,6 +115,48 @@ export declare class RebaseWebSocketClient {
101
115
  private mergeRows;
102
116
  listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (rows: Record<string, unknown>[]) => void, onError?: (error: Error) => void): () => void;
103
117
  listenOne<M extends Record<string, unknown>>(props: FetchOneProps<M>, onUpdate: (row: Record<string, unknown> | null) => void, onError?: (error: Error) => void): () => void;
118
+ /**
119
+ * Send a `subscribe_collection` for an already-registered subscription and
120
+ * arm its watchdog.
121
+ *
122
+ * Every path that registers a collection subscription goes through here, so
123
+ * that a subscribe which never lands — a rejected send, or a server that
124
+ * never answers — always ends up in `failCollectionSubscription` rather than
125
+ * leaving the entry parked with `isInitialDataReceived === false` forever.
126
+ */
127
+ private sendCollectionSubscribe;
128
+ /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
129
+ private sendEntitySubscribe;
130
+ /**
131
+ * Report a subscribe failure to every listener and drop the registration.
132
+ *
133
+ * Dropping it is the point: the callbacks stay live (their components are
134
+ * still mounted and have been told), but the next `listenCollection` for
135
+ * these params finds no entry and issues a fresh subscribe instead of
136
+ * silently attaching to a dead one.
137
+ */
138
+ private failCollectionSubscription;
139
+ /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
140
+ private failEntitySubscription;
141
+ /**
142
+ * Stop the watchdogs without failing anything — used when the socket drops,
143
+ * since the reconnect path re-subscribes everything anyway and a watchdog
144
+ * firing mid-reconnect would tear down healthy subscriptions.
145
+ */
146
+ private suspendSubscribeWatchdogs;
147
+ /**
148
+ * Arm watchdogs for subscribes that were requested while offline and have
149
+ * just been flushed to the socket. Their timers were deliberately not set at
150
+ * request time, so without this they would have no timeout at all.
151
+ */
152
+ private armPendingSubscribeWatchdogs;
153
+ private sendCollectionSubscribeWatchdog;
154
+ private sendEntitySubscribeWatchdog;
155
+ /**
156
+ * Fail every subscription that never received data. Called when reconnection
157
+ * is given up on, so views surface an error instead of spinning forever.
158
+ */
159
+ private failAllPendingSubscriptions;
104
160
  /**
105
161
  * Re-send all active subscriptions to the backend after a reconnect.
106
162
  * The server wipes subscription state when a client disconnects, so
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.9.1-canary.0fce67c",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -13,26 +13,25 @@
13
13
  "url": "https://github.com/rebasepro/rebase.git",
14
14
  "directory": "packages/client"
15
15
  },
16
- "main": "./dist/index.umd.cjs",
16
+ "main": "./dist/index.es.js",
17
17
  "module": "./dist/index.es.js",
18
18
  "types": "./dist/index.d.ts",
19
19
  "source": "src/index.ts",
20
20
  "engines": {
21
- "node": ">=14"
21
+ "node": ">=20"
22
22
  },
23
23
  "exports": {
24
24
  ".": {
25
25
  "types": "./dist/index.d.ts",
26
26
  "development": "./dist/index.es.js",
27
- "import": "./dist/index.es.js",
28
- "require": "./dist/index.umd.cjs"
27
+ "import": "./dist/index.es.js"
29
28
  },
30
29
  "./package.json": "./package.json"
31
30
  },
32
31
  "dependencies": {
33
- "@rebasepro/types": "0.9.0",
34
- "@rebasepro/utils": "0.9.0",
35
- "@rebasepro/common": "0.9.0"
32
+ "@rebasepro/utils": "0.9.1-canary.0fce67c",
33
+ "@rebasepro/common": "0.9.1-canary.0fce67c",
34
+ "@rebasepro/types": "0.9.1-canary.0fce67c"
36
35
  },
37
36
  "devDependencies": {
38
37
  "@jest/globals": "^30.4.1",
@@ -75,7 +74,7 @@
75
74
  },
76
75
  "scripts": {
77
76
  "watch": "vite build --watch",
78
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
77
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
79
78
  "test:lint": "eslint \"src/**\" --quiet",
80
79
  "test": "jest --passWithNoTests --forceExit",
81
80
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
package/src/admin.ts CHANGED
@@ -54,7 +54,7 @@ export function createAdmin(transport: Transport, options?: CreateAdminOptions)
54
54
  }
55
55
 
56
56
  async function resetPassword(userId: string, options?: { password?: string }) {
57
- return transport.request<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean }>(
57
+ return transport.request<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean; emailDeliveryFailed?: boolean }>(
58
58
  adminPath + "/users/" + encodeURIComponent(userId) + "/reset-password",
59
59
  {
60
60
  method: "POST",
package/src/api-keys.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Transport } from "./transport";
2
2
 
3
- // Re-define the types locally since they live in server-core, not in @rebasepro/types.
3
+ // Re-define the types locally since they live in server, not in @rebasepro/types.
4
4
  // These match the server-side types exactly.
5
5
 
6
6
  /** A single permission entry scoping an API key to a collection and its allowed operations. */
package/src/backups.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { Transport } from "./transport";
2
+ import type { BackupInfo, BackupDestinationKind } from "@rebasepro/types";
3
+
4
+ export interface CreateBackupsOptions {
5
+ backupsPath?: string;
6
+ }
7
+
8
+ export function createBackups(transport: Transport, options?: CreateBackupsOptions) {
9
+ const backupsPath = options?.backupsPath || "/admin/backups";
10
+
11
+ async function list(): Promise<{
12
+ backups: BackupInfo[];
13
+ destinationKind: BackupDestinationKind;
14
+ configured: boolean;
15
+ }> {
16
+ return transport.request(backupsPath, { method: "GET" });
17
+ }
18
+
19
+ /**
20
+ * Download a backup's bytes. Uses an authenticated fetch (not the JSON
21
+ * transport) so the octet-stream response comes back as a Blob.
22
+ */
23
+ async function download(key: string): Promise<Blob> {
24
+ const token = await transport.resolveToken();
25
+ // Mirror transport.request's URL construction (baseUrl + apiPath + path)
26
+ // — this endpoint returns an octet-stream, so we fetch it directly
27
+ // instead of going through the JSON transport.
28
+ const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
29
+ const res = await fetch(url, {
30
+ method: "GET",
31
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
32
+ });
33
+ if (!res.ok) {
34
+ throw new Error(`Failed to download backup (${res.status})`);
35
+ }
36
+ return res.blob();
37
+ }
38
+
39
+ return { list, download };
40
+ }
package/src/collection.ts CHANGED
@@ -70,6 +70,22 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
70
70
  return raw as M;
71
71
  },
72
72
 
73
+ async createMany(data: Partial<M>[], options?: { upsert?: boolean }) {
74
+ if (!Array.isArray(data)) {
75
+ throw new TypeError("createMany expects an array of records.");
76
+ }
77
+ if (data.length === 0) return [];
78
+
79
+ const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
80
+ method: "POST",
81
+ body: JSON.stringify({
82
+ rows: data,
83
+ ...(options?.upsert ? { upsert: true } : {})
84
+ })
85
+ });
86
+ return (raw.data || []) as M[];
87
+ },
88
+
73
89
  async update(id: string | number, data: Partial<M>) {
74
90
  const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
75
91
  method: "PUT",
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { RebaseClientError } from "./errors";
3
3
  import { createAuth, CreateAuthOptions } from "./auth";
4
4
  import { createAdmin, CreateAdminOptions } from "./admin";
5
5
  import { createCron, CreateCronOptions } from "./cron";
6
+ import { createBackups } from "./backups";
6
7
  import { createApiKeys, CreateApiKeysOptions } from "./api-keys";
7
8
  import { CollectionClient, createCollectionClient } from "./collection";
8
9
  import { createFunctionsClient } from "./functions";
@@ -59,6 +60,8 @@ export type { RebaseUser, RebaseTokens } from "./auth";
59
60
  export type { CreateAdminOptions } from "./admin";
60
61
  export type { AdminUser } from "./admin";
61
62
  export type { CreateCronOptions } from "./cron";
63
+ export { createBackups } from "./backups";
64
+ export type { CreateBackupsOptions } from "./backups";
62
65
  export type {
63
66
  ApiKeyMasked,
64
67
  ApiKeyPermission,
@@ -70,7 +73,7 @@ export type {
70
73
  export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
71
74
 
72
75
  // Realtime: the WebSocket client class is internal to `createRebaseClient()`,
73
- // but re-exported (see @internal on the class) because the `client-postgresql`
76
+ // but re-exported (see @internal on the class) because the `client-postgres`
74
77
  // driver constructs it directly. Not a stable app-facing API.
75
78
  export { RebaseWebSocketClient } from "./websocket";
76
79
 
@@ -135,14 +138,24 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
135
138
  auth: ReturnType<typeof createAuth>;
136
139
  admin: ReturnType<typeof createAdmin>;
137
140
  cron: ReturnType<typeof createCron>;
141
+ backups: ReturnType<typeof createBackups>;
138
142
  apiKeys: ReturnType<typeof createApiKeys>;
139
143
  functions: ReturnType<typeof createFunctionsClient>;
140
144
  ws?: RebaseWebSocketClient;
145
+ /**
146
+ * Release the realtime socket and its reconnect timer.
147
+ *
148
+ * An open socket keeps the Node event loop alive, so a script that does not
149
+ * call this will not exit on its own. Safe when realtime was never started
150
+ * (`realtime: false`), and safe to call twice.
151
+ */
152
+ close: () => void;
141
153
  storage: StorageSource;
142
154
  storageRegistry: StorageSourceRegistry;
143
155
  createStorageSource: (storageId: string) => StorageSource;
144
156
  fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
145
157
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
158
+ collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
146
159
  data: TypedDataLayer<DB>;
147
160
  };
148
161
 
@@ -187,6 +200,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
187
200
  const auth = createAuth(transport, options.auth);
188
201
  const admin = createAdmin(transport, options.admin);
189
202
  const cron = createCron(transport, options.cron);
203
+ const backups = createBackups(transport);
190
204
  const apiKeys = createApiKeys(transport, options.apiKeys);
191
205
  const storage = createStorage(transport);
192
206
  const functions = createFunctionsClient(transport);
@@ -233,7 +247,13 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
233
247
  return storageSourcesPromise;
234
248
  };
235
249
 
236
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
250
+ // Opting out has to happen before the URL is derived: `deriveWebSocketUrl`
251
+ // always produces one, so a truthy check alone can never leave the socket
252
+ // closed.
253
+ const realtimeEnabled = options.realtime !== false;
254
+ const resolvedWsUrl = realtimeEnabled
255
+ ? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
256
+ : undefined;
237
257
 
238
258
  let ws: RebaseWebSocketClient | undefined;
239
259
  if (resolvedWsUrl) {
@@ -395,6 +415,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
395
415
  auth,
396
416
  admin,
397
417
  cron,
418
+ backups,
398
419
  apiKeys,
399
420
  functions,
400
421
  storage,
@@ -402,6 +423,16 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
402
423
  createStorageSource,
403
424
  fetchStorageSources,
404
425
  ws,
426
+ /**
427
+ * Release the realtime socket and its reconnect timer.
428
+ *
429
+ * Until this returns, the open socket keeps the Node event loop alive
430
+ * and the process will not exit on its own. Safe to call when realtime
431
+ * was never started, and safe to call twice.
432
+ */
433
+ close: () => {
434
+ ws?.disconnect();
435
+ },
405
436
  setToken: transport.setToken,
406
437
  setAuthTokenGetter: transport.setAuthTokenGetter,
407
438
  setOnUnauthorized: transport.setOnUnauthorized,
@@ -0,0 +1,119 @@
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 the socket by default", () => {
44
+ const { FakeWebSocket, opened } = trackingWebSocket();
45
+ globalThis.WebSocket = FakeWebSocket;
46
+
47
+ createRebaseClient({ baseUrl: "http://localhost:3000/api" });
48
+
49
+ expect(opened).toHaveLength(1);
50
+ });
51
+
52
+ it("opens no socket when realtime is disabled", () => {
53
+ const { FakeWebSocket, opened } = trackingWebSocket();
54
+ globalThis.WebSocket = FakeWebSocket;
55
+
56
+ const client = createRebaseClient({
57
+ baseUrl: "http://localhost:3000/api",
58
+ realtime: false
59
+ });
60
+
61
+ // The socket is what keeps a CLI / cron / ETL process alive past its work.
62
+ expect(opened).toHaveLength(0);
63
+ expect(client.ws).toBeUndefined();
64
+ });
65
+
66
+ it("opens no socket when realtime is disabled even if a websocketUrl is given", () => {
67
+ const { FakeWebSocket, opened } = trackingWebSocket();
68
+ globalThis.WebSocket = FakeWebSocket;
69
+
70
+ createRebaseClient({
71
+ baseUrl: "http://localhost:3000/api",
72
+ websocketUrl: "ws://localhost:3000",
73
+ realtime: false
74
+ });
75
+
76
+ expect(opened).toHaveLength(0);
77
+ });
78
+
79
+ it("close() releases the socket", () => {
80
+ const { FakeWebSocket, opened, closed } = trackingWebSocket();
81
+ globalThis.WebSocket = FakeWebSocket;
82
+
83
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
84
+ expect(opened).toHaveLength(1);
85
+
86
+ client.close();
87
+
88
+ expect(closed).toHaveLength(1);
89
+ });
90
+
91
+ it("close() is safe when realtime was never started, and when called twice", () => {
92
+ const { FakeWebSocket } = trackingWebSocket();
93
+ globalThis.WebSocket = FakeWebSocket;
94
+
95
+ const offline = createRebaseClient({ baseUrl: "http://localhost:3000/api", realtime: false });
96
+ expect(() => offline.close()).not.toThrow();
97
+
98
+ const live = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
99
+ live.close();
100
+ expect(() => live.close()).not.toThrow();
101
+ });
102
+
103
+ it("leaves listen() absent so callers can feature-detect, and says why via the query builder", () => {
104
+ const { FakeWebSocket } = trackingWebSocket();
105
+ globalThis.WebSocket = FakeWebSocket;
106
+
107
+ const client = createRebaseClient({
108
+ baseUrl: "http://localhost:3000/api",
109
+ realtime: false
110
+ });
111
+
112
+ // `listen` stays undefined rather than becoming a throwing stub: the
113
+ // optional type is what makes `if (client.listen)` work and what makes
114
+ // TypeScript reject a bare call.
115
+ expect(client.collection("posts").listen).toBeUndefined();
116
+ expect(() => client.data.posts.include("author").listen(() => { /* noop */ }))
117
+ .toThrow(/realtime: false/);
118
+ });
119
+ });