@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,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
|
}
|
|
@@ -0,0 +1,403 @@
|
|
|
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
|
+
* Wire-format values are always strings — the wire format carries no type
|
|
9
|
+
* metadata, so type coercion is the responsibility of the server-side data
|
|
10
|
+
* driver which has access to the collection schema.
|
|
11
|
+
*
|
|
12
|
+
* Commas inside list values are backslash-escaped (`\,`), and literal
|
|
13
|
+
* backslashes are escaped as `\\`.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
WhereFilterOp,
|
|
20
|
+
FilterValues,
|
|
21
|
+
CANONICAL_TO_REST,
|
|
22
|
+
REST_TO_CANONICAL,
|
|
23
|
+
RestFilterOp,
|
|
24
|
+
toCanonicalOp,
|
|
25
|
+
LogicalCondition,
|
|
26
|
+
FilterCondition,
|
|
27
|
+
NULL_OPS
|
|
28
|
+
} from "@rebasepro/types";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Value stringification
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Serialize a JS value to its querystring representation.
|
|
36
|
+
* `null` is serialized as the literal string `"null"`.
|
|
37
|
+
*/
|
|
38
|
+
function stringifyValue(value: unknown): string {
|
|
39
|
+
if (value === null) return "null";
|
|
40
|
+
return String(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Comma escaping for list values
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Escape a single list item for the wire format.
|
|
49
|
+
* `\` → `\\`, `,` → `\,`
|
|
50
|
+
*/
|
|
51
|
+
function escapeListItem(value: string): string {
|
|
52
|
+
return value.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Unescape a single list item from the wire format.
|
|
57
|
+
* `\\` → `\`, `\,` → `,`
|
|
58
|
+
*/
|
|
59
|
+
function unescapeListItem(value: string): string {
|
|
60
|
+
let result = "";
|
|
61
|
+
for (let i = 0; i < value.length; i++) {
|
|
62
|
+
if (value[i] === "\\" && i + 1 < value.length) {
|
|
63
|
+
result += value[i + 1];
|
|
64
|
+
i++; // skip next char
|
|
65
|
+
} else {
|
|
66
|
+
result += value[i];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Split a parenthesized list string on unescaped commas.
|
|
74
|
+
* Input is the content between `(` and `)`.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* splitListItems("admin,editor") // ["admin", "editor"]
|
|
78
|
+
* splitListItems("hello\\, world,foo") // ["hello, world", "foo"]
|
|
79
|
+
*/
|
|
80
|
+
function splitListItems(inner: string): string[] {
|
|
81
|
+
const items: string[] = [];
|
|
82
|
+
let current = "";
|
|
83
|
+
for (let i = 0; i < inner.length; i++) {
|
|
84
|
+
if (inner[i] === "\\" && i + 1 < inner.length) {
|
|
85
|
+
// Escaped character — consume both chars
|
|
86
|
+
current += inner[i] + inner[i + 1];
|
|
87
|
+
i++;
|
|
88
|
+
} else if (inner[i] === ",") {
|
|
89
|
+
items.push(unescapeListItem(current));
|
|
90
|
+
current = "";
|
|
91
|
+
} else {
|
|
92
|
+
current += inner[i];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
items.push(unescapeListItem(current));
|
|
96
|
+
return items;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Typed operator map lookups (no `as any`)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const REST_OP_LOOKUP = REST_TO_CANONICAL as Readonly<Record<string, WhereFilterOp | undefined>>;
|
|
104
|
+
const CANONICAL_OP_LOOKUP = CANONICAL_TO_REST as Readonly<Record<string, RestFilterOp | undefined>>;
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Serialize: FilterValues → REST querystring
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Serialize a single canonical condition tuple to a PostgREST dot-string.
|
|
112
|
+
*
|
|
113
|
+
* Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* serializeTuple(["==", "active"]) // "eq.active"
|
|
117
|
+
* serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
|
|
118
|
+
* serializeTuple([">=", 18]) // "gte.18"
|
|
119
|
+
*/
|
|
120
|
+
function serializeTuple(tuple: [WhereFilterOp, unknown]): string {
|
|
121
|
+
if (!Array.isArray(tuple) || tuple.length !== 2) {
|
|
122
|
+
throw new TypeError(
|
|
123
|
+
`serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const [op, value] = tuple;
|
|
128
|
+
|
|
129
|
+
if (typeof op !== "string") {
|
|
130
|
+
throw new TypeError(
|
|
131
|
+
`serializeTuple: operator must be a string, got ${typeof op}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const restOp = CANONICAL_OP_LOOKUP[op];
|
|
136
|
+
if (!restOp) {
|
|
137
|
+
throw new TypeError(
|
|
138
|
+
`serializeTuple: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
const items = value.map(v => escapeListItem(stringifyValue(v))).join(",");
|
|
144
|
+
return `${restOp}.(${items})`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return `${restOp}.${stringifyValue(value)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style
|
|
152
|
+
* querystring record.
|
|
153
|
+
*
|
|
154
|
+
* - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.
|
|
155
|
+
* - Pre-serialized PostgREST strings (e.g. `"eq.published"`) are passed through.
|
|
156
|
+
* - Single conditions produce a string value.
|
|
157
|
+
* - Multiple conditions on the same field produce a string array (repeated params).
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* serializeFilter({ status: ["==", "active"] })
|
|
161
|
+
* // → { status: "eq.active" }
|
|
162
|
+
*
|
|
163
|
+
* serializeFilter({ age: [[">=", 18], ["<", 65]] })
|
|
164
|
+
* // → { age: ["gte.18", "lt.65"] }
|
|
165
|
+
*
|
|
166
|
+
* // Pre-serialized strings pass through unchanged:
|
|
167
|
+
* serializeFilter({ status: "eq.published" })
|
|
168
|
+
* // → { status: "eq.published" }
|
|
169
|
+
*/
|
|
170
|
+
export function serializeFilter(
|
|
171
|
+
filter: FilterValues<string> | Record<string, unknown>
|
|
172
|
+
): Record<string, string | string[]> {
|
|
173
|
+
const result: Record<string, string | string[]> = {};
|
|
174
|
+
|
|
175
|
+
for (const [field, condition] of Object.entries(filter)) {
|
|
176
|
+
if (condition === undefined) continue;
|
|
177
|
+
|
|
178
|
+
// Pre-serialized PostgREST string — pass through unchanged.
|
|
179
|
+
// This supports WireFilterValues where values may already be
|
|
180
|
+
// serialized dot-strings like "eq.active" or raw strings like "true".
|
|
181
|
+
if (typeof condition === "string") {
|
|
182
|
+
result[field] = condition;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Multiple conditions on the same field: array of tuples
|
|
187
|
+
// We detect this by checking if the first element is also an array.
|
|
188
|
+
if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) {
|
|
189
|
+
result[field] = (condition as [WhereFilterOp, unknown][]).map(serializeTuple);
|
|
190
|
+
} else {
|
|
191
|
+
// Single condition — must be a [WhereFilterOp, value] tuple
|
|
192
|
+
result[field] = serializeTuple(condition as [WhereFilterOp, unknown]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Deserialize: REST querystring → FilterValues
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
205
|
+
*
|
|
206
|
+
* All values are returned as strings — the wire format carries no type
|
|
207
|
+
* metadata, so coercion is the data driver's responsibility.
|
|
208
|
+
*
|
|
209
|
+
* If the string doesn't match a known operator prefix, it falls back to
|
|
210
|
+
* `["==", originalString]` (treating the whole string as an equality value).
|
|
211
|
+
* This intentional defense handles values like `"user@host.com"` or
|
|
212
|
+
* `"1.2.3"` that happen to contain dots.
|
|
213
|
+
*/
|
|
214
|
+
function deserializeSingle(raw: string): [WhereFilterOp, unknown] {
|
|
215
|
+
const dotIndex = raw.indexOf(".");
|
|
216
|
+
if (dotIndex === -1) {
|
|
217
|
+
// No dot → equality on the raw value (kept as string)
|
|
218
|
+
return ["==", raw];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const prefix = raw.substring(0, dotIndex);
|
|
222
|
+
const rest = raw.substring(dotIndex + 1);
|
|
223
|
+
|
|
224
|
+
// Check if the prefix is a known REST operator.
|
|
225
|
+
// This is the key defense against values like "eq.something" or "gt.foo"
|
|
226
|
+
// being misinterpreted — only known REST short-codes are treated as operators.
|
|
227
|
+
const canonicalOp = REST_OP_LOOKUP[prefix];
|
|
228
|
+
if (!canonicalOp) {
|
|
229
|
+
// Not a known operator (e.g., email "user@host.com" or version "1.2.3")
|
|
230
|
+
// Treat the entire string as an equality value
|
|
231
|
+
return ["==", raw];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Null-testing operators ignore their serialized value — normalize to null
|
|
235
|
+
// so the tuple round-trips stably (`isnull.null` → ["is-null", null]).
|
|
236
|
+
if (NULL_OPS.has(canonicalOp)) {
|
|
237
|
+
return [canonicalOp, null];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Parse list values: "(admin,editor)" → ["admin", "editor"]
|
|
241
|
+
if (rest.startsWith("(") && rest.endsWith(")")) {
|
|
242
|
+
const items = splitListItems(rest.slice(1, -1));
|
|
243
|
+
return [canonicalOp, items];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return [canonicalOp, rest];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Convert a PostgREST-style querystring record to `FilterValues`.
|
|
251
|
+
*
|
|
252
|
+
* - String values are parsed as single conditions.
|
|
253
|
+
* - String arrays (repeated query params) become multiple conditions on the same field.
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* deserializeFilter({ status: "eq.active" })
|
|
257
|
+
* // → { status: ["==", "active"] }
|
|
258
|
+
*
|
|
259
|
+
* deserializeFilter({ age: ["gte.18", "lt.65"] })
|
|
260
|
+
* // → { age: [[">=", "18"], ["<", "65"]] }
|
|
261
|
+
*/
|
|
262
|
+
export function deserializeFilter(
|
|
263
|
+
query: Record<string, unknown>
|
|
264
|
+
): FilterValues<string> {
|
|
265
|
+
const result: FilterValues<string> = {};
|
|
266
|
+
|
|
267
|
+
for (const [field, raw] of Object.entries(query)) {
|
|
268
|
+
if (raw === undefined) continue;
|
|
269
|
+
|
|
270
|
+
// If it's already a canonical tuple [op, value], keep it as is
|
|
271
|
+
if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
|
|
272
|
+
result[field] = raw as [WhereFilterOp, unknown];
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (Array.isArray(raw)) {
|
|
277
|
+
if (raw.length === 0) continue;
|
|
278
|
+
|
|
279
|
+
// Check if it's an array of canonical tuples
|
|
280
|
+
if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
|
|
281
|
+
result[field] = raw as [WhereFilterOp, unknown][];
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (raw.length === 1) {
|
|
286
|
+
result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
|
|
287
|
+
} else {
|
|
288
|
+
// If the elements are strings, they might be PostgREST dot-strings (repeated params)
|
|
289
|
+
if (typeof raw[0] === "string" && raw[0].includes(".")) {
|
|
290
|
+
result[field] = raw.map(r => typeof r === "string" ? deserializeSingle(r) : (["==", r] as [WhereFilterOp, unknown])) as [WhereFilterOp, unknown][];
|
|
291
|
+
} else {
|
|
292
|
+
// Otherwise assume it's a list of values for an implicit "in" or just multiple conditions
|
|
293
|
+
result[field] = ["in", raw];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} else if (typeof raw === "string") {
|
|
297
|
+
result[field] = deserializeSingle(raw);
|
|
298
|
+
} else {
|
|
299
|
+
result[field] = ["==", raw];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Logical conditions: serialize / deserialize
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
|
|
315
|
+
* // → "status.eq.active"
|
|
316
|
+
*
|
|
317
|
+
* serializeLogicalCondition({ type: "or", conditions: [...] })
|
|
318
|
+
* // → "or(status.eq.active,status.eq.pending)"
|
|
319
|
+
*/
|
|
320
|
+
export function serializeLogicalCondition(
|
|
321
|
+
cond: LogicalCondition | FilterCondition
|
|
322
|
+
): string {
|
|
323
|
+
if ("type" in cond) {
|
|
324
|
+
// LogicalCondition (and/or)
|
|
325
|
+
const inner = (cond.conditions ?? [])
|
|
326
|
+
.map(serializeLogicalCondition)
|
|
327
|
+
.join(",");
|
|
328
|
+
return `${cond.type}(${inner})`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// FilterCondition
|
|
332
|
+
const restOp = CANONICAL_OP_LOOKUP[cond.operator] ?? "eq";
|
|
333
|
+
if (Array.isArray(cond.value)) {
|
|
334
|
+
const items = cond.value.map(v => escapeListItem(stringifyValue(v))).join(",");
|
|
335
|
+
return `${cond.column}.${restOp}.(${items})`;
|
|
336
|
+
}
|
|
337
|
+
return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Parse a logical condition wire-format string back into a
|
|
342
|
+
* `LogicalCondition` or `FilterCondition`.
|
|
343
|
+
*
|
|
344
|
+
* @example
|
|
345
|
+
* deserializeLogicalCondition("status.eq.active")
|
|
346
|
+
* // → { column: "status", operator: "==", value: "active" }
|
|
347
|
+
*
|
|
348
|
+
* deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
|
|
349
|
+
* // → { type: "or", conditions: [...] }
|
|
350
|
+
*/
|
|
351
|
+
export function deserializeLogicalCondition(
|
|
352
|
+
str: string
|
|
353
|
+
): LogicalCondition | FilterCondition {
|
|
354
|
+
// Check for logical group: "and(...)" or "or(...)"
|
|
355
|
+
const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
|
|
356
|
+
if (logicalMatch) {
|
|
357
|
+
const type = logicalMatch[1] as "and" | "or";
|
|
358
|
+
const innerStr = logicalMatch[2];
|
|
359
|
+
|
|
360
|
+
// Split on commas that are not inside parentheses
|
|
361
|
+
const conditions: (LogicalCondition | FilterCondition)[] = [];
|
|
362
|
+
let depth = 0;
|
|
363
|
+
let start = 0;
|
|
364
|
+
for (let i = 0; i < innerStr.length; i++) {
|
|
365
|
+
if (innerStr[i] === "(") depth++;
|
|
366
|
+
else if (innerStr[i] === ")") depth--;
|
|
367
|
+
else if (innerStr[i] === "," && depth === 0) {
|
|
368
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
|
|
369
|
+
start = i + 1;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
|
|
373
|
+
|
|
374
|
+
return { type, conditions };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// FilterCondition: "column.op.value"
|
|
378
|
+
const firstDot = str.indexOf(".");
|
|
379
|
+
if (firstDot === -1) {
|
|
380
|
+
return { column: str, operator: "==", value: true };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const column = str.substring(0, firstDot);
|
|
384
|
+
const rest = str.substring(firstDot + 1);
|
|
385
|
+
|
|
386
|
+
const secondDot = rest.indexOf(".");
|
|
387
|
+
if (secondDot === -1) {
|
|
388
|
+
// "column.value" — treat as equality (value kept as string)
|
|
389
|
+
return { column, operator: "==", value: rest };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const opStr = rest.substring(0, secondDot);
|
|
393
|
+
const valueStr = rest.substring(secondDot + 1);
|
|
394
|
+
const operator = toCanonicalOp(opStr) ?? "==";
|
|
395
|
+
|
|
396
|
+
// Parse list values with escape-aware splitting
|
|
397
|
+
if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
|
|
398
|
+
const items = splitListItems(valueStr.slice(1, -1));
|
|
399
|
+
return { column, operator, value: items };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { column, operator, value: valueStr };
|
|
403
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CollectionAccessor,
|
|
3
|
+
FilterCondition,
|
|
4
|
+
FindParams,
|
|
5
|
+
FindResponse,
|
|
6
|
+
LogicalCondition,
|
|
7
|
+
QueryBuilderInterface,
|
|
8
|
+
WhereFilterOp,
|
|
9
|
+
WhereValue
|
|
10
|
+
} from "@rebasepro/types";
|
|
2
11
|
|
|
3
12
|
export function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition {
|
|
4
13
|
return { type: "or",
|
|
@@ -10,7 +19,7 @@ export function and(...conditions: (FilterCondition | LogicalCondition)[]): Logi
|
|
|
10
19
|
conditions };
|
|
11
20
|
}
|
|
12
21
|
|
|
13
|
-
export function cond(column: string, operator:
|
|
22
|
+
export function cond(column: string, operator: WhereFilterOp, value: unknown): FilterCondition {
|
|
14
23
|
return { column,
|
|
15
24
|
operator,
|
|
16
25
|
value };
|
|
@@ -26,9 +35,9 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
26
35
|
* @example
|
|
27
36
|
* client.collection('users').where('age', '>=', 18).find()
|
|
28
37
|
*/
|
|
29
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
38
|
+
where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
|
|
30
39
|
where(logicalCondition: LogicalCondition): this;
|
|
31
|
-
where(columnOrCondition: string | LogicalCondition, operator?:
|
|
40
|
+
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
|
|
32
41
|
// Handle LogicalCondition signature
|
|
33
42
|
if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
|
|
34
43
|
this.params.logical = columnOrCondition as LogicalCondition;
|
|
@@ -40,18 +49,18 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
const column = columnOrCondition as string;
|
|
43
|
-
const condition: [
|
|
52
|
+
const condition: [WhereFilterOp, unknown] = [operator!, value];
|
|
44
53
|
const existing = this.params.where[column];
|
|
45
54
|
|
|
46
55
|
if (existing === undefined) {
|
|
47
56
|
this.params.where[column] = condition;
|
|
48
57
|
} else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
|
|
49
|
-
(this.params.where[column] as [
|
|
58
|
+
(this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
|
|
50
59
|
} else {
|
|
51
60
|
// Convert existing single tuple/value into array of tuples
|
|
52
|
-
let firstCondition: [
|
|
61
|
+
let firstCondition: [WhereFilterOp, unknown];
|
|
53
62
|
if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
|
|
54
|
-
firstCondition = existing as [
|
|
63
|
+
firstCondition = existing as [WhereFilterOp, unknown];
|
|
55
64
|
} else {
|
|
56
65
|
firstCondition = ["==", existing];
|
|
57
66
|
}
|
|
@@ -66,8 +75,8 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
66
75
|
* @example
|
|
67
76
|
* client.collection('users').orderBy('createdAt', 'desc').find()
|
|
68
77
|
*/
|
|
69
|
-
orderBy(column: keyof M & string,
|
|
70
|
-
this.params.orderBy =
|
|
78
|
+
orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
|
|
79
|
+
this.params.orderBy = [column, direction];
|
|
71
80
|
return this;
|
|
72
81
|
}
|
|
73
82
|
|
|
@@ -7,15 +7,15 @@ import {
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The subset of a collection needed to resolve its data source. Accepting a
|
|
10
|
-
* structural type (rather than the full `
|
|
10
|
+
* structural type (rather than the full `CollectionConfig`) keeps this usable
|
|
11
11
|
* from anywhere — frontend router, backend registry, editor — without coupling
|
|
12
12
|
* to the collection union.
|
|
13
13
|
*/
|
|
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";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { OrderByTuple } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sort-order wire codec.
|
|
5
|
+
*
|
|
6
|
+
* This is the ONLY module that knows about the colon-delimited wire format
|
|
7
|
+
* (`"field:direction"`) used in HTTP query parameters.
|
|
8
|
+
* Everything else speaks {@link OrderByTuple} exclusively.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the filter architecture in `filter-dialect.ts`.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Serialize an {@link OrderByTuple} to the wire format `"field:direction"`.
|
|
17
|
+
*
|
|
18
|
+
* **Runtime tolerance:** if the input is already a well-formed wire string
|
|
19
|
+
* (from an untyped JS caller), it is returned unchanged.
|
|
20
|
+
* This is undocumented tolerance, not public API — don't rely on it.
|
|
21
|
+
*
|
|
22
|
+
* @param orderBy - A canonical `[field, direction]` tuple, or at runtime
|
|
23
|
+
* possibly a pre-serialized string (undocumented tolerance).
|
|
24
|
+
* @returns The wire-format string, or `undefined` if the input is falsy.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Field names containing `:` are representable in the tuple form but
|
|
28
|
+
* **not** on the wire — this is an inherent limitation of the colon-delimited
|
|
29
|
+
* encoding and is not resolved here.
|
|
30
|
+
*/
|
|
31
|
+
export function serializeOrderBy(orderBy?: OrderByTuple | string): string | undefined {
|
|
32
|
+
if (!orderBy) return undefined;
|
|
33
|
+
// Runtime tolerance: pass through a pre-serialized wire string unchanged.
|
|
34
|
+
if (typeof orderBy === "string") return orderBy;
|
|
35
|
+
return `${orderBy[0]}:${orderBy[1]}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
|
|
40
|
+
*
|
|
41
|
+
* Lenient parsing (matches existing server behaviour):
|
|
42
|
+
* - Bare field name (no colon): `"name"` → `["name", "asc"]`
|
|
43
|
+
* - Unknown direction: `"name:foo"` → `["name", "asc"]`
|
|
44
|
+
* - Empty / falsy input: → `undefined`
|
|
45
|
+
*
|
|
46
|
+
* @param raw - The wire-format string from an HTTP query parameter.
|
|
47
|
+
* @returns The canonical tuple, or `undefined` if the input is empty/falsy.
|
|
48
|
+
*/
|
|
49
|
+
export function deserializeOrderBy(raw?: string): OrderByTuple | undefined {
|
|
50
|
+
if (!raw) return undefined;
|
|
51
|
+
const idx = raw.indexOf(":");
|
|
52
|
+
if (idx === -1) return [raw, "asc"];
|
|
53
|
+
const field = raw.slice(0, idx);
|
|
54
|
+
const dir = raw.slice(idx + 1);
|
|
55
|
+
return [field, dir === "desc" ? "desc" : "asc"];
|
|
56
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,4 +4,6 @@ export * from "./data/buildRebaseData";
|
|
|
4
4
|
export * from "./data/buildRoutedRebaseData";
|
|
5
5
|
export * from "./data/resolveDataSource";
|
|
6
6
|
export * from "./data/query_builder";
|
|
7
|
+
export * from "./data/filter-dialect";
|
|
8
|
+
export * from "./data/sort-dialect";
|
|
7
9
|
export * from "./table-classification";
|