@rebasepro/client 0.0.1-canary.4d4fb3e
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 +95 -0
- package/dist/collection.d.ts +19 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.es.js +1882 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +1886 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/query_builder.d.ts +53 -0
- package/dist/storage.d.ts +3 -0
- package/dist/transport.d.ts +33 -0
- package/dist/websocket.d.ts +93 -0
- package/package.json +79 -0
- package/src/admin.ts +119 -0
- package/src/auth.ts +400 -0
- package/src/collection.ts +198 -0
- package/src/index.ts +154 -0
- package/src/query_builder.ts +126 -0
- package/src/storage.ts +181 -0
- package/src/transport.ts +173 -0
- package/src/websocket.ts +1114 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { Transport, FindParams, FindResponse, buildQueryString } from "./transport";
|
|
2
|
+
import { RebaseWebSocketClient } from "./websocket";
|
|
3
|
+
import { Entity, FilterValues, WhereFilterOp, CollectionAccessor } from "@rebasepro/types";
|
|
4
|
+
|
|
5
|
+
import { FilterOperator, QueryBuilder } from "./query_builder";
|
|
6
|
+
|
|
7
|
+
function parseWhereFilter(where?: Record<string, string>): FilterValues<any> | undefined {
|
|
8
|
+
if (!where) return undefined;
|
|
9
|
+
const filters: Record<string, [WhereFilterOp, unknown]> = {};
|
|
10
|
+
for (const [key, value] of Object.entries(where)) {
|
|
11
|
+
const dotIndex = value.indexOf(".");
|
|
12
|
+
if (dotIndex > 0) {
|
|
13
|
+
const opStr = value.substring(0, dotIndex);
|
|
14
|
+
const valStr = value.substring(dotIndex + 1);
|
|
15
|
+
let op: WhereFilterOp = "==";
|
|
16
|
+
let val: any = valStr;
|
|
17
|
+
|
|
18
|
+
switch (opStr) {
|
|
19
|
+
case "eq": op = "=="; break;
|
|
20
|
+
case "neq": op = "!="; break;
|
|
21
|
+
case "gt": op = ">"; break;
|
|
22
|
+
case "gte": op = ">="; break;
|
|
23
|
+
case "lt": op = "<"; break;
|
|
24
|
+
case "lte": op = "<="; break;
|
|
25
|
+
case "in":
|
|
26
|
+
op = "in";
|
|
27
|
+
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
28
|
+
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
29
|
+
: valStr.split(",");
|
|
30
|
+
break;
|
|
31
|
+
case "nin":
|
|
32
|
+
op = "not-in";
|
|
33
|
+
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
34
|
+
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
35
|
+
: valStr.split(",");
|
|
36
|
+
break;
|
|
37
|
+
case "cs": op = "array-contains"; break;
|
|
38
|
+
case "csa":
|
|
39
|
+
op = "array-contains-any";
|
|
40
|
+
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
41
|
+
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
42
|
+
: valStr.split(",");
|
|
43
|
+
break;
|
|
44
|
+
default: op = "=="; val = value;
|
|
45
|
+
}
|
|
46
|
+
// Simple type inference for parsing from URL-like strings
|
|
47
|
+
if (val === "true") val = true;
|
|
48
|
+
else if (val === "false") val = false;
|
|
49
|
+
else if (val === "null") val = null;
|
|
50
|
+
else if (typeof val === "string" && /^[0-9]+(\.[0-9]+)?$/.test(val)) val = Number(val);
|
|
51
|
+
|
|
52
|
+
filters[key] = [op, val];
|
|
53
|
+
} else {
|
|
54
|
+
filters[key] = ["==", value];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return filters;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Wrap a flat row (returned by the REST API as `{ id, ...fields }`) into
|
|
62
|
+
* a proper `Entity<M>` structure expected by the core framework.
|
|
63
|
+
* The `id` is kept inside `values` as well, since collection properties
|
|
64
|
+
* may define an `isId` field that the form binds to `formex.values`.
|
|
65
|
+
*/
|
|
66
|
+
function rowToEntity<M extends Record<string, any>>(row: Record<string, any>, slug: string): Entity<M> {
|
|
67
|
+
return {
|
|
68
|
+
id: row.id,
|
|
69
|
+
path: slug,
|
|
70
|
+
values: row as M
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* CollectionClient extends `CollectionAccessor` from `@rebasepro/types` so that
|
|
76
|
+
* `client.data` can be passed directly to the core Rebase component.
|
|
77
|
+
*
|
|
78
|
+
* Additionally it exposes fluent query builder methods like `.where()`, `.orderBy()`.
|
|
79
|
+
*/
|
|
80
|
+
export interface CollectionClient<M extends Record<string, any> = any> extends CollectionAccessor<M> {
|
|
81
|
+
// Fluent Query Builder
|
|
82
|
+
where(column: keyof M & string, operator: FilterOperator, value: any): QueryBuilder<M>;
|
|
83
|
+
orderBy(column: keyof M & string, ascending?: "asc" | "desc"): QueryBuilder<M>;
|
|
84
|
+
limit(count: number): QueryBuilder<M>;
|
|
85
|
+
offset(count: number): QueryBuilder<M>;
|
|
86
|
+
search(searchString: string): QueryBuilder<M>;
|
|
87
|
+
include(...relations: string[]): QueryBuilder<M>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createCollectionClient<M extends Record<string, any> = any>(transport: Transport, slug: string, ws?: RebaseWebSocketClient): CollectionClient<M> {
|
|
91
|
+
const basePath = `/data/${slug}`;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
async find(params?: FindParams) {
|
|
95
|
+
const qs = buildQueryString(params);
|
|
96
|
+
const raw = await transport.request<{ data: Record<string, any>[]; meta: any }>(basePath + qs, { method: "GET" });
|
|
97
|
+
return {
|
|
98
|
+
data: (raw.data || []).map((row: Record<string, any>) => rowToEntity<M>(row, slug)),
|
|
99
|
+
meta: raw.meta
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async findById(id: string | number) {
|
|
104
|
+
const raw = await transport.request<Record<string, any>>(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
|
|
105
|
+
if (!raw) return undefined;
|
|
106
|
+
return rowToEntity<M>(raw, slug);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async create(data: Partial<M>, id?: string | number) {
|
|
110
|
+
const body: Record<string, unknown> = { ...data };
|
|
111
|
+
if (id !== undefined) {
|
|
112
|
+
body.id = id;
|
|
113
|
+
}
|
|
114
|
+
const raw = await transport.request<Record<string, any>>(basePath, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
body: JSON.stringify(body),
|
|
117
|
+
});
|
|
118
|
+
return rowToEntity<M>(raw, slug);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async update(id: string | number, data: Partial<M>) {
|
|
122
|
+
const raw = await transport.request<Record<string, any>>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
123
|
+
method: "PUT",
|
|
124
|
+
body: JSON.stringify(data),
|
|
125
|
+
});
|
|
126
|
+
return rowToEntity<M>(raw, slug);
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async delete(id: string | number) {
|
|
130
|
+
return transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
131
|
+
method: "DELETE",
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
...(ws && {
|
|
136
|
+
listen(params: FindParams | undefined, onUpdate: (data: { data: Entity<M>[]; meta: any }) => void, onError?: (error: Error) => void) {
|
|
137
|
+
return ws.listenCollection(
|
|
138
|
+
{
|
|
139
|
+
path: slug,
|
|
140
|
+
filter: parseWhereFilter(params?.where),
|
|
141
|
+
limit: params?.limit,
|
|
142
|
+
startAfter: params?.offset ? String(params.offset) : undefined,
|
|
143
|
+
orderBy: params?.orderBy?.split(":")[0],
|
|
144
|
+
order: params?.orderBy?.split(":")[1] as "asc" | "desc",
|
|
145
|
+
searchString: params?.searchString
|
|
146
|
+
},
|
|
147
|
+
(entities: Entity[]) => {
|
|
148
|
+
const requestedLimit = params?.limit || 20;
|
|
149
|
+
onUpdate({
|
|
150
|
+
data: entities as Entity<M>[],
|
|
151
|
+
meta: {
|
|
152
|
+
total: entities.length,
|
|
153
|
+
limit: requestedLimit,
|
|
154
|
+
offset: params?.offset || 0,
|
|
155
|
+
hasMore: entities.length >= requestedLimit
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
onError
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
listenById(id: string | number, onUpdate: (data: Entity<M> | undefined) => void, onError?: (error: Error) => void) {
|
|
164
|
+
return ws.listenEntity(
|
|
165
|
+
{ path: slug, entityId: String(id) },
|
|
166
|
+
(entity: Entity | null) => {
|
|
167
|
+
if (entity) {
|
|
168
|
+
onUpdate(entity as Entity<M>);
|
|
169
|
+
} else {
|
|
170
|
+
onUpdate(undefined);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
onError
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}),
|
|
177
|
+
|
|
178
|
+
// Fluent builder instantiation
|
|
179
|
+
where(column: keyof M & string, operator: FilterOperator, value: any) {
|
|
180
|
+
return new QueryBuilder<M>(this as unknown as CollectionClient<M>).where(column, operator, value);
|
|
181
|
+
},
|
|
182
|
+
orderBy(column: keyof M & string, ascending?: "asc" | "desc") {
|
|
183
|
+
return new QueryBuilder<M>(this as unknown as CollectionClient<M>).orderBy(column, ascending);
|
|
184
|
+
},
|
|
185
|
+
limit(count: number) {
|
|
186
|
+
return new QueryBuilder<M>(this as unknown as CollectionClient<M>).limit(count);
|
|
187
|
+
},
|
|
188
|
+
offset(count: number) {
|
|
189
|
+
return new QueryBuilder<M>(this as unknown as CollectionClient<M>).offset(count);
|
|
190
|
+
},
|
|
191
|
+
search(searchString: string) {
|
|
192
|
+
return new QueryBuilder<M>(this as unknown as CollectionClient<M>).search(searchString);
|
|
193
|
+
},
|
|
194
|
+
include(...relations: string[]) {
|
|
195
|
+
return new QueryBuilder<M>(this as unknown as CollectionClient<M>).include(...relations);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createTransport, RebaseClientConfig } from "./transport";
|
|
2
|
+
import { createAuth, CreateAuthOptions } from "./auth";
|
|
3
|
+
import { createAdmin, CreateAdminOptions } from "./admin";
|
|
4
|
+
import { createCollectionClient, CollectionClient } from "./collection";
|
|
5
|
+
|
|
6
|
+
export * from "./transport";
|
|
7
|
+
export * from "./auth";
|
|
8
|
+
export * from "./admin";
|
|
9
|
+
export * from "./collection";
|
|
10
|
+
export * from "./websocket";
|
|
11
|
+
export * from "./storage";
|
|
12
|
+
|
|
13
|
+
export interface CreateRebaseClientOptions extends RebaseClientConfig {
|
|
14
|
+
auth?: CreateAuthOptions;
|
|
15
|
+
admin?: CreateAdminOptions;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
import { RebaseWebSocketClient } from "./websocket";
|
|
19
|
+
import { RebaseClient as BaseRebaseClient, RebaseData, CollectionAccessor, StorageSource } from "@rebasepro/types";
|
|
20
|
+
|
|
21
|
+
export type RebaseClient<DB = any> = BaseRebaseClient<DB> & {
|
|
22
|
+
setToken: (token: string | null) => void;
|
|
23
|
+
setAuthTokenGetter: (getter: () => Promise<string | null>) => void;
|
|
24
|
+
setOnUnauthorized: (handler: () => Promise<boolean>) => void;
|
|
25
|
+
resolveToken: () => Promise<string | null>;
|
|
26
|
+
auth: ReturnType<typeof createAuth>;
|
|
27
|
+
admin: ReturnType<typeof createAdmin>;
|
|
28
|
+
ws?: RebaseWebSocketClient;
|
|
29
|
+
storage?: StorageSource;
|
|
30
|
+
call: <T = any>(endpoint: string, payload?: any) => Promise<T>;
|
|
31
|
+
data: RebaseData & {
|
|
32
|
+
collection<K extends keyof DB>(slug: Extract<K, string>): CollectionClient<
|
|
33
|
+
DB[K] extends { Row: infer R extends Record<string, any> } ? R : any
|
|
34
|
+
>;
|
|
35
|
+
} & {
|
|
36
|
+
[K in keyof DB]: CollectionClient<
|
|
37
|
+
DB[K] extends { Row: infer R extends Record<string, any> } ? R : any
|
|
38
|
+
>;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
import { createStorage } from "./storage";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Derive a WebSocket URL from an HTTP base URL.
|
|
46
|
+
* `http://` → `ws://`, `https://` → `wss://`.
|
|
47
|
+
*/
|
|
48
|
+
function deriveWebSocketUrl(baseUrl: string): string {
|
|
49
|
+
return baseUrl
|
|
50
|
+
.replace(/^https:\/\//, "wss://")
|
|
51
|
+
.replace(/^http:\/\//, "ws://")
|
|
52
|
+
.replace(/\/$/, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createRebaseClient<DB = any>(options: CreateRebaseClientOptions): RebaseClient<DB> {
|
|
56
|
+
const transport = createTransport(options);
|
|
57
|
+
const auth = createAuth(transport, options.auth);
|
|
58
|
+
const admin = createAdmin(transport, options.admin);
|
|
59
|
+
const storage = createStorage(transport);
|
|
60
|
+
|
|
61
|
+
const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
|
|
62
|
+
|
|
63
|
+
let ws: RebaseWebSocketClient | undefined;
|
|
64
|
+
if (resolvedWsUrl) {
|
|
65
|
+
ws = new RebaseWebSocketClient({
|
|
66
|
+
websocketUrl: resolvedWsUrl,
|
|
67
|
+
getAuthToken: async () => {
|
|
68
|
+
const session = await auth.getSession();
|
|
69
|
+
return session?.accessToken || options.token || "";
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
auth.onAuthStateChange((event, session) => {
|
|
74
|
+
if (!ws) return;
|
|
75
|
+
if (event === "SIGNED_OUT") {
|
|
76
|
+
ws.disconnect();
|
|
77
|
+
} else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
78
|
+
if (session?.accessToken) {
|
|
79
|
+
ws.authenticate(session.accessToken).catch(console.warn);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Register transport callback for 401s after auth is instantiated
|
|
86
|
+
if (!options.onUnauthorized) {
|
|
87
|
+
options.onUnauthorized = async () => {
|
|
88
|
+
try {
|
|
89
|
+
await auth.refreshSession();
|
|
90
|
+
return true;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const collectionClients = new Map<string, CollectionClient<any>>();
|
|
98
|
+
|
|
99
|
+
function collection(slug: string): CollectionClient<any> {
|
|
100
|
+
if (!collectionClients.has(slug)) {
|
|
101
|
+
collectionClients.set(slug, createCollectionClient(transport, slug, ws));
|
|
102
|
+
}
|
|
103
|
+
return collectionClients.get(slug)!;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const dataTarget = { collection } as Record<string, any>;
|
|
107
|
+
|
|
108
|
+
const dataProxy = new Proxy(dataTarget, {
|
|
109
|
+
get(_target, prop: string | symbol) {
|
|
110
|
+
if (prop === "collection") {
|
|
111
|
+
return collection;
|
|
112
|
+
}
|
|
113
|
+
if (typeof prop === "symbol") return undefined;
|
|
114
|
+
if (typeof prop === "string" && prop !== "then" && prop !== "toJSON" && prop !== "$$typeof") {
|
|
115
|
+
return collection(prop);
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const target = {
|
|
122
|
+
auth,
|
|
123
|
+
admin,
|
|
124
|
+
storage,
|
|
125
|
+
ws,
|
|
126
|
+
setToken: transport.setToken,
|
|
127
|
+
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
128
|
+
setOnUnauthorized: transport.setOnUnauthorized,
|
|
129
|
+
resolveToken: transport.resolveToken,
|
|
130
|
+
baseUrl: transport.baseUrl,
|
|
131
|
+
collection,
|
|
132
|
+
call: async <T = any>(endpoint: string, payload?: any): Promise<T> => {
|
|
133
|
+
const prefix = endpoint.startsWith("/") ? "" : "/";
|
|
134
|
+
const res = await transport.request<{ data: T }>(`${prefix}${endpoint}`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
body: payload ? JSON.stringify(payload) : undefined
|
|
137
|
+
});
|
|
138
|
+
return res.data ?? (res as unknown as T);
|
|
139
|
+
},
|
|
140
|
+
data: dataProxy
|
|
141
|
+
} as unknown as RebaseClient<DB>;
|
|
142
|
+
|
|
143
|
+
return new Proxy(target, {
|
|
144
|
+
get(obj, prop: string | symbol) {
|
|
145
|
+
if (prop in obj) {
|
|
146
|
+
return (obj as Record<string, unknown>)[prop as string];
|
|
147
|
+
}
|
|
148
|
+
if (typeof prop === "string" && prop !== "then") {
|
|
149
|
+
return collection(prop as Extract<keyof DB, string>);
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}) as RebaseClient<DB>;
|
|
154
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { FindParams, Entity } from "@rebasepro/types";
|
|
2
|
+
import { FindResponse } from "./transport";
|
|
3
|
+
import { CollectionClient } from "./collection";
|
|
4
|
+
|
|
5
|
+
export type FilterOperator =
|
|
6
|
+
| "eq" | "neq" | "gt" | "gte" | "lt" | "lte"
|
|
7
|
+
| "in" | "nin" | "cs" | "csa"
|
|
8
|
+
| "==" | "!=" | ">" | ">=" | "<" | "<="
|
|
9
|
+
| "array-contains" | "array-contains-any"
|
|
10
|
+
| "not-in";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Maps standard operators to Rebase backend's string-based operators
|
|
14
|
+
*/
|
|
15
|
+
function mapOperator(op: FilterOperator): string {
|
|
16
|
+
switch (op) {
|
|
17
|
+
case "==": return "eq";
|
|
18
|
+
case "!=": return "neq";
|
|
19
|
+
case ">": return "gt";
|
|
20
|
+
case ">=": return "gte";
|
|
21
|
+
case "<": return "lt";
|
|
22
|
+
case "<=": return "lte";
|
|
23
|
+
case "array-contains": return "cs";
|
|
24
|
+
case "array-contains-any": return "csa";
|
|
25
|
+
case "not-in": return "nin";
|
|
26
|
+
default: return op;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class QueryBuilder<M extends Record<string, any> = any> {
|
|
31
|
+
private params: FindParams = { where: {} };
|
|
32
|
+
|
|
33
|
+
constructor(private collection: CollectionClient<M>) {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add a filter condition to your query.
|
|
37
|
+
* @example
|
|
38
|
+
* client.collection('users').where('age', '>=', 18).find()
|
|
39
|
+
*/
|
|
40
|
+
where(column: keyof M & string, operator: FilterOperator, value: any): this {
|
|
41
|
+
if (!this.params.where) {
|
|
42
|
+
this.params.where = {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mappedOp = mapOperator(operator);
|
|
46
|
+
let formattedValue = value;
|
|
47
|
+
|
|
48
|
+
// Handle arrays for in, nin, cs, csa
|
|
49
|
+
if (Array.isArray(value) && ["in", "nin", "cs", "csa"].includes(mappedOp)) {
|
|
50
|
+
formattedValue = `(${value.join(",")})`;
|
|
51
|
+
} else if (value === null) {
|
|
52
|
+
formattedValue = "null";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.params.where[column] = mappedOp === "eq" ? String(formattedValue) : `${mappedOp}.${formattedValue}`;
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Order the results by a specific column.
|
|
61
|
+
* @example
|
|
62
|
+
* client.collection('users').orderBy('createdAt', 'desc').find()
|
|
63
|
+
*/
|
|
64
|
+
orderBy(column: keyof M & string, ascending: "asc" | "desc" = "asc"): this {
|
|
65
|
+
this.params.orderBy = `${column}:${ascending}`;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Limit the number of results returned.
|
|
71
|
+
*/
|
|
72
|
+
limit(count: number): this {
|
|
73
|
+
this.params.limit = count;
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Skip the first N results.
|
|
79
|
+
*/
|
|
80
|
+
offset(count: number): this {
|
|
81
|
+
this.params.offset = count;
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Set a free-text search string if supported by the backend.
|
|
87
|
+
*/
|
|
88
|
+
search(searchString: string): this {
|
|
89
|
+
this.params.searchString = searchString;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Include related entities in the response.
|
|
95
|
+
* Relations will be populated with full entity data instead of just IDs.
|
|
96
|
+
*
|
|
97
|
+
* @param relations - Relation names to include, or "*" for all.
|
|
98
|
+
* @example
|
|
99
|
+
* // Include specific relations
|
|
100
|
+
* client.data.posts.include("tags", "author").find()
|
|
101
|
+
*
|
|
102
|
+
* // Include all relations
|
|
103
|
+
* client.data.posts.include("*").find()
|
|
104
|
+
*/
|
|
105
|
+
include(...relations: string[]): this {
|
|
106
|
+
this.params.include = relations;
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Execute the find query and return the results.
|
|
112
|
+
*/
|
|
113
|
+
async find(): Promise<FindResponse<M>> {
|
|
114
|
+
return this.collection.find(this.params) as Promise<FindResponse<M>>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Listen to realtime updates matching this query.
|
|
119
|
+
*/
|
|
120
|
+
listen(onUpdate: (data: FindResponse<M>) => void, onError?: (error: Error) => void): () => void {
|
|
121
|
+
if (!this.collection.listen) {
|
|
122
|
+
throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
|
|
123
|
+
}
|
|
124
|
+
return this.collection.listen(this.params, onUpdate as unknown as (data: { data: Entity<M>[]; meta: any }) => void, onError);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { StorageSource, UploadFileProps, UploadFileResult, DownloadConfig, StorageListResult } 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 uploadFile({
|
|
13
|
+
file,
|
|
14
|
+
fileName,
|
|
15
|
+
path,
|
|
16
|
+
metadata,
|
|
17
|
+
bucket
|
|
18
|
+
}: UploadFileProps): Promise<UploadFileResult> {
|
|
19
|
+
const formData = new FormData();
|
|
20
|
+
formData.append("file", file);
|
|
21
|
+
|
|
22
|
+
if (fileName) formData.append("fileName", fileName);
|
|
23
|
+
if (path) formData.append("path", path);
|
|
24
|
+
if (bucket) formData.append("bucket", bucket);
|
|
25
|
+
|
|
26
|
+
if (metadata) {
|
|
27
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
28
|
+
if (value !== undefined && value !== null) {
|
|
29
|
+
formData.append(
|
|
30
|
+
`metadata_${key}`,
|
|
31
|
+
typeof value === "string" ? value : JSON.stringify(value)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// We use fetchFn directly if we need to do multipart boundary, but Transport.request might override Content-Type?
|
|
38
|
+
// 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!
|
|
39
|
+
const result = await transport.request<{ data: UploadFileResult }>("/storage/upload", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
body: formData,
|
|
42
|
+
headers: {
|
|
43
|
+
// transport.request merges headers, so to prevent it setting application/json we can delete it
|
|
44
|
+
// in transport if body is FormData, or we can explicitly set it to an empty string.
|
|
45
|
+
// Let's rely on standard behaviour for now and adjust transport if it fails.
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return result.data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getDownloadURL(
|
|
53
|
+
pathOrUrl: string,
|
|
54
|
+
bucket?: string
|
|
55
|
+
): Promise<DownloadConfig> {
|
|
56
|
+
const cacheKey = bucket ? `${bucket}/${pathOrUrl}` : pathOrUrl;
|
|
57
|
+
const cached = urlsCache.get(cacheKey);
|
|
58
|
+
if (cached) return cached;
|
|
59
|
+
|
|
60
|
+
let filePath = pathOrUrl;
|
|
61
|
+
|
|
62
|
+
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://"))) {
|
|
63
|
+
filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (bucket && filePath && !filePath.startsWith(bucket)) {
|
|
67
|
+
filePath = `${bucket}/${filePath}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!filePath || filePath.trim() === '' || filePath === '/') {
|
|
71
|
+
return { url: null, fileNotFound: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const result = await transport.request<{ data: any }>(`/storage/metadata/${filePath}`);
|
|
76
|
+
|
|
77
|
+
const activeToken = await transport.resolveToken();
|
|
78
|
+
const tokenQuery = activeToken ? `?token=${activeToken}` : '';
|
|
79
|
+
|
|
80
|
+
const downloadConfig: DownloadConfig = {
|
|
81
|
+
url: `${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}${tokenQuery}`,
|
|
82
|
+
metadata: result.data
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
urlsCache.set(cacheKey, downloadConfig);
|
|
86
|
+
return downloadConfig;
|
|
87
|
+
} catch (e: unknown) {
|
|
88
|
+
if (e instanceof Error && 'status' in e && (e as { status: number }).status === 404) {
|
|
89
|
+
return { url: null, fileNotFound: true };
|
|
90
|
+
}
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function getFile(
|
|
96
|
+
path: string,
|
|
97
|
+
bucket?: string
|
|
98
|
+
): Promise<File | null> {
|
|
99
|
+
let filePath = path;
|
|
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 deleteFile(
|
|
130
|
+
path: string,
|
|
131
|
+
bucket?: string
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
let filePath = path;
|
|
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}/${path}` : path);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function list(
|
|
157
|
+
path: string,
|
|
158
|
+
options?: {
|
|
159
|
+
bucket?: string;
|
|
160
|
+
maxResults?: number;
|
|
161
|
+
pageToken?: string;
|
|
162
|
+
}
|
|
163
|
+
): Promise<StorageListResult> {
|
|
164
|
+
const params = new URLSearchParams();
|
|
165
|
+
if (path) params.set("path", path);
|
|
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
|
+
uploadFile,
|
|
176
|
+
getDownloadURL,
|
|
177
|
+
getFile,
|
|
178
|
+
deleteFile,
|
|
179
|
+
list
|
|
180
|
+
};
|
|
181
|
+
}
|