@rebasepro/client 0.8.0 → 0.9.1-canary.09aaf62

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,52 +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
- 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 { QueryBuilder } from "./query_builder";
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
- * CollectionClient extends `CollectionAccessor` from `@rebasepro/types` so that
32
- * `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()`, …).
33
18
  *
34
- * 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.
35
23
  */
36
- export interface CollectionClient<M extends Record<string, unknown> = Record<string, unknown>> extends CollectionAccessor<M> {
37
- where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): QueryBuilder<M>;
38
- where(logicalCondition: LogicalCondition): QueryBuilder<M>;
39
-
40
- orderBy(column: keyof M & string, direction?: "asc" | "desc"): QueryBuilder<M>;
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<FindResponse<M>> {
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: FindResponse<M>["meta"]
40
+ meta: FindResult<M>["meta"]
62
41
  }>(basePath + qs, { method: "GET" });
63
42
  return {
64
- data: (raw.data || []).map((row: Record<string, unknown>) => rowToEntity<M>(row, slug)),
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 rowToEntity<M>(raw, slug);
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,23 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
88
67
  method: "POST",
89
68
  body: JSON.stringify(body)
90
69
  });
91
- return rowToEntity<M>(raw, slug);
70
+ return raw as M;
71
+ },
72
+
73
+ async createMany(data: Partial<M>[], options?: { upsert?: boolean }) {
74
+ if (!Array.isArray(data)) {
75
+ throw new TypeError("createMany expects an array of records.");
76
+ }
77
+ if (data.length === 0) return [];
78
+
79
+ const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
80
+ method: "POST",
81
+ body: JSON.stringify({
82
+ rows: data,
83
+ ...(options?.upsert ? { upsert: true } : {})
84
+ })
85
+ });
86
+ return (raw.data || []) as M[];
92
87
  },
93
88
 
94
89
  async update(id: string | number, data: Partial<M>) {
@@ -96,11 +91,11 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
96
91
  method: "PUT",
97
92
  body: JSON.stringify(data)
98
93
  });
99
- return rowToEntity<M>(raw, slug);
94
+ return raw as M;
100
95
  },
101
96
 
102
97
  async delete(id: string | number) {
103
- return transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
98
+ await transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
104
99
  method: "DELETE"
105
100
  });
106
101
  },
@@ -118,31 +113,31 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
118
113
 
119
114
  // Fluent builder instantiation
120
115
  where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
121
- const builder = new QueryBuilder<M>(client as unknown as CollectionAccessor<M>);
116
+ const builder = new SDKQueryBuilder<M>(client);
122
117
  if (typeof columnOrCondition === "object") {
123
118
  return builder.where(columnOrCondition);
124
119
  }
125
120
  return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
126
121
  },
127
122
  orderBy(column: keyof M & string, direction?: "asc" | "desc") {
128
- return new QueryBuilder<M>(client).orderBy(column, direction);
123
+ return new SDKQueryBuilder<M>(client).orderBy(column, direction);
129
124
  },
130
125
  limit(count: number) {
131
- return new QueryBuilder<M>(client).limit(count);
126
+ return new SDKQueryBuilder<M>(client).limit(count);
132
127
  },
133
128
  offset(count: number) {
134
- return new QueryBuilder<M>(client).offset(count);
129
+ return new SDKQueryBuilder<M>(client).offset(count);
135
130
  },
136
131
  search(searchString: string) {
137
- return new QueryBuilder<M>(client).search(searchString);
132
+ return new SDKQueryBuilder<M>(client).search(searchString);
138
133
  },
139
134
  include(...relations: string[]) {
140
- return new QueryBuilder<M>(client).include(...relations);
135
+ return new SDKQueryBuilder<M>(client).include(...relations);
141
136
  }
142
137
  };
143
138
 
144
139
  if (ws) {
145
- client.listen = (params: FindParams | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
140
+ client.listen = (params: FindParams | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
146
141
  let active = true;
147
142
  let lastUpdateId = 0;
148
143
  const unsub = ws.listenCollection(
@@ -151,45 +146,63 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
151
146
  filter: params?.where,
152
147
  limit: params?.limit,
153
148
  startAfter: params?.offset ? String(params.offset) : undefined,
154
- orderBy: params?.orderBy?.split(":")[0],
155
- order: params?.orderBy?.split(":")[1] as "asc" | "desc",
149
+ orderBy: params?.orderBy?.[0],
150
+ order: params?.orderBy?.[1],
156
151
  searchString: params?.searchString
157
152
  },
158
- (entities: Entity[]) => {
153
+ (incomingRows: Record<string, unknown>[]) => {
159
154
  const currentUpdateId = ++lastUpdateId;
160
155
  const requestedLimit = params?.limit || 20;
161
156
  const offset = params?.offset || 0;
162
157
 
163
- // Immediately fire update with heuristic metadata
164
- onUpdate({
165
- data: entities as Entity<M>[],
166
- meta: {
167
- total: entities.length,
168
- limit: requestedLimit,
169
- offset,
170
- hasMore: entities.length >= requestedLimit
171
- }
172
- });
173
-
174
- // Asynchronously fetch the actual count from the server to get accurate total/hasMore
158
+ // WS client already delivers flat rows — just cast
159
+ const rows = incomingRows as M[];
160
+
161
+ // Heuristic metadata (used as fallback if count call fails)
162
+ const heuristicTotal = rows.length;
163
+ const heuristicHasMore = rows.length >= requestedLimit;
164
+
165
+ // Try to get authoritative count; fall back to heuristic
175
166
  if (client.count) {
176
167
  client.count(params)
177
168
  .then((total) => {
178
169
  if (active && currentUpdateId === lastUpdateId) {
179
170
  onUpdate({
180
- data: entities as Entity<M>[],
171
+ data: rows,
181
172
  meta: {
182
173
  total,
183
174
  limit: requestedLimit,
184
175
  offset,
185
- hasMore: offset + entities.length < total
176
+ hasMore: offset + rows.length < total
186
177
  }
187
178
  });
188
179
  }
189
180
  })
190
181
  .catch(() => {
191
- // Silent fallback on count error
182
+ // Count failed — use heuristic meta
183
+ if (active && currentUpdateId === lastUpdateId) {
184
+ onUpdate({
185
+ data: rows,
186
+ meta: {
187
+ total: heuristicTotal,
188
+ limit: requestedLimit,
189
+ offset,
190
+ hasMore: heuristicHasMore
191
+ }
192
+ });
193
+ }
192
194
  });
195
+ } else {
196
+ // No count method — fire immediately with heuristic meta
197
+ onUpdate({
198
+ data: rows,
199
+ meta: {
200
+ total: heuristicTotal,
201
+ limit: requestedLimit,
202
+ offset,
203
+ hasMore: heuristicHasMore
204
+ }
205
+ });
193
206
  }
194
207
  },
195
208
  onError
@@ -201,15 +214,15 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
201
214
  };
202
215
  };
203
216
 
204
- client.listenById = (id: string | number, onUpdate: (data: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
205
- return ws.listenEntity(
217
+ client.listenById = (id: string | number, onUpdate: (data: M | undefined) => void, onError?: (error: Error) => void) => {
218
+ return ws.listenOne(
206
219
  {
207
220
  path: slug,
208
- entityId: String(id)
221
+ id: String(id)
209
222
  },
210
- (entity: Entity | null) => {
211
- if (entity) {
212
- onUpdate(entity as Entity<M>);
223
+ (row: Record<string, unknown> | null) => {
224
+ if (row) {
225
+ onUpdate(row as M);
213
226
  } else {
214
227
  onUpdate(undefined);
215
228
  }
@@ -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";