@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,141 @@
1
+ import {
2
+ FindParams,
3
+ FindResult,
4
+ LogicalCondition,
5
+ SDKCollectionClient,
6
+ SDKQueryBuilderInterface,
7
+ WhereFilterOp,
8
+ WhereValue
9
+ } from "@rebasepro/types";
10
+
11
+ /**
12
+ * SDK Query Builder — returns flat rows (`FindResult<M>`) instead of
13
+ * Entity-wrapped results (`FindResponse<M>`).
14
+ *
15
+ * @example
16
+ * const { data } = await rebase.data.posts
17
+ * .where("status", "==", "published")
18
+ * .orderBy("created_at", "desc")
19
+ * .limit(10)
20
+ * .find();
21
+ *
22
+ * console.log(data[0].title); // flat access
23
+ */
24
+ export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements SDKQueryBuilderInterface<M> {
25
+ private params: FindParams = { where: {} };
26
+
27
+ constructor(private collection: SDKCollectionClient<M>) {}
28
+
29
+ /**
30
+ * Add a filter condition to your query.
31
+ * @example
32
+ * client.data.users.where('age', '>=', 18).find()
33
+ */
34
+ where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
35
+ where(logicalCondition: LogicalCondition): this;
36
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
37
+ if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
38
+ this.params.logical = columnOrCondition as LogicalCondition;
39
+ return this;
40
+ }
41
+
42
+ if (!this.params.where) {
43
+ this.params.where = {};
44
+ }
45
+
46
+ const column = columnOrCondition as string;
47
+ const condition: [WhereFilterOp, unknown] = [operator!, value];
48
+ const existing = this.params.where[column];
49
+
50
+ if (existing === undefined) {
51
+ this.params.where[column] = condition;
52
+ } else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
53
+ (this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
54
+ } else {
55
+ let firstCondition: [WhereFilterOp, unknown];
56
+ if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
57
+ firstCondition = existing as [WhereFilterOp, unknown];
58
+ } else {
59
+ firstCondition = ["==", existing];
60
+ }
61
+ this.params.where[column] = [firstCondition, condition];
62
+ }
63
+
64
+ return this;
65
+ }
66
+
67
+ /**
68
+ * Order the results by a specific column.
69
+ */
70
+ orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
71
+ this.params.orderBy = [column, direction];
72
+ return this;
73
+ }
74
+
75
+ /**
76
+ * Limit the number of results returned.
77
+ */
78
+ limit(count: number): this {
79
+ this.params.limit = count;
80
+ return this;
81
+ }
82
+
83
+ /**
84
+ * Skip the first N results.
85
+ */
86
+ offset(count: number): this {
87
+ this.params.offset = count;
88
+ return this;
89
+ }
90
+
91
+ /**
92
+ * Set a free-text search string if supported by the backend.
93
+ */
94
+ search(searchString: string): this {
95
+ this.params.searchString = searchString;
96
+ return this;
97
+ }
98
+
99
+ /**
100
+ * Include related entities in the response.
101
+ * Relations will be populated with full data instead of just IDs.
102
+ *
103
+ * @param relations - Relation names to include, or "*" for all.
104
+ * @example
105
+ * client.data.posts.include("tags", "author").find()
106
+ */
107
+ include(...relations: string[]): this {
108
+ this.params.include = relations;
109
+ return this;
110
+ }
111
+
112
+ /**
113
+ * Execute the find query and return the results as flat rows.
114
+ */
115
+ async find(): Promise<FindResult<M>> {
116
+ return this.collection.find(this.params);
117
+ }
118
+
119
+ /**
120
+ * Count the records matching this query.
121
+ */
122
+ async count(): Promise<number> {
123
+ if (!this.collection.count) {
124
+ throw new Error("count() is not supported by this collection client.");
125
+ }
126
+ return this.collection.count(this.params);
127
+ }
128
+
129
+ /**
130
+ * Listen to realtime updates matching this query.
131
+ */
132
+ listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void {
133
+ if (!this.collection.listen) {
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
+ );
138
+ }
139
+ return this.collection.listen(this.params, onUpdate, onError);
140
+ }
141
+ }
package/src/storage.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { StorageSource, UploadFileProps, UploadFileResult, DownloadConfig, StorageListResult, DownloadMetadata } from "@rebasepro/types";
1
+ import { StorageSource, UploadFileProps, UploadFileResult, DownloadConfig, StorageListResult, DownloadMetadata, PUBLIC_STORAGE_PREFIX, isPublicStoragePath } from "@rebasepro/types";
2
2
  import { Transport } from "./transport";
