@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.
- package/README.md +5 -5
- package/dist/collections/CollectionRegistry.d.ts +16 -16
- package/dist/collections/default-collections.d.ts +5 -1
- package/dist/data/buildRebaseData.d.ts +44 -3
- package/dist/data/buildRoutedRebaseData.d.ts +14 -9
- package/dist/data/filter-dialect.d.ts +18 -4
- package/dist/data/query_builder.d.ts +1 -1
- package/dist/data/resolveDataSource.d.ts +1 -1
- package/dist/data/sort-dialect.d.ts +41 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1236 -179
- package/dist/index.es.js.map +1 -1
- package/dist/util/auth-default-policies.d.ts +22 -0
- package/dist/util/builders.d.ts +19 -56
- package/dist/util/callbacks.d.ts +3 -3
- package/dist/util/collections.d.ts +4 -4
- package/dist/util/entities.d.ts +2 -2
- package/dist/util/filter-operator-resolution.d.ts +32 -0
- package/dist/util/identity.d.ts +83 -0
- package/dist/util/index.d.ts +4 -0
- package/dist/util/junction-policies.d.ts +108 -0
- package/dist/util/navigation_from_path.d.ts +4 -4
- package/dist/util/navigation_utils.d.ts +3 -3
- package/dist/util/parent_references_from_path.d.ts +2 -2
- package/dist/util/permissions.d.ts +6 -6
- package/dist/util/policy/evaluatePolicy.d.ts +8 -1
- package/dist/util/policy/index.d.ts +1 -0
- package/dist/util/policy/policyToPostgres.d.ts +14 -2
- package/dist/util/policy/sqlToPolicy.d.ts +24 -14
- package/dist/util/references.d.ts +2 -2
- package/dist/util/relations.d.ts +5 -5
- package/dist/util/resolutions.d.ts +2 -2
- package/package.json +7 -8
- package/src/collections/CollectionRegistry.ts +36 -36
- package/src/collections/default-collections.ts +2 -0
- package/src/data/buildRebaseData.ts +430 -60
- package/src/data/buildRoutedRebaseData.ts +22 -16
- package/src/data/filter-dialect.ts +151 -60
- package/src/data/query_builder.ts +11 -2
- package/src/data/resolveDataSource.ts +1 -1
- package/src/data/sort-dialect.ts +56 -0
- package/src/index.ts +1 -0
- package/src/util/auth-default-policies.ts +152 -0
- package/src/util/builders.ts +25 -99
- package/src/util/callbacks.ts +8 -8
- package/src/util/collections.ts +4 -4
- package/src/util/entities.ts +4 -4
- package/src/util/filter-operator-resolution.ts +81 -0
- package/src/util/identity.ts +166 -0
- package/src/util/index.ts +4 -0
- package/src/util/junction-policies.ts +353 -0
- package/src/util/navigation_from_path.ts +4 -4
- package/src/util/navigation_utils.ts +8 -8
- package/src/util/parent_references_from_path.ts +3 -3
- package/src/util/permissions.test.ts +2 -2
- package/src/util/permissions.ts +7 -7
- package/src/util/policy/evaluatePolicy.ts +26 -4
- package/src/util/policy/index.ts +1 -0
- package/src/util/policy/policyToPostgres.ts +123 -17
- package/src/util/policy/sqlToPolicy.ts +190 -13
- package/src/util/references.ts +2 -2
- package/src/util/relations.ts +12 -12
- package/src/util/resolutions.ts +5 -5
- package/dist/index.umd.js +0 -2901
- package/dist/index.umd.js.map +0 -1
|
@@ -1,103 +1,199 @@
|
|
|
1
1
|
import {
|
|
2
|
-
DataDriver,
|
|
3
|
-
RebaseData,
|
|
4
2
|
CollectionAccessor,
|
|
5
|
-
|
|
6
|
-
FindResponse,
|
|
3
|
+
DataDriver,
|
|
7
4
|
Entity,
|
|
8
5
|
EntityValues,
|
|
9
|
-
|
|
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
|
-
*
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return
|
|
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
|
|
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:
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
155
|
+
const row = await driver.save<M>({
|
|
67
156
|
path: slug,
|
|
68
157
|
values: data,
|
|
69
|
-
|
|
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
|
-
|
|
176
|
+
const row = await driver.save<M>({
|
|
76
177
|
path: slug,
|
|
77
178
|
values: data,
|
|
78
|
-
|
|
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.
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
return driver.
|
|
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:
|
|
118
|
-
order:
|
|
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.
|
|
230
|
+
listenById: driver.listenOne
|
|
136
231
|
? (id: string | number, onUpdate: (entity: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
|
|
137
|
-
return driver.
|
|
232
|
+
return driver.listenOne!<M>({
|
|
138
233
|
path: slug,
|
|
139
|
-
|
|
140
|
-
onUpdate: (entity) => onUpdate(entity
|
|
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: "
|
|
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,
|
|
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:
|
|
20
|
+
defaultData: T;
|
|
14
21
|
|
|
15
22
|
/**
|
|
16
|
-
* Per-data-source
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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,
|
|
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):
|
|
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):
|
|
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)
|
|
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
|
|
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
|
}
|