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 +12 -10
- package/README.md +1 -1
- package/package.json +8 -7
- package/{lex-gql-sqlite.d.ts → src/lex-gql-sqlite.d.ts} +0 -15
- package/{lex-gql-sqlite.js → src/lex-gql-sqlite.js} +112 -28
- package/{lex-gql-sqlite.test.js → test/lex-gql-sqlite.test.js} +226 -148
- package/tsconfig.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.2.0
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### Minor Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
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
|
-
###
|
|
9
|
+
### Patch Changes
|
|
14
10
|
|
|
15
|
-
-
|
|
11
|
+
- Updated dependencies [349ddb3]
|
|
12
|
+
- lex-gql@0.2.0
|
|
16
13
|
|
|
17
|
-
##
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
337
|
-
.map((/** @type {string} */
|
|
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 ${
|
|
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
|
|
348
|
-
LIMIT
|
|
415
|
+
ORDER BY count ${orderDirection}
|
|
416
|
+
LIMIT ?
|
|
349
417
|
`;
|
|
350
418
|
|
|
351
419
|
/** @type {Array<{count: number, [key: string]: any}>} */
|
|
352
|
-
const
|
|
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 '
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
'
|
|
291
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
'
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
'
|
|
377
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
'
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
'
|
|
410
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
'
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
'
|
|
508
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
'
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
'
|
|
539
|
-
|
|
540
|
-
'2024-01-01T00:00:00Z',
|
|
541
|
-
);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
'
|
|
546
|
-
|
|
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