Repository navigation
Expand file tree
/
Copy pathDatabaseMaintenance.test.ts
More file actions
262 lines (248 loc) · 12.9 KB
/
Copy pathDatabaseMaintenance.test.ts
File metadata and controls
262 lines (248 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Clock from "effect/Clock";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as SqlClient from "effect/sql/SqlClient";
import * as DatabaseMaintenance from "./DatabaseMaintenance.ts";
import * as Sqlite from "./Sqlite.ts";
it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer)))(
"DatabaseMaintenance",
(it) => {
it.effect("reports an unreadable database as an inspection failure", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance;
const dbPath = path.join(
yield* fs.makeTempDirectoryScoped({ prefix: "t3-inspect-" }),
"statev2.sqlite",
);
yield* fs.writeFileString(dbPath, "not a database");
const failure = yield* Effect.flip(maintenance.inspect(dbPath));
assert.equal(failure._tag, "DatabaseInspectError");
}).pipe(Effect.scoped),
);
it.effect("reports and deletes only the database from before 0.1.0", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance;
const userdata = yield* fs.makeTempDirectoryScoped({ prefix: "t3-legacy-" });
const dbPath = path.join(userdata, "statev2.sqlite");
const legacy = path.join(userdata, "state.sqlite");
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`CREATE TABLE retained_data (value TEXT)`;
yield* sql`INSERT INTO retained_data VALUES ('keep')`;
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath })));
assert.isNull((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes);
const openLegacy = NodeSqliteClient.layer({ filename: legacy });
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`PRAGMA journal_mode = WAL`;
yield* sql`CREATE TABLE v1_data (value TEXT)`;
yield* sql`INSERT INTO v1_data VALUES ('rollback')`;
}).pipe(Effect.provide(openLegacy));
assert.isAbove((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes ?? 0, 0);
const refused = yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`SELECT value FROM v1_data`;
const failure = yield* Effect.flip(maintenance.removeLegacyDatabase(dbPath));
assert.deepEqual(yield* sql`SELECT value FROM v1_data`, [{ value: "rollback" }]);
return failure;
}).pipe(Effect.provide(openLegacy));
assert.equal(refused._tag, "DatabaseInUseError");
assert.isTrue(yield* fs.exists(legacy));
yield* Effect.flatMap(DatabaseMaintenance.DatabaseMaintenance, (service) =>
service.removeLegacyDatabase(dbPath),
).pipe(
Effect.provide(Layer.fresh(DatabaseMaintenance.layer)),
Effect.provideService(HostProcess.Platform, "win32"),
Effect.provideService(FileSystem.FileSystem, {
...fs,
remove: (target, options) =>
Effect.gen(function* () {
if (target === legacy) {
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`PRAGMA busy_timeout = 0`;
yield* sql`BEGIN EXCLUSIVE`;
yield* sql`COMMIT`;
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacy })));
// Create sidecars after the probe closes so only Doctor can remove them.
yield* fs.writeFileString(`${legacy}-wal`, "leftover WAL");
yield* fs.writeFileString(`${legacy}-shm`, "leftover SHM");
}
yield* fs.remove(target, options);
}),
}),
);
assert.isFalse(yield* fs.exists(legacy));
assert.isFalse(yield* fs.exists(`${legacy}-wal`));
assert.isFalse(yield* fs.exists(`${legacy}-shm`));
assert.isTrue(yield* fs.exists(dbPath));
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
assert.deepEqual(yield* sql`SELECT value FROM retained_data`, [{ value: "keep" }]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath, readonly: true })));
assert.isNull((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes);
}).pipe(Effect.scoped),
);
it.effect("runs a repair requested from Settings once and keeps its failure for Settings", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-requested-reclaim-" });
const dbPath = path.join(baseDir, "userdata", "statev2.sqlite");
const files = DatabaseMaintenance.reclaimFiles(path, dbPath);
yield* Effect.void.pipe(Effect.provide(Sqlite.layerFromPath(dbPath)));
const autoVacuum = Effect.flatMap(
SqlClient.SqlClient,
(sql) => sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`,
).pipe(
Effect.map((rows) => rows[0]?.auto_vacuum),
Effect.provide(NodeSqliteClient.layer({ filename: dbPath, readonly: true })),
);
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`PRAGMA auto_vacuum = NONE`;
yield* sql`VACUUM`;
yield* sql`CREATE TABLE retained_data (value TEXT)`;
yield* sql`INSERT INTO retained_data VALUES ('keep')`;
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath })));
// Every server exit runs this; without a request it must leave the database alone.
yield* DatabaseMaintenance.runRequestedReclaim(dbPath);
assert.equal(yield* autoVacuum, 0);
yield* fs.writeFileString(files.request, "");
yield* fs.writeFileString(files.failure, "an earlier failure");
yield* DatabaseMaintenance.runRequestedReclaim(dbPath);
assert.isFalse(yield* fs.exists(files.request));
assert.isFalse(yield* fs.exists(files.failure));
assert.equal(yield* autoVacuum, 2);
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
assert.deepEqual(yield* sql`SELECT value FROM retained_data`, [{ value: "keep" }]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath, readonly: true })));
const brokenPath = path.join(baseDir, "broken", "statev2.sqlite");
const broken = DatabaseMaintenance.reclaimFiles(path, brokenPath);
yield* fs.makeDirectory(path.dirname(brokenPath));
yield* fs.writeFileString(brokenPath, "not a database");
yield* fs.writeFileString(broken.request, "");
yield* DatabaseMaintenance.runRequestedReclaim(brokenPath);
assert.isFalse(
yield* fs.exists(broken.request),
"a failed repair must not rerun on every exit",
);
assert.equal(
yield* fs.readFileString(broken.failure),
new DatabaseMaintenance.DatabaseCompactError({ path: brokenPath, cause: null }).message,
);
}).pipe(Effect.scoped),
);
it.effect("refuses a repair whose copies would not fit on disk", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dbPath = path.join(
yield* fs.makeTempDirectoryScoped({ prefix: "t3-reclaim-space-" }),
"statev2.sqlite",
);
assert.isNull(yield* DatabaseMaintenance.reclaimSpaceShortfall(dbPath, 1024));
const huge = 2 ** 60;
const shortfall = yield* DatabaseMaintenance.reclaimSpaceShortfall(dbPath, huge);
assert.isAtLeast(shortfall?.neededBytes ?? 0, huge);
assert.isBelow(shortfall?.freeBytes ?? Infinity, huge);
}).pipe(Effect.scoped),
);
it.effect("compacts an existing database offline only once its server has stopped", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-compact-" });
const dbPath = path.join(baseDir, "userdata", "statev2.sqlite");
// Opening the database migrates it.
yield* Effect.void.pipe(Effect.provide(Sqlite.layerFromPath(dbPath)));
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`PRAGMA auto_vacuum = NONE`;
yield* sql`VACUUM`;
yield* sql`CREATE TABLE retained_data (value TEXT)`;
yield* sql`INSERT INTO retained_data VALUES ('keep')`;
yield* sql`CREATE TABLE discarded_data (value BLOB)`;
yield* sql`INSERT INTO discarded_data VALUES (zeroblob(4194304))`;
yield* sql`DROP TABLE discarded_data`;
yield* sql`
INSERT INTO orchestration_v2_projection_messages (
message_id, thread_id, run_id, node_id, role, streaming, created_at, updated_at, payload_json
)
VALUES (
'message-1', 'thread-1', NULL, NULL, 'user', 0,
'2026-09-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', '{"text":"needle"}'
)
`;
// An index that drifted from its rows, as a renumbering VACUUM would leave it.
yield* sql`
INSERT INTO orchestration_v2_message_search (orchestration_v2_message_search)
VALUES ('delete-all')
`;
yield* sql`PRAGMA wal_checkpoint(TRUNCATE)`;
yield* sql`INSERT INTO orchestration_v2_legacy_imports (
thread_id, source_updated_at, shell_imported_at, transcript_imported_at, imported_message_count
) VALUES ('imported', '2026-10-17', '2026-10-17', '2026-10-17', 0)`;
yield* sql`WITH RECURSIVE history(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 51
) INSERT INTO orchestration_command_receipts (
command_id, aggregate_kind, aggregate_id, accepted_at, result_sequence, status, command_type
) SELECT 'legacy-' || n, 'thread', 'imported', '2026-10-17', 0, 'accepted', 'legacy' FROM history`;
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath })));
const sizeBefore = (yield* fs.stat(dbPath)).size;
const refused = yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`SELECT value FROM retained_data`;
return yield* maintenance.compact(dbPath).pipe(Effect.flip);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath })));
assert.equal(refused._tag, "DatabaseInUseError");
assert.equal((yield* fs.stat(dbPath)).size, sizeBefore);
const clock = yield* Clock.Clock;
const pauses: number[] = [];
let activeNanos = 0n;
const result = yield* maintenance.compact(dbPath).pipe(
Effect.provideService(Clock.Clock, {
...clock,
// Exceed the background budget whenever the compactor checks elapsed time.
monotonicTimeNanos: Effect.sync(() => (activeNanos += 30_000_000n)),
sleep: (duration) =>
Effect.sync(() => {
pauses.push(Duration.toMillis(duration));
}),
}),
);
assert.deepEqual(pauses, [], "offline doctor must not throttle compaction");
assert.equal(result.deletedReceiptCount, 51);
assert.isBelow(result.afterBytes, result.beforeBytes - 3 * 1024 * 1024);
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
assert.deepEqual(yield* sql`PRAGMA auto_vacuum`, [{ auto_vacuum: 2 }]);
assert.deepEqual(yield* sql`SELECT value FROM retained_data`, [{ value: "keep" }]);
assert.deepEqual(
yield* sql`
SELECT messages.message_id AS "messageId"
FROM orchestration_v2_message_search AS search
INNER JOIN orchestration_v2_projection_messages AS messages
ON messages.rowid = search.rowid
WHERE orchestration_v2_message_search MATCH '"needle"'
`,
[{ messageId: "message-1" }],
);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath, readonly: true })));
}).pipe(Effect.scoped),
);
},
);