@rebasepro/client 0.7.0 → 0.9.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/src/storage.ts CHANGED
@@ -1,25 +1,46 @@
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
- export function createStorage(transport: Transport): StorageSource {
5
- const urlsCache = new Map<string, DownloadConfig>();
6
-
7
- // We expect the transport to point to /api, and storage endpoints handle /api/storage internally if they are relative?
8
- // Wait, useBackendStorageSource uses `${apiUrl}/api/storage` directly.
9
- // Transport has `.request` which hits `${config.baseUrl}${config.apiPath}${path}`.
10
- // Assuming `config.apiPath` is "/api", we just request(`/storage/upload`, ...).
4
+ /**
5
+ * Create a StorageSource that talks to the Rebase backend REST API.
6
+ *
7
+ * @param transport - HTTP transport instance
8
+ * @param storageId - Optional storage-source key for multi-backend routing.
9
+ * When set, it is forwarded to the server so the correct
10
+ * `StorageController` is resolved from the registry.
11
+ */
12
+ export function createStorage(transport: Transport, storageId?: string): StorageSource {
13
+ const urlsCache = new Map<string, { config: DownloadConfig; expiresAt?: number }>();
14
+
15
+ /** Append ?storageId=... to a path when multi-backend routing is active. */
16
+ const withStorageId = (path: string): string => {
17
+ if (!storageId) return path;
18
+ const sep = path.includes("?") ? "&" : "?";
19
+ return `${path}${sep}storageId=${encodeURIComponent(storageId)}`;
20
+ };
11
21
 
12
22
  async function putObject({
13
23
  file,
14
24
  key,
15
25
  metadata,
16
- bucket
26
+ bucket,
27
+ public: isPublic
17
28
  }: UploadFileProps): Promise<UploadFileResult> {
18
29
  const formData = new FormData();
19
30
  formData.append("file", file);
20
31
 
21
- 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);
22
42
  if (bucket) formData.append("bucket", bucket);
43
+ if (storageId) formData.append("storageId", storageId);
23
44
 
24
45
  if (metadata) {
25
46
  for (const [key, value] of Object.entries(metadata)) {
@@ -32,16 +53,10 @@ export function createStorage(transport: Transport): StorageSource {
32
53
  }
33
54
  }
34
55
 
35
- // We use fetchFn directly if we need to do multipart boundary, but Transport.request might override Content-Type?
36
- // Wait, transport.request defaults to application/json. We must remove Content-Type header or allow it to be evaluated by fetch when body is FormData!
37
- const result = await transport.request<{ data: UploadFileResult }>("/storage/upload", {
56
+ const result = await transport.request<{ data: UploadFileResult }>(withStorageId("/storage/upload"), {
38
57
  method: "POST",
39
58
  body: formData,
40
- headers: {
41
- // transport.request merges headers, so to prevent it setting application/json we can delete it
42
- // in transport if body is FormData, or we can explicitly set it to an empty string.
43
- // Let's rely on standard behaviour for now and adjust transport if it fails.
44
- }
59
+ headers: {}
45
60
  });
46
61
 
47
62
  return result.data;
@@ -52,12 +67,17 @@ export function createStorage(transport: Transport): StorageSource {
52
67
  bucket?: string
53
68
  ): Promise<DownloadConfig> {
54
69
  const cacheKey = bucket ? `${bucket}/${keyOrUrl}` : keyOrUrl;
55
- const cached = urlsCache.get(cacheKey);
56
- 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
+ }
57
77
 
58
78
  let filePath = keyOrUrl;
59
79
 
60
- if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://"))) {
80
+ if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) {
61
81
  filePath = filePath.substring(filePath.indexOf("://") + 3);
62
82
  }
63
83
 
@@ -66,27 +86,58 @@ export function createStorage(transport: Transport): StorageSource {
66
86
  }
67
87
 
68
88
  if (!filePath || filePath.trim() === "" || filePath === "/") {
69
- return { url: null,
70
- 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;
71
102
  }
72
103
 
73
104
  try {
74
- const result = await transport.request<{ data: DownloadMetadata }>(`/storage/metadata/${filePath}`);
105
+ const result = await transport.request<{ data: DownloadMetadata }>(withStorageId(`/storage/metadata/${filePath}`));
106
+
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
+ }
75
116
 
76
- const activeToken = await transport.resolveToken();
77
- const tokenQuery = activeToken ? `?token=${activeToken}` : "";
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}` : "";
78
123
 
79
124
  const downloadConfig: DownloadConfig = {
80
- url: `${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}${tokenQuery}`,
125
+ // `withStorageId` picks `?` or `&` based on whether the token
126
+ // query is already present, so the URL stays valid even when
127
+ // there is no token.
128
+ url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}${tokenQuery}`),
81
129
  metadata: result.data
82
130
  };
83
131
 
84
- 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 });
85
137
  return downloadConfig;