3
3
 
4
4
  /**
@@ -10,7 +10,7 @@ import { Transport } from "./transport";
10
10
  * `StorageController` is resolved from the registry.
11
11
  */
12
12
  export function createStorage(transport: Transport, storageId?: string): StorageSource {
13
- const urlsCache = new Map<string, DownloadConfig>();
13
+ const urlsCache = new Map<string, { config: DownloadConfig; expiresAt?: number }>();
14
14
 
15
15
  /** Append ?storageId=... to a path when multi-backend routing is active. */
16
16
  const withStorageId = (path: string): string => {
@@ -23,12 +23,22 @@ export function createStorage(transport: Transport, storageId?: string): Storage
23
23
  file,
24
24
  key,
25
25
  metadata,
26
- bucket
26
+ bucket,
27
+ public: isPublic
27
28
  }: UploadFileProps): Promise<UploadFileResult> {
28
29
  const formData = new FormData();
29
30
  formData.append("file", file);
30
31
 
31
- if (key) formData.append("key", key);
32
+ // Public objects live under the public prefix so they can be served
33
+ // token-less via a stable, permanent URL. Normalize the key here so the
34
+ // stored path is self-describing (no server round-trip needed to know
35
+ // it's public).
36
+ let effectiveKey = key;
37
+ if (isPublic && effectiveKey && !isPublicStoragePath(effectiveKey)) {
38
+ effectiveKey = `${PUBLIC_STORAGE_PREFIX}${effectiveKey.replace(/^\/+/, "")}`;
39
+ }
40
+
41
+ if (effectiveKey) formData.append("key", effectiveKey);
32
42
  if (bucket) formData.append("bucket", bucket);
33
43
  if (storageId) formData.append("storageId", storageId);
34
44
 
@@ -57,8 +67,13 @@ export function createStorage(transport: Transport, storageId?: string): Storage
57
67
  bucket?: string
58
68
  ): Promise<DownloadConfig> {
59
69
  const cacheKey = bucket ? `${bucket}/${keyOrUrl}` : keyOrUrl;
60
- const cached = urlsCache.get(cacheKey);
61
- if (cached) return cached;
70
+ const cachedEntry = urlsCache.get(cacheKey);
71
+ if (cachedEntry) {
72
+ if (!cachedEntry.expiresAt || cachedEntry.expiresAt > Date.now()) {
73
+ return cachedEntry.config;
74
+ }
75
+ urlsCache.delete(cacheKey);
76
+ }
62
77
 
63
78
  let filePath = keyOrUrl;
64
79
 
@@ -71,30 +86,58 @@ export function createStorage(transport: Transport, storageId?: string): Storage
71
86
  }
72
87
 
73
88
  if (!filePath || filePath.trim() === "" || filePath === "/") {
74
- return { url: null,
75
- fileNotFound: true };
89
+ return { url: null, fileNotFound: true };
90
+ }
91
+
92
+ // ── Public objects ────────────────────────────────────────────────
93
+ // A public file (under the public prefix) is served token-less via a
94
+ // stable, permanent, CDN-cacheable URL. No metadata round-trip and no
95
+ // token are needed — build the URL directly and cache it forever.
96
+ if (isPublicStoragePath(filePath)) {
97
+ const publicConfig: DownloadConfig = {
98
+ url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`)
99
+ };
100
+ urlsCache.set(cacheKey, { config: publicConfig }); // no expiry
101
+ return publicConfig;
76
102
  }
77
103
 
78
104
  try {
79
105
  const result = await transport.request<{ data: DownloadMetadata }>(withStorageId(`/storage/metadata/${filePath}`));
80
106
 
81
- const activeToken = await transport.resolveToken();
82
- const tokenQuery = activeToken ? `?token=${activeToken}` : "";
107
+ // Public object (server-confirmed): token-less permanent URL.
108
+ if (result.data.public) {
109
+ const publicConfig: DownloadConfig = {
110
+ url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`),
111
+ metadata: result.data
112
+ };
113
+ urlsCache.set(cacheKey, { config: publicConfig }); // no expiry
114
+ return publicConfig;
115
+ }
116
+
117
+ // Private object: use the short-lived, file-scoped download token
118
+ // minted by the server. We deliberately do NOT fall back to the
119
+ // caller's access token — a URL must never carry a full-privilege
120
+ // credential. If no scoped token is present the URL fails closed.
121
+ const scopedToken = result.data.token;
122
+ const tokenQuery = scopedToken ? `?token=${scopedToken}` : "";
83
123
 
