lex-gql-sqlite 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,26 +1,28 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## 0.2.0
4
4
 
5
- ### Added
5
+ ### Minor Changes
6
6
 
7
- - `createWriter(db)` helper with prepared statements for efficient writes
8
- - `insertRecord({ uri, did, collection, rkey, cid?, record, indexedAt? })`
9
- - `deleteRecord(uri)`
10
- - `upsertActor(did, handle)`
11
- - `totalCount` field in findMany query results
7
+ - 349ddb3: Add aggregate enhancements, actorHandle filtering, and DuckDB adapter
12
8
 
13
- ### Changed
9
+ ### Patch Changes
14
10
 
15
- - AND/OR conditions format: `{ op: 'and', conditions: [...] }` instead of `{ field: 'AND', op: 'and', value: [...] }`
11
+ - Updated dependencies [349ddb3]
12
+ - lex-gql@0.2.0
16
13
 
17
- ## [0.1.0] - 2026-01-15
14
+ ## 0.1.0 - 2026-01-16
18
15
 
19
16
  ### Added
20
17
 
21
18
  - Initial release
22
19
  - `createSqliteAdapter(db)` - create query function from better-sqlite3 database
23
20
  - `setupSchema(db)` - create required tables and indexes
21
+ - `createWriter(db)` helper with prepared statements for efficient writes
22
+ - `insertRecord({ uri, did, collection, rkey, cid?, record, indexedAt? })`
23
+ - `deleteRecord(uri)`
24
+ - `upsertActor(did, handle)`
25
+ - `totalCount` field in findMany query results
24
26
  - Full WHERE support with AND/OR nesting
25
27
  - All filter operators: eq, in, contains, gt, gte, lt, lte
26
28
  - Multi-field sorting
package/README.md CHANGED
@@ -38,7 +38,7 @@ Creates the required database tables and indexes.
38
38
 
39
39
  ### `createSqliteAdapter(db)`
40
40
 
41
- Returns a query function compatible with lex-gql's adapter interface.
41
+ Returns a query function compatible with lex-gql's adapter interface. Supports cross-collection URI resolution (`collection: '*'`) for forward join batching.
42
42
 
43
43
  ### `buildWhere(where)`
44
44
 
package/package.json CHANGED
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "name": "lex-gql-sqlite",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "SQLite adapter for lex-gql",
5
5
  "type": "module",
6
- "main": "lex-gql-sqlite.js",
7
- "types": "lex-gql-sqlite.d.ts",
6
+ "main": "src/lex-gql-sqlite.js",
7
+ "types": "src/lex-gql-sqlite.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./lex-gql-sqlite.d.ts",
11
- "default": "./lex-gql-sqlite.js"
10
+ "types": "./src/lex-gql-sqlite.d.ts",
11
+ "default": "./src/lex-gql-sqlite.js"
12
12
  }
13
13
  },
14
14
  "scripts": {
15
+ "build": "tsc",
15
16
  "test": "vitest run",
16
17
  "test:watch": "vitest",
17
- "typecheck": "tsc"
18
+ "typecheck": "tsc --noEmit"
18
19
  },
