@ixo/oracle-runtime-workers 0.4.1 → 0.10.0

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 (43) hide show
  1. package/README.md +5 -1
  2. package/package.json +2 -2
  3. package/src/attachments/download.ts +43 -22
  4. package/src/attachments/index.ts +1 -0
  5. package/src/core/env.ts +28 -0
  6. package/src/core/main-agent.ts +56 -4
  7. package/src/core/middlewares/dangling-tool-calls.ts +99 -0
  8. package/src/core/middlewares/index.ts +5 -0
  9. package/src/core/runtime-context.ts +14 -5
  10. package/src/core/subagent-as-tool.ts +12 -1
  11. package/src/do/ambient.ts +13 -4
  12. package/src/do/boot-dirty.ts +38 -0
  13. package/src/do/contracts.ts +113 -18
  14. package/src/do/matrix-turn-ledger.ts +126 -0
  15. package/src/do/reauth-prompt.ts +76 -0
  16. package/src/do/room-mirror.ts +111 -0
  17. package/src/do/sse-stream.ts +104 -2
  18. package/src/do/transcript.ts +15 -2
  19. package/src/do/turn-config.ts +24 -0
  20. package/src/do/user-oracle-do.ts +484 -91
  21. package/src/matrix/gateway-do.ts +233 -42
  22. package/src/matrix/inbox-store.ts +188 -0
  23. package/src/matrix/txn-id.ts +35 -0
  24. package/src/matrix/work-status.ts +15 -3
  25. package/src/owner-store/ixo-vfs-store.ts +12 -5
  26. package/src/owner-store/matrix-media-store.ts +65 -40
  27. package/src/owner-store/measure.ts +39 -0
  28. package/src/owner-store/migrating-store.ts +29 -34
  29. package/src/owner-store/types.ts +0 -0
  30. package/src/plugin-api/types.ts +25 -1
  31. package/src/plugins/portal/action-log.ts +36 -9
  32. package/src/plugins/tasks/tasks-tools.ts +7 -7
  33. package/src/shell/app.ts +92 -7
  34. package/src/shell/turn-body-cap.ts +19 -0
  35. package/src/sqlite/cache-config.ts +27 -6
  36. package/src/sqlite/chunk-layout.ts +14 -0
  37. package/src/sqlite/database.ts +188 -19
  38. package/src/sqlite/do-vfs.ts +963 -99
  39. package/src/sqlite/index.ts +13 -0
  40. package/src/sqlite/page-tier.ts +668 -0
  41. package/src/tasks/schedule-input.ts +102 -0
  42. package/src/tasks/scheduler.ts +351 -54
  43. package/src/tasks/store.ts +198 -3
package/src/shell/app.ts CHANGED
@@ -26,6 +26,18 @@ import {
26
26
  type AuthResult,
27
27
  type RouteExclusion,
28
28
  } from './auth';
29
+ import { turnBodyTooLarge } from './turn-body-cap';
30
+ import { z } from 'zod';
31
+
32
+ const DebugEventBody = z.object({
33
+ type: z
34
+ .string()
35
+ .min(1)
36
+ .max(255)
37
+ .refine((t) => t !== 'm.room.redaction', 'redactions are not events'),
38
+ content: z.record(z.string(), z.unknown()).default({}),
39
+ txnId: z.string().min(1).max(255).optional(),
40
+ });
29
41
 