84
124
  const downloadConfig: DownloadConfig = {
85
125
  // `withStorageId` picks `?` or `&` based on whether the token
86
126
  // query is already present, so the URL stays valid even when
87
- // there is no auth token.
127
+ // there is no token.
88
128
  url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}${tokenQuery}`),
89
129
  metadata: result.data
90
130
  };
91
131
 
92
- urlsCache.set(cacheKey, downloadConfig);
132
+ const expiresAt = result.data.tokenExpiresIn
133
+ ? Date.now() + (result.data.tokenExpiresIn - 10) * 1000 // subtract 10s buffer
134
+ : undefined;
135
+
136
+ urlsCache.set(cacheKey, { config: downloadConfig, expiresAt });
93
137
  return downloadConfig;
94
138
  } catch (e: unknown) {
95
139
  if (e instanceof Error && "status" in e && (e as { status: number }).status === 404) {
96
- return { url: null,
97
- fileNotFound: true };
140
+ return { url: null, fileNotFound: true };
98
141
  }
99
142
  throw e;
100
143
  }
@@ -104,33 +147,22 @@ fileNotFound: true };
104
147
  key: string,
105
148
  bucket?: string
106
149
  ): Promise<File | null> {
107
- let filePath = key;
108
-
109
- if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) {
110
- filePath = filePath.substring(filePath.indexOf("://") + 3);
111
- }
112
-
113
- if (bucket && filePath && !filePath.startsWith(bucket)) {
114
- filePath = `${bucket}/${filePath}`;
115
- }
116
-
117
- if (!filePath || filePath.trim() === "" || filePath === "/") {
150
+ const downloadConfig = await getSignedUrl(key, bucket);
151
+ if (downloadConfig.fileNotFound || !downloadConfig.url) {
118
152
  return null;
119
153
  }
120
154
 
121
- // We must use plain fetch because transport.request expects JSON response, but here we want a Blob.
122
- const url = withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`);
123
-
124
- // This is a bit manual, but necessary for blob handling
125
- const response = await transport.fetchFn(url, {
126
- headers: transport.getHeaders ? transport.getHeaders() : {}
155
+ // Fetch using the signed URL directly. Since the scoped token is in the ?token= query param,
156
+ // we explicitly omit any Authorization headers to prevent passing full access tokens to file serving routes.
157
+ const response = await transport.fetchFn(downloadConfig.url, {
158
+ headers: {}
127
159
  });
128
160
 
129
161
  if (response.status === 404) return null;
130
162
  if (!response.ok) throw new Error("Failed to get file");
131
163
 
132
164
  const blob = await response.blob();
133
- const fileName = filePath.split("/").pop() || "file";
165
+ const fileName = (bucket ? `${bucket}/${key}` : key).split("/").pop() || "file";
134
166
  return new File([blob], fileName, { type: blob.type });
135
167
  }
136
168
 
package/src/transport.ts CHANGED
@@ -1,14 +1,54 @@
1
- import { FindParams as TypesFindParams, FindResponse as TypesFindResponse } from "@rebasepro/types";
2
- import { serializeFilter, serializeLogicalCondition } from "@rebasepro/common";
1
+ import { FindParams as TypesFindParams, FindResponse as TypesFindResponse, RebaseApiError } from "@rebasepro/types";
2
+ import { serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
3
3
  import { rebaseReviver } from "./reviver";
4
4
 
5
+ // The canonical client error now lives in `@rebasepro/types` so every package
6
+ // (client, auth, …) throws one type. Re-exported here to preserve the historical
7
+ // `import { RebaseApiError } from ".../transport"` path used across the SDK.
8
+ export { RebaseApiError } from "@rebasepro/types";
9
+ export type { RebaseErrorInit } from "@rebasepro/types";
10
+
5
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
+ */
6
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
+ */
7
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
+ */
8
35
  apiPath?: string;
9
36
  fetch?: typeof globalThis.fetch;
10
37
  onUnauthorized?: () => Promise<boolean>;
11
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;
12
52
  }
13
53
 
14
54
  /**
@@ -17,20 +57,6 @@ export interface RebaseClientConfig {
17
57
  export type FindParams = TypesFindParams;
18
58
  export type FindResponse<T> = TypesFindResponse<T extends Record<string, unknown> ? T : Record<string, unknown>>;
19
59
 
20
- export class RebaseApiError extends Error {
21
- status: number;
22
- code?: string;
23
- details?: unknown;
24
-
25
- constructor(status: number, message: string, code?: string, details?: unknown) {
26
- super(message);
27
- this.name = "RebaseApiError";
28
- this.status = status;
29
- this.code = code;
30
- this.details = details;
31
- }
32
- }
33
-
34
60
  export function buildQueryString(params?: FindParams): string {
35
61
  if (!params) return "";
36
62
  const parts: string[] = [];
@@ -40,7 +66,8 @@ export function buildQueryString(params?: FindParams): string {
40
66
  if (params.page != null) parts.push(`page=${params.page}`);
41
67
 
42
68
  if (params.orderBy) {
43
- parts.push(`orderBy=${encodeURIComponent(params.orderBy)}`);
69
+ const wire = serializeOrderBy(params.orderBy);
70
+ if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
44
71
  }
45
72
 
46
73
  if (params.searchString) {
@@ -138,12 +165,15 @@ headers });
138
165
  }
139
166
  }
140
167
 
168
+ // The server always emits the canonical `{ error: { message, code, details? } }`
169
+ // envelope (formatted by the central errorHandler), so we read strictly
170
+ // from `body.error.*`.
141
171
  const getErrorField = (obj: Record<string, unknown>, field: string): unknown => {
142
172
  const err = obj?.error;
143
- if (err && typeof err === "object" && err !== null && field in (err as Record<string, unknown>)) {
173
+ if (err && typeof err === "object" && err !== null) {
144
174
  return (err as Record<string, unknown>)[field];
145
175
  }
146
- return obj?.[field];
176
+ return undefined;
147
177
  };
148
178
 
149
179
  if (res.status === 401 && onUnauthorizedHandler) {
@@ -176,10 +206,12 @@ headers: retryHeaders });
176
206
  fallbackMessage = `Endpoint not found (${method} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
177
207
  }
178
208
  throw new RebaseApiError(
179
- retryRes.status,
180
209
  String(getErrorField(retryBody, "message") || fallbackMessage || `Request failed with status ${retryRes.status}`),
181
- getErrorField(retryBody, "code") as string | undefined,
182
- getErrorField(retryBody, "details")
210
+ {
211
+ status: retryRes.status,
212
+ code: getErrorField(retryBody, "code") as string | undefined,
213
+ details: getErrorField(retryBody, "details")
214
+ }
183
215
  );
184
216
  }
185
217
  return retryBody as T;
@@ -193,10 +225,12 @@ headers: retryHeaders });
193
225
  fallbackMessage = `Endpoint not found (${method} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
194
226
  }
195
227
  throw new RebaseApiError(
196
- res.status,
197
228
  String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`),
198
- getErrorField(body, "code") as string | undefined,
199
- getErrorField(body, "details")
229
+ {
230
+ status: res.status,
231
+ code: getErrorField(body, "code") as string | undefined,
232
+ details: getErrorField(body, "details")
233
+ }
200
234
  );
201
235
  }
202
236