@rebasepro/common 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.
Files changed (65) hide show
  1. package/README.md +5 -5
  2. package/dist/collections/CollectionRegistry.d.ts +16 -16
  3. package/dist/collections/default-collections.d.ts +5 -1
  4. package/dist/data/buildRebaseData.d.ts +44 -3
  5. package/dist/data/buildRoutedRebaseData.d.ts +14 -9
  6. package/dist/data/filter-dialect.d.ts +18 -4
  7. package/dist/data/query_builder.d.ts +1 -1
  8. package/dist/data/resolveDataSource.d.ts +1 -1
  9. package/dist/data/sort-dialect.d.ts +41 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.es.js +1236 -179
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/util/auth-default-policies.d.ts +22 -0
  14. package/dist/util/builders.d.ts +19 -56
  15. package/dist/util/callbacks.d.ts +3 -3
  16. package/dist/util/collections.d.ts +4 -4
  17. package/dist/util/entities.d.ts +2 -2
  18. package/dist/util/filter-operator-resolution.d.ts +32 -0
  19. package/dist/util/identity.d.ts +83 -0
  20. package/dist/util/index.d.ts +4 -0
  21. package/dist/util/junction-policies.d.ts +108 -0
  22. package/dist/util/navigation_from_path.d.ts +4 -4
  23. package/dist/util/navigation_utils.d.ts +3 -3
  24. package/dist/util/parent_references_from_path.d.ts +2 -2
  25. package/dist/util/permissions.d.ts +6 -6
  26. package/dist/util/policy/evaluatePolicy.d.ts +8 -1
  27. package/dist/util/policy/index.d.ts +1 -0
  28. package/dist/util/policy/policyToPostgres.d.ts +14 -2
  29. package/dist/util/policy/sqlToPolicy.d.ts +24 -14
  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/package.json +7 -8
  34. package/src/collections/CollectionRegistry.ts +36 -36
  35. package/src/collections/default-collections.ts +2 -0
  36. package/src/data/buildRebaseData.ts +430 -60
  37. package/src/data/buildRoutedRebaseData.ts +22 -16
  38. package/src/data/filter-dialect.ts +151 -60
  39. package/src/data/query_builder.ts +11 -2
  40. package/src/data/resolveDataSource.ts +1 -1
  41. package/src/data/sort-dialect.ts +56 -0
  42. package/src/index.ts +1 -0
  43. package/src/util/auth-default-policies.ts +152 -0
  44. package/src/util/builders.ts +25 -99
  45. package/src/util/callbacks.ts +8 -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/identity.ts +166 -0
  50. package/src/util/index.ts +4 -0
  51. package/src/util/junction-policies.ts +353 -0
  52. package/src/util/navigation_from_path.ts +4 -4
  53. package/src/util/navigation_utils.ts +8 -8
  54. package/src/util/parent_references_from_path.ts +3 -3
  55. package/src/util/permissions.test.ts +2 -2
  56. package/src/util/permissions.ts +7 -7
  57. package/src/util/policy/evaluatePolicy.ts +26 -4
  58. package/src/util/policy/index.ts +1 -0
  59. package/src/util/policy/policyToPostgres.ts +123 -17
  60. package/src/util/policy/sqlToPolicy.ts +190 -13
  61. package/src/util/references.ts +2 -2
  62. package/src/util/relations.ts +12 -12
  63. package/src/util/resolutions.ts +5 -5
  64. package/dist/index.umd.js +0 -2901
  65. package/dist/index.umd.js.map +0 -1
@@ -1,103 +1,199 @@
1
1
  import {
2
- DataDriver,
3
- RebaseData,
4
2
  CollectionAccessor,
5
- FindParams,
6
- FindResponse,
3
+ DataDriver,
7
4
  Entity,
8
5
  EntityValues,
9
- WhereFilterOp,
6
+ FindParams,
7
+ FindResponse,
8
+ FindResult,
10
9
  LogicalCondition,
10
+ RebaseData,
11
+ RebaseSdkData,
12
+ SDKCollectionClient,
13
+ SDKQueryBuilderInterface,
14
+ WhereFilterOp,
11
15
  WhereValue
12
16
  } from "@rebasepro/types";
