deepbase-indexeddb 3.10.1 → 3.11.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.
package/README.md CHANGED
@@ -268,6 +268,23 @@ const values = await db.values('users');
268
268
  const entries = await db.entries('users');
269
269
  ```
270
270
 
271
+ ### Query Operations
272
+
273
+ ```javascript
274
+ // Chain a query over the object at path and run it with a terminal method
275
+ const adults = await db.query('users').where('age', '>=', 18).orderBy('name').toArray();
276
+ // [{ id: 'alice', value: { name: 'Alice', age: 30 } }, ...]
277
+
278
+ const first = await db.query('users').where('age', '>=', 18).first();
279
+ const total = await db.query('users').count();
280
+ const hasAny = await db.query('users').any();
281
+ ```
282
+
283
+ `query()` reads the object at the path with `get()` and evaluates the chain in
284
+ memory, so it walks the object already read: v1 pushes no filters to IndexedDB
285
+ and uses no indexes. Terminals resolve to `{ id, value }` records, where `id` is
286
+ the property name and `value` the stored value.
287
+
271
288
  ## 🔒 Concurrency Safety
272
289
 
273
290
  The IndexedDB driver includes built-in operation queuing to prevent race conditions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepbase-indexeddb",
3
- "version": "3.10.1",
3
+ "version": "3.11.0",
4
4
  "description": "⚡ DeepBase IndexedDB - browser storage driver",
5
5
  "type": "module",
6
6
  "main": "src/index.cjs",
@@ -15,10 +15,7 @@
15
15
  },
16
16
  "dependencies": {},
17
17
  "peerDependencies": {
18
- "deepbase": "^3.10.1"
19
- },
20
- "scripts": {
21
- "test": "echo \"IndexedDB tests require browser environment. See test/test.html\""
18
+ "deepbase": "^3.11.0"
22
19
  },
23
20
  "repository": {
24
21
  "type": "git",
@@ -41,5 +38,9 @@
41
38
  "bugs": {
42
39
  "url": "https://github.com/clasen/DeepBase/issues"
43
40
  },
44
- "homepage": "https://github.com/clasen/DeepBase/tree/main/packages/driver-indexeddb"
45
- }
41
+ "homepage": "https://github.com/clasen/DeepBase/tree/main/packages/driver-indexeddb",
42
+ "scripts": {
43
+ "test": "echo \"IndexedDB tests need a browser: run npm run test:browser (headless Chrome) or open test/test.html over HTTP\"",
44
+ "test:browser": "node test/run-headless.js"
45
+ }
46
+ }
@@ -1,4 +1,4 @@
1
- import { DeepBaseDriver } from 'deepbase';
1
+ import { DeepBaseDriver, evaluateQuery, removeKey } from 'deepbase';
2
2
 
3
3
  export class IndexedDBDriver extends DeepBaseDriver {
4
4
  static _instances = {};
@@ -81,6 +81,14 @@ export class IndexedDBDriver extends DeepBaseDriver {
81
81
  ? JSON.parse(JSON.stringify(value))
82
82
  : value;
83
83
  }
84
+
85
+ async query(path, steps) {
86
+ if (!this._connected) {
87
+ throw new Error('Database not connected. Call connect() first.');
88
+ }
89
+
90
+ return evaluateQuery(await this.get(...path), steps, { path });
91
+ }
84
92
 
85
93
  async set(...args) {
86
94
  return this._queueOperation(async () => {
@@ -126,8 +134,7 @@ export class IndexedDBDriver extends DeepBaseDriver {
126
134
  const key = keys.pop();
127
135
  const parentObj = this._getRecursive(rootObj, keys.slice());
128
136
 
129
- if (parentObj && parentObj.hasOwnProperty(key)) {
130
- delete parentObj[key];
137
+ if (removeKey(parentObj, key)) {
131
138
  await this._setRoot(rootObj);
132
139
  }
133
140
  });
@@ -367,4 +374,3 @@ export class IndexedDBDriver extends DeepBaseDriver {
367
374
  }
368
375
 
369
376
  export default IndexedDBDriver;
370
-
package/test/README.md CHANGED
@@ -4,10 +4,27 @@ Since IndexedDB is a browser API, tests must be run in a browser environment.
4
4
 
5
5
  ## Running Tests
6
6
 
7
- 1. Open `test.html` in a web browser
8
- 2. Tests will automatically run when the page loads
9
- 3. You can also click "Run All Tests" button to re-run tests
10
- 4. Use "Clear Database" to reset the test database
7
+ Serve the repository over HTTP and open `test.html` in a browser:
8
+
9
+ ```bash
10
+ # from the repository root
11
+ python3 -m http.server 8791
12
+ # then open http://127.0.0.1:8791/packages/driver-indexeddb/test/test.html
13
+ ```
14
+
15
+ Opening the file directly (`file://`) does not work: the page loads ES modules, which the browser blocks outside `http(s)`, and the library uses bare specifiers that the import map inside `test.html` resolves. Once served, tests run automatically on load, the "Run All Tests" button re-runs them, and "Clear Database" resets the test database.
16
+
17
+ ### Headless
18
+
19
+ `run-headless.js` serves the repository itself and drives a local Chrome/Chromium build through the DevTools protocol, so no npm dependency is required:
20
+
21
+ ```bash
22
+ node packages/driver-indexeddb/test/run-headless.js
23
+ # or point at a specific binary:
24
+ CHROME_PATH="/path/to/chrome" node packages/driver-indexeddb/test/run-headless.js
25
+ ```
26
+
27
+ It prints one line per test, exits non-zero when any test fails, and accepts `--port <port>` and `--timeout <ms>`.
11
28
 
