lex-gql-sqlite 0.1.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 ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
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
12
+
13
+ ### Changed
14
+
15
+ - AND/OR conditions format: `{ op: 'and', conditions: [...] }` instead of `{ field: 'AND', op: 'and', value: [...] }`
16
+
17
+ ## [0.1.0] - 2026-01-15
18
+
19
+ ### Added
20
+
21
+ - Initial release
22
+ - `createSqliteAdapter(db)` - create query function from better-sqlite3 database
23
+ - `setupSchema(db)` - create required tables and indexes
24
+ - Full WHERE support with AND/OR nesting
25
+ - All filter operators: eq, in, contains, gt, gte, lt, lte
26
+ - Multi-field sorting
27
+ - Bidirectional cursor pagination (first/after, last/before)
28
+ - Aggregate queries with groupBy
29
+ - Actor handle joins
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # lex-gql-sqlite
2
+
3
+ SQLite adapter for [lex-gql](https://github.com/your-repo/lex-gql).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install lex-gql-sqlite lex-gql better-sqlite3
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```javascript
14
+ import Database from 'better-sqlite3';
15
+ import { createAdapter, parseLexicon } from 'lex-gql';
16
+ import { createSqliteAdapter, createWriter, setupSchema } from 'lex-gql-sqlite';
17
+
18
+ const db = new Database('./data.db');
19
+ setupSchema(db);
20
+
21
+ const query = createSqliteAdapter(db);
22
+ const adapter = createAdapter(lexicons, { query });
23
+
24
+ const result = await adapter.execute(`
25
+ query {
26
+ appBskyFeedPost(first: 10) {
27
+ edges { node { text } }
28
+ }
29
+ }
30
+ `);
31
+ ```
32
+
33
+ ## API
34
+
35
+ ### `setupSchema(db)`
36
+
37
+ Creates the required database tables and indexes.
38
+
39
+ ### `createSqliteAdapter(db)`
40
+
41
+ Returns a query function compatible with lex-gql's adapter interface.
42
+
43
+ ### `buildWhere(where)`
44
+
45
+ Builds SQL WHERE clause from lex-gql where conditions. Supports:
46
+ - All comparison operators: `eq`, `gt`, `gte`, `lt`, `lte`
47
+ - Array operators: `in`
48
+ - Text operators: `contains`
49
+ - Logical operators: nested `AND`/`OR`
50
+
51
+ ### `buildOrderBy(sort)`
52
+
53
+ Builds SQL ORDER BY clause from lex-gql sort conditions.
54
+
55
+ ### `createWriter(db)`
56
+
57
+ Creates a writer with prepared statements for efficient writes.
58
+
59
+ ```javascript
60
+ const writer = createWriter(db);
61
+
62
+ // Insert or replace a record
63
+ writer.insertRecord({
64
+ uri: 'at://did:plc:alice/app.bsky.feed.post/1',
65
+ did: 'did:plc:alice',
66
+ collection: 'app.bsky.feed.post',
67
+ rkey: '1',
68
+ cid: 'bafycid123', // optional
69
+ record: { text: 'Hello!' }, // object or JSON string
70
+ indexedAt: '2024-01-15T12:00:00Z', // optional, defaults to now
71
+ });
72
+
73
+ // Delete a record
74
+ writer.deleteRecord('at://did:plc:alice/app.bsky.feed.post/1');
75
+
76
+ // Insert or update an actor
77
+ writer.upsertActor('did:plc:alice', 'alice.bsky.social');
78
+ ```
79
+
80
+ ## Schema
81
+
82
+ The adapter expects this schema (created by `setupSchema`):
83
+
84
+ ```sql
85
+ CREATE TABLE records (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ uri TEXT UNIQUE NOT NULL,
88
+ did TEXT NOT NULL,
89
+ collection TEXT NOT NULL,
90
+ rkey TEXT NOT NULL,
91
+ cid TEXT,
92
+ record TEXT NOT NULL,
93
+ indexed_at TEXT NOT NULL
94
+ );
95
+
96
+ CREATE TABLE actors (
97
+ did TEXT PRIMARY KEY,
98
+ handle TEXT NOT NULL
99
+ );
100
+ ```
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Set up the required database schema for lex-gql-sqlite
3
+ * @param {import('better-sqlite3').Database} db
4
+ */
5
+ export function setupSchema(db: import("better-sqlite3").Database): void;
6
+ /**
7
+ * @typedef {Object} RecordInput
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
+ * @property {string} [cid] - Record CID
13
+ * @property {object} record - Record data (will be JSON stringified)
14
+ * @property {string} [indexedAt] - Timestamp (defaults to now)
15
+ */
16
+ /**
17
+ * @typedef {Object} Writer
18
+ * @property {(record: RecordInput) => void} insertRecord - Insert or replace a record
19
+ * @property {(uri: string) => void} deleteRecord - Delete a record by URI
20
+ * @property {(did: string, handle: string) => void} upsertActor - Insert or replace an actor
21
+ */
22
+ /**
23
+ * Create a writer with prepared statements for efficient writes
24
+ * @param {import('better-sqlite3').Database} db
25
+ * @returns {Writer}
26
+ */
27
+ export function createWriter(db: import("better-sqlite3").Database): Writer;
28
+ /**
29
+ * Build SQL WHERE clause from lex-gql where conditions
30
+ * @param {import('lex-gql').WhereClause[]} where
31
+ * @returns {{ sql: string, params: any[] }}
32
+ */
33
+ export function buildWhere(where: import("lex-gql").WhereClause[]): {
34
+ sql: string;
35
+ params: any[];
36
+ };
37
+ /**
38
+ * Build SQL ORDER BY clause from lex-gql sort conditions
39
+ * @param {Array<{field: string, dir?: string}>} sort
40
+ * @returns {string}
41
+ */
42
+ export function buildOrderBy(sort: Array<{
43
+ field: string;
44
+ dir?: string;
45
+ }>): string;
46
+ /**
47
+ * Create a SQLite query adapter for lex-gql
48
+ * @param {import('better-sqlite3').Database} db - better-sqlite3 database instance
49
+ * @returns {(op: import('lex-gql').Operation) => Promise<any>}
50
+ */
51
+ export function createSqliteAdapter(db: import("better-sqlite3").Database): (op: import("lex-gql").Operation) => Promise<any>;
52
+ /**
53
+ * SQL schema for lex-gql records
54
+ */
55
+ export const SCHEMA_SQL: "\n CREATE TABLE IF NOT EXISTS records (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n uri TEXT UNIQUE NOT NULL,\n did TEXT NOT NULL,\n collection TEXT NOT NULL,\n rkey TEXT NOT NULL,\n cid TEXT,\n record TEXT NOT NULL,\n indexed_at TEXT NOT NULL\n );\n\n CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection);\n CREATE INDEX IF NOT EXISTS idx_records_did ON records(did);\n CREATE INDEX IF NOT EXISTS idx_records_uri ON records(uri);\n\n CREATE TABLE IF NOT EXISTS actors (\n did TEXT PRIMARY KEY,\n handle TEXT NOT NULL\n );\n";
56
+ export type RecordInput = {
57
+ /**
58
+ * - Record URI (at://did/collection/rkey)
59
+ */
60
+ 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
+ /**
74
+ * - Record CID
75
+ */
76
+ cid?: string | undefined;
77
+ /**
78
+ * - Record data (will be JSON stringified)
79
+ */
80
+ record: object;
81
+ /**
82
+ * - Timestamp (defaults to now)
83
+ */
84
+ indexedAt?: string | undefined;
85
+ };
86
+ export type Writer = {
87
+ /**
88
+ * - Insert or replace a record
89
+ */
90
+ insertRecord: (record: RecordInput) => void;
91
+ /**
92
+ * - Delete a record by URI
93
+ */
94
+ deleteRecord: (uri: string) => void;
95
+ /**
96
+ * - Insert or replace an actor
97
+ */
98
+ upsertActor: (did: string, handle: string) => void;
99
+ };
@@ -0,0 +1,356 @@
1
+ /**
2
+ * SQLite adapter for lex-gql
3
+ */
4
+
5
+ import { hydrateRecord } from 'lex-gql';
6
+
7
+ /**
8
+ * SQL schema for lex-gql records
9
+ */
10
+ export const SCHEMA_SQL = `
11
+ CREATE TABLE IF NOT EXISTS records (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ uri TEXT UNIQUE NOT NULL,
14
+ did TEXT NOT NULL,
15
+ collection TEXT NOT NULL,
16
+ rkey TEXT NOT NULL,
17
+ cid TEXT,
18
+ record TEXT NOT NULL,
19
+ indexed_at TEXT NOT NULL
20
+ );
21
+
22
+ CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection);
23
+ CREATE INDEX IF NOT EXISTS idx_records_did ON records(did);
24
+ CREATE INDEX IF NOT EXISTS idx_records_uri ON records(uri);
25
+
26
+ CREATE TABLE IF NOT EXISTS actors (
27
+ did TEXT PRIMARY KEY,
28
+ handle TEXT NOT NULL
29
+ );
30
+ `;
31
+
32
+ /**
33
+ * Set up the required database schema for lex-gql-sqlite
34
+ * @param {import('better-sqlite3').Database} db
35
+ */
36
+ export function setupSchema(db) {
37
+ db.exec(SCHEMA_SQL);
38
+ }
39
+
40
+ /**
41
+ * @typedef {Object} RecordInput
42
+ * @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
+ * @property {string} [cid] - Record CID
47
+ * @property {object} record - Record data (will be JSON stringified)
48
+ * @property {string} [indexedAt] - Timestamp (defaults to now)
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} Writer
53
+ * @property {(record: RecordInput) => void} insertRecord - Insert or replace a record
54
+ * @property {(uri: string) => void} deleteRecord - Delete a record by URI
55
+ * @property {(did: string, handle: string) => void} upsertActor - Insert or replace an actor
56
+ */
57
+
58
+ /**
59
+ * Create a writer with prepared statements for efficient writes
60
+ * @param {import('better-sqlite3').Database} db
61
+ * @returns {Writer}
62
+ */
63
+ export function createWriter(db) {
64
+ const insertRecordStmt = db.prepare(`
65
+ INSERT OR REPLACE INTO records (uri, did, collection, rkey, cid, record, indexed_at)
66
+ VALUES (?, ?, ?, ?, ?, ?, ?)
67
+ `);
68
+
69
+ const deleteRecordStmt = db.prepare(`
70
+ DELETE FROM records WHERE uri = ?
71
+ `);
72
+
73
+ const upsertActorStmt = db.prepare(`
74
+ INSERT OR REPLACE INTO actors (did, handle) VALUES (?, ?)
75
+ `);
76
+
77
+ return {
78
+ insertRecord: ({ uri, did, collection, rkey, cid, record, indexedAt }) => {
79
+ const recordJson = typeof record === 'string' ? record : JSON.stringify(record);
80
+ const timestamp = indexedAt || new Date().toISOString();
81
+ insertRecordStmt.run(uri, did, collection, rkey, cid || null, recordJson, timestamp);
82
+ },
83
+ deleteRecord: (uri) => {
84
+ deleteRecordStmt.run(uri);
85
+ },
86
+ upsertActor: (did, handle) => {
87
+ upsertActorStmt.run(did, handle);
88
+ },
89
+ };
90
+ }
91
+
92
+ /** @type {Record<string, string>} */
93
+ const SYSTEM_FIELDS = {
94
+ did: 'r.did',
95
+ uri: 'r.uri',
96
+ collection: 'r.collection',
97
+ cid: 'r.cid',
98
+ indexedAt: 'r.indexed_at',
99
+ };
100
+
101
+ /**
102
+ * Build SQL WHERE clause from lex-gql where conditions
103
+ * @param {import('lex-gql').WhereClause[]} where
104
+ * @returns {{ sql: string, params: any[] }}
105
+ */
106
+ export function buildWhere(where) {
107
+ if (!where || where.length === 0) {
108
+ return { sql: '1=1', params: [] };
109
+ }
110
+
111
+ const parts = [];
112
+ const params = [];
113
+
114
+ for (const clause of where) {
115
+ const { field, op, value, conditions } = clause;
116
+
117
+ // Handle AND/OR logical operators
118
+ if (op === 'and' && conditions) {
119
+ /** @type {Array<{sql: string, params: any[]}>} */
120
+ const subClauses = conditions.map((/** @type {any} */ sub) => buildWhere(sub));
121
+ const subSql = subClauses.map((s) => s.sql).join(' AND ');
122
+ parts.push(`(${subSql})`);
123
+ for (const s of subClauses) {
124
+ params.push(...s.params);
125
+ }
126
+ continue;
127
+ }
128
+
129
+ if (op === 'or' && conditions) {
130
+ /** @type {Array<{sql: string, params: any[]}>} */
131
+ const subClauses = conditions.map((/** @type {any} */ sub) => buildWhere(sub));
132
+ const subSql = subClauses.map((s) => s.sql).join(' OR ');
133
+ parts.push(`(${subSql})`);
134
+ for (const s of subClauses) {
135
+ params.push(...s.params);
136
+ }
137
+ continue;
138
+ }
139
+
140
+ // Field conditions require a field name
141
+ if (!field) continue;
142
+
143
+ const fieldPath = SYSTEM_FIELDS[field] || `json_extract(r.record, '$.${field}')`;
144
+
145
+ switch (op) {
146
+ case 'eq':
147
+ parts.push(`${fieldPath} = ?`);
148
+ params.push(value);
149
+ break;
150
+ case 'in':
151
+ if (Array.isArray(value) && value.length > 0) {
152
+ const placeholders = value.map(() => '?').join(', ');
153
+ parts.push(`${fieldPath} IN (${placeholders})`);
154
+ params.push(...value);
155
+ }
156
+ break;
157
+ case 'contains':
158
+ parts.push(`${fieldPath} LIKE ?`);
159
+ params.push(`%${value}%`);
160
+ break;
161
+ case 'gt':
162
+ parts.push(`${fieldPath} > ?`);
163
+ params.push(value);
164
+ break;
165
+ case 'gte':
166
+ parts.push(`${fieldPath} >= ?`);
167
+ params.push(value);
168
+ break;
169
+ case 'lt':
170
+ parts.push(`${fieldPath} < ?`);
171
+ params.push(value);
172
+ break;
173
+ case 'lte':
174
+ parts.push(`${fieldPath} <= ?`);
175
+ params.push(value);
176
+ break;
177
+ }
178
+ }
179
+
180
+ return {
181
+ sql: parts.length > 0 ? parts.join(' AND ') : '1=1',
182
+ params,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Build SQL ORDER BY clause from lex-gql sort conditions
188
+ * @param {Array<{field: string, dir?: string}>} sort
189
+ * @returns {string}
190
+ */
191
+ export function buildOrderBy(sort) {
192
+ if (!sort || sort.length === 0) {
193
+ return 'r.id DESC';
194
+ }
195
+
196
+ return sort
197
+ .map(({ field, dir = 'asc' }) => {
198
+ const fieldPath = SYSTEM_FIELDS[field] || `json_extract(r.record, '$.${field}')`;
199
+ return `${fieldPath} ${dir.toUpperCase()}`;
200
+ })
201
+ .join(', ');
202
+ }
203
+
204
+ /**
205
+ * Create a SQLite query adapter for lex-gql
206
+ * @param {import('better-sqlite3').Database} db - better-sqlite3 database instance
207
+ * @returns {(op: import('lex-gql').Operation) => Promise<any>}
208
+ */
209
+ export function createSqliteAdapter(db) {
210
+ return async function query(op) {
211
+ if (op.type === 'findMany') {
212
+ return findMany(db, op);
213
+ }
214
+ if (op.type === 'aggregate') {
215
+ return aggregate(db, op);
216
+ }
217
+ throw new Error(`Not implemented: ${op.type}`);
218
+ };
219
+ }
220
+
221
+ /**
222
+ * @param {import('better-sqlite3').Database} db
223
+ * @param {any} op
224
+ */
225
+ function findMany(db, op) {
226
+ const { collection, where = [], sort = [], pagination = {} } = op;
227
+ const { first = 20, after, last, before } = pagination;
228
+
229
+ // Build WHERE clause
230
+ const { sql: whereSql, params: whereParams } = buildWhere([
231
+ { field: 'collection', op: 'eq', value: collection },
232
+ ...where,
233
+ ]);
234
+
235
+ // Handle cursor pagination
236
+ const cursorConditions = [];
237
+ const cursorParams = [];
238
+
239
+ if (after) {
240
+ try {
241
+ const cursor = JSON.parse(Buffer.from(after, 'base64').toString());
242
+ if (cursor.id) {
243
+ cursorConditions.push('r.id < ?');
244
+ cursorParams.push(cursor.id);
245
+ }
246
+ } catch {}
247
+ }
248
+
249
+ if (before) {
250
+ try {
251
+ const cursor = JSON.parse(Buffer.from(before, 'base64').toString());
252
+ if (cursor.id) {
253
+ cursorConditions.push('r.id > ?');
254
+ cursorParams.push(cursor.id);
255
+ }
256
+ } catch {}
257
+ }
258
+
259
+ const fullWhere =
260
+ cursorConditions.length > 0 ? `${whereSql} AND ${cursorConditions.join(' AND ')}` : whereSql;
261
+
262
+ const allParams = [...whereParams, ...cursorParams];
263
+
264
+ // Build ORDER BY
265
+ const orderBy = buildOrderBy(sort);
266
+
267
+ // Build query
268
+ const limit = (first || last || 20) + 1;
269
+ const sql = `
270
+ SELECT r.id, r.uri, r.did, r.collection, r.rkey, r.cid, r.record, r.indexed_at, a.handle
271
+ FROM records r
272
+ LEFT JOIN actors a ON r.did = a.did
273
+ WHERE ${fullWhere}
274
+ ORDER BY ${orderBy}
275
+ LIMIT ?
276
+ `;
277
+ allParams.push(limit);
278
+
279
+ const rawRows = db.prepare(sql).all(...allParams);
280
+ const hasMore = rawRows.length > (first || last || 20);
281
+ const rows = hasMore ? rawRows.slice(0, -1) : rawRows;
282
+
283
+ // Transform rows
284
+ const transformed = rows.map((/** @type {any} */ row) => ({
285
+ ...hydrateRecord({
286
+ uri: row.uri,
287
+ did: row.did,
288
+ collection: row.collection,
289
+ cid: row.cid,
290
+ record: row.record,
291
+ indexed_at: row.indexed_at,
292
+ handle: row.handle,
293
+ }),
294
+ _id: row.id,
295
+ }));
296
+
297
+ // Get total count (without pagination)
298
+ const countSql = `SELECT COUNT(*) as count FROM records r WHERE ${whereSql}`;
299
+ /** @type {{count: number}} */
300
+ const countResult = /** @type {any} */ (db.prepare(countSql).get(...whereParams));
301
+
302
+ return {
303
+ rows: transformed,
304
+ hasNext: first ? hasMore : !!before,
305
+ hasPrev: !!after || (last ? hasMore : false),
306
+ totalCount: countResult.count,
307
+ };
308
+ }
309
+
310
+ /**
311
+ * @param {import('better-sqlite3').Database} db
312
+ * @param {any} op
313
+ */
314
+ function aggregate(db, op) {
315
+ const { collection, where = [], groupBy = [] } = op;
316
+
317
+ const { sql: whereSql, params } = buildWhere([
318
+ { field: 'collection', op: 'eq', value: collection },
319
+ ...where,
320
+ ]);
321
+
322
+ if (groupBy.length === 0) {
323
+ const sql = `SELECT COUNT(*) as count FROM records r WHERE ${whereSql}`;
324
+ /** @type {{count: number}} */
325
+ const result = /** @type {any} */ (db.prepare(sql).get(...params));
326
+ return { count: result.count, groups: [] };
327
+ }
328
+
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
+ })
334
+ .join(', ');
335
+
336
+ const groupByClause = groupBy
337
+ .map((/** @type {string} */ f) => {
338
+ return SYSTEM_FIELDS[f] || `json_extract(r.record, '$.${f}')`;
339
+ })
340
+ .join(', ');
341
+
342
+ const sql = `
343
+ SELECT ${groupFields}, COUNT(*) as count
344
+ FROM records r
345
+ WHERE ${whereSql}
346
+ GROUP BY ${groupByClause}
347
+ ORDER BY count DESC
348
+ LIMIT 100
349
+ `;
350
+
351
+ /** @type {Array<{count: number, [key: string]: any}>} */
352
+ const groups = /** @type {any} */ (db.prepare(sql).all(...params));
353
+ const count = groups.reduce((sum, g) => sum + g.count, 0);
354
+
355
+ return { count, groups };
356
+ }
@@ -0,0 +1,575 @@
1
+ import Database from 'better-sqlite3';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import {
4
+ buildOrderBy,
5
+ buildWhere,
6
+ createSqliteAdapter,
7
+ createWriter,
8
+ setupSchema,
9
+ } from './lex-gql-sqlite.js';
10
+
11
+ describe('setupSchema', () => {
12
+ let db;
13
+
14
+ beforeEach(() => {
15
+ db = new Database(':memory:');
16
+ });
17
+
18
+ afterEach(() => {
19
+ db.close();
20
+ });
21
+
22
+ it('creates records table', () => {
23
+ setupSchema(db);
24
+ const tables = db
25
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='records'")
26
+ .all();
27
+ expect(tables).toHaveLength(1);
28
+ });
29
+
30
+ it('creates actors table', () => {
31
+ setupSchema(db);
32
+ const tables = db
33
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='actors'")
34
+ .all();
35
+ expect(tables).toHaveLength(1);
36
+ });
37
+
38
+ it('creates indexes', () => {
39
+ setupSchema(db);
40
+ const indexes = db
41
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")
42
+ .all();
43
+ expect(indexes.length).toBeGreaterThanOrEqual(2);
44
+ });
45
+
46
+ it('is idempotent (can run multiple times)', () => {
47
+ setupSchema(db);
48
+ setupSchema(db);
49
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
50
+ expect(tables.filter((t) => t.name === 'records')).toHaveLength(1);
51
+ });
52
+ });
53
+
54
+ describe('createWriter', () => {
55
+ let db;
56
+ let writer;
57
+
58
+ beforeEach(() => {
59
+ db = new Database(':memory:');
60
+ setupSchema(db);
61
+ writer = createWriter(db);
62
+ });
63
+
64
+ afterEach(() => {
65
+ db.close();
66
+ });
67
+
68
+ it('inserts a record', () => {
69
+ writer.insertRecord({
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
+ cid: 'bafycid123',
75
+ record: { text: 'Hello world' },
76
+ });
77
+
78
+ const row = db
79
+ .prepare('SELECT * FROM records WHERE uri = ?')
80
+ .get('at://did:plc:alice/app.bsky.feed.post/1');
81
+ expect(row.did).toBe('did:plc:alice');
82
+ expect(row.collection).toBe('app.bsky.feed.post');
83
+ expect(JSON.parse(row.record)).toEqual({ text: 'Hello world' });
84
+ });
85
+
86
+ it('replaces existing record on conflict', () => {
87
+ writer.insertRecord({
88
+ 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
+ record: { text: 'First version' },
93
+ });
94
+
95
+ writer.insertRecord({
96
+ 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
+ record: { text: 'Updated version' },
101
+ });
102
+
103
+ const rows = db.prepare('SELECT * FROM records').all();
104
+ expect(rows).toHaveLength(1);
105
+ expect(JSON.parse(rows[0].record)).toEqual({ text: 'Updated version' });
106
+ });
107
+
108
+ it('deletes a record', () => {
109
+ writer.insertRecord({
110
+ 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
+ record: { text: 'Hello' },
115
+ });
116
+
117
+ writer.deleteRecord('at://did:plc:alice/app.bsky.feed.post/1');
118
+
119
+ const rows = db.prepare('SELECT * FROM records').all();
120
+ expect(rows).toHaveLength(0);
121
+ });
122
+
123
+ it('upserts an actor', () => {
124
+ writer.upsertActor('did:plc:alice', 'alice.bsky.social');
125
+
126
+ const row = db.prepare('SELECT * FROM actors WHERE did = ?').get('did:plc:alice');
127
+ expect(row.handle).toBe('alice.bsky.social');
128
+ });
129
+
130
+ it('updates actor handle on conflict', () => {
131
+ writer.upsertActor('did:plc:alice', 'alice.bsky.social');
132
+ writer.upsertActor('did:plc:alice', 'alice.example.com');
133
+
134
+ const rows = db.prepare('SELECT * FROM actors').all();
135
+ expect(rows).toHaveLength(1);
136
+ expect(rows[0].handle).toBe('alice.example.com');
137
+ });
138
+ });
139
+
140
+ describe('buildWhere', () => {
141
+ it('handles empty where', () => {
142
+ const { sql, params } = buildWhere([]);
143
+ expect(sql).toBe('1=1');
144
+ expect(params).toEqual([]);
145
+ });
146
+
147
+ it('handles simple eq condition', () => {
148
+ const { sql, params } = buildWhere([{ field: 'status', op: 'eq', value: 'active' }]);
149
+ expect(sql).toBe("json_extract(r.record, '$.status') = ?");
150
+ expect(params).toEqual(['active']);
151
+ });
152
+
153
+ it('handles system field (did)', () => {
154
+ const { sql, params } = buildWhere([{ field: 'did', op: 'eq', value: 'did:plc:abc' }]);
155
+ expect(sql).toBe('r.did = ?');
156
+ expect(params).toEqual(['did:plc:abc']);
157
+ });
158
+
159
+ it('handles in operator', () => {
160
+ const { sql, params } = buildWhere([{ field: 'status', op: 'in', value: ['a', 'b', 'c'] }]);
161
+ expect(sql).toBe("json_extract(r.record, '$.status') IN (?, ?, ?)");
162
+ expect(params).toEqual(['a', 'b', 'c']);
163
+ });
164
+
165
+ it('handles contains operator', () => {
166
+ const { sql, params } = buildWhere([{ field: 'text', op: 'contains', value: 'hello' }]);
167
+ expect(sql).toBe("json_extract(r.record, '$.text') LIKE ?");
168
+ expect(params).toEqual(['%hello%']);
169
+ });
170
+
171
+ it('handles comparison operators', () => {
172
+ const { sql, params } = buildWhere([
173
+ { field: 'count', op: 'gt', value: 10 },
174
+ { field: 'count', op: 'lte', value: 100 },
175
+ ]);
176
+ expect(sql).toBe(
177
+ "json_extract(r.record, '$.count') > ? AND json_extract(r.record, '$.count') <= ?",
178
+ );
179
+ expect(params).toEqual([10, 100]);
180
+ });
181
+
182
+ it('handles AND conditions', () => {
183
+ const { sql, params } = buildWhere([
184
+ {
185
+ op: 'and',
186
+ conditions: [
187
+ [{ field: 'a', op: 'eq', value: '1' }],
188
+ [{ field: 'b', op: 'eq', value: '2' }],
189
+ ],
190
+ },
191
+ ]);
192
+ expect(sql).toBe("(json_extract(r.record, '$.a') = ? AND json_extract(r.record, '$.b') = ?)");
193
+ expect(params).toEqual(['1', '2']);
194
+ });
195
+
196
+ it('handles OR conditions', () => {
197
+ const { sql, params } = buildWhere([
198
+ {
199
+ op: 'or',
200
+ conditions: [
201
+ [{ field: 'a', op: 'eq', value: '1' }],
202
+ [{ field: 'b', op: 'eq', value: '2' }],
203
+ ],
204
+ },
205
+ ]);
206
+ expect(sql).toBe("(json_extract(r.record, '$.a') = ? OR json_extract(r.record, '$.b') = ?)");
207
+ expect(params).toEqual(['1', '2']);
208
+ });
209
+
210
+ it('handles nested AND/OR', () => {
211
+ const { sql, params } = buildWhere([
212
+ { field: 'status', op: 'eq', value: 'active' },
213
+ {
214
+ op: 'or',
215
+ conditions: [
216
+ [{ field: 'author', op: 'eq', value: 'alice' }],
217
+ [{ field: 'author', op: 'eq', value: 'bob' }],
218
+ ],
219
+ },
220
+ ]);
221
+ expect(sql).toBe(
222
+ "json_extract(r.record, '$.status') = ? AND (json_extract(r.record, '$.author') = ? OR json_extract(r.record, '$.author') = ?)",
223
+ );
224
+ expect(params).toEqual(['active', 'alice', 'bob']);
225
+ });
226
+ });
227
+
228
+ describe('buildOrderBy', () => {
229
+ it('returns default order when no sort', () => {
230
+ const sql = buildOrderBy([]);
231
+ expect(sql).toBe('r.id DESC');
232
+ });
233
+
234
+ it('handles single sort field', () => {
235
+ const sql = buildOrderBy([{ field: 'createdAt', dir: 'asc' }]);
236
+ expect(sql).toBe("json_extract(r.record, '$.createdAt') ASC");
237
+ });
238
+
239
+ it('handles system field sort', () => {
240
+ const sql = buildOrderBy([{ field: 'indexedAt', dir: 'desc' }]);
241
+ expect(sql).toBe('r.indexed_at DESC');
242
+ });
243
+
244
+ it('handles multi-field sort', () => {
245
+ const sql = buildOrderBy([
246
+ { field: 'status', dir: 'asc' },
247
+ { field: 'createdAt', dir: 'desc' },
248
+ ]);
249
+ expect(sql).toBe(
250
+ "json_extract(r.record, '$.status') ASC, json_extract(r.record, '$.createdAt') DESC",
251
+ );
252
+ });
253
+
254
+ it('defaults to asc when dir not specified', () => {
255
+ const sql = buildOrderBy([{ field: 'name' }]);
256
+ expect(sql).toBe("json_extract(r.record, '$.name') ASC");
257
+ });
258
+ });
259
+
260
+ describe('findMany', () => {
261
+ let db;
262
+ let query;
263
+
264
+ beforeEach(() => {
265
+ db = new Database(':memory:');
266
+ setupSchema(db);
267
+ query = createSqliteAdapter(db);
268
+ });
269
+
270
+ afterEach(() => {
271
+ db.close();
272
+ });
273
+
274
+ it('returns empty result for empty table', async () => {
275
+ const result = await query({
276
+ type: 'findMany',
277
+ collection: 'app.bsky.feed.post',
278
+ where: [],
279
+ pagination: { first: 10 },
280
+ });
281
+ expect(result.rows).toEqual([]);
282
+ expect(result.hasNext).toBe(false);
283
+ expect(result.hasPrev).toBe(false);
284
+ });
285
+
286
+ 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
+ );
297
+
298
+ const result = await query({
299
+ type: 'findMany',
300
+ collection: 'app.bsky.feed.post',
301
+ where: [],
302
+ pagination: { first: 10 },
303
+ });
304
+
305
+ expect(result.rows).toHaveLength(1);
306
+ expect(result.rows[0].text).toBe('hello');
307
+ expect(result.rows[0].uri).toBe('at://did:plc:abc/app.bsky.feed.post/123');
308
+ });
309
+
310
+ it('respects first limit', async () => {
311
+ 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
+ );
322
+ }
323
+
324
+ const result = await query({
325
+ type: 'findMany',
326
+ collection: 'col',
327
+ where: [],
328
+ pagination: { first: 3 },
329
+ });
330
+
331
+ expect(result.rows).toHaveLength(3);
332
+ expect(result.hasNext).toBe(true);
333
+ });
334
+
335
+ it('handles cursor pagination with after', async () => {
336
+ 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
+ );
347
+ }
348
+
349
+ const first = await query({
350
+ type: 'findMany',
351
+ collection: 'col',
352
+ where: [],
353
+ pagination: { first: 2 },
354
+ });
355
+
356
+ const cursor = Buffer.from(JSON.stringify({ id: first.rows[1]._id })).toString('base64');
357
+
358
+ const second = await query({
359
+ type: 'findMany',
360
+ collection: 'col',
361
+ where: [],
362
+ pagination: { first: 2, after: cursor },
363
+ });
364
+
365
+ expect(second.rows).toHaveLength(2);
366
+ expect(second.hasPrev).toBe(true);
367
+ });
368
+
369
+ 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
+ );
390
+
391
+ const result = await query({
392
+ type: 'findMany',
393
+ collection: 'col',
394
+ where: [{ field: 'status', op: 'eq', value: 'active' }],
395
+ pagination: { first: 10 },
396
+ });
397
+
398
+ expect(result.rows).toHaveLength(1);
399
+ expect(result.rows[0].status).toBe('active');
400
+ });
401
+
402
+ 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
+ );
423
+
424
+ const result = await query({
425
+ type: 'findMany',
426
+ collection: 'col',
427
+ where: [],
428
+ sort: [{ field: 'name', dir: 'asc' }],
429
+ pagination: { first: 10 },
430
+ });
431
+
432
+ expect(result.rows[0].name).toBe('apple');
433
+ expect(result.rows[1].name).toBe('banana');
434
+ });
435
+
436
+ 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');
441
+
442
+ const result = await query({
443
+ type: 'findMany',
444
+ collection: 'col',
445
+ where: [],
446
+ pagination: { first: 10 },
447
+ });
448
+
449
+ expect(result.rows[0].actorHandle).toBe('alice.test');
450
+ });
451
+ });
452
+
453
+ describe('aggregate', () => {
454
+ let db;
455
+ let query;
456
+
457
+ beforeEach(() => {
458
+ db = new Database(':memory:');
459
+ setupSchema(db);
460
+ query = createSqliteAdapter(db);
461
+ });
462
+
463
+ afterEach(() => {
464
+ db.close();
465
+ });
466
+
467
+ it('returns count for empty table', async () => {
468
+ const result = await query({
469
+ type: 'aggregate',
470
+ collection: 'col',
471
+ where: [],
472
+ });
473
+ expect(result.count).toBe(0);
474
+ expect(result.groups).toEqual([]);
475
+ });
476
+
477
+ it('returns count for collection', async () => {
478
+ 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
+ );
489
+ }
490
+
491
+ const result = await query({
492
+ type: 'aggregate',
493
+ collection: 'col',
494
+ where: [],
495
+ });
496
+
497
+ expect(result.count).toBe(5);
498
+ });
499
+
500
+ 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
+ );
521
+
522
+ const result = await query({
523
+ type: 'aggregate',
524
+ collection: 'col',
525
+ where: [{ field: 'status', op: 'eq', value: 'active' }],
526
+ });
527
+
528
+ expect(result.count).toBe(1);
529
+ });
530
+
531
+ 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
+ );
562
+
563
+ const result = await query({
564
+ type: 'aggregate',
565
+ collection: 'col',
566
+ where: [],
567
+ groupBy: ['status'],
568
+ });
569
+
570
+ expect(result.count).toBe(3);
571
+ expect(result.groups).toHaveLength(2);
572
+ expect(result.groups.find((g) => g.status === 'active').count).toBe(2);
573
+ expect(result.groups.find((g) => g.status === 'inactive').count).toBe(1);
574
+ });
575
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "lex-gql-sqlite",
3
+ "version": "0.1.0",
4
+ "description": "SQLite adapter for lex-gql",
5
+ "type": "module",
6
+ "main": "lex-gql-sqlite.js",
7
+ "types": "lex-gql-sqlite.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lex-gql-sqlite.d.ts",
11
+ "default": "./lex-gql-sqlite.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "typecheck": "tsc"
18
+ },
19
+ "keywords": [
20
+ "graphql",
21
+ "atproto",
22
+ "lexicon",
23
+ "sqlite",
24
+ "adapter"
25
+ ],
26
+ "license": "MIT",
27
+ "peerDependencies": {
28
+ "better-sqlite3": ">=11.0.0",
29
+ "lex-gql": ">=0.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/better-sqlite3": "^7.6.12",
33
+ "@types/node": "^22.10.0",
34
+ "better-sqlite3": "^12.6.0",
35
+ "lex-gql": "workspace:*",
36
+ "typescript": "^5.7.2",
37
+ "vitest": "^1.6.1"
38
+ }
39
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "checkJs": true,
6
+ "declaration": true,
7
+ "emitDeclarationOnly": true,
8
+ "types": ["node"]
9
+ },
10
+ "include": ["lex-gql-sqlite.js"],
11
+ "exclude": ["node_modules"]
12
+ }