19
20
  "keywords": [
20
21
  "graphql",
@@ -26,7 +27,7 @@
26
27
  "license": "MIT",
27
28
  "peerDependencies": {
28
29
  "better-sqlite3": ">=11.0.0",
29
- "lex-gql": ">=0.1.0"
30
+ "lex-gql": ">=0.2.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/better-sqlite3": "^7.6.12",
@@ -6,9 +6,6 @@ export function setupSchema(db: import("better-sqlite3").Database): void;
6
6
  /**
7
7
  * @typedef {Object} RecordInput
8
8
  * @property {string} uri - Record URI (at://did/collection/rkey)
9
- * @property {string} did - DID of record author
10
- * @property {string} collection - Collection NSID
11
- * @property {string} rkey - Record key
12
9
  * @property {string} [cid] - Record CID
13
10
  * @property {object} record - Record data (will be JSON stringified)
14
11
  * @property {string} [indexedAt] - Timestamp (defaults to now)
@@ -58,18 +55,6 @@ export type RecordInput = {
58
55
  * - Record URI (at://did/collection/rkey)
59
56
  */
60
57
  uri: string;
61
- /**
62
- * - DID of record author
63
- */
64
- did: string;
65
- /**
66
- * - Collection NSID
67
- */
68
- collection: string;
69
- /**
70
- * - Record key
71
- */
72
- rkey: string;
73
58
  /**
74
59
  * - Record CID
75
60
  */
@@ -37,12 +37,22 @@ export function setupSchema(db) {
37
37
  db.exec(SCHEMA_SQL);
38
38
  }
39
39
 
40
+ /**
41
+ * Parse an AT URI into its components
42
+ * @param {string} uri - AT URI (at://did/collection/rkey)
43
+ * @returns {{ did: string, collection: string, rkey: string }}
44
+ */
45
+ function parseAtUri(uri) {
46
+ const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
47
+ if (!match) {
48
+ throw new Error(`Invalid AT URI: ${uri}`);
49
+ }
50
+ return { did: match[1], collection: match[2], rkey: match[3] };
51
+ }
52
+
40
53
  /**
41
54
  * @typedef {Object} RecordInput
42
55
  * @property {string} uri - Record URI (at://did/collection/rkey)
43
- * @property {string} did - DID of record author
44
- * @property {string} collection - Collection NSID
45
- * @property {string} rkey - Record key
46
56
  * @property {string} [cid] - Record CID
47
57
  * @property {object} record - Record data (will be JSON stringified)
48
58
  * @property {string} [indexedAt] - Timestamp (defaults to now)
@@ -75,7 +85,8 @@ export function createWriter(db) {
75
85
  `);
76
86
 
77
87
  return {
78
- insertRecord: ({ uri, did, collection, rkey, cid, record, indexedAt }) => {
88
+ insertRecord: ({ uri, cid, record, indexedAt }) => {
89
+ const { did, collection, rkey } = parseAtUri(uri);
79
90
  const recordJson = typeof record === 'string' ? record : JSON.stringify(record);
80
91
  const timestamp = indexedAt || new Date().toISOString();
81
92
  insertRecordStmt.run(uri, did, collection, rkey, cid || null, recordJson, timestamp);
@@ -96,6 +107,7 @@ const SYSTEM_FIELDS = {
96
107
  collection: 'r.collection',
97
108
  cid: 'r.cid',
98
109
  indexedAt: 'r.indexed_at',
110
+ actorHandle: 'a.handle',
99
111
  };
100
112
 
101
113
  /**
@@ -155,8 +167,10 @@ export function buildWhere(where) {
155
167
  }
156
168
  break;
157
169
  case 'contains':
158
- parts.push(`${fieldPath} LIKE ?`);
159
- params.push(`%${value}%`);
170
+ parts.push(`${fieldPath} LIKE ? ESCAPE '\\'`);
171
+ // Escape LIKE wildcards (% and _) in the search value
172
+ const escapedValue = String(value).replace(/[%_\\]/g, '\\$&');
173
+ params.push(`%${escapedValue}%`);
160
174
  break;
161
175
  case 'gt':
162
176
  parts.push(`${fieldPath} > ?`);
@@ -227,10 +241,10 @@ function findMany(db, op) {
227
241
  const { first = 20, after, last, before } = pagination;
228
242
 
229
243
  // Build WHERE clause
230
- const { sql: whereSql, params: whereParams } = buildWhere([
231
- { field: 'collection', op: 'eq', value: collection },
232
- ...where,
233
- ]);
244
+ // Special case: collection '*' means query across all collections (for URI resolution)
245
+ const collectionFilter =
246
+ collection === '*' ? [] : [{ field: 'collection', op: 'eq', value: collection }];
247
+ const { sql: whereSql, params: whereParams } = buildWhere([...collectionFilter, ...where]);
234
248
 
235
249
  // Handle cursor pagination
236
250
  const cursorConditions = [];
@@ -243,7 +257,9 @@ function findMany(db, op) {
243
257
  cursorConditions.push('r.id < ?');
244
258
  cursorParams.push(cursor.id);
245
259
  }
246
- } catch {}
260
+ } catch (err) {
261
+ console.warn('lex-gql-sqlite: Malformed cursor (after), ignoring:', /** @type {Error} */ (err).message);
262
+ }
247
263
  }
248
264
 
249
265
  if (before) {
@@ -253,7 +269,9 @@ function findMany(db, op) {
253
269
  cursorConditions.push('r.id > ?');
254
270
  cursorParams.push(cursor.id);
255
271
  }
256
- } catch {}
272
+ } catch (err) {
273
+ console.warn('lex-gql-sqlite: Malformed cursor (before), ignoring:', /** @type {Error} */ (err).message);
274
+ }
257
275
  }
258
276
 
259
277
  const fullWhere =
