@rebasepro/client 0.8.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/README.md +10 -10
- package/dist/auth.d.ts +35 -38
- package/dist/collection.d.ts +9 -13
- package/dist/data-proxy.test.d.ts +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/index.d.ts +23 -22
- package/dist/index.es.js +485 -215
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +499 -230
- package/dist/index.umd.js.map +1 -1
- package/dist/sdk_query_builder.d.ts +63 -0
- package/dist/transport.d.ts +2 -6
- package/dist/websocket.d.ts +25 -21
- package/package.json +4 -4
- package/src/auth.ts +188 -64
- package/src/collection.test.ts +92 -3
- package/src/collection.ts +72 -75
- package/src/data-proxy.test.ts +167 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +146 -27
- package/src/reviver.ts +2 -2
- package/src/sdk_query_builder.ts +138 -0
- package/src/storage.ts +64 -32
- package/src/transport.ts +25 -25
- package/src/websocket.ts +133 -135
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createTransport, RebaseClientConfig } from "./transport";
|
|
2
|
+
import { RebaseClientError } from "./errors";
|
|
2
3
|
import { createAuth, CreateAuthOptions } from "./auth";
|
|
3
4
|
import { createAdmin, CreateAdminOptions } from "./admin";
|
|
4
5
|
import { createCron, CreateCronOptions } from "./cron";
|
|
@@ -10,27 +11,68 @@ import { ClientStorageSourceRegistry } from "./storage-registry";
|
|
|
10
11
|
import { RebaseWebSocketClient } from "./websocket";
|
|
11
12
|
import {
|
|
12
13
|
DEFAULT_STORAGE_SOURCE_KEY,
|
|
14
|
+
InsertOf,
|
|
13
15
|
RebaseClient,
|
|
14
|
-
|
|
16
|
+
RebaseSdkData,
|
|
17
|
+
RowOf,
|
|
15
18
|
StorageSource,
|
|
16
19
|
StorageSourceDefinition,
|
|
17
|
-
StorageSourceRegistry
|
|
20
|
+
StorageSourceRegistry,
|
|
21
|
+
UpdateOf
|
|
18
22
|
} from "@rebasepro/types";
|
|
19
23
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
// ─── Public API surface ──────────────────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// This barrel is the public API of `@rebasepro/client`. It is an explicit,
|
|
28
|
+
// curated list — NOT `export *` — so that adding an export to a module below
|
|
29
|
+
// does not silently republish it to app developers. Internal factories
|
|
30
|
+
// (`createTransport`, `createAuth`, `createCollectionClient`, …), the raw
|
|
31
|
+
// `Transport`, the storage-source registry impl, the JSON reviver, and the
|
|
32
|
+
// concrete `SDKQueryBuilder` class are intentionally NOT re-exported: they are
|
|
33
|
+
// implementation details of `createRebaseClient()` and have no external
|
|
34
|
+
// consumers. App developers reach them through the client instance, never by
|
|
35
|
+
// importing the factory. To add something to the public surface, add it here
|
|
36
|
+
// deliberately.
|
|
37
|
+
|
|
38
|
+
// Errors — the single error type thrown by SDK HTTP calls, plus the
|
|
39
|
+
// data-proxy's unknown-collection error.
|
|
40
|
+
export { RebaseApiError } from "./transport";
|
|
41
|
+
export { RebaseClientError } from "./errors";
|
|
42
|
+
|
|
43
|
+
// Query + collection types (annotate SDK results; construct via the fluent API).
|
|
44
|
+
export type { RebaseClientConfig, FindParams, FindResponse } from "./transport";
|
|
45
|
+
export type { CollectionClient } from "./collection";
|
|
46
|
+
export type { FindResult, SDKCollectionClient, SDKQueryBuilderInterface, PaginationMeta } from "@rebasepro/types";
|
|
47
|
+
|
|
48
|
+
// Logical-condition helpers for `.where(or(...), and(...))`.
|
|
49
|
+
export { QueryBuilder, or, and, cond } from "@rebasepro/common";
|
|
50
|
+
|
|
51
|
+
// Auth: session/token types, config, and the pluggable storage strategies.
|
|
52
|
+
export { createCookieStorage, createMemoryStorage } from "./auth";
|
|
53
|
+
export type { AuthConfig, AuthStorage, CookieStorageOptions, CreateAuthOptions } from "./auth";
|
|
54
|
+
export type { RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
|
|
55
|
+
/** @deprecated Import `User` / `AuthTokens` from `@rebasepro/types` instead. */
|
|
56
|
+
export type { RebaseUser, RebaseTokens } from "./auth";
|
|
57
|
+
|
|
58
|
+
// Control-plane client option/DTO types (the client instance exposes the impls).
|
|
59
|
+
export type { CreateAdminOptions } from "./admin";
|
|
60
|
+
export type { AdminUser } from "./admin";
|
|
61
|
+
export type { CreateCronOptions } from "./cron";
|
|
62
|
+
export type {
|
|
63
|
+
ApiKeyMasked,
|
|
64
|
+
ApiKeyPermission,
|
|
65
|
+
ApiKeyWithSecret,
|
|
66
|
+
CreateApiKeyRequest,
|
|
67
|
+
CreateApiKeysOptions,
|
|
68
|
+
UpdateApiKeyRequest
|
|
69
|
+
} from "./api-keys";
|
|
70
|
+
export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
|
|
71
|
+
|
|
72
|
+
// Realtime: the WebSocket client class is internal to `createRebaseClient()`,
|
|
73
|
+
// but re-exported (see @internal on the class) because the `client-postgresql`
|
|
74
|
+
// driver constructs it directly. Not a stable app-facing API.
|
|
75
|
+
export { RebaseWebSocketClient } from "./websocket";
|
|
34
76
|
|
|
35
77
|
export interface CreateRebaseClientOptions extends RebaseClientConfig {
|
|
36
78
|
auth?: CreateAuthOptions;
|
|
@@ -62,17 +104,21 @@ type KebabToCamelCase<S extends string> =
|
|
|
62
104
|
? `${T}${Capitalize<KebabToCamelCase<U>>}`
|
|
63
105
|
: S;
|
|
64
106
|
|
|
107
|
+
// Resolve a generated `Database` entry from a (kebab-case) slug literal,
|
|
108
|
+
// or `unknown` when the slug isn't in the schema — the extractors below
|
|
109
|
+
// then fall back to the open row / partial shapes.
|
|
110
|
+
type DBEntry<DB, S extends string> =
|
|
111
|
+
KebabToCamelCase<S> extends keyof DB ? DB[KebabToCamelCase<S>] : unknown;
|
|
112
|
+
|
|
65
113
|
type TypedDataLayer<DB> = {
|
|
66
114
|
collection<S extends string>(slug: S): CollectionClient<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
115
|
+
RowOf<DBEntry<DB, S>>,
|
|
116
|
+
InsertOf<DBEntry<DB, S>>,
|
|
117
|
+
UpdateOf<DBEntry<DB, S>>
|
|
70
118
|
>;
|
|
71
119
|
} & {
|
|
72
|
-
[K in keyof DB]: CollectionClient<
|
|
73
|
-
|
|
74
|
-
>;
|
|
75
|
-
} & RebaseData;
|
|
120
|
+
[K in keyof DB]: CollectionClient<RowOf<DB[K]>, InsertOf<DB[K]>, UpdateOf<DB[K]>>;
|
|
121
|
+
} & RebaseSdkData;
|
|
76
122
|
|
|
77
123
|
/**
|
|
78
124
|
* The return type of `createRebaseClient<DB>()`.
|
|
@@ -81,7 +127,7 @@ type TypedDataLayer<DB> = {
|
|
|
81
127
|
* capabilities populated and the `data` layer narrowed to provide
|
|
82
128
|
* typed collection accessors when a `DB` schema generic is supplied.
|
|
83
129
|
*/
|
|
84
|
-
export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<RebaseClient
|
|
130
|
+
export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<RebaseClient<DB>, "data" | "email"> & {
|
|
85
131
|
setToken: (token: string | null) => void;
|
|
86
132
|
setAuthTokenGetter: (getter: () => Promise<string | null>) => void;
|
|
87
133
|
setOnUnauthorized: (handler: () => Promise<boolean>) => void;
|
|
@@ -241,7 +287,62 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
241
287
|
});
|
|
242
288
|
}
|
|
243
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Suggest the closest known collection key for a mistyped accessor.
|
|
292
|
+
* Uses edit-distance-1 and prefix matching — no external dependency.
|
|
293
|
+
*/
|
|
294
|
+
function suggestCollection(prop: string, knownKeys: string[]): string | undefined {
|
|
295
|
+
// Prefix match (e.g. "prod" → "products")
|
|
296
|
+
const prefixMatch = knownKeys.find(k => k.startsWith(prop) || prop.startsWith(k));
|
|
297
|
+
if (prefixMatch) return prefixMatch;
|
|
298
|
+
|
|
299
|
+
// Edit-distance-1: deletions, insertions, substitutions, transpositions
|
|
300
|
+
for (const key of knownKeys) {
|
|
301
|
+
if (Math.abs(key.length - prop.length) > 1) continue;
|
|
302
|
+
let diffs = 0;
|
|
303
|
+
const longer = key.length >= prop.length ? key : prop;
|
|
304
|
+
const shorter = key.length >= prop.length ? prop : key;
|
|
305
|
+
if (longer.length === shorter.length) {
|
|
306
|
+
// Same length: allow 1 substitution or 1 transposition
|
|
307
|
+
for (let i = 0; i < longer.length; i++) {
|
|
308
|
+
if (longer[i] !== shorter[i]) {
|
|
309
|
+
// Check for transposition
|
|
310
|
+
if (
|
|
311
|
+
i + 1 < longer.length &&
|
|
312
|
+
longer[i] === shorter[i + 1] &&
|
|
313
|
+
longer[i + 1] === shorter[i]
|
|
314
|
+
) {
|
|
315
|
+
diffs++;
|
|
316
|
+
i++; // skip next char (already accounted for)
|
|
317
|
+
if (diffs > 1) break;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
diffs++;
|
|
321
|
+
}
|
|
322
|
+
if (diffs > 1) break;
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
// Length differs by 1: allow 1 insertion/deletion
|
|
326
|
+
let li = 0;
|
|
327
|
+
let si = 0;
|
|
328
|
+
while (li < longer.length) {
|
|
329
|
+
if (si < shorter.length && longer[li] === shorter[si]) {
|
|
330
|
+
si++;
|
|
331
|
+
} else {
|
|
332
|
+
diffs++;
|
|
333
|
+
}
|
|
334
|
+
li++;
|
|
335
|
+
if (diffs > 1) break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (diffs <= 1) return key;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
244
344
|
const collectionClients = new Map<string, CollectionClient<Record<string, unknown>>>();
|
|
345
|
+
let untypedWarned = false;
|
|
245
346
|
|
|
246
347
|
function collection(slug: string): CollectionClient<Record<string, unknown>> {
|
|
247
348
|
if (!collectionClients.has(slug)) {
|
|
@@ -259,11 +360,30 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
259
360
|
}
|
|
260
361
|
if (typeof prop === "symbol") return undefined;
|
|
261
362
|
if (typeof prop === "string" && prop !== "then" && prop !== "toJSON" && prop !== "$$typeof") {
|
|
262
|
-
if (options.collections
|
|
263
|
-
|
|
363
|
+
if (options.collections) {
|
|
364
|
+
if (prop in options.collections) {
|
|
365
|
+
return collection(options.collections[prop]);
|
|
366
|
+
}
|
|
367
|
+
// Strict mode: the developer supplied a typed dictionary,
|
|
368
|
+
// so we know the full set of valid accessors.
|
|
369
|
+
const knownKeys = Object.keys(options.collections);
|
|
370
|
+
const suggestion = suggestCollection(prop, knownKeys);
|
|
371
|
+
const knownList = knownKeys.join(", ");
|
|
372
|
+
let msg = `Unknown collection accessor "${prop}". Known collections: ${knownList}.`;
|
|
373
|
+
if (suggestion) msg += ` Did you mean "${suggestion}"?`;
|
|
374
|
+
msg += ` Use data.collection("<slug>") for dynamic slugs.`;
|
|
375
|
+
throw new RebaseClientError(msg);
|
|
264
376
|
}
|
|
265
|
-
//
|
|
377
|
+
// Untyped fallback: convert camelCase property names to snake_case slugs.
|
|
266
378
|
// e.g. `companyMembers` → `company_members`
|
|
379
|
+
if (!untypedWarned) {
|
|
380
|
+
untypedWarned = true;
|
|
381
|
+
console.warn(
|
|
382
|
+
`[Rebase] Untyped data access detected (client.data.${prop}). ` +
|
|
383
|
+
`Collection names are resolved via snake_case conversion, which may cause silent 404s at request time. ` +
|
|
384
|
+
`Pass a \`collections\` dictionary to createRebaseClient() or use the generated SDK for type-safe access.`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
267
387
|
const slug = toSnakeCase(prop);
|
|
268
388
|
return collection(slug);
|
|
269
389
|
}
|
|
@@ -297,7 +417,6 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
297
417
|
return res.data ?? (res as T);
|
|
298
418
|
},
|
|
299
419
|
data: dataProxy,
|
|
300
|
-
email: undefined
|
|
301
420
|
} as unknown as CreateRebaseClientResult<DB>;
|
|
302
421
|
|
|
303
422
|
return target;
|
package/src/reviver.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EntityReference, EntityRelation, GeoPoint, Vector
|
|
1
|
+
import { EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
|
|
2
2
|
|
|
3
3
|
export function rebaseReviver(_key: string, value: unknown): unknown {
|
|
4
4
|
if (value && typeof value === "object" && "__type" in value) {
|
|
@@ -25,7 +25,7 @@ export function rebaseReviver(_key: string, value: unknown): unknown {
|
|
|
25
25
|
return new EntityRelation(
|
|
26
26
|
record.id as string | number,
|
|
27
27
|
record.path as string,
|
|
28
|
-
record.data as
|
|
28
|
+
record.data as Record<string, unknown> | undefined
|
|
29
29
|
);
|
|
30
30
|
case "GeoPoint":
|
|
31
31
|
return new GeoPoint(record.latitude as number, record.longitude as number);
|
|
@@ -0,0 +1,138 @@
|
|
|
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("Listen is only available when RebaseClient is configured with a websocketUrl.");
|
|
135
|
+
}
|
|
136
|
+
return this.collection.listen(this.params, onUpdate, onError);
|
|
137
|
+
}
|
|
138
|
+
}
|
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
|
-
|
|
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
|
|
61
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
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,7 +1,13 @@
|
|
|
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 {
|
|
6
12
|
baseUrl?: string;
|
|
7
13
|
token?: string;
|
|
@@ -17,20 +23,6 @@ export interface RebaseClientConfig {
|
|
|
17
23
|
export type FindParams = TypesFindParams;
|
|
18
24
|
export type FindResponse<T> = TypesFindResponse<T extends Record<string, unknown> ? T : Record<string, unknown>>;
|
|
19
25
|
|
|
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
26
|
export function buildQueryString(params?: FindParams): string {
|
|
35
27
|
if (!params) return "";
|
|
36
28
|
const parts: string[] = [];
|
|
@@ -40,7 +32,8 @@ export function buildQueryString(params?: FindParams): string {
|
|
|
40
32
|
if (params.page != null) parts.push(`page=${params.page}`);
|
|
41
33
|
|
|
42
34
|
if (params.orderBy) {
|
|
43
|
-
|
|
35
|
+
const wire = serializeOrderBy(params.orderBy);
|
|
36
|
+
if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
|
|
44
37
|
}
|
|
45
38
|
|
|
46
39
|
if (params.searchString) {
|
|
@@ -138,12 +131,15 @@ headers });
|
|
|
138
131
|
}
|
|
139
132
|
}
|
|
140
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.*`.
|
|
141
137
|
const getErrorField = (obj: Record<string, unknown>, field: string): unknown => {
|
|
142
138
|
const err = obj?.error;
|
|
143
|
-
if (err && typeof err === "object" && err !== null
|
|
139
|
+
if (err && typeof err === "object" && err !== null) {
|
|
144
140
|
return (err as Record<string, unknown>)[field];
|
|
145
141
|
}
|
|
146
|
-
return
|
|
142
|
+
return undefined;
|
|
147
143
|
};
|
|
148
144
|
|
|
149
145
|
if (res.status === 401 && onUnauthorizedHandler) {
|
|
@@ -176,10 +172,12 @@ headers: retryHeaders });
|
|
|
176
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.`;
|
|
177
173
|
}
|
|
178
174
|
throw new RebaseApiError(
|
|
179
|
-
retryRes.status,
|
|
180
175
|
String(getErrorField(retryBody, "message") || fallbackMessage || `Request failed with status ${retryRes.status}`),
|
|
181
|
-
|
|
182
|
-
|
|
176
|
+
{
|
|
177
|
+
status: retryRes.status,
|
|
178
|
+
code: getErrorField(retryBody, "code") as string | undefined,
|
|
179
|
+
details: getErrorField(retryBody, "details")
|
|
180
|
+
}
|
|
183
181
|
);
|
|
184
182
|
}
|
|
185
183
|
return retryBody as T;
|
|
@@ -193,10 +191,12 @@ headers: retryHeaders });
|
|
|
193
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.`;
|
|
194
192
|
}
|
|
195
193
|
throw new RebaseApiError(
|
|
196
|
-
res.status,
|
|
197
194
|
String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`),
|
|
198
|
-
|
|
199
|
-
|
|
195
|
+
{
|
|
196
|
+
status: res.status,
|
|
197
|
+
code: getErrorField(body, "code") as string | undefined,
|
|
198
|
+
details: getErrorField(body, "details")
|
|
199
|
+
}
|
|
200
200
|
);
|
|
201
201
|
}
|
|
202
202
|
|