@rebasepro/common 0.17.3 → 0.18.1

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 (58) hide show
  1. package/README.md +4 -0
  2. package/dist/collections/CollectionRegistry.d.ts +1 -1
  3. package/dist/collections/default-collections.d.ts +15 -84
  4. package/dist/data/buildRebaseData.d.ts +1 -1
  5. package/dist/data/filter-dialect.d.ts +11 -0
  6. package/dist/data/sort-dialect.d.ts +15 -3
  7. package/dist/index.es.js +375 -63
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/util/builders.d.ts +69 -24
  10. package/dist/util/callback-errors.d.ts +77 -0
  11. package/dist/util/callback-errors.test.d.ts +1 -0
  12. package/dist/util/index.d.ts +1 -0
  13. package/dist/util/policy/evaluatePolicy.d.ts +6 -0
  14. package/dist/util/relations.d.ts +41 -0
  15. package/dist/util/table-name.test.d.ts +1 -0
  16. package/package.json +26 -22
  17. package/src/collections/CollectionRegistry.ts +0 -485
  18. package/src/collections/default-collections.ts +0 -109
  19. package/src/collections/index.ts +0 -2
  20. package/src/data/buildRebaseData.ts +0 -816
  21. package/src/data/buildRoutedRebaseData.ts +0 -103
  22. package/src/data/filter-conditions.ts +0 -46
  23. package/src/data/filter-dialect.ts +0 -737
  24. package/src/data/paginate.ts +0 -334
  25. package/src/data/query_builder.ts +0 -176
  26. package/src/data/resolveDataSource.ts +0 -135
  27. package/src/data/sort-dialect.ts +0 -237
  28. package/src/index.ts +0 -11
  29. package/src/table-classification.ts +0 -109
  30. package/src/types/json-logic-js.d.ts +0 -8
  31. package/src/util/auth-default-policies.ts +0 -215
  32. package/src/util/builders.ts +0 -82
  33. package/src/util/callbacks.ts +0 -122
  34. package/src/util/collections.ts +0 -117
  35. package/src/util/common.ts +0 -2
  36. package/src/util/conditions.ts +0 -168
  37. package/src/util/email.ts +0 -32
  38. package/src/util/entities.ts +0 -282
  39. package/src/util/enums.ts +0 -26
  40. package/src/util/identity.ts +0 -202
  41. package/src/util/index.ts +0 -21
  42. package/src/util/internal-tables.test.ts +0 -188
  43. package/src/util/internal-tables.ts +0 -197
  44. package/src/util/junction-policies.ts +0 -355
  45. package/src/util/paths.ts +0 -27
  46. package/src/util/permissions.test.ts +0 -866
  47. package/src/util/permissions.ts +0 -206
  48. package/src/util/pg-column-to-property.ts +0 -377
  49. package/src/util/policy/evaluatePolicy.ts +0 -194
  50. package/src/util/policy/index.ts +0 -4
  51. package/src/util/policy/policyToPostgres.ts +0 -263
  52. package/src/util/policy/securityRuleToConditions.ts +0 -67
  53. package/src/util/policy/sqlToPolicy.ts +0 -422
  54. package/src/util/relations.ts +0 -236
  55. package/src/util/resolutions.ts +0 -534
  56. package/src/util/resolve-relation.ts +0 -243
  57. package/src/util/storage.ts +0 -177
  58. package/src/util/string-column-length.ts +0 -31
