@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,249 @@
|
|
|
1
|
+
import { Transport, FindParams, buildQueryString } from "./transport";
|
|
2
|
+
import { RebaseWebSocketClient } from "./websocket";
|
|
3
|
+
import { Entity, FilterValues, WhereFilterOp, CollectionAccessor, WhereFieldValue, FindResponse } from "@rebasepro/types";
|
|
4
|
+
|
|
5
|
+
import { FilterOperator, QueryBuilder } from "./query_builder";
|
|
6
|
+
|
|
7
|
+
function parseWhereFilter(where?: Record<string, WhereFieldValue>): FilterValues<string> | undefined {
|
|
8
|
+
if (!where) return undefined;
|
|
9
|
+
const filters: Record<string, [WhereFilterOp, unknown]> = {};
|
|
10
|
+
for (const [key, rawValue] of Object.entries(where)) {
|
|
11
|
+
// Handle null → equality
|
|
12
|
+
if (rawValue === null) {
|
|
13
|
+
filters[key] = ["==", null];
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Handle boolean → equality
|
|
18
|
+
if (typeof rawValue === "boolean") {
|
|
19
|
+
filters[key] = ["==", rawValue];
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Handle number → equality
|
|
24
|
+
if (typeof rawValue === "number") {
|
|
25
|
+
filters[key] = ["==", rawValue];
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Handle tuple: [operator, value]
|
|
30
|
+
if (Array.isArray(rawValue) && rawValue.length === 2) {
|
|
31
|
+
const [rawOp, val] = rawValue;
|
|
32
|
+
const OP_TO_FILTER: Record<string, WhereFilterOp> = {
|
|
33
|
+
"eq": "==",
|
|
34
|
+
"neq": "!=",
|
|
35
|
+
"gt": ">",
|
|
36
|
+
"gte": ">=",
|
|
37
|
+
"lt": "<",
|
|
38
|
+
"lte": "<=",
|
|
39
|
+
"==": "==",
|
|
40
|
+
"!=": "!=",
|
|
41
|
+
">": ">",
|
|
42
|
+
">=": ">=",
|
|
43
|
+
"<": "<",
|
|
44
|
+
"<=": "<=",
|
|
45
|
+
"in": "in",
|
|
46
|
+
"nin": "not-in",
|
|
47
|
+
"not-in": "not-in",
|
|
48
|
+
"cs": "array-contains",
|
|
49
|
+
"csa": "array-contains-any",
|
|
50
|
+
"array-contains": "array-contains",
|
|
51
|
+
"array-contains-any": "array-contains-any"
|
|
52
|
+
};
|
|
53
|
+
filters[key] = [OP_TO_FILTER[rawOp] ?? "==", val];
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Handle string (original PostgREST format)
|
|
58
|
+
const value = String(rawValue);
|
|
59
|
+
const dotIndex = value.indexOf(".");
|
|
60
|
+
if (dotIndex > 0) {
|
|
61
|
+
const opStr = value.substring(0, dotIndex);
|
|
62
|
+
const valStr = value.substring(dotIndex + 1);
|
|
63
|
+
let op: WhereFilterOp = "==";
|
|
64
|
+
let val: string | number | boolean | null | string[] = valStr;
|
|
65
|
+
|
|
66
|
+
switch (opStr) {
|
|
67
|
+
case "eq": op = "=="; break;
|
|
68
|
+
case "neq": op = "!="; break;
|
|
69
|
+
case "gt": op = ">"; break;
|
|
70
|
+
case "gte": op = ">="; break;
|
|
71
|
+
case "lt": op = "<"; break;
|
|
72
|
+
case "lte": op = "<="; break;
|
|
73
|
+
case "in":
|
|
74
|
+
op = "in";
|
|
75
|
+
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
76
|
+
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
77
|
+
: valStr.split(",");
|
|
78
|
+
break;
|
|
79
|
+
case "nin":
|
|
80
|
+
op = "not-in";
|
|
81
|
+
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
82
|
+
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
83
|
+
: valStr.split(",");
|
|
84
|
+
break;
|
|
85
|
+
case "cs": op = "array-contains"; break;
|
|
86
|
+
case "csa":
|
|
87
|
+
op = "array-contains-any";
|
|
88
|
+
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
89
|
+
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
90
|
+
: valStr.split(",");
|
|
91
|
+
break;
|
|
92
|
+
default: op = "=="; val = value;
|
|
93
|
+
}
|
|
94
|
+
// Simple type inference for parsing from URL-like strings
|
|
95
|
+
if (val === "true") val = true;
|
|
96
|
+
else if (val === "false") val = false;
|
|
97
|
+
else if (val === "null") val = null;
|
|
98
|
+
else if (typeof val === "string" && /^[0-9]+(\.[0-9]+)?$/.test(val) && key !== "id" && !key.endsWith("_id")) val = Number(val);
|
|
99
|
+
|
|
100
|
+
filters[key] = [op, val];
|
|
101
|
+
} else {
|
|
102
|
+
filters[key] = ["==", value];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return filters;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wrap a flat row (returned by the REST API as `{ id, ...fields }`) into
|
|
110
|
+
* a proper `Entity<M>` structure expected by the core framework.
|
|
111
|
+
* The `id` is kept inside `values` as well, since collection properties
|
|
112
|
+
* may define an `isId` field that the form binds to `formex.values`.
|
|
113
|
+
*/
|
|
114
|
+
function rowToEntity<M extends Record<string, unknown>>(row: Record<string, unknown>, slug: string): Entity<M> {
|
|
115
|
+
return {
|
|
116
|
+
id: row.id as string | number,
|
|
117
|
+
path: slug,
|
|
118
|
+
values: row as M
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* CollectionClient extends `CollectionAccessor` from `@rebasepro/types` so that
|
|
124
|
+
* `client.data` can be passed directly to the core Rebase component.
|
|
125
|
+
*
|
|
126
|
+
* Additionally it exposes fluent query builder methods like `.where()`, `.orderBy()`.
|
|
127
|
+
*/
|
|
128
|
+
export interface CollectionClient<M extends Record<string, unknown> = Record<string, unknown>> extends CollectionAccessor<M> {
|
|
129
|
+
// Fluent Query Builder
|
|
130
|
+
where(column: keyof M & string, operator: FilterOperator, value: unknown): QueryBuilder<M>;
|
|
131
|
+
orderBy(column: keyof M & string, ascending?: "asc" | "desc"): QueryBuilder<M>;
|
|
132
|
+
limit(count: number): QueryBuilder<M>;
|
|
133
|
+
offset(count: number): QueryBuilder<M>;
|
|
134
|
+
search(searchString: string): QueryBuilder<M>;
|
|
135
|
+
include(...relations: string[]): QueryBuilder<M>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createCollectionClient<M extends Record<string, unknown> = Record<string, unknown>>(transport: Transport, slug: string, ws?: RebaseWebSocketClient): CollectionClient<M> {
|
|
139
|
+
const basePath = `/data/${slug}`;
|
|
140
|
+
|
|
141
|
+
const client: CollectionClient<M> = {
|
|
142
|
+
async find(params?: FindParams): Promise<FindResponse<M>> {
|
|
143
|
+
const qs = buildQueryString(params);
|
|
144
|
+
const raw = await transport.request<{ data: Record<string, unknown>[]; meta: FindResponse<M>["meta"] }>(basePath + qs, { method: "GET" });
|
|
145
|
+
return {
|
|
146
|
+
data: (raw.data || []).map((row: Record<string, unknown>) => rowToEntity<M>(row, slug)),
|
|
147
|
+
meta: raw.meta
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async findById(id: string | number) {
|
|
152
|
+
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
|
|
153
|
+
if (!raw) return undefined;
|
|
154
|
+
return rowToEntity<M>(raw, slug);
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async create(data: Partial<M>, id?: string | number) {
|
|
158
|
+
const body: Record<string, unknown> = { ...data };
|
|
159
|
+
if (id !== undefined) {
|
|
160
|
+
body.id = id;
|
|
161
|
+
}
|
|
162
|
+
const raw = await transport.request<Record<string, unknown>>(basePath, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
body: JSON.stringify(body)
|
|
165
|
+
});
|
|
166
|
+
return rowToEntity<M>(raw, slug);
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async update(id: string | number, data: Partial<M>) {
|
|
170
|
+
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
171
|
+
method: "PUT",
|
|
172
|
+
body: JSON.stringify(data)
|
|
173
|
+
});
|
|
174
|
+
return rowToEntity<M>(raw, slug);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async delete(id: string | number) {
|
|
178
|
+
return transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
179
|
+
method: "DELETE"
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// Fluent builder instantiation
|
|
184
|
+
where(column: keyof M & string, operator: FilterOperator, value: unknown) {
|
|
185
|
+
return new QueryBuilder<M>(client).where(column, operator, value);
|
|
186
|
+
},
|
|
187
|
+
orderBy(column: keyof M & string, ascending?: "asc" | "desc") {
|
|
188
|
+
return new QueryBuilder<M>(client).orderBy(column, ascending);
|
|
189
|
+
},
|
|
190
|
+
limit(count: number) {
|
|
191
|
+
return new QueryBuilder<M>(client).limit(count);
|
|
192
|
+
},
|
|
193
|
+
offset(count: number) {
|
|
194
|
+
return new QueryBuilder<M>(client).offset(count);
|
|
195
|
+
},
|
|
196
|
+
search(searchString: string) {
|
|
197
|
+
return new QueryBuilder<M>(client).search(searchString);
|
|
198
|
+
},
|
|
199
|
+
include(...relations: string[]) {
|
|
200
|
+
return new QueryBuilder<M>(client).include(...relations);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (ws) {
|
|
205
|
+
client.listen = (params: FindParams | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
|
|
206
|
+
return ws.listenCollection(
|
|
207
|
+
{
|
|
208
|
+
path: slug,
|
|
209
|
+
filter: parseWhereFilter(params?.where),
|
|
210
|
+
limit: params?.limit,
|
|
211
|
+
startAfter: params?.offset ? String(params.offset) : undefined,
|
|
212
|
+
orderBy: params?.orderBy?.split(":")[0],
|
|
213
|
+
order: params?.orderBy?.split(":")[1] as "asc" | "desc",
|
|
214
|
+
searchString: params?.searchString
|
|
215
|
+
},
|
|
216
|
+
(entities: Entity[]) => {
|
|
217
|
+
const requestedLimit = params?.limit || 20;
|
|
218
|
+
onUpdate({
|
|
219
|
+
data: entities as Entity<M>[],
|
|
220
|
+
meta: {
|
|
221
|
+
total: entities.length,
|
|
222
|
+
limit: requestedLimit,
|
|
223
|
+
offset: params?.offset || 0,
|
|
224
|
+
hasMore: entities.length >= requestedLimit
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
onError
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
client.listenById = (id: string | number, onUpdate: (data: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
|
|
233
|
+
return ws.listenEntity(
|
|
234
|
+
{ path: slug,
|
|
235
|
+
entityId: String(id) },
|
|
236
|
+
(entity: Entity | null) => {
|
|
237
|
+
if (entity) {
|
|
238
|
+
onUpdate(entity as Entity<M>);
|
|
239
|
+
} else {
|
|
240
|
+
onUpdate(undefined);
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
onError
|
|
244
|
+
);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return client;
|
|
249
|
+
}
|
package/src/cron.test.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach } from "@jest/globals";
|
|
2
|
+
import { createCron } from "./cron";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
|
|
5
|
+
// ─── Mock Transport ─────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function createMockTransport() {
|
|
8
|
+
const requestMock = jest.fn() as unknown as jest.MockedFunction<Transport["request"]>;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
request: requestMock,
|
|
12
|
+
setToken: jest.fn(),
|
|
13
|
+
setAuthTokenGetter: jest.fn(),
|
|
14
|
+
setOnUnauthorized: jest.fn(),
|
|
15
|
+
get baseUrl() { return "http://test"; },
|
|
16
|
+
get apiPath() { return "/api"; },
|
|
17
|
+
get fetchFn() { return globalThis.fetch; },
|
|
18
|
+
getHeaders: () => ({}),
|
|
19
|
+
resolveToken: async () => null
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Tests ──────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe("createCron", () => {
|
|
26
|
+
let transport: ReturnType<typeof createMockTransport>;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
transport = createMockTransport();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("listJobs", () => {
|
|
33
|
+
it("calls GET /cron", async () => {
|
|
34
|
+
transport.request.mockResolvedValue({ jobs: [] });
|
|
35
|
+
const cron = createCron(transport);
|
|
36
|
+
|
|
37
|
+
const result = await cron.listJobs();
|
|
38
|
+
|
|
39
|
+
expect(transport.request).toHaveBeenCalledWith("/cron", { method: "GET" });
|
|
40
|
+
expect(result).toEqual({ jobs: [] });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("getJob", () => {
|
|
45
|
+
it("calls GET /cron/:id with encoded ID", async () => {
|
|
46
|
+
transport.request.mockResolvedValue({ job: { id: "my-job" } });
|
|
47
|
+
const cron = createCron(transport);
|
|
48
|
+
|
|
49
|
+
await cron.getJob("my-job");
|
|
50
|
+
|
|
51
|
+
expect(transport.request).toHaveBeenCalledWith("/cron/my-job", { method: "GET" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("encodes special characters in job ID", async () => {
|
|
55
|
+
transport.request.mockResolvedValue({ job: {} });
|
|
56
|
+
const cron = createCron(transport);
|
|
57
|
+
|
|
58
|
+
await cron.getJob("job with spaces");
|
|
59
|
+
|
|
60
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
61
|
+
"/cron/job%20with%20spaces",
|
|
62
|
+
{ method: "GET" }
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("triggerJob", () => {
|
|
68
|
+
it("calls POST /cron/:id/trigger", async () => {
|
|
69
|
+
transport.request.mockResolvedValue({ log: {},
|
|
70
|
+
job: {} });
|
|
71
|
+
const cron = createCron(transport);
|
|
72
|
+
|
|
73
|
+
await cron.triggerJob("my-job");
|
|
74
|
+
|
|
75
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
76
|
+
"/cron/my-job/trigger",
|
|
77
|
+
{ method: "POST" }
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("getJobLogs", () => {
|
|
83
|
+
it("calls GET /cron/:id/logs without limit", async () => {
|
|
84
|
+
transport.request.mockResolvedValue({ logs: [] });
|
|
85
|
+
const cron = createCron(transport);
|
|
86
|
+
|
|
87
|
+
await cron.getJobLogs("my-job");
|
|
88
|
+
|
|
89
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
90
|
+
"/cron/my-job/logs",
|
|
91
|
+
{ method: "GET" }
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("appends limit query param when provided", async () => {
|
|
96
|
+
transport.request.mockResolvedValue({ logs: [] });
|
|
97
|
+
const cron = createCron(transport);
|
|
98
|
+
|
|
99
|
+
await cron.getJobLogs("my-job", { limit: 5 });
|
|
100
|
+
|
|
101
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
102
|
+
"/cron/my-job/logs?limit=5",
|
|
103
|
+
{ method: "GET" }
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("toggleJob", () => {
|
|
109
|
+
it("calls PUT /cron/:id with enabled=true", async () => {
|
|
110
|
+
transport.request.mockResolvedValue({ job: {} });
|
|
111
|
+
const cron = createCron(transport);
|
|
112
|
+
|
|
113
|
+
await cron.toggleJob("my-job", true);
|
|
114
|
+
|
|
115
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
116
|
+
"/cron/my-job",
|
|
117
|
+
{
|
|
118
|
+
method: "PUT",
|
|
119
|
+
body: JSON.stringify({ enabled: true })
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("calls PUT /cron/:id with enabled=false", async () => {
|
|
125
|
+
transport.request.mockResolvedValue({ job: {} });
|
|
126
|
+
const cron = createCron(transport);
|
|
127
|
+
|
|
128
|
+
await cron.toggleJob("my-job", false);
|
|
129
|
+
|
|
130
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
131
|
+
"/cron/my-job",
|
|
132
|
+
{
|
|
133
|
+
method: "PUT",
|
|
134
|
+
body: JSON.stringify({ enabled: false })
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("custom cronPath", () => {
|
|
141
|
+
it("uses custom path prefix", async () => {
|
|
142
|
+
transport.request.mockResolvedValue({ jobs: [] });
|
|
143
|
+
const cron = createCron(transport, { cronPath: "/admin/scheduled" });
|
|
144
|
+
|
|
145
|
+
await cron.listJobs();
|
|
146
|
+
|
|
147
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
148
|
+
"/admin/scheduled",
|
|
149
|
+
{ method: "GET" }
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("custom path applies to all methods", async () => {
|
|
154
|
+
transport.request.mockResolvedValue({});
|
|
155
|
+
const cron = createCron(transport, { cronPath: "/v2/cron" });
|
|
156
|
+
|
|
157
|
+
await cron.getJob("x");
|
|
158
|
+
expect(transport.request).toHaveBeenCalledWith("/v2/cron/x", { method: "GET" });
|
|
159
|
+
|
|
160
|
+
await cron.triggerJob("x");
|
|
161
|
+
expect(transport.request).toHaveBeenCalledWith("/v2/cron/x/trigger", { method: "POST" });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Transport } from "./transport";
|
|
2
|
+
import type { CronJobStatus, CronJobLogEntry } from "@rebasepro/types";
|
|
3
|
+
|
|
4
|
+
export interface CreateCronOptions {
|
|
5
|
+
cronPath?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createCron(transport: Transport, options?: CreateCronOptions) {
|
|
9
|
+
const cronPath = options?.cronPath || "/cron";
|
|
10
|
+
|
|
11
|
+
async function listJobs(): Promise<{ jobs: CronJobStatus[] }> {
|
|
12
|
+
return transport.request<{ jobs: CronJobStatus[] }>(cronPath, { method: "GET" });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function getJob(jobId: string): Promise<{ job: CronJobStatus }> {
|
|
16
|
+
return transport.request<{ job: CronJobStatus }>(
|
|
17
|
+
cronPath + "/" + encodeURIComponent(jobId),
|
|
18
|
+
{ method: "GET" }
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function triggerJob(jobId: string): Promise<{ log: CronJobLogEntry; job: CronJobStatus }> {
|
|
23
|
+
return transport.request<{ log: CronJobLogEntry; job: CronJobStatus }>(
|
|
24
|
+
cronPath + "/" + encodeURIComponent(jobId) + "/trigger",
|
|
25
|
+
{ method: "POST" }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function getJobLogs(
|
|
30
|
+
jobId: string,
|
|
31
|
+
options?: { limit?: number }
|
|
32
|
+
): Promise<{ logs: CronJobLogEntry[] }> {
|
|
33
|
+
const params = new URLSearchParams();
|
|
34
|
+
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
35
|
+
const qs = params.toString();
|
|
36
|
+
return transport.request<{ logs: CronJobLogEntry[] }>(
|
|
37
|
+
cronPath + "/" + encodeURIComponent(jobId) + "/logs" + (qs ? "?" + qs : ""),
|
|
38
|
+
{ method: "GET" }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function toggleJob(
|
|
43
|
+
jobId: string,
|
|
44
|
+
enabled: boolean
|
|
45
|
+
): Promise<{ job: CronJobStatus }> {
|
|
46
|
+
return transport.request<{ job: CronJobStatus }>(
|
|
47
|
+
cronPath + "/" + encodeURIComponent(jobId),
|
|
48
|
+
{
|
|
49
|
+
method: "PUT",
|
|
50
|
+
body: JSON.stringify({ enabled })
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
listJobs,
|
|
57
|
+
getJob,
|
|
58
|
+
triggerJob,
|
|
59
|
+
getJobLogs,
|
|
60
|
+
toggleJob
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createTransport, RebaseClientConfig } from "./transport";
|
|
2
|
+
import { createAuth, CreateAuthOptions } from "./auth";
|
|
3
|
+
import { createAdmin, CreateAdminOptions } from "./admin";
|
|
4
|
+
import { createCron, CreateCronOptions } from "./cron";
|
|
5
|
+
import { createCollectionClient, CollectionClient } from "./collection";
|
|
6
|
+
|
|
7
|
+
export * from "./transport";
|
|
8
|
+
export * from "./auth";
|
|
9
|
+
export * from "./admin";
|
|
10
|
+
export * from "./cron";
|
|
11
|
+
export * from "./collection";
|
|
12
|
+
export * from "./websocket";
|
|
13
|
+
export * from "./storage";
|
|
14
|
+
export * from "./reviver";
|
|
15
|
+
|
|
16
|
+
export interface CreateRebaseClientOptions extends RebaseClientConfig {
|
|
17
|
+
auth?: CreateAuthOptions;
|
|
18
|
+
admin?: CreateAdminOptions;
|
|
19
|
+
cron?: CreateCronOptions;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
import { RebaseWebSocketClient } from "./websocket";
|
|
23
|
+
import { RebaseClient as BaseRebaseClient, RebaseData, CollectionAccessor, StorageSource } from "@rebasepro/types";
|
|
24
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
25
|
+
|
|
26
|
+
export type RebaseClient<DB = Record<string, unknown>> = BaseRebaseClient<DB> & {
|
|
27
|
+
setToken: (token: string | null) => void;
|
|
28
|
+
setAuthTokenGetter: (getter: () => Promise<string | null>) => void;
|
|
29
|
+
setOnUnauthorized: (handler: () => Promise<boolean>) => void;
|
|
30
|
+
resolveToken: () => Promise<string | null>;
|
|
31
|
+
auth: ReturnType<typeof createAuth>;
|
|
32
|
+
admin: ReturnType<typeof createAdmin>;
|
|
33
|
+
cron: ReturnType<typeof createCron>;
|
|
34
|
+
ws?: RebaseWebSocketClient;
|
|
35
|
+
storage?: StorageSource;
|
|
36
|
+
call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
|
|
37
|
+
data: RebaseData & {
|
|
38
|
+
collection<K extends keyof DB>(slug: Extract<K, string>): CollectionClient<
|
|
39
|
+
DB[K] extends { Row: infer R extends Record<string, unknown> } ? R : Record<string, unknown>
|
|
40
|
+
>;
|
|
41
|
+
} & {
|
|
42
|
+
[K in keyof DB]: CollectionClient<
|
|
43
|
+
DB[K] extends { Row: infer R extends Record<string, unknown> } ? R : Record<string, unknown>
|
|
44
|
+
>;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
import { createStorage } from "./storage";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive a WebSocket URL from an HTTP base URL.
|
|
52
|
+
* `http://` → `ws://`, `https://` → `wss://`.
|
|
53
|
+
*/
|
|
54
|
+
function deriveWebSocketUrl(baseUrl?: string): string {
|
|
55
|
+
if (!baseUrl) {
|
|
56
|
+
// If no baseUrl is provided, we can try to derive it from the window object if in browser
|
|
57
|
+
if (typeof window !== "undefined") {
|
|
58
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
59
|
+
return `${protocol}//${window.location.host}`;
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
return baseUrl
|
|
64
|
+
.replace(/^https:\/\//, "wss://")
|
|
65
|
+
.replace(/^http:\/\//, "ws://")
|
|
66
|
+
.replace(/\/$/, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createRebaseClient<DB = Record<string, unknown>>(options: CreateRebaseClientOptions): RebaseClient<DB> {
|
|
70
|
+
const transport = createTransport(options);
|
|
71
|
+
const auth = createAuth(transport, options.auth);
|
|
72
|
+
const admin = createAdmin(transport, options.admin);
|
|
73
|
+
const cron = createCron(transport, options.cron);
|
|
74
|
+
const storage = createStorage(transport);
|
|
75
|
+
|
|
76
|
+
const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
|
|
77
|
+
|
|
78
|
+
let ws: RebaseWebSocketClient | undefined;
|
|
79
|
+
if (resolvedWsUrl) {
|
|
80
|
+
ws = new RebaseWebSocketClient({
|
|
81
|
+
websocketUrl: resolvedWsUrl,
|
|
82
|
+
getAuthToken: async () => {
|
|
83
|
+
const session = await auth.getSession();
|
|
84
|
+
return session?.accessToken || options.token || "";
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
auth.onAuthStateChange((event, session) => {
|
|
89
|
+
if (!ws) return;
|
|
90
|
+
if (event === "SIGNED_OUT") {
|
|
91
|
+
ws.disconnect();
|
|
92
|
+
} else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
93
|
+
if (session?.accessToken) {
|
|
94
|
+
ws.authenticate(session.accessToken).catch(console.warn);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Register transport callback for 401s after auth is instantiated.
|
|
101
|
+
// IMPORTANT: We must use transport.setOnUnauthorized() here — NOT set
|
|
102
|
+
// options.onUnauthorized — because the transport was already created above
|
|
103
|
+
// and captured the (undefined) value from the config closure.
|
|
104
|
+
if (!options.onUnauthorized) {
|
|
105
|
+
transport.setOnUnauthorized(async () => {
|
|
106
|
+
try {
|
|
107
|
+
await auth.refreshSession();
|
|
108
|
+
return true;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const collectionClients = new Map<string, CollectionClient<Record<string, unknown>>>();
|
|
116
|
+
|
|
117
|
+
function collection(slug: string): CollectionClient<Record<string, unknown>> {
|
|
118
|
+
if (!collectionClients.has(slug)) {
|
|
119
|
+
collectionClients.set(slug, createCollectionClient(transport, slug, ws));
|
|
120
|
+
}
|
|
121
|
+
return collectionClients.get(slug)!;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const dataTarget = { collection } as Record<string, unknown>;
|
|
125
|
+
|
|
126
|
+
const dataProxy = new Proxy(dataTarget, {
|
|
127
|
+
get(_target, prop: string | symbol) {
|
|
128
|
+
if (prop === "collection") {
|
|
129
|
+
return collection;
|
|
130
|
+
}
|
|
131
|
+
if (typeof prop === "symbol") return undefined;
|
|
132
|
+
if (typeof prop === "string" && prop !== "then" && prop !== "toJSON" && prop !== "$$typeof") {
|
|
133
|
+
// Convert camelCase property names to snake_case slugs.
|
|
134
|
+
// e.g. `companyMembers` → `company_members`
|
|
135
|
+
const slug = toSnakeCase(prop);
|
|
136
|
+
return collection(slug);
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const target = {
|
|
143
|
+
auth,
|
|
144
|
+
admin,
|
|
145
|
+
cron,
|
|
146
|
+
storage,
|
|
147
|
+
ws,
|
|
148
|
+
setToken: transport.setToken,
|
|
149
|
+
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
150
|
+
setOnUnauthorized: transport.setOnUnauthorized,
|
|
151
|
+
resolveToken: transport.resolveToken,
|
|
152
|
+
baseUrl: transport.baseUrl,
|
|
153
|
+
collection,
|
|
154
|
+
call: async <T = unknown>(endpoint: string, payload?: unknown): Promise<T> => {
|
|
155
|
+
const prefix = endpoint.startsWith("/") ? "" : "/";
|
|
156
|
+
const res = await transport.request<{ data: T }>(`${prefix}${endpoint}`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
body: payload ? JSON.stringify(payload) : undefined
|
|
159
|
+
});
|
|
160
|
+
return res.data ?? (res as unknown as T);
|
|
161
|
+
},
|
|
162
|
+
data: dataProxy,
|
|
163
|
+
email: undefined
|
|
164
|
+
} as unknown as RebaseClient<DB>;
|
|
165
|
+
|
|
166
|
+
return target;
|
|
167
|
+
}
|