@rebasepro/common 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.
Files changed (63) hide show
  1. package/README.md +4 -4
  2. package/dist/collections/CollectionRegistry.d.ts +30 -15
  3. package/dist/collections/default-collections.d.ts +255 -2
  4. package/dist/data/buildRebaseData.d.ts +30 -2
  5. package/dist/data/buildRoutedRebaseData.d.ts +14 -9
  6. package/dist/data/filter-dialect.d.ts +75 -0
  7. package/dist/data/query_builder.d.ts +4 -4
  8. package/dist/data/resolveDataSource.d.ts +8 -8
  9. package/dist/data/sort-dialect.d.ts +41 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.es.js +1125 -299
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/index.umd.js +1138 -303
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/util/builders.d.ts +52 -42
  16. package/dist/util/callbacks.d.ts +8 -3
  17. package/dist/util/collections.d.ts +4 -4
  18. package/dist/util/entities.d.ts +2 -2
  19. package/dist/util/filter-operator-resolution.d.ts +32 -0
  20. package/dist/util/index.d.ts +2 -0
  21. package/dist/util/navigation_from_path.d.ts +4 -4
  22. package/dist/util/navigation_utils.d.ts +3 -3
  23. package/dist/util/parent_references_from_path.d.ts +2 -2
  24. package/dist/util/permissions.d.ts +30 -6
  25. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  26. package/dist/util/policy/index.d.ts +3 -0
  27. package/dist/util/policy/policyToPostgres.d.ts +22 -0
  28. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  29. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  30. package/dist/util/references.d.ts +2 -2
  31. package/dist/util/relations.d.ts +5 -5
  32. package/dist/util/resolutions.d.ts +2 -2
  33. package/dist/util/storage.d.ts +26 -1
  34. package/package.json +13 -13
  35. package/src/collections/CollectionRegistry.ts +92 -61
  36. package/src/collections/default-collections.ts +4 -4
  37. package/src/data/buildRebaseData.ts +336 -172
  38. package/src/data/buildRoutedRebaseData.ts +22 -16
  39. package/src/data/filter-dialect.ts +403 -0
  40. package/src/data/query_builder.ts +19 -10
  41. package/src/data/resolveDataSource.ts +10 -10
  42. package/src/data/sort-dialect.ts +56 -0
  43. package/src/index.ts +2 -0
  44. package/src/util/builders.ts +87 -84
  45. package/src/util/callbacks.ts +15 -8
  46. package/src/util/collections.ts +4 -4
  47. package/src/util/entities.ts +4 -4
  48. package/src/util/filter-operator-resolution.ts +81 -0
  49. package/src/util/index.ts +2 -0
  50. package/src/util/navigation_from_path.ts +4 -4
  51. package/src/util/navigation_utils.ts +8 -8
  52. package/src/util/parent_references_from_path.ts +3 -3
  53. package/src/util/permissions.test.ts +7 -5
  54. package/src/util/permissions.ts +90 -163
  55. package/src/util/policy/evaluatePolicy.ts +152 -0
  56. package/src/util/policy/index.ts +3 -0
  57. package/src/util/policy/policyToPostgres.ts +165 -0
  58. package/src/util/policy/securityRuleToConditions.ts +67 -0
  59. package/src/util/policy/sqlToPolicy.ts +88 -0
  60. package/src/util/references.ts +3 -3
  61. package/src/util/relations.ts +19 -20
  62. package/src/util/resolutions.ts +11 -11
  63. package/src/util/storage.ts +34 -1
@@ -1,143 +1,33 @@
1
1
  import {
2
- DataDriver,
3
- RebaseData,
4
2
  CollectionAccessor,
5
- FindParams,
6
- FindResponse,
3
+ DataDriver,
7
4
  Entity,
8
5
  EntityValues,
9
- FilterValues,
10
- WhereFilterOp,
11
- WhereFieldValue,
12
- WhereFilterOpShort,
6
+ FindParams,
7
+ FindResponse,
8
+ FindResult,
13
9
  LogicalCondition,
10
+ RebaseData,
11
+ RebaseSdkData,
12
+ SDKCollectionClient,
13
+ SDKQueryBuilderInterface,
14
+ WhereFilterOp,
14
15
  WhereValue
15
16
  } from "@rebasepro/types";