@@ -1,816 +0,0 @@
1
- import {
2
- CollectionAccessor,
3
- DataDriver,
4
- Entity,
5
- EntityValues,
6
- FindAllParams,
7
- FindParams,
8
- FindResponse,
9
- FindResult,
10
- IterateParams,
11
- LogicalCondition,
12
- OrderByTuple,
13
- RebaseData,
14
- RebaseSdkData,
15
- SDKCollectionClient,
16
- SDKQueryBuilderInterface,
17
- WhereFilterOp,
18
- WhereValueFor,
19
- type ComputedSortField,
20
- type SearchMatch
21
- } from "@rebasepro/types";
22
- import { toSnakeCase } from "@rebasepro/utils";
23
- import { QueryBuilder } from "./query_builder";
24
- import { collectAllPages, paginateFind, resolveFindWindow } from "./paginate";
25
- import { normalizeOrderBy } from "./sort-dialect";
26
- import { deserializeFilter } from "./filter-dialect";
27
- import { buildCompositeId, resolvePrimaryKeys, PrimaryKeyInfo } from "../util/identity";
28
-
29
- export interface EntityDataOptions {
30
- /**
31
- * Look up a collection's config by slug, to derive row addresses from its
32
- * primary keys.
33
- *
34
- * Called lazily rather than up front: the data layer is created by `Rebase`,
35
- * which sits *above* the admin that owns the collections, so a resolver
36
- * registered on mount would otherwise arrive too late to be seen.
37
- */
38
- resolveCollection?: (slug: string) => { properties?: Record<string, unknown> } | undefined;
39
- }
40
-
41
- function createPrimaryKeyResolver(options?: EntityDataOptions) {
42
- const cache = new Map<string, PrimaryKeyInfo[]>();
43
- const warned = new Set<string>();
44
-
45
- return function primaryKeysFor(slug: string): PrimaryKeyInfo[] {
46
- const cached = cache.get(slug);
47
- if (cached) return cached;
48
-
49
- const collection = options?.resolveCollection?.(slug);
50
- if (!collection) {
51
- // The registry may not have been registered yet. Don't memoize a
52
- // miss, or the collection would stay address-less for this session.
53
- return [];
54
- }
55
-
56
- const keys = resolvePrimaryKeys(collection);
57
- if (keys.length > 0) {
58
- // Memoized for the session: a collection's key does not change
59
- // while the app runs, and this is called once per row. Editing
60
- // `isId` in the schema editor needs a reload to take effect here.
61
- cache.set(slug, keys);
62
- return keys;
63
- }
64
-
65
- if (!warned.has(slug)) {
66
- warned.add(slug);
67
- // Silence here surfaces much later as rows that cannot be opened,
68
- // linked, or saved, with nothing pointing back at the cause.
69
- console.warn(
70
- `[rebase] Collection '${slug}' declares no primary key, so its rows have no address: ` +
71
- `detail links, caching and relations will not work for it. ` +
72
- `Mark the key property with \`isId\` in its collection config — the server logs which ` +
73
- `column to mark at boot, if its schema knows the key.`
74
- );
75
- }
76
- return keys;
77
- };
78
- }
79
-
80
- /**
81
- * Give a flat row the Entity view-model the admin renders.
82
- *
83
- * The address is *derived here* — it is not a column, and the row it came from
84
- * does not contain one. Rows carry exactly what the table has, with the types
85
- * Postgres returned; the id is this layer's invention, and this is the only
86
- * place it is minted.
87
- *
88
- * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
89
- * than postgres still serve rows with one, and this keeps them working.
90
- */
91
- function rowToEntity<M extends Record<string, unknown>>(
92
- row: Record<string, unknown>,
93
- slug: string,
94
- primaryKeys: PrimaryKeyInfo[] = []
95
- ): Entity<M> {
96
- // Query-computed metadata rides in on the row because that is how the wire
97
- // carries it, but it is not a column: it belongs beside `values`, not in
98
- // them. Left inside, `_matches` would show up in the record inspector as a
99
- // field the collection never declared.
100
- const { _matches, ...values } = row as Record<string, unknown> & { _matches?: SearchMatch[] };
101
-
102
- return {
103
- id: primaryKeys.length > 0
104
- ? buildCompositeId(row, primaryKeys)
105
- : row.id as string | number,
106
- path: slug,
107
- values: values as EntityValues<M>,
108
- ...(_matches ? { searchMatches: _matches } : {})
109
- };
110
- }
111
-
112
- /**
113
- * The relation envelope `toFlatRow` writes where a relation was:
114
- * `{ id, path, __type: "relation", data: { id, path, values } }`. It is the
115
- * admin's view-model, and the only pipeline that produces one is postgres'.
116
- */
117
- function isRelationEnvelope(
118
- value: unknown
119
- ): value is { __type: "relation"; data?: { values?: Record<string, unknown> } } {
120
- return typeof value === "object"
121
- && value !== null
122
- && !Array.isArray(value)
123
- && (value as { __type?: unknown }).__type === "relation";
124
- }
125
-
126
- /** The target's own columns, as `toRestRow` would have inlined them. */
127
- function inlineEnvelope(envelope: { data?: { values?: Record<string, unknown> } }): Record<string, unknown> {
128
- return envelope.data?.values ?? {};
129
- }
130
-
131
- /**
132
- * Replace every relation envelope on a row with the target's flat columns.
133
- *
134
- * The SDK serves one relation shape — the inlined one (see
135
- * {@link RestFetchService}) — and reads that come back through a *driver*
136
- * method rather than the REST pipeline still carry envelopes. Realtime is the
137
- * one such read left: there is no `listenForRest`, so the rows arrive shaped
138
- * for the admin and are flattened here instead.
139
- *
140
- * Only applied where the REST pipeline is the contract (see `find`); a driver
141
- * without a `restFetchService` keeps whatever it returns, so the admin's own
142
- * path through {@link buildRebaseData} is untouched.
143
- */
144
- function inlineRelationRefs(row: Record<string, unknown>): Record<string, unknown> {
145
- let out: Record<string, unknown> | undefined;
146
- for (const [key, value] of Object.entries(row)) {
147
- if (isRelationEnvelope(value)) {
148
- out = out ?? { ...row };
149
- out[key] = inlineEnvelope(value);
150
- } else if (Array.isArray(value) && value.some(isRelationEnvelope)) {
151
- out = out ?? { ...row };
152
- out[key] = value.map((item) => isRelationEnvelope(item) ? inlineEnvelope(item) : item);
153
- }
154
- }
155
- return out ?? row;
156
- }
157
-
158
- function createDriverAccessor<M extends Record<string, unknown> = Record<string, unknown>>(
159
- driver: DataDriver,
160
- slug: string,
161
- getPks: () => PrimaryKeyInfo[] = () => []
162
- ): CollectionAccessor<M> {
163
- const accessor: CollectionAccessor<M> = {
164
- async find(params?: FindParams<M>): Promise<FindResponse<M>> {
165
- // Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
166
- const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
167
- const { limit, offset, driverOffset } = resolveFindWindow(params);
168
-
169
- // One relation shape, whatever the call looks like.
170
- //
171
- // This used to fork on `include`: asking for one ran the REST
172
- // pipeline, which inlines a relation as the target's own columns;
173
- // not asking ran the driver's own fetch, which eagerly loaded
174
- // *every* relation and put a `{ __type: "relation" }` envelope
175
- // where the foreign key was. The same method answered in two
176
- // shapes, the generated types described only one, and a column
177
- // typed `string` arrived as an object.
178
- //
179
- // The REST pipeline is the published contract — the shape the HTTP
180
- // API serves for this same query, and what `RestFetchService`
181
- // documents — so every read goes through it when the driver has
182
- // one. Drivers without one (every browser driver, and so the
183
- // admin's own path through `buildRebaseData`) are untouched.
184
- const fetchService = driver.restFetchService;
185
- const rows = fetchService
186
- ? await fetchService.fetchCollectionForRest(
187
- slug,
188
- {
189
- filter,
190
- // Without this the group was dropped and the read ran
191
- // unfiltered — every row the caller's policies allow,
192
- // in place of the ones they asked for.
193
- logical: params?.logical,
194
- limit,
195
- offset: driverOffset,
196
- orderBy: normalizeOrderBy(params?.orderBy),
197
- searchString: params?.searchString
198
- },
199
- params?.include
200
- )
201
- : await driver.fetchCollection<M>({
202
- path: slug,
203
- limit,
204
- offset: driverOffset,
205
- filter,
206
- logical: params?.logical,
207
- orderBy: normalizeOrderBy(params?.orderBy),
208
- searchString: params?.searchString
209
- });
210
-
211
- // Compute real total when count is available
212
- let total = rows.length + offset;
213
- let hasMore = rows.length >= limit;
214
- if (driver.count) {
215
- // The same narrowing the rows were read with. Counting only by
216
- // `filter` reported the whole collection beside a narrowed
217
- // page, and `hasMore` is derived from it — so the list offered
218
- // a next page that did not exist.
219
- total = await driver.count({
220
- path: slug,
221
- filter,
222
- logical: params?.logical,
223
- searchString: params?.searchString
224
- });
225
- hasMore = offset + rows.length < total;
226
- }
227
-
228
- return {
229
- data: rows.map((row: Record<string, unknown>) => rowToEntity<M>(row, slug, getPks())),
230
- meta: { total, limit, offset, hasMore }
231
- };
232
- },
233
-
234
- async findById(id: string | number): Promise<Entity<M> | undefined> {
235
- // Same contract as `find` above: one row read the same way the
236
- // collection read is, so `find()[0]` and `findById()` agree.
237
- const fetchService = driver.restFetchService;
238
- const row = fetchService
239
- ? await fetchService.fetchOneForRest(slug, id)
240
- : await driver.fetchOne<M>({ path: slug, id: id });
241
- return row ? rowToEntity<M>(row, slug, getPks()) : undefined;
242
- },
243
-
244
- async create(data: Partial<EntityValues<M>>, id?: string | number): Promise<Entity<M>> {
245
- const row = await driver.save<M>({
246
- path: slug,
247
- values: data,
248
- id: id,
249
- status: "new"
250
- });
251
- return rowToEntity<M>(row, slug, getPks());
252
- },
253
-
254
- createMany: driver.saveMany
255
- ? async (data: Partial<EntityValues<M>>[], options?: { upsert?: boolean }): Promise<Entity<M>[]> => {
256
- const rows = await driver.saveMany!<M>({
257
- path: slug,
258
- rows: data,
259
- upsert: options?.upsert
260
- });
261
- return rows.map((row) => rowToEntity<M>(row, slug, getPks()));
262
- }
263
- : undefined,
264
-
265
- async update(id: string | number, data: Partial<EntityValues<M>>): Promise<Entity<M>> {
266
- const row = await driver.save<M>({
267
- path: slug,
268
- values: data,
269
- id: id,
270
- status: "existing"
271
- });
272
- return rowToEntity<M>(row, slug, getPks());
273
- },
274
-
275
- async delete(id: string | number): Promise<void> {
276
- return driver.delete({
277
- row: { id,
278
- path: slug,
279
- values: {} as Record<string, unknown> }
280
- });
281
- },
282
-
283
- // Present only when the driver is: exposing these unconditionally and
284
- // looping single writes underneath would give a caller neither the
285
- // atomicity nor the single round trip they reached for a batch to get,
286
- // while looking exactly like it had.
287
- updateMany: driver.updateMany
288
- ? async (updates: { id: string | number; data: Partial<EntityValues<M>> }[]): Promise<Entity<M>[]> => {
289
- const rows = await driver.updateMany!<M>({
290
- path: slug,
291
- updates: updates.map(u => ({ id: u.id,
292
- values: u.data })),
293
- });
294
- return rows.map(row => rowToEntity<M>(row, slug, getPks()));
295
- }
296
- : undefined,
297
-
298
- deleteMany: driver.deleteMany
299
- ? async (ids: (string | number)[]): Promise<void> => {
300
- await driver.deleteMany!<M>({ path: slug,
301
- ids });
302
- }
303
- : undefined,
304
-
305
- count: driver.count
306
- ? async (params?: FindParams<M>): Promise<number> => {
307
- const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
308
- // Every narrowing `find()` applies has to apply here too, or
309
- // the count describes a different query than the one it is
310
- // reported against.
311
- return driver.count!({
312
- path: slug,
313
- filter,
314
- logical: params?.logical,
315
- searchString: params?.searchString
316
- });
317
- }
318
- : undefined,
319
-
320
- listen: driver.listenCollection
321
- ? (params: FindParams<M> | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
322
- const { limit, offset, driverOffset } = resolveFindWindow(params);
323
- // Realtime has no REST-pipeline equivalent, so the rows arrive
324
- // admin-shaped. Flatten them to the one shape the rest of this
325
- // accessor serves.
326
- const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
327
- return driver.listenCollection!<M>({
328
- path: slug,
329
- limit,
330
- offset: driverOffset,
331
- filter: params?.where,
332
- logical: params?.logical,
333
- orderBy: normalizeOrderBy(params?.orderBy),
334
- searchString: params?.searchString,
335
- searchExplain: params?.searchExplain,
336
- // Forwarded so the SERVER can refuse it. `realtimeService`
337
- // rejects a subscription carrying `vectorSearch` — a
338
- // subscription is re-run on every matching write and
339
- // nothing there computes distances — and the docs promise
340
- // that refusal. Both producers hand-list their fields and
341
- // both omitted this one, so the guard could not fire and
342
- // `.vectorSearch(…).listen()` returned an ordinary
343
- // `id DESC` listing with no `_distance` and no error.
344
- vectorSearch: params?.vectorSearch,
345
- onUpdate: (entities) => {
346
- onUpdate({
347
- data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(normalize(row), slug, getPks())),
348
- meta: {
349
- // No count is issued on this path, so the total
350
- // is unknown; the lower bound is the rows in
351
- // hand plus the ones paged past to reach them.
352
- // Reporting `entities.length` claimed a read at
353
- // offset 100 had found a collection of two.
354
- total: offset + entities.length,
355
- limit,
356
- offset,
357
- hasMore: entities.length >= limit
358
- }
359
- });
360
- },
361
- onError
362
- });
363
- } : undefined,
364
-
365
- listenById: driver.listenOne
366
- ? (id: string | number, onUpdate: (entity: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
367
- const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
368
- return driver.listenOne!<M>({
369
- path: slug,
370
- id: id,
371
- onUpdate: (entity) => onUpdate(entity ? rowToEntity<M>(normalize(entity), slug, getPks()) : undefined),
372
- onError
373
- });
374
- } : undefined,
375
-
376
- // Fluent Query Builder
377
- where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
378
- const builder = new QueryBuilder<M>(accessor);
379
- if (typeof columnOrCondition === "object") {
380
- return builder.where(columnOrCondition);
381
- }
382
- return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
383
- },
384
- orderBy(column: (keyof M & string) | ComputedSortField, ascending?: "asc" | "desc") {
385
- return new QueryBuilder<M>(accessor).orderBy(column, ascending);
386
- },
387
- limit(count: number) {
388
- return new QueryBuilder<M>(accessor).limit(count);
389
- },
390
- offset(count: number) {
391
- return new QueryBuilder<M>(accessor).offset(count);
392
- },
393
- search(searchString: string, options?: { explain?: boolean }) {
394
- return new QueryBuilder<M>(accessor).search(searchString, options);
395
- },
396
- vectorSearch(
397
- property: string,
398
- vector: number[],
399
- options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
400
- ) {
401
- return new QueryBuilder<M>(accessor).vectorSearch(property, vector, options);
402
- },
403
- include(...relations: string[]) {
404
- return new QueryBuilder<M>(accessor).include(...relations);
405
- }
406
- };
407
-
408
- return accessor;
409
- }
410
-
411
- /**
412
- * Build a `RebaseData` object from a `DataDriver` using JavaScript Proxy.
413
- *
414
- * This is the key bridge: any property access like `data.products` returns
415
- * a `CollectionAccessor` backed by the underlying DataDriver, without
416
- * needing per-collection code generation.
417
- *
418
- * @example
419
- * const data = buildRebaseData(driver);
420
- * await data.products.create({ name: "Camera", price: 299 });
421
- * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
422
- */
423
- export function buildRebaseData(driver: DataDriver, options?: EntityDataOptions): RebaseData {
424
- const cache = new Map<string, CollectionAccessor>();
425
- const primaryKeysFor = createPrimaryKeyResolver(options);
426
-
427
- function getAccessor(slug: string): CollectionAccessor {
428
- let accessor = cache.get(slug);
429
- if (!accessor) {
430
- accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
431
- cache.set(slug, accessor);
432
- }
433
- return accessor;
434
- }
435
-
436
- const target = {
437
- collection: getAccessor
438
- } as RebaseData;
439
-
440
- return new Proxy(target, {
441
- get(_target, prop: string | symbol) {
442
- if (prop === "collection") return getAccessor;
443
- // Ignore Symbol properties (e.g. Symbol.toPrimitive, Symbol.iterator)
444
- if (typeof prop === "symbol") return undefined;
445
- // Ignore internal JS properties
446
- if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
447
-
448
- // Convert camelCase property names to snake_case slugs
449
- const slug = toSnakeCase(prop);
450
- return getAccessor(slug);
451
- }
452
- });
453
- }
454
-
455
- // =============================================================================
456
- // SDK data — flat rows (symmetric with the frontend SDK client)
457
- // =============================================================================
458
-
459
- /**
460
- * Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
461
- * the row untouched under `.values` and derives `.id` alongside it, so dropping
462
- * the wrapper is the whole operation — the address was never part of the row.
463
- */
464
- function entityToRow<M extends Record<string, unknown>>(entity: Entity<M>): M {
465
- return entity.values as unknown as M;
466
- }
467
-
468
- /**
469
- * Fluent query builder for the flat SDK data layer. Mirrors {@link QueryBuilder}
470
- * but resolves to `FindResult<M>` (flat rows) instead of Entity-wrapped
471
- * `FindResponse<M>`.
472
- */
473
- class SdkQueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements SDKQueryBuilderInterface<M> {
474
- private params: FindParams = { where: {} };
475
-
476
- constructor(private client: SDKCollectionClient<M>) {}
477
-
478
- where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
479
- where(logicalCondition: LogicalCondition): this;
480
- where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
481
- if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
482
- this.params.logical = columnOrCondition as LogicalCondition;
483
- return this;
484
- }
485
- if (!this.params.where) this.params.where = {};
486
- const column = columnOrCondition as string;
487
- const condition: [WhereFilterOp, unknown] = [operator!, value];
488
- const existing = this.params.where[column];
489
- if (existing === undefined) {
490
- this.params.where[column] = condition;
491
- } else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
492
- (this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
493
- } else {
494
- let firstCondition: [WhereFilterOp, unknown];
495
- if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
496
- firstCondition = existing as [WhereFilterOp, unknown];
497
- } else {
498
- firstCondition = ["==", existing];
499
- }
500
- this.params.where[column] = [firstCondition, condition];
501
- }
502
- return this;
503
- }
504
-
505
- /** Called again, this adds a tie-breaker rather than replacing the sort. */
506
- orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
507
- const existing = normalizeOrderBy(this.params.orderBy) ?? [];
508
- this.params.orderBy = [...existing, [column, direction] as OrderByTuple];
509
- return this;
510
- }
511
-
512
- limit(count: number): this { this.params.limit = count; return this; }
513
- offset(count: number): this { this.params.offset = count; return this; }
514
- search(searchString: string, options?: { explain?: boolean }): this { this.params.searchString = searchString; if (options?.explain !== undefined) this.params.searchExplain = options.explain; return this; }
515
- vectorSearch(
516
- property: string,
517
- vector: number[],
518
- options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
519
- ): this {
520
- this.params.vectorSearch = {
521
- property,
522
- vector,
523
- ...(options?.distance !== undefined && { distance: options.distance }),
524
- ...(options?.threshold !== undefined && { threshold: options.threshold })
525
- };
526
- return this;
527
- }
528
- include(...relations: string[]): this { this.params.include = relations; return this; }
529
-
530
- async find(): Promise<FindResult<M>> {
531
- return this.client.find(this.params as FindParams<M>);
532
- }
533
-
534
- async count(): Promise<number> {
535
- return this.client.count ? this.client.count(this.params as FindParams<M>) : 0;
536
- }
537
-
538
- listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void {
539
- if (!this.client.listen) {
540
- throw new Error("Listen is only available when the driver supports realtime.");
541
- }
542
- return this.client.listen(this.params as FindParams<M>, onUpdate, onError);
543
- }
544
- }
545
-
546
- /**
547
- * Wrap a Entity-shaped {@link CollectionAccessor} into a flat
548
- * {@link SDKCollectionClient}. Every returned record is unwrapped to a flat row
549
- * so the backend SDK is byte-for-byte the same shape as the frontend client.
550
- */
551
- function toSdkCollectionClient<M extends Record<string, unknown>>(
552
- snap: CollectionAccessor<M>,
553
- slug = "collection"
554
- ): SDKCollectionClient<M> {
555
- const client: SDKCollectionClient<M> = {
556
- async find(params?: FindParams<M>): Promise<FindResult<M>> {
557
- const res = await snap.find(params);
558
- return { data: res.data.map(entityToRow), meta: res.meta };
559
- },
560
- // Pagination is shared with the HTTP client rather than reimplemented:
561
- // both transports satisfy the same `SDKCollectionClient`, so a walk that
562
- // behaved differently in-process than over the wire would be a bug the
563
- // type system could not see.
564
- iterate(params?: IterateParams<M>) {
565
- return paginateFind<M>((p) => client.find(p), params, slug);
566
- },
567
- findAll(params?: FindAllParams<M>) {
568
- return collectAllPages<M>((p) => client.find(p), params, slug);
569
- },
570
- async findById(id: string | number): Promise<M | undefined> {
571
- const s = await snap.findById(id);
572
- return s ? entityToRow(s) : undefined;
573
- },
574
- async create(data: Partial<M>, id?: string | number): Promise<M> {
575
- return entityToRow(await snap.create(data as Partial<EntityValues<M>>, id));
576
- },
577
- async createMany(data: Partial<M>[], options?: { upsert?: boolean }): Promise<M[]> {
578
- if (!Array.isArray(data)) {
579
- throw new TypeError("createMany expects an array of records.");
580
- }
581
- if (data.length === 0) return [];
582
- if (!snap.createMany) {
583
- throw new Error(
584
- "Bulk writes are not supported by this collection's data source. " +
585
- "Fall back to create() per record."
586
- );
587
- }
588
- const rows = await snap.createMany(data as Partial<EntityValues<M>>[], options);
589
- return rows.map(entityToRow);
590
- },
591
- async update(id: string | number, data: Partial<M>): Promise<M> {
592
- return entityToRow(await snap.update(id, data as Partial<EntityValues<M>>));
593
- },
594
- async updateMany(updates: { id: string | number; data: Partial<M> }[]): Promise<M[]> {
595
- if (!Array.isArray(updates)) {
596
- throw new TypeError("updateMany expects an array of { id, data } entries.");
597
- }
598
- if (updates.length === 0) return [];
599
- if (!snap.updateMany) {
600
- throw new Error(
601
- "Bulk updates are not supported by this collection's data source. " +
602
- "Fall back to update() per record."
603
- );
604
- }
605
- const rows = await snap.updateMany(
606
- updates.map(u => ({ id: u.id,
607
- data: u.data as Partial<EntityValues<M>> }))
608
- );
609
- return rows.map(entityToRow);
610
- },
611
- delete(id: string | number): Promise<void> {
612
- return snap.delete(id);
613
- },
614
- async deleteMany(ids: (string | number)[]): Promise<void> {
615
- if (!Array.isArray(ids)) {
616
- throw new TypeError("deleteMany expects an array of ids.");
617
- }
618
- if (ids.length === 0) return;
619
- if (!snap.deleteMany) {
620
- throw new Error(
621
- "Bulk deletes are not supported by this collection's data source. " +
622
- "Fall back to delete() per record."
623
- );
624
- }
625
- await snap.deleteMany(ids);
626
- },
627
- count: snap.count ? (params?: FindParams<M>) => snap.count!(params) : undefined,
628
- listen: snap.listen
629
- ? (params: FindParams<M> | undefined, onUpdate: (r: FindResult<M>) => void, onError?: (e: Error) => void) =>
630
- snap.listen!(params, (res) => onUpdate({ data: res.data.map(entityToRow), meta: res.meta }), onError)
631
- : undefined,
632
- listenById: snap.listenById
633
- ? (id: string | number, onUpdate: (r: M | undefined) => void, onError?: (e: Error) => void) =>
634
- snap.listenById!(id, (s) => onUpdate(s ? entityToRow(s) : undefined), onError)
635
- : undefined,
636
- where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
637
- const builder = new SdkQueryBuilder<M>(client);
638
- if (typeof columnOrCondition === "object") {
639
- return builder.where(columnOrCondition);
640
- }
641
- return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
642
- },
643
- orderBy: (column: keyof M & string, direction?: "asc" | "desc") => new SdkQueryBuilder<M>(client).orderBy(column, direction),
644
- limit: (count: number) => new SdkQueryBuilder<M>(client).limit(count),
645
- offset: (count: number) => new SdkQueryBuilder<M>(client).offset(count),
646
- search: (searchString: string) => new SdkQueryBuilder<M>(client).search(searchString),
647
- vectorSearch: (
648
- property: string,
649
- vector: number[],
650
- options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
651
- ) => new SdkQueryBuilder<M>(client).vectorSearch(property, vector, options),
652
- include: (...relations: string[]) => new SdkQueryBuilder<M>(client).include(...relations)
653
- };
654
- return client;
655
- }
656
-
657
- /**
658
- * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
659
- * {@link CollectionAccessor}. Every returned row is re-wrapped into the
660
- * `{ id, path, values }` view-model the admin panel renders.
661
- */
662
- function toEntityAccessor<M extends Record<string, unknown>>(
663
- sdk: SDKCollectionClient<M>,
664
- slug: string,
665
- getPks: () => PrimaryKeyInfo[] = () => []
666
- ): CollectionAccessor<M> {
667
- const accessor: CollectionAccessor<M> = {
668
- async find(params?: FindParams<M>): Promise<FindResponse<M>> {
669
- const res = await sdk.find(params);
670
- return { data: res.data.map((row) => rowToEntity<M>(row, slug, getPks())), meta: res.meta };
671
- },
672
- async findById(id: string | number): Promise<Entity<M> | undefined> {
673
- const row = await sdk.findById(id);
674
- return row ? rowToEntity<M>(row, slug, getPks()) : undefined;
675
- },
676
- async create(data: Partial<EntityValues<M>>, id?: string | number): Promise<Entity<M>> {
677
- return rowToEntity<M>(await sdk.create(data as Partial<M>, id), slug, getPks());
678
- },
679
- // Declared on `CollectionAccessor` and, until now, never implemented on
680
- // this side of the boundary — so the admin's own import wrote one HTTP
681
- // request per row and could neither be atomic nor upsert. It forwards to
682
- // the same `/bulk` route the SDK client uses.
683
- createMany: sdk.createMany
684
- ? async (data: Partial<EntityValues<M>>[], options?: { upsert?: boolean }): Promise<Entity<M>[]> => {
685
- const rows = await sdk.createMany!(data as Partial<M>[], options);
686
- return rows.map((row) => rowToEntity<M>(row, slug, getPks()));
687
- }
688
- : undefined,
689
- async update(id: string | number, data: Partial<EntityValues<M>>): Promise<Entity<M>> {
690
- const row = await sdk.update(id, data as Partial<M>);
691
- if (!row) throw new Error(`Update returned no data for id ${id}`);
692
- return rowToEntity<M>(row, slug, getPks());
693
- },
694
- delete(id: string | number): Promise<void> {
695
- return sdk.delete(id);
696
- },
697
- count: sdk.count ? (params?: FindParams<M>) => sdk.count!(params) : undefined,
698
- listen: sdk.listen
699
- ? (params: FindParams<M> | undefined, onUpdate: (r: FindResponse<M>) => void, onError?: (e: Error) => void) =>
700
- sdk.listen!(params, (res) => onUpdate({ data: res.data.map((row) => rowToEntity<M>(row, slug, getPks())), meta: res.meta }), onError)
701
- : undefined,
702
- listenById: sdk.listenById
703
- ? (id: string | number, onUpdate: (s: Entity<M> | undefined) => void, onError?: (e: Error) => void) =>
704
- sdk.listenById!(id, (row) => onUpdate(row ? rowToEntity<M>(row, slug, getPks()) : undefined), onError)
705
- : undefined,
706
- where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
707
- const builder = new QueryBuilder<M>(accessor);
708
- if (typeof columnOrCondition === "object") {
709
- return builder.where(columnOrCondition);
710
- }
711
- return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
712
- },
713
- orderBy: (column: keyof M & string, direction?: "asc" | "desc") => new QueryBuilder<M>(accessor).orderBy(column, direction),
714
- limit: (count: number) => new QueryBuilder<M>(accessor).limit(count),
715
- offset: (count: number) => new QueryBuilder<M>(accessor).offset(count),
716
- search: (searchString: string) => new QueryBuilder<M>(accessor).search(searchString),
717
- vectorSearch: (
718
- property: string,
719
- vector: number[],
720
- options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
721
- ) => new QueryBuilder<M>(accessor).vectorSearch(property, vector, options),
722
- include: (...relations: string[]) => new QueryBuilder<M>(accessor).include(...relations)
723
- };
724
- return accessor;
725
- }
726
-
727
- /**
728
- * Wrap a flat {@link RebaseSdkData} into a Entity-shaped {@link RebaseData}.
729
- *
730
- * This is the **admin boundary**: the SDK client (`client.data`) returns flat
731
- * rows, but the admin renders the `Entity` view-model (`entity.values.*`).
732
- * `core/Rebase.tsx` wraps `client.data` through this before handing it to the
733
- * admin `RebaseDataContext` — without it the admin renders rows with only their
734
- * `id`.
735
- */
736
- /**
737
- * Only the by-slug accessor is asked for, so only that is required.
738
- *
739
- * Taking a whole `RebaseSdkData` meant taking `RebaseSdkData<unknown>`, whose
740
- * dynamic branch is an index signature — and no `RebaseSdkData<DB>` satisfies
741
- * it, because its own `collection` method is not a `SDKCollectionClient`. So a
742
- * caller holding a *typed* client could not pass it to a function that reads
743
- * one method off it, and that method is identical on every instantiation.
744
- */
745
- export function wrapAsEntityData(sdkData: Pick<RebaseSdkData, "collection">, options?: EntityDataOptions): RebaseData {
746
- const cache = new Map<string, CollectionAccessor>();
747
- const primaryKeysFor = createPrimaryKeyResolver(options);
748
-
749
- function getAccessor(slug: string): CollectionAccessor {
750
- let accessor = cache.get(slug);
751
- if (!accessor) {
752
- accessor = toEntityAccessor(sdkData.collection(slug), slug, () => primaryKeysFor(slug));
753
- cache.set(slug, accessor);
754
- }
755
- return accessor;
756
- }
757
-
758
- const target = { collection: getAccessor } as RebaseData;
759
-
760
- return new Proxy(target, {
761
- get(_target, prop: string | symbol) {
762
- if (prop === "collection") return getAccessor;
763
- if (typeof prop === "symbol") return undefined;
764
- if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
765
- return getAccessor(toSnakeCase(prop));
766
- }
767
- });
768
- }
769
-
770
- /**
771
- * Wrap a Entity-shaped {@link RebaseData} into a flat {@link RebaseSdkData}.
772
- *
773
- * Every collection accessor is adapted to return flat rows. Use this to derive
774
- * the flat SDK data layer (`context.data`) from an existing Entity data layer
775
- * — e.g. the admin routes its Entity data via `useData()` and exposes the
776
- * same routing as flat `context.data` for callbacks by wrapping it here.
777
- */
778
- export function wrapAsSdkData(entityData: RebaseData): RebaseSdkData {
779
- const cache = new Map<string, SDKCollectionClient>();
780
-
781
- function getAccessor(slug: string): SDKCollectionClient {
782
- let accessor = cache.get(slug);
783
- if (!accessor) {
784
- accessor = toSdkCollectionClient(entityData.collection(slug), slug);
785
- cache.set(slug, accessor);
786
- }
787
- return accessor;
788
- }
789
-
790
- const target = { collection: getAccessor } as RebaseSdkData;
791
-
792
- return new Proxy(target, {
793
- get(_target, prop: string | symbol) {
794
- if (prop === "collection") return getAccessor;
795
- if (typeof prop === "symbol") return undefined;
796
- if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
797
- return getAccessor(toSnakeCase(prop));
798
- }
799
- });
800
- }
801
-
802
- /**
803
- * Build a flat {@link RebaseSdkData} from a `DataDriver`.
804
- *
805
- * This is the developer-facing SDK data layer used by backend framework
806
- * callbacks & scripts (`context.data` / `rebase.data`). It returns flat rows —
807
- * identical in shape to the frontend SDK client, down to how a relation is
808
- * served: a foreign key stays a foreign key, and a relation named in `include`
809
- * arrives as the target's own columns. The `{ __type: "relation" }` envelope is
810
- * the admin's view-model and never reaches here.
811
- *
812
- * The admin uses {@link buildRebaseData} (Entity) over its own driver.
813
- */
814
- export function buildSdkData(driver: DataDriver): RebaseSdkData {
815
- return wrapAsSdkData(buildRebaseData(driver));
816
- }