13
17
  import { toSnakeCase } from "@rebasepro/utils";
14
18
  import { QueryBuilder } from "./query_builder";
15
19
  import { deserializeFilter } from "./filter-dialect";
20
+ import { buildCompositeId, resolvePrimaryKeys, PrimaryKeyInfo } from "../util/identity";
21
+
22
+ export interface EntityDataOptions {
23
+ /**
24
+ * Look up a collection's config by slug, to derive row addresses from its
25
+ * primary keys.
26
+ *
27
+ * Called lazily rather than up front: the data layer is created by `Rebase`,
28
+ * which sits *above* the admin that owns the collections, so a resolver
29
+ * registered on mount would otherwise arrive too late to be seen.
30
+ */
31
+ resolveCollection?: (slug: string) => { properties?: Record<string, unknown> } | undefined;
32
+ }
33
+
34
+ function createPrimaryKeyResolver(options?: EntityDataOptions) {
35
+ const cache = new Map<string, PrimaryKeyInfo[]>();
36
+ const warned = new Set<string>();
37
+
38
+ return function primaryKeysFor(slug: string): PrimaryKeyInfo[] {
39
+ const cached = cache.get(slug);
40
+ if (cached) return cached;
41
+
42
+ const collection = options?.resolveCollection?.(slug);
43
+ if (!collection) {
44
+ // The registry may not have been registered yet. Don't memoize a
45
+ // miss, or the collection would stay address-less for this session.
46
+ return [];
47
+ }
48
+
49
+ const keys = resolvePrimaryKeys(collection);
50
+ if (keys.length > 0) {
51
+ // Memoized for the session: a collection's key does not change
52
+ // while the app runs, and this is called once per row. Editing
53
+ // `isId` in the schema editor needs a reload to take effect here.
54
+ cache.set(slug, keys);
55
+ return keys;
56
+ }
57
+
58
+ if (!warned.has(slug)) {
59
+ warned.add(slug);
60
+ // Silence here surfaces much later as rows that cannot be opened,
61
+ // linked, or saved, with nothing pointing back at the cause.
62
+ console.warn(
63
+ `[rebase] Collection '${slug}' declares no primary key, so its rows have no address: ` +
64
+ `detail links, caching and relations will not work for it. ` +
65
+ `Mark the key property with \`isId\` in its collection config — the server logs which ` +
66
+ `column to mark at boot, if its schema knows the key.`
67
+ );
68
+ }
69
+ return keys;
70
+ };
71
+ }
16
72
 
17
73
  /**
18
- * Parse an orderBy string like "created_at:desc" into [field, direction].
74
+ * Give a flat row the Entity view-model the admin renders.
75
+ *
76
+ * The address is *derived here* — it is not a column, and the row it came from
77
+ * does not contain one. Rows carry exactly what the table has, with the types
78
+ * Postgres returned; the id is this layer's invention, and this is the only
79
+ * place it is minted.
80
+ *
81
+ * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
82
+ * than postgres still serve rows with one, and this keeps them working.
19
83
  */
