ugly-app 0.1.821 → 0.1.823

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.
Files changed (67) hide show
  1. package/dist/cli/version.d.ts +1 -1
  2. package/dist/cli/version.js +1 -1
  3. package/dist/inspect/agent/perfAgent.d.ts +8 -0
  4. package/dist/inspect/agent/perfAgent.d.ts.map +1 -1
  5. package/dist/inspect/agent/perfAgent.js +69 -6
  6. package/dist/inspect/agent/perfAgent.js.map +1 -1
  7. package/dist/inspect/agent-bundle.js +3 -3
  8. package/dist/inspect/host-bundle.js +8 -8
  9. package/dist/inspect/protocol.d.ts +3 -1
  10. package/dist/inspect/protocol.d.ts.map +1 -1
  11. package/dist/inspect/protocol.js.map +1 -1
  12. package/dist/server/Logging.d.ts +1 -1
  13. package/dist/server/Logging.d.ts.map +1 -1
  14. package/dist/server/sqlite/SqliteDoc.d.ts +44 -0
  15. package/dist/server/sqlite/SqliteDoc.d.ts.map +1 -0
  16. package/dist/server/sqlite/SqliteDoc.js +201 -0
  17. package/dist/server/sqlite/SqliteDoc.js.map +1 -0
  18. package/dist/server/sqlite/SqliteExec.d.ts +18 -0
  19. package/dist/server/sqlite/SqliteExec.d.ts.map +1 -0
  20. package/dist/server/sqlite/SqliteExec.js +19 -0
  21. package/dist/server/sqlite/SqliteExec.js.map +1 -0
  22. package/dist/server/sqlite/SqliteFilter.d.ts +21 -0
  23. package/dist/server/sqlite/SqliteFilter.d.ts.map +1 -0
  24. package/dist/server/sqlite/SqliteFilter.js +143 -0
  25. package/dist/server/sqlite/SqliteFilter.js.map +1 -0
  26. package/dist/server/sqlite/SqlitePipeline.d.ts +8 -0
  27. package/dist/server/sqlite/SqlitePipeline.d.ts.map +1 -0
  28. package/dist/server/sqlite/SqlitePipeline.js +154 -0
  29. package/dist/server/sqlite/SqlitePipeline.js.map +1 -0
  30. package/dist/server/sqlite/SqliteSchema.d.ts +15 -0
  31. package/dist/server/sqlite/SqliteSchema.d.ts.map +1 -0
  32. package/dist/server/sqlite/SqliteSchema.js +35 -0
  33. package/dist/server/sqlite/SqliteSchema.js.map +1 -0
  34. package/dist/server/sqlite/assertIndexed.d.ts +20 -0
  35. package/dist/server/sqlite/assertIndexed.d.ts.map +1 -0
  36. package/dist/server/sqlite/assertIndexed.js +43 -0
  37. package/dist/server/sqlite/assertIndexed.js.map +1 -0
  38. package/dist/server/sqlite/sqliteUpdateOps.d.ts +17 -0
  39. package/dist/server/sqlite/sqliteUpdateOps.d.ts.map +1 -0
  40. package/dist/server/sqlite/sqliteUpdateOps.js +100 -0
  41. package/dist/server/sqlite/sqliteUpdateOps.js.map +1 -0
  42. package/dist/shared/FrameworkRequests.d.ts +1 -1
  43. package/dist/shared/FrameworkRequests.d.ts.map +1 -1
  44. package/dist/shared/FrameworkRequests.js +1 -0
  45. package/dist/shared/FrameworkRequests.js.map +1 -1
  46. package/package.json +3 -1
  47. package/src/cli/version.ts +1 -1
  48. package/src/inspect/agent/perfAgent.test.ts +24 -0
  49. package/src/inspect/agent/perfAgent.ts +67 -6
  50. package/src/inspect/protocol.ts +3 -1
  51. package/src/server/App.ts +1 -1
  52. package/src/server/Logging.ts +1 -1
  53. package/src/server/adapter/workers/createWorkersApp.ts +1 -1
  54. package/src/server/sqlite/SqliteDoc.test.ts +145 -0
  55. package/src/server/sqlite/SqliteDoc.ts +356 -0
  56. package/src/server/sqlite/SqliteExec.ts +35 -0
  57. package/src/server/sqlite/SqliteFilter.test.ts +110 -0
  58. package/src/server/sqlite/SqliteFilter.ts +161 -0
  59. package/src/server/sqlite/SqlitePipeline.test.ts +54 -0
  60. package/src/server/sqlite/SqlitePipeline.ts +172 -0
  61. package/src/server/sqlite/SqliteSchema.test.ts +64 -0
  62. package/src/server/sqlite/SqliteSchema.ts +56 -0
  63. package/src/server/sqlite/assertIndexed.test.ts +58 -0
  64. package/src/server/sqlite/assertIndexed.ts +58 -0
  65. package/src/server/sqlite/sqliteUpdateOps.test.ts +74 -0
  66. package/src/server/sqlite/sqliteUpdateOps.ts +111 -0
  67. package/src/shared/FrameworkRequests.ts +1 -0
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { translateFilter, translateSort, collectJsonFields } from './SqliteFilter.js';
3
+
4
+ describe('SqliteFilter.translateFilter', () => {
5
+ it('empty filter → empty where', () => {
6
+ expect(translateFilter({})).toEqual({ where: '', values: [] });
7
+ });
8
+
9
+ it('bare string equality uses json_extract with ? placeholder', () => {
10
+ const r = translateFilter({ name: 'hi' });
11
+ expect(r.where).toBe("json_extract(data,'$.name') = ?");
12
+ expect(r.values).toEqual(['hi']);
13
+ });
14
+
15
+ it('_id equality uses the primary-key column', () => {
16
+ expect(translateFilter({ _id: 'abc' })).toEqual({
17
+ where: '_id = ?',
18
+ values: ['abc'],
19
+ });
20
+ });
21
+
22
+ it('number equality compares typed json_extract', () => {
23
+ const r = translateFilter({ age: 5 });
24
+ expect(r.where).toBe("json_extract(data,'$.age') = ?");
25
+ expect(r.values).toEqual([5]);
26
+ });
27
+
28
+ it('boolean equality normalizes operand to 1/0', () => {
29
+ expect(translateFilter({ ok: true }).values).toEqual([1]);
30
+ expect(translateFilter({ ok: false }).values).toEqual([0]);
31
+ });
32
+
33
+ it('{f: null} mirrors PG absent-or-string-null quirk', () => {
34
+ const r = translateFilter({ f: null });
35
+ expect(r.where).toBe(
36
+ "(json_type(data,'$.f') IS NULL OR json_extract(data,'$.f') = 'null')",
37
+ );
38
+ expect(r.values).toEqual([]);
39
+ });
40
+
41
+ it('$exists:true → key present via json_type', () => {
42
+ expect(translateFilter({ f: { $exists: true } }).where).toBe(
43
+ "json_type(data,'$.f') IS NOT NULL",
44
+ );
45
+ });
46
+
47
+ it('$exists:false → json_type IS NULL', () => {
48
+ expect(translateFilter({ f: { $exists: false } }).where).toBe(
49
+ "json_type(data,'$.f') IS NULL",
50
+ );
51
+ });
52
+
53
+ it('$ne non-null also matches missing/null', () => {
54
+ const r = translateFilter({ v: { $ne: 'x' } });
55
+ expect(r.where).toBe(
56
+ "(json_extract(data,'$.v') IS NULL OR json_extract(data,'$.v') != ?)",
57
+ );
58
+ expect(r.values).toEqual(['x']);
59
+ });
60
+
61
+ it('$gt compares typed json_extract', () => {
62
+ const r = translateFilter({ n: { $gt: 3 } });
63
+ expect(r.where).toBe("json_extract(data,'$.n') > ?");
64
+ expect(r.values).toEqual([3]);
65
+ });
66
+
67
+ it('$in matches scalar or array-valued field, binding operand twice', () => {
68
+ const r = translateFilter({ tag: { $in: ['a', 'b'] } });
69
+ expect(r.where).toBe(
70
+ "(json_extract(data,'$.tag') IN (?,?) OR EXISTS(SELECT 1 FROM json_each(data,'$.tag') WHERE json_each.value IN (?,?)))",
71
+ );
72
+ expect(r.values).toEqual(['a', 'b', 'a', 'b']);
73
+ });
74
+
75
+ it('combines multiple fields with AND', () => {
76
+ const r = translateFilter({ a: 1, b: 'x' });
77
+ expect(r.where).toBe(
78
+ "json_extract(data,'$.a') = ? AND json_extract(data,'$.b') = ?",
79
+ );
80
+ expect(r.values).toEqual([1, 'x']);
81
+ });
82
+ });
83
+
84
+ describe('SqliteFilter.translateSort', () => {
85
+ it('emits nulls-last for ASC (matches PG default)', () => {
86
+ expect(translateSort({ name: 1 })).toBe(
87
+ "(CAST(json_extract(data,'$.name') AS TEXT) IS NULL) ASC, CAST(json_extract(data,'$.name') AS TEXT) ASC",
88
+ );
89
+ });
90
+
91
+ it('emits nulls-first for DESC (matches PG default)', () => {
92
+ expect(translateSort({ name: -1 })).toBe(
93
+ "(CAST(json_extract(data,'$.name') AS TEXT) IS NULL) DESC, CAST(json_extract(data,'$.name') AS TEXT) DESC",
94
+ );
95
+ });
96
+
97
+ it('top-level column sorts without json_extract', () => {
98
+ expect(translateSort({ created: -1 })).toBe(
99
+ '("created" IS NULL) DESC, "created" DESC',
100
+ );
101
+ });
102
+ });
103
+
104
+ describe('SqliteFilter.collectJsonFields', () => {
105
+ it('returns discriminating json fields, skipping top-level and $exists-only', () => {
106
+ expect(
107
+ collectJsonFields({ a: 1, _id: 'x', b: { $exists: true }, c: { $gt: 2 } }),
108
+ ).toEqual(['a', 'c']);
109
+ });
110
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * MongoDB-style filter → SQLite WHERE clause translation.
3
+ *
4
+ * Mirrors PostgresFilter.ts's EXACT current semantics so a collection can move
5
+ * between the Postgres and SQLite backends with identical query results.
6
+ * Placeholders are `?` (accepted by both better-sqlite3 and Cloudflare D1).
7
+ * Pure string manipulation — MUST NOT import any DB driver (native or otherwise).
8
+ */
9
+
10
+ export interface TranslatedFilter {
11
+ where: string;
12
+ values: unknown[];
13
+ }
14
+
15
+ const TOP_LEVEL_COLUMNS = new Set(['_id', 'created', 'updated', 'version']);
16
+
17
+ /** `json_extract(data,'$.a.b')` (typed) or, in text context, cast to TEXT. */
18
+ function jsonPath(field: string, asText = false): string {
19
+ if (TOP_LEVEL_COLUMNS.has(field)) return `"${field}"`;
20
+ const path = '$.' + field; // dot-notation maps directly to a JSON path
21
+ const extract = `json_extract(data,'${path}')`;
22
+ return asText ? `CAST(${extract} AS TEXT)` : extract;
23
+ }
24
+
25
+ /** `json_type(data,'$.f')` — NULL only when the path is ABSENT (present-null → 'null'). */
26
+ function jsonTypePath(field: string): string {
27
+ return `json_type(data,'$.${field}')`;
28
+ }
29
+
30
+ function isOperatorObject(value: unknown): value is Record<string, unknown> {
31
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
32
+ return false;
33
+ return Object.keys(value).some((k) => k.startsWith('$'));
34
+ }
35
+
36
+ /** SQLite has no boolean type; JSON booleans extract to 1/0. Normalize operands. */
37
+ function normalize(value: unknown): unknown {
38
+ return typeof value === 'boolean' ? (value ? 1 : 0) : value;
39
+ }
40
+
41
+ function placeholders(n: number): string {
42
+ return Array.from({ length: n }, () => '?').join(',');
43
+ }
44
+
45
+ export function translateFilter(
46
+ filter: Record<string, unknown>,
47
+ ): TranslatedFilter {
48
+ const clauses: string[] = [];
49
+ const values: unknown[] = [];
50
+
51
+ for (const [field, value] of Object.entries(filter)) {
52
+ const acc = jsonPath(field);
53
+ if (isOperatorObject(value)) {
54
+ for (const [op, operand] of Object.entries(value)) {
55
+ switch (op) {
56
+ case '$in': {
57
+ const arr = (operand as unknown[]).map(normalize);
58
+ if (TOP_LEVEL_COLUMNS.has(field)) {
59
+ clauses.push(`${acc} IN (${placeholders(arr.length)})`);
60
+ values.push(...arr);
61
+ } else {
62
+ clauses.push(
63
+ `(${acc} IN (${placeholders(arr.length)}) OR EXISTS(SELECT 1 FROM json_each(data,'$.${field}') WHERE json_each.value IN (${placeholders(arr.length)})))`,
64
+ );
65
+ values.push(...arr, ...arr);
66
+ }
67
+ break;
68
+ }
69
+ case '$nin': {
70
+ const arr = (operand as unknown[]).map(normalize);
71
+ if (TOP_LEVEL_COLUMNS.has(field)) {
72
+ clauses.push(
73
+ `(${acc} IS NULL OR ${acc} NOT IN (${placeholders(arr.length)}))`,
74
+ );
75
+ values.push(...arr);
76
+ } else {
77
+ clauses.push(
78
+ `(${acc} IS NULL OR (${acc} NOT IN (${placeholders(arr.length)}) AND NOT EXISTS(SELECT 1 FROM json_each(data,'$.${field}') WHERE json_each.value IN (${placeholders(arr.length)}))))`,
79
+ );
80
+ values.push(...arr, ...arr);
81
+ }
82
+ break;
83
+ }
84
+ case '$gt':
85
+ case '$gte':
86
+ case '$lt':
87
+ case '$lte': {
88
+ const sqlOp = { $gt: '>', $gte: '>=', $lt: '<', $lte: '<=' }[op]!;
89
+ clauses.push(`${acc} ${sqlOp} ?`);
90
+ values.push(normalize(operand));
91
+ break;
92
+ }
93
+ case '$ne':
94
+ if (operand === null) {
95
+ clauses.push(
96
+ `(${jsonTypePath(field)} IS NOT NULL AND ${acc} != 'null')`,
97
+ );
98
+ } else {
99
+ clauses.push(`(${acc} IS NULL OR ${acc} != ?)`);
100
+ values.push(normalize(operand));
101
+ }
102
+ break;
103
+ case '$eq':
104
+ if (operand === null) {
105
+ clauses.push(`(${jsonTypePath(field)} IS NULL OR ${acc} = 'null')`);
106
+ } else {
107
+ const eqAcc = field === '_id' ? '_id' : acc;
108
+ clauses.push(`${eqAcc} = ?`);
109
+ values.push(normalize(operand));
110
+ }
111
+ break;
112
+ case '$exists':
113
+ clauses.push(
114
+ operand
115
+ ? `${jsonTypePath(field)} IS NOT NULL`
116
+ : `${jsonTypePath(field)} IS NULL`,
117
+ );
118
+ break;
119
+ default:
120
+ throw new Error(`[SqliteFilter] Unsupported filter operator: ${op}`);
121
+ }
122
+ }
123
+ } else if (value === null) {
124
+ clauses.push(`(${jsonTypePath(field)} IS NULL OR ${acc} = 'null')`);
125
+ } else {
126
+ const eqAcc = field === '_id' ? '_id' : acc;
127
+ clauses.push(`${eqAcc} = ?`);
128
+ values.push(normalize(value));
129
+ }
130
+ }
131
+
132
+ return { where: clauses.join(' AND '), values };
133
+ }
134
+
135
+ /**
136
+ * Field names a filter actually discriminates on (excludes top-level columns
137
+ * and `$exists`-only checks). Sibling of PostgresFilter.collectJsonbFields —
138
+ * used later by index-coverage enforcement.
139
+ */
140
+ export function collectJsonFields(filter: Record<string, unknown>): string[] {
141
+ const out: string[] = [];
142
+ for (const [field, value] of Object.entries(filter)) {
143
+ if (TOP_LEVEL_COLUMNS.has(field)) continue;
144
+ if (isOperatorObject(value)) {
145
+ const keys = Object.keys(value);
146
+ if (keys.every((k) => k === '$exists')) continue;
147
+ }
148
+ out.push(field);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ export function translateSort(sort: Record<string, 1 | -1>): string {
154
+ return Object.entries(sort)
155
+ .map(([field, dir]) => {
156
+ const d = dir === 1 ? 'ASC' : 'DESC';
157
+ const expr = jsonPath(field, true); // text context — lexical, matches PG ->>'
158
+ return `(${expr} IS NULL) ${d}, ${expr} ${d}`;
159
+ })
160
+ .join(', ');
161
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { translatePipeline } from './SqlitePipeline.js';
3
+
4
+ describe('SqlitePipeline.translatePipeline', () => {
5
+ it('$match compiles a filter into WHERE with ? params', () => {
6
+ const { sql, values } = translatePipeline('c', [{ $match: { kind: 'a' } }]);
7
+ expect(sql).toContain('FROM "c"');
8
+ expect(sql).toContain("WHERE json_extract(data,'$.kind') = ?");
9
+ expect(values).toEqual(['a']);
10
+ });
11
+
12
+ it('$group by field with $sum builds GROUP BY + SUM(CAST REAL)', () => {
13
+ const { sql } = translatePipeline('c', [
14
+ { $group: { _id: '$kind', total: { $sum: '$amount' } } },
15
+ ]);
16
+ expect(sql).toContain(`json_extract(data,'$.kind') AS "_id"`);
17
+ expect(sql).toContain(`SUM(CAST((json_extract(data,'$.amount')) AS REAL)) AS "total"`);
18
+ expect(sql).toContain(`GROUP BY json_extract(data,'$.kind')`);
19
+ });
20
+
21
+ it('$group _id:null yields NULL AS "_id" and no GROUP BY', () => {
22
+ const { sql } = translatePipeline('c', [
23
+ { $group: { _id: null, total: { $sum: '$amount' } } },
24
+ ]);
25
+ expect(sql).toContain('NULL AS "_id"');
26
+ expect(sql).not.toContain('GROUP BY');
27
+ });
28
+
29
+ it('$count wraps the query in a COUNT(*) subquery', () => {
30
+ const { sql } = translatePipeline('c', [
31
+ { $match: { kind: 'a' } },
32
+ { $count: 'n' },
33
+ ]);
34
+ expect(sql).toContain('COUNT(*) AS "n"');
35
+ expect(sql).toContain('AS _counted');
36
+ });
37
+
38
+ it('$sort emits nulls-last ASC (lexical text cast)', () => {
39
+ const { sql } = translatePipeline('c', [{ $sort: { name: 1 } }]);
40
+ expect(sql).toContain(
41
+ `ORDER BY (CAST(json_extract(data,'$.name') AS TEXT) IS NULL) ASC, CAST(json_extract(data,'$.name') AS TEXT) ASC`,
42
+ );
43
+ });
44
+
45
+ it('$limit and $skip inline as LIMIT/OFFSET', () => {
46
+ const { sql } = translatePipeline('c', [{ $limit: 5 }, { $skip: 2 }]);
47
+ expect(sql).toContain('LIMIT 5');
48
+ expect(sql).toContain('OFFSET 2');
49
+ });
50
+
51
+ it('rejects an unsupported stage', () => {
52
+ expect(() => translatePipeline('c', [{ $unwind: '$x' }])).toThrow(/Unsupported/);
53
+ });
54
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * MongoDB aggregation pipeline → SQLite SQL. Mirrors PostgresPipeline.ts's MVP
3
+ * subset ($match/$sort/$limit/$skip/$count/$group with $sum/$avg/$min/$max).
4
+ * `?` placeholders (positional, consumed in order — no $n renumbering). Pure —
5
+ * no DB, no native import.
6
+ */
7
+ import { translateFilter } from './SqliteFilter.js';
8
+
9
+ const TOP_LEVEL_COLUMNS = new Set(['_id', 'created', 'updated', 'version']);
10
+
11
+ function jsonPath(field: string, asText = false): string {
12
+ if (TOP_LEVEL_COLUMNS.has(field)) return `"${field}"`;
13
+ const extract = `json_extract(data,'$.${field}')`;
14
+ return asText ? `CAST(${extract} AS TEXT)` : extract;
15
+ }
16
+
17
+ interface QueryParts {
18
+ select: string;
19
+ from: string;
20
+ where: string[];
21
+ groupBy: string[];
22
+ orderBy: string[];
23
+ limit: number | null;
24
+ offset: number | null;
25
+ values: unknown[];
26
+ grouped: boolean;
27
+ }
28
+
29
+ function buildSql(p: QueryParts): string {
30
+ let sql = `SELECT ${p.select} FROM ${p.from}`;
31
+ if (p.where.length > 0) sql += ` WHERE ${p.where.join(' AND ')}`;
32
+ if (p.groupBy.length > 0) sql += ` GROUP BY ${p.groupBy.join(', ')}`;
33
+ if (p.orderBy.length > 0) sql += ` ORDER BY ${p.orderBy.join(', ')}`;
34
+ if (p.limit !== null) sql += ` LIMIT ${p.limit}`;
35
+ if (p.offset !== null) sql += ` OFFSET ${p.offset}`;
36
+ return sql;
37
+ }
38
+
39
+ function wrapAsSubquery(p: QueryParts): QueryParts {
40
+ const inner = buildSql(p);
41
+ return {
42
+ select: '*',
43
+ from: `(${inner}) AS _sub`,
44
+ where: [],
45
+ groupBy: [],
46
+ orderBy: [],
47
+ limit: null,
48
+ offset: null,
49
+ values: [...p.values],
50
+ grouped: false,
51
+ };
52
+ }
53
+
54
+ function resolveAccumulator(acc: Record<string, unknown>): string {
55
+ const [op, field] = Object.entries(acc)[0]!;
56
+ const col =
57
+ typeof field === 'string' && field.startsWith('$')
58
+ ? jsonPath(field.slice(1))
59
+ : String(field);
60
+ switch (op) {
61
+ case '$sum':
62
+ if (typeof field === 'number') return String(field); // mirror PG quirk
63
+ return `SUM(CAST((${col}) AS REAL))`;
64
+ case '$avg':
65
+ return `AVG(CAST((${col}) AS REAL))`;
66
+ case '$min':
67
+ return `MIN(CAST((${col}) AS REAL))`;
68
+ case '$max':
69
+ return `MAX(CAST((${col}) AS REAL))`;
70
+ default:
71
+ throw new Error(`[SqlitePipeline] Unsupported accumulator: ${op}`);
72
+ }
73
+ }
74
+
75
+ function orderByExpr(field: string, dir: number, fromSub: boolean): string {
76
+ const d = dir === 1 ? 'ASC' : 'DESC';
77
+ const expr = fromSub ? `"${field}"` : jsonPath(field, true);
78
+ return `(${expr} IS NULL) ${d}, ${expr} ${d}`;
79
+ }
80
+
81
+ export function translatePipeline(
82
+ collection: string,
83
+ pipeline: Record<string, unknown>[],
84
+ options?: { skip?: number; limit?: number },
85
+ ): { sql: string; values: unknown[] } {
86
+ const parts: QueryParts = {
87
+ select: '_id, data, created, updated, version',
88
+ from: `"${collection}"`,
89
+ where: [],
90
+ groupBy: [],
91
+ orderBy: [],
92
+ limit: null,
93
+ offset: null,
94
+ values: [],
95
+ grouped: false,
96
+ };
97
+
98
+ for (const stage of pipeline) {
99
+ const [key, value] = Object.entries(stage)[0]!;
100
+ switch (key) {
101
+ case '$match': {
102
+ if (parts.grouped) Object.assign(parts, wrapAsSubquery(parts));
103
+ const f = translateFilter(value as Record<string, unknown>);
104
+ if (f.where) {
105
+ parts.where.push(f.where);
106
+ parts.values.push(...f.values);
107
+ }
108
+ break;
109
+ }
110
+ case '$sort': {
111
+ if (parts.grouped) Object.assign(parts, wrapAsSubquery(parts));
112
+ const fromSub = parts.from.includes('_sub');
113
+ parts.orderBy = Object.entries(value as Record<string, number>).map(
114
+ ([field, dir]) => orderByExpr(field, dir, fromSub),
115
+ );
116
+ break;
117
+ }
118
+ case '$limit':
119
+ parts.limit = value as number;
120
+ break;
121
+ case '$skip':
122
+ parts.offset = value as number;
123
+ break;
124
+ case '$count': {
125
+ const inner = buildSql(parts);
126
+ parts.select = `COUNT(*) AS "${value as string}"`;
127
+ parts.from = `(${inner}) AS _counted`;
128
+ parts.where = [];
129
+ parts.groupBy = [];
130
+ parts.orderBy = [];
131
+ parts.limit = null;
132
+ parts.offset = null;
133
+ parts.grouped = false;
134
+ break;
135
+ }
136
+ case '$group': {
137
+ const group = value as Record<string, unknown>;
138
+ const groupId = group._id;
139
+ const selectParts: string[] = [];
140
+ const groupByParts: string[] = [];
141
+ if (groupId === null) {
142
+ selectParts.push(`NULL AS "_id"`);
143
+ } else if (typeof groupId === 'string' && groupId.startsWith('$')) {
144
+ const accessor = jsonPath(groupId.slice(1));
145
+ selectParts.push(`${accessor} AS "_id"`);
146
+ groupByParts.push(accessor);
147
+ } else {
148
+ throw new Error(
149
+ `[SqlitePipeline] Unsupported $group _id: ${JSON.stringify(groupId)}`,
150
+ );
151
+ }
152
+ for (const [alias, expr] of Object.entries(group)) {
153
+ if (alias === '_id') continue;
154
+ selectParts.push(
155
+ `${resolveAccumulator(expr as Record<string, unknown>)} AS "${alias}"`,
156
+ );
157
+ }
158
+ parts.select = selectParts.join(', ');
159
+ parts.groupBy = groupByParts;
160
+ parts.grouped = true;
161
+ break;
162
+ }
163
+ default:
164
+ throw new Error(`[SqlitePipeline] Unsupported pipeline stage: ${key}`);
165
+ }
166
+ }
167
+
168
+ if (options?.skip !== undefined) parts.offset = options.skip;
169
+ if (options?.limit !== undefined) parts.limit = options.limit;
170
+
171
+ return { sql: buildSql(parts), values: parts.values };
172
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import { fieldIndexStatements, ensureSqliteIndexes } from './SqliteSchema.js';
3
+ import { makeBetterSqliteExec, type SqliteExec } from './SqliteExec.js';
4
+ import { ensureSqliteTable, sqliteInsertRaw } from './SqliteDoc.js';
5
+
6
+ describe('fieldIndexStatements', () => {
7
+ it('emits a single-field expression index', () => {
8
+ expect(fieldIndexStatements('post', [{ fields: { status: 1 } }])).toEqual([
9
+ `CREATE INDEX IF NOT EXISTS "idx_post_status" ON "post" ((json_extract(data,'$.status')))`,
10
+ ]);
11
+ });
12
+
13
+ it('emits a composite index with DESC and preserves field order', () => {
14
+ expect(
15
+ fieldIndexStatements('post', [{ fields: { a: 1, b: -1 } }]),
16
+ ).toEqual([
17
+ `CREATE INDEX IF NOT EXISTS "idx_post_a_b" ON "post" ((json_extract(data,'$.a')), (json_extract(data,'$.b')) DESC)`,
18
+ ]);
19
+ });
20
+
21
+ it('marks unique indexes and names them _unique', () => {
22
+ expect(
23
+ fieldIndexStatements('post', [{ fields: { email: 1 }, unique: true }]),
24
+ ).toEqual([
25
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_post_email_unique" ON "post" ((json_extract(data,'$.email')))`,
26
+ ]);
27
+ });
28
+
29
+ it('rejects an index whose field name is unsafe (dotted/injection)', () => {
30
+ expect(fieldIndexStatements('post', [{ fields: { 'a.b': 1 } }])).toEqual([]);
31
+ });
32
+ });
33
+
34
+ describe('ensureSqliteIndexes (planner uses the index)', () => {
35
+ let exec: SqliteExec;
36
+ beforeAll(async () => {
37
+ exec = await makeBetterSqliteExec();
38
+ await ensureSqliteTable(exec, 'idxt');
39
+ for (let i = 0; i < 300; i++) {
40
+ await sqliteInsertRaw(exec, 'idxt', {
41
+ _id: `id${i}`,
42
+ status: i % 3 === 0 ? 'open' : 'closed',
43
+ n: i,
44
+ });
45
+ }
46
+ await ensureSqliteIndexes(exec, 'idxt', [{ fields: { status: 1 } }]);
47
+ });
48
+
49
+ it('uses the expression index for a filter on the indexed field', async () => {
50
+ const plan = await exec.all<{ detail: string }>(
51
+ `EXPLAIN QUERY PLAN SELECT _id FROM "idxt" WHERE json_extract(data,'$.status') = ?`,
52
+ ['open'],
53
+ );
54
+ expect(plan.map((r) => r.detail).join(' ')).toMatch(/USING INDEX idx_idxt_status/);
55
+ });
56
+
57
+ it('scans for a filter on an unindexed field', async () => {
58
+ const plan = await exec.all<{ detail: string }>(
59
+ `EXPLAIN QUERY PLAN SELECT _id FROM "idxt" WHERE json_extract(data,'$.n') = ?`,
60
+ [5],
61
+ );
62
+ expect(plan.map((r) => r.detail).join(' ')).toMatch(/SCAN/);
63
+ });
64
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Expression indexes a collection declares via `meta.indexes`. Mirrors
3
+ * schemaIndexes.fieldIndexStatements, emitting SQLite expression indexes over
4
+ * `json_extract(data,'$.field')` — the exact expression SqliteFilter builds, so
5
+ * the planner uses them (EXPLAIN QUERY PLAN → SEARCH ... USING INDEX). Pure
6
+ * statement builder + a thin exec runner. No native import.
7
+ */
8
+ import type { IndexDef } from '../../shared/DB.js';
9
+ import type { SqliteExec } from './SqliteExec.js';
10
+
11
+ const VALID_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
12
+
13
+ /** Raw `CREATE [UNIQUE] INDEX` SQL for each declared (single- or multi-field) index. */
14
+ export function fieldIndexStatements(
15
+ collection: string,
16
+ indexes?: IndexDef[],
17
+ ): string[] {
18
+ if (!VALID_NAME.test(collection)) return [];
19
+ const out: string[] = [];
20
+ for (const idx of indexes ?? []) {
21
+ const entries = Object.entries(idx.fields);
22
+ if (entries.length === 0) continue;
23
+ if (!entries.every(([field]) => VALID_NAME.test(field))) continue;
24
+ const unique = idx.unique ? 'UNIQUE ' : '';
25
+ const base = `idx_${collection}_${entries.map(([field]) => field).join('_')}`;
26
+ const name = idx.unique ? `${base}_unique` : base;
27
+ const cols = entries
28
+ .map(
29
+ ([field, dir]) =>
30
+ `(json_extract(data,'$.${field}'))${dir === -1 ? ' DESC' : ''}`,
31
+ )
32
+ .join(', ');
33
+ out.push(
34
+ `CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${collection}" (${cols})`,
35
+ );
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /** Create each declared index. Isolates a single failing index (e.g. a UNIQUE
41
+ * over pre-existing duplicate data) so it never aborts the rest of the sync. */
42
+ export async function ensureSqliteIndexes(
43
+ exec: SqliteExec,
44
+ collection: string,
45
+ indexes?: IndexDef[],
46
+ ): Promise<void> {
47
+ for (const sql of fieldIndexStatements(collection, indexes)) {
48
+ try {
49
+ await exec.run(sql);
50
+ } catch (err) {
51
+ console.warn(
52
+ `[SqliteSchema] index skipped for "${collection}": ${(err as Error).message}`,
53
+ );
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ assertIndexed,
4
+ coveredFields,
5
+ UnindexedQueryError,
6
+ } from './assertIndexed.js';
7
+ import type { IndexDef } from '../../shared/DB.js';
8
+
9
+ const IDX: IndexDef[] = [{ fields: { status: 1 } }, { fields: { a: 1, b: -1 } }];
10
+
11
+ describe('coveredFields', () => {
12
+ it('credits every field of single and composite indexes', () => {
13
+ expect(coveredFields(IDX)).toEqual(new Set(['status', 'a', 'b']));
14
+ });
15
+ });
16
+
17
+ describe('assertIndexed', () => {
18
+ it('passes a filter on an indexed field', () => {
19
+ expect(() => assertIndexed('c', IDX, { status: 'open' })).not.toThrow();
20
+ });
21
+
22
+ it('credits the trailing field of a composite index', () => {
23
+ expect(() => assertIndexed('c', IDX, { b: 1 })).not.toThrow();
24
+ });
25
+
26
+ it('throws UnindexedQueryError on an unindexed filter field', () => {
27
+ expect(() => assertIndexed('c', IDX, { other: 1 })).toThrow(UnindexedQueryError);
28
+ });
29
+
30
+ it('names the offending field in the message', () => {
31
+ expect(() => assertIndexed('c', IDX, { other: 1 })).toThrow(/other/);
32
+ });
33
+
34
+ it('checks sort keys too', () => {
35
+ expect(() => assertIndexed('c', IDX, undefined, { other: 1 })).toThrow(
36
+ UnindexedQueryError,
37
+ );
38
+ expect(() => assertIndexed('c', IDX, undefined, { status: -1 })).not.toThrow();
39
+ });
40
+
41
+ it('always allows _id / created / updated (top-level columns)', () => {
42
+ expect(() => assertIndexed('c', IDX, { _id: 'x' }, { created: -1 })).not.toThrow();
43
+ });
44
+
45
+ it('ignores $exists-only filter fields', () => {
46
+ expect(() => assertIndexed('c', IDX, { other: { $exists: true } })).not.toThrow();
47
+ });
48
+
49
+ it('allowScan bypasses the check', () => {
50
+ expect(() =>
51
+ assertIndexed('c', IDX, { other: 1 }, undefined, { allowScan: true }),
52
+ ).not.toThrow();
53
+ });
54
+
55
+ it('no filter/sort → no-op', () => {
56
+ expect(() => assertIndexed('c', IDX)).not.toThrow();
57
+ });
58
+ });