12
29
  ## Test Files
13
30
 
@@ -55,4 +72,3 @@ Tests should work in:
55
72
  - Safari 10+
56
73
  - Edge (all versions)
57
74
  - Opera 15+
58
-
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run test.html in headless Chrome/Chromium without adding npm dependencies.
4
+ *
5
+ * The script serves the repository over HTTP (ES modules and the page import
6
+ * map need http(s)), launches a local Chrome build with the DevTools protocol,
7
+ * waits for the page test runner and reports every test result.
8
+ *
9
+ * Usage: node test/run-headless.js [--port 8791] [--timeout 90000] [--keep-open]
10
+ * Env: CHROME_PATH=/path/to/chrome
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import http from 'node:http';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const here = path.dirname(fileURLToPath(import.meta.url));
20
+ const repoRoot = path.resolve(here, '../../..');
21
+ const pagePath = '/packages/driver-indexeddb/test/test.html';
22
+
23
+ const MIME_TYPES = {
24
+ '.css': 'text/css',
25
+ '.html': 'text/html',
26
+ '.js': 'text/javascript',
27
+ '.json': 'application/json',
28
+ '.mjs': 'text/javascript',
29
+ };
30
+
31
+ function parseArgs(argv) {
32
+ const options = { port: 8791, timeout: 90000, keepOpen: false };
33
+ for (let index = 0; index < argv.length; index++) {
34
+ const arg = argv[index];
35
+ if (arg === '--port') options.port = Number(argv[++index]);
36
+ else if (arg === '--timeout') options.timeout = Number(argv[++index]);
37
+ else if (arg === '--keep-open') options.keepOpen = true;
38
+ }
39
+ return options;
40
+ }
41
+
42
+ function findChrome() {
43
+ if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
44
+
45
+ const candidates = [
46
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
47
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
48
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
49
+ '/usr/bin/google-chrome',
50
+ '/usr/bin/chromium',
51
+ '/usr/bin/chromium-browser',
52
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
53
+ ];
54
+ return candidates.find(candidate => fs.existsSync(candidate));
55
+ }
56
+
57
+ function startServer(port) {
58
+ const server = http.createServer((request, response) => {
59
+ const requested = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
60
+ const file = path.join(repoRoot, requested);
61
+
62
+ if (!file.startsWith(repoRoot) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
63
+ response.writeHead(404);
64
+ response.end('not found');
65
+ return;
66
+ }
67
+
68
+ response.writeHead(200, { 'content-type': MIME_TYPES[path.extname(file)] ?? 'application/octet-stream' });
69
+ fs.createReadStream(file).pipe(response);
70
+ });
71
+
72
+ return new Promise((resolve, reject) => {
73
+ server.once('error', reject);
74
+ server.listen(port, '127.0.0.1', () => resolve(server));
75
+ });
76
+ }
77
+
78
+ function startChrome(executablePath) {
79
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepbase-chrome-'));
80
+ // chrome-headless-shell is already headless and rejects the flag.
81
+ const headlessFlag = path.basename(executablePath).includes('headless') ? [] : ['--headless=new'];
82
+ const child = spawn(executablePath, [
83
+ ...headlessFlag,
84
+ '--disable-gpu',
85
+ '--no-first-run',
86
+ '--no-default-browser-check',
87
+ '--remote-debugging-port=0',
88
+ `--user-data-dir=${userDataDir}`,
89
+ 'about:blank',
90
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
91
+
92
+ return new Promise((resolve, reject) => {
93
+ let stderr = '';
94
+ const timer = setTimeout(() => reject(new Error(`Chrome did not report a DevTools endpoint:\n${stderr}`)), 30000);
95
+
96
+ child.stderr.on('data', chunk => {
97
+ stderr += chunk;
98
+ const match = /DevTools listening on (ws:\/\/\S+)/.exec(stderr);
99
+ if (match) {
100
+ clearTimeout(timer);
101
+ resolve({ child, browserUrl: match[1], userDataDir });
102
+ }
103
+ });
104
+ child.once('error', reject);
105
+ child.once('exit', code => {
106
+ clearTimeout(timer);
107
+ reject(new Error(`Chrome exited early with code ${code}:\n${stderr}`));
108
+ });
109
+ });
110
+ }
111
+
112
+ function connect(browserUrl) {
113
+ const socket = new WebSocket(browserUrl);
114
+ const pending = new Map();
115
+ const listeners = new Set();
116
+ let nextId = 0;
117
+
118
+ socket.addEventListener('message', event => {
119
+ const message = JSON.parse(event.data);
120
+ if (message.id !== undefined && pending.has(message.id)) {
121
+ const { resolve, reject } = pending.get(message.id);
122
+ pending.delete(message.id);
123
+ if (message.error) reject(new Error(message.error.message));
124
+ else resolve(message.result);
125
+ return;
126
+ }
127
+ for (const listener of listeners) listener(message);
128
+ });
129
+
130
+ const ready = new Promise((resolve, reject) => {
131
+ socket.addEventListener('open', () => resolve());
132
+ socket.addEventListener('error', () => reject(new Error('Could not connect to the DevTools endpoint')));
133
+ });
134
+
135
+ return {
136
+ ready,
137
+ onMessage(listener) {
138
+ listeners.add(listener);
139
+ },
140
+ send(method, params = {}, sessionId) {
141
+ const id = ++nextId;
142
+ return new Promise((resolve, reject) => {
143
+ pending.set(id, { resolve, reject });
144
+ socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
145
+ });
146
+ },
147
+ close() {
148
+ socket.close();
149
+ },
150
+ };
151
+ }
152
+
153
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
154
+
155
+ async function main() {
156
+ const options = parseArgs(process.argv.slice(2));
157
+ const executablePath = findChrome();
158
+ if (!executablePath) {
159
+ throw new Error('No Chrome/Chromium found. Set CHROME_PATH to a browser binary.');
160
+ }
161
+
162
+ const server = await startServer(options.port);
163
+ const chrome = await startChrome(executablePath);
164
+ const client = connect(chrome.browserUrl);
165
+ let exitCode = 0;
166
+
167
+ try {
168
+ await client.ready;
169
+ const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
170
+ const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
171
+ await client.send('Runtime.enable', {}, sessionId);
172
+ await client.send('Page.enable', {}, sessionId);
173
+ await client.send('Page.navigate', { url: `http://127.0.0.1:${options.port}${pagePath}` }, sessionId);
174
+
175
+ const deadline = Date.now() + options.timeout;
176
+ let payload;
177
+ while (Date.now() < deadline) {
178
+ const { result } = await client.send('Runtime.evaluate', {
179
+ expression: `(() => {
180
+ if (typeof testResults === 'undefined') return 'pending';
181
+ const summary = document.querySelector('#summary');
182
+ if (!summary || !summary.textContent.trim()) return 'pending';
183
+ return JSON.stringify({ results: testResults, summary: summary.textContent.replace(/\\s+/g, ' ').trim() });
184
+ })()`,
185
+ returnByValue: true,
186
+ }, sessionId);
187
+
188
+ if (result.value && result.value !== 'pending') {
189
+ payload = JSON.parse(result.value);
190
+ break;
191
+ }
192
+ await sleep(250);
193
+ }
194
+
195
+ if (!payload) throw new Error(`The page did not finish its tests within ${options.timeout}ms`);
196
+
197
+ console.log(payload.summary);
198
+ for (const test of payload.results) {
199
+ console.log(`${test.passed ? 'PASS' : 'FAIL'}: ${test.name}${test.passed ? '' : ` :: ${test.error}`}`);
200
+ }
201
+
202
+ const failed = payload.results.filter(test => !test.passed).length;
203
+ console.log(`total=${payload.results.length} ok=${payload.results.length - failed} fallos=${failed}`);
204
+ exitCode = failed === 0 ? 0 : 1;
205
+
206
+ if (options.keepOpen) {
207
+ console.log('--keep-open: press Ctrl+C to stop the browser');
208
+ await new Promise(() => {});
209
+ }
210
+ } finally {
211
+ client.close();
212
+ const stopped = new Promise(resolve => chrome.child.once('exit', resolve));
213
+ chrome.child.kill('SIGKILL');
214
+ await Promise.race([stopped, sleep(3000)]);
215
+ server.close();
216
+ try {
217
+ fs.rmSync(chrome.userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
218
+ } catch {
219
+ // The profile directory is disposable; Chrome may still be releasing files.
220
+ }
221
+ }
222
+
223
+ process.exitCode = exitCode;
224
+ }
225
+
226
+ main().catch(error => {
227
+ console.error(error.message);
228
+ process.exitCode = 1;
229
+ });
package/test/test.html CHANGED
@@ -99,6 +99,16 @@
99
99
  <div id="tests"></div>
100
100
  <div id="summary"></div>
101
101
 
102
+ <!-- Served over HTTP, the browser needs an import map for the bare specifiers
103
+ the library uses: "deepbase" in the drivers and "nanoid" in the core. -->
104
+ <script type="importmap">
105
+ {
106
+ "imports": {
107
+ "deepbase": "../../core/src/index.js",
108
+ "nanoid": "../../../node_modules/nanoid/index.browser.js"
109
+ }
110
+ }
111
+ </script>
102
112
  <script type="module">
103
113
  // Import DeepBase (now works in browser without bundler thanks to dynamic imports!)
104
114
  import DeepBase from '../../core/src/index.js';
@@ -162,6 +172,11 @@
162
172
  { name: 'Shift from array', fn: testShift },
163
173
  { name: 'Concurrent operations', fn: testConcurrency },
164
174
  { name: 'Complex nested objects', fn: testComplexObjects },
175
+ { name: 'Array pop/shift/del', fn: testArrayOperations },
176
+ { name: 'Query filter and nested paths', fn: testQueryFilters },
177
+ { name: 'Query order, skip and take', fn: testQueryOrderAndPagination },
178
+ { name: 'Query select and terminals', fn: testQuerySelectAndTerminals },
179
+ { name: 'Query missing path and invalid target', fn: testQueryValidation },
165
180
  { name: 'Disconnect', fn: testDisconnect }
166
181
  ];
167
182
 
@@ -366,6 +381,220 @@
366
381
  return `Complex object stored and retrieved successfully`;
367
382
  }
368
383
 
384
+ // query() reads the object at the path with get() and evaluates the chain in
385
+ // memory, so these checks mirror core/test/query-scenario.js.
386
+ async function testArrayOperations() {
387
+ const arrayDb = new window.DeepBase(new window.IndexedDBDriver({
388
+ name: 'deepbase-array-test',
389
+ version: 1
390
+ }));
391
+ await arrayDb.connect();
392
+
393
+ try {
394
+ await arrayDb.del();
395
+
396
+ await arrayDb.set('myArray', [1, 2, 3, 4, 5]);
397
+ assertQueryValue(await arrayDb.pop('myArray'), 5, 'pop returns the last element');
398
+ assertQueryValue(await arrayDb.get('myArray'), [1, 2, 3, 4], 'pop shrinks the array');
399
+
400
+ await arrayDb.set('myArray', [1, 2, 3, 4, 5]);
401
+ assertQueryValue(await arrayDb.shift('myArray'), 1, 'shift returns the first element');
402
+ assertQueryValue(await arrayDb.get('myArray'), [2, 3, 4, 5], 'shift shrinks the array');
403
+
404
+ await arrayDb.set('myArray', ['a', 'b', 'c', 'd']);
405
+ await arrayDb.del('myArray', '1');
406
+ assertQueryValue(await arrayDb.get('myArray'), ['a', 'c', 'd'], 'del splices the index');
407
+
408
+ await arrayDb.set('myArray', [1, 2, 3]);
409
+ await arrayDb.del('myArray', '9');
410
+ await arrayDb.del('myArray', 'not-an-index');
411
+ assertQueryValue(await arrayDb.get('myArray'), [1, 2, 3], 'del ignores indexes it does not have');
412
+
413
+ await arrayDb.set('config', 'tags', ['a', 'b', 'c']);
414
+ assertQueryValue(await arrayDb.pop('config', 'tags'), 'c', 'nested pop');
415
+ assertQueryValue(await arrayDb.get('config', 'tags'), ['a', 'b'], 'nested pop shrinks the array');
416
+
417
+ await arrayDb.set('queue', 'a', { n: 1 });
418
+ await arrayDb.set('queue', 'b', { n: 2 });
419
+ assertQueryValue(await arrayDb.shift('queue'), { n: 1 }, 'shift still works on keyed collections');
420
+ assertQueryValue(await arrayDb.get('queue'), { b: { n: 2 } }, 'keyed collection keeps its remaining key');
421
+
422
+ return 'Arrays shrink on pop/shift/del and keyed collections keep working';
423
+ } finally {
424
+ await arrayDb.del();
425
+ await arrayDb.disconnect();
426
+ }
427
+ }
428
+
429
+ const QUERY_RECORDS = {
430
+ u1: { name: 'Ana', age: 31, address: { city: 'Rosario' } },
431
+ u2: { name: 'Beto', age: 18 },
432
+ u3: { name: 'Caro', age: 45, address: { city: 'Córdoba' } },
433
+ u4: { name: 'Dani', age: 18, address: { city: 'Rosario' } },
434
+ u5: { name: 'Eva' },
435
+ u6: { name: 'Fabi', age: null }
436
+ };
437
+
438
+ const QUERY_IDS = Object.keys(QUERY_RECORDS);
439
+
440
+ function assertQueryValue(actual, expected, label) {
441
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
442
+ throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
443
+ }
444
+ }
445
+
446
+ async function seedQueryFixture(queryDb) {
447
+ await queryDb.del();
448
+ for (const id of QUERY_IDS) {
449
+ await queryDb.set('users', id, QUERY_RECORDS[id]);
450
+ }
451
+ await queryDb.set('flag', true);
452
+ await queryDb.set('list', [1, 2, 3]);
453
+ await queryDb.set('settings', 'theme', 'dark');
454
+ }
455
+
456
+ async function withQueryFixture(run) {
457
+ const queryDb = new window.DeepBase(new window.IndexedDBDriver({
458
+ name: 'deepbase-query-test',
459
+ version: 1
460
+ }));
461
+ await queryDb.connect();
462
+ try {
463
+ await seedQueryFixture(queryDb);
464
+ return await run(queryDb);
465
+ } finally {
466
+ await queryDb.disconnect();
467
+ }
468
+ }
469
+
470
+ async function testQueryFilters() {
471
+ return withQueryFixture(async queryDb => {
472
+ const ids = records => records.map(record => record.id);
473
+
474
+ assertQueryValue(ids(await queryDb.query('users').toArray()), QUERY_IDS, 'records ordered by key');
475
+ assertQueryValue(ids(await queryDb.query('users').where('age', '=', 18).toArray()), ['u2', 'u4'], 'equality');
476
+ assertQueryValue(ids(await queryDb.query('users').where('age', '!=', 18).toArray()), ['u1', 'u3', 'u6'], 'inequality');
477
+ assertQueryValue(ids(await queryDb.query('users').where('age', '>', 18).toArray()), ['u1', 'u3'], 'greater than');
478
+ assertQueryValue(ids(await queryDb.query('users').where('age', '>=', 31).toArray()), ['u1', 'u3'], 'greater or equal');
479
+ assertQueryValue(ids(await queryDb.query('users').where('age', '<', 31).toArray()), ['u2', 'u4'], 'less than');
480
+ assertQueryValue(ids(await queryDb.query('users').where('age', '<=', 18).toArray()), ['u2', 'u4'], 'less or equal');
481
+ assertQueryValue(ids(await queryDb.query('users').where('name', 'in', ['Ana', 'Caro']).toArray()), ['u1', 'u3'], 'in');
482
+ assertQueryValue(await queryDb.query('users').where('name', 'in', []).toArray(), [], 'in with an empty list');
483
+
484
+ assertQueryValue(
485
+ ids(await queryDb.query('users').where('age', '>=', 18).where('address.city', '=', 'Rosario').toArray()),
486
+ ['u1', 'u4'],
487
+ 'several where() steps'
488
+ );
489
+ assertQueryValue(ids(await queryDb.query('users').where('address.city', '=', 'Rosario').toArray()), ['u1', 'u4'], 'nested field');
490
+ assertQueryValue(ids(await queryDb.query('users').where(['address', 'city'], '=', 'Córdoba').toArray()), ['u3'], 'path segments');
491
+ assertQueryValue(ids(await queryDb.query('users').where('address.city', '=', 'Salta').toArray()), [], 'unmatched nested field');
492
+
493
+ assertQueryValue(ids(await queryDb.query('users').where('age', '=', null).toArray()), ['u6'], 'explicit null');
494
+ assertQueryValue(ids(await queryDb.query('users').where('missing', '=', null).toArray()), [], 'missing field');
495
+ assertQueryValue(ids(await queryDb.query('users').where('address.city', '=', null).toArray()), [], 'missing nested field');
496
+
497
+ assertQueryValue(await queryDb.query('users').where('age', '=', '18').toArray(), [], 'equality without coercion');
498
+ assertQueryValue(await queryDb.query('users').where('age', '>', '20').toArray(), [], 'range without coercion');
499
+ assertQueryValue(await queryDb.query('users').where('name', 'in', [1, 2]).toArray(), [], 'in without coercion');
500
+
501
+ return 'Filter and nested path results matched the shared fixture';
502
+ });
503
+ }
504
+
505
+ async function testQueryOrderAndPagination() {
506
+ return withQueryFixture(async queryDb => {
507
+ const ids = records => records.map(record => record.id);
508
+
509
+ assertQueryValue(ids(await queryDb.query('users').orderBy('age').toArray()), ['u2', 'u4', 'u1', 'u3', 'u5', 'u6'], 'ascending order');
510
+ assertQueryValue(ids(await queryDb.query('users').orderBy('age', 'desc').toArray()), ['u5', 'u6', 'u3', 'u1', 'u2', 'u4'], 'descending order');
511
+ assertQueryValue(ids(await queryDb.query('users').orderBy('address.city').toArray()), ['u3', 'u1', 'u4', 'u2', 'u5', 'u6'], 'nested field order');
512
+ assertQueryValue(ids(await queryDb.query('users').orderBy(['address', 'city'], 'desc').toArray()), ['u2', 'u5', 'u6', 'u1', 'u4', 'u3'], 'nested path descending order');
513
+
514
+ assertQueryValue(ids(await queryDb.query('users').orderBy('age').skip(1).take(2).toArray()), ['u4', 'u1'], 'skip then take');
515
+ assertQueryValue(ids(await queryDb.query('users').skip(4).toArray()), ['u5', 'u6'], 'skip');
516
+ assertQueryValue(ids(await queryDb.query('users').take(0).toArray()), [], 'take zero');
517
+ assertQueryValue(ids(await queryDb.query('users').skip(10).toArray()), [], 'skip past the end');
518
+ assertQueryValue(ids(await queryDb.query('users').take(2).where('age', '>', 18).toArray()), ['u1'], 'take before where');
519
+
520
+ return 'Order and pagination results matched the shared fixture';
521
+ });
522
+ }
523
+
524
+ async function testQuerySelectAndTerminals() {
525
+ return withQueryFixture(async queryDb => {
526
+ const values = records => records.map(record => record.value);
527
+
528
+ assertQueryValue(
529
+ values(await queryDb.query('users').select('name').toArray()),
530
+ QUERY_IDS.map(id => ({ name: QUERY_RECORDS[id].name })),
531
+ 'select a single field'
532
+ );
533
+ assertQueryValue(
534
+ values(await queryDb.query('users').select('name', 'address.city').toArray()),
535
+ [
536
+ { name: 'Ana', address: { city: 'Rosario' } },
537
+ { name: 'Beto' },
538
+ { name: 'Caro', address: { city: 'Córdoba' } },
539
+ { name: 'Dani', address: { city: 'Rosario' } },
540
+ { name: 'Eva' },
541
+ { name: 'Fabi' }
542
+ ],
543
+ 'select paths'
544
+ );
545
+ assertQueryValue(
546
+ values(await queryDb.query('users').select(['age']).where('age', '>', 30).toArray()),
547
+ [{ age: 31 }, { age: 45 }],
548
+ 'select after where'
549
+ );
550
+ assertQueryValue(
551
+ values(await queryDb.query('users').select('missing').toArray()),
552
+ [{}, {}, {}, {}, {}, {}],
553
+ 'select a missing field'
554
+ );
555
+
556
+ assertQueryValue(await queryDb.query('users').first(), { id: 'u1', value: QUERY_RECORDS.u1 }, 'first record');
557
+ assertQueryValue(await queryDb.query('users').orderBy('age', 'desc').first(), { id: 'u5', value: QUERY_RECORDS.u5 }, 'first after orderBy');
558
+ assertQueryValue(await queryDb.query('users').where('age', '<', 10).first(), null, 'first with no match');
559
+ assertQueryValue(await queryDb.query('users').count(), 6, 'count');
560
+ assertQueryValue(await queryDb.query('users').where('age', '=', 18).count(), 2, 'count with where');
561
+ assertQueryValue(await queryDb.query('users').any(), true, 'any');
562
+ assertQueryValue(await queryDb.query('users').where('age', '>', 100).any(), false, 'any with no match');
563
+
564
+ return 'Projection and terminal results matched the shared fixture';
565
+ });
566
+ }
567
+
568
+ async function testQueryValidation() {
569
+ return withQueryFixture(async queryDb => {
570
+ assertQueryValue(await queryDb.query('missing').toArray(), [], 'missing path toArray');
571
+ assertQueryValue(await queryDb.query('users', 'nobody').toArray(), [], 'missing nested path');
572
+ assertQueryValue(await queryDb.query('missing').first(), null, 'missing path first');
573
+ assertQueryValue(await queryDb.query('missing').count(), 0, 'missing path count');
574
+ assertQueryValue(await queryDb.query('missing').any(), false, 'missing path any');
575
+
576
+ const rejectsQuery = async (promise, pattern, label) => {
577
+ try {
578
+ await promise;
579
+ } catch (error) {
580
+ if (pattern.test(error.message)) return;
581
+ throw new Error(`${label}: unexpected error "${error.message}"`);
582
+ }
583
+ throw new Error(`${label}: expected the query to reject`);
584
+ };
585
+
586
+ await rejectsQuery(queryDb.query('flag').toArray(), /requires an object at "flag", received boolean/, 'boolean target');
587
+ await rejectsQuery(queryDb.query('list').toArray(), /requires an object at "list", received array/, 'array target');
588
+ await rejectsQuery(queryDb.query('settings', 'theme').toArray(), /requires an object at "settings.theme", received string/, 'string target');
589
+
590
+ const rootRecords = await queryDb.query().toArray();
591
+ assertQueryValue(rootRecords.map(record => record.id), ['flag', 'list', 'settings', 'users'], 'root keys');
592
+ assertQueryValue(await queryDb.query().count(), 4, 'root count');
593
+
594
+ return 'Missing paths, invalid targets and the root query matched the shared fixture';
595
+ });
596
+ }
597
+
369
598
  async function testDisconnect() {
370
599
  await db.disconnect();
371
600
  return 'Database disconnected';
@@ -379,4 +608,3 @@
379
608
  </script>
380
609
  </body>
381
610
  </html>
382
-