@spooky-sync/query-builder 0.0.1-canary.20 → 0.0.1-canary.201

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
 
@@ -468,3 +578,94 @@ describe('Subquery Filtering', () => {
468
578
  );
469
579
  });
470
580
  });
581
+
582
+ // An `-- @opaque` column is synced to the client but never stored server-side,
583
+ // so nothing on the server can evaluate a predicate against it. The failure mode
584
+ // without a guard is silent and asymmetric: the LOCAL cache does hold the value,
585
+ // so the clause filters correctly on screen while the server-side membership set
586
+ // it is reconciled against was computed without it — rows appear and vanish
587
+ // instead of erroring. Fail at the call site instead.
588
+ const opaqueSchema = {
589
+ tables: [
590
+ {
591
+ name: 'document' as const,
592
+ columns: {
593
+ id: { type: 'string' as const, optional: false },
594
+ title: { type: 'string' as const, optional: false },
595
+ thumbnail: {
596
+ type: 'Uint8Array' as const,
597
+ optional: true,
598
+ bytes: true,
599
+ opaque: true,
600
+ },
601
+ meta: { type: 'json' as const, optional: true, opaque: true },
602
+ },
603
+ primaryKey: ['id'] as const,
604
+ },
605
+ ],
606
+ relationships: [],
607
+ backends: {},
608
+ } as const;
609
+
610
+ describe('@opaque column guards', () => {
611
+ const qb = () => new QueryBuilder(opaqueSchema, 'document');
612
+
613
+ it('rejects an opaque column in where()', () => {
614
+ expect(() => qb().where({ thumbnail: null } as never)).toThrow(/thumbnail/);
615
+ expect(() => qb().where({ thumbnail: null } as never)).toThrow(/@opaque/);
616
+ });
617
+
618
+ it('rejects an opaque column used with a comparison operator object', () => {
619
+ expect(() =>
620
+ qb().where({ thumbnail: { _op: '!=', _val: null } } as never)
621
+ ).toThrow(/@opaque/);
622
+ });
623
+
624
+ it('rejects an opaque column inside an _or branch', () => {
625
+ expect(() =>
626
+ qb().where({
627
+ _or: [{ title: 'a' }, { thumbnail: null }],
628
+ } as never)
629
+ ).toThrow(/thumbnail/);
630
+ });
631
+
632
+ it('rejects a nested path rooted at an opaque column', () => {
633
+ expect(() => qb().where({ 'meta.secret': 'x' } as never)).toThrow(/@opaque/);
634
+ });
635
+
636
+ it('rejects an opaque column in orderBy()', () => {
637
+ expect(() => qb().orderBy('thumbnail' as never)).toThrow(/@opaque/);
638
+ });
639
+
640
+ it('allows a normal column in where() and orderBy()', () => {
641
+ expect(() => qb().where({ title: 'a' }).orderBy('title')).not.toThrow();
642
+ });
643
+
644
+ it('allows selecting an opaque column', () => {
645
+ // Projection is the whole point of @opaque: the value IS delivered, the
646
+ // client just cannot ask the server to filter on it.
647
+ expect(() => qb().select('id', 'thumbnail' as never)).not.toThrow();
648
+ });
649
+
650
+ it('does not reject an opaque column name on a different table', () => {
651
+ // The flag is per (table, column); names are not globally unique.
652
+ const multi = {
653
+ tables: [
654
+ ...opaqueSchema.tables,
655
+ {
656
+ name: 'other' as const,
657
+ columns: {
658
+ id: { type: 'string' as const, optional: false },
659
+ thumbnail: { type: 'string' as const, optional: false },
660
+ },
661
+ primaryKey: ['id'] as const,
662
+ },
663
+ ],
664
+ relationships: [],
665
+ backends: {},
666
+ } as const;
667
+ expect(() =>
668
+ new QueryBuilder(multi, 'other').where({ thumbnail: 'x' } as never)
669
+ ).not.toThrow();
670
+ });
671
+ });