@spooky-sync/query-builder 0.0.1-canary.7 → 0.0.1-canary.71

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.
@@ -0,0 +1,201 @@
1
+ ---
2
+ name: sp00ky-query-builder
3
+ description: >-
4
+ Type-safe query builder for SurrealDB used by the Sp00ky framework. Use when
5
+ defining schemas (SchemaStructure), building queries with QueryBuilder, handling
6
+ relationships (one/many cardinality), or working with Sp00ky type helpers like
7
+ TableNames, GetTable, TableModel, and QueryResult.
8
+ metadata:
9
+ author: sp00ky-sync
10
+ version: "0.0.1"
11
+ ---
12
+
13
+ # Sp00ky Query Builder
14
+
15
+ `@spooky-sync/query-builder` provides the type-safe schema definition format and query builder used throughout the Sp00ky framework.
16
+
17
+ ## Schema Definition
18
+
19
+ Sp00ky schemas are defined as `const` objects satisfying `SchemaStructure`. They are typically generated by the Sp00ky CLI (`spooky generate`), but can be written by hand.
20
+
21
+ ```typescript
22
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
23
+
24
+ export const schema = {
25
+ tables: [
26
+ {
27
+ name: 'user',
28
+ columns: {
29
+ id: { type: 'string', optional: false },
30
+ email: { type: 'string', optional: false },
31
+ name: { type: 'string', optional: true },
32
+ },
33
+ primaryKey: ['id'],
34
+ },
35
+ {
36
+ name: 'post',
37
+ columns: {
38
+ id: { type: 'string', optional: false },
39
+ title: { type: 'string', optional: false },
40
+ body: { type: 'string', optional: false },
41
+ author: { type: 'string', optional: false, recordId: true },
42
+ },
43
+ primaryKey: ['id'],
44
+ },
45
+ ],
46
+ relationships: [
47
+ { from: 'post', field: 'author', to: 'user', cardinality: 'one' },
48
+ { from: 'user', field: 'posts', to: 'post', cardinality: 'many' },
49
+ ],
50
+ backends: {},
51
+ } as const satisfies SchemaStructure;
52
+ ```
53
+
54
+ ### Column Types
55
+
56
+ The `type` field maps to TypeScript types:
57
+
58
+ | ValueType | TypeScript Type |
59
+ |-------------|-----------------|
60
+ | `'string'` | `string` |
61
+ | `'number'` | `number` |
62
+ | `'boolean'` | `boolean` |
63
+ | `'null'` | `null` |
64
+ | `'json'` | `unknown` |
65
+
66
+ Set `optional: true` to make a field nullable (`T | null`).
67
+ Set `recordId: true` to indicate a field stores a SurrealDB RecordId.
68
+
69
+ ## QueryBuilder API
70
+
71
+ The `QueryBuilder` class provides a fluent, chainable API for constructing type-safe queries.
72
+
73
+ ```typescript
74
+ import { QueryBuilder } from '@spooky-sync/query-builder';
75
+
76
+ // Create a query builder for the "post" table
77
+ const query = new QueryBuilder(schema, 'post')
78
+ .where({ author: 'user:alice' })
79
+ .select('id', 'title', 'body')
80
+ .orderBy('title', 'desc')
81
+ .limit(10)
82
+ .offset(0)
83
+ .related('author')
84
+ .build();
85
+ ```
86
+
87
+ ### Chain Methods
88
+
89
+ | Method | Signature | Description |
90
+ |--------|-----------|-------------|
91
+ | `where` | `.where(conditions)` | Filter by field values. String IDs are auto-parsed to RecordId. |
92
+ | `select` | `.select(...fields)` | Pick specific fields. Can only be called once per query. |
93
+ | `orderBy` | `.orderBy(field, 'asc' \| 'desc')` | Sort results. Default direction is `'asc'`. |
94
+ | `limit` | `.limit(count)` | Limit the number of results. |
95
+ | `offset` | `.offset(count)` | Skip the first N results. |
96
+ | `one` | `.one()` | Return a single object instead of an array. Implicitly sets `limit(1)`. |
97
+ | `related` | `.related(field, modifier?)` | Include related data via subquery. See [Relationships](#relationships). |
98
+ | `build` | `.build()` | Finalize the query into a `FinalQuery` object. |
99
+
100
+ ### Relationships
101
+
102
+ Use `.related()` to include related tables. Relationships must be declared in the schema.
103
+
104
+ ```typescript
105
+ // One-to-one: post has one author
106
+ new QueryBuilder(schema, 'post')
107
+ .related('author')
108
+ .build();
109
+
110
+ // One-to-many: user has many posts
111
+ new QueryBuilder(schema, 'user')
112
+ .related('posts')
113
+ .build();
114
+
115
+ // Nested relationship with modifier
116
+ new QueryBuilder(schema, 'user')
117
+ .related('posts', (q) => q.orderBy('title', 'asc').limit(5))
118
+ .build();
119
+ ```
120
+
121
+ Cardinality is inferred from the schema. For `'one'` relationships, the result is an object. For `'many'`, it is an array.
122
+
123
+ ## Backend Schema
124
+
125
+ The `backends` field in `SchemaStructure` defines available HTTP backends, their outbox tables, and typed routes. This is generated by `spooky generate` from your `sp00ky.yml` config and OpenAPI spec.
126
+
127
+ ### Schema Structure
128
+
129
+ ```typescript
130
+ export interface SchemaStructure {
131
+ // ...tables, relationships...
132
+ readonly backends: Record<string, HTTPOutboxBackendDefinition>;
133
+ }
134
+
135
+ export interface HTTPOutboxBackendDefinition {
136
+ readonly outboxTable: string; // The SurrealDB table used as the outbox (e.g., 'job')
137
+ readonly routes: Record<string, HTTPBackendRouteDefinition>;
138
+ }
139
+
140
+ export interface HTTPBackendRouteDefinition {
141
+ readonly args: Record<string, HTTPBackendRouteArgsDefinition>;
142
+ }
143
+
144
+ export interface HTTPBackendRouteArgsDefinition {
145
+ readonly type: ValueType; // 'string' | 'number' | 'boolean' | 'null' | 'json'
146
+ readonly optional: boolean;
147
+ }
148
+ ```
149
+
150
+ ### Generated Example
151
+
152
+ Given a `sp00ky.yml` with an `api` backend and an OpenAPI spec defining a `/spookify` route with an `id` parameter, `spooky generate` produces:
153
+
154
+ ```typescript
155
+ export const schema = {
156
+ // ...tables, relationships...
157
+ backends: {
158
+ "api": {
159
+ outboxTable: 'job' as const,
160
+ routes: {
161
+ "/spookify": {
162
+ args: {
163
+ "id": {
164
+ type: 'string' as const,
165
+ optional: false as const
166
+ },
167
+ }
168
+ },
169
+ }
170
+ },
171
+ },
172
+ } as const satisfies SchemaStructure;
173
+ ```
174
+
175
+ ### Backend Type Helpers
176
+
177
+ - `BackendNames<S>` — Union of all backend name strings (e.g., `'api'`)
178
+ - `BackendRoutes<S, B>` — Union of all route paths for backend `B` (e.g., `'/spookify'`)
179
+ - `RoutePayload<S, B, R>` — Typed payload object for route `R` on backend `B`. Required args become required fields, optional args become optional fields. Types are mapped from `ValueType` to TypeScript types.
180
+
181
+ These types are used by `db.run()` to provide full type safety — backend name, route path, and payload are all checked at compile time.
182
+
183
+ ## Type Helpers
184
+
185
+ See [references/type-helpers.md](references/type-helpers.md) for a full reference of all type utilities.
186
+
187
+ Key types:
188
+
189
+ - `TableNames<S>` — Union of all table name strings
190
+ - `GetTable<S, Name>` — Extract a table definition by name
191
+ - `TableModel<T>` — Convert a table's columns to a TypeScript object type
192
+ - `QueryResult<S, TableName, RelatedFields, IsOne>` — The full result type including related fields
193
+ - `BackendNames<S>`, `BackendRoutes<S, B>`, `RoutePayload<S, B, R>` — Backend/run type helpers
194
+
195
+ ## Common Pitfalls
196
+
197
+ 1. **String IDs are auto-converted**: When you pass `'user:alice'` in a `where()`, it is automatically parsed into a SurrealDB `RecordId`. If the string does not contain `:`, and the field is named `id`, the table name is prepended.
198
+
199
+ 2. **`select()` can only be called once**: Calling it twice throws an error. Combine all fields in one call.
200
+
201
+ 3. **Schema must use `as const satisfies SchemaStructure`**: Without `as const`, TypeScript cannot infer literal types for table names and relationships, and type safety is lost.
@@ -0,0 +1,80 @@
1
+ # Type Helpers Reference
2
+
3
+ ## Schema Types
4
+
5
+ ### `SchemaStructure`
6
+
7
+ The top-level schema interface:
8
+
9
+ ```typescript
10
+ interface SchemaStructure {
11
+ readonly tables: readonly {
12
+ readonly name: string;
13
+ readonly columns: Record<string, ColumnSchema>;
14
+ readonly primaryKey: readonly string[];
15
+ }[];
16
+ readonly relationships: readonly {
17
+ readonly from: string;
18
+ readonly field: string;
19
+ readonly to: string;
20
+ readonly cardinality: 'one' | 'many';
21
+ }[];
22
+ readonly backends: Record<string, HTTPOutboxBackendDefinition>;
23
+ readonly access?: Record<string, AccessDefinition>;
24
+ readonly buckets?: readonly BucketDefinitionSchema[];
25
+ }
26
+ ```
27
+
28
+ ### `ColumnSchema`
29
+
30
+ ```typescript
31
+ interface ColumnSchema {
32
+ readonly type: 'string' | 'number' | 'boolean' | 'null' | 'json';
33
+ readonly optional: boolean;
34
+ readonly dateTime?: boolean;
35
+ readonly recordId?: boolean;
36
+ }
37
+ ```
38
+
39
+ ## Table Type Helpers
40
+
41
+ | Type | Description |
42
+ |------|-------------|
43
+ | `TableNames<S>` | Union of all table name strings from the schema |
44
+ | `GetTable<S, Name>` | Extract a specific table definition by name |
45
+ | `TableModel<T>` | Convert a table's `columns` record into a TypeScript object type |
46
+ | `TableFieldNames<T>` | Union of all column names for a table |
47
+
48
+ ### Example
49
+
50
+ ```typescript
51
+ type MySchema = typeof schema;
52
+ type Tables = TableNames<MySchema>; // 'user' | 'post'
53
+ type UserTable = GetTable<MySchema, 'user'>; // The user table definition
54
+ type UserModel = TableModel<UserTable>; // { id: string; email: string; name: string | null }
55
+ ```
56
+
57
+ ## Relationship Type Helpers
58
+
59
+ | Type | Description |
60
+ |------|-------------|
61
+ | `TableRelationships<S, TableName>` | All relationship definitions originating from a table |
62
+ | `RelationshipFields<S, TableName>` | Union of relationship field names for a table |
63
+ | `GetRelationship<S, TableName, Field>` | Get a specific relationship by table and field name |
64
+
65
+ ## Result Type Helpers
66
+
67
+ | Type | Description |
68
+ |------|-------------|
69
+ | `QueryResult<S, TableName, RelatedFields, IsOne>` | The full query result type. If `IsOne` is `true`, returns a single object; otherwise an array. Includes related fields merged into the base model. |
70
+ | `RelatedFieldsMap` | A record mapping field names to `{ to, cardinality, relatedFields }` |
71
+
72
+ ## Backend Type Helpers
73
+
74
+ | Type | Description |
75
+ |------|-------------|
76
+ | `BackendNames<S>` | Union of all backend names |
77
+ | `BackendRoutes<S, B>` | Union of all route paths for a backend |
78
+ | `RoutePayload<S, B, R>` | The typed payload object for a specific route |
79
+ | `BucketNames<S>` | Union of all bucket names |
80
+ | `BucketConfig<S, B>` | Configuration for a specific bucket |
@@ -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,7 @@ import type {
7
7
  RelatedQuery,
8
8
  SchemaAwareQueryModifier,
9
9
  SchemaAwareQueryModifierBuilder,
10
+ WhereInput,
10
11
  } from './types';
11
12
  import type {
12
13
  TableNames,
@@ -172,7 +173,7 @@ export class InnerQuery<
172
173
  /**
173
174
  * Helper type to get the model type for a related table
174
175
  */
175
- type GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
176
+ type _GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
176
177
  RelatedTableName extends TableNames<S> ? TableModel<GetTable<S, RelatedTableName>> : never;
177
178
 
178
179
  /**
@@ -234,6 +235,7 @@ export class FinalQuery<
234
235
  S extends SchemaStructure,
235
236
  TableName extends TableNames<S>,
236
237
  T extends { columns: Record<string, ColumnSchema> },
238
+ // oxlint-disable-next-line no-unused-vars -- RelatedFields is used externally for type inference
237
239
  RelatedFields extends RelatedFieldsMap,
238
240
  IsOne extends boolean,
239
241
  R = void,
@@ -299,7 +301,7 @@ class SchemaAwareQueryModifierBuilderImpl<
299
301
  private readonly schema: S
300
302
  ) {}
301
303
 
302
- where(conditions: Partial<TableModel<GetTable<S, TableName>>>): this {
304
+ where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this {
303
305
  this.options.where = { ...this.options.where, ...conditions };
304
306
  return this;
305
307
  }
@@ -412,7 +414,7 @@ export class QueryBuilder<
412
414
  * Add additional where conditions
413
415
  */
414
416
  where(
415
- conditions: Partial<TableModel<GetTable<S, TableName>>>
417
+ conditions: WhereInput<TableModel<GetTable<S, TableName>>>
416
418
  ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
417
419
  this.options.where = { ...this.options.where, ...conditions };
418
420
  return this;
@@ -641,6 +643,7 @@ export function extractSubqueryQueryInfos<S extends SchemaStructure>(
641
643
  if (relationship) {
642
644
  // Determine foreign key field
643
645
  // rel.alias is guaranteed to be defined if relationship is found (matched r.field)
646
+ // oxlint-disable-next-line no-non-null-assertion -- alias is guaranteed defined when relationship is found
644
647
  let foreignKeyField = rel.alias!;
645
648
 
646
649
  if (relationship.cardinality === 'many') {
@@ -751,32 +754,52 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
751
754
  const vars: Record<string, unknown> = {};
752
755
  if (parsedWhere && Object.keys(parsedWhere).length > 0) {
753
756
  const conditions: string[] = [];
754
- for (const [key, value] of Object.entries(parsedWhere)) {
755
- const varName = key;
756
757
 
757
- // Handle operator objects { _op, _val }
758
+ // Build a single condition for `field`, binding its value under `varName`.
759
+ // Supports operator objects `{ _op, _val, _swap }` (e.g. `{ _op: '<=', _val:
760
+ // 5 }`); a `$`-prefixed string `_val` references an existing param verbatim.
761
+ // Plain values mean equality (`field = $varName`).
762
+ const buildCondition = (field: string, value: unknown, varName: string): string => {
758
763
  if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
759
764
  const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
760
-
761
- let rightSide = '';
765
+ let rightSide: string;
762
766
  if (typeof _val === 'string' && _val.startsWith('$')) {
763
767
  rightSide = _val;
764
768
  } else {
765
769
  vars[varName] = _val;
766
770
  rightSide = `$${varName}`;
767
771
  }
772
+ return _swap ? `${rightSide} ${_op} ${field}` : `${field} ${_op} ${rightSide}`;
773
+ }
774
+ vars[varName] = value;
775
+ return `${field} = $${varName}`;
776
+ };
768
777
 
769
- if (_swap) {
770
- conditions.push(`${rightSide} ${_op} ${key}`);
771
- } else {
772
- conditions.push(`${key} ${_op} ${rightSide}`);
778
+ for (const [key, value] of Object.entries(parsedWhere)) {
779
+ // OR-group: `{ _or: [ {field: val}, {field: {_op,_val}}, ... ] }` compiles
780
+ // to one parenthesised `(c1 OR c2 ...)` conjunct. Each branch condition gets
781
+ // a unique, position-indexed param name (`or0`, `or1`, ) so it never
782
+ // collides with a top-level condition on the same field (e.g. a `white =
783
+ // $white` filter alongside an opponent `_or` on white/black) — keeping the
784
+ // surql + vars, and thus the query hash, stable and deterministic.
785
+ if (key === '_or' && Array.isArray(value)) {
786
+ const orParts: string[] = [];
787
+ let i = 0;
788
+ for (const branch of value) {
789
+ if (branch && typeof branch === 'object') {
790
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
791
+ orParts.push(buildCondition(bField, bVal, `or${i++}`));
792
+ }
793
+ }
773
794
  }
774
- } else {
775
- vars[varName] = value;
776
- conditions.push(`${key} = $${varName}`);
795
+ if (orParts.length > 0) conditions.push(`(${orParts.join(' OR ')})`);
796
+ continue;
777
797
  }
798
+
799
+ conditions.push(buildCondition(key, value, key));
778
800
  }
779
- query += ` WHERE ${conditions.join(' AND ')}`;
801
+
802
+ if (conditions.length > 0) query += ` WHERE ${conditions.join(' AND ')}`;
780
803
  }
781
804
 
782
805
  // Add PATCH for UPDATE
@@ -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,25 @@
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;
14
23
  }
15
24
 
16
25
  /**
@@ -62,6 +71,7 @@ export type TypeNameToTypeMap = {
62
71
  boolean: boolean;
63
72
  null: null;
64
73
  json: unknown;
74
+ Uint8Array: Uint8Array;
65
75
  };
66
76
 
67
77
  /**
package/src/types.ts CHANGED
@@ -36,9 +36,40 @@ export interface RelatedQuery {
36
36
  cardinality: 'one' | 'many';
37
37
  }
38
38
 
39
+ /**
40
+ * Comparison-operator descriptor for a single WHERE field, e.g.
41
+ * `{ _op: '<=', _val: 5 }` → `field <= $field`. A `$`-prefixed string `_val`
42
+ * references an existing query param verbatim; `_swap: true` flips the operands
43
+ * (`$val _op field`). Plain values still mean equality (`field = $field`).
44
+ */
45
+ export interface ComparisonOp {
46
+ _op: '=' | '!=' | '>' | '>=' | '<' | '<=' | (string & {});
47
+ _val: unknown;
48
+ _swap?: boolean;
49
+ }
50
+
51
+ /** A single WHERE field value: an equality value or a comparison descriptor. */
52
+ export type WhereFieldValue<V> = V | ComparisonOp;
53
+
54
+ /** A flat conjunction of field conditions (equality or comparison). */
55
+ export type WhereConditions<TModel extends GenericModel> = {
56
+ [K in keyof TModel]?: WhereFieldValue<TModel[K]>;
57
+ };
58
+
59
+ /**
60
+ * WHERE input for `.where()`. Supports equality (`{ field: value }`), comparison
61
+ * operators (`{ field: { _op, _val } }`), and a single top-level `_or` group of
62
+ * condition fragments that compile to a parenthesised `(... OR ...)` conjunct —
63
+ * e.g. `{ _or: [{ white: x }, { black: x }] }` → `(white = $or0 OR black = $or1)`.
64
+ * Backward-compatible with plain `Partial<TModel>` equality objects.
65
+ */
66
+ export type WhereInput<TModel extends GenericModel> = WhereConditions<TModel> & {
67
+ _or?: WhereConditions<TModel>[];
68
+ };
69
+
39
70
  export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean> {
40
71
  select?: ((keyof TModel & string) | '*')[];
41
- where?: Partial<TModel>;
72
+ where?: WhereInput<TModel>;
42
73
  limit?: number;
43
74
  offset?: number;
44
75
  orderBy?: Partial<Record<keyof TModel, 'asc' | 'desc'>>;
@@ -47,10 +78,10 @@ export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean
47
78
  isOne?: IsOne;
48
79
  }
49
80
 
50
- export interface LiveQueryOptions<TModel extends GenericModel> extends Omit<
81
+ export type LiveQueryOptions<TModel extends GenericModel> = Omit<
51
82
  QueryOptions<TModel, boolean>,
52
83
  'orderBy'
53
- > {}
84
+ >;
54
85
 
55
86
  // Import schema types for schema-aware modifiers
56
87
  import type {
@@ -78,7 +109,7 @@ export type SchemaAwareQueryModifier<
78
109
 
79
110
  // Simplified query builder interface for modifying subqueries
80
111
  export interface QueryModifierBuilder<TModel extends GenericModel> {
81
- where(conditions: Partial<TModel>): this;
112
+ where(conditions: WhereInput<TModel>): this;
82
113
  select(...fields: ((keyof TModel & string) | '*')[]): this;
83
114
  limit(count: number): this;
84
115
  offset(count: number): this;
@@ -93,7 +124,7 @@ export interface SchemaAwareQueryModifierBuilder<
93
124
  TableName extends TableNames<S>,
94
125
  RelatedFields extends Record<string, any> = {},
95
126
  > {
96
- where(conditions: Partial<TableModel<GetTable<S, TableName>>>): this;
127
+ where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this;
97
128
  select(...fields: ((keyof TableModel<GetTable<S, TableName>> & string) | '*')[]): this;
98
129
  limit(count: number): this;
99
130
  offset(count: number): this;
@@ -139,6 +170,7 @@ export type RelationshipFields<TModel extends GenericModel> = {
139
170
  * Simplified to directly access the nested structure
140
171
  */
141
172
  export type InferRelatedModelFromMetadata<
173
+ // oxlint-disable-next-line no-unused-vars -- Schema is used as a generic constraint
142
174
  Schema extends GenericSchema,
143
175
  TableName extends string,
144
176
  FieldName extends string,