@rebasepro/client 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +10 -10
- package/dist/api-keys.d.ts +1 -0
- 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 +41 -21
- package/dist/index.es.js +605 -368
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +616 -379
- package/dist/index.umd.js.map +1 -1
- package/dist/sdk_query_builder.d.ts +63 -0
- package/dist/storage-registry.d.ts +42 -0
- package/dist/storage.d.ts +9 -1
- package/dist/transport.d.ts +2 -6
- package/dist/websocket.d.ts +25 -21
- package/package.json +13 -12
- package/src/api-keys.ts +1 -0
- package/src/auth.ts +188 -64
- package/src/collection.test.ts +94 -5
- package/src/collection.ts +103 -184
- package/src/data-proxy.test.ts +167 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +218 -24
- package/src/reviver.ts +2 -2
- package/src/sdk_query_builder.ts +138 -0
- package/src/storage-registry.ts +102 -0
- package/src/storage.ts +92 -50
- package/src/transport.ts +31 -105
- package/src/websocket.ts +133 -135
package/src/collection.ts
CHANGED
|
@@ -1,162 +1,31 @@
|
|
|
1
1
|
import { buildQueryString, FindParams, RebaseApiError, Transport } from "./transport";
|
|
2
2
|
import { RebaseWebSocketClient } from "./websocket";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
Entity,
|
|
6
|
-
FilterOperator,
|
|
7
|
-
FilterValues,
|
|
8
|
-
FindResponse,
|
|
9
|
-
WhereFieldValue,
|
|
10
|
-
WhereFilterOp,
|
|
4
|
+
FindResult,
|
|
11
5
|
LogicalCondition,
|
|
6
|
+
SDKCollectionClient,
|
|
7
|
+
SDKQueryBuilderInterface,
|
|
8
|
+
WhereFilterOp,
|
|
12
9
|
WhereValue
|
|
13
10
|
} from "@rebasepro/types";
|
|
14
11
|
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
function parseWhereFilter(where?: Record<string, WhereFieldValue>): FilterValues<string> | undefined {
|
|
18
|
-
if (!where) return undefined;
|
|
19
|
-
const filters: Record<string, any> = {};
|
|
20
|
-
|
|
21
|
-
const OP_TO_FILTER: Record<string, WhereFilterOp> = {
|
|
22
|
-
"eq": "==",
|
|
23
|
-
"neq": "!=",
|
|
24
|
-
"gt": ">",
|
|
25
|
-
"gte": ">=",
|
|
26
|
-
"lt": "<",
|
|
27
|
-
"lte": "<=",
|
|
28
|
-
"==": "==",
|
|
29
|
-
"!=": "!=",
|
|
30
|
-
">": ">",
|
|
31
|
-
">=": ">=",
|
|
32
|
-
"<": "<",
|
|
33
|
-
"<=": "<=",
|
|
34
|
-
"in": "in",
|
|
35
|
-
"nin": "not-in",
|
|
36
|
-
"not-in": "not-in",
|
|
37
|
-
"cs": "array-contains",
|
|
38
|
-
"csa": "array-contains-any",
|
|
39
|
-
"array-contains": "array-contains",
|
|
40
|
-
"array-contains-any": "array-contains-any"
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const parseSingle = (rawValue: any, fieldKey: string): [WhereFilterOp, unknown] => {
|
|
44
|
-
if (rawValue === null) return ["==", null];
|
|
45
|
-
if (typeof rawValue === "boolean") return ["==", rawValue];
|
|
46
|
-
if (typeof rawValue === "number") return ["==", rawValue];
|
|
47
|
-
|
|
48
|
-
if (Array.isArray(rawValue) && rawValue.length === 2 && typeof rawValue[0] === "string") {
|
|
49
|
-
const [rawOp, val] = rawValue;
|
|
50
|
-
return [OP_TO_FILTER[rawOp] ?? "==", val];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const value = String(rawValue);
|
|
54
|
-
const dotIndex = value.indexOf(".");
|
|
55
|
-
if (dotIndex > 0) {
|
|
56
|
-
const opStr = value.substring(0, dotIndex);
|
|
57
|
-
const valStr = value.substring(dotIndex + 1);
|
|
58
|
-
let op: WhereFilterOp = "==";
|
|
59
|
-
let val: string | number | boolean | null | string[] = valStr;
|
|
60
|
-
|
|
61
|
-
switch (opStr) {
|
|
62
|
-
case "eq":
|
|
63
|
-
op = "==";
|
|
64
|
-
break;
|
|
65
|
-
case "neq":
|
|
66
|
-
op = "!=";
|
|
67
|
-
break;
|
|
68
|
-
case "gt":
|
|
69
|
-
op = ">";
|
|
70
|
-
break;
|
|
71
|
-
case "gte":
|
|
72
|
-
op = ">=";
|
|
73
|
-
break;
|
|
74
|
-
case "lt":
|
|
75
|
-
op = "<";
|
|
76
|
-
break;
|
|
77
|
-
case "lte":
|
|
78
|
-
op = "<=";
|
|
79
|
-
break;
|
|
80
|
-
case "in":
|
|
81
|
-
op = "in";
|
|
82
|
-
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
83
|
-
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
84
|
-
: valStr.split(",");
|
|
85
|
-
break;
|
|
86
|
-
case "nin":
|
|
87
|
-
op = "not-in";
|
|
88
|
-
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
89
|
-
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
90
|
-
: valStr.split(",");
|
|
91
|
-
break;
|
|
92
|
-
case "cs":
|
|
93
|
-
op = "array-contains";
|
|
94
|
-
break;
|
|
95
|
-
case "csa":
|
|
96
|
-
op = "array-contains-any";
|
|
97
|
-
val = valStr.startsWith("(") && valStr.endsWith(")")
|
|
98
|
-
? valStr.slice(1, -1).split(",").map(v => v.trim())
|
|
99
|
-
: valStr.split(",");
|
|
100
|
-
break;
|
|
101
|
-
default:
|
|
102
|
-
op = "==";
|
|
103
|
-
val = value;
|
|
104
|
-
}
|
|
105
|
-
if (val === "true") val = true;
|
|
106
|
-
else if (val === "false") val = false;
|
|
107
|
-
else if (val === "null") val = null;
|
|
108
|
-
else if (typeof val === "string" && /^[0-9]+(\.[0-9]+)?$/.test(val) && fieldKey !== "id" && !fieldKey.endsWith("_id")) val = Number(val);
|
|
109
|
-
|
|
110
|
-
return [op, val];
|
|
111
|
-
} else {
|
|
112
|
-
return ["==", value];
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
for (const [key, rawValue] of Object.entries(where)) {
|
|
117
|
-
if (Array.isArray(rawValue) && rawValue.length > 0 && Array.isArray(rawValue[0])) {
|
|
118
|
-
filters[key] = rawValue.map(r => parseSingle(r, key));
|
|
119
|
-
} else {
|
|
120
|
-
filters[key] = parseSingle(rawValue, key);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return filters;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Wrap a flat row (returned by the REST API as `{ id, ...fields }`) into
|
|
128
|
-
* a proper `Entity<M>` structure expected by the core framework.
|
|
129
|
-
* The `id` is kept inside `values` as well, since collection properties
|
|
130
|
-
* may define an `isId` field that the form binds to `formex.values`.
|
|
131
|
-
*/
|
|
132
|
-
function rowToEntity<M extends Record<string, unknown>>(row: Record<string, unknown>, slug: string): Entity<M> {
|
|
133
|
-
return {
|
|
134
|
-
id: row.id as string | number,
|
|
135
|
-
path: slug,
|
|
136
|
-
values: row as M
|
|
137
|
-
};
|
|
138
|
-
}
|
|
12
|
+
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
139
13
|
|
|
140
14
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
15
|
+
* The concrete, HTTP-backed implementation of the public
|
|
16
|
+
* {@link SDKCollectionClient} contract — flat rows (no Entity wrapper), plus
|
|
17
|
+
* fluent query-builder methods (`.where()`, `.orderBy()`, …).
|
|
143
18
|
*
|
|
144
|
-
*
|
|
19
|
+
* This is what `createRebaseClient().data.<collection>` returns. It is not a
|
|
20
|
+
* separate API from {@link SDKCollectionClient}; it only widens it with
|
|
21
|
+
* `count()`. Program against {@link SDKCollectionClient} when you want a
|
|
22
|
+
* transport-agnostic type.
|
|
145
23
|
*/
|
|
146
|
-
export interface CollectionClient<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
limit(count: number): QueryBuilder<M>;
|
|
153
|
-
|
|
154
|
-
offset(count: number): QueryBuilder<M>;
|
|
155
|
-
|
|
156
|
-
search(searchString: string): QueryBuilder<M>;
|
|
157
|
-
|
|
158
|
-
include(...relations: string[]): QueryBuilder<M>;
|
|
159
|
-
|
|
24
|
+
export interface CollectionClient<
|
|
25
|
+
M extends Record<string, unknown> = Record<string, unknown>,
|
|
26
|
+
I = Partial<M>,
|
|
27
|
+
U = Partial<M>
|
|
28
|
+
> extends SDKCollectionClient<M, I, U> {
|
|
160
29
|
count(params?: FindParams): Promise<number>;
|
|
161
30
|
}
|
|
162
31
|
|
|
@@ -164,14 +33,14 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
164
33
|
const basePath = `/data/${slug}`;
|
|
165
34
|
|
|
166
35
|
const client: CollectionClient<M> = {
|
|
167
|
-
async find(params?: FindParams): Promise<
|
|
36
|
+
async find(params?: FindParams): Promise<FindResult<M>> {
|
|
168
37
|
const qs = buildQueryString(params);
|
|
169
38
|
const raw = await transport.request<{
|
|
170
39
|
data: Record<string, unknown>[];
|
|
171
|
-
meta:
|
|
40
|
+
meta: FindResult<M>["meta"]
|
|
172
41
|
}>(basePath + qs, { method: "GET" });
|
|
173
42
|
return {
|
|
174
|
-
data: (raw.data || [])
|
|
43
|
+
data: (raw.data || []) as M[],
|
|
175
44
|
meta: raw.meta
|
|
176
45
|
};
|
|
177
46
|
},
|
|
@@ -180,7 +49,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
180
49
|
try {
|
|
181
50
|
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
|
|
182
51
|
if (!raw) return undefined;
|
|
183
|
-
return
|
|
52
|
+
return raw as M;
|
|
184
53
|
} catch (err) {
|
|
185
54
|
if (err instanceof RebaseApiError && err.status === 404) {
|
|
186
55
|
return undefined;
|
|
@@ -198,7 +67,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
198
67
|
method: "POST",
|
|
199
68
|
body: JSON.stringify(body)
|
|
200
69
|
});
|
|
201
|
-
return
|
|
70
|
+
return raw as M;
|
|
202
71
|
},
|
|
203
72
|
|
|
204
73
|
async update(id: string | number, data: Partial<M>) {
|
|
@@ -206,11 +75,11 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
206
75
|
method: "PUT",
|
|
207
76
|
body: JSON.stringify(data)
|
|
208
77
|
});
|
|
209
|
-
return
|
|
78
|
+
return raw as M;
|
|
210
79
|
},
|
|
211
80
|
|
|
212
81
|
async delete(id: string | number) {
|
|
213
|
-
|
|
82
|
+
await transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
214
83
|
method: "DELETE"
|
|
215
84
|
});
|
|
216
85
|
},
|
|
@@ -227,67 +96,117 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
227
96
|
},
|
|
228
97
|
|
|
229
98
|
// Fluent builder instantiation
|
|
230
|
-
where(columnOrCondition: string | LogicalCondition, operator?:
|
|
231
|
-
const builder = new
|
|
99
|
+
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
|
|
100
|
+
const builder = new SDKQueryBuilder<M>(client);
|
|
232
101
|
if (typeof columnOrCondition === "object") {
|
|
233
102
|
return builder.where(columnOrCondition);
|
|
234
103
|
}
|
|
235
104
|
return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
|
|
236
105
|
},
|
|
237
|
-
orderBy(column: keyof M & string,
|
|
238
|
-
return new
|
|
106
|
+
orderBy(column: keyof M & string, direction?: "asc" | "desc") {
|
|
107
|
+
return new SDKQueryBuilder<M>(client).orderBy(column, direction);
|
|
239
108
|
},
|
|
240
109
|
limit(count: number) {
|
|
241
|
-
return new
|
|
110
|
+
return new SDKQueryBuilder<M>(client).limit(count);
|
|
242
111
|
},
|
|
243
112
|
offset(count: number) {
|
|
244
|
-
return new
|
|
113
|
+
return new SDKQueryBuilder<M>(client).offset(count);
|
|
245
114
|
},
|
|
246
115
|
search(searchString: string) {
|
|
247
|
-
return new
|
|
116
|
+
return new SDKQueryBuilder<M>(client).search(searchString);
|
|
248
117
|
},
|
|
249
118
|
include(...relations: string[]) {
|
|
250
|
-
return new
|
|
119
|
+
return new SDKQueryBuilder<M>(client).include(...relations);
|
|
251
120
|
}
|
|
252
121
|
};
|
|
253
122
|
|
|
254
123
|
if (ws) {
|
|
255
|
-
client.listen = (params: FindParams | undefined, onUpdate: (response:
|
|
256
|
-
|
|
124
|
+
client.listen = (params: FindParams | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
|
|
125
|
+
let active = true;
|
|
126
|
+
let lastUpdateId = 0;
|
|
127
|
+
const unsub = ws.listenCollection(
|
|
257
128
|
{
|
|
258
129
|
path: slug,
|
|
259
|
-
filter:
|
|
130
|
+
filter: params?.where,
|
|
260
131
|
limit: params?.limit,
|
|
261
132
|
startAfter: params?.offset ? String(params.offset) : undefined,
|
|
262
|
-
orderBy: params?.orderBy?.
|
|
263
|
-
order: params?.orderBy?.
|
|
133
|
+
orderBy: params?.orderBy?.[0],
|
|
134
|
+
order: params?.orderBy?.[1],
|
|
264
135
|
searchString: params?.searchString
|
|
265
136
|
},
|
|
266
|
-
(
|
|
137
|
+
(incomingRows: Record<string, unknown>[]) => {
|
|
138
|
+
const currentUpdateId = ++lastUpdateId;
|
|
267
139
|
const requestedLimit = params?.limit || 20;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
140
|
+
const offset = params?.offset || 0;
|
|
141
|
+
|
|
142
|
+
// WS client already delivers flat rows — just cast
|
|
143
|
+
const rows = incomingRows as M[];
|
|
144
|
+
|
|
145
|
+
// Heuristic metadata (used as fallback if count call fails)
|
|
146
|
+
const heuristicTotal = rows.length;
|
|
147
|
+
const heuristicHasMore = rows.length >= requestedLimit;
|
|
148
|
+
|
|
149
|
+
// Try to get authoritative count; fall back to heuristic
|
|
150
|
+
if (client.count) {
|
|
151
|
+
client.count(params)
|
|
152
|
+
.then((total) => {
|
|
153
|
+
if (active && currentUpdateId === lastUpdateId) {
|
|
154
|
+
onUpdate({
|
|
155
|
+
data: rows,
|
|
156
|
+
meta: {
|
|
157
|
+
total,
|
|
158
|
+
limit: requestedLimit,
|
|
159
|
+
offset,
|
|
160
|
+
hasMore: offset + rows.length < total
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
.catch(() => {
|
|
166
|
+
// Count failed — use heuristic meta
|
|
167
|
+
if (active && currentUpdateId === lastUpdateId) {
|
|
168
|
+
onUpdate({
|
|
169
|
+
data: rows,
|
|
170
|
+
meta: {
|
|
171
|
+
total: heuristicTotal,
|
|
172
|
+
limit: requestedLimit,
|
|
173
|
+
offset,
|
|
174
|
+
hasMore: heuristicHasMore
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
// No count method — fire immediately with heuristic meta
|
|
181
|
+
onUpdate({
|
|
182
|
+
data: rows,
|
|
183
|
+
meta: {
|
|
184
|
+
total: heuristicTotal,
|
|
185
|
+
limit: requestedLimit,
|
|
186
|
+
offset,
|
|
187
|
+
hasMore: heuristicHasMore
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
277
191
|
},
|
|
278
192
|
onError
|
|
279
193
|
);
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
active = false;
|
|
197
|
+
unsub();
|
|
198
|
+
};
|
|
280
199
|
};
|
|
281
200
|
|
|
282
|
-
client.listenById = (id: string | number, onUpdate: (data:
|
|
283
|
-
return ws.
|
|
201
|
+
client.listenById = (id: string | number, onUpdate: (data: M | undefined) => void, onError?: (error: Error) => void) => {
|
|
202
|
+
return ws.listenOne(
|
|
284
203
|
{
|
|
285
204
|
path: slug,
|
|
286
|
-
|
|
205
|
+
id: String(id)
|
|
287
206
|
},
|
|
288
|
-
(
|
|
289
|
-
if (
|
|
290
|
-
onUpdate(
|
|
207
|
+
(row: Record<string, unknown> | null) => {
|
|
208
|
+
if (row) {
|
|
209
|
+
onUpdate(row as M);
|
|
291
210
|
} else {
|
|
292
211
|
onUpdate(undefined);
|
|
293
212
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { createRebaseClient, RebaseClientError } from "./index";
|
|
3
|
+
|
|
4
|
+
// Minimal mock: we only need the proxy behavior, not real HTTP calls.
|
|
5
|
+
// The global fetch mock prevents the transport from making real requests.
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
globalThis.fetch = jest.fn<typeof fetch>().mockResolvedValue(new Response("{}")) as typeof fetch;
|
|
9
|
+
});
|
|
10
|
+
afterAll(() => {
|
|
11
|
+
globalThis.fetch = originalFetch;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const COLLECTIONS = {
|
|
15
|
+
products: "products",
|
|
16
|
+
orders: "orders",
|
|
17
|
+
blogPosts: "blog-posts",
|
|
18
|
+
companyMembers: "company_members",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
describe("client.data proxy — strict mode (collections dictionary provided)", () => {
|
|
22
|
+
const client = createRebaseClient({
|
|
23
|
+
baseUrl: "http://localhost:3000",
|
|
24
|
+
collections: { ...COLLECTIONS },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("resolves a known key to a CollectionClient", () => {
|
|
28
|
+
const accessor = client.data.collection("products");
|
|
29
|
+
expect(accessor).toBeDefined();
|
|
30
|
+
expect(typeof accessor.find).toBe("function");
|
|
31
|
+
expect(typeof accessor.findById).toBe("function");
|
|
32
|
+
expect(typeof accessor.create).toBe("function");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("resolves a known camelCase key (blogPosts) via dictionary", () => {
|
|
36
|
+
// Should not throw — blogPosts is in the dictionary
|
|
37
|
+
const products = (client.data as Record<string, unknown>)["products"];
|
|
38
|
+
expect(products).toBeDefined();
|
|
39
|
+
const blogPosts = (client.data as Record<string, unknown>)["blogPosts"];
|
|
40
|
+
expect(blogPosts).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("throws RebaseClientError on unknown key with suggestion", () => {
|
|
44
|
+
const access = () => (client.data as Record<string, unknown>)["prodcuts"];
|
|
45
|
+
expect(access).toThrow(RebaseClientError);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
access();
|
|
49
|
+
} catch (e) {
|
|
50
|
+
expect(e).toBeInstanceOf(RebaseClientError);
|
|
51
|
+
expect((e as RebaseClientError).message).toContain("prodcuts");
|
|
52
|
+
expect((e as RebaseClientError).message).toContain("products");
|
|
53
|
+
expect((e as RebaseClientError).message).toContain("Did you mean");
|
|
54
|
+
expect((e as RebaseClientError).message).toContain("data.collection");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws RebaseClientError on unknown key without a close match", () => {
|
|
59
|
+
const access = () => (client.data as Record<string, unknown>)["zzz"];
|
|
60
|
+
expect(access).toThrow(RebaseClientError);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
access();
|
|
64
|
+
} catch (e) {
|
|
65
|
+
expect(e).toBeInstanceOf(RebaseClientError);
|
|
66
|
+
expect((e as RebaseClientError).message).toContain("zzz");
|
|
67
|
+
expect((e as RebaseClientError).message).not.toContain("Did you mean");
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("lists all known collections in the error message", () => {
|
|
72
|
+
const access = () => (client.data as Record<string, unknown>)["nonexistent"];
|
|
73
|
+
try {
|
|
74
|
+
access();
|
|
75
|
+
} catch (e) {
|
|
76
|
+
const msg = (e as RebaseClientError).message;
|
|
77
|
+
expect(msg).toContain("products");
|
|
78
|
+
expect(msg).toContain("orders");
|
|
79
|
+
expect(msg).toContain("blogPosts");
|
|
80
|
+
expect(msg).toContain("companyMembers");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns undefined for symbol access", () => {
|
|
85
|
+
expect((client.data as Record<symbol, unknown>)[Symbol.toPrimitive]).toBeUndefined();
|
|
86
|
+
expect((client.data as Record<symbol, unknown>)[Symbol.iterator]).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns undefined for 'then' (Promise resolution safe)", () => {
|
|
90
|
+
expect((client.data as Record<string, unknown>)["then"]).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns undefined for 'toJSON'", () => {
|
|
94
|
+
expect((client.data as Record<string, unknown>)["toJSON"]).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns undefined for '$$typeof' (React introspection safe)", () => {
|
|
98
|
+
expect((client.data as Record<string, unknown>)["$$typeof"]).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not throw when awaited in a Promise.resolve context", async () => {
|
|
102
|
+
// `await obj` checks obj.then — which must return undefined, not throw.
|
|
103
|
+
const result = await Promise.resolve(client.data as Record<string, unknown>);
|
|
104
|
+
expect(result).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("collection('anything') never throws — dynamic escape hatch", () => {
|
|
108
|
+
expect(() => client.data.collection("nonexistent")).not.toThrow();
|
|
109
|
+
expect(() => client.data.collection("totally-made-up")).not.toThrow();
|
|
110
|
+
const col = client.data.collection("dynamic-slug");
|
|
111
|
+
expect(col).toBeDefined();
|
|
112
|
+
expect(typeof col.find).toBe("function");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("client.data proxy — untyped mode (no collections dictionary)", () => {
|
|
117
|
+
const client = createRebaseClient({
|
|
118
|
+
baseUrl: "http://localhost:3000",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("falls back to snake_case conversion without a dictionary", () => {
|
|
122
|
+
// Should not throw for any property name
|
|
123
|
+
expect(() => (client.data as Record<string, unknown>)["blogPosts"]).not.toThrow();
|
|
124
|
+
expect(() => (client.data as Record<string, unknown>)["anyRandomProp"]).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns a CollectionClient for any camelCase property", () => {
|
|
128
|
+
const accessor = (client.data as Record<string, unknown>)["blogPosts"];
|
|
129
|
+
expect(accessor).toBeDefined();
|
|
130
|
+
expect(typeof (accessor as Record<string, unknown>).find).toBe("function");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns undefined for symbols, then, toJSON, $$typeof", () => {
|
|
134
|
+
expect((client.data as Record<symbol, unknown>)[Symbol.toPrimitive]).toBeUndefined();
|
|
135
|
+
expect((client.data as Record<string, unknown>)["then"]).toBeUndefined();
|
|
136
|
+
expect((client.data as Record<string, unknown>)["toJSON"]).toBeUndefined();
|
|
137
|
+
expect((client.data as Record<string, unknown>)["$$typeof"]).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("collection() still works without a dictionary", () => {
|
|
141
|
+
expect(() => client.data.collection("anything")).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("emits a one-shot console.warn on first untyped access", () => {
|
|
145
|
+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
146
|
+
try {
|
|
147
|
+
// Create a fresh client so the warning flag is reset
|
|
148
|
+
const freshClient = createRebaseClient({
|
|
149
|
+
baseUrl: "http://localhost:3000",
|
|
150
|
+
});
|
|
151
|
+
const readFirst = () => (freshClient.data as Record<string, unknown>)["firstAccess"];
|
|
152
|
+
readFirst();
|
|
153
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
155
|
+
expect.stringContaining("[Rebase] Untyped data access detected")
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Second access should NOT warn again
|
|
159
|
+
warnSpy.mockClear();
|
|
160
|
+
const readSecond = () => (freshClient.data as Record<string, unknown>)["secondAccess"];
|
|
161
|
+
readSecond();
|
|
162
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
163
|
+
} finally {
|
|
164
|
+
warnSpy.mockRestore();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side logic error (e.g. accessing an unknown collection when a typed
|
|
3
|
+
* dictionary is available). A subclass of {@link RebaseApiError}, so a single
|
|
4
|
+
* `catch (e) { if (e instanceof RebaseApiError) ... }` covers it too.
|
|
5
|
+
*
|
|
6
|
+
* The canonical definition now lives in `@rebasepro/types`; re-exported here to
|
|
7
|
+
* preserve the historical `import { RebaseClientError } from ".../errors"` path.
|
|
8
|
+
*/
|
|
9
|
+
export { RebaseClientError } from "@rebasepro/types";
|