@rebasepro/client 0.8.0 → 0.9.1-canary.09aaf62

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Broadcast channels and presence, as an SDK surface.
3
+ *
4
+ * The realtime engine has supported `join_channel`, `broadcast`,
5
+ * `presence_track`, `presence_untrack` and `presence_state` for a while, but
6
+ * the client only recognised those types well enough to send them
7
+ * fire-and-forget: there were no methods to call and no way to receive channel
8
+ * or broadcast events, since `on()` handles only connect / disconnect /
9
+ * reconnect / error. Anything wanting presence therefore opened a *second*
10
+ * socket and reimplemented the AUTHENTICATE → AUTH_SUCCESS handshake, the
11
+ * reconnect backoff, and the presence heartbeat — a couple of hundred lines
12
+ * per app, all of it duplicating this package.
13
+ *
14
+ * Two protocol details this hides, because both are easy to get wrong and
15
+ * neither is discoverable from the message list:
16
+ *
17
+ * - **A joining client is told only about its own join.** The `presence_diff`
18
+ * it receives after `presence_track` contains just itself. The existing
19
+ * roster arrives only in response to an explicit `presence_state` request,
20
+ * so `join()` sends one.
21
+ * - **Presence expires after 30s** (`PRESENCE_TIMEOUT_MS` server-side). A
22
+ * client that tracks once and goes quiet silently vanishes from everyone
23
+ * else's roster while still sitting in the document, so `track()` starts a
24
+ * heartbeat and `leave()` stops it.
25
+ */
26
+ /** Presence state keyed by the server's client id. */
27
+ export type PresenceState = Record<string, Record<string, unknown>>;
28
+ export interface PresenceDiff {
29
+ joins: PresenceState;
30
+ leaves: PresenceState;
31
+ }
32
+ export interface BroadcastEvent {
33
+ event: string;
34
+ payload: unknown;
35
+ }
36
+ /** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
37
+ export interface ChannelTransport {
38
+ sendMessage(message: Record<string, unknown>): Promise<unknown>;
39
+ onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
40
+ onReconnect(handler: () => void): () => void;
41
+ }
42
+ export declare class RebaseRealtimeChannel {
43
+ readonly name: string;
44
+ private transport;
45
+ private presenceHandlers;
46
+ private broadcastHandlers;
47
+ private unsubscribers;
48
+ /** Last known roster, kept so handlers always get a full picture. */
49
+ private presences;
50
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
51
+ private trackedState;
52
+ private heartbeat;
53
+ private joined;
54
+ constructor(name: string, transport: ChannelTransport);
55
+ /**
56
+ * Join the channel and ask for the current roster.
57
+ *
58
+ * Called automatically by `track`, `broadcast`, `onPresence` and
59
+ * `onBroadcast`; calling it directly is only needed to start receiving
60
+ * before there is anything to send.
61
+ */
62
+ join(): Promise<void>;
63
+ private rejoin;
64
+ /**
65
+ * Publish this client's presence state, and keep publishing it.
66
+ *
67
+ * Calling `track` again replaces the state (and restarts the heartbeat),
68
+ * which is how you update e.g. a cursor position.
69
+ */
70
+ track(state: Record<string, unknown>): Promise<void>;
71
+ /** Stop publishing presence, without leaving the channel. */
72
+ untrack(): Promise<void>;
73
+ /**
74
+ * Observe the roster. The handler fires immediately with what is already
75
+ * known, then on every change.
76
+ */
77
+ onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void;
78
+ /** Send a broadcast. The sender does not receive its own message. */
79
+ broadcast(event: string, payload: unknown): Promise<void>;
80
+ /** Observe broadcasts. Pass an event name to filter. */
81
+ onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
82
+ onBroadcast(event: string, handler: (payload: unknown) => void): () => void;
83
+ /** Leave the channel and release every listener and timer. */
84
+ leave(): Promise<void>;
85
+ private stopHeartbeat;
86
+ /** Fold an incoming frame into the roster and fan it out. */
87
+ private handle;
88
+ private emitPresence;
89
+ }
@@ -0,0 +1,63 @@
1
+ import { FindResult, LogicalCondition, SDKCollectionClient, SDKQueryBuilderInterface, WhereFilterOp, WhereValue } from "@rebasepro/types";
2
+ /**
3
+ * SDK Query Builder — returns flat rows (`FindResult<M>`) instead of
4
+ * Entity-wrapped results (`FindResponse<M>`).
5
+ *
6
+ * @example
7
+ * const { data } = await rebase.data.posts
8
+ * .where("status", "==", "published")
9
+ * .orderBy("created_at", "desc")
10
+ * .limit(10)
11
+ * .find();
12
+ *
13
+ * console.log(data[0].title); // flat access
14
+ */
15
+ export declare class SDKQueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements SDKQueryBuilderInterface<M> {
16
+ private collection;
17
+ private params;
18
+ constructor(collection: SDKCollectionClient<M>);
19
+ /**
20
+ * Add a filter condition to your query.
21
+ * @example
22
+ * client.data.users.where('age', '>=', 18).find()
23
+ */
24
+ where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
25
+ where(logicalCondition: LogicalCondition): this;
26
+ /**
27
+ * Order the results by a specific column.
28
+ */
29
+ orderBy(column: keyof M & string, direction?: "asc" | "desc"): this;
30
+ /**
31
+ * Limit the number of results returned.
32
+ */
33
+ limit(count: number): this;
34
+ /**
35
+ * Skip the first N results.
36
+ */
37
+ offset(count: number): this;
38
+ /**
39
+ * Set a free-text search string if supported by the backend.
40
+ */
41
+ search(searchString: string): this;
42
+ /**
43
+ * Include related entities in the response.
44
+ * Relations will be populated with full data instead of just IDs.
45
+ *
46
+ * @param relations - Relation names to include, or "*" for all.
47
+ * @example
48
+ * client.data.posts.include("tags", "author").find()
49
+ */
50
+ include(...relations: string[]): this;
51
+ /**
52
+ * Execute the find query and return the results as flat rows.
53
+ */
54
+ find(): Promise<FindResult<M>>;
55
+ /**
56
+ * Count the records matching this query.
57
+ */
58
+ count(): Promise<number>;
59
+ /**
60
+ * Listen to realtime updates matching this query.
61
+ */
62
+ listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void;
63
+ }
@@ -1,23 +1,53 @@
1
1
  import { FindParams as TypesFindParams, FindResponse as TypesFindResponse } from "@rebasepro/types";
