@rebasepro/client 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/auth.d.ts +35 -38
- package/dist/collection.d.ts +9 -13
- package/dist/data-proxy.test.d.ts +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/index.d.ts +23 -22
- package/dist/index.es.js +485 -215
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +499 -230
- package/dist/index.umd.js.map +1 -1
- package/dist/sdk_query_builder.d.ts +63 -0
- package/dist/transport.d.ts +2 -6
- package/dist/websocket.d.ts +25 -21
- package/package.json +4 -4
- package/src/auth.ts +188 -64
- package/src/collection.test.ts +92 -3
- package/src/collection.ts +72 -75
- package/src/data-proxy.test.ts +167 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +146 -27
- package/src/reviver.ts +2 -2
- package/src/sdk_query_builder.ts +138 -0
- package/src/storage.ts +64 -32
- package/src/transport.ts +25 -25
- package/src/websocket.ts +133 -135
package/src/collection.test.ts
CHANGED
|
@@ -60,7 +60,7 @@ describe("createCollectionClient", () => {
|
|
|
60
60
|
|
|
61
61
|
const client = createCollectionClient(transport, "items");
|
|
62
62
|
const result = await client.count({
|
|
63
|
-
orderBy: "created_at
|
|
63
|
+
orderBy: ["created_at", "desc"]
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
|
|
@@ -123,7 +123,7 @@ offset: 10 });
|
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
describe("find()", () => {
|
|
126
|
-
it("should call the list endpoint and return
|
|
126
|
+
it("should call the list endpoint and return flat rows", async () => {
|
|
127
127
|
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({
|
|
128
128
|
data: [{ id: "1",
|
|
129
129
|
name: "Product A" }],
|
|
@@ -141,12 +141,101 @@ hasMore: false }
|
|
|
141
141
|
{ method: "GET" }
|
|
142
142
|
);
|
|
143
143
|
expect(result.data).toHaveLength(1);
|
|
144
|
+
// Flat row access — no .values wrapper
|
|
144
145
|
expect(result.data[0].id).toBe("1");
|
|
145
|
-
expect(result.data[0].
|
|
146
|
+
expect((result.data[0] as Record<string, unknown>).name).toBe("Product A");
|
|
147
|
+
// No path field — that's CMS leakage
|
|
148
|
+
expect((result.data[0] as Record<string, unknown>).path).toBeUndefined();
|
|
146
149
|
expect(result.meta.total).toBe(1);
|
|
147
150
|
});
|
|
148
151
|
});
|
|
149
152
|
|
|
153
|
+
describe("findById()", () => {
|
|
154
|
+
it("should return a flat row directly", async () => {
|
|
155
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "42", title: "Hello" });
|
|
156
|
+
|
|
157
|
+
const client = createCollectionClient(transport, "posts");
|
|
158
|
+
const result = await client.findById("42");
|
|
159
|
+
|
|
160
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
161
|
+
"/data/posts/42",
|
|
162
|
+
{ method: "GET" }
|
|
163
|
+
);
|
|
164
|
+
expect(result).toBeDefined();
|
|
165
|
+
expect(result!.id).toBe("42");
|
|
166
|
+
expect((result as Record<string, unknown>).title).toBe("Hello");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should return undefined for 404", async () => {
|
|
170
|
+
const { RebaseApiError } = await import("./transport");
|
|
171
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
|
|
172
|
+
new RebaseApiError("Not Found", { status: 404 })
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const client = createCollectionClient(transport, "posts");
|
|
176
|
+
const result = await client.findById("999");
|
|
177
|
+
expect(result).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("create()", () => {
|
|
182
|
+
it("should return a flat row", async () => {
|
|
183
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "new-1", title: "Created" });
|
|
184
|
+
|
|
185
|
+
const client = createCollectionClient(transport, "posts");
|
|
186
|
+
const result = await client.create({ title: "Created" } as Record<string, unknown>);
|
|
187
|
+
|
|
188
|
+
expect(result.id).toBe("new-1");
|
|
189
|
+
expect((result as Record<string, unknown>).title).toBe("Created");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("update()", () => {
|
|
194
|
+
it("should return a flat row", async () => {
|
|
195
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "1", title: "Updated" });
|
|
196
|
+
|
|
197
|
+
const client = createCollectionClient(transport, "posts");
|
|
198
|
+
const result = await client.update("1", { title: "Updated" } as Record<string, unknown>);
|
|
199
|
+
|
|
200
|
+
expect(result.id).toBe("1");
|
|
201
|
+
expect((result as Record<string, unknown>).title).toBe("Updated");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should throw RebaseApiError for 404", async () => {
|
|
205
|
+
const { RebaseApiError } = await import("./transport");
|
|
206
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
|
|
207
|
+
new RebaseApiError("Not Found", { status: 404 })
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const client = createCollectionClient(transport, "posts");
|
|
211
|
+
await expect(
|
|
212
|
+
client.update("999", { title: "Nope" } as Record<string, unknown>)
|
|
213
|
+
).rejects.toMatchObject({ status: 404 });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("delete()", () => {
|
|
218
|
+
it("should throw RebaseApiError for 404", async () => {
|
|
219
|
+
const { RebaseApiError } = await import("./transport");
|
|
220
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
|
|
221
|
+
new RebaseApiError("Not Found", { status: 404 })
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const client = createCollectionClient(transport, "posts");
|
|
225
|
+
await expect(client.delete("999")).rejects.toMatchObject({ status: 404 });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should propagate non-404 errors", async () => {
|
|
229
|
+
const { RebaseApiError } = await import("./transport");
|
|
230
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
|
|
231
|
+
new RebaseApiError("Internal Server Error", { status: 500 })
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const client = createCollectionClient(transport, "posts");
|
|
235
|
+
await expect(client.delete("1")).rejects.toThrow("Internal Server Error");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
150
239
|
describe("count() is defined", () => {
|
|
151
240
|
it("should have count as a defined function on the accessor", () => {
|
|
152
241
|
const client = createCollectionClient(transport, "products");
|
package/src/collection.ts
CHANGED
|
@@ -1,52 +1,31 @@
|
|
|
1
1
|
import { buildQueryString, FindParams, RebaseApiError, Transport } from "./transport";
|
|
2
2
|
import { RebaseWebSocketClient } from "./websocket";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
Entity,
|
|
6
|
-
FilterValues,
|
|
7
|
-
FindResponse,
|
|
4
|
+
FindResult,
|
|
8
5
|
LogicalCondition,
|
|
6
|
+
SDKCollectionClient,
|
|
7
|
+
SDKQueryBuilderInterface,
|
|
9
8
|
WhereFilterOp,
|
|
10
9
|
WhereValue
|
|
11
10
|
} from "@rebasepro/types";
|
|
12
11
|
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Wrap a flat row (returned by the REST API as `{ id, ...fields }`) into
|
|
18
|
-
* a proper `Entity<M>` structure expected by the core framework.
|
|
19
|
-
* The `id` is kept inside `values` as well, since collection properties
|
|
20
|
-
* may define an `isId` field that the form binds to `formex.values`.
|
|
21
|
-
*/
|
|
22
|
-
function rowToEntity<M extends Record<string, unknown>>(row: Record<string, unknown>, slug: string): Entity<M> {
|
|
23
|
-
return {
|
|
24
|
-
id: row.id as string | number,
|
|
25
|
-
path: slug,
|
|
26
|
-
values: row as M
|
|
27
|
-
};
|
|
28
|
-
}
|
|
12
|
+
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
29
13
|
|
|
30
14
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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()`, …).
|
|
33
18
|
*
|
|
34
|
-
*
|
|
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.
|
|
35
23
|
*/
|
|
36
|
-
export interface CollectionClient<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
limit(count: number): QueryBuilder<M>;
|
|
43
|
-
|
|
44
|
-
offset(count: number): QueryBuilder<M>;
|
|
45
|
-
|
|
46
|
-
search(searchString: string): QueryBuilder<M>;
|
|
47
|
-
|
|
48
|
-
include(...relations: string[]): QueryBuilder<M>;
|
|
49
|
-
|
|
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> {
|
|
50
29
|
count(params?: FindParams): Promise<number>;
|
|
51
30
|
}
|
|
52
31
|
|
|
@@ -54,14 +33,14 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
54
33
|
const basePath = `/data/${slug}`;
|
|
55
34
|
|
|
56
35
|
const client: CollectionClient<M> = {
|
|
57
|
-
async find(params?: FindParams): Promise<
|
|
36
|
+
async find(params?: FindParams): Promise<FindResult<M>> {
|
|
58
37
|
const qs = buildQueryString(params);
|
|
59
38
|
const raw = await transport.request<{
|
|
60
39
|
data: Record<string, unknown>[];
|
|
61
|
-
meta:
|
|
40
|
+
meta: FindResult<M>["meta"]
|
|
62
41
|
}>(basePath + qs, { method: "GET" });
|
|
63
42
|
return {
|
|
64
|
-
data: (raw.data || [])
|
|
43
|
+
data: (raw.data || []) as M[],
|
|
65
44
|
meta: raw.meta
|
|
66
45
|
};
|
|
67
46
|
},
|
|
@@ -70,7 +49,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
70
49
|
try {
|
|
71
50
|
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
|
|
72
51
|
if (!raw) return undefined;
|
|
73
|
-
return
|
|
52
|
+
return raw as M;
|
|
74
53
|
} catch (err) {
|
|
75
54
|
if (err instanceof RebaseApiError && err.status === 404) {
|
|
76
55
|
return undefined;
|
|
@@ -88,7 +67,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
88
67
|
method: "POST",
|
|
89
68
|
body: JSON.stringify(body)
|
|
90
69
|
});
|
|
91
|
-
return
|
|
70
|
+
return raw as M;
|
|
92
71
|
},
|
|
93
72
|
|
|
94
73
|
async update(id: string | number, data: Partial<M>) {
|
|
@@ -96,11 +75,11 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
96
75
|
method: "PUT",
|
|
97
76
|
body: JSON.stringify(data)
|
|
98
77
|
});
|
|
99
|
-
return
|
|
78
|
+
return raw as M;
|
|
100
79
|
},
|
|
101
80
|
|
|
102
81
|
async delete(id: string | number) {
|
|
103
|
-
|
|
82
|
+
await transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
104
83
|
method: "DELETE"
|
|
105
84
|
});
|
|
106
85
|
},
|
|
@@ -118,31 +97,31 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
118
97
|
|
|
119
98
|
// Fluent builder instantiation
|
|
120
99
|
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
|
|
121
|
-
const builder = new
|
|
100
|
+
const builder = new SDKQueryBuilder<M>(client);
|
|
122
101
|
if (typeof columnOrCondition === "object") {
|
|
123
102
|
return builder.where(columnOrCondition);
|
|
124
103
|
}
|
|
125
104
|
return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
|
|
126
105
|
},
|
|
127
106
|
orderBy(column: keyof M & string, direction?: "asc" | "desc") {
|
|
128
|
-
return new
|
|
107
|
+
return new SDKQueryBuilder<M>(client).orderBy(column, direction);
|
|
129
108
|
},
|
|
130
109
|
limit(count: number) {
|
|
131
|
-
return new
|
|
110
|
+
return new SDKQueryBuilder<M>(client).limit(count);
|
|
132
111
|
},
|
|
133
112
|
offset(count: number) {
|
|
134
|
-
return new
|
|
113
|
+
return new SDKQueryBuilder<M>(client).offset(count);
|
|
135
114
|
},
|
|
136
115
|
search(searchString: string) {
|
|
137
|
-
return new
|
|
116
|
+
return new SDKQueryBuilder<M>(client).search(searchString);
|
|
138
117
|
},
|
|
139
118
|
include(...relations: string[]) {
|
|
140
|
-
return new
|
|
119
|
+
return new SDKQueryBuilder<M>(client).include(...relations);
|
|
141
120
|
}
|
|
142
121
|
};
|
|
143
122
|
|
|
144
123
|
if (ws) {
|
|
145
|
-
client.listen = (params: FindParams | undefined, onUpdate: (response:
|
|
124
|
+
client.listen = (params: FindParams | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
|
|
146
125
|
let active = true;
|
|
147
126
|
let lastUpdateId = 0;
|
|
148
127
|
const unsub = ws.listenCollection(
|
|
@@ -151,45 +130,63 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
151
130
|
filter: params?.where,
|
|
152
131
|
limit: params?.limit,
|
|
153
132
|
startAfter: params?.offset ? String(params.offset) : undefined,
|
|
154
|
-
orderBy: params?.orderBy?.
|
|
155
|
-
order: params?.orderBy?.
|
|
133
|
+
orderBy: params?.orderBy?.[0],
|
|
134
|
+
order: params?.orderBy?.[1],
|
|
156
135
|
searchString: params?.searchString
|
|
157
136
|
},
|
|
158
|
-
(
|
|
137
|
+
(incomingRows: Record<string, unknown>[]) => {
|
|
159
138
|
const currentUpdateId = ++lastUpdateId;
|
|
160
139
|
const requestedLimit = params?.limit || 20;
|
|
161
140
|
const offset = params?.offset || 0;
|
|
162
141
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// Asynchronously fetch the actual count from the server to get accurate total/hasMore
|
|
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
|
|
175
150
|
if (client.count) {
|
|
176
151
|
client.count(params)
|
|
177
152
|
.then((total) => {
|
|
178
153
|
if (active && currentUpdateId === lastUpdateId) {
|
|
179
154
|
onUpdate({
|
|
180
|
-
data:
|
|
155
|
+
data: rows,
|
|
181
156
|
meta: {
|
|
182
157
|
total,
|
|
183
158
|
limit: requestedLimit,
|
|
184
159
|
offset,
|
|
185
|
-
hasMore: offset +
|
|
160
|
+
hasMore: offset + rows.length < total
|
|
186
161
|
}
|
|
187
162
|
});
|
|
188
163
|
}
|
|
189
164
|
})
|
|
190
165
|
.catch(() => {
|
|
191
|
-
//
|
|
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
|
+
}
|
|
192
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
|
+
});
|
|
193
190
|
}
|
|
194
191
|
},
|
|
195
192
|
onError
|
|
@@ -201,15 +198,15 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
201
198
|
};
|
|
202
199
|
};
|
|
203
200
|
|
|
204
|
-
client.listenById = (id: string | number, onUpdate: (data:
|
|
205
|
-
return ws.
|
|
201
|
+
client.listenById = (id: string | number, onUpdate: (data: M | undefined) => void, onError?: (error: Error) => void) => {
|
|
202
|
+
return ws.listenOne(
|
|
206
203
|
{
|
|
207
204
|
path: slug,
|
|
208
|
-
|
|
205
|
+
id: String(id)
|
|
209
206
|
},
|
|
210
|
-
(
|
|
211
|
-
if (
|
|
212
|
-
onUpdate(
|
|
207
|
+
(row: Record<string, unknown> | null) => {
|
|
208
|
+
if (row) {
|
|
209
|
+
onUpdate(row as M);
|
|
213
210
|
} else {
|
|
214
211
|
onUpdate(undefined);
|
|
215
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";
|