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
@@ -37,6 +37,10 @@ const PROFILE_COOLDOWN_MS = 30_000;
37
37
  // racing `visibilitychange`), not event-loop lag. Comfortably above the largest block
38
38
  // we still want to report as a spike.
39
39
  const SUSPEND_GAP_MS = 10_000;
40
+ // Delay before the one-shot `page-load` snapshot. Long enough for LCP/FCP to settle
41
+ // and the page to become interactive; well inside the "if it's slower than this it
42
+ // doesn't matter" horizon. Startup data lands without needing tab-close or app-quit.
43
+ const STARTUP_SNAPSHOT_DELAY_MS = 8_000;
40
44
 
41
45
  /**
42
46
  * A hidden page's timers are throttled by the browser — Chromium clamps them to 1s
@@ -245,6 +249,7 @@ export class PerfAgent {
245
249
  private snapshots: PerfSnapshot[] = [];
246
250
  private profiling = false;
247
251
  private lastProfileAt = 0;
252
+ private startupCaptured = false;
248
253
  private lcpMs: number | undefined;
249
254
  private fcpMs: number | undefined;
250
255
  private _startup: {
@@ -294,12 +299,6 @@ export class PerfAgent {
294
299
  for (const e of list.getEntries()) {
295
300
  this.longTasks.add(Date.now(), e.duration);
296
301
  }
297
- // Track LCP
298
- for (const e of list.getEntries()) {
299
- if ((e as PerformanceEntry & { entryType?: string }).entryType === 'largest-contentful-paint') {
300
- this.lcpMs = e.startTime;
301
- }
302
- }
303
302
  if (this.longTaskAnomalyDue(vitals)) {
304
303
  void this.captureAnomaly('long-task');
305
304
  }
@@ -308,6 +307,21 @@ export class PerfAgent {
308
307
  }
309
308
  } catch { /* unsupported */ }
310
309
 
310
+ // LCP capture — MUST be its own observer. This previously looked for
311
+ // 'largest-contentful-paint' entries inside the longtask observer above, which
312
+ // only ever receives longtask entries, so lcpMs was always null. LCP fires
313
+ // repeatedly (each larger paint), so we keep the latest startTime.
314
+ try {
315
+ if (typeof PerformanceObserver !== 'undefined') {
316
+ const lcpObs = new PerformanceObserver((list) => {
317
+ const entries = list.getEntries();
318
+ const last = entries[entries.length - 1];
319
+ if (last) this.lcpMs = last.startTime;
320
+ });
321
+ lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });
322
+ }
323
+ } catch { /* unsupported (e.g. Safari) */ }
324
+
311
325
  // FCP capture via paint observer