@@ -295,7 +313,7 @@ function findMany(db, op) {
295
313
  }));
296
314
 
297
315
  // Get total count (without pagination)
298
- const countSql = `SELECT COUNT(*) as count FROM records r WHERE ${whereSql}`;
316
+ const countSql = `SELECT COUNT(*) as count FROM records r LEFT JOIN actors a ON r.did = a.did WHERE ${whereSql}`;
299
317
  /** @type {{count: number}} */
300
318
  const countResult = /** @type {any} */ (db.prepare(countSql).get(...whereParams));
301
319
 
@@ -307,12 +325,55 @@ function findMany(db, op) {
307
325
  };
308
326
  }
309
327
 
328
+ /**
329
+ * Get the SQL expression for a field
330
+ * @param {string} field
331
+ * @returns {string}
332
+ */
333
+ function getFieldExpression(field) {
334
+ return SYSTEM_FIELDS[field] || `json_extract(r.record, '$.${field}')`;
335
+ }
336
+
337
+ /**
338
+ * Get SQL expression for a groupBy field, handling date interval suffixes
339
+ * @param {string} field
340
+ * @returns {{ expr: string, alias: string }}
341
+ */
342
+ function getGroupByExpression(field) {
343
+ const dayMatch = field.match(/^(.+)_day$/);
344
+ const weekMatch = field.match(/^(.+)_week$/);
345
+ const monthMatch = field.match(/^(.+)_month$/);
346
+
347
+ if (dayMatch) {
348
+ const base = dayMatch[1];
349
+ const path = getFieldExpression(base);
350
+ return { expr: `date(${path})`, alias: field };
351
+ }
352
+ if (weekMatch) {
353
+ const base = weekMatch[1];
354
+ const path = getFieldExpression(base);
355
+ return { expr: `strftime('%Y-%W', ${path})`, alias: field };
356
+ }
357
+ if (monthMatch) {
358
+ const base = monthMatch[1];
359
+ const path = getFieldExpression(base);
360
+ return { expr: `strftime('%Y-%m', ${path})`, alias: field };
361
+ }
362
+
363
+ const expr = getFieldExpression(field);
364
+ return { expr, alias: field };
365
+ }
366
+
310
367
  /**
311
368
  * @param {import('better-sqlite3').Database} db
312
369
  * @param {any} op
313
370
  */