30
42
  export interface PluginRoute {
31
43
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'ALL';
@@ -54,6 +66,17 @@ export interface ShellOptions {
54
66
 
55
67
  type Variables = { auth: AuthResult };
56
68
 
69
+ function errorText(err: unknown): string {
70
+ return err instanceof Error ? err.message : String(err);
71
+ }
72
+
73
+ /** The rejection an in-flight RPC gets when `ctx.abort()` resets its object. */
74
+ function isAbortRejection(err: unknown): boolean {
75
+ return /debug reset requested|reset requested by the debug route|durable object reset|no longer active|aborted/i.test(
76
+ errorText(err),
77
+ );
78
+ }
79
+
57
80
  const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
58
81
  { path: '/', method: 'GET' },
59
82
  { path: '/health', method: 'ALL' },
@@ -66,9 +89,6 @@ const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
66
89
  { path: '/debug/matrix/stop', method: 'POST' },
67
90
  ];
68
91
 
69
- /** Express body-parser default in the Node runtime: JSON bodies over 100 KiB are a 413. */
70
- const MAX_TURN_BODY_BYTES = 100 * 1024;
71
-
72
92
  export function createShell(
73
93
  opts: ShellOptions = {},
74
94
  ): Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }> {
@@ -267,9 +287,9 @@ export function createShell(
267
287
  const identity = identityOf(c.get('auth'), c.req.raw.headers);
268
288
  const sessionId = c.req.param('sessionId');
269
289
  const body = await c.req.text();
270
- // Node (express body-parser default) refuses JSON bodies over 100 KiB
271
- // with a 413; attachments are references, so real turns never get near it.
272
- if (new TextEncoder().encode(body).byteLength > MAX_TURN_BODY_BYTES) {
290
+ // Bounds the memory one request can pin (see turn-body-cap.ts); the
291
+ // status and message are the ones Node's body parser uses.
292
+ if (turnBodyTooLarge(body)) {
273
293
  return c.json(
274
294
  { statusCode: 413, message: 'request entity too large' },
275
295
  413,
@@ -451,9 +471,33 @@ export function createShell(
451
471
  app.post('/debug/storage/flush', async (c) =>
452
472
  c.json(await userStub(c.env, c.get('auth').userDid).flushToOwnerStore()),
453
473
  );
474
+ app.post('/debug/storage/tier-flush', async (c) => {
475
+ const body: unknown = await c.req.json().catch(() => ({}));
476
+ const rec = typeof body === 'object' && body !== null ? body : {};
477
+ const force = Reflect.get(rec, 'force') === true;
478
+ const max = Number(Reflect.get(rec, 'maxSegments'));
479
+ return c.json(
480
+ await userStub(c.env, c.get('auth').userDid).tierFlush({
481
+ force,
482
+ ...(Number.isInteger(max) && max > 0 && { maxSegments: max }),
483
+ }),
484
+ );
485
+ });
454
486
  app.post('/debug/storage/reset', async (c) =>
455
487
  c.json(await userStub(c.env, c.get('auth').userDid).resetWorkingCopy()),
456
488
  );
489
+ app.post('/debug/object/abort', async (c) => {
490
+ try {
491
+ await userStub(c.env, c.get('auth').userDid).debugAbortObject();
492
+ } catch (err) {
493
+ // The abort tears the object down under the call: that rejection is
494
+ // the expected signal. Anything else (an older build without the
495
+ // method, a transport error) is a real failure.
496
+ if (!isAbortRejection(err))
497
+ return c.json({ aborted: false, error: errorText(err) }, 500);
498
+ }
499
+ return c.json({ aborted: true });
500
+ });
457
501
  app.get('/debug/memory-schema', async (c) => {
458
502
  const userDid = c.get('auth').userDid;
459
503
  return c.json(await userStub(c.env, userDid).debugMemorySchema(userDid));
@@ -469,11 +513,19 @@ export function createShell(
469
513
  : c.json({ statusCode: 404, message: 'Session not found' }, 404);
470
514
  });
471
515
  app.get('/debug/tasks', async (c) =>
472
- c.json(await userStub(c.env, c.get('auth').userDid).tasksStatus()),
516
+ c.json(
517
+ await userStub(c.env, c.get('auth').userDid).tasksStatus(
518
+ c.get('auth').userDid,
519
+ ),
520
+ ),
473
521
  );
474
522
  app.get('/debug/realtime', async (c) =>
475
523
  c.json(await userStub(c.env, c.get('auth').userDid).realtimeStatus()),
476
524
  );
525
+ app.post('/debug/reauth-prompt/reset', async (c) => {
526
+ await userStub(c.env, c.get('auth').userDid).debugResetReauthThrottle();
527
+ return c.json({ reset: true });
528
+ });
477
529
  app.get('/debug/delegation', async (c) =>
478
530
  c.json(
479
531
  await userStub(c.env, c.get('auth').userDid).delegationStatus(
@@ -487,6 +539,39 @@ export function createShell(
487
539
  app.get('/debug/matrix/outbox', async (c) =>
488
540
  c.json({ rows: await gateway(c.env).listOutbox() }),
489
541
  );
542
+ // Drill: post a custom event into the caller's own room under a
543
+ // caller-pinned transaction id. The same id twice returns the same event
544
+ // (the homeserver deduplicates per device), which is what makes a
545
+ // best-effort post retried across a gateway restart land once.
546
+ app.post('/debug/matrix/event', async (c) => {
547
+ const parsed = DebugEventBody.safeParse(
548
+ await c.req.json().catch(() => null),
549
+ );
550
+ if (!parsed.success)
551
+ return c.json(
552
+ { error: 'expected { type: string, content: object, txnId?: string }' },
553
+ 400,
554
+ );
555
+ const room = await gateway(c.env).resolveUserRoom(c.get('auth').userDid);
556
+ if (!room) return c.json({ error: 'no room for this user' }, 404);
557
+ const { type, content, txnId } = parsed.data;
558
+ const eventId = await gateway(c.env).sendEvent(
559
+ room.roomId,
560
+ type,
561
+ JSON.stringify(content),
562
+ txnId === undefined ? {} : { txnId },
563
+ );
564
+ return c.json({ roomId: room.roomId, eventId });
565
+ });
566
+ app.post('/debug/matrix/abort', async (c) => {
567
+ try {
568
+ await gateway(c.env).debugAbortObject();
569
+ } catch (err) {
570
+ if (!isAbortRejection(err))
571
+ return c.json({ aborted: false, error: errorText(err) }, 500);
572
+ }
573
+ return c.json({ aborted: true });
574
+ });
490
575
  app.post('/debug/matrix/restart', async (c) =>
491
576
  c.json(await gateway(c.env).restart()),
492
577
  );
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Upper bound on a chat turn's request body (`POST /messages/:sessionId`).
3
+ *
4
+ * The cap bounds the memory one request can pin: the shell reads the body in
5
+ * full before forwarding it to the user object. It no longer mirrors the Node
6
+ * runtime, which keeps Express body-parser's 100 kb default — a normal Portal
7
+ * turn now exceeds that. Every turn carries the browser-tool catalogue (26–32
8
+ * tools, 65–70 KiB of JSON schema) and the AG-UI action schemas (~27 KiB)
9
+ * before the message text, and a compose-topic turn adds the pinned text in
10
+ * `metadata.contextToolCall`; on devnet turns of 102–115 KiB were refused.
11
+ * 256 KiB leaves room for those. Attachments never count: files go to Matrix
12
+ * media first and the body carries only their references.
13
+ */
14
+ export const MAX_TURN_BODY_BYTES = 256 * 1024;
15
+
16
+ /** Whether a raw body is over the cap, measured as the UTF-8 bytes the wire carried. */
17
+ export function turnBodyTooLarge(body: string): boolean {
18
+ return new TextEncoder().encode(body).byteLength > MAX_TURN_BODY_BYTES;
19
+ }
@@ -19,22 +19,43 @@ export function parseChunkCacheBytes(
19
19
  raw: string | undefined,
20
20
  warn: (message: string) => void = () => undefined,
21
21
  ): number {
22
- if (raw === undefined || raw.trim() === '') return DEFAULT_CHUNK_CACHE_BYTES;
22
+ return parseByteSize(raw, {
23
+ name: 'CHUNK_CACHE_BYTES',
24
+ fallback: DEFAULT_CHUNK_CACHE_BYTES,
25
+ min: MIN_CHUNK_CACHE_BYTES,
26
+ max: MAX_CHUNK_CACHE_BYTES,
27
+ warn,
28
+ });
29
+ }
30
+
31
+ /** Same grammar for any byte-sized env knob (`TIER_HOT_BUDGET_BYTES`, ...). */
32
+ export function parseByteSize(
33
+ raw: string | undefined,
34
+ options: {
35
+ name: string;
36
+ fallback: number;
37
+ min: number;
38
+ max: number;
39
+ warn?: (message: string) => void;
40
+ },
41
+ ): number {
42
+ const warn = options.warn ?? (() => undefined);
43
+ if (raw === undefined || raw.trim() === '') return options.fallback;
23
44
  const match = /^\s*(\d+)\s*([kKmM]?)\s*$/.exec(raw);
24
45
  if (!match) {
25
46
  warn(
26
- `CHUNK_CACHE_BYTES=${JSON.stringify(raw)} is not a byte count — using the default ${DEFAULT_CHUNK_CACHE_BYTES}`,
47
+ `${options.name}=${JSON.stringify(raw)} is not a byte count — using the default ${options.fallback}`,
27
48
  );
28
- return DEFAULT_CHUNK_CACHE_BYTES;
49
+ return options.fallback;
29
50
  }
30
51
  const unit = match[2]?.toLowerCase();
31
52
  const multiplier = unit === 'k' ? 1024 : unit === 'm' ? 1024 * 1024 : 1;
32
53
  const bytes = Number(match[1]) * multiplier;
33
- if (bytes < MIN_CHUNK_CACHE_BYTES || bytes > MAX_CHUNK_CACHE_BYTES) {
54
+ if (bytes < options.min || bytes > options.max) {
34
55
  warn(
35
- `CHUNK_CACHE_BYTES=${raw} is outside ${MIN_CHUNK_CACHE_BYTES}..${MAX_CHUNK_CACHE_BYTES} — using the default ${DEFAULT_CHUNK_CACHE_BYTES}`,
56
+ `${options.name}=${raw} is outside ${options.min}..${options.max} — using the default ${options.fallback}`,
36
57
  );
37
- return DEFAULT_CHUNK_CACHE_BYTES;
58
+ return options.fallback;
38
59
  }
39
60
  return bytes;
40
61
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The storage grain shared by the DO VFS and the R2 page tier. Kept in its
3
+ * own module so `page-tier.ts` can import it without a cycle through
4
+ * `do-vfs.ts`.
5
+ */
6
+
7
+ /** Bytes per SQLite page. Matches `PRAGMA page_size=4096`. */
8
+ export const VFS_PAGE_SIZE = 4096;
9
+
10
+ /** SQLite pages packed into one stored row (billing grain). */
11
+ export const PAGES_PER_CHUNK = 16;
12
+
13
+ /** Bytes per stored chunk row (64 KiB — well under the 2 MB DO value cap). */
14
+ export const CHUNK_SIZE = VFS_PAGE_SIZE * PAGES_PER_CHUNK;
@@ -31,14 +31,28 @@ import {
31
31
  VFS_PAGE_SIZE,
32
32
  type DoVfsOptions,
33
33
  type DoVfsStats,
34
+ type TierFlushResult,
35
+ type TierStatus,
34
36
  type VfsSnapshot,
35
37
  } from './do-vfs';
38
+ import type { PageTierOptions } from './page-tier';
36
39
  import {
37
40
  getOrRegisterVfs,
38
41
  loadSqlite,
39
42
  type SqliteRuntime,
40
43
  } from './wa-sqlite-loader';
41
44
 
45
+ /**
46
+ * Cold-miss retries. A statement outside a transaction re-runs after each
47
+ * resolve; a transaction rolls back, resolves, and re-runs its callback.
48
+ * Every resolve fetches whole 1 MiB segments (plus one of read-ahead) and
49
+ * pins them for the retry, so even a scan across a cold file converges in
50
+ * a handful of rounds; the caps only guard against a bug that keeps
51
+ * recording misses.
52
+ */
53
+ const MAX_STATEMENT_MISS_RETRIES = 64;
54
+ const MAX_TRANSACTION_MISS_RETRIES = 32;
55
+
42
56
  export type SqlValue = null | number | bigint | string | Uint8Array;
43
57
  export type SqlParam = SqlValue | boolean | undefined;
44
58
  export type SqlParams =
@@ -136,7 +150,10 @@ export class DoSqliteDatabase {
136
150
  }
137
151
  vfs.attach(ctx.storage);
138
152
  }
139
- if (!created) vfs.configureCache(options.cachePages);
153
+ if (!created) {
154
+ vfs.configureCache(options.cachePages);
155
+ vfs.configureTier(options.tier);
156
+ }
140
157
  const pragmas = options.pragmas ?? DEFAULT_PRAGMAS;
141
158
  const db = await DoSqliteDatabase.openConnection(
142
159
  runtime,
@@ -152,6 +169,38 @@ export class DoSqliteDatabase {
152
169
  vfs: DoVfs,
153
170
  fileName: string,
154
171
  pragmas: readonly string[],
172
+ ): Promise<number> {
173
+ // Opening reads the file header and the pragmas read page 1 — before
174
+ // any statement of ours could retry a cold miss. Chunk 0 is never
175
+ // evicted, but a pin-less retry here keeps a reopen robust regardless.
176
+ let pinned = false;
177
+ try {
178
+ for (let attempt = 0; ; attempt++) {
179
+ try {
180
+ return await DoSqliteDatabase.openConnectionOnce(
181
+ runtime,
182
+ vfs,
183
+ fileName,
184
+ pragmas,
185
+ );
186
+ } catch (error) {
187
+ if (!vfs.hasMisses() || attempt >= MAX_STATEMENT_MISS_RETRIES)
188
+ throw error;
189
+ await vfs.resolveMisses();
190
+ pinned = true;
191
+ vfs.missRetries++;
192
+ }
193
+ }
194
+ } finally {
195
+ if (pinned) vfs.releasePins();
196
+ }
197
+ }
198
+
199
+ private static async openConnectionOnce(
200
+ runtime: SqliteRuntime,
201
+ vfs: DoVfs,
202
+ fileName: string,
203
+ pragmas: readonly string[],
155
204
  ): Promise<number> {
156
205
  const db = await runtime.sqlite3.open_v2(
157
206
  fileName,
@@ -203,12 +252,54 @@ export class DoSqliteDatabase {
203
252
  sql: string,
204
253
  params?: SqlParams,
205
254
  ): Promise<T[]> {
206
- return this.serialized(() => this.execUnlocked<T>(sql, params));
255
+ return this.serialized(() =>
256
+ this.retryingColdMisses(() => this.execUnlocked<T>(sql, params)),
257
+ );
207
258
  }
208
259
 
209
260
  /** Run a statement that produces no rows of interest; returns the change count. */
210
261
  async run(sql: string, params?: SqlParams): Promise<RunResult> {
211
- return this.serialized(() => this.runUnlocked(sql, params));
262
+ return this.serialized(() =>
263
+ this.retryingColdMisses(() => this.runUnlocked(sql, params)),
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Run one statement, re-running it after a cold-miss resolve. Only
269
+ * outside a transaction: inside one, SQLite may have put the pager into
270
+ * its error state, so the transaction as a whole is rolled back and
271
+ * retried by `transaction()` instead.
272
+ */
273
+ private async retryingColdMisses<T>(op: () => Promise<T>): Promise<T> {
274
+ if (this.txContext.getStore() !== undefined || this.txDepth > 0)
275
+ return op();
276
+ let pinned = false;
277
+ try {
278
+ for (let attempt = 0; ; attempt++) {
279
+ try {
280
+ const result = await op();
281
+ // A few statements swallow an I/O error into their result instead
282
+ // of failing (`PRAGMA integrity_check` reports it as corruption):
283
+ // misses left behind by a "successful" statement mean exactly
284
+ // that, so resolve and run it again.
285
+ if (this.vfs.hasMisses() && attempt < MAX_STATEMENT_MISS_RETRIES) {
286
+ await this.vfs.resolveMisses();
287
+ pinned = true;
288
+ this.vfs.missRetries++;
289
+ continue;
290
+ }
291
+ return result;
292
+ } catch (error) {
293
+ if (!this.vfs.hasMisses() || attempt >= MAX_STATEMENT_MISS_RETRIES)
294
+ throw error;
295
+ await this.vfs.resolveMisses();
296
+ pinned = true;
297
+ this.vfs.missRetries++;
298
+ }
299
+ }
300
+ } finally {
301
+ if (pinned) this.vfs.releasePins();
302
+ }
212
303
  }
213
304
 
214
305
  /**
@@ -329,24 +420,50 @@ export class DoSqliteDatabase {
329
420
  });
330
421
  await previous;
331
422
  try {
332
- return await this.txContext.run({ depth: 1 }, async () => {
333
- await this.run('BEGIN IMMEDIATE');
334
- this.txDepth++;
335
- try {
336
- const result = await fn();
337
- this.txDepth--;
338
- await this.run('COMMIT');
339
- return result;
340
- } catch (error) {
341
- this.txDepth--;
423
+ // A cold page inside `fn` fails a statement with SQLITE_IOERR; the
424
+ // transaction is rolled back, the segment(s) fetched and pinned, and
425
+ // `fn` re-run from the top (it must be re-runnable: the checkpointer's
426
+ // and stores' callbacks only issue statements). The pins are released
427
+ // to the LRU once the transaction ends either way.
428
+ let pinned = false;
429
+ try {
430
+ for (let attempt = 0; ; attempt++) {
342
431
  try {
343
- await this.run('ROLLBACK');
344
- } catch {
345
- // The original error is more useful than a rollback failure.
432
+ return await this.txContext.run({ depth: 1 }, async () => {
433
+ await this.run('BEGIN IMMEDIATE');
434
+ this.txDepth++;
435
+ try {
436
+ const result = await fn();
437
+ this.txDepth--;
438
+ await this.run('COMMIT');
439
+ // Misses a statement swallowed (see `retryingColdMisses`)
440
+ // must not trigger a retry of some later, unrelated error.
441
+ this.vfs.clearMisses();
442
+ return result;
443
+ } catch (error) {
444
+ this.txDepth--;
445
+ try {
446
+ await this.run('ROLLBACK');
447
+ } catch {
448
+ // The original error is more useful than a rollback failure.
449
+ }
450
+ throw error;
451
+ }
452
+ });
453
+ } catch (error) {
454
+ if (
455
+ !this.vfs.hasMisses() ||
456
+ attempt >= MAX_TRANSACTION_MISS_RETRIES
457
+ )
458
+ throw error;
459
+ await this.vfs.resolveMisses();
460
+ pinned = true;
461
+ this.vfs.missRetries++;
346
462
  }
347
- throw error;
348
463
  }
349
- });
464
+ } finally {
465
+ if (pinned) this.vfs.releasePins();
466
+ }
350
467
  } finally {
351
468
  release();
352
469
  }
@@ -387,15 +504,17 @@ export class DoSqliteDatabase {
387
504
  static async wipe(
388
505
  ctx: DoSqliteContext,
389
506
  fileName = 'oracle.db',
507
+ options: { tier?: PageTierOptions } = {},
390
508
  ): Promise<void> {
391
509
  const runtime = await loadSqlite();
392
510
  const vfsName = vfsNameForObject(ctx);
393
511
  const vfs = getOrRegisterVfs(
394
512
  runtime,
395
513
  vfsName,
396
- () => new DoVfs(vfsName, ctx.storage),
514
+ () => new DoVfs(vfsName, ctx.storage, { tier: options.tier }),
397
515
  isDoVfs,
398
516
  );
517
+ vfs.configureTier(options.tier);
399
518
  for (const handle of vfs.takeConnections()) {
400
519
  try {
401
520
  await runtime.sqlite3.close(handle);
@@ -410,6 +529,53 @@ export class DoSqliteDatabase {
410
529
  // leaked xOpen handle cannot block the delete.
411
530
  vfs.attach(ctx.storage);
412
531
  vfs.deleteFile(fileName);
532
+ // The R2 side of the working copy goes with it (nothing references it
533
+ // any more; the owner's file upstream is untouched).
534
+ await vfs.deleteTierObjects();
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // R2 page tier controls (see do-vfs.ts / page-tier.ts)
539
+ // ---------------------------------------------------------------------------
540
+
541
+ get tierEnabled(): boolean {
542
+ return this.vfs.tierEnabled;
543
+ }
544
+
545
+ /** One eviction pass; holds the transaction mutex so no write interleaves. */
546
+ async tierFlush(
547
+ opts: { force?: boolean; maxSegments?: number } = {},
548
+ ): Promise<TierFlushResult> {
549
+ if (this.txDepth > 0)
550
+ throw new DoVfsError('tierFlush() is not allowed inside a transaction');
551
+ return this.withoutTransactions(() =>
552
+ this.vfs.tierFlush(this.fileName, opts),
553
+ );
554
+ }
555
+
556
+ /** Persist the chunks touched since the last call (call at the end of a turn). */
557
+ recordAccess(): void {
558
+ this.vfs.recordAccess(this.fileName);
559
+ }
560
+
561
+ tierStatus(): TierStatus {
562
+ return this.vfs.tierStatus(this.fileName);
563
+ }
564
+
565
+ /** Delete queued/orphaned R2 objects. */
566
+ async tierMaintenance(
567
+ opts: { sweep?: boolean } = {},
568
+ ): Promise<{ deleted: number }> {
569
+ return this.vfs.tierMaintenance(opts);
570
+ }
571
+
572
+ /** Pull every cold chunk back into storage (the file is then fully hot). */
573
+ async materializeTier(): Promise<{ chunks: number }> {
574
+ if (this.txDepth > 0)
575
+ throw new DoVfsError(
576
+ 'materializeTier() is not allowed inside a transaction',
577
+ );
578
+ return this.withoutTransactions(() => this.vfs.materialize(this.fileName));
413
579
  }
414
580
 
415
581
  /** Close the connection. Buffered pages are flushed by the VFS on close. */
@@ -535,6 +701,9 @@ export class DoSqliteDatabase {
535
701
  return this.withoutTransactions(async () => {
536
702
  const beforeBytes = this.fileSize;
537
703
  if (this.vfs.fileExists(target)) this.vfs.deleteFile(target);
704
+ // VACUUM reads the whole file through the synchronous path; a cold
705
+ // chunk would fail and restart it. Bring everything home first.
706
+ if (this.vfs.tierEnabled) await this.vfs.materialize(this.fileName);
538
707
  // `fileName` is a constant of the runtime, never user input.
539
708
  await this.run(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
540
709
  await this.close();