@spooky-sync/query-builder 0.0.1-canary.15 → 0.0.1-canary.150

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');
@@ -181,6 +251,23 @@ describe('Relationship Queries', () => {
181
251
  'SELECT *, (SELECT *, (SELECT * FROM user WHERE id=$parent.author LIMIT 1)[0] AS author FROM comment WHERE thread=$parent.id) AS comments FROM thread;'
182
252
  );
183
253
  });
254
+
255
+ // An unknown relationship (e.g. a table owned by a devOnly backend that a
256
+ // free/Cloudflare deployment never provisions, so codegen drops it from the
257
+ // client schema) must be SKIPPED, not throw — otherwise it takes the whole
258
+ // query (and its other `.related()` siblings) down. This is what left the
259
+ // ThreadDetail page stuck on "Loading..." when `jobs` disappeared.
260
+ it('skips an unknown relationship instead of throwing', () => {
261
+ const builder = new QueryBuilder(testSchema, 'thread', (q) => q.selectQuery);
262
+ expect(() => {
263
+ builder.related('author' as any);
264
+ builder.related('does_not_exist' as any); // must NOT throw
265
+ }).not.toThrow();
266
+ const result = builder.build().run();
267
+ // The valid relation is still projected; the unknown one is simply absent.
268
+ expect(result.query).toContain('AS author');
269
+ expect(result.query).not.toContain('does_not_exist');
270
+ });
184
271
  });
185
272
 
186
273
  describe('buildQueryFromOptions', () => {
@@ -215,6 +302,28 @@ describe('buildQueryFromOptions', () => {
215
302
 
216
303
  expect(result.query).toBe('LIVE SELECT * FROM user WHERE username = $username;');
217
304
  });
305
+
306
+ // Regression guard for the thread-detail "crossed results → 404" bug: the
307
+ // engine-neutral plan's top-level WHERE must reference the SAME var the surql
308
+ // binds (`$username`), so materialization (`select(plan, params)`) filters by
309
+ // the query's own `params` (its identity) instead of a baked literal that
310
+ // could belong to another query's plan. So the top-level plan node must carry
311
+ // `paramRef` equal to the surql var name.
312
+ it('top-level plan WHERE uses paramRef matching the surql var (slaved to params)', () => {
313
+ const result = buildQueryFromOptions<TableModel<(typeof testSchema)['tables'][0]>, boolean>(
314
+ 'SELECT',
315
+ 'user',
316
+ { where: { username: 'john' } },
317
+ testSchema
318
+ );
319
+ // surql binds $username …
320
+ expect(result.query).toBe('SELECT * FROM user WHERE username = $username;');
321
+ expect(result.vars).toEqual({ username: 'john' });
322
+ // … and the plan references that same var, not just a baked literal.
323
+ expect(result.plan?.where).toEqual([
324
+ { field: 'username', op: '=', value: 'john', paramRef: 'username' },
325
+ ]);
326
+ });
218
327
  });
219
328
 
