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.
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/inspect/agent/perfAgent.d.ts +8 -0
- package/dist/inspect/agent/perfAgent.d.ts.map +1 -1
- package/dist/inspect/agent/perfAgent.js +69 -6
- package/dist/inspect/agent/perfAgent.js.map +1 -1
- package/dist/inspect/agent-bundle.js +3 -3
- package/dist/inspect/host-bundle.js +8 -8
- package/dist/inspect/protocol.d.ts +3 -1
- package/dist/inspect/protocol.d.ts.map +1 -1
- package/dist/inspect/protocol.js.map +1 -1
- package/dist/server/Logging.d.ts +1 -1
- package/dist/server/Logging.d.ts.map +1 -1
- package/dist/server/sqlite/SqliteDoc.d.ts +44 -0
- package/dist/server/sqlite/SqliteDoc.d.ts.map +1 -0
- package/dist/server/sqlite/SqliteDoc.js +201 -0
- package/dist/server/sqlite/SqliteDoc.js.map +1 -0
- package/dist/server/sqlite/SqliteExec.d.ts +18 -0
- package/dist/server/sqlite/SqliteExec.d.ts.map +1 -0
- package/dist/server/sqlite/SqliteExec.js +19 -0
- package/dist/server/sqlite/SqliteExec.js.map +1 -0
- package/dist/server/sqlite/SqliteFilter.d.ts +21 -0
- package/dist/server/sqlite/SqliteFilter.d.ts.map +1 -0
- package/dist/server/sqlite/SqliteFilter.js +143 -0
- package/dist/server/sqlite/SqliteFilter.js.map +1 -0
- package/dist/server/sqlite/SqlitePipeline.d.ts +8 -0
- package/dist/server/sqlite/SqlitePipeline.d.ts.map +1 -0
- package/dist/server/sqlite/SqlitePipeline.js +154 -0
- package/dist/server/sqlite/SqlitePipeline.js.map +1 -0
- package/dist/server/sqlite/SqliteSchema.d.ts +15 -0
- package/dist/server/sqlite/SqliteSchema.d.ts.map +1 -0
- package/dist/server/sqlite/SqliteSchema.js +35 -0
- package/dist/server/sqlite/SqliteSchema.js.map +1 -0
- package/dist/server/sqlite/assertIndexed.d.ts +20 -0
- package/dist/server/sqlite/assertIndexed.d.ts.map +1 -0
- package/dist/server/sqlite/assertIndexed.js +43 -0
- package/dist/server/sqlite/assertIndexed.js.map +1 -0
- package/dist/server/sqlite/sqliteUpdateOps.d.ts +17 -0
- package/dist/server/sqlite/sqliteUpdateOps.d.ts.map +1 -0
- package/dist/server/sqlite/sqliteUpdateOps.js +100 -0
- package/dist/server/sqlite/sqliteUpdateOps.js.map +1 -0
- package/dist/shared/FrameworkRequests.d.ts +1 -1
- package/dist/shared/FrameworkRequests.d.ts.map +1 -1
- package/dist/shared/FrameworkRequests.js +1 -0
- package/dist/shared/FrameworkRequests.js.map +1 -1
- package/package.json +3 -1
- package/src/cli/version.ts +1 -1
- package/src/inspect/agent/perfAgent.test.ts +24 -0
- package/src/inspect/agent/perfAgent.ts +67 -6
- package/src/inspect/protocol.ts +3 -1
- package/src/server/App.ts +1 -1
- package/src/server/Logging.ts +1 -1
- package/src/server/adapter/workers/createWorkersApp.ts +1 -1
- package/src/server/sqlite/SqliteDoc.test.ts +145 -0
- package/src/server/sqlite/SqliteDoc.ts +356 -0
- package/src/server/sqlite/SqliteExec.ts +35 -0
- package/src/server/sqlite/SqliteFilter.test.ts +110 -0
- package/src/server/sqlite/SqliteFilter.ts +161 -0
- package/src/server/sqlite/SqlitePipeline.test.ts +54 -0
- package/src/server/sqlite/SqlitePipeline.ts +172 -0
- package/src/server/sqlite/SqliteSchema.test.ts +64 -0
- package/src/server/sqlite/SqliteSchema.ts +56 -0
- package/src/server/sqlite/assertIndexed.test.ts +58 -0
- package/src/server/sqlite/assertIndexed.ts +58 -0
- package/src/server/sqlite/sqliteUpdateOps.test.ts +74 -0
- package/src/server/sqlite/sqliteUpdateOps.ts +111 -0
- 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
|
/**
|
package/src/inspect/protocol.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/server/Logging.ts
CHANGED
|
@@ -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
|
+
}
|