@rebasepro/common 0.7.0 → 0.8.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/dist/collections/CollectionRegistry.d.ts +17 -2
- package/dist/collections/default-collections.d.ts +255 -2
- package/dist/data/filter-dialect.d.ts +61 -0
- package/dist/data/query_builder.d.ts +4 -4
- package/dist/data/resolveDataSource.d.ts +7 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +604 -188
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +611 -186
- package/dist/index.umd.js.map +1 -1
- package/dist/util/builders.d.ts +48 -1
- package/dist/util/callbacks.d.ts +6 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/permissions.d.ts +26 -2
- 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 +10 -0
- package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
- package/dist/util/policy/sqlToPolicy.d.ts +20 -0
- package/dist/util/storage.d.ts +26 -1
- package/package.json +13 -13
- package/src/collections/CollectionRegistry.ts +59 -28
- package/src/collections/default-collections.ts +4 -4
- package/src/data/buildRebaseData.ts +9 -120
- package/src/data/filter-dialect.ts +318 -0
- package/src/data/query_builder.ts +10 -10
- package/src/data/resolveDataSource.ts +9 -9
- package/src/index.ts +1 -0
- package/src/util/builders.ts +78 -1
- package/src/util/callbacks.ts +8 -1
- package/src/util/index.ts +1 -0
- package/src/util/permissions.test.ts +5 -3
- package/src/util/permissions.ts +85 -158
- package/src/util/policy/evaluatePolicy.ts +146 -0
- package/src/util/policy/index.ts +3 -0
- package/src/util/policy/policyToPostgres.ts +85 -0
- package/src/util/policy/securityRuleToConditions.ts +67 -0
- package/src/util/policy/sqlToPolicy.ts +88 -0
- package/src/util/references.ts +1 -1
- package/src/util/relations.ts +8 -9
- package/src/util/resolutions.ts +6 -6
- package/src/util/storage.ts +34 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST wire-format adapter for the unified filter system.
|
|
3
|
+
*
|
|
4
|
+
* This module is the ONLY code in the entire codebase that knows about
|
|
5
|
+
* PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
|
|
6
|
+
* Everything else speaks `FilterValues` exclusively.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
WhereFilterOp,
|
|
13
|
+
FilterValues,
|
|
14
|
+
CANONICAL_TO_REST,
|
|
15
|
+
REST_TO_CANONICAL,
|
|
16
|
+
RestFilterOp,
|
|
17
|
+
toCanonicalOp,
|
|
18
|
+
LogicalCondition,
|
|
19
|
+
FilterCondition
|
|
20
|
+
} from "@rebasepro/types";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Value coercion (querystring → typed JS values)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Coerce a raw querystring value to its natural JS type.
|
|
28
|
+
* - `"true"` / `"false"` → boolean
|
|
29
|
+
* - `"null"` → null
|
|
30
|
+
* - Numeric strings → number
|
|
31
|
+
* - Everything else → string (unchanged)
|
|
32
|
+
*/
|
|
33
|
+
function coerceValue(raw: string): unknown {
|
|
34
|
+
if (raw === "true") return true;
|
|
35
|
+
if (raw === "false") return false;
|
|
36
|
+
if (raw === "null") return null;
|
|
37
|
+
if (raw !== "" && !isNaN(Number(raw))) return Number(raw);
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Serialize a JS value to its querystring representation.
|
|
43
|
+
*/
|
|
44
|
+
function stringifyValue(value: unknown): string {
|
|
45
|
+
if (value === null) return "null";
|
|
46
|
+
if (typeof value === "boolean") return String(value);
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Serialize: FilterValues → REST querystring
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Serialize a single condition tuple to a PostgREST dot-string.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* serializeTuple(["==", "active"]) // "eq.active"
|
|
59
|
+
* serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
|
|
60
|
+
* serializeTuple([">=", 18]) // "gte.18"
|
|
61
|
+
*/
|
|
62
|
+
function serializeTuple(tuple: [WhereFilterOp, unknown] | unknown): string {
|
|
63
|
+
// If it's already a string, it might be a PostgREST string (with dot)
|
|
64
|
+
// or a raw value (without dot). In both cases, existing tests expect
|
|
65
|
+
// them to be passed through or treated as simple equality if no dot.
|
|
66
|
+
if (typeof tuple === "string") {
|
|
67
|
+
if (tuple.includes(".")) {
|
|
68
|
+
const dotIndex = tuple.indexOf(".");
|
|
69
|
+
const prefix = tuple.substring(0, dotIndex);
|
|
70
|
+
if ((REST_TO_CANONICAL as any)[prefix]) {
|
|
71
|
+
return tuple;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return tuple;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// If it's NOT a canonical tuple [WhereFilterOp, value], treat as equality.
|
|
78
|
+
if (!Array.isArray(tuple) || tuple.length !== 2 || typeof tuple[0] !== "string" || !(CANONICAL_TO_REST as any)[tuple[0]]) {
|
|
79
|
+
return `eq.${stringifyValue(tuple)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [op, value] = tuple as [WhereFilterOp, unknown];
|
|
83
|
+
const restOp = CANONICAL_TO_REST[op];
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
const items = value.map(stringifyValue).join(",");
|
|
87
|
+
return `${restOp}.(${items})`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return `${restOp}.${stringifyValue(value)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert `FilterValues` to a PostgREST-style querystring record.
|
|
95
|
+
*
|
|
96
|
+
* - Single conditions produce a string value.
|
|
97
|
+
* - Multiple conditions on the same field produce a string array (repeated params).
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* serializeFilter({ status: ["==", "active"] })
|
|
101
|
+
* // → { status: "eq.active" }
|
|
102
|
+
*
|
|
103
|
+
* serializeFilter({ age: [[">=", 18], ["<", 65]] })
|
|
104
|
+
* // → { age: ["gte.18", "lt.65"] }
|
|
105
|
+
*/
|
|
106
|
+
export function serializeFilter(
|
|
107
|
+
filter: FilterValues<string> | Record<string, any>
|
|
108
|
+
): Record<string, string | string[]> {
|
|
109
|
+
const result: Record<string, string | string[]> = {};
|
|
110
|
+
|
|
111
|
+
for (const [field, condition] of Object.entries(filter)) {
|
|
112
|
+
if (condition === undefined) continue;
|
|
113
|
+
|
|
114
|
+
// Multiple conditions on the same field: array of tuples
|
|
115
|
+
// We detect this by checking if the first element is also an array.
|
|
116
|
+
if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) {
|
|
117
|
+
result[field] = (condition as any[]).map(serializeTuple);
|
|
118
|
+
} else {
|
|
119
|
+
// Single condition (could be a tuple, a raw value, or an already-serialized string)
|
|
120
|
+
result[field] = serializeTuple(condition);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Deserialize: REST querystring → FilterValues
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
133
|
+
*
|
|
134
|
+
* If the string doesn't match a known operator prefix, falls back to
|
|
135
|
+
* `["==", originalString]` (treating the whole string as an equality value).
|
|
136
|
+
*/
|
|
137
|
+
function deserializeSingle(raw: string): [WhereFilterOp, unknown] {
|
|
138
|
+
const dotIndex = raw.indexOf(".");
|
|
139
|
+
if (dotIndex === -1) {
|
|
140
|
+
// No dot → equality on the raw value (coerced)
|
|
141
|
+
return ["==", coerceValue(raw)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const prefix = raw.substring(0, dotIndex);
|
|
145
|
+
const rest = raw.substring(dotIndex + 1);
|
|
146
|
+
|
|
147
|
+
// Check if the prefix is a known REST operator
|
|
148
|
+
const canonicalOp = (REST_TO_CANONICAL as Record<string, WhereFilterOp | undefined>)[prefix];
|
|
149
|
+
if (!canonicalOp) {
|
|
150
|
+
// Not a known operator (e.g., email "user@host.com" or version "1.2.3")
|
|
151
|
+
// Treat the entire string as an equality value
|
|
152
|
+
return ["==", raw];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Parse list values: "(admin,editor)" → ["admin", "editor"]
|
|
156
|
+
if (rest.startsWith("(") && rest.endsWith(")")) {
|
|
157
|
+
const items = rest.slice(1, -1).split(",").map(s => coerceValue(s.trim()));
|
|
158
|
+
return [canonicalOp, items];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [canonicalOp, coerceValue(rest)];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Convert a PostgREST-style querystring record to `FilterValues`.
|
|
166
|
+
*
|
|
167
|
+
* - String values are parsed as single conditions.
|
|
168
|
+
* - String arrays (repeated query params) become multiple conditions on the same field.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* deserializeFilter({ status: "eq.active" })
|
|
172
|
+
* // → { status: ["==", "active"] }
|
|
173
|
+
*
|
|
174
|
+
* deserializeFilter({ age: ["gte.18", "lt.65"] })
|
|
175
|
+
* // → { age: [[">=", 18], ["<", 65]] }
|
|
176
|
+
*/
|
|
177
|
+
export function deserializeFilter(
|
|
178
|
+
query: Record<string, any>
|
|
179
|
+
): FilterValues<string> {
|
|
180
|
+
const result: FilterValues<string> = {};
|
|
181
|
+
|
|
182
|
+
for (const [field, raw] of Object.entries(query)) {
|
|
183
|
+
if (raw === undefined) continue;
|
|
184
|
+
|
|
185
|
+
// If it's already a canonical tuple [op, value], keep it as is
|
|
186
|
+
if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
|
|
187
|
+
result[field] = raw as [WhereFilterOp, unknown];
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (Array.isArray(raw)) {
|
|
192
|
+
if (raw.length === 0) continue;
|
|
193
|
+
|
|
194
|
+
// Check if it's an array of canonical tuples
|
|
195
|
+
if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
|
|
196
|
+
result[field] = raw as [WhereFilterOp, unknown][];
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (raw.length === 1) {
|
|
201
|
+
result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
|
|
202
|
+
} else {
|
|
203
|
+
// If the elements are strings, they might be PostgREST dot-strings (repeated params)
|
|
204
|
+
if (typeof raw[0] === "string" && raw[0].includes(".")) {
|
|
205
|
+
result[field] = raw.map(r => typeof r === "string" ? deserializeSingle(r) : (["==", r] as [WhereFilterOp, unknown])) as [WhereFilterOp, unknown][];
|
|
206
|
+
} else {
|
|
207
|
+
// Otherwise assume it's a list of values for an implicit "in" or just multiple conditions
|
|
208
|
+
result[field] = ["in", raw];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} else if (typeof raw === "string") {
|
|
212
|
+
result[field] = deserializeSingle(raw);
|
|
213
|
+
} else {
|
|
214
|
+
result[field] = ["==", raw];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Logical conditions: serialize / deserialize
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
|
|
230
|
+
* // → "status.eq.active"
|
|
231
|
+
*
|
|
232
|
+
* serializeLogicalCondition({ type: "or", conditions: [...] })
|
|
233
|
+
* // → "or(status.eq.active,status.eq.pending)"
|
|
234
|
+
*/
|
|
235
|
+
export function serializeLogicalCondition(
|
|
236
|
+
cond: LogicalCondition | FilterCondition
|
|
237
|
+
): string {
|
|
238
|
+
if ("type" in cond) {
|
|
239
|
+
// LogicalCondition (and/or)
|
|
240
|
+
const inner = (cond.conditions ?? [])
|
|
241
|
+
.map(serializeLogicalCondition)
|
|
242
|
+
.join(",");
|
|
243
|
+
return `${cond.type}(${inner})`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// FilterCondition
|
|
247
|
+
const restOp = (CANONICAL_TO_REST as any)[cond.operator] || "eq";
|
|
248
|
+
if (Array.isArray(cond.value)) {
|
|
249
|
+
const items = cond.value.map(stringifyValue).join(",");
|
|
250
|
+
return `${cond.column}.${restOp}.(${items})`;
|
|
251
|
+
}
|
|
252
|
+
return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Parse a logical condition wire-format string back into a
|
|
257
|
+
* `LogicalCondition` or `FilterCondition`.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* deserializeLogicalCondition("status.eq.active")
|
|
261
|
+
* // → { column: "status", operator: "==", value: "active" }
|
|
262
|
+
*
|
|
263
|
+
* deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
|
|
264
|
+
* // → { type: "or", conditions: [...] }
|
|
265
|
+
*/
|
|
266
|
+
export function deserializeLogicalCondition(
|
|
267
|
+
str: string
|
|
268
|
+
): LogicalCondition | FilterCondition {
|
|
269
|
+
// Check for logical group: "and(...)" or "or(...)"
|
|
270
|
+
const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
|
|
271
|
+
if (logicalMatch) {
|
|
272
|
+
const type = logicalMatch[1] as "and" | "or";
|
|
273
|
+
const innerStr = logicalMatch[2];
|
|
274
|
+
|
|
275
|
+
// Split on commas that are not inside parentheses
|
|
276
|
+
const conditions: (LogicalCondition | FilterCondition)[] = [];
|
|
277
|
+
let depth = 0;
|
|
278
|
+
let start = 0;
|
|
279
|
+
for (let i = 0; i < innerStr.length; i++) {
|
|
280
|
+
if (innerStr[i] === "(") depth++;
|
|
281
|
+
else if (innerStr[i] === ")") depth--;
|
|
282
|
+
else if (innerStr[i] === "," && depth === 0) {
|
|
283
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
|
|
284
|
+
start = i + 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
|
|
288
|
+
|
|
289
|
+
return { type, conditions };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// FilterCondition: "column.op.value"
|
|
293
|
+
const firstDot = str.indexOf(".");
|
|
294
|
+
if (firstDot === -1) {
|
|
295
|
+
return { column: str, operator: "==", value: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const column = str.substring(0, firstDot);
|
|
299
|
+
const rest = str.substring(firstDot + 1);
|
|
300
|
+
|
|
301
|
+
const secondDot = rest.indexOf(".");
|
|
302
|
+
if (secondDot === -1) {
|
|
303
|
+
// "column.value" — treat as equality
|
|
304
|
+
return { column, operator: "==", value: coerceValue(rest) };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const opStr = rest.substring(0, secondDot);
|
|
308
|
+
let valueStr = rest.substring(secondDot + 1);
|
|
309
|
+
const operator = toCanonicalOp(opStr) ?? "==";
|
|
310
|
+
|
|
311
|
+
// Parse list values
|
|
312
|
+
if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
|
|
313
|
+
const items = valueStr.slice(1, -1).split(",").map(s => coerceValue(s.trim()));
|
|
314
|
+
return { column, operator, value: items };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { column, operator, value: coerceValue(valueStr) };
|
|
318
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface,
|
|
1
|
+
import { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface, WhereFilterOp, LogicalCondition, WhereValue, FilterCondition } from "@rebasepro/types";
|
|
2
2
|
|
|
3
3
|
export function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition {
|
|
4
4
|
return { type: "or",
|
|
@@ -10,7 +10,7 @@ export function and(...conditions: (FilterCondition | LogicalCondition)[]): Logi
|
|
|
10
10
|
conditions };
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function cond(column: string, operator:
|
|
13
|
+
export function cond(column: string, operator: WhereFilterOp, value: unknown): FilterCondition {
|
|
14
14
|
return { column,
|
|
15
15
|
operator,
|
|
16
16
|
value };
|
|
@@ -26,9 +26,9 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
26
26
|
* @example
|
|
27
27
|
* client.collection('users').where('age', '>=', 18).find()
|
|
28
28
|
*/
|
|
29
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
29
|
+
where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
|
|
30
30
|
where(logicalCondition: LogicalCondition): this;
|
|
31
|
-
where(columnOrCondition: string | LogicalCondition, operator?:
|
|
31
|
+
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
|
|
32
32
|
// Handle LogicalCondition signature
|
|
33
33
|
if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
|
|
34
34
|
this.params.logical = columnOrCondition as LogicalCondition;
|
|
@@ -40,18 +40,18 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const column = columnOrCondition as string;
|
|
43
|
-
const condition: [
|
|
43
|
+
const condition: [WhereFilterOp, unknown] = [operator!, value];
|
|
44
44
|
const existing = this.params.where[column];
|
|
45
45
|
|
|
46
46
|
if (existing === undefined) {
|
|
47
47
|
this.params.where[column] = condition;
|
|
48
48
|
} else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
|
|
49
|
-
(this.params.where[column] as [
|
|
49
|
+
(this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
|
|
50
50
|
} else {
|
|
51
51
|
// Convert existing single tuple/value into array of tuples
|
|
52
|
-
let firstCondition: [
|
|
52
|
+
let firstCondition: [WhereFilterOp, unknown];
|
|
53
53
|
if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
|
|
54
|
-
firstCondition = existing as [
|
|
54
|
+
firstCondition = existing as [WhereFilterOp, unknown];
|
|
55
55
|
} else {
|
|
56
56
|
firstCondition = ["==", existing];
|
|
57
57
|
}
|
|
@@ -66,8 +66,8 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
66
66
|
* @example
|
|
67
67
|
* client.collection('users').orderBy('createdAt', 'desc').find()
|
|
68
68
|
*/
|
|
69
|
-
orderBy(column: keyof M & string,
|
|
70
|
-
this.params.orderBy = `${column}:${
|
|
69
|
+
orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
|
|
70
|
+
this.params.orderBy = `${column}:${direction}`;
|
|
71
71
|
return this;
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
export interface DataSourceResolvable {
|
|
15
15
|
/** Preferred routing key. */
|
|
16
16
|
dataSource?: string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/** Engine type discriminant (set on variant collection types). */
|
|
18
|
+
engine?: string;
|
|
19
19
|
/** Within-engine instance. */
|
|
20
20
|
databaseId?: string;
|
|
21
21
|
}
|
|
@@ -41,13 +41,13 @@ export function createDataSourceRegistry(definitions?: DataSourceDefinition[]):
|
|
|
41
41
|
* editor's capability lookups.
|
|
42
42
|
*
|
|
43
43
|
* Resolution order:
|
|
44
|
-
* 1. The routing **key** is `collection.dataSource`, else
|
|
45
|
-
*
|
|
44
|
+
* 1. The routing **key** is `collection.dataSource`, else
|
|
45
|
+
* {@link DEFAULT_DATA_SOURCE_KEY}.
|
|
46
46
|
* 2. If a definition is registered for that key, it provides `engine`,
|
|
47
47
|
* `transport`, and `databaseId`.
|
|
48
|
-
* 3. Otherwise values are synthesized
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* 3. Otherwise values are synthesized: `engine` from `collection.engine`
|
|
49
|
+
* (or the key, or `"postgres"`), `transport` defaults to `"server"`,
|
|
50
|
+
* and `databaseId` from the collection.
|
|
51
51
|
*
|
|
52
52
|
* `capabilities` are always derived from the resolved `engine`, so two
|
|
53
53
|
* data sources sharing an engine share capabilities.
|
|
@@ -59,11 +59,11 @@ export function resolveDataSource(
|
|
|
59
59
|
collection: DataSourceResolvable | undefined,
|
|
60
60
|
registry?: DataSourceRegistry
|
|
61
61
|
): ResolvedDataSource {
|
|
62
|
-
const key = collection?.dataSource ??
|
|
62
|
+
const key = collection?.dataSource ?? DEFAULT_DATA_SOURCE_KEY;
|
|
63
63
|
const def = registry?.[key];
|
|
64
64
|
|
|
65
65
|
const engine = def?.engine
|
|
66
|
-
?? collection?.
|
|
66
|
+
?? collection?.engine
|
|
67
67
|
?? (key !== DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
|
|
68
68
|
|
|
69
69
|
const transport = def?.transport ?? "server";
|
package/src/index.ts
CHANGED
package/src/util/builders.ts
CHANGED
|
@@ -7,9 +7,17 @@ import {
|
|
|
7
7
|
EntityCollection,
|
|
8
8
|
EnumValueConfig,
|
|
9
9
|
EnumValues,
|
|
10
|
+
FirebaseCollection,
|
|
11
|
+
FirebaseProperties,
|
|
10
12
|
GeopointProperty,
|
|
13
|
+
InferEntityType,
|
|
11
14
|
MapProperty,
|
|
12
|
-
|
|
15
|
+
MongoDBCollection,
|
|
16
|
+
MongoProperties,
|
|
17
|
+
NumberProperty,
|
|
18
|
+
PostgresCollection,
|
|
19
|
+
PostgresProperties,
|
|
20
|
+
Properties,
|
|
13
21
|
Property,
|
|
14
22
|
ReferenceProperty,
|
|
15
23
|
StringProperty,
|
|
@@ -32,6 +40,75 @@ export function buildCollection<
|
|
|
32
40
|
return collection;
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
// ── defineCollection ─────────────────────────────────────────────────────
|
|
44
|
+
// A smarter builder that uses `const` type-parameter inference (TS 5.0+)
|
|
45
|
+
// to capture literal property types automatically. This gives you
|
|
46
|
+
// autocomplete on `titleProperty`, `sort`, `propertiesOrder`, `fixedFilter`,
|
|
47
|
+
// callbacks, etc. — without writing `as const` or passing manual generics.
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Define a PostgreSQL-backed collection with full type inference.
|
|
51
|
+
*
|
|
52
|
+
* The `const P` generic captures literal property types from your
|
|
53
|
+
* `properties` object, which enables autocomplete on `titleProperty`,
|
|
54
|
+
* `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const products = defineCollection({
|
|
59
|
+
* name: "Products",
|
|
60
|
+
* slug: "products",
|
|
61
|
+
* table: "products",
|
|
62
|
+
* properties: {
|
|
63
|
+
* name: { name: "Name", type: "string", validation: { required: true } },
|
|
64
|
+
* price: { name: "Price", type: "number" },
|
|
65
|
+
* },
|
|
66
|
+
* titleProperty: "name", // ✅ autocomplete: "name" | "price"
|
|
67
|
+
* sort: ["price", "asc"], // ✅ autocomplete on first element
|
|
68
|
+
* });
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @group Builder
|
|
72
|
+
*/
|
|
73
|
+
export function defineCollection<
|
|
74
|
+
const P extends PostgresProperties,
|
|
75
|
+
USER extends User = User
|
|
76
|
+
>(
|
|
77
|
+
collection: Omit<PostgresCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
|
|
78
|
+
): PostgresCollection<InferEntityType<P>, USER> & { properties: P };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Define a Firestore-backed collection with full type inference.
|
|
82
|
+
* @group Builder
|
|
83
|
+
*/
|
|
84
|
+
export function defineCollection<
|
|
85
|
+
const P extends FirebaseProperties,
|
|
86
|
+
USER extends User = User
|
|
87
|
+
>(
|
|
88
|
+
collection: Omit<FirebaseCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
|
|
89
|
+
): FirebaseCollection<InferEntityType<P>, USER> & { properties: P };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Define a MongoDB-backed collection with full type inference.
|
|
93
|
+
* @group Builder
|
|
94
|
+
*/
|
|
95
|
+
export function defineCollection<
|
|
96
|
+
const P extends MongoProperties,
|
|
97
|
+
USER extends User = User
|
|
98
|
+
>(
|
|
99
|
+
collection: Omit<MongoDBCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
|
|
100
|
+
): MongoDBCollection<InferEntityType<P>, USER> & { properties: P };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Implementation — delegates to the correct overload at the type level.
|
|
104
|
+
* At runtime this is a plain identity function.
|
|
105
|
+
*/
|
|
106
|
+
export function defineCollection(
|
|
107
|
+
collection: EntityCollection
|
|
108
|
+
): EntityCollection {
|
|
109
|
+
return collection;
|
|
110
|
+
}
|
|
111
|
+
|
|
35
112
|
/**
|
|
36
113
|
* Identity function we use to defeat the type system of Typescript and preserve
|
|
37
114
|
* the property keys.
|
package/src/util/callbacks.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import { EntityCallbacks, Properties } from "@rebasepro/types";
|
|
1
|
+
import { EntityCallbacks, Properties, RebaseCallContext } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Context passed to entity lifecycle callbacks.
|
|
5
|
+
* @group Models
|
|
6
|
+
*/
|
|
7
|
+
export type EntityCallbackContext = RebaseCallContext;
|
|
8
|
+
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* Helper function to recursively check if there are any callbacks in the properties.
|
package/src/util/index.ts
CHANGED
|
@@ -162,9 +162,10 @@ roles: ["author"] }
|
|
|
162
162
|
expect(canReadCollection(collection, mockAuthController)).toBe(true);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
test("11. Empty roles array []
|
|
165
|
+
test("11. Empty roles array [] adds no role restriction (public rule stays public)", () => {
|
|
166
166
|
const collection = createMockCollection([
|
|
167
167
|
{ operation: "insert",
|
|
168
|
+
access: "public",
|
|
168
169
|
roles: [] }
|
|
169
170
|
]);
|
|
170
171
|
expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
|
|
@@ -172,9 +173,10 @@ roles: [] }
|
|
|
172
173
|
expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
|
|
173
174
|
});
|
|
174
175
|
|
|
175
|
-
test("12. Undefined roles on rule grants access to everyone
|
|
176
|
+
test("12. Undefined roles on a public rule grants access to everyone", () => {
|
|
176
177
|
const collection = createMockCollection([
|
|
177
|
-
{ operation: "insert"
|
|
178
|
+
{ operation: "insert",
|
|
179
|
+
access: "public" }
|
|
178
180
|
]);
|
|
179
181
|
expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
|
|
180
182
|
expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
|