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