2
+ export { RebaseApiError } from "@rebasepro/types";
3
+ export type { RebaseErrorInit } from "@rebasepro/types";
2
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
+ */
3
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
+ */
4
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
+ */
5
28
  apiPath?: string;
6
29
  fetch?: typeof globalThis.fetch;
7
30
  onUnauthorized?: () => Promise<boolean>;
8
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;
9
45
  }
10
46
  /**
11
47
  * Re-export from `@rebasepro/types` for backward compatibility.
12
48
  */
13
49
  export type FindParams = TypesFindParams;
14
50
  export type FindResponse<T> = TypesFindResponse<T extends Record<string, unknown> ? T : Record<string, unknown>>;
15
- export declare class RebaseApiError extends Error {
16
- status: number;
17
- code?: string;
18
- details?: unknown;
19
- constructor(status: number, message: string, code?: string, details?: unknown);
20
- }
21
51
  export declare function buildQueryString(params?: FindParams): string;
22
52
  export interface Transport {
23
53
  request: <T = unknown>(path: string, init?: RequestInit) => Promise<T>;
@@ -1,4 +1,4 @@
1
- import { DeleteEntityProps, Entity, EntityCollection, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo } from "@rebasepro/types";
1
+ import { DeleteProps, CollectionConfig, FetchCollectionProps, FetchOneProps, SaveProps, TableMetadata, BranchInfo } from "@rebasepro/types";
2
2
  export interface RebaseWebSocketConfig {
3
3
  websocketUrl: string;
4
4
  /** Optional auth token getter for WebSocket authentication */
@@ -8,21 +8,31 @@ export interface RebaseWebSocketConfig {
8
8
  /** Callback to handle unauthorized requests or token expiration (refreshes auth session) */
9
9
  onUnauthorized?: () => Promise<boolean>;
10
10
  }
11
- export declare class ApiError extends Error {
12
- code?: string;
13
- error?: string;
14
- constructor(message: string, error?: string, code?: string);
15
- }
11
+ /**
12
+ * Low-level realtime WebSocket client.
13
+ *
14
+ * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
15
+ * manages this internally (exposed as `client.ws`, typed by the minimal
16
+ * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
17
+ * package root only because the `@rebasepro/client-postgres` driver
18
+ * instantiates it directly; its surface may change without a major bump.
19
+ */
16
20
  export declare class RebaseWebSocketClient {
17
21
  private websocketUrl;
18
22
  private ws;
19
23
  getAuthToken?: () => Promise<string | null>;
20
24
  private subscriptions;
21
25
  private listeners;
26
+ /** Channel-name → handlers, for broadcast and presence frames. */
27
+ private channelHandlers;
28
+ /** Subscribe to broadcast/presence frames for one channel. */
29
+ onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
30
+ /** Notified after the socket comes back, so channels can re-join. */
31
+ onReconnect(handler: () => void): () => void;
22
32
  on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void): () => boolean;
23
33
  private emit;
24
34
  private collectionSubscriptions;
25
- private entitySubscriptions;
35
+ private singleSubscriptions;
26
36
  private backendToCollectionKey;
27
37
  private backendToEntityKey;
28
38
  private pendingRequests;
@@ -31,6 +41,7 @@ export declare class RebaseWebSocketClient {
31
41
  private isConnected;
32
42
  private messageQueue;
33
43
  private requestTimeoutMs;
44
+ private subscriptionTimeoutMs;
34
45
  private reconnectTimeout;
35
46
  private isAuthenticated;
36
47
  private authPromise;
@@ -53,19 +64,23 @@ export declare class RebaseWebSocketClient {
53
64
  private isAuthError;
54
65
  private handleAuthFailure;
55
66
  /**
56
- * Shared logic for re-subscribing a collection or entity subscription
67
+ * Shared logic for re-subscribing a collection or row subscription
57
68
  * after an auth error is resolved by refreshing credentials.
58
69
  */
59
70
  private resubscribeAfterAuthRefresh;
60
71
  private handleWebSocketMessage;
61
72
  private ensureAuthenticated;
62
73
  reauthenticate(): Promise<void>;
63
- private sendMessage;
74
+ /**
75
+ * Public because `RebaseRealtimeChannel` sends channel frames through it.
76
+ * Not part of the stable surface — prefer `client.realtime.channel(name)`.
77
+ */
78
+ sendMessage(message: Record<string, unknown>): Promise<unknown>;
64
79
  private doSendMessage;
65
- fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Entity<M>[]>;
66
- fetchEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>): Promise<Entity<M> | undefined>;
67
- saveEntity<M extends Record<string, unknown>>(props: SaveEntityProps<M>): Promise<Entity<M>>;
68
- deleteEntity<M extends Record<string, unknown>>(props: DeleteEntityProps<M>): Promise<void>;
80
+ fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]>;
81
+ fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined>;
82
+ save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>>;
83
+ delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void>;
69
84
  executeSql(sql: string, options?: {
70
85
  database?: string;
71
86
  role?: string;
@@ -73,8 +88,8 @@ export declare class RebaseWebSocketClient {
73
88
  fetchAvailableDatabases(): Promise<string[]>;
74
89
  fetchAvailableRoles(): Promise<string[]>;
75
90
  fetchCurrentDatabase(): Promise<string | undefined>;
76
- checkUniqueField(path: string, name: string, value: unknown, entityId?: string, collection?: EntityCollection): Promise<boolean>;
77
- countEntities<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number>;
91
+ checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean>;
92
+ count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number>;
78
93
  fetchUnmappedTables(mappedPaths?: string[]): Promise<string[]>;
79
94
  fetchTableMetadata(tableName: string): Promise<TableMetadata>;
80
95
  createBranch(name: string, options?: {
@@ -89,14 +104,69 @@ export declare class RebaseWebSocketClient {
89
104
  private deepEqual;
90
105
  private normalizeForComparison;
91
106
  /**
92
- * Merge incoming entities with cached data, preserving cached references
93
- * for entities whose values haven't changed. This avoids unnecessary
94
- * React re-renders when the server refetches all entities but most
107
+ * The address of a row, for matching it against another copy of itself.
108
+ *
109
+ * A row is exactly its columns and carries no address, so it is derived
110
+ * from the key columns the server named — including the ordinary case where
111
+ * that key is `id`, which the server reports like any other.
112
+ *
113
+ * Undefined when there are no keys, which means the server could not
114
+ * resolve any: such rows genuinely cannot be recognised, and guessing at a
115
+ * column called `id` would be inventing an identity for a table that has
116
+ * none.
117
+ */
118
+ private rowAddress;
119
+ /**
120
+ * Merge incoming rows with cached data, preserving cached references
121
+ * for rows whose values haven't changed. This avoids unnecessary
122
+ * React re-renders when the server refetches all rows but most
95
123
  * haven't actually changed.
96
124
  */
97
- private mergeEntities;
98
- listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (entities: Entity[]) => void, onError?: (error: Error) => void): () => void;
99
- listenEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>, onUpdate: (entity: Entity | null) => void, onError?: (error: Error) => void): () => void;
125
+ private mergeRows;
126
+ listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (rows: Record<string, unknown>[]) => void, onError?: (error: Error) => void): () => void;
127
+ listenOne<M extends Record<string, unknown>>(props: FetchOneProps<M>, onUpdate: (row: Record<string, unknown> | null) => void, onError?: (error: Error) => void): () => void;
128
+ /**
129
+ * Send a `subscribe_collection` for an already-registered subscription and
130
+ * arm its watchdog.
131
+ *
132
+ * Every path that registers a collection subscription goes through here, so
133
+ * that a subscribe which never lands — a rejected send, or a server that
134
+ * never answers — always ends up in `failCollectionSubscription` rather than
135
+ * leaving the entry parked with `isInitialDataReceived === false` forever.
136
+ */
137
+ private sendCollectionSubscribe;
138
+ /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
139
+ private sendEntitySubscribe;
140
+ /**
141
+ * Report a subscribe failure to every listener and drop the registration.
142
+ *
143
+ * Dropping it is the point: the callbacks stay live (their components are
144
+ * still mounted and have been told), but the next `listenCollection` for
145
+ * these params finds no entry and issues a fresh subscribe instead of
146
+ * silently attaching to a dead one.
147
+ */
148
+ private failCollectionSubscription;
149
+ /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
150
+ private failEntitySubscription;
151
+ /**
152
+ * Stop the watchdogs without failing anything — used when the socket drops,
153
+ * since the reconnect path re-subscribes everything anyway and a watchdog
154
+ * firing mid-reconnect would tear down healthy subscriptions.
155
+ */
156
+ private suspendSubscribeWatchdogs;
157
+ /**
158
+ * Arm watchdogs for subscribes that were requested while offline and have
159
+ * just been flushed to the socket. Their timers were deliberately not set at
160
+ * request time, so without this they would have no timeout at all.
161
+ */
162
+ private armPendingSubscribeWatchdogs;
163
+ private sendCollectionSubscribeWatchdog;
164
+ private sendEntitySubscribeWatchdog;
165
+ /**
166
+ * Fail every subscription that never received data. Called when reconnection
167
+ * is given up on, so views surface an error instead of spinning forever.
168
+ */
169
+ private failAllPendingSubscriptions;
100
170
  /**
101
171
  * Re-send all active subscriptions to the backend after a reconnect.
102
172
  * The server wipes subscription state when a client disconnects, so
@@ -104,5 +174,5 @@ export declare class RebaseWebSocketClient {
104
174
  */
105
175
  private resubscribeAll;
106
176
  private createCollectionSubscriptionKey;
107
- private createEntitySubscriptionKey;
177
+ private createSingleSubscriptionKey;
108
178
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.8.0",
4
+ "version": "0.9.1-canary.09aaf62",
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/common": "0.8.0",
34
- "@rebasepro/types": "0.8.0",
35
- "@rebasepro/utils": "0.8.0"
32
+ "@rebasepro/common": "0.9.1-canary.09aaf62",
33
+ "@rebasepro/utils": "0.9.1-canary.09aaf62",
34
+ "@rebasepro/types": "0.9.1-canary.09aaf62"
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. */