86
138
  } catch (e: unknown) {
87
139
  if (e instanceof Error && "status" in e && (e as { status: number }).status === 404) {
88
- return { url: null,
89
- fileNotFound: true };
140
+ return { url: null, fileNotFound: true };
90
141
  }
91
142
  throw e;
92
143
  }
@@ -96,33 +147,22 @@ fileNotFound: true };
96
147
  key: string,
97
148
  bucket?: string
98
149
  ): Promise<File | null> {
99
- let filePath = key;
100
-
101
- if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://"))) {
102
- filePath = filePath.substring(filePath.indexOf("://") + 3);
103
- }
104
-
105
- if (bucket && filePath && !filePath.startsWith(bucket)) {
106
- filePath = `${bucket}/${filePath}`;
107
- }
108
-
109
- if (!filePath || filePath.trim() === "" || filePath === "/") {
150
+ const downloadConfig = await getSignedUrl(key, bucket);
151
+ if (downloadConfig.fileNotFound || !downloadConfig.url) {
110
152
  return null;
111
153
  }
112
154
 
113
- // We must use plain fetch because transport.request expects JSON response, but here we want a Blob.
114
- const url = `${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`;
115
-
116
- // This is a bit manual, but necessary for blob handling
117
- const response = await transport.fetchFn(url, {
118
- 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: {}
119
159
  });
120
160
 
121
161
  if (response.status === 404) return null;
122
162
  if (!response.ok) throw new Error("Failed to get file");
123
163
 
124
164
  const blob = await response.blob();
125
- const fileName = filePath.split("/").pop() || "file";
165
+ const fileName = (bucket ? `${bucket}/${key}` : key).split("/").pop() || "file";
126
166
  return new File([blob], fileName, { type: blob.type });
127
167
  }
128
168
 
@@ -132,7 +172,7 @@ fileNotFound: true };
132
172
  ): Promise<void> {
133
173
  let filePath = key;
134
174
 
135
- if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://"))) {
175
+ if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) {
136
176
  filePath = filePath.substring(filePath.indexOf("://") + 3);
137
177
  }
138
178
 
@@ -145,7 +185,7 @@ fileNotFound: true };
145
185
  }
146
186
 
147
187
  try {
148
- await transport.request(`/storage/file/${filePath}`, { method: "DELETE" });
188
+ await transport.request(withStorageId(`/storage/file/${filePath}`), { method: "DELETE" });
149
189
  } catch (e: unknown) {
150
190
  if (!(e instanceof Error && "status" in e && (e as { status: number }).status === 404)) throw e;
151
191
  }
@@ -167,6 +207,8 @@ fileNotFound: true };
167
207
  if (options?.maxResults) params.set("maxResults", String(options.maxResults));
168
208
  if (options?.pageToken) params.set("pageToken", options.pageToken);
169
209
 
210
+ if (storageId) params.set("storageId", storageId);
211
+
170
212
  const result = await transport.request<{ data: StorageListResult }>(`/storage/list?${params.toString()}`);