220
329
  describe('RecordId Parsing', () => {
@@ -270,11 +379,15 @@ describe('Edge Cases', () => {
270
379
  describe('Type Tests', () => {
271
380
  it('should enforce correct table names', () => {
272
381
  // Valid table names should work
382
+ // oxlint-disable-next-line no-new
273
383
  new QueryBuilder(testSchema, 'user');
384
+ // oxlint-disable-next-line no-new
274
385
  new QueryBuilder(testSchema, 'thread');
386
+ // oxlint-disable-next-line no-new
275
387
  new QueryBuilder(testSchema, 'comment');
276
388
 
277
389
  // @ts-expect-error - invalid table name should not compile
390
+ // oxlint-disable-next-line no-new
278
391
  new QueryBuilder(testSchema, 'invalid_table');
279
392
  });
280
393
 
@@ -389,9 +502,6 @@ describe('Type Tests', () => {
389
502
  });
390
503
 
391
504
  describe('Schema Metadata Integration', () => {
392
- // Using testSchema from top-level scope
393
- type TestSchemaMetadata = typeof testSchema;
394
-
395
505
  it('should accept testSchema in constructor', () => {
396
506
  const builder = new QueryBuilder(testSchema, 'thread', (q) => q.selectQuery);
397
507
 
@@ -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
  }
@@ -365,9 +372,18 @@ class SchemaAwareQueryModifierBuilderImpl<
365
372
  );
366
373
 
367
374
  if (!relationship) {
368
- throw new Error(
369
- `Relationship '${String(relatedField)}' not found for table '${this.tableName}'`
370
- );
375
+ // No such relationship in the client schema — e.g. a table owned by a
376
+ // devOnly backend (the outbox `job`) that a free/Cloudflare deployment
377
+ // never provisions, so codegen omits it + its relationships. Skip the
378
+ // projection instead of throwing, which would take the whole query
379
+ // (and its other `.related()` siblings — author, comments) down.
380
+ // Mirrors the server's "unpermitted subquery → empty" degradation.
381
+ if (typeof console !== 'undefined') {
382
+ console.warn(
383
+ `[sp00ky] .related('${String(relatedField)}') skipped — no such relationship on '${this.tableName}' in the client schema`
384
+ );
385
+ }
386
+ return this as any;
371
387
  }
372
388
 
373
389
  const relatedTable = relationship.to;
@@ -412,7 +428,7 @@ export class QueryBuilder<
412
428
  * Add additional where conditions
413
429
  */
414
430
  where(
415
- conditions: Partial<TableModel<GetTable<S, TableName>>>
431
+ conditions: WhereInput<TableModel<GetTable<S, TableName>>>
416
432
  ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
417
433
  this.options.where = { ...this.options.where, ...conditions };
418
434
  return this;
@@ -515,7 +531,15 @@ export class QueryBuilder<
515
531
  );
516
532
 
517
533
  if (!relationship) {
518
- throw new Error(`Relationship '${String(field)}' not found for table '${this.tableName}'`);
534
+ // See the note on the other `.related()` overload: skip an unknown
535
+ // relationship (warn) rather than throwing, so a table absent from the
536
+ // client schema (e.g. the free-plan `job` outbox) can't crash the query.
537
+ if (typeof console !== 'undefined') {
538
+ console.warn(
539
+ `[sp00ky] .related('${String(field)}') skipped — no such relationship on '${this.tableName}' in the client schema`
540
+ );
541
+ }
542
+ return this as any;
519
543
  }
520
544
 
521
545
  // Determine cardinality and modifier based on arguments
@@ -641,6 +665,7 @@ export function extractSubqueryQueryInfos<S extends SchemaStructure>(
641
665
  if (relationship) {
642
666
  // Determine foreign key field
643
667
  // rel.alias is guaranteed to be defined if relationship is found (matched r.field)
668
+ // oxlint-disable-next-line no-non-null-assertion -- alias is guaranteed defined when relationship is found
644
669
  let foreignKeyField = rel.alias!;
645
670
 
646
671
  if (relationship.cardinality === 'many') {
@@ -751,32 +776,52 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
751
776
  const vars: Record<string, unknown> = {};
752
777
  if (parsedWhere && Object.keys(parsedWhere).length > 0) {
753
778
  const conditions: string[] = [];
754
- for (const [key, value] of Object.entries(parsedWhere)) {
755
- const varName = key;
756
779
 
757
- // Handle operator objects { _op, _val }
780
+ // Build a single condition for `field`, binding its value under `varName`.
781
+ // Supports operator objects `{ _op, _val, _swap }` (e.g. `{ _op: '<=', _val:
782
+ // 5 }`); a `$`-prefixed string `_val` references an existing param verbatim.
783
+ // Plain values mean equality (`field = $varName`).
784
+ const buildCondition = (field: string, value: unknown, varName: string): string => {
758
785
  if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
759
786
  const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
760
-
761
- let rightSide = '';
787
+ let rightSide: string;
762
788
  if (typeof _val === 'string' && _val.startsWith('$')) {
763
789
  rightSide = _val;
764
790
  } else {
765
791
  vars[varName] = _val;
766
792
  rightSide = `$${varName}`;
767
793
  }
794
+ return _swap ? `${rightSide} ${_op} ${field}` : `${field} ${_op} ${rightSide}`;
795
+ }
796
+ vars[varName] = value;
797
+ return `${field} = $${varName}`;
798
+ };
768
799
 
769
- if (_swap) {
770
- conditions.push(`${rightSide} ${_op} ${key}`);
771
- } else {
772
- conditions.push(`${key} ${_op} ${rightSide}`);
800
+ for (const [key, value] of Object.entries(parsedWhere)) {
801
+ // OR-group: `{ _or: [ {field: val}, {field: {_op,_val}}, ... ] }` compiles
802
+ // to one parenthesised `(c1 OR c2 ...)` conjunct. Each branch condition gets
803
+ // a unique, position-indexed param name (`or0`, `or1`, …) so it never
804
+ // collides with a top-level condition on the same field (e.g. a `white =
805
+ // $white` filter alongside an opponent `_or` on white/black) — keeping the
806
+ // surql + vars, and thus the query hash, stable and deterministic.
807
+ if (key === '_or' && Array.isArray(value)) {
808
+ const orParts: string[] = [];
809
+ let i = 0;
810
+ for (const branch of value) {
811
+ if (branch && typeof branch === 'object') {
812
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
813
+ orParts.push(buildCondition(bField, bVal, `or${i++}`));
814
+ }
815
+ }
773
816
  }
774
- } else {
775
- vars[varName] = value;
776
- conditions.push(`${key} = $${varName}`);
817
+ if (orParts.length > 0) conditions.push(`(${orParts.join(' OR ')})`);
818
+ continue;
777
819
  }
820
+
821
+ conditions.push(buildCondition(key, value, key));
778
822
  }
779
- query += ` WHERE ${conditions.join(' AND ')}`;
823
+
824
+ if (conditions.length > 0) query += ` WHERE ${conditions.join(' AND ')}`;
780
825
  }
781
826
 
782
827
  // Add PATCH for UPDATE
@@ -815,9 +860,194 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
815
860
  0
816
861
  ),
817
862
  vars: Object.keys(vars).length > 0 ? vars : undefined,
863
+ // Engine-neutral plan mirrors the SELECT above for non-SurrealQL backends.
864
+ // Only SELECT carries a plan; the isOne→limit=1 mutation above is already
865
+ // reflected in `options.limit`, so the plan sees it too.
866
+ plan: method === 'SELECT' ? buildQueryPlan(tableName, options, schema) : undefined,
818
867
  };
819
868
  }
820
869
 
870
+ /**
871
+ * Build the engine-neutral {@link QueryPlan} for a SELECT. Mirrors the string
872
+ * assembly in {@link buildQueryFromOptions} / {@link buildSubquery} exactly so a
873
+ * non-SurrealQL backend produces results identical to the SurrealQL path.
874
+ */
875
+ function buildQueryPlan<TModel extends GenericModel, IsOne extends boolean>(
876
+ tableName: string,
877
+ options: QueryOptions<TModel, IsOne>,
878
+ schema: SchemaStructure
879
+ ): QueryPlan {
880
+ const plan: QueryPlan = { table: tableName };
881
+
882
+ if (options.select && options.select.length > 0 && !options.select.includes('*')) {
883
+ plan.select = options.select.filter((f) => f !== '*') as string[];
884
+ }
885
+
886
+ const parsedWhere = options.where
887
+ ? (parseObjectIdsToRecordId(options.where, tableName) as Record<string, unknown>)
888
+ : undefined;
889
+ if (parsedWhere && Object.keys(parsedWhere).length > 0) {
890
+ // slaveToParams: top-level filters materialize from `params` (the query's
891
+ // identity), not a baked literal — see buildWhereNodes. Prevents a query's
892
+ // rows ever coming from a different query's plan.
893
+ const nodes = buildWhereNodes(parsedWhere, true);
894
+ if (nodes.length > 0) plan.where = nodes;
895
+ }
896
+
897
+ if (options.orderBy && Object.keys(options.orderBy).length > 0) {
898
+ plan.orderBy = Object.entries(options.orderBy).map(
899
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
900
+ );
901
+ }
902
+
903
+ if (options.limit !== undefined) plan.limit = options.limit;
904
+ if (options.offset !== undefined) plan.offset = options.offset;
905
+
906
+ if (options.related && options.related.length > 0) {
907
+ plan.relations = options.related.map((rel) => buildRelationPlan(rel, schema));
908
+ }
909
+
910
+ return plan;
911
+ }
912
+
913
+ /**
914
+ * Engine-neutral counterpart of {@link buildSubquery}. Resolves the same
915
+ * cardinality / foreign-key / nested-relation metadata but returns a structured
916
+ * {@link RelationPlan} instead of a SurrealQL subquery string.
917
+ */
918
+ function buildRelationPlan(
919
+ rel: RelatedQuery & { foreignKeyField?: string },
920
+ schema: SchemaStructure
921
+ ): RelationPlan {
922
+ const { relatedTable, alias, modifier, cardinality } = rel;
923
+ // Same fallback chain as buildSubquery (`rel.foreignKeyField || alias`); the
924
+ // top-level foreignKeyField is already reverse-resolved by `.related()`.
925
+ const foreignKeyField = (rel.foreignKeyField || alias || relatedTable) as string;
926
+
927
+ const plan: RelationPlan = {
928
+ alias: (alias || relatedTable) as string,
929
+ table: relatedTable,
930
+ cardinality,
931
+ foreignKeyField,
932
+ };
933
+
934
+ if (modifier) {
935
+ const modifierBuilder = new SchemaAwareQueryModifierBuilderImpl(relatedTable, schema);
936
+ modifier(modifierBuilder as any);
937
+ const subOptions = modifierBuilder._getOptions();
938
+
939
+ if (subOptions.select && subOptions.select.length > 0 && !subOptions.select.includes('*')) {
940
+ plan.select = subOptions.select.filter((f) => f !== '*') as string[];
941
+ }
942
+
943
+ if (subOptions.where && Object.keys(subOptions.where).length > 0) {
944
+ const parsedSubWhere = parseObjectIdsToRecordId(subOptions.where, relatedTable) as Record<
945
+ string,
946
+ unknown
947
+ >;
948
+ const nodes = buildWhereNodes(parsedSubWhere);
949
+ if (nodes.length > 0) plan.where = nodes;
950
+ }
951
+
952
+ if (subOptions.orderBy && Object.keys(subOptions.orderBy).length > 0) {
953
+ plan.orderBy = Object.entries(subOptions.orderBy).map(
954
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
955
+ );
956
+ }
957
+
958
+ if (subOptions.limit !== undefined) plan.limit = subOptions.limit;
959
+
960
+ // Nested relations: re-resolve exactly as buildSubquery does — the child's
961
+ // foreignKeyField comes from the relatedTable-based lookup, not the reverse
962
+ // heuristic used for top-level relations.
963
+ if (subOptions.related && subOptions.related.length > 0) {
964
+ const resolvedNestedRels = subOptions.related.map((nestedRel) => {
965
+ const relationship = schema.relationships.find(
966
+ (r) => r.from === relatedTable && r.field === nestedRel.alias
967
+ );
968
+ if (relationship) {
969
+ const nestedForeignKeyField =
970
+ relationship.cardinality === 'many' ? relatedTable : (nestedRel.alias as string);
971
+ return {
972
+ ...nestedRel,
973
+ relatedTable: relationship.to,
974
+ cardinality: relationship.cardinality,
975
+ foreignKeyField: nestedForeignKeyField,
976
+ } as RelatedQuery & { foreignKeyField: string };
977
+ }
978
+ return nestedRel;
979
+ });
980
+ plan.relations = resolvedNestedRels.map((nestedRel) => buildRelationPlan(nestedRel, schema));
981
+ }
982
+ }
983
+
984
+ // one-to-one gets an implicit per-parent LIMIT 1 (matches buildSubquery).
985
+ if (cardinality === 'one' && plan.limit === undefined) {
986
+ plan.limit = 1;
987
+ }
988
+
989
+ return plan;
990
+ }
991
+
992
+ /**
993
+ * Convert a parsed WHERE object (string IDs already → RecordId) into the
994
+ * engine-neutral {@link WhereNode}[] conjunction. Mirrors the `_or` / comparison
995
+ * / equality handling in {@link buildQueryFromOptions}. A `$`-prefixed `_val`
996
+ * becomes a `paramRef` (with the leading `$` stripped).
997
+ */
998
+ /**
999
+ * @param slaveToParams When true, top-level plain/operator-literal comparisons
1000
+ * ALSO carry a `paramRef` equal to the field name — the same var name
1001
+ * `buildQueryFromOptions` binds the value under (`field = $field`). The
1002
+ * engines then materialize by reading `params[field]` (falling back to the
1003
+ * baked `value` when the param is absent), so a query's rows are slaved to
1004
+ * its `params` (its identity) and can never come from a different query's
1005
+ * baked plan. Only safe at the TOP LEVEL, where the field is a schema column
1006
+ * that survives `parseParams` and the caller passes `params`. NOT used for
1007
+ * relation sub-wheres (rendered with a params-less ctx) or `_or` branches
1008
+ * (bound under synthetic `or0…` names that `parseParams` strips) — those
1009
+ * stay baked.
1010
+ */
1011
+ function buildWhereNodes(
1012
+ parsedWhere: Record<string, unknown>,
1013
+ slaveToParams = false
1014
+ ): WhereNode[] {
1015
+ const toComparison = (field: string, value: unknown, slave: boolean): WhereComparison => {
1016
+ if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
1017
+ const { _op, _val, _swap } = value as ComparisonOp;
1018
+ if (typeof _val === 'string' && _val.startsWith('$')) {
1019
+ return { field, op: _op, value: undefined, paramRef: _val.slice(1), swap: _swap };
1020
+ }
1021
+ // Literal operand: keep `value` as a fallback and add `paramRef: field`
1022
+ // (slave mode) so materialization reads the query's own `params[field]`.
1023
+ return slave
1024
+ ? { field, op: _op, value: _val, paramRef: field, swap: _swap }
1025
+ : { field, op: _op, value: _val, swap: _swap };
1026
+ }
1027
+ return slave ? { field, op: '=', value, paramRef: field } : { field, op: '=', value };
1028
+ };
1029
+
1030
+ const nodes: WhereNode[] = [];
1031
+ for (const [key, value] of Object.entries(parsedWhere)) {
1032
+ if (key === '_or' && Array.isArray(value)) {
1033
+ const or: WhereComparison[] = [];
1034
+ for (const branch of value) {
1035
+ if (branch && typeof branch === 'object') {
1036
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
1037
+ // OR branches bind under synthetic `or0…` names (see
1038
+ // buildQueryFromOptions) that parseParams strips — keep them baked.
1039
+ or.push(toComparison(bField, bVal, false));
1040
+ }
1041
+ }
1042
+ }
1043
+ if (or.length > 0) nodes.push({ or });
1044
+ continue;
1045
+ }
1046
+ nodes.push(toComparison(key, value, slaveToParams));
1047
+ }
1048
+ return nodes;
1049
+ }
1050
+
821
1051
  /**
822
1052
  * Build a subquery for a related field
823
1053
  */
@@ -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