20
- function parseOrderBy(orderBy?: string): [string, "asc" | "desc"] | undefined {
21
- if (!orderBy) return undefined;
22
- const parts = orderBy.split(":");
23
- const field = parts[0];
24
- const direction = (parts[1] as "asc" | "desc") || "asc";
25
- return [field, direction];
84
+ function rowToEntity<M extends Record<string, unknown>>(
85
+ row: Record<string, unknown>,
86
+ slug: string,
87
+ primaryKeys: PrimaryKeyInfo[] = []
88
+ ): Entity<M> {
89
+ return {
90
+ id: primaryKeys.length > 0
91
+ ? buildCompositeId(row, primaryKeys)
92
+ : row.id as string | number,
93
+ path: slug,
94
+ values: row as EntityValues<M>
95
+ };
26
96
  }
27
97
 
28
98
  function createDriverAccessor<M extends Record<string, unknown> = Record<string, unknown>>(
29
99
  driver: DataDriver,
30
- slug: string
100
+ slug: string,
101
+ getPks: () => PrimaryKeyInfo[] = () => []
31
102
  ): CollectionAccessor<M> {
32
103
  const accessor: CollectionAccessor<M> = {
33
104
  async find(params?: FindParams): Promise<FindResponse<M>> {
34
- const orderParsed = parseOrderBy(params?.orderBy);
35
105
  // Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
36
- const filter = params?.where ? deserializeFilter(params.where as any) : undefined;
37
-
38
- const entities = await driver.fetchCollection<M>({
39
- path: slug,
40
- limit: params?.limit,
41
- offset: params?.offset,
42
- filter,
43
- orderBy: orderParsed?.[0],
44
- order: orderParsed?.[1],
45
- searchString: params?.searchString
46
- });
106
+ const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
47
107
  const limit = params?.limit ?? 20;
48
108
  const offset = params?.offset ?? 0;
109
+
110
+ // Use the RestFetchService for include-aware queries when available
111
+ const fetchService = driver.restFetchService;
112
+ const rows = (fetchService && params?.include && params.include.length > 0)
113
+ ? await fetchService.fetchCollectionForRest(
114
+ slug,
115
+ {
116
+ filter,
117
+ limit: params?.limit,
118
+ offset: params?.offset,
119
+ orderBy: params?.orderBy?.[0],
120
+ order: params?.orderBy?.[1],
121
+ searchString: params?.searchString
122
+ },
123
+ params.include
124
+ )
125
+ : await driver.fetchCollection<M>({
126
+ path: slug,
127
+ limit: params?.limit,
128
+ offset: params?.offset,
129
+ filter,
130
+ orderBy: params?.orderBy?.[0],
131
+ order: params?.orderBy?.[1],
132
+ searchString: params?.searchString
133
+ });
134
+
135
+ // Compute real total when count is available
136
+ let total = rows.length + offset;
137
+ let hasMore = rows.length >= limit;
138
+ if (driver.count) {
139
+ total = await driver.count({ path: slug, filter });
140
+ hasMore = offset + rows.length < total;
141
+ }
142
+
49
143
  return {
50
- data: entities,
51
- meta: {
52
- total: entities.length,
53
- limit,
54
- offset,
55
- hasMore: entities.length >= limit
56
- }
144
+ data: rows.map((row: Record<string, unknown>) => rowToEntity<M>(row, slug, getPks())),
145
+ meta: { total, limit, offset, hasMore }
57
146
  };
58
147
  },
59
148
 
60
149
  async findById(id: string | number): Promise<Entity<M> | undefined> {
61
- return driver.fetchEntity<M>({ path: slug,
62
- entityId: id });
150
+ const row = await driver.fetchOne<M>({ path: slug, id: id });
151
+ return row ? rowToEntity<M>(row, slug, getPks()) : undefined;
63
152
  },
64
153
 
65
154
  async create(data: Partial<EntityValues<M>>, id?: string | number): Promise<Entity<M>> {
66
- return driver.saveEntity<M>({
155
+ const row = await driver.save<M>({
67
156
  path: slug,
68
157
  values: data,
69
- entityId: id,
158
+ id: id,
70
159
  status: "new"
71
160
  });
161
+ return rowToEntity<M>(row, slug, getPks());
72
162
  },
73
163
 
164
+ createMany: driver.saveMany
165
+ ? async (data: Partial<EntityValues<M>>[], options?: { upsert?: boolean }): Promise<Entity<M>[]> => {
166
+ const rows = await driver.saveMany!<M>({
167
+ path: slug,
168
+ rows: data,
169
+ upsert: options?.upsert
170
+ });
171
+ return rows.map((row) => rowToEntity<M>(row, slug, getPks()));
172
+ }
173
+ : undefined,
174
+
74
175
  async update(id: string | number, data: Partial<EntityValues<M>>): Promise<Entity<M>> {
75
- return driver.saveEntity<M>({
176
+ const row = await driver.save<M>({
76
177
  path: slug,
77
178
  values: data,
78
- entityId: id,
179
+ id: id,
79
180
  status: "existing"
80
181
  });
182
+ return rowToEntity<M>(row, slug, getPks());
81
183
  },
82
184
 
83
185
  async delete(id: string | number): Promise<void> {
84
- return driver.deleteEntity({
85
- entity: { id,
186
+ return driver.delete({
187
+ row: { id,
86
188
  path: slug,
87
189
  values: {} as Record<string, unknown> }
88
190
  });
89
191
  },
90
192
 
91
- deleteAll: driver.deleteAll
92
- ? async (): Promise<void> => {
93
- return driver.deleteAll!(slug);
94
- }
95
- : undefined,
96
-
97
- count: driver.countEntities
193
+ count: driver.count
98
194
  ? async (params?: FindParams): Promise<number> => {
99
- const filter = params?.where ? deserializeFilter(params.where as any) : undefined;
100
- return driver.countEntities!({
195
+ const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
196
+ return driver.count!({
101
197
  path: slug,
102
198
  filter
103
199
  });
@@ -106,7 +202,6 @@ values: {} as Record<string, unknown> }
106
202
 
107
203
  listen: driver.listenCollection
108
204
  ? (params: FindParams | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
109
- const orderParsed = parseOrderBy(params?.orderBy);
110
205
  const limit = params?.limit ?? 20;
111
206
  const offset = params?.offset ?? 0;
112
207
  return driver.listenCollection!<M>({
@@ -114,12 +209,12 @@ values: {} as Record<string, unknown> }
114
209
  limit: params?.limit,
115
210
  offset: params?.offset,
116
211
  filter: params?.where,
117
- orderBy: orderParsed?.[0],
118
- order: orderParsed?.[1],
212
+ orderBy: params?.orderBy?.[0],
213
+ order: params?.orderBy?.[1],
119
214
  searchString: params?.searchString,
120
215
  onUpdate: (entities) => {
121
216
  onUpdate({
122
- data: entities,
217
+ data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(row, slug, getPks())),
123
218
  meta: {
124
219
  total: entities.length,
125
220
  limit,
@@ -132,12 +227,12 @@ values: {} as Record<string, unknown> }
132
227
  });
133
228
  } : undefined,
134
229
 
135
- listenById: driver.listenEntity
230
+ listenById: driver.listenOne
136
231
  ? (id: string | number, onUpdate: (entity: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
137
- return driver.listenEntity!<M>({
232
+ return driver.listenOne!<M>({
138
233
  path: slug,
139
- entityId: id,
140
- onUpdate: (entity) => onUpdate(entity ?? undefined),
234
+ id: id,
235
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity<M>(entity, slug, getPks()) : undefined),
141
236
  onError
142
237
  });
143
238
  } : undefined,
@@ -180,15 +275,16 @@ values: {} as Record<string, unknown> }
180
275
  * @example
181
276
  * const data = buildRebaseData(driver);
182
277
  * await data.products.create({ name: "Camera", price: 299 });
183
- * const { data: items } = await data.products.find({ where: { status: "eq.published" } });
278
+ * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
184
279
  */
185
- export function buildRebaseData(driver: DataDriver): RebaseData {
280
+ export function buildRebaseData(driver: DataDriver, options?: EntityDataOptions): RebaseData {
186
281
  const cache = new Map<string, CollectionAccessor>();
282
+ const primaryKeysFor = createPrimaryKeyResolver(options);
187
283
 
188
284
  function getAccessor(slug: string): CollectionAccessor {
189
285
  let accessor = cache.get(slug);
190
286
  if (!accessor) {
191
- accessor = createDriverAccessor(driver, slug);
287
+ accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
192
288
  cache.set(slug, accessor);
193
289
  }
194
290
  return accessor;
@@ -212,3 +308,277 @@ export function buildRebaseData(driver: DataDriver): RebaseData {
212
308
  }
213
309
  });
214
310
  }
311
+
312
+ // =============================================================================
313
+ // SDK data — flat rows (symmetric with the frontend SDK client)
314
+ // =============================================================================
315
+
316
+ /**
317
+ * Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
318
+ * the row untouched under `.values` and derives `.id` alongside it, so dropping
319
+ * the wrapper is the whole operation — the address was never part of the row.
320
+ */
321
+ function entityToRow<M extends Record<string, unknown>>(entity: Entity<M>): M {
322
+ return entity.values as unknown as M;
323
+ }
324
+
325
+ /**
326
+ * Fluent query builder for the flat SDK data layer. Mirrors {@link QueryBuilder}
327
+ * but resolves to `FindResult<M>` (flat rows) instead of Entity-wrapped
328
+ * `FindResponse<M>`.
329
+ */
330
+ class SdkQueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements SDKQueryBuilderInterface<M> {
331
+ private params: FindParams = { where: {} };
332
+
333
+ constructor(private client: SDKCollectionClient<M>) {}
334
+
335
+ where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
336
+ where(logicalCondition: LogicalCondition): this;
337
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
338
+ if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
339
+ this.params.logical = columnOrCondition as LogicalCondition;
340
+ return this;
341
+ }
342
+ if (!this.params.where) this.params.where = {};
343
+ const column = columnOrCondition as string;
344
+ const condition: [WhereFilterOp, unknown] = [operator!, value];
345
+ const existing = this.params.where[column];
346
+ if (existing === undefined) {
347
+ this.params.where[column] = condition;
348
+ } else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
349
+ (this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
350
+ } else {
351
+ let firstCondition: [WhereFilterOp, unknown];
352
+ if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
353
+ firstCondition = existing as [WhereFilterOp, unknown];
354
+ } else {
355
+ firstCondition = ["==", existing];
356
+ }
357
+ this.params.where[column] = [firstCondition, condition];
358
+ }
359
+ return this;
360
+ }
361
+
362
+ orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
363
+ this.params.orderBy = [column, direction];
364
+ return this;
365
+ }
366
+
367
+ limit(count: number): this { this.params.limit = count; return this; }
368
+ offset(count: number): this { this.params.offset = count; return this; }
369
+ search(searchString: string): this { this.params.searchString = searchString; return this; }
370
+ include(...relations: string[]): this { this.params.include = relations; return this; }
371
+
372
+ async find(): Promise<FindResult<M>> {
373
+ return this.client.find(this.params);
374
+ }
375
+
376
+ async count(): Promise<number> {
377
+ return this.client.count ? this.client.count(this.params) : 0;
378
+ }
379
+
380
+ listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void {
381
+ if (!this.client.listen) {
382
+ throw new Error("Listen is only available when the driver supports realtime.");
383
+ }
384
+ return this.client.listen(this.params, onUpdate, onError);
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Wrap a Entity-shaped {@link CollectionAccessor} into a flat
390
+ * {@link SDKCollectionClient}. Every returned record is unwrapped to a flat row
391
+ * so the backend SDK is byte-for-byte the same shape as the frontend client.
392
+ */
393
+ function toSdkCollectionClient<M extends Record<string, unknown>>(
394
+ snap: CollectionAccessor<M>
395
+ ): SDKCollectionClient<M> {
396
+ const client: SDKCollectionClient<M> = {
397
+ async find(params?: FindParams): Promise<FindResult<M>> {
398
+ const res = await snap.find(params);
399
+ return { data: res.data.map(entityToRow), meta: res.meta };
400
+ },
401
+ async findById(id: string | number): Promise<M | undefined> {
402
+ const s = await snap.findById(id);
403
+ return s ? entityToRow(s) : undefined;
404
+ },
405
+ async create(data: Partial<M>, id?: string | number): Promise<M> {
406
+ return entityToRow(await snap.create(data as Partial<EntityValues<M>>, id));
407
+ },
408
+ async createMany(data: Partial<M>[], options?: { upsert?: boolean }): Promise<M[]> {
409
+ if (!Array.isArray(data)) {
410
+ throw new TypeError("createMany expects an array of records.");
411
+ }
412
+ if (data.length === 0) return [];
413
+ if (!snap.createMany) {
414
+ throw new Error(
415
+ "Bulk writes are not supported by this collection's data source. " +
416
+ "Fall back to create() per record."
417
+ );
418
+ }
419
+ const rows = await snap.createMany(data as Partial<EntityValues<M>>[], options);
420
+ return rows.map(entityToRow);
421
+ },
422
+ async update(id: string | number, data: Partial<M>): Promise<M> {
423
+ return entityToRow(await snap.update(id, data as Partial<EntityValues<M>>));
424
+ },
425
+ delete(id: string | number): Promise<void> {
426
+ return snap.delete(id);
427
+ },
428
+ count: snap.count ? (params?: FindParams) => snap.count!(params) : undefined,
429
+ listen: snap.listen
430
+ ? (params: FindParams | undefined, onUpdate: (r: FindResult<M>) => void, onError?: (e: Error) => void) =>
431
+ snap.listen!(params, (res) => onUpdate({ data: res.data.map(entityToRow), meta: res.meta }), onError)
432
+ : undefined,
433
+ listenById: snap.listenById
434
+ ? (id: string | number, onUpdate: (r: M | undefined) => void, onError?: (e: Error) => void) =>
435
+ snap.listenById!(id, (s) => onUpdate(s ? entityToRow(s) : undefined), onError)
436
+ : undefined,
437
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
438
+ const builder = new SdkQueryBuilder<M>(client);
439
+ if (typeof columnOrCondition === "object") {
440
+ return builder.where(columnOrCondition);
441
+ }
442
+ return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
443
+ },
444
+ orderBy: (column: keyof M & string, direction?: "asc" | "desc") => new SdkQueryBuilder<M>(client).orderBy(column, direction),
445
+ limit: (count: number) => new SdkQueryBuilder<M>(client).limit(count),
446
+ offset: (count: number) => new SdkQueryBuilder<M>(client).offset(count),
447
+ search: (searchString: string) => new SdkQueryBuilder<M>(client).search(searchString),
448
+ include: (...relations: string[]) => new SdkQueryBuilder<M>(client).include(...relations)
449
+ };
450
+ return client;
451
+ }
452
+
453
+ /**
454
+ * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
455
+ * {@link CollectionAccessor}. Every returned row is re-wrapped into the
456
+ * `{ id, path, values }` view-model the admin CMS renders.
457
+ */
458
+ function toEntityAccessor<M extends Record<string, unknown>>(
459
+ sdk: SDKCollectionClient<M>,
460
+ slug: string,
461
+ getPks: () => PrimaryKeyInfo[] = () => []
462
+ ): CollectionAccessor<M> {
463
+ const accessor: CollectionAccessor<M> = {
464
+ async find(params?: FindParams): Promise<FindResponse<M>> {
465
+ const res = await sdk.find(params);
466
+ return { data: res.data.map((row) => rowToEntity<M>(row, slug, getPks())), meta: res.meta };
467
+ },
468
+ async findById(id: string | number): Promise<Entity<M> | undefined> {
469
+ const row = await sdk.findById(id);
470
+ return row ? rowToEntity<M>(row, slug, getPks()) : undefined;
471
+ },
472
+ async create(data: Partial<EntityValues<M>>, id?: string | number): Promise<Entity<M>> {
473
+ return rowToEntity<M>(await sdk.create(data as Partial<M>, id), slug, getPks());
474
+ },
475
+ async update(id: string | number, data: Partial<EntityValues<M>>): Promise<Entity<M>> {
476
+ const row = await sdk.update(id, data as Partial<M>);
477
+ if (!row) throw new Error(`Update returned no data for id ${id}`);
478
+ return rowToEntity<M>(row, slug, getPks());
479
+ },
480
+ delete(id: string | number): Promise<void> {
481
+ return sdk.delete(id);
482
+ },
483
+ count: sdk.count ? (params?: FindParams) => sdk.count!(params) : undefined,
484
+ listen: sdk.listen
485
+ ? (params: FindParams | undefined, onUpdate: (r: FindResponse<M>) => void, onError?: (e: Error) => void) =>
486
+ sdk.listen!(params, (res) => onUpdate({ data: res.data.map((row) => rowToEntity<M>(row, slug, getPks())), meta: res.meta }), onError)
487
+ : undefined,
488
+ listenById: sdk.listenById
489
+ ? (id: string | number, onUpdate: (s: Entity<M> | undefined) => void, onError?: (e: Error) => void) =>
490
+ sdk.listenById!(id, (row) => onUpdate(row ? rowToEntity<M>(row, slug, getPks()) : undefined), onError)
491
+ : undefined,
492
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
493
+ const builder = new QueryBuilder<M>(accessor);
494
+ if (typeof columnOrCondition === "object") {
495
+ return builder.where(columnOrCondition);
496
+ }
497
+ return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
498
+ },
499
+ orderBy: (column: keyof M & string, direction?: "asc" | "desc") => new QueryBuilder<M>(accessor).orderBy(column, direction),
500
+ limit: (count: number) => new QueryBuilder<M>(accessor).limit(count),
501
+ offset: (count: number) => new QueryBuilder<M>(accessor).offset(count),
502
+ search: (searchString: string) => new QueryBuilder<M>(accessor).search(searchString),
503
+ include: (...relations: string[]) => new QueryBuilder<M>(accessor).include(...relations)
504
+ };
505
+ return accessor;
506
+ }
507
+
508
+ /**
509
+ * Wrap a flat {@link RebaseSdkData} into a Entity-shaped {@link RebaseData}.
510
+ *
511
+ * This is the **CMS boundary**: the SDK client (`client.data`) returns flat
512
+ * rows, but the admin renders the `Entity` view-model (`entity.values.*`).
513
+ * `core/Rebase.tsx` wraps `client.data` through this before handing it to the
514
+ * CMS `RebaseDataContext` — without it the admin renders rows with only their
515
+ * `id`.
516
+ */
517
+ export function wrapAsEntityData(sdkData: RebaseSdkData, options?: EntityDataOptions): RebaseData {
518
+ const cache = new Map<string, CollectionAccessor>();
519
+ const primaryKeysFor = createPrimaryKeyResolver(options);
520
+
521
+ function getAccessor(slug: string): CollectionAccessor {
522
+ let accessor = cache.get(slug);
523
+ if (!accessor) {
524
+ accessor = toEntityAccessor(sdkData.collection(slug), slug, () => primaryKeysFor(slug));
525
+ cache.set(slug, accessor);
526
+ }
527
+ return accessor;
528
+ }
529
+
530
+ const target = { collection: getAccessor } as RebaseData;
531
+
532
+ return new Proxy(target, {
533
+ get(_target, prop: string | symbol) {
534
+ if (prop === "collection") return getAccessor;
535
+ if (typeof prop === "symbol") return undefined;
536
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
537
+ return getAccessor(toSnakeCase(prop));
538
+ }
539
+ });
540
+ }
541
+
542
+ /**
543
+ * Wrap a Entity-shaped {@link RebaseData} into a flat {@link RebaseSdkData}.
544
+ *
545
+ * Every collection accessor is adapted to return flat rows. Use this to derive
546
+ * the flat SDK data layer (`context.data`) from an existing Entity data layer
547
+ * — e.g. the admin routes its Entity data via `useData()` and exposes the
548
+ * same routing as flat `context.data` for callbacks by wrapping it here.
549
+ */
550
+ export function wrapAsSdkData(entityData: RebaseData): RebaseSdkData {
551
+ const cache = new Map<string, SDKCollectionClient>();
552
+
553
+ function getAccessor(slug: string): SDKCollectionClient {
554
+ let accessor = cache.get(slug);
555
+ if (!accessor) {
556
+ accessor = toSdkCollectionClient(entityData.collection(slug));
557
+ cache.set(slug, accessor);
558
+ }
559
+ return accessor;
560
+ }
561
+
562
+ const target = { collection: getAccessor } as RebaseSdkData;
563
+
564
+ return new Proxy(target, {
565
+ get(_target, prop: string | symbol) {
566
+ if (prop === "collection") return getAccessor;
567
+ if (typeof prop === "symbol") return undefined;
568
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
569
+ return getAccessor(toSnakeCase(prop));
570
+ }
571
+ });
572
+ }
573
+
574
+ /**
575
+ * Build a flat {@link RebaseSdkData} from a `DataDriver`.
576
+ *
577
+ * This is the developer-facing SDK data layer used by backend framework
578
+ * callbacks & scripts (`context.data` / `rebase.data`). It returns flat rows —
579
+ * identical in shape to the frontend SDK client — so the API is symmetric
580
+ * across front and back. The admin CMS uses {@link buildRebaseData} (Entity).
581
+ */
582
+ export function buildSdkData(driver: DataDriver): RebaseSdkData {
583
+ return wrapAsSdkData(buildRebaseData(driver));
584
+ }
@@ -1,24 +1,30 @@
1
- import { RebaseData, CollectionAccessor } from "@rebasepro/types";
1
+ import { RebaseData, RebaseSdkData } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
 
4
+ /**
5
+ * The two data-layer shapes that can be routed: the Entity-shaped admin
6
+ * {@link RebaseData} or the flat SDK {@link RebaseSdkData}. Both expose a
7
+ * `.collection(slug)` accessor, which is all the router needs.
8
+ */
9
+ export type RoutableData = RebaseData | RebaseSdkData;
10
+
4
11
  /**
5
12
  * Parameters for {@link buildRoutedRebaseData}.
6
13
  */
7
- export interface RoutedRebaseDataParams {
14
+ export interface RoutedRebaseDataParams<T extends RoutableData = RebaseData> {
8
15
  /**
9
16
  * The default data source. Handles every collection that does not
10
17
  * resolve to an entry in `sources` (i.e. server-transport collections,
11
18
  * which ride the Rebase client).
12
19
  */
13
- defaultData: RebaseData;
20
+ defaultData: T;
14
21
 
15
22
  /**
16
- * Per-data-source {@link RebaseData} instances for direct and custom
17
- * transports, keyed by data-source key (e.g. `"analytics"`). Server-
18
- * mediated sources are not listed here — they fall through to
19
- * `defaultData`.
23
+ * Per-data-source instances for direct and custom transports, keyed by
24
+ * data-source key (e.g. `"analytics"`). Server-mediated sources are not
25
+ * listed here — they fall through to `defaultData`.
20
26
  */
21
- sources: Record<string, RebaseData>;
27
+ sources: Record<string, T>;
22
28
 
23
29
  /**
24
30
  * Resolve the data-source key for a given collection slug or path.
@@ -55,11 +61,11 @@ export interface RoutedRebaseDataParams {
55
61
  * await data.products.find(); // → default (server / Postgres)
56
62
  * await data.events.find(); // → Firestore, if `events.dataSource === "analytics"`
57
63
  */
58
- export function buildRoutedRebaseData({
64
+ export function buildRoutedRebaseData<T extends RoutableData = RebaseData>({
59
65
  defaultData,
60
66
  sources,
61
67
  resolveKey
62
- }: RoutedRebaseDataParams): RebaseData {
68
+ }: RoutedRebaseDataParams<T>): T {
63
69
 
64
70
  // Fast path: nothing to route → return the default untouched (preserves
65
71
  // referential identity for effect dependencies).
@@ -67,21 +73,21 @@ export function buildRoutedRebaseData({
67
73
  return defaultData;
68
74
  }
69
75
 
70
- function resolve(slugOrPath: string): RebaseData {
76
+ function resolve(slugOrPath: string): T {
71
77
  const key = resolveKey(slugOrPath);
72
78
  if (key && sources[key]) return sources[key];
73
79
  return defaultData;
74
80
  }
75
81
 
76
- function getAccessor(slugOrPath: string): CollectionAccessor {
77
- return resolve(slugOrPath).collection(slugOrPath);
82
+ function getAccessor(slugOrPath: string) {
83
+ return (resolve(slugOrPath) as RoutableData).collection(slugOrPath);
78
84
  }
79
85
 
80
86
  const target = {
81
87
  collection: getAccessor
82
- } as RebaseData;
88
+ } as unknown as T;
83
89
 
84
- return new Proxy(target, {
90
+ return new Proxy(target as object, {
85
91
  get(_target, prop: string | symbol) {
86
92
  if (prop === "collection") return getAccessor;
87
93
  // Ignore Symbol properties (e.g. Symbol.toPrimitive, Symbol.iterator)
@@ -93,5 +99,5 @@ export function buildRoutedRebaseData({
93
99
  // buildRebaseData so dynamic access routes consistently.
94
100
  return getAccessor(toSnakeCase(prop));
95
101
  }
96
- });
102
+ }) as T;
97
103
  }