@rebasepro/common 0.17.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/collections/CollectionRegistry.d.ts +1 -1
- package/dist/collections/default-collections.d.ts +15 -84
- package/dist/data/buildRebaseData.d.ts +1 -1
- package/dist/data/filter-dialect.d.ts +11 -0
- package/dist/data/sort-dialect.d.ts +15 -3
- package/dist/index.es.js +375 -63
- package/dist/index.es.js.map +1 -1
- package/dist/util/builders.d.ts +69 -24
- package/dist/util/callback-errors.d.ts +77 -0
- package/dist/util/callback-errors.test.d.ts +1 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/policy/evaluatePolicy.d.ts +6 -0
- package/dist/util/relations.d.ts +41 -0
- package/dist/util/table-name.test.d.ts +1 -0
- package/package.json +26 -22
- package/src/collections/CollectionRegistry.ts +0 -485
- package/src/collections/default-collections.ts +0 -109
- package/src/collections/index.ts +0 -2
- package/src/data/buildRebaseData.ts +0 -816
- package/src/data/buildRoutedRebaseData.ts +0 -103
- package/src/data/filter-conditions.ts +0 -46
- package/src/data/filter-dialect.ts +0 -737
- package/src/data/paginate.ts +0 -334
- package/src/data/query_builder.ts +0 -176
- package/src/data/resolveDataSource.ts +0 -135
- package/src/data/sort-dialect.ts +0 -237
- package/src/index.ts +0 -11
- package/src/table-classification.ts +0 -109
- package/src/types/json-logic-js.d.ts +0 -8
- package/src/util/auth-default-policies.ts +0 -215
- package/src/util/builders.ts +0 -82
- package/src/util/callbacks.ts +0 -122
- package/src/util/collections.ts +0 -117
- package/src/util/common.ts +0 -2
- package/src/util/conditions.ts +0 -168
- package/src/util/email.ts +0 -32
- package/src/util/entities.ts +0 -282
- package/src/util/enums.ts +0 -26
- package/src/util/identity.ts +0 -202
- package/src/util/index.ts +0 -21
- package/src/util/internal-tables.test.ts +0 -188
- package/src/util/internal-tables.ts +0 -197
- package/src/util/junction-policies.ts +0 -355
- package/src/util/paths.ts +0 -27
- package/src/util/permissions.test.ts +0 -866
- package/src/util/permissions.ts +0 -206
- package/src/util/pg-column-to-property.ts +0 -377
- package/src/util/policy/evaluatePolicy.ts +0 -194
- package/src/util/policy/index.ts +0 -4
- package/src/util/policy/policyToPostgres.ts +0 -263
- package/src/util/policy/securityRuleToConditions.ts +0 -67
- package/src/util/policy/sqlToPolicy.ts +0 -422
- package/src/util/relations.ts +0 -236
- package/src/util/resolutions.ts +0 -534
- package/src/util/resolve-relation.ts +0 -243
- package/src/util/storage.ts +0 -177
- 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
|
-
}
|