@hyperdrive.bot/fleet-server 0.3.164 → 0.3.165

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 (55) hide show
  1. package/dist/server/extensions/daemon-backend.js +25 -0
  2. package/dist/server/server/agent/agent-storage.d.ts +67 -12
  3. package/dist/server/server/agent/agent-storage.js +208 -132
  4. package/dist/server/server/agent/providers/claude/transport/pty-query.js +10 -0
  5. package/dist/server/server/bootstrap.d.ts +5 -0
  6. package/dist/server/server/bootstrap.js +26 -3
  7. package/dist/server/server/chat/chat-service.d.ts +22 -0
  8. package/dist/server/server/chat/chat-service.js +46 -3
  9. package/dist/server/server/daemon-worker.js +1 -0
  10. package/dist/server/server/fleet/decision-service.d.ts +5 -0
  11. package/dist/server/server/fleet/decision-service.js +30 -0
  12. package/dist/server/server/loop-service.d.ts +95 -0
  13. package/dist/server/server/loop-service.js +64 -3
  14. package/dist/server/server/migrations/backfill-workspace-id.migration.js +3 -2
  15. package/dist/server/server/push/token-store.d.ts +5 -1
  16. package/dist/server/server/push/token-store.js +26 -5
  17. package/dist/server/server/schedule/service.js +4 -1
  18. package/dist/server/server/schedule/store.d.ts +21 -1
  19. package/dist/server/server/schedule/store.js +283 -2
  20. package/dist/server/server/search/indexer.d.ts +10 -0
  21. package/dist/server/server/search/indexer.js +51 -2
  22. package/dist/server/server/session.js +12 -13
  23. package/dist/server/server/state/agent-state.d.ts +39 -0
  24. package/dist/server/server/state/agent-state.js +94 -0
  25. package/dist/server/server/state/daemon-state.d.ts +30 -0
  26. package/dist/server/server/state/daemon-state.js +99 -0
  27. package/dist/server/server/state/legacy-import.d.ts +124 -0
  28. package/dist/server/server/state/legacy-import.js +682 -0
  29. package/dist/server/server/state/legacy-manifest.d.ts +73 -0
  30. package/dist/server/server/state/legacy-manifest.js +112 -0
  31. package/dist/server/server/state/legacy-mirror.d.ts +57 -0
  32. package/dist/server/server/state/legacy-mirror.js +114 -0
  33. package/dist/server/server/state/legacy-sources.d.ts +9 -0
  34. package/dist/server/server/state/legacy-sources.js +163 -0
  35. package/dist/server/server/state/state-db.d.ts +263 -0
  36. package/dist/server/server/state/state-db.js +772 -0
  37. package/dist/server/server/state/state-schema.d.ts +39 -0
  38. package/dist/server/server/state/state-schema.js +125 -0
  39. package/dist/server/server/state/state-worker.d.ts +48 -0
  40. package/dist/server/server/state/state-worker.js +165 -0
  41. package/dist/server/server/websocket-server.js +2 -1
  42. package/dist/server/server/workspace/session-openable.js +7 -0
  43. package/dist/server/server/workspace-reconciliation-service.js +14 -3
  44. package/dist/server/server/workspace-registry.d.ts +45 -2
  45. package/dist/server/server/workspace-registry.js +75 -4
  46. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js → index-38580ed8926a5ddc569d744b8299d339.js} +4 -4
  47. package/dist/server/web-ui/_expo/static/js/web/index-38580ed8926a5ddc569d744b8299d339.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.gz → index-38580ed8926a5ddc569d744b8299d339.js.gz} +0 -0
  49. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.br → index-38580ed8926a5ddc569d744b8299d339.js.map.br} +0 -0
  50. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.gz → index-38580ed8926a5ddc569d744b8299d339.js.map.gz} +0 -0
  51. package/dist/server/web-ui/index.html +1 -1
  52. package/dist/server/web-ui/index.html.br +0 -0
  53. package/dist/server/web-ui/index.html.gz +0 -0
  54. package/package.json +6 -6
  55. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.br +0 -0
