@rebasepro/client 0.0.1-canary.eae7889 → 0.1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.0.1-canary.eae7889",
4
+ "version": "0.1.0",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -30,8 +30,8 @@
30
30
  "./package.json": "./package.json"
31
31
  },
32
32
  "dependencies": {
33
- "@rebasepro/types": "0.0.1-canary.eae7889",
34
- "@rebasepro/utils": "0.0.1-canary.eae7889"
33
+ "@rebasepro/types": "0.1.0",
34
+ "@rebasepro/utils": "0.1.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@jest/globals": "^29.7.0",
@@ -41,7 +41,6 @@
41
41
  "jest": "^29.7.0",
42
42
  "npm-run-all": "^4.1.5",
43
43
  "ts-jest": "^29.3.1",
44
- "ts-node": "^10.9.2",
45
44
  "tsd": "^0.31.2",
46
45
  "typescript": "^5.8.3",
47
46
  "vite": "^5.4.17"
package/src/auth.ts CHANGED
@@ -193,19 +193,31 @@ accessToken: session.accessToken,
193
193
  refreshToken: session.refreshToken };
194
194
  }
195
195
 
196
- async function signInWithGoogle(idToken: string) {
196
+ /**
197
+ * Sign in with Google.
198
+ *
199
+ * Supports two invocation styles:
200
+ * - `signInWithGoogle({ idToken })` — ID-token flow (One Tap / Sign In button)
201
+ * - `signInWithGoogle({ accessToken })` — Access-token flow (popup)
202
+ * - `signInWithGoogle({ code, redirectUri })` — Authorization code flow (most secure)
203
+ * - `signInWithGoogle(idToken)` — Legacy shorthand for ID-token flow
204
+ */
205
+ async function signInWithGoogle(
206
+ tokenOrPayload: string | { idToken?: string; accessToken?: string; code?: string; redirectUri?: string }
207
+ ) {
197
208
  const fetchFn = getFetch();
209
+ const body = typeof tokenOrPayload === "string"
210
+ ? { idToken: tokenOrPayload }
211
+ : tokenOrPayload;
198
212
  const res = await fetchFn(authUrl("/google"), {
199
213
  method: "POST",
200
214
  headers: { "Content-Type": "application/json" },
201
- body: JSON.stringify({ idToken })
215
+ body: JSON.stringify(body)
202
216
  });
203
- const body = await res.json().catch(() => ({}));
204
- if (!res.ok) throwApiError(res.status, body, res.statusText);
205
- const session = handleAuthResponse(body, "SIGNED_IN");
206
- return { user: session.user,
207
- accessToken: session.accessToken,
208
- refreshToken: session.refreshToken };
217
+ const responseBody = await res.json().catch(() => ({}));
218
+ if (!res.ok) throwApiError(res.status, responseBody, res.statusText);
219
+ const session = handleAuthResponse(responseBody, "SIGNED_IN");
220
+ return { user: session.user, accessToken: session.accessToken, refreshToken: session.refreshToken };
209
221
  }
210
222
 
211
223
  async function signInWithLinkedin(code: string, redirectUri: string) {
@@ -0,0 +1,159 @@
1
+ import { jest } from '@jest/globals';
2
+ import { createCollectionClient } from "./collection";
3
+ import type { Transport } from "./transport";
4
+
5
+ function createMockTransport(): Transport {
6
+ return {
7
+ request: jest.fn<any>().mockResolvedValue({}),
8
+ setToken: jest.fn(),
9
+ setAuthTokenGetter: jest.fn(),
10
+ setOnUnauthorized: jest.fn(),
11
+ baseUrl: "http://localhost:3000",
12
+ apiPath: "/api",
13
+ fetchFn: globalThis.fetch,
14
+ getHeaders: () => ({}),
15
+ resolveToken: jest.fn<any>().mockResolvedValue(null),
16
+ };
17
+ }
18
+
19
+ describe("createCollectionClient", () => {
20
+ let transport: Transport;
21
+
22
+ beforeEach(() => {
23
+ transport = createMockTransport();
24
+ });
25
+
26
+ describe("count()", () => {
27
+ it("should call the /count endpoint and return the count", async () => {
28
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 42 });
29
+
30
+ const client = createCollectionClient(transport, "products");
31
+ const result = await client.count();
32
+
33
+ expect(transport.request).toHaveBeenCalledWith(
34
+ "/data/products/count",
35
+ { method: "GET" }
36
+ );
37
+ expect(result).toBe(42);
38
+ });
39
+
40
+ it("should forward where filters to the count endpoint", async () => {
41
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 7 });
42
+
43
+ const client = createCollectionClient(transport, "products");
44
+ const result = await client.count({
45
+ where: { status: "eq.published" }
46
+ });
47
+
48
+ expect(transport.request).toHaveBeenCalledWith(
49
+ expect.stringContaining("/data/products/count?"),
50
+ { method: "GET" }
51
+ );
52
+ // Verify the query string includes the filter
53
+ const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
54
+ expect(calledUrl).toContain("status=");
55
+ expect(result).toBe(7);
56
+ });
57
+
58
+ it("should forward orderBy to the count endpoint", async () => {
59
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 15 });
60
+
61
+ const client = createCollectionClient(transport, "items");
62
+ const result = await client.count({
63
+ orderBy: "created_at:desc"
64
+ });
65
+
66
+ const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
67
+ expect(calledUrl).toContain("/data/items/count?");
68
+ expect(calledUrl).toContain("orderBy=");
69
+ expect(result).toBe(15);
70
+ });
71
+
72
+ it("should NOT include limit or offset in the count request", async () => {
73
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 100 });
74
+
75
+ const client = createCollectionClient(transport, "users");
76
+ // Even if the caller passes limit/offset in params, they should be stripped
77
+ await client.count({ limit: 50, offset: 10 });
78
+
79
+ const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
80
+ expect(calledUrl).not.toContain("limit=");
81
+ expect(calledUrl).not.toContain("offset=");
82
+ });
83
+
84
+ it("should return 0 when meta.count is missing", async () => {
85
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({});
86
+
87
+ const client = createCollectionClient(transport, "empty");
88
+ const result = await client.count();
89
+
90
+ expect(result).toBe(0);
91
+ });
92
+
93
+ it("should forward searchString to the count endpoint", async () => {
94
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 3 });
95
+
96
+ const client = createCollectionClient(transport, "articles");
97
+ const result = await client.count({
98
+ searchString: "hello world"
99
+ });
100
+
101
+ const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
102
+ expect(calledUrl).toContain("searchString=");
103
+ expect(calledUrl).toContain("hello");
104
+ expect(result).toBe(3);
105
+ });
106
+
107
+ it("should forward multiple where filters correctly", async () => {
108
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 5 });
109
+
110
+ const client = createCollectionClient(transport, "orders");
111
+ await client.count({
112
+ where: {
113
+ status: "eq.active",
114
+ total: [">=", 100],
115
+ }
116
+ });
117
+
118
+ const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
119
+ expect(calledUrl).toContain("status=");
120
+ expect(calledUrl).toContain("total=");
121
+ });
122
+ });
123
+
124
+ describe("find()", () => {
125
+ it("should call the list endpoint and return entities", async () => {
126
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({
127
+ data: [{ id: "1", name: "Product A" }],
128
+ meta: { total: 1, limit: 20, offset: 0, hasMore: false }
129
+ });
130
+
131
+ const client = createCollectionClient(transport, "products");
132
+ const result = await client.find();
133
+
134
+ expect(transport.request).toHaveBeenCalledWith(
135
+ "/data/products",
136
+ { method: "GET" }
137
+ );
138
+ expect(result.data).toHaveLength(1);
139
+ expect(result.data[0].id).toBe("1");
140
+ expect(result.data[0].path).toBe("products");
141
+ expect(result.meta.total).toBe(1);
142
+ });
143
+ });
144
+
145
+ describe("count() is defined", () => {
146
+ it("should have count as a defined function on the accessor", () => {
147
+ const client = createCollectionClient(transport, "products");
148
+ expect(typeof client.count).toBe("function");
149
+ expect(client.count).toBeDefined();
150
+ });
151
+
152
+ it("should pass the accessor.count truthiness check (used by EntitiesCount component)", () => {
153
+ const client = createCollectionClient(transport, "products");
154
+ // The EntitiesCount component does `if (accessor.count) { ... }`
155
+ // This verifies that check would pass
156
+ expect(!!client.count).toBe(true);
157
+ });
158
+ });
159
+ });
package/src/collection.ts CHANGED
@@ -133,6 +133,7 @@ export interface CollectionClient<M extends Record<string, unknown> = Record<str
133
133
  offset(count: number): QueryBuilder<M>;