171
213
  return result.data;
172
214
  }
package/src/transport.ts CHANGED
@@ -1,6 +1,13 @@
1
- import { FindParams as TypesFindParams, FindResponse as TypesFindResponse, WhereFieldValue, WhereFilterOpShort } from "@rebasepro/types";
1
+ import { FindParams as TypesFindParams, FindResponse as TypesFindResponse, RebaseApiError } from "@rebasepro/types";
2
+ import { serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
2
3
  import { rebaseReviver } from "./reviver";
3
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
+
4
11
  export interface RebaseClientConfig {
5
12
  baseUrl?: string;
6
13
  token?: string;
@@ -16,94 +23,6 @@ export interface RebaseClientConfig {
16
23
  export type FindParams = TypesFindParams;
17
24
  export type FindResponse<T> = TypesFindResponse<T extends Record<string, unknown> ? T : Record<string, unknown>>;
18
25
 
19
- export class RebaseApiError extends Error {
20
- status: number;
21
- code?: string;
22
- details?: unknown;
23
-
24
- constructor(status: number, message: string, code?: string, details?: unknown) {
25
- super(message);
26
- this.name = "RebaseApiError";
27
- this.status = status;
28
- this.code = code;
29
- this.details = details;
30
- }
31
- }
32
-
33
- /**
34
- * Maps a short operator alias to the PostgREST-style short code.
35
- */
36
- const OP_MAP: Record<string, string> = {
37
- "==": "eq",
38
- "!=": "neq",
39
- ">": "gt",
40
- ">=": "gte",
41
- "<": "lt",
42
- "<=": "lte",
43
- "not-in": "nin",
44
- "array-contains": "cs",
45
- "array-contains-any": "csa"
46
- };
47
-
48
- /**
49
- * Normalise a single `WhereFieldValue` into the PostgREST query-string
50
- * representation the backend expects.
51
- *
52
- * Supports:
53
- * - `null` → `"eq.null"`
54
- * - `true`/`false` → `"eq.true"` / `"eq.false"`
55
- * - `42` → `"42"` (plain equality)
56
- * - `"active"` → `"active"` (plain equality, backward-compat)
57
- * - `"gte.18"` → `"gte.18"` (pass-through PostgREST string)
58
- * - `[">=", 18]` → `"gte.18"` (tuple syntax)
59
- * - `["in", [1,2]]` → `"in.(1,2)"` (tuple with array value)
60
- * - `["!=", null]` → `"neq.null"`
61
- */
62
- function normalizeWhereValue(value: WhereFieldValue): string {
63
- // Null → eq.null
64
- if (value === null) return "eq.null";
65
-
66
- // Boolean → eq.true / eq.false
67
- if (typeof value === "boolean") return `eq.${value}`;
68
-
69
- // Number → plain equality
70
- if (typeof value === "number") return String(value);
71
-
72
- // Tuple: [operator, val]
73
- if (Array.isArray(value)) {
74
- const conditions: [WhereFilterOpShort, any][] = Array.isArray(value[0])
75
- ? (value as [WhereFilterOpShort, any][])
76
- : [value as [WhereFilterOpShort, any]];
77
-
78
- const [rawOp, val] = conditions[0] || [];
79
- if (rawOp) {
80
- const op = OP_MAP[rawOp] ?? rawOp;
81
- if (val === null) return `${op}.null`;
82
- if (Array.isArray(val)) return `${op}.(${val.join(",")})`;
83
- return `${op}.${val}`;
84
- }
85
- }
86
-
87
- // String — pass through (either plain equality value or PostgREST syntax)
88
- return String(value);
89
- }
90
-
91
- function serializeLogicalCondition(cond: any): string {
92
- if ("type" in cond) {
93
- const sub = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
94
- return `${cond.type}(${sub})`;
95
- } else {
96
- const op = OP_MAP[cond.operator] ?? cond.operator;
97
- let formattedValue = cond.value;
98
- if (Array.isArray(cond.value)) {
99
- formattedValue = `(${cond.value.join(",")})`;
100
- } else if (cond.value === null) {
101
- formattedValue = "null";
102
- }
103
- return `${cond.column}.${op}.${formattedValue}`;
104
- }
105
- }
106
-
107
26
  export function buildQueryString(params?: FindParams): string {
108
27
  if (!params) return "";
109
28
  const parts: string[] = [];
@@ -113,7 +32,8 @@ export function buildQueryString(params?: FindParams): string {
113
32
  if (params.page != null) parts.push(`page=${params.page}`);
114
33
 
115
34
  if (params.orderBy) {
116
- parts.push(`orderBy=${encodeURIComponent(params.orderBy)}`);
35
+ const wire = serializeOrderBy(params.orderBy);
36
+ if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
117
37
  }
118
38
 
119
39
  if (params.searchString) {
@@ -131,15 +51,14 @@ export function buildQueryString(params?: FindParams): string {
131
51
  }
132
52
 
133
53
  if (params.where) {
134
- for (const [field, value] of Object.entries(params.where)) {
135
- if (Array.isArray(value) && value.length > 0 && Array.isArray(value[0])) {
136
- for (const subVal of value) {
137
- const normalized = normalizeWhereValue(subVal as WhereFieldValue);
138
- parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(normalized)}`);
54
+ const serialized = serializeFilter(params.where);
55
+ for (const [field, value] of Object.entries(serialized)) {
56
+ if (Array.isArray(value)) {
57
+ for (const v of value) {
58
+ parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(v)}`);
139
59
  }
140
60
  } else {
141
- const normalized = normalizeWhereValue(value as WhereFieldValue);
142
- parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(normalized)}`);
61
+ parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(value)}`);
143
62
  }
144
63
  }
145
64
  }
@@ -212,12 +131,15 @@ headers });
212
131
  }