@@ -0,0 +1,772 @@
1
+ import { createRequire } from "node:module";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname, join, resolve as resolvePath } from "node:path";
4
+ import { LegacyManifest } from "./legacy-manifest.js";
5
+ import { LegacyMirror } from "./legacy-mirror.js";
6
+ import { StateWorker } from "./state-worker.js";
7
+ import { ensureStateSchema, TOMBSTONED_TABLES } from "./state-schema.js";
8
+ export const STATE_DB_FILENAME = "state.sqlite";
9
+ /** Kill switch: `PASEO_STATE_SQLITE=0` keeps every store on its legacy files. */
10
+ export const STATE_SQLITE_ENV = "PASEO_STATE_SQLITE";
11
+ /** Kill switch: `PASEO_STATE_FILE_MIRROR=0` stops writing the legacy files. */
12
+ export const STATE_FILE_MIRROR_ENV = "PASEO_STATE_FILE_MIRROR";
13
+ /**
14
+ * Resolve `node:sqlite` lazily, like `ingestion/ledger.ts`: a static import
15
+ * would crash the daemon at load on a runtime without the module, while this
16
+ * moves the failure inside `openStateDb`'s try, where it becomes a fallback.
17
+ */
18
+ function loadDatabaseSyncCtor() {
19
+ const req = createRequire(import.meta.url);
20
+ return req("node:sqlite").DatabaseSync;
21
+ }
22
+ const RECENT_TXN_SAMPLES = 512;
23
+ /** Backoff for a batch whose transaction could not start or commit (SQLITE_BUSY, I/O). */
24
+ const COMMIT_RETRY_BASE_MS = 50;
25
+ const COMMIT_RETRY_MAX_MS = 5000;
26
+ /** How long a batch keeps retrying before its writes reject. */
27
+ const COMMIT_RETRY_BUDGET_MS = 30000;
28
+ /**
29
+ * The main connection never waits long for the write lock: a wait holds the
30
+ * event loop. Contention is retried asynchronously instead (see `requeue`).
31
+ * The import's connection on the worker keeps the longer wait.
32
+ */
33
+ const MAIN_BUSY_TIMEOUT_MS = 250;
34
+ /**
35
+ * The one handle on `state.sqlite`.
36
+ *
37
+ * Writes are queued and committed together in one transaction per turn of the
38
+ * event loop (`setImmediate`), so a burst of 50 agent updates is one commit, not
39
+ * 50. Reads on the hot path never come here: every store keeps its in-memory
40
+ * index and treats this file as its durable log. The only reads are at load, in
41
+ * explicit paged queries, and in `listChangedSince`.
42
+ */
43
+ export class StateDb {
44
+ constructor(options) {
45
+ this.statements = new Map();
46
+ this.docs = new Map();
47
+ this.pending = [];
48
+ this.waiters = [];
49
+ this.flushScheduled = false;
50
+ this.closed = false;
51
+ this.worker = null;
52
+ this.consecutiveFailures = 0;
53
+ this.retryAttempts = 0;
54
+ this.retryStartedAt = null;
55
+ this.retryTimer = null;
56
+ this.stats = {
57
+ transactions: 0,
58
+ rows: 0,
59
+ recentTxnMs: [],
60
+ maxTxnMs: 0,
61
+ };
62
+ this.db = options.db;
63
+ this.dbPath = options.dbPath;
64
+ this.logger = options.logger;
65
+ this.mirror = options.mirror;
66
+ this.manifest = new LegacyManifest(this, dirname(options.dbPath));
67
+ this.revCounter = Number(this.getMeta("rev") ?? "0");
68
+ }
69
+ prepare(sql) {
70
+ let statement = this.statements.get(sql);
71
+ if (!statement) {
72
+ statement = this.db.prepare(sql);
73
+ this.statements.set(sql, statement);
74
+ }
75
+ return statement;
76
+ }
77
+ doc(name, options = {}) {
78
+ let table = this.docs.get(name);
79
+ if (!table) {
80
+ table = new DocTable(this, name, options.tombstones === true || TOMBSTONED_TABLES.has(name));
81
+ this.docs.set(name, table);
82
+ }
83
+ return table;
84
+ }
85
+ /** Next value of the daemon-wide change sequence. Persisted with each commit. */
86
+ nextRev() {
87
+ this.revCounter += 1;
88
+ return this.revCounter;
89
+ }
90
+ get currentRev() {
91
+ return this.revCounter;
92
+ }
93
+ /**
94
+ * The rev a mirror write reflects: the last rev already committed. Commits
95
+ * are synchronous, so every row the caller's in-memory state was built from
96
+ * has a rev at or below it. Deliberately no flush: flushing here would stamp
97
+ * the file with revs of writes queued after its content was captured, and a
98
+ * crash would then leave a lagging file the boot catch-up cannot see
99
+ * (re-review R1). Capture it where the content is captured.
100
+ */
101
+ mirrorRev() {
102
+ return this.revCounter;
103
+ }
104
+ /**
105
+ * Shutdown has begun (SIGTERM, SIGINT, the updater stopping the daemon):
106
+ * commit the queue and start every pending legacy file write now, instead of
107
+ * after the rest of the daemon has wound down.
108
+ */
109
+ beginShutdown() {
110
+ if (this.closed)
111
+ return Promise.resolve();
112
+ this.flushSync();
113
+ return this.mirror.beginShutdown();
114
+ }
115
+ /**
116
+ * Queue a write for the next batched commit. The promise settles when that
117
+ * commit lands. When a batch fails, its ops are retried one transaction each,
118
+ * so only the op that actually fails rejects.
119
+ */
120
+ enqueue(op) {
121
+ if (this.closed) {
122
+ return Promise.reject(new Error("state.sqlite is closed"));
123
+ }
124
+ this.pending.push(op);
125
+ const promise = new Promise((resolve, reject) => {
126
+ this.waiters.push({ resolve, reject });
127
+ });
128
+ // While a failed batch waits out its backoff, new writes join it.
129
+ if (!this.flushScheduled && this.retryTimer === null) {
130
+ this.flushScheduled = true;
131
+ setImmediate(() => this.flushSync());
132
+ }
133
+ return promise;
134
+ }
135
+ /** Commit everything queued, now. Safe to call with nothing queued. */
136
+ flushSync() {
137
+ // A batch is waiting out a lock-retry backoff: reads serve what is
138
+ // committed and never retry the commit on the event loop themselves.
139
+ if (this.retryTimer !== null) {
140
+ return;
141
+ }
142
+ this.commitPending();
143
+ }
144
+ commitPending() {
145
+ this.flushScheduled = false;
146
+ if (this.pending.length === 0) {
147
+ return;
148
+ }
149
+ const ops = this.pending;
150
+ const waiters = this.waiters;
151
+ this.pending = [];
152
+ this.waiters = [];
153
+ const started = performance.now();
154
+ let opFailed = false;
155
+ try {
156
+ this.transaction(() => {
157
+ for (const op of ops) {
158
+ try {
159
+ op();
160
+ }
161
+ catch (error) {
162
+ opFailed = true;
163
+ throw error;
164
+ }
165
+ }
166
+ this.setMetaInTransaction("rev", String(this.revCounter));
167
+ });
168
+ }
169
+ catch (error) {
170
+ if (!opFailed) {
171
+ // The transaction itself failed (SQLITE_BUSY from another connection
172
+ // holding the write lock, an I/O error at BEGIN or COMMIT): no op is
173
+ // at fault. Keep the whole batch queued, in order, and retry off the
174
+ // event loop with backoff instead of busy-waiting per write here.
175
+ this.requeue(ops, waiters, error);
176
+ return;
177
+ }
178
+ this.retryStartedAt = null;
179
+ if (ops.length === 1) {
180
+ this.recordFailure(error, 1);
181
+ waiters[0].reject(error);
182
+ return;
183
+ }
184
+ // One bad op must not take every other store's write down with it:
185
+ // retry each on its own, so only the ones that fail again reject.
186
+ this.logger.warn({ err: error, rows: ops.length }, "state.sqlite batch commit failed; retrying each write on its own");
187
+ ops.forEach((op, index) => {
188
+ try {
189
+ this.transaction(() => {
190
+ op();
191
+ this.setMetaInTransaction("rev", String(this.revCounter));
192
+ });
193
+ waiters[index].resolve();
194
+ }
195
+ catch (opError) {
196
+ this.recordFailure(opError, 1);
197
+ waiters[index].reject(opError);
198
+ }
199
+ });
200
+ this.recordTxn(performance.now() - started, ops.length);
201
+ return;
202
+ }
203
+ this.consecutiveFailures = 0;
204
+ this.retryAttempts = 0;
205
+ this.retryStartedAt = null;
206
+ this.recordTxn(performance.now() - started, ops.length);
207
+ for (const waiter of waiters)
208
+ waiter.resolve();
209
+ }
210
+ /**
211
+ * Put a batch whose transaction failed back at the head of the queue and
212
+ * retry it after a backoff. Once the batch has been failing for
213
+ * `COMMIT_RETRY_BUDGET_MS` it rejects, so a writer that holds the lock for good surfaces as
214
+ * failed writes and `failedCommits`, not as a queue that grows forever.
215
+ */
216
+ requeue(ops, waiters, error) {
217
+ this.retryAttempts += 1;
218
+ this.retryStartedAt ?? (this.retryStartedAt = Date.now());
219
+ this.recordFailure(error, ops.length);
220
+ if (Date.now() - this.retryStartedAt >= COMMIT_RETRY_BUDGET_MS) {
221
+ this.retryAttempts = 0;
222
+ this.retryStartedAt = null;
223
+ for (const waiter of waiters)
224
+ waiter.reject(error);
225
+ this.commitPending();
226
+ return;
227
+ }
228
+ this.pending = [...ops, ...this.pending];
229
+ this.waiters = [...waiters, ...this.waiters];
230
+ const delay = Math.min(COMMIT_RETRY_MAX_MS, COMMIT_RETRY_BASE_MS * 2 ** (this.retryAttempts - 1));
231
+ this.retryTimer = setTimeout(() => {
232
+ this.retryTimer = null;
233
+ if (!this.closed)
234
+ this.commitPending();
235
+ }, delay);
236
+ this.retryTimer.unref?.();
237
+ }
238
+ clearRetry() {
239
+ if (this.retryTimer)
240
+ clearTimeout(this.retryTimer);
241
+ this.retryTimer = null;
242
+ }
243
+ /** Commits that failed in a row; a health signal once it keeps climbing. */
244
+ get failedCommits() {
245
+ return this.consecutiveFailures;
246
+ }
247
+ recordFailure(error, rows) {
248
+ this.consecutiveFailures += 1;
249
+ this.logger.error({ err: error, rows, consecutiveFailures: this.consecutiveFailures }, "state.sqlite write failed");
250
+ }
251
+ /**
252
+ * Move WAL checkpoints and bulk writes to a worker thread (see
253
+ * `StateWorker`). If the worker cannot start, SQLite keeps checkpointing
254
+ * inside commits and bulk writes run here, as usual.
255
+ */
256
+ startWorker() {
257
+ this.worker = StateWorker.start(this.dbPath, this.logger);
258
+ if (this.worker) {
259
+ this.db.exec("PRAGMA wal_autocheckpoint = 0");
260
+ }
261
+ }
262
+ /** See `StateWorker.pause`. */
263
+ pauseCheckpoints() {
264
+ this.worker?.pause();
265
+ }
266
+ resumeCheckpoints() {
267
+ this.worker?.resume();
268
+ }
269
+ /**
270
+ * Commit a bulk batch as one transaction, off the event loop when the worker
271
+ * is up. Only for work nothing else is writing concurrently (the import runs
272
+ * before any store exists); the rev counter is persisted with the batch.
273
+ */
274
+ async commitBatch(statements) {
275
+ const all = [...statements, this.metaStatement("rev", String(this.revCounter))];
276
+ if (this.worker) {
277
+ await this.worker.write(all);
278
+ return;
279
+ }
280
+ this.transaction(() => {
281
+ for (const statement of all) {
282
+ this.prepare(statement.sql).run(...statement.params);
283
+ }
284
+ });
285
+ }
286
+ metaStatement(key, value) {
287
+ return {
288
+ sql: "INSERT INTO state_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
289
+ params: [key, value],
290
+ };
291
+ }
292
+ /** Run `fn` in one IMMEDIATE transaction, rolling back on throw. */
293
+ transaction(fn) {
294
+ this.db.exec("BEGIN IMMEDIATE");
295
+ try {
296
+ const result = fn();
297
+ this.db.exec("COMMIT");
298
+ this.worker?.markDirty();
299
+ return result;
300
+ }
301
+ catch (error) {
302
+ try {
303
+ this.db.exec("ROLLBACK");
304
+ }
305
+ catch {
306
+ // The original error is the one worth reporting.
307
+ }
308
+ throw error;
309
+ }
310
+ }
311
+ getMeta(key) {
312
+ const row = this.prepare("SELECT value FROM state_meta WHERE key = ?").get(key);
313
+ return typeof row?.value === "string" ? row.value : null;
314
+ }
315
+ setMeta(key, value) {
316
+ this.flushSync();
317
+ this.setMetaInTransaction(key, value);
318
+ }
319
+ /** Every meta entry whose key starts with `prefix`, keyed by the rest of the key. */
320
+ getMetaWithPrefix(prefix) {
321
+ const rows = this.prepare("SELECT key, value FROM state_meta WHERE key >= ? AND key < ? ORDER BY key").all(prefix, `${prefix}\uffff`);
322
+ return new Map(rows.map((row) => [String(row.key).slice(prefix.length), String(row.value)]));
323
+ }
324
+ deleteMeta(key) {
325
+ this.flushSync();
326
+ this.prepare("DELETE FROM state_meta WHERE key = ?").run(key);
327
+ }
328
+ /** `setMeta` for use inside an open transaction. */
329
+ setMetaInTransaction(key, value) {
330
+ this.prepare("INSERT INTO state_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
331
+ }
332
+ getImportMarker(store) {
333
+ const row = this.prepare("SELECT status, cursor, imported, skipped FROM state_import WHERE store = ?").get(store);
334
+ if (!row) {
335
+ return null;
336
+ }
337
+ return {
338
+ status: row.status === "done" ? "done" : "running",
339
+ cursor: typeof row.cursor === "string" ? row.cursor : null,
340
+ imported: Number(row.imported ?? 0),
341
+ skipped: Number(row.skipped ?? 0),
342
+ };
343
+ }
344
+ /** Write an import marker. Call inside `transaction` to commit it with the rows it covers. */
345
+ putImportMarker(store, marker) {
346
+ const statement = this.importMarkerStatement(store, marker);
347
+ this.prepare(statement.sql).run(...statement.params);
348
+ }
349
+ /** `putImportMarker` as a statement for `commitBatch`, to commit with its rows. */
350
+ importMarkerStatement(store, marker) {
351
+ const now = Date.now();
352
+ return {
353
+ sql: `INSERT INTO state_import (store, status, cursor, imported, skipped, started_at, finished_at)
354
+ VALUES (?, ?, ?, ?, ?, ?, ?)
355
+ ON CONFLICT(store) DO UPDATE SET status = excluded.status, cursor = excluded.cursor,
356
+ imported = excluded.imported, skipped = excluded.skipped,
357
+ finished_at = excluded.finished_at`,
358
+ params: [
359
+ store,
360
+ marker.status,
361
+ marker.cursor,
362
+ marker.imported,
363
+ marker.skipped,
364
+ now,
365
+ marker.status === "done" ? now : null,
366
+ ],
367
+ };
368
+ }
369
+ get writeStats() {
370
+ return { ...this.stats, recentTxnMs: [...this.stats.recentTxnMs] };
371
+ }
372
+ recordTxn(ms, rows) {
373
+ this.stats.transactions += 1;
374
+ this.stats.rows += rows;
375
+ this.stats.maxTxnMs = Math.max(this.stats.maxTxnMs, ms);
376
+ this.stats.recentTxnMs.push(ms);
377
+ if (this.stats.recentTxnMs.length > RECENT_TXN_SAMPLES) {
378
+ this.stats.recentTxnMs.shift();
379
+ }
380
+ }
381
+ /** Flush queued writes and the legacy mirror, then close the handle. */
382
+ async close() {
383
+ if (this.closed) {
384
+ return;
385
+ }
386
+ this.clearRetry();
387
+ this.flushSync();
388
+ await this.mirror.flush();
389
+ this.clearRetry();
390
+ this.flushSync();
391
+ this.clearRetry();
392
+ // Anything still queued could not commit (the lock never came free).
393
+ for (const waiter of this.waiters.splice(0)) {
394
+ waiter.reject(new Error("state.sqlite closed with writes that could not commit"));
395
+ }
396
+ this.pending = [];
397
+ this.closed = true;
398
+ await this.worker?.close();
399
+ try {
400
+ this.db.exec("PRAGMA optimize");
401
+ }
402
+ catch {
403
+ // Advisory only.
404
+ }
405
+ this.db.close();
406
+ }
407
+ /**
408
+ * Close without flushing the mirror or the queue, and drop pending mirror
409
+ * writes. Tests use it to simulate a kill -9.
410
+ */
411
+ closeImmediately() {
412
+ this.clearRetry();
413
+ this.mirror.discard();
414
+ this.pending = [];
415
+ this.waiters = [];
416
+ this.closed = true;
417
+ void this.worker?.close();
418
+ this.db.close();
419
+ }
420
+ }
421
+ /** A keyed-document table. See `state-schema.ts`. */
422
+ export class DocTable {
423
+ constructor(state, name, tombstones) {
424
+ this.state = state;
425
+ this.name = name;
426
+ this.tombstones = tombstones;
427
+ }
428
+ /** Serialize now (so later mutation of `value` cannot leak in) and queue the upsert. */
429
+ put(scope, id, value, ord = 0) {
430
+ return this.putRaw(scope, id, JSON.stringify(value), ord);
431
+ }
432
+ putRaw(scope, id, body, ord = 0) {
433
+ return this.state.enqueue(() => this.putNow(scope, id, body, ord));
434
+ }
435
+ /** `put`, resolving with the rev the row was committed at. */
436
+ async putWithRev(scope, id, value) {
437
+ const body = JSON.stringify(value);
438
+ let rev = 0;
439
+ await this.state.enqueue(() => {
440
+ const statement = this.putStatement(scope, id, body);
441
+ rev = Number(statement.params[3]);
442
+ this.state.prepare(statement.sql).run(...statement.params);
443
+ });
444
+ return rev;
445
+ }
446
+ /** Upsert inside the caller's transaction. */
447
+ putNow(scope, id, body, ord = 0) {
448
+ const statement = this.putStatement(scope, id, body, ord);
449
+ this.state.prepare(statement.sql).run(...statement.params);
450
+ }
451
+ /** The upsert as a statement for `StateDb.commitBatch`; takes its rev now. */
452
+ putStatement(scope, id, body, ord = 0) {
453
+ return {
454
+ sql: `INSERT INTO ${this.name} (scope, id, ord, rev, updated_at, deleted_at, body)
455
+ VALUES (?, ?, ?, ?, ?, NULL, ?)
456
+ ON CONFLICT(scope, id) DO UPDATE SET ord = excluded.ord, rev = excluded.rev,
457
+ updated_at = excluded.updated_at, deleted_at = NULL, body = excluded.body`,
458
+ params: [scope, id, ord, this.state.nextRev(), Date.now(), body],
459
+ };
460
+ }
461
+ remove(scope, id) {
462
+ return this.state.enqueue(() => this.removeNow(scope, id));
463
+ }
464
+ removeNow(scope, id) {
465
+ const statement = this.removeStatement(scope, id);
466
+ this.state.prepare(statement.sql).run(...statement.params);
467
+ }
468
+ /** The removal (a tombstone on a tombstoned table) as a batch statement; takes its rev now. */
469
+ removeStatement(scope, id) {
470
+ if (this.tombstones) {
471
+ const now = Date.now();
472
+ return {
473
+ sql: `UPDATE ${this.name} SET rev = ?, updated_at = ?, deleted_at = ?, body = NULL
474
+ WHERE scope = ? AND id = ? AND deleted_at IS NULL`,
475
+ params: [this.state.nextRev(), now, now, scope, id],
476
+ };
477
+ }
478
+ return { sql: `DELETE FROM ${this.name} WHERE scope = ? AND id = ?`, params: [scope, id] };
479
+ }
480
+ /**
481
+ * Rows, tombstones included, optionally narrowed to one scope and one id. No
482
+ * flush: for reconciliation, which runs before any store writes.
483
+ */
484
+ storedRows(filter = {}) {
485
+ const where = [];
486
+ const params = [];
487
+ if (filter.scope !== undefined) {
488
+ where.push("scope = ?");
489
+ params.push(filter.scope);
490
+ }
491
+ if (filter.id !== undefined) {
492
+ where.push("id = ?");
493
+ params.push(filter.id);
494
+ }
495
+ return this.state
496
+ .prepare(`SELECT scope, id, rev, deleted_at, body FROM ${this.name}${where.length > 0 ? ` WHERE ${where.join(" AND ")}` : ""}`)
497
+ .all(...params)
498
+ .map((row) => ({
499
+ scope: String(row.scope),
500
+ id: String(row.id),
501
+ rev: Number(row.rev),
502
+ deletedAt: row.deleted_at === null ? null : Number(row.deleted_at),
503
+ body: row.body === null ? null : String(row.body),
504
+ }));
505
+ }
506
+ /** Drop every row of a scope (a deleted schedule's runs). Never tombstoned. */
507
+ removeScope(scope) {
508
+ return this.state.enqueue(() => {
509
+ this.state.prepare(`DELETE FROM ${this.name} WHERE scope = ?`).run(scope);
510
+ });
511
+ }
512
+ /** Live rows, ordered by scope, ord, id. Flushes queued writes first. */
513
+ loadAll(scope) {
514
+ this.state.flushSync();
515
+ return this.loadAllInTransaction(scope);
516
+ }
517
+ /** `loadAll` for use inside an open transaction (no flush). */
518
+ loadAllInTransaction(scope) {
519
+ const rows = scope === undefined
520
+ ? this.state
521
+ .prepare(`SELECT scope, id, body, rev FROM ${this.name} WHERE deleted_at IS NULL ORDER BY scope, ord, id`)
522
+ .all()
523
+ : this.state
524
+ .prepare(`SELECT scope, id, body, rev FROM ${this.name} WHERE scope = ? AND deleted_at IS NULL ORDER BY ord, id`)
525
+ .all(scope);
526
+ return rows.map((row) => ({
527
+ scope: String(row.scope),
528
+ id: String(row.id),
529
+ body: String(row.body),
530
+ rev: Number(row.rev),
531
+ }));
532
+ }
533
+ get(scope, id) {
534
+ this.state.flushSync();
535
+ return this.getInTransaction(scope, id);
536
+ }
537
+ /** `get` for use inside an open transaction (no flush). */
538
+ getInTransaction(scope, id) {
539
+ const row = this.state
540
+ .prepare(`SELECT body FROM ${this.name} WHERE scope = ? AND id = ? AND deleted_at IS NULL`)
541
+ .get(scope, id);
542
+ return typeof row?.body === "string" ? row.body : null;
543
+ }
544
+ /**
545
+ * Drop tombstones deleted before `before`, except ids in `keep`. Returns how
546
+ * many went.
547
+ */
548
+ purgeTombstones(before, keep = new Set()) {
549
+ this.state.flushSync();
550
+ const rows = this.state
551
+ .prepare(`SELECT scope, id FROM ${this.name} WHERE deleted_at IS NOT NULL AND deleted_at < ?`)
552
+ .all(before)
553
+ .filter((row) => !keep.has(String(row.id)));
554
+ if (rows.length === 0)
555
+ return 0;
556
+ this.state.transaction(() => {
557
+ const statement = this.state.prepare(`DELETE FROM ${this.name} WHERE scope = ? AND id = ? AND deleted_at IS NOT NULL`);
558
+ for (const row of rows)
559
+ statement.run(String(row.scope), String(row.id));
560
+ });
561
+ return rows.length;
562
+ }
563
+ /** Highest rev in the table, tombstones included (0 when empty). */
564
+ maxRev() {
565
+ this.state.flushSync();
566
+ const row = this.state.prepare(`SELECT MAX(rev) AS rev FROM ${this.name}`).get();
567
+ return Number(row?.rev ?? 0);
568
+ }
569
+ count() {
570
+ this.state.flushSync();
571
+ const row = this.state
572
+ .prepare(`SELECT COUNT(*) AS n FROM ${this.name} WHERE deleted_at IS NULL`)
573
+ .get();
574
+ return Number(row?.n ?? 0);
575
+ }
576
+ /**
577
+ * Rows (live and tombstoned) whose rev is greater than `rev`, oldest change
578
+ * first. Internal for now; the delta protocol exposes it in a later release.
579
+ */
580
+ listChangedSince(rev, limit = 1000) {
581
+ this.state.flushSync();
582
+ return this.state
583
+ .prepare(`SELECT scope, id, rev, deleted_at, body FROM ${this.name} WHERE rev > ? ORDER BY rev LIMIT ?`)
584
+ .all(rev, limit)
585
+ .map((row) => ({
586
+ scope: String(row.scope),
587
+ id: String(row.id),
588
+ rev: Number(row.rev),
589
+ deletedAt: row.deleted_at === null ? null : Number(row.deleted_at),
590
+ body: row.body === null ? null : String(row.body),
591
+ }));
592
+ }
593
+ }
594
+ /**
595
+ * Keeps a scope's rows in step with an in-memory list, writing only what
596
+ * changed. The last-written body per row is remembered, so a store that
597
+ * persists "everything" on each change (loops, chat, registries) costs one
598
+ * stringify per row and zero writes for the rows that did not move.
599
+ *
600
+ * A write that fails to commit is forgotten again (unless a later sync already
601
+ * replaced it), so the next sync writes that row instead of skipping it as
602
+ * already on disk.
603
+ */
604
+ /** Never equal to a JSON body, so a row marked with it is always rewritten. */
605
+ const UNKNOWN_BODY = "\u0000unknown";
606
+ export class ScopeSync {
607
+ constructor(table) {
608
+ this.table = table;
609
+ this.written = new Map();
610
+ }
611
+ /** Seed from rows already in the table, so the first sync does not rewrite them. */
612
+ seed(rows) {
613
+ for (const row of rows) {
614
+ this.scopeMap(row.scope).set(row.id, row.body);
615
+ }
616
+ }
617
+ /** Queue the diff inside the next batch. `prune` removes rows no longer listed. */
618
+ sync(scope, items, options) {
619
+ const previous = this.scopeMap(scope);
620
+ const next = new Map();
621
+ const writes = [];
622
+ items.forEach((item, index) => {
623
+ const body = JSON.stringify(item.value);
624
+ next.set(item.id, body);
625
+ if (previous.get(item.id) !== body) {
626
+ writes.push({ id: item.id, body, ord: index });
627
+ }
628
+ });
629
+ const removals = options.prune ? [...previous.keys()].filter((id) => !next.has(id)) : [];
630
+ if (!options.prune) {
631
+ for (const [id, body] of previous) {
632
+ if (!next.has(id))
633
+ next.set(id, body);
634
+ }
635
+ }
636
+ this.written.set(scope, next);
637
+ if (writes.length === 0 && removals.length === 0) {
638
+ return Promise.resolve();
639
+ }
640
+ const forgetOnFailure = (id, body) => (error) => {
641
+ const map = this.written.get(scope);
642
+ if (!map)
643
+ throw error;
644
+ if (body === null) {
645
+ // A failed removal: mark the row unknown so a prune tries again.
646
+ if (!map.has(id))
647
+ map.set(id, UNKNOWN_BODY);
648
+ }
649
+ else if (map.get(id) === body) {
650
+ map.delete(id);
651
+ }
652
+ throw error;
653
+ };
654
+ return Promise.all([
655
+ ...writes.map((write) => this.table
656
+ .putRaw(scope, write.id, write.body, write.ord)
657
+ .catch(forgetOnFailure(write.id, write.body))),
658
+ ...removals.map((id) => this.table.remove(scope, id).catch(forgetOnFailure(id, null))),
659
+ ]).then(() => undefined);
660
+ }
661
+ /**
662
+ * Drop every tracked scope not in `live` (a deleted room's messages, a
663
+ * deleted loop's logs), so children never outlive their parent.
664
+ */
665
+ dropScopesExcept(live) {
666
+ const stale = [...this.written.keys()].filter((scope) => !live.has(scope));
667
+ return Promise.all(stale.map((scope) => this.dropScope(scope))).then(() => undefined);
668
+ }
669
+ dropScope(scope) {
670
+ this.written.delete(scope);
671
+ return this.table.removeScope(scope);
672
+ }
673
+ scopeMap(scope) {
674
+ let map = this.written.get(scope);
675
+ if (!map) {
676
+ map = new Map();
677
+ this.written.set(scope, map);
678
+ }
679
+ return map;
680
+ }
681
+ }
682
+ /**
683
+ * Open and probe `state.sqlite`, or return null so every store stays on its
684
+ * legacy files. Never throws: a runtime without `node:sqlite` (Node 20, Node
685
+ * 22 before 22.13 without the flag), a locked or corrupt file, or a schema
686
+ * newer than this build all degrade to the file stores with one warning.
687
+ */
688
+ export function openStateDb(options) {
689
+ const env = options.env ?? process.env;
690
+ const logger = options.logger.child({ module: "state-db" });
691
+ if (env[STATE_SQLITE_ENV] === "0") {
692
+ logger.info(`${STATE_SQLITE_ENV}=0: daemon state stays on the legacy file stores`);
693
+ return null;
694
+ }
695
+ const dbPath = join(options.paseoHome, STATE_DB_FILENAME);
696
+ let db = null;
697
+ try {
698
+ mkdirSync(options.paseoHome, { recursive: true });
699
+ const Ctor = (options.loadCtor ?? loadDatabaseSyncCtor)();
700
+ db = new Ctor(dbPath);
701
+ // busy_timeout first: switching to WAL needs a lock too, and without a
702
+ // timeout a checkpoint still finishing elsewhere fails the open at once.
703
+ db.exec("PRAGMA busy_timeout = 5000");
704
+ db.exec("PRAGMA journal_mode = WAL");
705
+ db.exec("PRAGMA synchronous = NORMAL");
706
+ db.exec("PRAGMA temp_store = MEMORY");
707
+ ensureStateSchema(db);
708
+ // Probe a real write and read back before any store trusts this file.
709
+ const probe = String(Date.now());
710
+ db.prepare("INSERT INTO state_meta (key, value) VALUES ('probe', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(probe);
711
+ const readBack = db.prepare("SELECT value FROM state_meta WHERE key = 'probe'").get();
712
+ if (readBack?.value !== probe) {
713
+ throw new Error("state.sqlite probe read back a different value");
714
+ }
715
+ db.exec(`PRAGMA busy_timeout = ${MAIN_BUSY_TIMEOUT_MS}`);
716
+ }
717
+ catch (error) {
718
+ try {
719
+ db?.close();
720
+ }
721
+ catch {
722
+ // Already failing; the warning below is the report.
723
+ }
724
+ logger.warn({ err: error, dbPath }, "state.sqlite unavailable; daemon state stays on the legacy file stores");
725
+ return null;
726
+ }
727
+ const mirror = new LegacyMirror({
728
+ enabled: env[STATE_FILE_MIRROR_ENV] !== "0",
729
+ logger,
730
+ debounceMs: options.mirrorDebounceMs,
731
+ });
732
+ const stateDb = new StateDb({ db, dbPath, logger, mirror });
733
+ if (options.worker !== false) {
734
+ stateDb.startWorker();
735
+ }
736
+ return stateDb;
737
+ }
738
+ const attached = new Map();
739
+ /**
740
+ * Stores constructed deep inside other services (schedules, fleet decisions,
741
+ * push tokens, extension storage) find the daemon's handle by PASEO_HOME
742
+ * instead of having it threaded through every constructor in between. Nothing
743
+ * attached means file stores, which is also what every unit test gets.
744
+ */
745
+ export function attachStateDb(paseoHome, stateDb) {
746
+ attached.set(resolvePath(paseoHome), stateDb);
747
+ installBeforeExitFlush();
748
+ }
749
+ let beforeExitInstalled = false;
750
+ /**
751
+ * Mirror timers are unref'd so they never hold the process open, which means a
752
+ * process that simply runs out of work would exit with writes still debounced.
753
+ * `beforeExit` fires exactly then, and async work started here runs to the end.
754
+ */
755
+ function installBeforeExitFlush() {
756
+ if (beforeExitInstalled)
757
+ return;
758
+ beforeExitInstalled = true;
759
+ process.on("beforeExit", () => {
760
+ for (const stateDb of attached.values()) {
761
+ if (stateDb.mirror.pendingCount > 0)
762
+ void stateDb.beginShutdown();
763
+ }
764
+ });
765
+ }
766
+ export function detachStateDb(paseoHome) {
767
+ attached.delete(resolvePath(paseoHome));
768
+ }
769
+ export function stateDbFor(paseoHome) {
770
+ return attached.get(resolvePath(paseoHome)) ?? null;
771
+ }
772
+ //# sourceMappingURL=state-db.js.map