16
17
  import { toSnakeCase } from "@rebasepro/utils";
17
18
  import { QueryBuilder } from "./query_builder";
19
+ import { deserializeFilter } from "./filter-dialect";
18
20
 
19
21
  /**
20
- * Convert where-clause filter object to the internal DataDriver FilterValues format.
21
- *
22
- * Supports multiple value formats:
23
- * - PostgREST string: { status: "eq.published", age: "gte.18" }
24
- * - Equality shorthand: { company_profile_id: null, status: "active", age: 18 }
25
- * - Tuple syntax: { age: [">=", 18], role: ["in", ["admin", "editor"]] }
26
- *
27
- * Internal: { status: ["==", "published"], age: [">=", 18] }
22
+ * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
23
+ * Mirrors the client SDK's rowToEntity conversion.
28
24
  */
29
- function convertWhereToFilter(where?: Record<string, WhereFieldValue>): FilterValues<string> | undefined {
30
- if (!where) return undefined;
31
-
32
- const operatorMap: Record<string, WhereFilterOp> = {
33
- "eq": "==",
34
- "neq": "!=",
35
- "gt": ">",
36
- "gte": ">=",
37
- "lt": "<",
38
- "lte": "<=",
39
- "in": "in",
40
- "nin": "not-in",
41
- "not-in": "not-in",
42
- "cs": "array-contains",
43
- "csa": "array-contains-any",
44
- "==": "==",
45
- "!=": "!=",
46
- ">": ">",
47
- ">=": ">=",
48
- "<": "<",
49
- "<=": "<=",
50
- "array-contains": "array-contains",
51
- "array-contains-any": "array-contains-any"
25
+ function rowToEntity<M extends Record<string, unknown>>(row: Record<string, unknown>, slug: string): Entity<M> {
26
+ return {
27
+ id: row.id as string | number,
28
+ path: slug,
29
+ values: row as EntityValues<M>
52
30
  };
53
-
54
- const filter: FilterValues<string> = {};
55
-
56
- for (const [field, rawValue] of Object.entries(where)) {
57
- // Handle null → equality
58
- if (rawValue === null) {
59
- filter[field] = ["==", null];
60
- continue;
61
- }
62
-
63
- // Handle boolean → equality
64
- if (typeof rawValue === "boolean") {
65
- filter[field] = ["==", rawValue];
66
- continue;
67
- }
68
-
69
- // Handle number → equality
70
- if (typeof rawValue === "number") {
71
- filter[field] = ["==", rawValue];
72
- continue;
73
- }
74
-
75
- // Handle tuple or array of tuples
76
- if (Array.isArray(rawValue)) {
77
- const conditions: [WhereFilterOpShort, unknown][] = Array.isArray(rawValue[0])
78
- ? (rawValue as [WhereFilterOpShort, unknown][])
79
- : [rawValue as [WhereFilterOpShort, unknown]];
80
-
81
- const mappedConditions: [WhereFilterOp, unknown][] = conditions.map(([rawOp, val]) => {
82
- const mappedOp = operatorMap[rawOp] ?? "==";
83
- return [mappedOp, val];
84
- });
85
-
86
- filter[field] = Array.isArray(rawValue[0]) ? mappedConditions : mappedConditions[0];
87
- continue;
88
- }
89
-
90
- // Handle PostgREST string format: "op.value"
91
- if (typeof rawValue === "string") {
92
- const dotIndex = rawValue.indexOf(".");
93
- if (dotIndex === -1) {
94
- // Plain string equality
95
- filter[field] = ["==", rawValue];
96
- continue;
97
- }
98
-
99
- const op = rawValue.substring(0, dotIndex);
100
- let value: unknown = rawValue.substring(dotIndex + 1);
101
-
102
- // Parse list values like "(admin,editor)"
103
- if (typeof value === "string" && value.startsWith("(") && value.endsWith(")")) {
104
- value = value.slice(1, -1).split(",").map((v: string) => v.trim());
105
- }
106
-
107
- // Parse null string
108
- if (value === "null") {
109
- value = null;
110
- }
111
- // Parse boolean strings
112
- else if (value === "true") {
113
- value = true;
114
- } else if (value === "false") {
115
- value = false;
116
- }
117
- // Try to parse numbers
118
- else if (typeof value === "string" && !isNaN(Number(value)) && value.trim() !== "") {
119
- value = Number(value);
120
- }
121
-
122
- const mappedOp = operatorMap[op];
123
- if (mappedOp) {
124
- filter[field] = [mappedOp, value];
125
- }
126
- }
127
- }
128
-
129
- return Object.keys(filter).length > 0 ? filter : undefined;
130
- }
131
-
132
- /**
133
- * Parse an orderBy string like "created_at:desc" into [field, direction].
134
- */
135
- function parseOrderBy(orderBy?: string): [string, "asc" | "desc"] | undefined {
136
- if (!orderBy) return undefined;
137
- const parts = orderBy.split(":");
138
- const field = parts[0];
139
- const direction = (parts[1] as "asc" | "desc") || "asc";
140
- return [field, direction];
141
31
  }
142
32
 
143
33
  function createDriverAccessor<M extends Record<string, unknown> = Record<string, unknown>>(
@@ -146,91 +36,108 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
146
36
  ): CollectionAccessor<M> {
147
37
  const accessor: CollectionAccessor<M> = {
148
38
  async find(params?: FindParams): Promise<FindResponse<M>> {
149
- const orderParsed = parseOrderBy(params?.orderBy);
150
- const entities = await driver.fetchCollection<M>({
151
- path: slug,
152
- limit: params?.limit,
153
- offset: params?.offset,
154
- filter: convertWhereToFilter(params?.where),
155
- orderBy: orderParsed?.[0],
156
- order: orderParsed?.[1],
157
- searchString: params?.searchString
158
- });
39
+ // Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
40
+ const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
159
41
  const limit = params?.limit ?? 20;
160
42
  const offset = params?.offset ?? 0;
43
+
44
+ // Use the RestFetchService for include-aware queries when available
45
+ const fetchService = driver.restFetchService;
46
+ const rows = (fetchService && params?.include && params.include.length > 0)
47
+ ? await fetchService.fetchCollectionForRest(
48
+ slug,
49
+ {
50
+ filter,
51
+ limit: params?.limit,
52
+ offset: params?.offset,
53
+ orderBy: params?.orderBy?.[0],
54
+ order: params?.orderBy?.[1],
55
+ searchString: params?.searchString
56
+ },
57
+ params.include
58
+ )
59
+ : await driver.fetchCollection<M>({
60
+ path: slug,
61
+ limit: params?.limit,
62
+ offset: params?.offset,
63
+ filter,
64
+ orderBy: params?.orderBy?.[0],
65
+ order: params?.orderBy?.[1],
66
+ searchString: params?.searchString
67
+ });
68
+
69
+ // Compute real total when count is available
70
+ let total = rows.length + offset;
71
+ let hasMore = rows.length >= limit;
72
+ if (driver.count) {
73
+ total = await driver.count({ path: slug, filter });
74
+ hasMore = offset + rows.length < total;
75
+ }
76
+
161
77
  return {
162
- data: entities,
163
- meta: {
164
- total: entities.length,
165
- limit,
166
- offset,
167
- hasMore: entities.length >= limit
168
- }
78
+ data: rows.map((row: Record<string, unknown>) => rowToEntity<M>(row, slug)),
79
+ meta: { total, limit, offset, hasMore }
169
80
  };
170
81
  },
171
82
 
172
83
  async findById(id: string | number): Promise<Entity<M> | undefined> {
173
- return driver.fetchEntity<M>({ path: slug,
174
- entityId: id });
84
+ const row = await driver.fetchOne<M>({ path: slug, id: id });
85
+ return row ? rowToEntity<M>(row, slug) : undefined;
175
86
  },
176
87
 
177
88
  async create(data: Partial<EntityValues<M>>, id?: string | number): Promise<Entity<M>> {
178
- return driver.saveEntity<M>({
89
+ const row = await driver.save<M>({
179
90
  path: slug,
180
91
  values: data,
181
- entityId: id,
92
+ id: id,
182
93
  status: "new"
183
94
  });
95
+ return rowToEntity<M>(row, slug);
184
96
  },
185
97
 
186
98
  async update(id: string | number, data: Partial<EntityValues<M>>): Promise<Entity<M>> {
187
- return driver.saveEntity<M>({
99
+ const row = await driver.save<M>({
188
100
  path: slug,
189
101
  values: data,
190
- entityId: id,
102
+ id: id,
191
103
  status: "existing"
192
104
  });
105
+ return rowToEntity<M>(row, slug);
193
106
  },
194
107
 
195
108
  async delete(id: string | number): Promise<void> {
196
- return driver.deleteEntity({
197
- entity: { id,
109
+ return driver.delete({
110
+ row: { id,
198
111
  path: slug,
199
112
  values: {} as Record<string, unknown> }
200
113
  });
201
114
  },
202
115
 
203
- deleteAll: driver.deleteAll
204
- ? async (): Promise<void> => {
205
- return driver.deleteAll!(slug);
206
- }
207
- : undefined,
208
-
209
- count: driver.countEntities
116
+ count: driver.count
210
117
  ? async (params?: FindParams): Promise<number> => {
211
- return driver.countEntities!({
118
+ const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
119
+ return driver.count!({
212
120
  path: slug,
213
- filter: convertWhereToFilter(params?.where)
121
+ filter
214
122
  });
215
123
  }
216
124
  : undefined,
217
125
 
218
126
  listen: driver.listenCollection
219
127
  ? (params: FindParams | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
220
- const orderParsed = parseOrderBy(params?.orderBy);
221
128
  const limit = params?.limit ?? 20;
222
129
  const offset = params?.offset ?? 0;
223
130
  return driver.listenCollection!<M>({
224
131
  path: slug,
225
132
  limit: params?.limit,
226
133
  offset: params?.offset,
227
- filter: convertWhereToFilter(params?.where),
228
- orderBy: orderParsed?.[0],
229
- order: orderParsed?.[1],
134
+ filter: params?.where,
135
+ orderBy: params?.orderBy?.[0],
136
+ order: params?.orderBy?.[1],
230
137
  searchString: params?.searchString,
231
138
  onUpdate: (entities) => {
232
139
  onUpdate({
233
- data: entities,
140
+ data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(row, slug)),
234
141
  meta: {
235
142
  total: entities.length,
236
143
  limit,
@@ -243,18 +150,18 @@ values: {} as Record<string, unknown> }
243
150
  });
244
151
  } : undefined,
245
152
 
246
- listenById: driver.listenEntity
153
+ listenById: driver.listenOne
247
154
  ? (id: string | number, onUpdate: (entity: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
248
- return driver.listenEntity!<M>({
155
+ return driver.listenOne!<M>({
249
156
  path: slug,
250
- entityId: id,
251
- onUpdate: (entity) => onUpdate(entity ?? undefined),
157
+ id: id,
158
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity<M>(entity, slug) : undefined),
252
159
  onError
253
160
  });
254
161
  } : undefined,
255
162
 
256
163
  // Fluent Query Builder
257
- where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOpShort, value?: unknown) {
164
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
258
165
  const builder = new QueryBuilder<M>(accessor);
259
166
  if (typeof columnOrCondition === "object") {
260
167
  return builder.where(columnOrCondition);
@@ -291,7 +198,7 @@ values: {} as Record<string, unknown> }
291
198
  * @example
292
199
  * const data = buildRebaseData(driver);
293
200
  * await data.products.create({ name: "Camera", price: 299 });
294
- * const { data: items } = await data.products.find({ where: { status: "eq.published" } });
201
+ * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
295
202
  */
296
203
  export function buildRebaseData(driver: DataDriver): RebaseData {
297
204
  const cache = new Map<string, CollectionAccessor>();
@@ -323,3 +230,260 @@ export function buildRebaseData(driver: DataDriver): RebaseData {
323
230
  }
324
231
  });
325
232
  }
233
+
234
+ // =============================================================================
235
+ // SDK data — flat rows (symmetric with the frontend SDK client)
236
+ // =============================================================================
237
+
238
+ /**
239
+ * Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
240
+ * (id included) under `.values`, so this is just that payload.
241
+ */
242
+ function entityToRow<M extends Record<string, unknown>>(entity: Entity<M>): M {
243
+ return entity.values as unknown as M;
244
+ }
245
+
246
+ /**
247
+ * Fluent query builder for the flat SDK data layer. Mirrors {@link QueryBuilder}
248
+ * but resolves to `FindResult<M>` (flat rows) instead of Entity-wrapped
249
+ * `FindResponse<M>`.
250
+ */
251
+ class SdkQueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements SDKQueryBuilderInterface<M> {
252
+ private params: FindParams = { where: {} };
253
+
254
+ constructor(private client: SDKCollectionClient<M>) {}
255
+
256
+ where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
257
+ where(logicalCondition: LogicalCondition): this;
258
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
259
+ if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
260
+ this.params.logical = columnOrCondition as LogicalCondition;
261
+ return this;
262
+ }
263
+ if (!this.params.where) this.params.where = {};
264
+ const column = columnOrCondition as string;
265
+ const condition: [WhereFilterOp, unknown] = [operator!, value];
266
+ const existing = this.params.where[column];
267
+ if (existing === undefined) {
268
+ this.params.where[column] = condition;
269
+ } else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
270
+ (this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
271
+ } else {
272
+ let firstCondition: [WhereFilterOp, unknown];
273
+ if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
274
+ firstCondition = existing as [WhereFilterOp, unknown];
275
+ } else {
276
+ firstCondition = ["==", existing];
277
+ }
278
+ this.params.where[column] = [firstCondition, condition];
279
+ }
280
+ return this;
281
+ }
282
+
283
+ orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
284
+ this.params.orderBy = [column, direction];
285
+ return this;
286
+ }
287
+
288
+ limit(count: number): this { this.params.limit = count; return this; }
289
+ offset(count: number): this { this.params.offset = count; return this; }
290
+ search(searchString: string): this { this.params.searchString = searchString; return this; }
291
+ include(...relations: string[]): this { this.params.include = relations; return this; }
292
+
293
+ async find(): Promise<FindResult<M>> {
294
+ return this.client.find(this.params);
295
+ }
296
+
297
+ async count(): Promise<number> {
298
+ return this.client.count ? this.client.count(this.params) : 0;
299
+ }
300
+
301
+ listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void {
302
+ if (!this.client.listen) {
303
+ throw new Error("Listen is only available when the driver supports realtime.");
304
+ }
305
+ return this.client.listen(this.params, onUpdate, onError);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Wrap a Entity-shaped {@link CollectionAccessor} into a flat
311
+ * {@link SDKCollectionClient}. Every returned record is unwrapped to a flat row
312
+ * so the backend SDK is byte-for-byte the same shape as the frontend client.
313
+ */
314
+ function toSdkCollectionClient<M extends Record<string, unknown>>(
315
+ snap: CollectionAccessor<M>
316
+ ): SDKCollectionClient<M> {
317
+ const client: SDKCollectionClient<M> = {
318
+ async find(params?: FindParams): Promise<FindResult<M>> {
319
+ const res = await snap.find(params);
320
+ return { data: res.data.map(entityToRow), meta: res.meta };
321
+ },
322
+ async findById(id: string | number): Promise<M | undefined> {
323
+ const s = await snap.findById(id);
324
+ return s ? entityToRow(s) : undefined;
325
+ },
326
+ async create(data: Partial<M>, id?: string | number): Promise<M> {
327
+ return entityToRow(await snap.create(data as Partial<EntityValues<M>>, id));
328
+ },
329
+ async update(id: string | number, data: Partial<M>): Promise<M> {
330
+ return entityToRow(await snap.update(id, data as Partial<EntityValues<M>>));
331
+ },
332
+ delete(id: string | number): Promise<void> {
333
+ return snap.delete(id);
334
+ },
335
+ count: snap.count ? (params?: FindParams) => snap.count!(params) : undefined,
336
+ listen: snap.listen
337
+ ? (params: FindParams | undefined, onUpdate: (r: FindResult<M>) => void, onError?: (e: Error) => void) =>
338
+ snap.listen!(params, (res) => onUpdate({ data: res.data.map(entityToRow), meta: res.meta }), onError)
339
+ : undefined,
340
+ listenById: snap.listenById
341
+ ? (id: string | number, onUpdate: (r: M | undefined) => void, onError?: (e: Error) => void) =>
342
+ snap.listenById!(id, (s) => onUpdate(s ? entityToRow(s) : undefined), onError)
343
+ : undefined,
344
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
345
+ const builder = new SdkQueryBuilder<M>(client);
346
+ if (typeof columnOrCondition === "object") {
347
+ return builder.where(columnOrCondition);
348
+ }
349
+ return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
350
+ },
351
+ orderBy: (column: keyof M & string, direction?: "asc" | "desc") => new SdkQueryBuilder<M>(client).orderBy(column, direction),
352
+ limit: (count: number) => new SdkQueryBuilder<M>(client).limit(count),
353
+ offset: (count: number) => new SdkQueryBuilder<M>(client).offset(count),
354
+ search: (searchString: string) => new SdkQueryBuilder<M>(client).search(searchString),
355
+ include: (...relations: string[]) => new SdkQueryBuilder<M>(client).include(...relations)
356
+ };
357
+ return client;
358
+ }
359
+
360
+ /**
361
+ * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
362
+ * {@link CollectionAccessor}. Every returned row is re-wrapped into the
363
+ * `{ id, path, values }` view-model the admin CMS renders.
364
+ */
365
+ function toEntityAccessor<M extends Record<string, unknown>>(
366
+ sdk: SDKCollectionClient<M>,
367
+ slug: string
368
+ ): CollectionAccessor<M> {
369
+ const accessor: CollectionAccessor<M> = {
370
+ async find(params?: FindParams): Promise<FindResponse<M>> {
371
+ const res = await sdk.find(params);
372
+ return { data: res.data.map((row) => rowToEntity<M>(row, slug)), meta: res.meta };
373
+ },
374
+ async findById(id: string | number): Promise<Entity<M> | undefined> {
375
+ const row = await sdk.findById(id);
376
+ return row ? rowToEntity<M>(row, slug) : undefined;
377
+ },
378
+ async create(data: Partial<EntityValues<M>>, id?: string | number): Promise<Entity<M>> {
379
+ return rowToEntity<M>(await sdk.create(data as Partial<M>, id), slug);
380
+ },
381
+ async update(id: string | number, data: Partial<EntityValues<M>>): Promise<Entity<M>> {
382
+ const row = await sdk.update(id, data as Partial<M>);
383
+ if (!row) throw new Error(`Update returned no data for id ${id}`);
384
+ return rowToEntity<M>(row, slug);
385
+ },
386
+ delete(id: string | number): Promise<void> {
387
+ return sdk.delete(id);
388
+ },
389
+ count: sdk.count ? (params?: FindParams) => sdk.count!(params) : undefined,
390
+ listen: sdk.listen
391
+ ? (params: FindParams | undefined, onUpdate: (r: FindResponse<M>) => void, onError?: (e: Error) => void) =>
392
+ sdk.listen!(params, (res) => onUpdate({ data: res.data.map((row) => rowToEntity<M>(row, slug)), meta: res.meta }), onError)
393
+ : undefined,
394
+ listenById: sdk.listenById
395
+ ? (id: string | number, onUpdate: (s: Entity<M> | undefined) => void, onError?: (e: Error) => void) =>
396
+ sdk.listenById!(id, (row) => onUpdate(row ? rowToEntity<M>(row, slug) : undefined), onError)
397
+ : undefined,
398
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
399
+ const builder = new QueryBuilder<M>(accessor);
400
+ if (typeof columnOrCondition === "object") {
401
+ return builder.where(columnOrCondition);
402
+ }
403
+ return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
404
+ },
405
+ orderBy: (column: keyof M & string, direction?: "asc" | "desc") => new QueryBuilder<M>(accessor).orderBy(column, direction),
406
+ limit: (count: number) => new QueryBuilder<M>(accessor).limit(count),
407
+ offset: (count: number) => new QueryBuilder<M>(accessor).offset(count),
408
+ search: (searchString: string) => new QueryBuilder<M>(accessor).search(searchString),
409
+ include: (...relations: string[]) => new QueryBuilder<M>(accessor).include(...relations)
410
+ };
411
+ return accessor;
412
+ }
413
+
414
+ /**
415
+ * Wrap a flat {@link RebaseSdkData} into a Entity-shaped {@link RebaseData}.
416
+ *
417
+ * This is the **CMS boundary**: the SDK client (`client.data`) returns flat
418
+ * rows, but the admin renders the `Entity` view-model (`entity.values.*`).
419
+ * `core/Rebase.tsx` wraps `client.data` through this before handing it to the
420
+ * CMS `RebaseDataContext` — without it the admin renders rows with only their
421
+ * `id`.
422
+ */
423
+ export function wrapAsEntityData(sdkData: RebaseSdkData): RebaseData {
424
+ const cache = new Map<string, CollectionAccessor>();
425
+
426
+ function getAccessor(slug: string): CollectionAccessor {
427
+ let accessor = cache.get(slug);
428
+ if (!accessor) {
429
+ accessor = toEntityAccessor(sdkData.collection(slug), slug);
430
+ cache.set(slug, accessor);
431
+ }
432
+ return accessor;
433
+ }
434
+
435
+ const target = { collection: getAccessor } as RebaseData;
436
+
437
+ return new Proxy(target, {
438
+ get(_target, prop: string | symbol) {
439
+ if (prop === "collection") return getAccessor;
440
+ if (typeof prop === "symbol") return undefined;
441
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
442
+ return getAccessor(toSnakeCase(prop));
443
+ }
444
+ });
445
+ }
446
+
447
+ /**
448
+ * Wrap a Entity-shaped {@link RebaseData} into a flat {@link RebaseSdkData}.
449
+ *
450
+ * Every collection accessor is adapted to return flat rows. Use this to derive
451
+ * the flat SDK data layer (`context.data`) from an existing Entity data layer
452
+ * — e.g. the admin routes its Entity data via `useData()` and exposes the
453
+ * same routing as flat `context.data` for callbacks by wrapping it here.
454
+ */
455
+ export function wrapAsSdkData(entityData: RebaseData): RebaseSdkData {
456
+ const cache = new Map<string, SDKCollectionClient>();
457
+
458
+ function getAccessor(slug: string): SDKCollectionClient {
459
+ let accessor = cache.get(slug);
460
+ if (!accessor) {
461
+ accessor = toSdkCollectionClient(entityData.collection(slug));
462
+ cache.set(slug, accessor);
463
+ }
464
+ return accessor;
465
+ }
466
+
467
+ const target = { collection: getAccessor } as RebaseSdkData;
468
+
469
+ return new Proxy(target, {
470
+ get(_target, prop: string | symbol) {
471
+ if (prop === "collection") return getAccessor;
472
+ if (typeof prop === "symbol") return undefined;
473
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
474
+ return getAccessor(toSnakeCase(prop));
475
+ }
476
+ });
477
+ }
478
+
479
+ /**
480
+ * Build a flat {@link RebaseSdkData} from a `DataDriver`.
481
+ *
482
+ * This is the developer-facing SDK data layer used by backend framework
483
+ * callbacks & scripts (`context.data` / `rebase.data`). It returns flat rows —
484
+ * identical in shape to the frontend SDK client — so the API is symmetric
485
+ * across front and back. The admin CMS uses {@link buildRebaseData} (Entity).
486
+ */
487
+ export function buildSdkData(driver: DataDriver): RebaseSdkData {
488
+ return wrapAsSdkData(buildRebaseData(driver));
489
+ }