@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.
Files changed (63) hide show
  1. package/README.md +4 -4
  2. package/dist/collections/CollectionRegistry.d.ts +30 -15
  3. package/dist/collections/default-collections.d.ts +255 -2
  4. package/dist/data/buildRebaseData.d.ts +30 -2
  5. package/dist/data/buildRoutedRebaseData.d.ts +14 -9
  6. package/dist/data/filter-dialect.d.ts +75 -0
  7. package/dist/data/query_builder.d.ts +4 -4
  8. package/dist/data/resolveDataSource.d.ts +8 -8
  9. package/dist/data/sort-dialect.d.ts +41 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.es.js +1125 -299
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/index.umd.js +1138 -303
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/util/builders.d.ts +52 -42
  16. package/dist/util/callbacks.d.ts +8 -3
  17. package/dist/util/collections.d.ts +4 -4
  18. package/dist/util/entities.d.ts +2 -2
  19. package/dist/util/filter-operator-resolution.d.ts +32 -0
  20. package/dist/util/index.d.ts +2 -0
  21. package/dist/util/navigation_from_path.d.ts +4 -4
  22. package/dist/util/navigation_utils.d.ts +3 -3
  23. package/dist/util/parent_references_from_path.d.ts +2 -2
  24. package/dist/util/permissions.d.ts +30 -6
  25. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  26. package/dist/util/policy/index.d.ts +3 -0
  27. package/dist/util/policy/policyToPostgres.d.ts +22 -0
  28. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  29. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  30. package/dist/util/references.d.ts +2 -2
  31. package/dist/util/relations.d.ts +5 -5
  32. package/dist/util/resolutions.d.ts +2 -2
  33. package/dist/util/storage.d.ts +26 -1
  34. package/package.json +13 -13
  35. package/src/collections/CollectionRegistry.ts +92 -61
  36. package/src/collections/default-collections.ts +4 -4
  37. package/src/data/buildRebaseData.ts +336 -172
  38. package/src/data/buildRoutedRebaseData.ts +22 -16
  39. package/src/data/filter-dialect.ts +403 -0
  40. package/src/data/query_builder.ts +19 -10
  41. package/src/data/resolveDataSource.ts +10 -10
  42. package/src/data/sort-dialect.ts +56 -0
  43. package/src/index.ts +2 -0
  44. package/src/util/builders.ts +87 -84
  45. package/src/util/callbacks.ts +15 -8
  46. package/src/util/collections.ts +4 -4
  47. package/src/util/entities.ts +4 -4
  48. package/src/util/filter-operator-resolution.ts +81 -0
  49. package/src/util/index.ts +2 -0
  50. package/src/util/navigation_from_path.ts +4 -4
  51. package/src/util/navigation_utils.ts +8 -8
  52. package/src/util/parent_references_from_path.ts +3 -3
  53. package/src/util/permissions.test.ts +7 -5
  54. package/src/util/permissions.ts +90 -163
  55. package/src/util/policy/evaluatePolicy.ts +152 -0
  56. package/src/util/policy/index.ts +3 -0
  57. package/src/util/policy/policyToPostgres.ts +165 -0
  58. package/src/util/policy/securityRuleToConditions.ts +67 -0
  59. package/src/util/policy/sqlToPolicy.ts +88 -0
  60. package/src/util/references.ts +3 -3
  61. package/src/util/relations.ts +19 -20
  62. package/src/util/resolutions.ts +11 -11
  63. package/src/util/storage.ts +34 -1
@@ -1,24 +1,30 @@
1
- import { RebaseData, CollectionAccessor } from "@rebasepro/types";
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: RebaseData;
20
+ defaultData: T;
14
21
 
15
22
  /**
16
- * Per-data-source {@link RebaseData} instances for direct and custom
17
- * transports, keyed by data-source key (e.g. `"analytics"`). Server-
18
- * mediated sources are not listed here — they fall through to
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, RebaseData>;
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): RebaseData {
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): RebaseData {
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): CollectionAccessor {
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 RebaseData;
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 { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface, FilterOperator, LogicalCondition, WhereValue, FilterCondition } from "@rebasepro/types";
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: FilterOperator, value: unknown): FilterCondition {
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: FilterOperator, value: WhereValue<M[K]>): this;
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?: FilterOperator, value?: unknown): this {
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: [FilterOperator, unknown] = [operator!, value];
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 [FilterOperator, unknown][]).push(condition);
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: [FilterOperator, unknown];
61
+ let firstCondition: [WhereFilterOp, unknown];
53
62
  if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
54
- firstCondition = existing as [FilterOperator, unknown];
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, ascending: "asc" | "desc" = "asc"): this {
70
- this.params.orderBy = `${column}:${ascending}`;
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 `EntityCollection`) keeps this usable
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
- /** Legacy engine hint / fallback routing key. */
18
- driver?: string;
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 the legacy
45
- * `collection.driver`, else {@link DEFAULT_DATA_SOURCE_KEY}.
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 for backward compatibility: `engine`
49
- * from the legacy `driver` (or the key, or `"postgres"`), `transport`
50
- * defaults to `"server"`, and `databaseId` from the collection.
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 ?? collection?.driver ?? DEFAULT_DATA_SOURCE_KEY;
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?.driver
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";