@rebasepro/common 0.6.1 → 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.
Files changed (46) hide show
  1. package/dist/collections/CollectionRegistry.d.ts +30 -2
  2. package/dist/collections/default-collections.d.ts +255 -2
  3. package/dist/data/buildRoutedRebaseData.d.ts +53 -0
  4. package/dist/data/filter-dialect.d.ts +61 -0
  5. package/dist/data/query_builder.d.ts +4 -4
  6. package/dist/data/resolveDataSource.d.ts +43 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.es.js +777 -178
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +793 -176
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/table-classification.d.ts +47 -0
  13. package/dist/util/builders.d.ts +48 -1
  14. package/dist/util/callbacks.d.ts +6 -1
  15. package/dist/util/index.d.ts +1 -0
  16. package/dist/util/permissions.d.ts +26 -2
  17. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  18. package/dist/util/policy/index.d.ts +3 -0
  19. package/dist/util/policy/policyToPostgres.d.ts +10 -0
  20. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  21. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  22. package/dist/util/storage.d.ts +26 -1
  23. package/package.json +3 -3
  24. package/src/collections/CollectionRegistry.ts +80 -16
  25. package/src/collections/default-collections.ts +4 -4
  26. package/src/data/buildRebaseData.ts +9 -120
  27. package/src/data/buildRoutedRebaseData.ts +97 -0
  28. package/src/data/filter-dialect.ts +318 -0
  29. package/src/data/query_builder.ts +10 -10
  30. package/src/data/resolveDataSource.ts +79 -0
  31. package/src/index.ts +4 -1
  32. package/src/table-classification.ts +109 -0
  33. package/src/util/builders.ts +78 -1
  34. package/src/util/callbacks.ts +8 -1
  35. package/src/util/index.ts +1 -0
  36. package/src/util/permissions.test.ts +5 -3
  37. package/src/util/permissions.ts +85 -158
  38. package/src/util/policy/evaluatePolicy.ts +146 -0
  39. package/src/util/policy/index.ts +3 -0
  40. package/src/util/policy/policyToPostgres.ts +85 -0
  41. package/src/util/policy/securityRuleToConditions.ts +67 -0
  42. package/src/util/policy/sqlToPolicy.ts +88 -0
  43. package/src/util/references.ts +1 -1
  44. package/src/util/relations.ts +8 -9
  45. package/src/util/resolutions.ts +6 -6
  46. package/src/util/storage.ts +34 -1
@@ -6,128 +6,13 @@ import {
6
6
  FindResponse,
7
7
  Entity,
8
8
  EntityValues,
9
- FilterValues,
10
9
  WhereFilterOp,
11
- WhereFieldValue,
12
- WhereFilterOpShort,
13
10
  LogicalCondition,
14
11
  WhereValue
15
12
  } from "@rebasepro/types";
16
13
  import { toSnakeCase } from "@rebasepro/utils";
17
14
  import { QueryBuilder } from "./query_builder";
18
-
19
- /**
20
- * Convert where-clause filter object to the internal DataDriver FilterValues format.
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] }
28
- */
29
- function convertWhereToFilter(where?: Record<string, WhereFieldValue>): FilterValues<string> | undefined {
30
- if (!where) return undefined;
31
-
32
- const operatorMap: Record<string, WhereFilterOp> = {
33
- "eq": "==",
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"
52
- };
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
- }
15
+ import { deserializeFilter } from "./filter-dialect";
131
16
 