134
134
  search(searchString: string): QueryBuilder<M>;
135
135
  include(...relations: string[]): QueryBuilder<M>;
136
+ count(params?: FindParams): Promise<number>;
136
137
  }
137
138
 
138
139
  export function createCollectionClient<M extends Record<string, unknown> = Record<string, unknown>>(transport: Transport, slug: string, ws?: RebaseWebSocketClient): CollectionClient<M> {
@@ -180,6 +181,13 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
180
181
  });
181
182
  },
182
183
 
184
+ async count(params?: FindParams): Promise<number> {
185
+ const countParams: FindParams = { ...params, limit: undefined, offset: undefined };
186
+ const qs = buildQueryString(countParams);
187
+ const raw = await transport.request<{ count: number }>(basePath + "/count" + qs, { method: "GET" });
188
+ return raw.count ?? 0;
189
+ },
190
+
183
191
  // Fluent builder instantiation
184
192
  where(column: keyof M & string, operator: FilterOperator, value: unknown) {
185
193
  return new QueryBuilder<M>(client).where(column, operator, value);
@@ -0,0 +1,80 @@
1
+ import type { Transport } from "./transport";
2
+
3
+ /**
4
+ * Client interface for invoking custom backend functions.
5
+ *
6
+ * Custom functions are Hono route files auto-mounted by the Rebase backend
7
+ * at `/api/functions/{name}`. The `FunctionsClient` wraps the shared
8
+ * transport so callers never need to manually construct URLs or inject
9
+ * auth tokens.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const result = await client.functions.invoke<{ job: Job }>('extract-job', {
14
+ * url: 'https://example.com/posting',
15
+ * html: htmlContent,
16
+ * });
17
+ * ```
18
+ */
19
+ export interface FunctionsClient {
20
+ /**
21
+ * Invoke a custom backend function by name.
22
+ *
23
+ * @typeParam T - Expected shape of the response payload.
24
+ * @param name - Function name (the filename without extension, e.g. `"extract-job"`).
25
+ * @param payload - Optional JSON-serialisable body sent as `POST`.
26
+ * @param options - Optional overrides (HTTP method, sub-path, extra headers).
27
+ * @returns The parsed JSON response from the function.
28
+ */
29
+ invoke<T = unknown>(
30
+ name: string,
31
+ payload?: unknown,
32
+ options?: FunctionInvokeOptions,
33
+ ): Promise<T>;
34
+ }
35
+
36
+ export interface FunctionInvokeOptions {
37
+ /** HTTP method — defaults to `"POST"`. */
38
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
39
+ /** Sub-path appended after the function name, e.g. `"status/123"`. */
40
+ path?: string;
41
+ /** Extra headers merged into the request (auth is still injected automatically). */
42
+ headers?: Record<string, string>;
43
+ }
44
+
45
+ /**
46
+ * Create a `FunctionsClient` backed by the given transport.
47
+ *
48
+ * The transport already handles:
49
+ * - Base URL resolution
50
+ * - JWT injection via `Authorization: Bearer`
51
+ * - 401 retry / `onUnauthorized` flow
52
+ * - Consistent error throwing via `RebaseApiError`
53
+ *
54
+ * @internal
55
+ */
56
+ export function createFunctionsClient(transport: Transport): FunctionsClient {
57
+ return {
58
+ async invoke<T = unknown>(
59
+ name: string,
60
+ payload?: unknown,
61
+ options?: FunctionInvokeOptions,
62
+ ): Promise<T> {
63
+ const method = options?.method ?? "POST";
64
+ const subPath = options?.path ? `/${options.path.replace(/^\//, "")}` : "";
65
+ const routePath = `/functions/${encodeURIComponent(name)}${subPath}`;
66
+
67
+ const init: RequestInit = { method };
68
+
69
+ if (payload !== undefined && method !== "GET") {
70
+ init.body = JSON.stringify(payload);
71
+ }
72
+
73
+ if (options?.headers) {
74
+ init.headers = options.headers;
75
+ }
76
+
77
+ return transport.request<T>(routePath, init);
78
+ },
79
+ };
80
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ import { createAuth, CreateAuthOptions } from "./auth";
3
3
  import { createAdmin, CreateAdminOptions } from "./admin";
4
4
  import { createCron, CreateCronOptions } from "./cron";
5
5
  import { createCollectionClient, CollectionClient } from "./collection";
6
+ import { createFunctionsClient } from "./functions";
7
+ import type { FunctionsClient } from "./functions";
6
8
 
7
9
  export * from "./transport";
8
10
  export * from "./auth";
@@ -12,6 +14,7 @@ export * from "./collection";
12
14
  export * from "./websocket";
13
15
  export * from "./storage";
14
16
  export * from "./reviver";
17
+ export * from "./functions";
15
18
 
16
19
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
17
20
  auth?: CreateAuthOptions;
@@ -31,6 +34,7 @@ export type RebaseClient<DB = Record<string, unknown>> = BaseRebaseClient<DB> &
31
34
  auth: ReturnType<typeof createAuth>;
32
35
  admin: ReturnType<typeof createAdmin>;
33
36
  cron: ReturnType<typeof createCron>;
37
+ functions: FunctionsClient;
34
38
  ws?: RebaseWebSocketClient;
35
39
  storage?: StorageSource;
36
40
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
@@ -72,6 +76,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
72
76
  const admin = createAdmin(transport, options.admin);
73
77
  const cron = createCron(transport, options.cron);
74
78
  const storage = createStorage(transport);
79
+ const functions = createFunctionsClient(transport);
75
80
 
76
81
  const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
77
82
 
@@ -143,6 +148,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
143
148
  auth,
144
149
  admin,
145
150
  cron,
151
+ functions,
146
152
  storage,
147
153
  ws,
148
154
  setToken: transport.setToken,
package/src/websocket.ts CHANGED
@@ -190,16 +190,18 @@ export class RebaseWebSocketClient {
190
190
  this.getAuthToken = getAuthToken;
191
191
  // Auto-authenticate if we are already connected but didn't have the token getter yet
192
192
  if (this.isConnected && !this.isAuthenticated && !this.authPromise) {
193
- console.log("WebSocket auto-authenticating after token getter set");
193
+ console.debug("WebSocket auto-authenticating after token getter set");
194
194
  this.getAuthToken().then(token => {
195
195
  if (!this.ws) return; // Prevent memory leaks / actions after disconnect
196
196
  if (token) {
197
197
  this.authenticate(token).catch(e => {
198
- if (this.ws) console.warn("WebSocket auto-auth failed:", e);
198
+ if (this.ws) console.debug("WebSocket auto-auth skipped:", e?.message || e);
199
199
  });
200
200
  }
201
201
  }).catch(e => {
202
- if (this.ws) console.warn("WebSocket auto-auth failed:", e);
202
+ // User not logged in or auth still loading — this is expected,
203
+ // the WebSocket will authenticate on-demand when a request is made.
204
+ if (this.ws) console.debug("WebSocket auto-auth skipped:", e?.message || e);
203
205
  });
204
206
  }
205
207
  }
@@ -230,7 +232,7 @@ export class RebaseWebSocketClient {
230
232
  this.ws = new this.WebSocketConstructor(this.websocketUrl);
231
233
 
232
234
  this.ws!.onopen = async () => {
233
- console.log("Connected to PostgreSQL backend");
235
+ console.debug("Connected to PostgreSQL backend");
234
236
  const wasReconnect = this.reconnectAttempts > 0;
235
237
  this.isConnected = true;
236
238
  this.reconnectAttempts = 0;
@@ -241,10 +243,12 @@ export class RebaseWebSocketClient {
241
243
  const token = await this.getAuthToken();
242
244
  if (token) {
243
245
  await this.authenticate(token);
244
- console.log("WebSocket auto-authenticated");
246
+ console.debug("WebSocket auto-authenticated");
245
247
  }
246
248
  } catch (error) {
247
- console.warn("WebSocket auto-auth failed, requests may fail:", error);
249
+ // User not logged in or auth still loading this is expected.
250
+ // Authentication will happen on-demand when the user logs in.
251
+ console.debug("WebSocket connected without auth:", (error as Error)?.message || error);
248
252
  }
249
253
  }
250
254
 
@@ -269,7 +273,7 @@ export class RebaseWebSocketClient {
269
273
  };
270
274
 
271
275
  this.ws!.onclose = () => {
272
- console.log("Disconnected from PostgreSQL backend");
276
+ console.debug("Disconnected from PostgreSQL backend");
273
277
  this.isConnected = false;
274
278
  this.isAuthenticated = false;
275
279
  this.authPromise = null;
@@ -319,7 +323,7 @@ export class RebaseWebSocketClient {
319
323
  this.reconnectAttempts++;
320
324
  const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
321
325
 
322
- console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
326
+ console.debug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
323
327
 
324
328
  if (this.reconnectTimeout) {
325
329
  clearTimeout(this.reconnectTimeout);
@@ -534,7 +538,7 @@ export class RebaseWebSocketClient {
534
538
  this.authPromise = this.authenticate(token);
535
539
  await this.authPromise;
536
540
  this.authPromise = null;
537
- console.log("WebSocket authenticated on demand");
541
+ console.debug("WebSocket authenticated on demand");
538
542
  return; // Success
539
543
  } catch (error: unknown) {
540
544
  this.authPromise = null;
@@ -560,7 +564,7 @@ export class RebaseWebSocketClient {
560
564
  // For other errors, retry with backoff
561
565
  if (attempt < retryCount - 1) {
562
566
  const delay = Math.min(1000 * (attempt + 1), 3000);
563
- console.log(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
567
+ console.debug(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
564
568
  await new Promise(resolve => setTimeout(resolve, delay));
565
569
  }
566
570
  }
@@ -580,7 +584,7 @@ export class RebaseWebSocketClient {
580
584
  try {
581
585
  const token = await this.getAuthToken();
582
586
  await this.authenticate(token);
583
- console.log("WebSocket reauthenticated successfully");
587
+ console.debug("WebSocket reauthenticated successfully");
584
588
  } catch (error) {
585
589
  console.error("WebSocket reauthentication failed:", error);
586
590
  throw error;
@@ -891,7 +895,7 @@ options }
891
895
  incoming: normIncoming[key] };
892
896
  }
893
897
  }
894
- console.log(`[RebaseWS] Row ${incomingEntity.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
898
+ console.debug(`[RebaseWS] Row ${incomingEntity.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
895
899
  }
896
900
  }
897
901
  return incomingEntity;
@@ -1101,7 +1105,7 @@ onError });
1101
1105
  * we need to re-register everything to resume receiving updates.
1102
1106
  */
1103
1107
  private resubscribeAll(): void {
1104
- console.log(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.entitySubscriptions.size} entity(ies)`);
1108
+ console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.entitySubscriptions.size} entity(ies)`);
1105
1109
 
1106
1110
  // Re-subscribe collection subscriptions
1107
1111
  for (const [key, sub] of this.collectionSubscriptions.entries()) {