213
132
  }
214
133
 
134
+ // The server always emits the canonical `{ error: { message, code, details? } }`
135
+ // envelope (formatted by the central errorHandler), so we read strictly
136
+ // from `body.error.*`.
215
137
  const getErrorField = (obj: Record<string, unknown>, field: string): unknown => {
216
138
  const err = obj?.error;
217
- if (err && typeof err === "object" && err !== null && field in (err as Record<string, unknown>)) {
139
+ if (err && typeof err === "object" && err !== null) {
218
140
  return (err as Record<string, unknown>)[field];
219
141
  }
220
- return obj?.[field];
142
+ return undefined;
221
143
  };
222
144
 
223
145
  if (res.status === 401 && onUnauthorizedHandler) {
@@ -250,10 +172,12 @@ headers: retryHeaders });
250
172
  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.`;
251
173
  }
252
174
  throw new RebaseApiError(
253
- retryRes.status,
254
175
  String(getErrorField(retryBody, "message") || fallbackMessage || `Request failed with status ${retryRes.status}`),
255
- getErrorField(retryBody, "code") as string | undefined,
256
- getErrorField(retryBody, "details")
176
+ {
177
+ status: retryRes.status,
178
+ code: getErrorField(retryBody, "code") as string | undefined,
179
+ details: getErrorField(retryBody, "details")
180
+ }
257
181
  );
258
182
  }
259
183
  return retryBody as T;
@@ -267,10 +191,12 @@ headers: retryHeaders });
267
191
  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.`;
268
192
  }
269
193
  throw new RebaseApiError(
270
- res.status,
271
194
  String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`),
272
- getErrorField(body, "code") as string | undefined,
273
- getErrorField(body, "details")
195
+ {
196
+ status: res.status,
197
+ code: getErrorField(body, "code") as string | undefined,
198
+ details: getErrorField(body, "details")
199
+ }
274
200
  );
275
201
  }
276
202