132
17
  /**
133
18
  * Parse an orderBy string like "created_at:desc" into [field, direction].
@@ -147,11 +32,14 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
147
32
  const accessor: CollectionAccessor<M> = {
148
33
  async find(params?: FindParams): Promise<FindResponse<M>> {
149
34
  const orderParsed = parseOrderBy(params?.orderBy);
35
+ // Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
36
+ const filter = params?.where ? deserializeFilter(params.where as any) : undefined;
37
+
150
38
  const entities = await driver.fetchCollection<M>({
151
39
  path: slug,
152
40
  limit: params?.limit,
153
41
  offset: params?.offset,
154
- filter: convertWhereToFilter(params?.where),
42
+ filter,
155
43
  orderBy: orderParsed?.[0],
156
44
  order: orderParsed?.[1],
157
45
  searchString: params?.searchString
@@ -208,9 +96,10 @@ values: {} as Record<string, unknown> }
208
96
 
209
97
  count: driver.countEntities
210
98
  ? async (params?: FindParams): Promise<number> => {
99
+ const filter = params?.where ? deserializeFilter(params.where as any) : undefined;
211
100
  return driver.countEntities!({
212
101
  path: slug,
213
- filter: convertWhereToFilter(params?.where)
102
+ filter
214
103
  });
215
104
  }
216
105
  : undefined,
@@ -224,7 +113,7 @@ values: {} as Record<string, unknown> }
224
113
  path: slug,
225
114
  limit: params?.limit,
226
115
  offset: params?.offset,
227
- filter: convertWhereToFilter(params?.where),
116
+ filter: params?.where,
228
117
  orderBy: orderParsed?.[0],
229
118
  order: orderParsed?.[1],
230
119
  searchString: params?.searchString,
@@ -254,7 +143,7 @@ values: {} as Record<string, unknown> }
254
143
  } : undefined,
255
144
 
256
145
  // Fluent Query Builder
257
- where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOpShort, value?: unknown) {
146
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
258
147
  const builder = new QueryBuilder<M>(accessor);
259
148
  if (typeof columnOrCondition === "object") {
260
149
  return builder.where(columnOrCondition);
@@ -0,0 +1,97 @@
1
+ import { RebaseData, CollectionAccessor } from "@rebasepro/types";
2
+ import { toSnakeCase } from "@rebasepro/utils";
3
+
4
+ /**
5
+ * Parameters for {@link buildRoutedRebaseData}.
6
+ */
7
+ export interface RoutedRebaseDataParams {
8
+ /**
9
+ * The default data source. Handles every collection that does not
10
+ * resolve to an entry in `sources` (i.e. server-transport collections,
11
+ * which ride the Rebase client).
12
+ */
13
+ defaultData: RebaseData;
14
+
15
+ /**
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`.
20
+ */
21
+ sources: Record<string, RebaseData>;
22
+
23
+ /**
24
+ * Resolve the data-source key for a given collection slug or path.
25
+ * Typically backed by the collection registry + `resolveDataSource`
26
+ * (`resolveDataSource(registry.getCollection(path), defs).key`).
27
+ *
28
+ * Return `undefined` (or a key absent from `sources`) to route to the
29
+ * default data source.
30
+ */
31
+ resolveKey: (slugOrPath: string) => string | undefined;
32
+ }
33
+
34
+ /**
35
+ * Build a {@link RebaseData} that routes each collection to the right
36
+ * backend based on its resolved data source.
37
+ *
38
+ * `.collection(path)` (and dynamic `data.products`-style access) resolves the
39
+ * collection's data-source key via `resolveKey` and delegates to the matching
40
+ * entry in `sources`, falling back to `defaultData` when there is no match.
41
+ * Because routing keys off the *path being accessed*, a reference widget
42
+ * inside a Firestore form that points at a Postgres collection is still
43
+ * served by Postgres — routing follows the target, not the ancestor.
44
+ *
45
+ * When `sources` is empty this returns `defaultData` untouched, so the
46
+ * single-driver setup keeps identical behaviour and identity (important for
47
+ * effect dependencies that key off the data instance).
48
+ *
49
+ * @example
50
+ * const data = buildRoutedRebaseData({
51
+ * defaultData: client.data,
52
+ * sources: { analytics: buildRebaseData(firestoreDriver) },
53
+ * resolveKey: (path) => resolveDataSource(registry.getCollection(path), defs).key
54
+ * });
55
+ * await data.products.find(); // → default (server / Postgres)
56
+ * await data.events.find(); // → Firestore, if `events.dataSource === "analytics"`
57
+ */
58
+ export function buildRoutedRebaseData({
59
+ defaultData,
60
+ sources,
61
+ resolveKey
62
+ }: RoutedRebaseDataParams): RebaseData {
63
+
64
+ // Fast path: nothing to route → return the default untouched (preserves
65
+ // referential identity for effect dependencies).
66
+ if (!sources || Object.keys(sources).length === 0) {
67
+ return defaultData;
68
+ }
69
+
70
+ function resolve(slugOrPath: string): RebaseData {
71
+ const key = resolveKey(slugOrPath);
72
+ if (key && sources[key]) return sources[key];
73
+ return defaultData;
74
+ }
75
+
76
+ function getAccessor(slugOrPath: string): CollectionAccessor {
77
+ return resolve(slugOrPath).collection(slugOrPath);
78
+ }
79
+
80
+ const target = {
81
+ collection: getAccessor
82
+ } as RebaseData;
83
+
84
+ return new Proxy(target, {
85
+ get(_target, prop: string | symbol) {
86
+ if (prop === "collection") return getAccessor;
87
+ // Ignore Symbol properties (e.g. Symbol.toPrimitive, Symbol.iterator)
88
+ if (typeof prop === "symbol") return undefined;
89
+ // Ignore internal JS properties
90
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
91
+
92
+ // Convert camelCase property names to snake_case slugs, mirroring
93
+ // buildRebaseData so dynamic access routes consistently.
94
+ return getAccessor(toSnakeCase(prop));
95
+ }
96
+ });
97
+ }
@@ -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, FilterOperator, LogicalCondition, WhereValue, FilterCondition } from "@rebasepro/types";
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: FilterOperator, value: unknown): FilterCondition {
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: FilterOperator, value: WhereValue<M[K]>): this;
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?: FilterOperator, value?: unknown): this {
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: [FilterOperator, unknown] = [operator!, value];
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 [FilterOperator, unknown][]).push(condition);
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: [FilterOperator, unknown];
52
+ let firstCondition: [WhereFilterOp, unknown];
53
53
  if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
54
- firstCondition = existing as [FilterOperator, unknown];
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, ascending: "asc" | "desc" = "asc"): this {
70
- this.params.orderBy = `${column}:${ascending}`;
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