314
371
  function aggregate(db, op) {
315
- const { collection, where = [], groupBy = [] } = op;
372
+ const { collection, where = [], groupBy = [], limit = 50, orderBy = 'COUNT_DESC', arrayFields = [] } = op;
373
+
374
+ // Cap limit at 1000
375
+ const effectiveLimit = Math.min(limit, 1000);
376
+ const orderDirection = orderBy === 'COUNT_ASC' ? 'ASC' : 'DESC';
316
377
 
317
378
  const { sql: whereSql, params } = buildWhere([
318
379
  { field: 'collection', op: 'eq', value: collection },
@@ -320,36 +381,59 @@ function aggregate(db, op) {
320
381
  ]);
321
382
 
322
383
  if (groupBy.length === 0) {
323
- const sql = `SELECT COUNT(*) as count FROM records r WHERE ${whereSql}`;
384
+ const sql = `SELECT COUNT(*) as count FROM records r LEFT JOIN actors a ON r.did = a.did WHERE ${whereSql}`;
324
385
  /** @type {{count: number}} */
325
386
  const result = /** @type {any} */ (db.prepare(sql).get(...params));
326
387
  return { count: result.count, groups: [] };
327
388
  }
328
389
 
329
- const groupFields = groupBy
330
- .map((/** @type {string} */ f) => {
331
- const fieldPath = SYSTEM_FIELDS[f] || `json_extract(r.record, '$.${f}')`;
332
- return `${fieldPath} as ${f}`;
333
- })
390
+ const groupExpressions = groupBy.map((/** @type {string} */ f) => getGroupByExpression(f));
391
+
392
+ const groupFields = groupExpressions
393
+ .map((/** @type {{ expr: string, alias: string }} */ { expr, alias }) => `${expr} as ${alias}`)
334
394
  .join(', ');
335
395
 
336
- const groupByClause = groupBy
337
- .map((/** @type {string} */ f) => {
338
- return SYSTEM_FIELDS[f] || `json_extract(r.record, '$.${f}')`;
339
- })
396
+ const groupByClause = groupExpressions
397
+ .map((/** @type {{ expr: string }} */ { expr }) => expr)
340
398
  .join(', ');
341
399
 
400
+ // Build array field selections using MAX to get a sample value from each group
401
+ const arrayFieldSelects = arrayFields
402
+ .map((/** @type {string} */ f) => `MAX(json_extract(r.record, '$.${f}')) as ${f}`)
403
+ .join(', ');
404
+
405
+ const selectClause = arrayFieldSelects
406
+ ? `${groupFields}, ${arrayFieldSelects}, COUNT(*) as count`
407
+ : `${groupFields}, COUNT(*) as count`;
408
+
342
409
  const sql = `
343
- SELECT ${groupFields}, COUNT(*) as count
410
+ SELECT ${selectClause}
344
411
  FROM records r
412
+ LEFT JOIN actors a ON r.did = a.did
345
413
  WHERE ${whereSql}
346
414
  GROUP BY ${groupByClause}
347
- ORDER BY count DESC
348
- LIMIT 100
415
+ ORDER BY count ${orderDirection}
416
+ LIMIT ?
349
417
  `;
350
418
 
351
419
  /** @type {Array<{count: number, [key: string]: any}>} */
352
- const groups = /** @type {any} */ (db.prepare(sql).all(...params));
420
+ const rawGroups = /** @type {any} */ (db.prepare(sql).all(...params, effectiveLimit));
421
+
422
+ // Parse JSON array fields back into actual arrays
423
+ const groups = rawGroups.map((group) => {
424
+ const parsed = { ...group };
425
+ for (const field of arrayFields) {
426
+ if (parsed[field] && typeof parsed[field] === 'string') {
427
+ try {
428
+ parsed[field] = JSON.parse(parsed[field]);
429
+ } catch {
430
+ // Keep as-is if not valid JSON
431
+ }
432
+ }
433
+ }
434
+ return parsed;
435
+ });
436
+
353
437
  const count = groups.reduce((sum, g) => sum + g.count, 0);
354
438
 
355
439
  return { count, groups };
@@ -6,7 +6,7 @@ import {
6
6
  createSqliteAdapter,
7
7
  createWriter,
8
8
  setupSchema,
9
- } from './lex-gql-sqlite.js';
9
+ } from '../src/lex-gql-sqlite.js';
10
10
 
11
11
  describe('setupSchema', () => {
12
12
  let db;
@@ -68,9 +68,6 @@ describe('createWriter', () => {
68
68
  it('inserts a record', () => {
69
69
  writer.insertRecord({
70
70
  uri: 'at://did:plc:alice/app.bsky.feed.post/1',
71
- did: 'did:plc:alice',
72
- collection: 'app.bsky.feed.post',
73
- rkey: '1',
74
71
  cid: 'bafycid123',
75
72
  record: { text: 'Hello world' },
76
73
  });
@@ -86,17 +83,11 @@ describe('createWriter', () => {
86
83
  it('replaces existing record on conflict', () => {
87
84
  writer.insertRecord({
88
85
  uri: 'at://did:plc:alice/app.bsky.feed.post/1',
89
- did: 'did:plc:alice',
90
- collection: 'app.bsky.feed.post',
91
- rkey: '1',
92
86
  record: { text: 'First version' },
93
87
  });
94
88
 
95
89
  writer.insertRecord({
96
90
  uri: 'at://did:plc:alice/app.bsky.feed.post/1',
97
- did: 'did:plc:alice',
98
- collection: 'app.bsky.feed.post',
99
- rkey: '1',
100
91
  record: { text: 'Updated version' },
101
92
  });
102
93
 
@@ -108,9 +99,6 @@ describe('createWriter', () => {
108
99
  it('deletes a record', () => {
109
100
  writer.insertRecord({
110
101
  uri: 'at://did:plc:alice/app.bsky.feed.post/1',
111
- did: 'did:plc:alice',
112
- collection: 'app.bsky.feed.post',
113
- rkey: '1',
114
102
  record: { text: 'Hello' },
115
103
  });
116
104
 
@@ -164,10 +152,16 @@ describe('buildWhere', () => {
164
152
 
165
153
  it('handles contains operator', () => {
166
154
  const { sql, params } = buildWhere([{ field: 'text', op: 'contains', value: 'hello' }]);
167
- expect(sql).toBe("json_extract(r.record, '$.text') LIKE ?");
155
+ expect(sql).toBe("json_extract(r.record, '$.text') LIKE ? ESCAPE '\\'");
168
156
  expect(params).toEqual(['%hello%']);
169
157
  });
170
158
 
159
+ it('escapes LIKE wildcards in contains operator', () => {
160
+ const { sql, params } = buildWhere([{ field: 'text', op: 'contains', value: '50%' }]);
161
+ expect(sql).toBe("json_extract(r.record, '$.text') LIKE ? ESCAPE '\\'");
162
+ expect(params).toEqual(['%50\\%%']);
163
+ });
164
+
171
165
  it('handles comparison operators', () => {
172
166
  const { sql, params } = buildWhere([
173
167
  { field: 'count', op: 'gt', value: 10 },
@@ -260,11 +254,13 @@ describe('buildOrderBy', () => {
260
254
  describe('findMany', () => {
261
255
  let db;
262
256
  let query;
257
+ let writer;
263
258
 
264
259
  beforeEach(() => {
265
260
  db = new Database(':memory:');
266
261
  setupSchema(db);
267
262
  query = createSqliteAdapter(db);
263
+ writer = createWriter(db);
268
264
  });
269
265
 
270
266
  afterEach(() => {
@@ -284,16 +280,11 @@ describe('findMany', () => {
284
280
  });
285
281
 
286
282
  it('returns records for collection', async () => {
287
- db.prepare(
288
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
289
- ).run(
290
- 'at://did:plc:abc/app.bsky.feed.post/123',
291
- 'did:plc:abc',
292
- 'app.bsky.feed.post',
293
- '123',
294
- JSON.stringify({ text: 'hello' }),
295
- '2024-01-01T00:00:00Z',
296
- );
283
+ writer.insertRecord({
284
+ uri: 'at://did:plc:abc/app.bsky.feed.post/123',
285
+ record: { text: 'hello' },
286
+ indexedAt: '2024-01-01T00:00:00Z',
287
+ });
297
288
 
298
289
  const result = await query({
299
290
  type: 'findMany',
@@ -309,16 +300,11 @@ describe('findMany', () => {
309
300
 
310
301
  it('respects first limit', async () => {
311
302
  for (let i = 0; i < 5; i++) {
312
- db.prepare(
313
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
314
- ).run(
315
- `at://did:plc:abc/col/${i}`,
316
- 'did:plc:abc',
317
- 'col',
318
- `${i}`,
319
- '{}',
320
- '2024-01-01T00:00:00Z',
321
- );
303
+ writer.insertRecord({
304
+ uri: `at://did:plc:abc/col/${i}`,
305
+ record: {},
306
+ indexedAt: '2024-01-01T00:00:00Z',
307
+ });
322
308
  }
323
309
 
324
310
  const result = await query({
@@ -334,16 +320,11 @@ describe('findMany', () => {
334
320
 
335
321
  it('handles cursor pagination with after', async () => {
336
322
  for (let i = 0; i < 5; i++) {
337
- db.prepare(
338
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
339
- ).run(
340
- `at://did:plc:abc/col/${i}`,
341
- 'did:plc:abc',
342
- 'col',
343
- `${i}`,
344
- '{}',
345
- '2024-01-01T00:00:00Z',
346
- );
323
+ writer.insertRecord({
324
+ uri: `at://did:plc:abc/col/${i}`,
325
+ record: {},
326
+ indexedAt: '2024-01-01T00:00:00Z',
327
+ });
347
328
  }
348
329
 
349
330
  const first = await query({
@@ -367,26 +348,16 @@ describe('findMany', () => {
367
348
  });
368
349
 
369
350
  it('filters with where clause', async () => {
370
- db.prepare(
371
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
372
- ).run(
373
- 'at://did:plc:abc/col/1',
374
- 'did:plc:abc',
375
- 'col',
376
- '1',
377
- JSON.stringify({ status: 'active' }),
378
- '2024-01-01T00:00:00Z',
379
- );
380
- db.prepare(
381
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
382
- ).run(
383
- 'at://did:plc:abc/col/2',
384
- 'did:plc:abc',
385
- 'col',
386
- '2',
387
- JSON.stringify({ status: 'inactive' }),
388
- '2024-01-01T00:00:00Z',
389
- );
351
+ writer.insertRecord({
352
+ uri: 'at://did:plc:abc/col/1',
353
+ record: { status: 'active' },
354
+ indexedAt: '2024-01-01T00:00:00Z',
355
+ });
356
+ writer.insertRecord({
357
+ uri: 'at://did:plc:abc/col/2',
358
+ record: { status: 'inactive' },
359
+ indexedAt: '2024-01-01T00:00:00Z',
360
+ });
390
361
 
391
362
  const result = await query({
392
363
  type: 'findMany',
@@ -400,26 +371,16 @@ describe('findMany', () => {
400
371
  });
401
372
 
402
373
  it('sorts results', async () => {
403
- db.prepare(
404
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
405
- ).run(
406
- 'at://did:plc:abc/col/1',
407
- 'did:plc:abc',
408
- 'col',
409
- '1',
410
- JSON.stringify({ name: 'banana' }),
411
- '2024-01-01T00:00:00Z',
412
- );
413
- db.prepare(
414
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
415
- ).run(
416
- 'at://did:plc:abc/col/2',
417
- 'did:plc:abc',
418
- 'col',
419
- '2',
420
- JSON.stringify({ name: 'apple' }),
421
- '2024-01-01T00:00:00Z',
422
- );
374
+ writer.insertRecord({
375
+ uri: 'at://did:plc:abc/col/1',
376
+ record: { name: 'banana' },
377
+ indexedAt: '2024-01-01T00:00:00Z',
378
+ });
379
+ writer.insertRecord({
380
+ uri: 'at://did:plc:abc/col/2',
381
+ record: { name: 'apple' },
382
+ indexedAt: '2024-01-01T00:00:00Z',
383
+ });
423
384
 
424
385
  const result = await query({
425
386
  type: 'findMany',
@@ -434,10 +395,12 @@ describe('findMany', () => {
434
395
  });
435
396
 
436
397
  it('joins actor handle', async () => {
437
- db.prepare(`INSERT INTO actors (did, handle) VALUES (?, ?)`).run('did:plc:abc', 'alice.test');
438
- db.prepare(
439
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
440
- ).run('at://did:plc:abc/col/1', 'did:plc:abc', 'col', '1', '{}', '2024-01-01T00:00:00Z');
398
+ writer.upsertActor('did:plc:abc', 'alice.test');
399
+ writer.insertRecord({
400
+ uri: 'at://did:plc:abc/col/1',
401
+ record: {},
402
+ indexedAt: '2024-01-01T00:00:00Z',
403
+ });
441
404
 
442
405
  const result = await query({
443
406
  type: 'findMany',
@@ -448,16 +411,44 @@ describe('findMany', () => {
448
411
 
449
412
  expect(result.rows[0].actorHandle).toBe('alice.test');
450
413
  });
414
+
415
+ it('filters by actorHandle', async () => {
416
+ writer.upsertActor('did:plc:alice', 'alice.test');
417
+ writer.upsertActor('did:plc:bob', 'bob.test');
418
+ writer.insertRecord({
419
+ uri: 'at://did:plc:alice/col/1',
420
+ record: { text: 'from alice' },
421
+ indexedAt: '2024-01-01T00:00:00Z',
422
+ });
423
+ writer.insertRecord({
424
+ uri: 'at://did:plc:bob/col/2',
425
+ record: { text: 'from bob' },
426
+ indexedAt: '2024-01-01T00:00:00Z',
427
+ });
428
+
429
+ const result = await query({
430
+ type: 'findMany',
431
+ collection: 'col',
432
+ where: [{ field: 'actorHandle', op: 'eq', value: 'alice.test' }],
433
+ pagination: { first: 10 },
434
+ });
435
+
436
+ expect(result.rows).toHaveLength(1);
437
+ expect(result.rows[0].actorHandle).toBe('alice.test');
438
+ expect(result.rows[0].text).toBe('from alice');
439
+ });
451
440
  });
452
441
 
453
442
  describe('aggregate', () => {
454
443
  let db;
455
444
  let query;
445
+ let writer;
456
446
 
457
447
  beforeEach(() => {
458
448
  db = new Database(':memory:');
459
449
  setupSchema(db);
460
450
  query = createSqliteAdapter(db);
451
+ writer = createWriter(db);
461
452
  });
462
453
 
463
454
  afterEach(() => {
@@ -476,16 +467,11 @@ describe('aggregate', () => {
476
467
 
477
468
  it('returns count for collection', async () => {
478
469
  for (let i = 0; i < 5; i++) {
479
- db.prepare(
480
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
481
- ).run(
482
- `at://did:plc:abc/col/${i}`,
483
- 'did:plc:abc',
484
- 'col',
485
- `${i}`,
486
- '{}',
487
- '2024-01-01T00:00:00Z',
488
- );
470
+ writer.insertRecord({
471
+ uri: `at://did:plc:abc/col/${i}`,
472
+ record: {},
473
+ indexedAt: '2024-01-01T00:00:00Z',
474
+ });
489
475
  }
490
476
 
491
477
  const result = await query({
@@ -498,26 +484,16 @@ describe('aggregate', () => {
498
484
  });
499
485
 
500
486
  it('respects where clause', async () => {
501
- db.prepare(
502
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
503
- ).run(
504
- 'at://did:plc:abc/col/1',
505
- 'did:plc:abc',
506
- 'col',
507
- '1',
508
- JSON.stringify({ status: 'active' }),
509
- '2024-01-01T00:00:00Z',
510
- );
511
- db.prepare(
512
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
513
- ).run(
514
- 'at://did:plc:abc/col/2',
515
- 'did:plc:abc',
516
- 'col',
517
- '2',
518
- JSON.stringify({ status: 'inactive' }),
519
- '2024-01-01T00:00:00Z',
520
- );
487
+ writer.insertRecord({
488
+ uri: 'at://did:plc:abc/col/1',
489
+ record: { status: 'active' },
490
+ indexedAt: '2024-01-01T00:00:00Z',
491
+ });
492
+ writer.insertRecord({
493
+ uri: 'at://did:plc:abc/col/2',
494
+ record: { status: 'inactive' },
495
+ indexedAt: '2024-01-01T00:00:00Z',
496
+ });
521
497
 
522
498
  const result = await query({
523
499
  type: 'aggregate',
@@ -529,36 +505,21 @@ describe('aggregate', () => {
529
505
  });
530
506
 
531
507
  it('groups by field', async () => {
532
- db.prepare(
533
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
534
- ).run(
535
- 'at://did:plc:abc/col/1',
536
- 'did:plc:abc',
537
- 'col',
538
- '1',
539
- JSON.stringify({ status: 'active' }),
540
- '2024-01-01T00:00:00Z',
541
- );
542
- db.prepare(
543
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
544
- ).run(
545
- 'at://did:plc:abc/col/2',
546
- 'did:plc:abc',
547
- 'col',
548
- '2',
549
- JSON.stringify({ status: 'active' }),
550
- '2024-01-01T00:00:00Z',
551
- );
552
- db.prepare(
553
- `INSERT INTO records (uri, did, collection, rkey, record, indexed_at) VALUES (?, ?, ?, ?, ?, ?)`,
554
- ).run(
555
- 'at://did:plc:abc/col/3',
556
- 'did:plc:abc',
557
- 'col',
558
- '3',
559
- JSON.stringify({ status: 'inactive' }),
560
- '2024-01-01T00:00:00Z',
561
- );
508
+ writer.insertRecord({
509
+ uri: 'at://did:plc:abc/col/1',
510
+ record: { status: 'active' },
511
+ indexedAt: '2024-01-01T00:00:00Z',
512
+ });
513
+ writer.insertRecord({
514
+ uri: 'at://did:plc:abc/col/2',
515
+ record: { status: 'active' },
516
+ indexedAt: '2024-01-01T00:00:00Z',
517
+ });
518
+ writer.insertRecord({
519
+ uri: 'at://did:plc:abc/col/3',
520
+ record: { status: 'inactive' },
521
+ indexedAt: '2024-01-01T00:00:00Z',
522
+ });
562
523
 
563
524
  const result = await query({
564
525
  type: 'aggregate',
@@ -572,4 +533,121 @@ describe('aggregate', () => {
572
533
  expect(result.groups.find((g) => g.status === 'active').count).toBe(2);
573
534
  expect(result.groups.find((g) => g.status === 'inactive').count).toBe(1);
574
535
  });
536
+
537
+ it('groups by day interval', async () => {
538
+ writer.insertRecord({
539
+ uri: 'at://did:plc:alice/col/1',
540
+ record: { playedTime: '2024-01-15T10:00:00Z' },
541
+ });
542
+ writer.insertRecord({
543
+ uri: 'at://did:plc:alice/col/2',
544
+ record: { playedTime: '2024-01-15T22:00:00Z' },
545
+ });
546
+ writer.insertRecord({
547
+ uri: 'at://did:plc:alice/col/3',
548
+ record: { playedTime: '2024-01-16T08:00:00Z' },
549
+ });
550
+
551
+ const result = await query({
552
+ type: 'aggregate',
553
+ collection: 'col',
554
+ where: [],
555
+ groupBy: ['playedTime_day'],
556
+ });
557
+
558
+ expect(result.count).toBe(3);
559
+ expect(result.groups).toHaveLength(2);
560
+ expect(result.groups.find((g) => g.playedTime_day === '2024-01-15').count).toBe(2);
561
+ expect(result.groups.find((g) => g.playedTime_day === '2024-01-16').count).toBe(1);
562
+ });
563
+
564
+ it('groups by week interval', async () => {
565
+ writer.insertRecord({
566
+ uri: 'at://did:plc:alice/col/1',
567
+ record: { playedTime: '2024-01-01T10:00:00Z' }, // Week 01
568
+ });
569
+ writer.insertRecord({
570
+ uri: 'at://did:plc:alice/col/2',
571
+ record: { playedTime: '2024-01-03T10:00:00Z' }, // Week 01
572
+ });
573
+ writer.insertRecord({
574
+ uri: 'at://did:plc:alice/col/3',
575
+ record: { playedTime: '2024-01-08T10:00:00Z' }, // Week 02
576
+ });
577
+
578
+ const result = await query({
579
+ type: 'aggregate',
580
+ collection: 'col',
581
+ where: [],
582
+ groupBy: ['playedTime_week'],
583
+ });
584
+
585
+ expect(result.count).toBe(3);
586
+ expect(result.groups).toHaveLength(2);
587
+ });
588
+
589
+ it('groups by month interval', async () => {
590
+ writer.insertRecord({
591
+ uri: 'at://did:plc:alice/col/1',
592
+ record: { playedTime: '2024-01-15T10:00:00Z' },
593
+ });
594
+ writer.insertRecord({
595
+ uri: 'at://did:plc:alice/col/2',
596
+ record: { playedTime: '2024-01-20T10:00:00Z' },
597
+ });
598
+ writer.insertRecord({
599
+ uri: 'at://did:plc:alice/col/3',
600
+ record: { playedTime: '2024-02-05T10:00:00Z' },
601
+ });
602
+
603
+ const result = await query({
604
+ type: 'aggregate',
605
+ collection: 'col',
606
+ where: [],
607
+ groupBy: ['playedTime_month'],
608
+ });
609
+
610
+ expect(result.count).toBe(3);
611
+ expect(result.groups).toHaveLength(2);
612
+ expect(result.groups.find((g) => g.playedTime_month === '2024-01').count).toBe(2);
613
+ expect(result.groups.find((g) => g.playedTime_month === '2024-02').count).toBe(1);
614
+ });
615
+
616
+ it('respects custom limit', async () => {
617
+ // Create 5 distinct groups
618
+ for (let i = 0; i < 5; i++) {
619
+ writer.insertRecord({
620
+ uri: `at://did:plc:alice/col/${i}`,
621
+ record: { category: `cat${i}` },
622
+ });
623
+ }
624
+
625
+ const result = await query({
626
+ type: 'aggregate',
627
+ collection: 'col',
628
+ where: [],
629
+ groupBy: ['category'],
630
+ limit: 3,
631
+ });
632
+
633
+ expect(result.groups).toHaveLength(3);
634
+ });
635
+
636
+ it('supports ascending count order', async () => {
637
+ writer.insertRecord({ uri: 'at://did:plc:alice/col/1', record: { cat: 'a' } });
638
+ writer.insertRecord({ uri: 'at://did:plc:alice/col/2', record: { cat: 'a' } });
639
+ writer.insertRecord({ uri: 'at://did:plc:alice/col/3', record: { cat: 'a' } });
640
+ writer.insertRecord({ uri: 'at://did:plc:alice/col/4', record: { cat: 'b' } });
641
+
642
+ const result = await query({
643
+ type: 'aggregate',
644
+ collection: 'col',
645
+ where: [],
646
+ groupBy: ['cat'],
647
+ orderBy: 'COUNT_ASC',
648
+ });
649
+
650
+ expect(result.groups[0].cat).toBe('b'); // count 1 first
651
+ expect(result.groups[1].cat).toBe('a'); // count 3 second
652
+ });
575
653
  });
package/tsconfig.json CHANGED
@@ -7,6 +7,6 @@
7
7
  "emitDeclarationOnly": true,
8
8
  "types": ["node"]
9
9
  },
10
- "include": ["lex-gql-sqlite.js"],
11
- "exclude": ["node_modules"]
10
+ "include": ["src/lex-gql-sqlite.js"],
11
+ "exclude": ["node_modules", "test"]
12
12
  }