@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/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
- CollectionAccessor,
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 { QueryBuilder } from "./query_builder";
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
- * CollectionClient extends `CollectionAccessor` from `@rebasepro/types` so that
142
- * `client.data` can be passed directly to the core Rebase component.
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
- * Additionally it exposes fluent query builder methods like `.where()`, `.orderBy()`.
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<M extends Record<string, unknown> = Record<string, unknown>> extends CollectionAccessor<M> {
147
- where<K extends keyof M & string>(column: K, operator: FilterOperator, value: WhereValue<M[K]>): QueryBuilder<M>;
148
- where(logicalCondition: LogicalCondition): QueryBuilder<M>;
149
-
150
- orderBy(column: keyof M & string, ascending?: "asc" | "desc"): QueryBuilder<M>;
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<FindResponse<M>> {
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: FindResponse<M>["meta"]
40
+ meta: FindResult<M>["meta"]
172
41
  }>(basePath + qs, { method: "GET" });
173
42
  return {
174
- data: (raw.data || []).map((row: Record<string, unknown>) => rowToEntity<M>(row, slug)),
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 rowToEntity<M>(raw, slug);
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 rowToEntity<M>(raw, slug);
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 rowToEntity<M>(raw, slug);
78
+ return raw as M;
210
79
  },
211
80
 
212
81
  async delete(id: string | number) {
213
- return transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
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?: FilterOperator, value?: unknown) {
231
- const builder = new QueryBuilder<M>(client as unknown as CollectionAccessor<M>);
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, ascending?: "asc" | "desc") {
238
- return new QueryBuilder<M>(client).orderBy(column, ascending);
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 QueryBuilder<M>(client).limit(count);
110
+ return new SDKQueryBuilder<M>(client).limit(count);
242
111
  },
243
112
  offset(count: number) {
244
- return new QueryBuilder<M>(client).offset(count);
113
+ return new SDKQueryBuilder<M>(client).offset(count);
245
114
  },
246
115
  search(searchString: string) {
247
- return new QueryBuilder<M>(client).search(searchString);
116
+ return new SDKQueryBuilder<M>(client).search(searchString);
248
117
  },
249
118
  include(...relations: string[]) {
250
- return new QueryBuilder<M>(client).include(...relations);
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: FindResponse<M>) => void, onError?: (error: Error) => void) => {
256
- return ws.listenCollection(
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: parseWhereFilter(params?.where),
130
+ filter: params?.where,
260
131
  limit: params?.limit,
261
132
  startAfter: params?.offset ? String(params.offset) : undefined,
262
- orderBy: params?.orderBy?.split(":")[0],
263
- order: params?.orderBy?.split(":")[1] as "asc" | "desc",
133
+ orderBy: params?.orderBy?.[0],
134
+ order: params?.orderBy?.[1],
264
135
  searchString: params?.searchString
265
136
  },
266
- (entities: Entity[]) => {
137
+ (incomingRows: Record<string, unknown>[]) => {
138
+ const currentUpdateId = ++lastUpdateId;
267
139
  const requestedLimit = params?.limit || 20;
268
- onUpdate({
269
- data: entities as Entity<M>[],
270
- meta: {
271
- total: entities.length,
272
- limit: requestedLimit,
273
- offset: params?.offset || 0,
274
- hasMore: entities.length >= requestedLimit
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: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
283
- return ws.listenEntity(
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
- entityId: String(id)
205
+ id: String(id)
287
206
  },
288
- (entity: Entity | null) => {
289
- if (entity) {
290
- onUpdate(entity as Entity<M>);
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";