312
326
  try {
313
327
  if (typeof PerformanceObserver !== 'undefined') {
@@ -333,6 +347,53 @@ export class PerfAgent {
333
347
 
334
348
  // Track startup milestones
335
349
  this._startup.milestones.push({ label: 'agent:install', deltaMs: Math.round(this.now() - this._startup.t0) });
350
+
351
+ // One-shot startup snapshot so initial-load data (startup timings + LCP/FCP)
352
+ // lands reliably a few seconds after load — not only on anomaly or unload.
353
+ try {
354
+ const t = setTimeout(() => this.captureStartupSnapshot(), STARTUP_SNAPSHOT_DELAY_MS);
355
+ if (t && typeof (t as { unref?: () => void }).unref === 'function') (t as { unref: () => void }).unref();
356
+ } catch { /* no timers (test) */ }
357
+ }
358
+
359
+ /**
360
+ * Fire once, ~STARTUP_SNAPSHOT_DELAY_MS after load: a `page-load` snapshot carrying
361
+ * the startup milestone breakdown + LCP/FCP. Bypasses the hidden-tab gate — startup
362
+ * timing is a one-time load metric, not ongoing pressure — but LCP/FCP may be null
363
+ * if the tab never painted (preloaded in the background).
364
+ */
365
+ private captureStartupSnapshot(): void {
366
+ if (this.startupCaptured) return;
367
+ this.startupCaptured = true;
368
+ const heap = heapMb();
369
+ const vitals = this.vitalsReader();
370
+ const totalMs = this._startup.milestones.length > 0
371
+ ? this._startup.milestones[this._startup.milestones.length - 1]!.deltaMs
372
+ : 0;
373
+ const snapshot: PerfSnapshot = {
374
+ trigger: 'page-load',
375
+ cpuPressure: this.heartbeat.pressure,
376
+ eventLoopLagMs: Math.round(this.heartbeat.avgLagMs),
377
+ heapUsedMb: Math.round(heap.used * 10) / 10,
378
+ heapTotalMb: Math.round(heap.total * 10) / 10,
379
+ longTaskCount: this.longTasks.count,
380
+ domNodeCount: domCount(),
381
+ ...(this.lcpMs != null ? { lcpMs: this.lcpMs } : {}),
382
+ ...(this.fcpMs != null ? { fcpMs: this.fcpMs } : {}),
383
+ ...(vitals.cls != null ? { cls: vitals.cls } : {}),
384
+ callTree: null,
385
+ startup: {
386
+ milestones: this._startup.milestones.slice(),
387
+ totalMs,
388
+ longTaskCount: this._startup.longTasks,
389
+ },
390
+ };
391
+ if (this.snapshots.length >= MAX_PENDING_ANOMALIES) {
392
+ this.snapshots.shift();
393
+ this.pendingAnomalies.shift();
394
+ }
395
+ this.pendingAnomalies.push('page-load');
396
+ this.snapshots.push(snapshot);
336
397
  }
337
398
 
338
399
  /**
@@ -228,7 +228,9 @@ export interface WebVitals {
228
228
  * rAF, and PerformanceObserver. On anomaly or page unload it captures a
229
229
  * snapshot that the host can poll and forward to D1 for root-cause analysis. */
230
230
  export interface PerfSnapshot {
231
- trigger: 'cpu-pressure' | 'dropped-frames' | 'long-task' | 'page-unload';
231
+ /** `page-load` fires once, a few seconds after load, carrying startup timings +
232
+ * LCP/FCP — so initial-load data lands without waiting for anomaly or unload. */
233
+ trigger: 'cpu-pressure' | 'dropped-frames' | 'long-task' | 'page-unload' | 'page-load';
232
234
  /** 0..1 — fraction of recent heartbeat ticks classified as delayed. */
233
235
  cpuPressure: number;
234
236
  /** Average event-loop lag in ms over the recent window. */
package/src/server/App.ts CHANGED
@@ -948,7 +948,7 @@ export function createApp<
948
948
  perfSnapshotCaptureNoAuth: (
949
949
  _userId: string | null,
950
950
  input: {
951
- trigger: "cpu-pressure" | "dropped-frames" | "long-task" | "page-unload" | "self-snapshot" | "app-quit";
951
+ trigger: "cpu-pressure" | "dropped-frames" | "long-task" | "page-unload" | "page-load" | "self-snapshot" | "app-quit";
952
952
  source?: string;
953
953
  cpuPressure?: number;
954
954
  eventLoopLagMs?: number;
@@ -364,7 +364,7 @@ export interface PerfSnapshotInput {
364
364
  sessionId?: string | null;
365
365
  /** Origin of the snapshot: "app:<id>", "tab:<id>", or "electron-main". */
366
366
  source?: string | null;
367
- trigger: "cpu-pressure" | "dropped-frames" | "long-task" | "page-unload" | "self-snapshot" | "app-quit";
367
+ trigger: "cpu-pressure" | "dropped-frames" | "long-task" | "page-unload" | "page-load" | "self-snapshot" | "app-quit";
368
368
  cpuPressure?: number | null;
369
369
  eventLoopLagMs?: number | null;
370
370
  heapUsedMb?: number | null;
@@ -1918,7 +1918,7 @@ export function createWorkersApp<R extends AppRegistryBase>(
1918
1918
  },
1919
1919
  perfSnapshotCaptureNoAuth: async (userId, rawInput) => {
1920
1920
  const input = (rawInput ?? {}) as {
1921
- trigger: 'cpu-pressure' | 'dropped-frames' | 'long-task' | 'page-unload' | 'self-snapshot' | 'app-quit';
1921
+ trigger: 'cpu-pressure' | 'dropped-frames' | 'long-task' | 'page-unload' | 'page-load' | 'self-snapshot' | 'app-quit';
1922
1922
  source?: string;
1923
1923
  cpuPressure?: number;
1924
1924
  eventLoopLagMs?: number;
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import { makeBetterSqliteExec, type SqliteExec } from './SqliteExec.js';
3
+ import {
4
+ ensureSqliteTable,
5
+ sqliteInsertRaw,
6
+ sqliteGetDoc,
7
+ sqliteGetDocs,
8
+ sqliteFindCount,
9
+ sqliteSetDoc,
10
+ sqliteSetDocFields,
11
+ sqliteSetDocOp,
12
+ sqliteSetDocFieldsOrIgnore,
13
+ sqliteDeleteDoc,
14
+ sqliteDeleteDocs,
15
+ sqliteGetQuery,
16
+ sqliteGetQueryCount,
17
+ } from './SqliteDoc.js';
18
+
19
+ let exec: SqliteExec;
20
+
21
+ beforeAll(async () => {
22
+ exec = await makeBetterSqliteExec(); // in-memory
23
+ await ensureSqliteTable(exec, 'post');
24
+ await sqliteInsertRaw(exec, 'post', { _id: 'a', title: 'hello', n: 10 });
25
+ await sqliteInsertRaw(exec, 'post', { _id: 'b', title: 'world', n: 9 });
26
+ await sqliteInsertRaw(exec, 'post', { _id: 'c', title: '' });
27
+ });
28
+
29
+ describe('SqliteDoc read path', () => {
30
+ it('getDoc returns a hydrated doc with Date meta', async () => {
31
+ const doc = await sqliteGetDoc<{ _id: string; title: string; created: Date }>(
32
+ exec,
33
+ 'post',
34
+ 'a',
35
+ );
36
+ expect(doc?._id).toBe('a');
37
+ expect(doc?.title).toBe('hello');
38
+ expect(doc?.created).toBeInstanceOf(Date);
39
+ });
40
+
41
+ it('getDoc returns null for a missing id', async () => {
42
+ expect(await sqliteGetDoc(exec, 'post', 'zzz')).toBeNull();
43
+ });
44
+
45
+ it('getDocs filters by empty-string field (distinct from missing)', async () => {
46
+ const rows = await sqliteGetDocs<{ _id: string }>(exec, 'post', { title: '' });
47
+ expect(rows.map((r) => r._id)).toEqual(['c']);
48
+ });
49
+
50
+ it('getDocs sorts ascending with nulls last', async () => {
51
+ const rows = await sqliteGetDocs<{ _id: string }>(exec, 'post', {}, {
52
+ sort: { n: 1 },
53
+ });
54
+ // 'c' has no n (NULL) → last; '10' < '9' lexically (matches PG) → a before b.
55
+ expect(rows.map((r) => r._id)).toEqual(['a', 'b', 'c']);
56
+ });
57
+
58
+ it('findCount counts matches', async () => {
59
+ expect(await sqliteFindCount(exec, 'post', { title: { $exists: true } })).toBe(3);
60
+ });
61
+ });
62
+
63
+ describe('SqliteDoc write path', () => {
64
+ it('setDoc inserts then replaces by _id', async () => {
65
+ await ensureSqliteTable(exec, 'w');
66
+ expect(await sqliteSetDoc(exec, 'w', { _id: 'x', v: 1 })).toBe(true);
67
+ await sqliteSetDoc(exec, 'w', { _id: 'x', v: 2 });
68
+ const doc = await sqliteGetDoc<{ v: number }>(exec, 'w', 'x');
69
+ expect(doc?.v).toBe(2);
70
+ });
71
+
72
+ it('setDoc skipIfExists returns false and keeps the original', async () => {
73
+ await ensureSqliteTable(exec, 'w2');
74
+ await sqliteSetDoc(exec, 'w2', { _id: 'y', v: 1 });
75
+ expect(
76
+ await sqliteSetDoc(exec, 'w2', { _id: 'y', v: 9 }, { skipIfExists: true }),
77
+ ).toBe(false);
78
+ expect((await sqliteGetDoc<{ v: number }>(exec, 'w2', 'y'))?.v).toBe(1);
79
+ });
80
+
81
+ it('setDocFields updates and bumps version', async () => {
82
+ await ensureSqliteTable(exec, 'w3');
83
+ await sqliteSetDoc(exec, 'w3', { _id: 'z', a: 1, version: 1 });
84
+ const updated = await sqliteSetDocFields<{ a: number; b: number; version: number }>(
85
+ exec,
86
+ 'w3',
87
+ 'z',
88
+ { b: 2 },
89
+ );
90
+ expect(updated).toMatchObject({ a: 1, b: 2 });
91
+ expect(updated.version).toBe(2);
92
+ });
93
+
94
+ it('setDocFields throws when the doc is missing', async () => {
95
+ await ensureSqliteTable(exec, 'w4');
96
+ await expect(sqliteSetDocFields(exec, 'w4', 'nope', { a: 1 })).rejects.toThrow();
97
+ });
98
+
99
+ it('setDocFieldsOrIgnore returns null when missing', async () => {
100
+ await ensureSqliteTable(exec, 'w5');
101
+ expect(await sqliteSetDocFieldsOrIgnore(exec, 'w5', 'nope', { a: 1 })).toBeNull();
102
+ });
103
+
104
+ it('setDocOp $inc increments', async () => {
105
+ await ensureSqliteTable(exec, 'w6');
106
+ await sqliteSetDoc(exec, 'w6', { _id: 'c', n: 5 });
107
+ const r = await sqliteSetDocOp<{ n: number }>(exec, 'w6', 'c', { $inc: { n: 3 } });
108
+ expect(r.n).toBe(8);
109
+ });
110
+
111
+ it('deleteDoc removes a row; deleteDocs returns matched ids', async () => {
112
+ await ensureSqliteTable(exec, 'w7');
113
+ await sqliteSetDoc(exec, 'w7', { _id: 'd1', kind: 'a' });
114
+ await sqliteSetDoc(exec, 'w7', { _id: 'd2', kind: 'a' });
115
+ await sqliteSetDoc(exec, 'w7', { _id: 'd3', kind: 'b' });
116
+ await sqliteDeleteDoc(exec, 'w7', 'd1');
117
+ expect(await sqliteGetDoc(exec, 'w7', 'd1')).toBeNull();
118
+ const removed = await sqliteDeleteDocs(exec, 'w7', { kind: 'a' });
119
+ expect(removed).toEqual(['d2']);
120
+ });
121
+ });
122
+
123
+ describe('SqliteDoc getQuery', () => {
124
+ it('groups and sums', async () => {
125
+ await ensureSqliteTable(exec, 'agg');
126
+ await sqliteInsertRaw(exec, 'agg', { _id: 'a1', kind: 'a', amt: 10 });
127
+ await sqliteInsertRaw(exec, 'agg', { _id: 'a2', kind: 'a', amt: 20 });
128
+ await sqliteInsertRaw(exec, 'agg', { _id: 'b1', kind: 'b', amt: 5 });
129
+ const rows = await sqliteGetQuery<{ _id: string; total: number }>(exec, 'agg', [
130
+ { $group: { _id: '$kind', total: { $sum: '$amt' } } },
131
+ ]);
132
+ const byId = Object.fromEntries(rows.map((r) => [r._id, r.total]));
133
+ expect(byId).toEqual({ a: 30, b: 5 });
134
+ });
135
+
136
+ it('getQueryCount counts matches', async () => {
137
+ await ensureSqliteTable(exec, 'agg2');
138
+ await sqliteInsertRaw(exec, 'agg2', { _id: 'x1', k: 'a' });
139
+ await sqliteInsertRaw(exec, 'agg2', { _id: 'x2', k: 'a' });
140
+ await sqliteInsertRaw(exec, 'agg2', { _id: 'x3', k: 'b' });
141
+ expect(
142
+ await sqliteGetQueryCount(exec, 'agg2', [{ $match: { k: 'a' } }]),
143
+ ).toBe(2);
144
+ });
145
+ });
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Document read operations on a SQLite/D1 backend. Sibling of PostgresDoc.ts.
3
+ * Pure builders + an injected SqliteExec — no driver import here. FTS/vector
4
+ * are intentionally absent (a db:'d1' collection may not declare search/vector).
5
+ */
6
+ import { translateFilter, translateSort } from './SqliteFilter.js';
7
+ import { translatePipeline } from './SqlitePipeline.js';
8
+ import { applyOpInMemory, type UpdateOp } from './sqliteUpdateOps.js';
9
+ import { ensureSqliteIndexes } from './SqliteSchema.js';
10
+ import type { IndexDef } from '../../shared/DB.js';
11
+ import type { SqliteExec } from './SqliteExec.js';
12
+
13
+ interface RawRow {
14
+ _id: string;
15
+ data: string;
16
+ created: number;
17
+ updated: number;
18
+ version: number;
19
+ }
20
+
21
+ function toDoc<T>(row: RawRow): T {
22
+ const data = JSON.parse(row.data) as Record<string, unknown>;
23
+ return {
24
+ ...data,
25
+ _id: row._id,
26
+ created: new Date(row.created),
27
+ updated: new Date(row.updated),
28
+ version: row.version,
29
+ } as T;
30
+ }
31
+
32
+ const VALID_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
33
+ function assertSafeName(name: string): void {
34
+ if (!VALID_NAME.test(name)) throw new Error(`Invalid collection name: ${name}`);
35
+ }
36
+
37
+ export async function ensureSqliteTable(
38
+ exec: SqliteExec,
39
+ collection: string,
40
+ indexes?: IndexDef[],
41
+ ): Promise<void> {
42
+ assertSafeName(collection);
43
+ await exec.run(
44
+ `CREATE TABLE IF NOT EXISTS "${collection}" (
45
+ _id TEXT PRIMARY KEY,
46
+ data TEXT NOT NULL,
47
+ created INTEGER NOT NULL,
48
+ updated INTEGER NOT NULL,
49
+ version INTEGER NOT NULL
50
+ )`,
51
+ );
52
+ if (indexes && indexes.length > 0) {
53
+ await ensureSqliteIndexes(exec, collection, indexes);
54
+ }
55
+ }
56
+
57
+ /** Seed helper (full writes/upserts land in Plan 2). Stores JSON text + epoch-ms meta. */
58
+ export async function sqliteInsertRaw(
59
+ exec: SqliteExec,
60
+ collection: string,
61
+ doc: Record<string, unknown> & { _id: string },
62
+ ): Promise<void> {
63
+ const now = Date.now();
64
+ const { _id, created, updated, version, ...rest } = doc as Record<
65
+ string,
66
+ unknown
67
+ > & { _id: string };
68
+ await exec.run(
69
+ `INSERT INTO "${collection}" (_id, data, created, updated, version)
70
+ VALUES (?, ?, ?, ?, ?)
71
+ ON CONFLICT(_id) DO UPDATE SET data=excluded.data, updated=excluded.updated, version=excluded.version`,
72
+ [
73
+ _id,
74
+ JSON.stringify(rest),
75
+ typeof created === 'number' ? created : now,
76
+ typeof updated === 'number' ? updated : now,
77
+ typeof version === 'number' ? version : 1,
78
+ ],
79
+ );
80
+ }
81
+
82
+ export async function sqliteGetDoc<T>(
83
+ exec: SqliteExec,
84
+ collection: string,
85
+ id: string,
86
+ ): Promise<T | null> {
87
+ const rows = await exec.all<RawRow>(
88
+ `SELECT _id, data, created, updated, version FROM "${collection}" WHERE _id = ?`,
89
+ [id],
90
+ );
91
+ return rows.length === 0 ? null : toDoc<T>(rows[0]!);
92
+ }
93
+
94
+ export interface SqliteGetDocsOptions {
95
+ sort?: Record<string, 1 | -1>;
96
+ limit?: number;
97
+ skip?: number;
98
+ }
99
+
100
+ export async function sqliteGetDocs<T>(
101
+ exec: SqliteExec,
102
+ collection: string,
103
+ filter?: Record<string, unknown>,
104
+ options?: SqliteGetDocsOptions,
105
+ ): Promise<T[]> {
106
+ const { where, values } = translateFilter(filter ?? {});
107
+ let sql = `SELECT _id, data, created, updated, version FROM "${collection}"`;
108
+ if (where) sql += ` WHERE ${where}`;
109
+ if (options?.sort) sql += ` ORDER BY ${translateSort(options.sort)}`;
110
+ if (options?.limit !== undefined) {
111
+ sql += ` LIMIT ?`;
112
+ values.push(options.limit);
113
+ }
114
+ if (options?.skip !== undefined) {
115
+ // SQLite requires a LIMIT before OFFSET; use -1 (unlimited) when only skip is set.
116
+ if (options.limit === undefined) sql += ` LIMIT -1`;
117
+ sql += ` OFFSET ?`;
118
+ values.push(options.skip);
119
+ }
120
+ const rows = await exec.all<RawRow>(sql, values);
121
+ return rows.map((r) => toDoc<T>(r));
122
+ }
123
+
124
+ export async function sqliteFind<T>(
125
+ exec: SqliteExec,
126
+ collection: string,
127
+ filter?: Record<string, unknown>,
128
+ options?: SqliteGetDocsOptions,
129
+ ): Promise<T[]> {
130
+ return sqliteGetDocs<T>(exec, collection, filter, options);
131
+ }
132
+
133
+ export async function sqliteFindCount(
134
+ exec: SqliteExec,
135
+ collection: string,
136
+ filter?: Record<string, unknown>,
137
+ ): Promise<number> {
138
+ const { where, values } = translateFilter(filter ?? {});
139
+ let sql = `SELECT COUNT(*) AS count FROM "${collection}"`;
140
+ if (where) sql += ` WHERE ${where}`;
141
+ const rows = await exec.all<{ count: number }>(sql, values);
142
+ return rows[0]?.count ?? 0;
143
+ }
144
+
145
+ export async function sqliteFindRandom<T>(
146
+ exec: SqliteExec,
147
+ collection: string,
148
+ filter?: Record<string, unknown>,
149
+ limit?: number,
150
+ ): Promise<T[]> {
151
+ const { where, values } = translateFilter(filter ?? {});
152
+ let sql = `SELECT _id, data, created, updated, version FROM "${collection}"`;
153
+ if (where) sql += ` WHERE ${where}`;
154
+ sql += ` ORDER BY RANDOM() LIMIT ?`;
155
+ values.push(limit ?? 100);
156
+ const rows = await exec.all<RawRow>(sql, values);
157
+ return rows.map((r) => toDoc<T>(r));
158
+ }
159
+
160
+ // ── Writes ────────────────────────────────────────────────────────────────
161
+
162
+ export async function sqliteSetDoc(
163
+ exec: SqliteExec,
164
+ collection: string,
165
+ doc: Record<string, unknown> & { _id: string },
166
+ options?: { skipIfExists?: boolean },
167
+ ): Promise<boolean> {
168
+ const now = Date.now();
169
+ const { _id, created, updated, version, ...rest } = doc;
170
+ const c = typeof created === 'number' ? created : now;
171
+ const u = typeof updated === 'number' ? updated : now;
172
+ const v = typeof version === 'number' ? version : 1;
173
+ const json = JSON.stringify(rest);
174
+ if (options?.skipIfExists) {
175
+ const before = await exec.all<{ n: number }>(
176
+ `SELECT COUNT(*) AS n FROM "${collection}" WHERE _id = ?`,
177
+ [_id],
178
+ );
179
+ if ((before[0]?.n ?? 0) > 0) return false;
180
+ await exec.run(
181
+ `INSERT INTO "${collection}" (_id, data, created, updated, version) VALUES (?, ?, ?, ?, ?)
182
+ ON CONFLICT(_id) DO NOTHING`,
183
+ [_id, json, c, u, v],
184
+ );
185
+ return true;
186
+ }
187
+ await exec.run(
188
+ `INSERT INTO "${collection}" (_id, data, created, updated, version) VALUES (?, ?, ?, ?, ?)
189
+ ON CONFLICT(_id) DO UPDATE SET data=excluded.data, updated=excluded.updated, version=excluded.version`,
190
+ [_id, json, c, u, v],
191
+ );
192
+ return true;
193
+ }
194
+
195
+ export async function sqliteApplyUpdateOp<T>(
196
+ exec: SqliteExec,
197
+ collection: string,
198
+ id: string,
199
+ op: UpdateOp,
200
+ ): Promise<T | null> {
201
+ const rows = await exec.all<RawRow>(
202
+ `SELECT _id, data, created, updated, version FROM "${collection}" WHERE _id = ?`,
203
+ [id],
204
+ );
205
+ if (rows.length === 0) return null;
206
+ const row = rows[0]!;
207
+ const nextData = applyOpInMemory(
208
+ JSON.parse(row.data) as Record<string, unknown>,
209
+ op,
210
+ );
211
+ const nextVersion = row.version + 1;
212
+ const now = Date.now();
213
+ await exec.run(
214
+ `UPDATE "${collection}" SET data = ?, updated = ?, version = ? WHERE _id = ?`,
215
+ [JSON.stringify(nextData), now, nextVersion, id],
216
+ );
217
+ return {
218
+ ...nextData,
219
+ _id: row._id,
220
+ created: new Date(row.created),
221
+ updated: new Date(now),
222
+ version: nextVersion,
223
+ } as T;
224
+ }
225
+
226
+ export async function sqliteSetDocFields<T>(
227
+ exec: SqliteExec,
228
+ collection: string,
229
+ id: string,
230
+ fields: Record<string, unknown>,
231
+ ): Promise<T> {
232
+ const r = await sqliteApplyUpdateOp<T>(exec, collection, id, { $set: fields });
233
+ if (r === null) throw new Error(`Document not found: ${collection}/${id}`);
234
+ return r;
235
+ }
236
+
237
+ export async function sqliteSetDocFieldsOrIgnore<T>(
238
+ exec: SqliteExec,
239
+ collection: string,
240
+ id: string,
241
+ fields: Record<string, unknown>,
242
+ ): Promise<T | null> {
243
+ return sqliteApplyUpdateOp<T>(exec, collection, id, { $set: fields });
244
+ }
245
+
246
+ export async function sqliteSetDocFieldsOrCreate<T extends { _id: string }>(
247
+ exec: SqliteExec,
248
+ collection: string,
249
+ id: string,
250
+ fields: Record<string, unknown>,
251
+ obj: T,
252
+ ): Promise<T> {
253
+ const existing = await sqliteApplyUpdateOp<T>(exec, collection, id, {
254
+ $set: fields,
255
+ });
256
+ if (existing !== null) return existing;
257
+ await sqliteSetDoc(
258
+ exec,
259
+ collection,
260
+ obj as Record<string, unknown> & { _id: string },
261
+ { skipIfExists: true },
262
+ );
263
+ return obj;
264
+ }
265
+
266
+ export async function sqliteSetDocOp<T>(
267
+ exec: SqliteExec,
268
+ collection: string,
269
+ id: string,
270
+ op: UpdateOp,
271
+ ): Promise<T> {
272
+ const r = await sqliteApplyUpdateOp<T>(exec, collection, id, op);
273
+ if (r === null) throw new Error(`Document not found: ${collection}/${id}`);
274
+ return r;
275
+ }
276
+
277
+ export async function sqliteSetDocOpOrIgnore<T>(
278
+ exec: SqliteExec,
279
+ collection: string,
280
+ id: string,
281
+ op: UpdateOp,
282
+ ): Promise<T | null> {
283
+ return sqliteApplyUpdateOp<T>(exec, collection, id, op);
284
+ }
285
+
286
+ export async function sqliteDeleteDoc(
287
+ exec: SqliteExec,
288
+ collection: string,
289
+ id: string,
290
+ ): Promise<void> {
291
+ await exec.run(`DELETE FROM "${collection}" WHERE _id = ?`, [id]);
292
+ }
293
+
294
+ export async function sqliteDeleteDocs(
295
+ exec: SqliteExec,
296
+ collection: string,
297
+ filter: Record<string, unknown>,
298
+ ): Promise<string[]> {
299
+ const { where, values } = translateFilter(filter);
300
+ let selectSql = `SELECT _id FROM "${collection}"`;
301
+ if (where) selectSql += ` WHERE ${where}`;
302
+ const matched = await exec.all<{ _id: string }>(selectSql, values);
303
+ const ids = matched.map((r) => r._id);
304
+ let deleteSql = `DELETE FROM "${collection}"`;
305
+ if (where) deleteSql += ` WHERE ${where}`;
306
+ await exec.run(deleteSql, values);
307
+ return ids;
308
+ }
309
+
310
+ export async function sqliteDeleteWhere(
311
+ exec: SqliteExec,
312
+ collection: string,
313
+ filter: Record<string, unknown>,
314
+ ): Promise<string[]> {
315
+ return sqliteDeleteDocs(exec, collection, filter);
316
+ }
317
+
318
+ // ── Aggregation pipeline ────────────────────────────────────────────────────
319
+
320
+ export async function sqliteGetQuery<T>(
321
+ exec: SqliteExec,
322
+ collection: string,
323
+ pipeline: Record<string, unknown>[],
324
+ options?: { skip?: number; limit?: number },
325
+ ): Promise<T[]> {
326
+ const { sql, values } = translatePipeline(collection, pipeline, options);
327
+ const rows = await exec.all<Record<string, unknown>>(sql, values);
328
+ return rows.map((row) =>
329
+ row.data !== undefined && row._id !== undefined && row.created !== undefined
330
+ ? toDoc<T>(row as unknown as RawRow)
331
+ : (row as T),
332
+ );
333
+ }
334
+
335
+ export async function sqliteGetQueryCount(
336
+ exec: SqliteExec,
337
+ collection: string,
338
+ pipeline: Record<string, unknown>[],
339
+ ): Promise<number> {
340
+ const { sql, values } = translatePipeline(collection, [
341
+ ...pipeline,
342
+ { $count: 'count' },
343
+ ]);
344
+ const rows = await exec.all<{ count: number }>(sql, values);
345
+ return Number(rows[0]?.count ?? 0);
346
+ }
347
+
348
+ export async function sqliteGetQueryRaw<T>(
349
+ exec: SqliteExec,
350
+ collection: string,
351
+ pipeline: Record<string, unknown>[],
352
+ options?: { skip?: number; limit?: number },
353
+ ): Promise<T[]> {
354
+ const { sql, values } = translatePipeline(collection, pipeline, options);
355
+ return (await exec.all(sql, values)) as T[];
356
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The single SQL primitive the SQLite doc store runs against. Two impls:
3
+ * - better-sqlite3 (Node dev / tests) — built by makeBetterSqliteExec below.
4
+ * - Cloudflare D1 (prod) — built from the binding in the Workers adapter
5
+ * (added in a later plan); its .prepare().bind().all()/run() maps 1:1.
6
+ * SqliteDoc.ts depends ONLY on this interface, never on a concrete driver.
7
+ */
8
+ export interface SqliteExec {
9
+ all<T = Record<string, unknown>>(
10
+ sql: string,
11
+ params?: readonly unknown[],
12
+ ): Promise<T[]>;
13
+ run(sql: string, params?: readonly unknown[]): Promise<void>;
14
+ }
15
+
16
+ /**
17
+ * Node-only executor over better-sqlite3. Uses a DYNAMIC import so the native
18
+ * addon is never pulled into a Workers bundle (see Global Constraints). `:memory:`
19
+ * by default. better-sqlite3 is synchronous; we wrap results in resolved promises.
20
+ */
21
+ export async function makeBetterSqliteExec(
22
+ filename = ':memory:',
23
+ ): Promise<SqliteExec> {
24
+ const { default: Database } = await import('better-sqlite3');
25
+ const db = new Database(filename);
26
+ db.pragma('journal_mode = WAL');
27
+ return {
28
+ async all<T>(sql: string, params: readonly unknown[] = []): Promise<T[]> {
29
+ return db.prepare(sql).all(...(params as unknown[])) as T[];
30
+ },
31
+ async run(sql: string, params: readonly unknown[] = []): Promise<void> {
32
+ db.prepare(sql).run(...(params as unknown[]));
33
+ },
34
+ };
35
+ }