@rebasepro/client 0.0.1-canary.09e5ec5
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/LICENSE +6 -0
- package/dist/admin.d.ts +94 -0
- package/dist/auth.d.ts +161 -0
- package/dist/collection.d.ts +19 -0
- package/dist/cron.d.ts +25 -0
- package/dist/cron.test.d.ts +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.es.js +2277 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +2279 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/query_builder.d.ts +53 -0
- package/dist/reviver.d.ts +1 -0
- package/dist/storage.d.ts +3 -0
- package/dist/transport.d.ts +33 -0
- package/dist/websocket.d.ts +99 -0
- package/package.json +83 -0
- package/src/admin.ts +119 -0
- package/src/auth.ts +512 -0
- package/src/collection.ts +249 -0
- package/src/cron.test.ts +164 -0
- package/src/cron.ts +62 -0
- package/src/index.ts +167 -0
- package/src/query_builder.ts +125 -0
- package/src/reviver.ts +39 -0
- package/src/storage.ts +181 -0
- package/src/transport.ts +259 -0
- package/src/websocket.ts +1176 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { FindParams, Entity, FindResponse } from "@rebasepro/types";
|
|
2
|
+
import { CollectionClient } from "./collection";
|
|
3
|
+
|
|
4
|
+
export type FilterOperator =
|
|
5
|
+
| "eq" | "neq" | "gt" | "gte" | "lt" | "lte"
|
|
6
|
+
| "in" | "nin" | "cs" | "csa"
|
|
7
|
+
| "==" | "!=" | ">" | ">=" | "<" | "<="
|
|
8
|
+
| "array-contains" | "array-contains-any"
|
|
9
|
+
| "not-in";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Maps standard operators to Rebase backend's string-based operators
|
|
13
|
+
*/
|
|
14
|
+
function mapOperator(op: FilterOperator): string {
|
|
15
|
+
switch (op) {
|
|
16
|
+
case "==": return "eq";
|
|
17
|
+
case "!=": return "neq";
|
|
18
|
+
case ">": return "gt";
|
|
19
|
+
case ">=": return "gte";
|
|
20
|
+
case "<": return "lt";
|
|
21
|
+
case "<=": return "lte";
|
|
22
|
+
case "array-contains": return "cs";
|
|
23
|
+
case "array-contains-any": return "csa";
|
|
24
|
+
case "not-in": return "nin";
|
|
25
|
+
default: return op;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class QueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> {
|
|
30
|
+
private params: FindParams = { where: {} };
|
|
31
|
+
|
|
32
|
+
constructor(private collection: CollectionClient<M>) {}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Add a filter condition to your query.
|
|
36
|
+
* @example
|
|
37
|
+
* client.collection('users').where('age', '>=', 18).find()
|
|
38
|
+
*/
|
|
39
|
+
where(column: keyof M & string, operator: FilterOperator, value: unknown): this {
|
|
40
|
+
if (!this.params.where) {
|
|
41
|
+
this.params.where = {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mappedOp = mapOperator(operator);
|
|
45
|
+
let formattedValue = value;
|
|
46
|
+
|
|
47
|
+
// Handle arrays for in, nin, cs, csa
|
|
48
|
+
if (Array.isArray(value) && ["in", "nin", "cs", "csa"].includes(mappedOp)) {
|
|
49
|
+
formattedValue = `(${value.join(",")})`;
|
|
50
|
+
} else if (value === null) {
|
|
51
|
+
formattedValue = "null";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.params.where[column] = mappedOp === "eq" ? String(formattedValue) : `${mappedOp}.${formattedValue}`;
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Order the results by a specific column.
|
|
60
|
+
* @example
|
|
61
|
+
* client.collection('users').orderBy('createdAt', 'desc').find()
|
|
62
|
+
*/
|
|
63
|
+
orderBy(column: keyof M & string, ascending: "asc" | "desc" = "asc"): this {
|
|
64
|
+
this.params.orderBy = `${column}:${ascending}`;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Limit the number of results returned.
|
|
70
|
+
*/
|
|
71
|
+
limit(count: number): this {
|
|
72
|
+
this.params.limit = count;
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Skip the first N results.
|
|
78
|
+
*/
|
|
79
|
+
offset(count: number): this {
|
|
80
|
+
this.params.offset = count;
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Set a free-text search string if supported by the backend.
|
|
86
|
+
*/
|
|
87
|
+
search(searchString: string): this {
|
|
88
|
+
this.params.searchString = searchString;
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Include related entities in the response.
|
|
94
|
+
* Relations will be populated with full entity data instead of just IDs.
|
|
95
|
+
*
|
|
96
|
+
* @param relations - Relation names to include, or "*" for all.
|
|
97
|
+
* @example
|
|
98
|
+
* // Include specific relations
|
|
99
|
+
* client.data.posts.include("tags", "author").find()
|
|
100
|
+
*
|
|
101
|
+
* // Include all relations
|
|
102
|
+
* client.data.posts.include("*").find()
|
|
103
|
+
*/
|
|
104
|
+
include(...relations: string[]): this {
|
|
105
|
+
this.params.include = relations;
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Execute the find query and return the results.
|
|
111
|
+
*/
|
|
112
|
+
async find(): Promise<FindResponse<M>> {
|
|
113
|
+
return this.collection.find(this.params) as Promise<FindResponse<M>>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Listen to realtime updates matching this query.
|
|
118
|
+
*/
|
|
119
|
+
listen(onUpdate: (data: FindResponse<M>) => void, onError?: (error: Error) => void): () => void {
|
|
120
|
+
if (!this.collection.listen) {
|
|
121
|
+
throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
|
|
122
|
+
}
|
|
123
|
+
return this.collection.listen(this.params, onUpdate, onError);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/reviver.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EntityReference, EntityRelation, GeoPoint, Vector, Entity } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
export function rebaseReviver(_key: string, value: unknown): unknown {
|
|
4
|
+
if (value && typeof value === "object" && "__type" in value) {
|
|
5
|
+
const record = value as Record<string, unknown>;
|
|
6
|
+
switch (record.__type) {
|
|
7
|
+
case "date":
|
|
8
|
+
case "Date": {
|
|
9
|
+
if (typeof record.value !== "string") {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
const date = new Date(record.value);
|
|
13
|
+
return isNaN(date.getTime()) ? null : date;
|
|
14
|
+
}
|
|
15
|
+
case "reference":
|
|
16
|
+
case "EntityReference":
|
|
17
|
+
return new EntityReference({
|
|
18
|
+
id: String(record.id),
|
|
19
|
+
path: record.path as string,
|
|
20
|
+
driver: record.driver as string | undefined,
|
|
21
|
+
databaseId: record.databaseId as string | undefined
|
|
22
|
+
});
|
|
23
|
+
case "relation":
|
|
24
|
+
case "EntityRelation":
|
|
25
|
+
return new EntityRelation(
|
|
26
|
+
record.id as string | number,
|
|
27
|
+
record.path as string,
|
|
28
|
+
record.data as Entity | undefined
|
|
29
|
+
);
|
|
30
|
+
case "GeoPoint":
|
|
31
|
+
return new GeoPoint(record.latitude as number, record.longitude as number);
|
|
32
|
+
case "Vector":
|
|
33
|
+
return new Vector(record.value as number[]);
|
|
34
|
+
default:
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { StorageSource, UploadFileProps, UploadFileResult, DownloadConfig, StorageListResult, DownloadMetadata } from "@rebasepro/types";
|
|
2
|
+
import { Transport } from "./transport";
|
|
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`, ...).
|
|
11
|
+
|
|
12
|
+
async function putObject({
|
|
13
|
+
file,
|
|
14
|
+
key,
|
|
15
|
+
metadata,
|
|
16
|
+
bucket
|
|
17
|
+
}: UploadFileProps): Promise<UploadFileResult> {
|
|
18
|
+
const formData = new FormData();
|
|
19
|
+
formData.append("file", file);
|
|
20
|
+
|
|
21
|
+
if (key) formData.append("key", key);
|
|
22
|
+
if (bucket) formData.append("bucket", bucket);
|
|
23
|
+
|
|
24
|
+
if (metadata) {
|
|
25
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
26
|
+
if (value !== undefined && value !== null) {
|
|
27
|
+
formData.append(
|
|
28
|
+
`metadata_${key}`,
|
|
29
|
+
typeof value === "string" ? value : JSON.stringify(value)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
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", {
|
|
38
|
+
method: "POST",
|
|
39
|
+
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
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return result.data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getSignedUrl(
|
|
51
|
+
keyOrUrl: string,
|
|
52
|
+
bucket?: string
|
|
53
|
+
): Promise<DownloadConfig> {
|
|
54
|
+
const cacheKey = bucket ? `${bucket}/${keyOrUrl}` : keyOrUrl;
|
|
55
|
+
const cached = urlsCache.get(cacheKey);
|
|
56
|
+
if (cached) return cached;
|
|
57
|
+
|
|
58
|
+
let filePath = keyOrUrl;
|
|
59
|
+
|
|
60
|
+
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://"))) {
|
|
61
|
+
filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (bucket && filePath && !filePath.startsWith(bucket)) {
|
|
65
|
+
filePath = `${bucket}/${filePath}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!filePath || filePath.trim() === "" || filePath === "/") {
|
|
69
|
+
return { url: null,
|
|
70
|
+
fileNotFound: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = await transport.request<{ data: DownloadMetadata }>(`/storage/metadata/${filePath}`);
|
|
75
|
+
|
|
76
|
+
const activeToken = await transport.resolveToken();
|
|
77
|
+
const tokenQuery = activeToken ? `?token=${activeToken}` : "";
|
|
78
|
+
|
|
79
|
+
const downloadConfig: DownloadConfig = {
|
|
80
|
+
url: `${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}${tokenQuery}`,
|
|
81
|
+
metadata: result.data
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
urlsCache.set(cacheKey, downloadConfig);
|
|
85
|
+
return downloadConfig;
|
|
86
|
+
} catch (e: unknown) {
|
|
87
|
+
if (e instanceof Error && "status" in e && (e as { status: number }).status === 404) {
|
|
88
|
+
return { url: null,
|
|
89
|
+
fileNotFound: true };
|
|
90
|
+
}
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function getObject(
|
|
96
|
+
key: string,
|
|
97
|
+
bucket?: string
|
|
98
|
+
): 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 === "/") {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
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() : {}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (response.status === 404) return null;
|
|
122
|
+
if (!response.ok) throw new Error("Failed to get file");
|
|
123
|
+
|
|
124
|
+
const blob = await response.blob();
|
|
125
|
+
const fileName = filePath.split("/").pop() || "file";
|
|
126
|
+
return new File([blob], fileName, { type: blob.type });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function deleteObject(
|
|
130
|
+
key: string,
|
|
131
|
+
bucket?: string
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
let filePath = key;
|
|
134
|
+
|
|
135
|
+
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://"))) {
|
|
136
|
+
filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (bucket && filePath && !filePath.startsWith(bucket)) {
|
|
140
|
+
filePath = `${bucket}/${filePath}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!filePath || filePath.trim() === "" || filePath === "/") {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await transport.request(`/storage/file/${filePath}`, { method: "DELETE" });
|
|
149
|
+
} catch (e: unknown) {
|
|
150
|
+
if (!(e instanceof Error && "status" in e && (e as { status: number }).status === 404)) throw e;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
urlsCache.delete(bucket ? `${bucket}/${key}` : key);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function listObjects(
|
|
157
|
+
prefix: string,
|
|
158
|
+
options?: {
|
|
159
|
+
bucket?: string;
|
|
160
|
+
maxResults?: number;
|
|
161
|
+
pageToken?: string;
|
|
162
|
+
}
|
|
163
|
+
): Promise<StorageListResult> {
|
|
164
|
+
const params = new URLSearchParams();
|
|
165
|
+
if (prefix) params.set("prefix", prefix);
|
|
166
|
+
if (options?.bucket) params.set("bucket", options.bucket);
|
|
167
|
+
if (options?.maxResults) params.set("maxResults", String(options.maxResults));
|
|
168
|
+
if (options?.pageToken) params.set("pageToken", options.pageToken);
|
|
169
|
+
|
|
170
|
+
const result = await transport.request<{ data: StorageListResult }>(`/storage/list?${params.toString()}`);
|
|
171
|
+
return result.data;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
putObject,
|
|
176
|
+
getSignedUrl,
|
|
177
|
+
getObject,
|
|
178
|
+
deleteObject,
|
|
179
|
+
listObjects
|
|
180
|
+
};
|
|
181
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { FindParams as TypesFindParams, FindResponse as TypesFindResponse, WhereFieldValue } from "@rebasepro/types";
|
|
2
|
+
import { rebaseReviver } from "./reviver";
|
|
3
|
+
|
|
4
|
+
export interface RebaseClientConfig {
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
token?: string;
|
|
7
|
+
apiPath?: string;
|
|
8
|
+
fetch?: typeof globalThis.fetch;
|
|
9
|
+
onUnauthorized?: () => Promise<boolean>;
|
|
10
|
+
websocketUrl?: string; // Optional real-time WebSocket connection
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Re-export from `@rebasepro/types` for backward compatibility.
|
|
15
|
+
*/
|
|
16
|
+
export type FindParams = TypesFindParams;
|
|
17
|
+
export type FindResponse<T> = TypesFindResponse<T extends Record<string, unknown> ? T : Record<string, unknown>>;
|
|
18
|
+
|
|
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) && value.length === 2) {
|
|
74
|
+
const [rawOp, val] = value;
|
|
75
|
+
const op = OP_MAP[rawOp] ?? rawOp;
|
|
76
|
+
|
|
77
|
+
if (val === null) return `${op}.null`;
|
|
78
|
+
if (Array.isArray(val)) return `${op}.(${val.join(",")})`;
|
|
79
|
+
return `${op}.${val}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// String — pass through (either plain equality value or PostgREST syntax)
|
|
83
|
+
return String(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildQueryString(params?: FindParams): string {
|
|
87
|
+
if (!params) return "";
|
|
88
|
+
const parts: string[] = [];
|
|
89
|
+
|
|
90
|
+
if (params.limit != null) parts.push(`limit=${params.limit}`);
|
|
91
|
+
if (params.offset != null) parts.push(`offset=${params.offset}`);
|
|
92
|
+
if (params.page != null) parts.push(`page=${params.page}`);
|
|
93
|
+
|
|
94
|
+
if (params.orderBy) {
|
|
95
|
+
parts.push(`orderBy=${encodeURIComponent(params.orderBy)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (params.searchString) {
|
|
99
|
+
parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (params.include && params.include.length > 0) {
|
|
103
|
+
parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (params.where) {
|
|
107
|
+
for (const [field, value] of Object.entries(params.where)) {
|
|
108
|
+
const normalized = normalizeWhereValue(value as WhereFieldValue);
|
|
109
|
+
parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(normalized)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parts.length > 0 ? "?" + parts.join("&") : "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface Transport {
|
|
117
|
+
request: <T = unknown>(path: string, init?: RequestInit) => Promise<T>;
|
|
118
|
+
setToken: (newToken: string | null) => void;
|
|
119
|
+
setAuthTokenGetter: (getter: () => Promise<string | null>) => void;
|
|
120
|
+
setOnUnauthorized: (handler: () => Promise<boolean>) => void;
|
|
121
|
+
readonly baseUrl: string;
|
|
122
|
+
readonly apiPath: string;
|
|
123
|
+
readonly fetchFn: typeof globalThis.fetch;
|
|
124
|
+
getHeaders: (init?: RequestInit) => Record<string, string>;
|
|
125
|
+
resolveToken: () => Promise<string | null>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createTransport(config: RebaseClientConfig): Transport {
|
|
129
|
+
const fetchFn = config.fetch || globalThis.fetch;
|
|
130
|
+
const apiPath = config.apiPath || "/api";
|
|
131
|
+
let token = config.token;
|
|
132
|
+
let tokenGetter: (() => Promise<string | null>) | undefined;
|
|
133
|
+
let onUnauthorizedHandler = config.onUnauthorized;
|
|
134
|
+
|
|
135
|
+
function getHeaders(activeToken: string | undefined, init?: RequestInit) {
|
|
136
|
+
return {
|
|
137
|
+
"Content-Type": "application/json",
|
|
138
|
+
...(activeToken ? { Authorization: `Bearer ${activeToken}` } : {}),
|
|
139
|
+
...((init?.headers as Record<string, string>) || {})
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
|
144
|
+
const base = config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "";
|
|
145
|
+
const url = base + apiPath + path;
|
|
146
|
+
|
|
147
|
+
let activeToken = token;
|
|
148
|
+
if (tokenGetter) {
|
|
149
|
+
try {
|
|
150
|
+
const fetched = await tokenGetter();
|
|
151
|
+
if (fetched !== null && fetched !== undefined) {
|
|
152
|
+
activeToken = fetched;
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// Ignore error, fallback to static token if any
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const headers = getHeaders(activeToken, init);
|
|
160
|
+
|
|
161
|
+
// If passing FormData, we MUST let fetch set the boundary, so remove Content-Type
|
|
162
|
+
if (init?.body instanceof FormData) {
|
|
163
|
+
delete (headers as Record<string, string>)["Content-Type"];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const res = await fetchFn(url, { ...init,
|
|
167
|
+
headers });
|
|
168
|
+
|
|
169
|
+
if (res.status === 204) return undefined as unknown as T;
|
|
170
|
+
|
|
171
|
+
const text = await res.text().catch(() => "");
|
|
172
|
+
let body: any = {};
|
|
173
|
+
if (text) {
|
|
174
|
+
try {
|
|
175
|
+
body = JSON.parse(text, rebaseReviver);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
// If not valid JSON, fallback
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (res.status === 401 && onUnauthorizedHandler) {
|
|
182
|
+
const retried = await onUnauthorizedHandler();
|
|
183
|
+
if (retried) {
|
|
184
|
+
let retryToken = token;
|
|
185
|
+
if (tokenGetter) {
|
|
186
|
+
try {
|
|
187
|
+
const fetched = await tokenGetter();
|
|
188
|
+
if (fetched !== null && fetched !== undefined) {
|
|
189
|
+
retryToken = fetched;
|
|
190
|
+
}
|
|
191
|
+
} catch (e) { /* ignore */ }
|
|
192
|
+
}
|
|
193
|
+
const retryHeaders = getHeaders(retryToken, init) as Record<string, string>;
|
|
194
|
+
const retryRes = await fetchFn(url, { ...init,
|
|
195
|
+
headers: retryHeaders });
|
|
196
|
+
if (retryRes.status === 204) return undefined as unknown as T;
|
|
197
|
+
const retryText = await retryRes.text().catch(() => "");
|
|
198
|
+
let retryBody: any = {};
|
|
199
|
+
if (retryText) {
|
|
200
|
+
try {
|
|
201
|
+
retryBody = JSON.parse(retryText, rebaseReviver);
|
|
202
|
+
} catch (e) { /* ignore */ }
|
|
203
|
+
}
|
|
204
|
+
if (!retryRes.ok) {
|
|
205
|
+
let fallbackMessage = retryRes.statusText;
|
|
206
|
+
if (retryRes.status === 404 && !fallbackMessage) {
|
|
207
|
+
const method = init?.method || "GET";
|
|
208
|
+
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.`;
|
|
209
|
+
}
|
|
210
|
+
throw new RebaseApiError(
|
|
211
|
+
retryRes.status,
|
|
212
|
+
retryBody?.error?.message || retryBody?.message || fallbackMessage || `Request failed with status ${retryRes.status}`,
|
|
213
|
+
retryBody?.error?.code || retryBody?.code,
|
|
214
|
+
retryBody?.error?.details || retryBody?.details
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return retryBody as T;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
let fallbackMessage = res.statusText;
|
|
223
|
+
if (res.status === 404 && !fallbackMessage) {
|
|
224
|
+
const method = init?.method || "GET";
|
|
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.`;
|
|
226
|
+
}
|
|
227
|
+
throw new RebaseApiError(
|
|
228
|
+
res.status,
|
|
229
|
+
body?.error?.message || body?.message || fallbackMessage || `Request failed with status ${res.status}`,
|
|
230
|
+
body?.error?.code || body?.code,
|
|
231
|
+
body?.error?.details || body?.details
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return body as T;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
request,
|
|
240
|
+
setToken(newToken: string | null) { token = newToken || undefined; },
|
|
241
|
+
setAuthTokenGetter(getter: () => Promise<string | null>) { tokenGetter = getter; },
|
|
242
|
+
setOnUnauthorized(handler: () => Promise<boolean>) { onUnauthorizedHandler = handler; },
|
|
243
|
+
get baseUrl() { return config.baseUrl ? config.baseUrl.replace(/\/$/, "") : ""; },
|
|
244
|
+
get apiPath() { return apiPath; },
|
|
245
|
+
get fetchFn() { return fetchFn; },
|
|
246
|
+
getHeaders: (init?: RequestInit) => getHeaders(token, init) as Record<string, string>,
|
|
247
|
+
resolveToken: async () => {
|
|
248
|
+
if (tokenGetter) {
|
|
249
|
+
try {
|
|
250
|
+
const fetched = await tokenGetter();
|
|
251
|
+
if (fetched !== null && fetched !== undefined) {
|
|
252
|
+
return fetched;
|
|
253
|
+
}
|
|
254
|
+
} catch (e) { /* ignore */ }
|
|
255
|
+
}
|
|
256
|
+
return token || null;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|