@spooky-sync/query-builder 0.0.1-canary.11 → 0.0.1-canary.110

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.
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, expectTypeOf } from 'vitest';
2
2
  import { QueryBuilder, buildQueryFromOptions } from './query-builder';
3
3
  import { RecordId } from 'surrealdb';
4
- import type { TableNames, TableModel, GetTable } from './table-schema';
4
+ import type { TableModel } from './table-schema';
5
5
 
6
6
  // Schema for testing the new array-based API
7
7
  const testSchema = {
@@ -92,6 +92,76 @@ describe('QueryBuilder', () => {
92
92
  });
93
93
  });
94
94
 
95
+ it('should build a comparison operator condition via { _op, _val }', () => {
96
+ const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
97
+ builder.where({ created_at: { _op: '<=', _val: 5 } });
98
+ const result = builder.build().run();
99
+
100
+ expect(result.query).toBe('SELECT * FROM user WHERE created_at <= $created_at;');
101
+ expect(result.vars).toEqual({ created_at: 5 });
102
+ });
103
+
104
+ it('should build an OR group via _or with position-indexed params', () => {
105
+ const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
106
+ builder.where({ _or: [{ username: 'x' }, { email: 'x' }] });
107
+ const result = builder.build().run();
108
+
109
+ expect(result.query).toBe('SELECT * FROM user WHERE (username = $or0 OR email = $or1);');
110
+ expect(result.vars).toEqual({ or0: 'x', or1: 'x' });
111
+ });
112
+
113
+ it('should not collide an _or branch with a top-level condition on the same field', () => {
114
+ // Mirrors the game filter where a color filter (white = me) coexists with an
115
+ // opponent OR on white/black: the OR branch must use its own param name.
116
+ const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
117
+ builder.where({ username: 'me', _or: [{ username: 'opp' }, { email: 'opp' }] });
118
+ const result = builder.build().run();
119
+
120
+ expect(result.query).toBe(
121
+ 'SELECT * FROM user WHERE username = $username AND (username = $or0 OR email = $or1);'
122
+ );
123
+ expect(result.vars).toEqual({ username: 'me', or0: 'opp', or1: 'opp' });
124
+ });
125
+
126
+ it('should combine equality + comparison + OR group + order/limit/offset', () => {
127
+ // The shape the filtered game list produces: scope equality, a date floor as
128
+ // an integer sort_index comparison, an opponent OR group, paginated.
129
+ const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
130
+ builder
131
+ .where({ email: 'e', created_at: { _op: '<=', _val: 5 }, _or: [{ username: 'p' }, { email: 'p' }] })
132
+ .orderBy('created_at', 'asc')
133
+ .limit(50)
134
+ .offset(0);
135
+ const result = builder.build().run();
136
+
137
+ expect(result.query).toBe(
138
+ 'SELECT * FROM user WHERE email = $email AND created_at <= $created_at AND ' +
139
+ '(username = $or0 OR email = $or1) ORDER BY created_at asc LIMIT 50 START 0;'
140
+ );
141
+ expect(result.vars).toEqual({ email: 'e', created_at: 5, or0: 'p', or1: 'p' });
142
+ });
143
+
144
+ it('should produce a stable hash for the same logical filtered query', () => {
145
+ const make = () =>
146
+ new QueryBuilder(testSchema, 'user', (q) => q.selectQuery)
147
+ .where({ email: 'e', _or: [{ username: 'p' }, { email: 'p' }] })
148
+ .orderBy('created_at', 'asc')
149
+ .limit(50)
150
+ .offset(0)
151
+ .build()
152
+ .run();
153
+ expect(make().hash).toBe(make().hash);
154
+
155
+ const different = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery)
156
+ .where({ email: 'e', _or: [{ username: 'q' }, { email: 'q' }] })
157
+ .orderBy('created_at', 'asc')
158
+ .limit(50)
159
+ .offset(0)
160
+ .build()
161
+ .run();
162
+ expect(different.hash).not.toBe(make().hash);
163
+ });
164
+
95
165
  it('should build query with select fields', () => {
96
166
  const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
97
167
  builder.select('username', 'email');
@@ -270,11 +340,15 @@ describe('Edge Cases', () => {
270
340
  describe('Type Tests', () => {
271
341
  it('should enforce correct table names', () => {
272
342
  // Valid table names should work
343
+ // oxlint-disable-next-line no-new
273
344
  new QueryBuilder(testSchema, 'user');
345
+ // oxlint-disable-next-line no-new
274
346
  new QueryBuilder(testSchema, 'thread');
347
+ // oxlint-disable-next-line no-new
275
348
  new QueryBuilder(testSchema, 'comment');
276
349
 
277
350
  // @ts-expect-error - invalid table name should not compile
351
+ // oxlint-disable-next-line no-new
278
352
  new QueryBuilder(testSchema, 'invalid_table');
279
353
  });
280
354
 
@@ -389,9 +463,6 @@ describe('Type Tests', () => {
389
463
  });
390
464
 
391
465
  describe('Schema Metadata Integration', () => {
392
- // Using testSchema from top-level scope
393
- type TestSchemaMetadata = typeof testSchema;
394
-
395
466
  it('should accept testSchema in constructor', () => {
396
467
  const builder = new QueryBuilder(testSchema, 'thread', (q) => q.selectQuery);
397
468
 
@@ -7,6 +7,12 @@ import type {
7
7
  RelatedQuery,
8
8
  SchemaAwareQueryModifier,
9
9
  SchemaAwareQueryModifierBuilder,
10
+ WhereInput,
11
+ QueryPlan,
12
+ RelationPlan,
13
+ WhereNode,
14
+ WhereComparison,
15
+ ComparisonOp,
10
16
  } from './types';
11
17
  import type {
12
18
  TableNames,
@@ -172,7 +178,7 @@ export class InnerQuery<
172
178
  /**
173
179
  * Helper type to get the model type for a related table
174
180
  */
175
- type GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
181
+ type _GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
176
182
  RelatedTableName extends TableNames<S> ? TableModel<GetTable<S, RelatedTableName>> : never;
177
183
 
178
184
  /**
@@ -234,6 +240,7 @@ export class FinalQuery<
234
240
  S extends SchemaStructure,
235
241
  TableName extends TableNames<S>,
236
242
  T extends { columns: Record<string, ColumnSchema> },
243
+ // oxlint-disable-next-line no-unused-vars -- RelatedFields is used externally for type inference
237
244
  RelatedFields extends RelatedFieldsMap,
238
245
  IsOne extends boolean,
239
246
  R = void,
@@ -299,7 +306,7 @@ class SchemaAwareQueryModifierBuilderImpl<
299
306
  private readonly schema: S
300
307
  ) {}
301
308
 
302
- where(conditions: Partial<TableModel<GetTable<S, TableName>>>): this {
309
+ where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this {
303
310
  this.options.where = { ...this.options.where, ...conditions };
304
311
  return this;
305
312
  }
@@ -412,7 +419,7 @@ export class QueryBuilder<
412
419
  * Add additional where conditions
413
420
  */
414
421
  where(
415
- conditions: Partial<TableModel<GetTable<S, TableName>>>
422
+ conditions: WhereInput<TableModel<GetTable<S, TableName>>>
416
423
  ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
417
424
  this.options.where = { ...this.options.where, ...conditions };
418
425
  return this;
@@ -641,6 +648,7 @@ export function extractSubqueryQueryInfos<S extends SchemaStructure>(
641
648
  if (relationship) {
642
649
  // Determine foreign key field
643
650
  // rel.alias is guaranteed to be defined if relationship is found (matched r.field)
651
+ // oxlint-disable-next-line no-non-null-assertion -- alias is guaranteed defined when relationship is found
644
652
  let foreignKeyField = rel.alias!;
645
653
 
646
654
  if (relationship.cardinality === 'many') {
@@ -751,32 +759,52 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
751
759
  const vars: Record<string, unknown> = {};
752
760
  if (parsedWhere && Object.keys(parsedWhere).length > 0) {
753
761
  const conditions: string[] = [];
754
- for (const [key, value] of Object.entries(parsedWhere)) {
755
- const varName = key;
756
762
 
757
- // Handle operator objects { _op, _val }
763
+ // Build a single condition for `field`, binding its value under `varName`.
764
+ // Supports operator objects `{ _op, _val, _swap }` (e.g. `{ _op: '<=', _val:
765
+ // 5 }`); a `$`-prefixed string `_val` references an existing param verbatim.
766
+ // Plain values mean equality (`field = $varName`).
767
+ const buildCondition = (field: string, value: unknown, varName: string): string => {
758
768
  if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
759
769
  const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
760
-
761
- let rightSide = '';
770
+ let rightSide: string;
762
771
  if (typeof _val === 'string' && _val.startsWith('$')) {
763
772
  rightSide = _val;
764
773
  } else {
765
774
  vars[varName] = _val;
766
775
  rightSide = `$${varName}`;
767
776
  }
777
+ return _swap ? `${rightSide} ${_op} ${field}` : `${field} ${_op} ${rightSide}`;
778
+ }
779
+ vars[varName] = value;
780
+ return `${field} = $${varName}`;
781
+ };
768
782
 
769
- if (_swap) {
770
- conditions.push(`${rightSide} ${_op} ${key}`);
771
- } else {
772
- conditions.push(`${key} ${_op} ${rightSide}`);
783
+ for (const [key, value] of Object.entries(parsedWhere)) {
784
+ // OR-group: `{ _or: [ {field: val}, {field: {_op,_val}}, ... ] }` compiles
785
+ // to one parenthesised `(c1 OR c2 ...)` conjunct. Each branch condition gets
786
+ // a unique, position-indexed param name (`or0`, `or1`, ) so it never
787
+ // collides with a top-level condition on the same field (e.g. a `white =
788
+ // $white` filter alongside an opponent `_or` on white/black) — keeping the
789
+ // surql + vars, and thus the query hash, stable and deterministic.
790
+ if (key === '_or' && Array.isArray(value)) {
791
+ const orParts: string[] = [];
792
+ let i = 0;
793
+ for (const branch of value) {
794
+ if (branch && typeof branch === 'object') {
795
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
796
+ orParts.push(buildCondition(bField, bVal, `or${i++}`));
797
+ }
798
+ }
773
799
  }
774
- } else {
775
- vars[varName] = value;
776
- conditions.push(`${key} = $${varName}`);
800
+ if (orParts.length > 0) conditions.push(`(${orParts.join(' OR ')})`);
801
+ continue;
777
802
  }
803
+
804
+ conditions.push(buildCondition(key, value, key));
778
805
  }
779
- query += ` WHERE ${conditions.join(' AND ')}`;
806
+
807
+ if (conditions.length > 0) query += ` WHERE ${conditions.join(' AND ')}`;
780
808
  }
781
809
 
782
810
  // Add PATCH for UPDATE
@@ -815,9 +843,169 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
815
843
  0
816
844
  ),
817
845
  vars: Object.keys(vars).length > 0 ? vars : undefined,
846
+ // Engine-neutral plan mirrors the SELECT above for non-SurrealQL backends.
847
+ // Only SELECT carries a plan; the isOne→limit=1 mutation above is already
848
+ // reflected in `options.limit`, so the plan sees it too.
849
+ plan: method === 'SELECT' ? buildQueryPlan(tableName, options, schema) : undefined,
818
850
  };
819
851
  }
820
852
 
853
+ /**
854
+ * Build the engine-neutral {@link QueryPlan} for a SELECT. Mirrors the string
855
+ * assembly in {@link buildQueryFromOptions} / {@link buildSubquery} exactly so a
856
+ * non-SurrealQL backend produces results identical to the SurrealQL path.
857
+ */
858
+ function buildQueryPlan<TModel extends GenericModel, IsOne extends boolean>(
859
+ tableName: string,
860
+ options: QueryOptions<TModel, IsOne>,
861
+ schema: SchemaStructure
862
+ ): QueryPlan {
863
+ const plan: QueryPlan = { table: tableName };
864
+
865
+ if (options.select && options.select.length > 0 && !options.select.includes('*')) {
866
+ plan.select = options.select.filter((f) => f !== '*') as string[];
867
+ }
868
+
869
+ const parsedWhere = options.where
870
+ ? (parseObjectIdsToRecordId(options.where, tableName) as Record<string, unknown>)
871
+ : undefined;
872
+ if (parsedWhere && Object.keys(parsedWhere).length > 0) {
873
+ const nodes = buildWhereNodes(parsedWhere);
874
+ if (nodes.length > 0) plan.where = nodes;
875
+ }
876
+
877
+ if (options.orderBy && Object.keys(options.orderBy).length > 0) {
878
+ plan.orderBy = Object.entries(options.orderBy).map(
879
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
880
+ );
881
+ }
882
+
883
+ if (options.limit !== undefined) plan.limit = options.limit;
884
+ if (options.offset !== undefined) plan.offset = options.offset;
885
+
886
+ if (options.related && options.related.length > 0) {
887
+ plan.relations = options.related.map((rel) => buildRelationPlan(rel, schema));
888
+ }
889
+
890
+ return plan;
891
+ }
892
+
893
+ /**
894
+ * Engine-neutral counterpart of {@link buildSubquery}. Resolves the same
895
+ * cardinality / foreign-key / nested-relation metadata but returns a structured
896
+ * {@link RelationPlan} instead of a SurrealQL subquery string.
897
+ */
898
+ function buildRelationPlan(
899
+ rel: RelatedQuery & { foreignKeyField?: string },
900
+ schema: SchemaStructure
901
+ ): RelationPlan {
902
+ const { relatedTable, alias, modifier, cardinality } = rel;
903
+ // Same fallback chain as buildSubquery (`rel.foreignKeyField || alias`); the
904
+ // top-level foreignKeyField is already reverse-resolved by `.related()`.
905
+ const foreignKeyField = (rel.foreignKeyField || alias || relatedTable) as string;
906
+
907
+ const plan: RelationPlan = {
908
+ alias: (alias || relatedTable) as string,
909
+ table: relatedTable,
910
+ cardinality,
911
+ foreignKeyField,
912
+ };
913
+
914
+ if (modifier) {
915
+ const modifierBuilder = new SchemaAwareQueryModifierBuilderImpl(relatedTable, schema);
916
+ modifier(modifierBuilder as any);
917
+ const subOptions = modifierBuilder._getOptions();
918
+
919
+ if (subOptions.select && subOptions.select.length > 0 && !subOptions.select.includes('*')) {
920
+ plan.select = subOptions.select.filter((f) => f !== '*') as string[];
921
+ }
922
+
923
+ if (subOptions.where && Object.keys(subOptions.where).length > 0) {
924
+ const parsedSubWhere = parseObjectIdsToRecordId(subOptions.where, relatedTable) as Record<
925
+ string,
926
+ unknown
927
+ >;
928
+ const nodes = buildWhereNodes(parsedSubWhere);
929
+ if (nodes.length > 0) plan.where = nodes;
930
+ }
931
+
932
+ if (subOptions.orderBy && Object.keys(subOptions.orderBy).length > 0) {
933
+ plan.orderBy = Object.entries(subOptions.orderBy).map(
934
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
935
+ );
936
+ }
937
+
938
+ if (subOptions.limit !== undefined) plan.limit = subOptions.limit;
939
+
940
+ // Nested relations: re-resolve exactly as buildSubquery does — the child's
941
+ // foreignKeyField comes from the relatedTable-based lookup, not the reverse
942
+ // heuristic used for top-level relations.
943
+ if (subOptions.related && subOptions.related.length > 0) {
944
+ const resolvedNestedRels = subOptions.related.map((nestedRel) => {
945
+ const relationship = schema.relationships.find(
946
+ (r) => r.from === relatedTable && r.field === nestedRel.alias
947
+ );
948
+ if (relationship) {
949
+ const nestedForeignKeyField =
950
+ relationship.cardinality === 'many' ? relatedTable : (nestedRel.alias as string);
951
+ return {
952
+ ...nestedRel,
953
+ relatedTable: relationship.to,
954
+ cardinality: relationship.cardinality,
955
+ foreignKeyField: nestedForeignKeyField,
956
+ } as RelatedQuery & { foreignKeyField: string };
957
+ }
958
+ return nestedRel;
959
+ });
960
+ plan.relations = resolvedNestedRels.map((nestedRel) => buildRelationPlan(nestedRel, schema));
961
+ }
962
+ }
963
+
964
+ // one-to-one gets an implicit per-parent LIMIT 1 (matches buildSubquery).
965
+ if (cardinality === 'one' && plan.limit === undefined) {
966
+ plan.limit = 1;
967
+ }
968
+
969
+ return plan;
970
+ }
971
+
972
+ /**
973
+ * Convert a parsed WHERE object (string IDs already → RecordId) into the
974
+ * engine-neutral {@link WhereNode}[] conjunction. Mirrors the `_or` / comparison
975
+ * / equality handling in {@link buildQueryFromOptions}. A `$`-prefixed `_val`
976
+ * becomes a `paramRef` (with the leading `$` stripped).
977
+ */
978
+ function buildWhereNodes(parsedWhere: Record<string, unknown>): WhereNode[] {
979
+ const toComparison = (field: string, value: unknown): WhereComparison => {
980
+ if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
981
+ const { _op, _val, _swap } = value as ComparisonOp;
982
+ if (typeof _val === 'string' && _val.startsWith('$')) {
983
+ return { field, op: _op, value: undefined, paramRef: _val.slice(1), swap: _swap };
984
+ }
985
+ return { field, op: _op, value: _val, swap: _swap };
986
+ }
987
+ return { field, op: '=', value };
988
+ };
989
+
990
+ const nodes: WhereNode[] = [];
991
+ for (const [key, value] of Object.entries(parsedWhere)) {
992
+ if (key === '_or' && Array.isArray(value)) {
993
+ const or: WhereComparison[] = [];
994
+ for (const branch of value) {
995
+ if (branch && typeof branch === 'object') {
996
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
997
+ or.push(toComparison(bField, bVal));
998
+ }
999
+ }
1000
+ }
1001
+ if (or.length > 0) nodes.push({ or });
1002
+ continue;
1003
+ }
1004
+ nodes.push(toComparison(key, value));
1005
+ }
1006
+ return nodes;
1007
+ }
1008
+
821
1009
  /**
822
1010
  * Build a subquery for a related field
823
1011
  */
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { QueryBuilder, SchemaStructure } from './index';
2
+ import { QueryBuilder } from './index';
3
3
 
4
4
  describe('QueryBuilder Relationship Inference', () => {
5
5
  const schema = {
@@ -1,16 +1,30 @@
1
1
  /**
2
2
  * Supported value types in the schema
3
3
  */
4
- export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json';
4
+ export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json' | 'Uint8Array';
5
5
 
6
6
  /**
7
7
  * Column metadata defining the type and optionality of a field
8
8
  */
9
+ /**
10
+ * CRDT types supported by Sp00ky's Loro integration
11
+ */
12
+ export type CrdtType = 'text' | 'map' | 'list' | 'counter';
13
+
9
14
  export interface ColumnSchema {
10
15
  readonly type: ValueType;
11
16
  readonly optional: boolean;
12
17
  readonly dateTime?: boolean;
13
18
  readonly recordId?: boolean;
19
+ readonly crdt?: CrdtType;
20
+ readonly cursor?: boolean;
21
+ /** True for `TYPE bytes` columns. Runtime values are `Uint8Array`. */
22
+ readonly bytes?: boolean;
23
+ /**
24
+ * True for `TYPE array<...>` columns. `type` then names the ELEMENT type, so
25
+ * the runtime value is `ElementType[]` (e.g. `array<string>` → `string[]`).
26
+ */
27
+ readonly array?: boolean;
14
28
  }
15
29
 
16
30
  /**
@@ -62,16 +76,25 @@ export type TypeNameToTypeMap = {
62
76
  boolean: boolean;
63
77
  null: null;
64
78
  json: unknown;
79
+ Uint8Array: Uint8Array;
65
80
  };
66
81
 
82
+ /**
83
+ * The element/base TS type of a column, wrapping in an array for `array: true`
84
+ * columns (where `type` names the element type).
85
+ */
86
+ export type ColumnBaseTSType<T extends ColumnSchema> = T extends { array: true }
87
+ ? TypeNameToTypeMap[T['type']][]
88
+ : TypeNameToTypeMap[T['type']];
89
+
67
90
  /**
68
91
  * Convert a column type to its TypeScript type
69
92
  */
70
93
  export type ColumnToTSType<T extends ColumnSchema> = T extends {
71
94
  optional: true;
72
95
  }
73
- ? TypeNameToTypeMap[T['type']] | null
74
- : TypeNameToTypeMap[T['type']];
96
+ ? ColumnBaseTSType<T> | null
97
+ : ColumnBaseTSType<T>;
75
98
 
76
99
  /**
77
100
  * Helper to extract relationship field names for a table
package/src/types.ts CHANGED
@@ -23,6 +23,79 @@ export interface QueryInfo {
23
23
  query: string;
24
24
  hash: number;
25
25
  vars?: Record<string, unknown>;
26
+ /**
27
+ * Engine-neutral description of the same SELECT, used by non-SurrealQL local
28
+ * cache backends (e.g. SQLite) that cannot parse the `query` string. Only
29
+ * populated for `SELECT` (undefined for LIVE/UPDATE/DELETE). See `QueryPlan`.
30
+ */
31
+ plan?: QueryPlan;
32
+ }
33
+
34
+ /**
35
+ * A single WHERE comparison. `value` is the resolved value (string IDs already
36
+ * converted to `RecordId`); when `paramRef` is set the condition references an
37
+ * existing query param verbatim (`$name`) instead of an inline value. `swap`
38
+ * flips the operands (`value op field`), mirroring `ComparisonOp._swap`.
39
+ */
40
+ export interface WhereComparison {
41
+ field: string;
42
+ op: ComparisonOp['_op'];
43
+ value: unknown;
44
+ paramRef?: string;
45
+ swap?: boolean;
46
+ }
47
+
48
+ /** A parenthesised `(c1 OR c2 …)` group, from a `_or` fragment. */
49
+ export interface WhereOr {
50
+ or: WhereComparison[];
51
+ }
52
+
53
+ /**
54
+ * Engine-neutral WHERE: a top-level conjunction (AND) of comparisons and/or
55
+ * OR-groups. Mirrors `buildQueryFromOptions`'s condition assembly exactly.
56
+ */
57
+ export type WhereNode = WhereComparison | WhereOr;
58
+
59
+ /**
60
+ * Engine-neutral description of a SELECT query. Backends render it to their own
61
+ * dialect (SurrealQL, SQLite, …). Relations are resolved by the caller via
62
+ * level-ordered decomposition rather than nested projection, so `relations`
63
+ * carries the tree rather than a flattened subquery string.
64
+ */
65
+ export interface QueryPlan {
66
+ table: string;
67
+ /** Projection field names; undefined means all (`*`). */
68
+ select?: string[];
69
+ where?: WhereNode[];
70
+ orderBy?: [field: string, direction: 'asc' | 'desc'][];
71
+ limit?: number;
72
+ offset?: number;
73
+ relations?: RelationPlan[];
74
+ /**
75
+ * Window materialization: when set, the base rows are EXACTLY these record
76
+ * ids (the window the SSP already computed), ignoring `where`/`limit`/
77
+ * `offset`. `orderBy`, `select` and `relations` still apply. Set by
78
+ * {@link buildWindowMaterializationPlan}; see `window-query.ts`.
79
+ */
80
+ ids?: unknown[];
81
+ }
82
+
83
+ /**
84
+ * One `.related()` edge in a {@link QueryPlan}. Correlation:
85
+ * - `one` → parent[`foreignKeyField`] = child.id (attach `bucket[0] ?? null`)
86
+ * - `many` → child[`foreignKeyField`] = parent.id (attach `bucket`)
87
+ * `limit`/`orderBy` are applied PER PARENT during decomposition.
88
+ */
89
+ export interface RelationPlan {
90
+ alias: string;
91
+ table: string;
92
+ cardinality: 'one' | 'many';
93
+ foreignKeyField: string;
94
+ select?: string[];
95
+ where?: WhereNode[];
96
+ orderBy?: [field: string, direction: 'asc' | 'desc'][];
97
+ limit?: number;
98
+ relations?: RelationPlan[];
26
99
  }
27
100
 
28
101
  export interface RelatedQuery {
@@ -36,9 +109,40 @@ export interface RelatedQuery {
36
109
  cardinality: 'one' | 'many';
37
110
  }
38
111
 
112
+ /**
113
+ * Comparison-operator descriptor for a single WHERE field, e.g.
114
+ * `{ _op: '<=', _val: 5 }` → `field <= $field`. A `$`-prefixed string `_val`
115
+ * references an existing query param verbatim; `_swap: true` flips the operands
116
+ * (`$val _op field`). Plain values still mean equality (`field = $field`).
117
+ */
118
+ export interface ComparisonOp {
119
+ _op: '=' | '!=' | '>' | '>=' | '<' | '<=' | (string & {});
120
+ _val: unknown;
121
+ _swap?: boolean;
122
+ }
123
+
124
+ /** A single WHERE field value: an equality value or a comparison descriptor. */
125
+ export type WhereFieldValue<V> = V | ComparisonOp;
126
+
127
+ /** A flat conjunction of field conditions (equality or comparison). */
128
+ export type WhereConditions<TModel extends GenericModel> = {
129
+ [K in keyof TModel]?: WhereFieldValue<TModel[K]>;
130
+ };
131
+
132
+ /**
133
+ * WHERE input for `.where()`. Supports equality (`{ field: value }`), comparison
134
+ * operators (`{ field: { _op, _val } }`), and a single top-level `_or` group of
135
+ * condition fragments that compile to a parenthesised `(... OR ...)` conjunct —
136
+ * e.g. `{ _or: [{ white: x }, { black: x }] }` → `(white = $or0 OR black = $or1)`.
137
+ * Backward-compatible with plain `Partial<TModel>` equality objects.
138
+ */
139
+ export type WhereInput<TModel extends GenericModel> = WhereConditions<TModel> & {
140
+ _or?: WhereConditions<TModel>[];
141
+ };
142
+
39
143
  export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean> {
40
144
  select?: ((keyof TModel & string) | '*')[];
41
- where?: Partial<TModel>;
145
+ where?: WhereInput<TModel>;
42
146
  limit?: number;
43
147
  offset?: number;
44
148
  orderBy?: Partial<Record<keyof TModel, 'asc' | 'desc'>>;
@@ -47,10 +151,10 @@ export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean
47
151
  isOne?: IsOne;
48
152
  }
49
153
 
50
- export interface LiveQueryOptions<TModel extends GenericModel> extends Omit<
154
+ export type LiveQueryOptions<TModel extends GenericModel> = Omit<
51
155
  QueryOptions<TModel, boolean>,
52
156
  'orderBy'
53
- > {}
157
+ >;
54
158
 
55
159
  // Import schema types for schema-aware modifiers
56
160
  import type {
@@ -78,7 +182,7 @@ export type SchemaAwareQueryModifier<
78
182
 
79
183
  // Simplified query builder interface for modifying subqueries
80
184
  export interface QueryModifierBuilder<TModel extends GenericModel> {
81
- where(conditions: Partial<TModel>): this;
185
+ where(conditions: WhereInput<TModel>): this;
82
186
  select(...fields: ((keyof TModel & string) | '*')[]): this;
83
187
  limit(count: number): this;
84
188
  offset(count: number): this;
@@ -93,7 +197,7 @@ export interface SchemaAwareQueryModifierBuilder<
93
197
  TableName extends TableNames<S>,
94
198
  RelatedFields extends Record<string, any> = {},
95
199
  > {
96
- where(conditions: Partial<TableModel<GetTable<S, TableName>>>): this;
200
+ where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this;
97
201
  select(...fields: ((keyof TableModel<GetTable<S, TableName>> & string) | '*')[]): this;
98
202
  limit(count: number): this;
99
203
  offset(count: number): this;
@@ -139,6 +243,7 @@ export type RelationshipFields<TModel extends GenericModel> = {
139
243
  * Simplified to directly access the nested structure
140
244
  */
141
245
  export type InferRelatedModelFromMetadata<
246
+ // oxlint-disable-next-line no-unused-vars -- Schema is used as a generic constraint
142
247
  Schema extends GenericSchema,
143
248
  TableName extends string,
144
249
  FieldName extends string,