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 +29 -0
- package/README.md +104 -0
- package/lex-gql-sqlite.d.ts +99 -0
- package/lex-gql-sqlite.js +356 -0
- package/lex-gql-sqlite.test.js +575 -0
- package/package.json +39 -0
- package/tsconfig.json +12 -0
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