@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +2003 -274
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +562 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -101,6 +101,7 @@ import {
101
101
  bitmapHas,
102
102
  FULL_MASK,
103
103
  PageTier,
104
+ PageTierError,
104
105
  resolvePageTierOptions,
105
106
  SEGMENT_BYTES,
106
107
  SEGMENT_CHUNKS,
@@ -109,6 +110,7 @@ import {
109
110
  type PageTierOptions,
110
111
  type PageTierStats,
111
112
  type SegmentEntry,
113
+ type SweepStep,
112
114
  } from './page-tier';
113
115
 
114
116
  export { CHUNK_SIZE, PAGES_PER_CHUNK, VFS_PAGE_SIZE } from './chunk-layout';
@@ -142,6 +144,13 @@ export const IMPORT_FILE_SUFFIX = '.importing';
142
144
 
143
145
  const SQLITE_HEADER_MAGIC = 'SQLite format 3\0';
144
146
 
147
+ /**
148
+ * Times a cold-miss resolve re-fetches a segment whose map entry changed
149
+ * while its GET was in flight. One eviction pass rewrites a segment once,
150
+ * so a second change in a row means something keeps rewriting it.
151
+ */
152
+ const SEGMENT_REFETCH_LIMIT = 4;
153
+
145
154
  export interface DoVfsOptions {
146
155
  /**
147
156
  * Max clean pages held in the LRU cache (dirty pages are never evicted).
@@ -166,7 +175,39 @@ export interface TierFlushResult {
166
175
  /** Hot rows / bytes after the pass. */
167
176
  hotRows: number;
168
177
  hotBytes: number;
169
- skipped?: 'no-tier' | 'snapshot-open' | 'write-transaction' | 'in-progress';
178
+ skipped?: TierSkip;
179
+ }
180
+
181
+ type TierSkip =
182
+ | 'no-tier'
183
+ | 'snapshot-open'
184
+ | 'write-transaction'
185
+ | 'in-progress';
186
+
187
+ /**
188
+ * Runs a synchronous step while nothing else uses the file's connection
189
+ * (see `DoVfs.tierFlush`); resolves with the step's result.
190
+ */
191
+ export type Exclusive = <T>(step: () => T) => Promise<T>;
192
+
193
+ /** For direct VFS callers that issue no statements concurrently (tests). */
194
+ const runNow: Exclusive = async (step) => step();
195
+
196
+ /** What one eviction pass set out to do. */
197
+ interface TierPassPlan {
198
+ /** The open file (its flushes are tracked in `tierWrittenSince`), if open. */
199
+ file: PersistentFile | undefined;
200
+ totalChunks: number;
201
+ /** Hot rows when the pass planned. */
202
+ hotRows: number;
203
+ /** Eviction candidates (chunk numbers) per segment. */
204
+ bySegment: Map<number, number[]>;
205
+ /** Segments this pass rewrites (capped). */
206
+ todo: number[];
207
+ /** Segments with candidates, rewritten by this pass or a later one. */
208
+ segments: number;
209
+ /** `DoVfs.layoutEpoch` when the pass planned. */
210
+ epoch: number;
170
211
  }
171
212
 
172
213
  export interface TierStatus extends PageTierStats {
@@ -445,8 +486,28 @@ export class DoVfs implements SQLiteVFS {
445
486
  private pinnedBytes = 0;
446
487
  /** Statement/transaction retries the wrapper ran for cold misses (reported in `tierStatus`). */
447
488
  missRetries = 0;
448
- /** A tier pass in flight (one at a time per VFS). */
449
- private tierPassInFlight = false;
489
+ /**
490
+ * The tier pass in flight (one at a time per VFS), as an ownership token:
491
+ * only the pass that set it clears it, so a pass of an instance replaced
492
+ * by `attach()` cannot clear the flag of a newer one.
493
+ */
494
+ private tierPass: object | null = null;
495
+ /**
496
+ * The orphan sweep in flight (token as above). A sweep deletes every
497
+ * object the map does not reference — which includes one a pass uploaded
498
+ * but has not committed yet — so no pass starts while it runs and it
499
+ * never runs while another pass is in flight.
500
+ */
501
+ private tierSweep: object | null = null;
502
+ /** `exportFile`/`fileChecksum` reads in flight: no R2 object is deleted meanwhile. */
503
+ private windowReaders = 0;
504
+ /**
505
+ * Bumped by everything that rewrites chunk rows or the tier map other
506
+ * than a pass's own commit and an ordinary flush: a truncation, import,
507
+ * rename, delete, materialize, re-attach. A pass that sees it move
508
+ * between reading a segment and committing it gives up (see `tierFlush`).
509
+ */
510
+ private layoutEpoch = 0;
450
511
 
451
512
  constructor(
452
513
  readonly name: string,
@@ -509,7 +570,10 @@ export class DoVfs implements SQLiteVFS {
509
570
  this.misses.clear();
510
571
  this.pinned.clear();
511
572
  this.pinnedBytes = 0;
512
- this.tierPassInFlight = false;
573
+ this.tierPass = null;
574
+ this.tierSweep = null;
575
+ this.windowReaders = 0;
576
+ this.layoutEpoch++;
513
577
  this.lastFlushError = undefined;
514
578
  this.tier?.attach(storage);
515
579
  }
@@ -876,12 +940,21 @@ export class DoVfs implements SQLiteVFS {
876
940
  const out = new Uint8Array(size);
877
941
  const totalChunks = Math.ceil(size / CHUNK_SIZE);
878
942
  const segments = this.tier ? this.tier.segmentsOf(name) : new Map();
879
- for (let start = 0; start < totalChunks; start += SNAPSHOT_WINDOW_CHUNKS) {
880
- const end = Math.min(totalChunks, start + SNAPSHOT_WINDOW_CHUNKS);
881
- out.set(
882
- await this.readWindow(name, size, start, end, undefined, segments),
883
- start * CHUNK_SIZE,
884
- );
943
+ this.windowReaders++;
944
+ try {
945
+ for (
946
+ let start = 0;
947
+ start < totalChunks;
948
+ start += SNAPSHOT_WINDOW_CHUNKS
949
+ ) {
950
+ const end = Math.min(totalChunks, start + SNAPSHOT_WINDOW_CHUNKS);
951
+ out.set(
952
+ await this.readWindow(name, size, start, end, undefined, segments),
953
+ start * CHUNK_SIZE,
954
+ );
955
+ }
956
+ } finally {
957
+ this.windowReaders--;
885
958
  }
886
959
  return out;
887
960
  }
@@ -935,6 +1008,7 @@ export class DoVfs implements SQLiteVFS {
935
1008
  }
936
1009
  if (rows.length > 0) this.upsertChunks(name, rows);
937
1010
  });
1011
+ this.layoutEpoch++;
938
1012
  this.stats.storageWrites++;
939
1013
  }
940
1014
 
@@ -970,11 +1044,20 @@ export class DoVfs implements SQLiteVFS {
970
1044
  const hash = createHash('sha256');
971
1045
  const totalChunks = Math.ceil(size / CHUNK_SIZE);
972
1046
  const segments = this.tier ? this.tier.segmentsOf(name) : new Map();
973
- for (let start = 0; start < totalChunks; start += SNAPSHOT_WINDOW_CHUNKS) {
974
- const end = Math.min(totalChunks, start + SNAPSHOT_WINDOW_CHUNKS);
975
- hash.update(
976
- await this.readWindow(name, size, start, end, undefined, segments),
977
- );
1047
+ this.windowReaders++;
1048
+ try {
1049
+ for (
1050
+ let start = 0;
1051
+ start < totalChunks;
1052
+ start += SNAPSHOT_WINDOW_CHUNKS
1053
+ ) {
1054
+ const end = Math.min(totalChunks, start + SNAPSHOT_WINDOW_CHUNKS);
1055
+ hash.update(
1056
+ await this.readWindow(name, size, start, end, undefined, segments),
1057
+ );
1058
+ }
1059
+ } finally {
1060
+ this.windowReaders--;
978
1061
  }
979
1062
  return hash.digest('hex');
980
1063
  }
@@ -1233,6 +1316,7 @@ export class DoVfs implements SQLiteVFS {
1233
1316
  this.storage.sql.exec('DELETE FROM vfs2_files WHERE file = ?', staging);
1234
1317
  this.stats.rowsWritten += chunkno + 1;
1235
1318
  });
1319
+ this.layoutEpoch++;
1236
1320
  this.stats.storageWrites++;
1237
1321
  return total;
1238
1322
  } catch (error) {
@@ -1280,6 +1364,7 @@ export class DoVfs implements SQLiteVFS {
1280
1364
  this.storage.sql.exec('DELETE FROM vfs2_files WHERE file = ?', from);
1281
1365
  this.stats.rowsWritten += Math.ceil(meta.size / CHUNK_SIZE) + 1;
1282
1366
  });
1367
+ this.layoutEpoch++;
1283
1368
  this.stats.storageWrites++;
1284
1369
  }
1285
1370
 
@@ -1450,12 +1535,16 @@ export class DoVfs implements SQLiteVFS {
1450
1535
  /**
1451
1536
  * The chunk holding `chunkno`, from (in order) the dirty set, the clean
1452
1537
  * cache, then storage. Returns a buffer that must be treated as read-only
1453
- * unless it came from `dirty` — writers copy before mutating. Throws
1454
- * `ColdChunkMiss` when pages of it live only in R2 (see the file header).
1538
+ * unless it came from `dirty` — writers copy before mutating. Only the
1539
+ * pages in `wanted` are guaranteed valid: a partial row (or partial dirty
1540
+ * chunk) whose mask covers them is returned as is. Throws
1541
+ * `ColdChunkMiss` when a wanted page lives only in R2 (see the file
1542
+ * header).
1455
1543
  */
1456
1544
  private loadChunk(
1457
1545
  file: PersistentFile,
1458
1546
  chunkno: number,
1547
+ wanted: number = FULL_MASK,
1459
1548
  ): Uint8Array | undefined {
1460
1549
  this.touch(file, chunkno);
1461
1550
  const dirty = file.dirty.get(chunkno);
@@ -1477,12 +1566,18 @@ export class DoVfs implements SQLiteVFS {
1477
1566
  file.dirtyMask.set(chunkno, FULL_MASK);
1478
1567
  return dirty;
1479
1568
  }
1569
+ if ((wanted & ~mask) === 0) return dirty;
1480
1570
  throw new ColdChunkMiss(file.name, chunkno);
1481
1571
  }
1482
1572
  const local = this.lookupLocal(file, chunkno);
1483
1573
  if (local !== undefined && local.mask === FULL_MASK) return local.data;
1484
- if (this.tier !== null && this.tier.slotInR2(file.name, chunkno))
1574
+ if (this.tier !== null && this.tier.slotInR2(file.name, chunkno)) {
1575
+ // A partial row holding every wanted page serves the read. It is not
1576
+ // cached: the clean cache only holds whole chunks.
1577
+ if (local !== undefined && (wanted & ~local.mask) === 0)
1578
+ return local.data;
1485
1579
  throw new ColdChunkMiss(file.name, chunkno);
1580
+ }
1486
1581
  if (local === undefined) return undefined; // sparse
1487
1582
  // A partial row whose R2 slot is gone (truncated away): the rest is zeros.
1488
1583
  this.cache.set(file.name, chunkno, local.data);
@@ -1530,7 +1625,7 @@ export class DoVfs implements SQLiteVFS {
1530
1625
  const chunkno = Math.floor(pos / CHUNK_SIZE);
1531
1626
  const inChunk = pos - chunkno * CHUNK_SIZE;
1532
1627
  const n = Math.min(CHUNK_SIZE - inChunk, available - done);
1533
- const chunk = this.loadChunk(file, chunkno);
1628
+ const chunk = this.loadChunk(file, chunkno, pagesOf(inChunk, n));
1534
1629
  if (chunk === undefined) {
1535
1630
  pData.fill(0, done, done + n); // sparse region
1536
1631
  } else {
@@ -1701,6 +1796,7 @@ export class DoVfs implements SQLiteVFS {
1701
1796
  this.stats.rowsWritten += 1;
1702
1797
  });
1703
1798
  file.gen += 1;
1799
+ if (truncateTo !== null) this.layoutEpoch++;
1704
1800
  this.stats.storageWrites++;
1705
1801
  this.stats.flushes++;
1706
1802
  const pins = this.pinned.get(file.name);
@@ -1824,6 +1920,7 @@ export class DoVfs implements SQLiteVFS {
1824
1920
  this.storage.sql.exec('DELETE FROM vfs2_files WHERE file = ?', name);
1825
1921
  this.tier?.dropFile(name);
1826
1922
  });
1923
+ this.layoutEpoch++;
1827
1924
  this.stats.storageWrites++;
1828
1925
  }
1829
1926
 
@@ -1883,8 +1980,10 @@ export class DoVfs implements SQLiteVFS {
1883
1980
  for (let chunkno = first; chunkno <= last && !wanted; chunkno++)
1884
1981
  wanted = !pins.has(chunkno) && tier.slotInR2(name, chunkno);
1885
1982
  if (!wanted) continue;
1886
- const seg = await tier.getSegment(name, segno);
1983
+ const seg = await this.currentSegment(tier, name, segno);
1887
1984
  fetched++;
1985
+ // Synchronous from here on: the map, the hot rows read below and
1986
+ // `seg` all describe the same moment.
1888
1987
  const rows = this.storage.sql
1889
1988
  .exec<ChunkRow>(
1890
1989
  'SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno <= ?',
@@ -1933,6 +2032,46 @@ export class DoVfs implements SQLiteVFS {
1933
2032
  return fetched;
1934
2033
  }
1935
2034
 
2035
+ /**
2036
+ * The bytes of segment `segno` of `name` as the map references it when
2037
+ * this returns, or null when the map has no entry for it. An eviction
2038
+ * pass may rewrite the segment while the GET is in flight — a new
2039
+ * generation, and the hot rows it absorbed deleted — so a response for
2040
+ * a generation the map no longer points at is discarded and the current
2041
+ * one fetched (a pass committing in between can also delete the old
2042
+ * object, which then reads as missing).
2043
+ */
2044
+ private async currentSegment(
2045
+ tier: PageTier,
2046
+ name: string,
2047
+ segno: number,
2048
+ ): Promise<Uint8Array | null> {
2049
+ for (let attempt = 0; ; attempt++) {
2050
+ const mapped = tier.segmentsOf(name).get(segno);
2051
+ if (mapped === undefined) return null;
2052
+ const entry: SegmentEntry = { gen: mapped.gen, mask: mapped.mask };
2053
+ let bytes: Uint8Array | null;
2054
+ try {
2055
+ bytes = await tier.getSegmentAt(name, segno, entry);
2056
+ } catch (error) {
2057
+ if (
2058
+ !(error instanceof PageTierError) ||
2059
+ sameSegmentEntry(tier.segmentsOf(name).get(segno), entry) ||
2060
+ attempt >= SEGMENT_REFETCH_LIMIT
2061
+ )
2062
+ throw error;
2063
+ continue;
2064
+ }
2065
+ if (sameSegmentEntry(tier.segmentsOf(name).get(segno), entry))
2066
+ return bytes;
2067
+ if (attempt >= SEGMENT_REFETCH_LIMIT) {
2068
+ throw new DoVfsError(
2069
+ `segment ${segno} of '${name}' kept changing while it was fetched`,
2070
+ );
2071
+ }
2072
+ }
2073
+ }
2074
+
1936
2075
  /**
1937
2076
  * The retried statement/transaction is done: hand the pinned chunks to
1938
2077
  * the LRU (the most recently used survive) and free the pin budget.
@@ -2004,12 +2143,25 @@ export class DoVfs implements SQLiteVFS {
2004
2143
  /**
2005
2144
  * One eviction pass over `name` (see page-tier.ts): rows nobody touched
2006
2145
  * for `evictAfterPeriods` periods are written into their R2 segments and
2007
- * deleted. Must run outside a write transaction; skips while a snapshot is
2008
- * open. `force` evicts every clean row regardless of recency (tests, ops).
2146
+ * deleted. Skips while a snapshot is open or a write transaction holds
2147
+ * the file. `force` evicts every clean row regardless of recency (tests,
2148
+ * ops).
2149
+ *
2150
+ * `exclusive` runs a synchronous step with no transaction or statement
2151
+ * of the file's connection in flight (`DoSqliteDatabase` passes its
2152
+ * connection mutex). The pass takes it to plan, to read a segment's rows
2153
+ * and to commit the segment — never across its R2 GET/PUT — so turns and
2154
+ * cold-miss resolves interleave with the uploads. A commit re-checks what
2155
+ * those may have changed meanwhile: rows a flush rewrote stay hot
2156
+ * (`tierWrittenSince`); a moved segment entry, a truncation, import,
2157
+ * rename or delete of the file (`layoutEpoch`), a re-opened file or a
2158
+ * snapshot opened in between end the pass without committing that
2159
+ * segment (its upload is queued for deletion; the next pass starts over).
2009
2160
  */
2010
2161
  async tierFlush(
2011
2162
  name: string,
2012
2163
  opts: { force?: boolean; maxSegments?: number } = {},
2164
+ exclusive: Exclusive = runNow,
2013
2165
  ): Promise<TierFlushResult> {
2014
2166
  const tier = this.tier;
2015
2167
  const none = (skipped: TierFlushResult['skipped']): TierFlushResult => ({
@@ -2021,15 +2173,60 @@ export class DoVfs implements SQLiteVFS {
2021
2173
  skipped,
2022
2174
  });
2023
2175
  if (tier === null) return none('no-tier');
2024
- if (this.tierPassInFlight) return none('in-progress');
2025
- if (this.snapshots.has(name)) return none('snapshot-open');
2176
+ if (this.tierPass !== null || this.tierSweep !== null)
2177
+ return none('in-progress');
2178
+ const pass = {};
2179
+ this.tierPass = pass;
2180
+ let plan: TierPassPlan | undefined;
2181
+ try {
2182
+ const start = await exclusive(() => this.planTierPass(tier, name, opts));
2183
+ if ('skipped' in start) return none(start.skipped);
2184
+ plan = start;
2185
+ let evicted = 0;
2186
+ let rewritten = 0;
2187
+ for (const segno of plan.todo) {
2188
+ const outcome = await this.evictSegment(
2189
+ tier,
2190
+ name,
2191
+ segno,
2192
+ plan,
2193
+ exclusive,
2194
+ );
2195
+ if (outcome === null) break;
2196
+ evicted += outcome;
2197
+ rewritten++;
2198
+ }
2199
+ tier.stats.evictedChunks += evicted;
2200
+ tier.stats.evictionPasses++;
2201
+ await this.runTierMaintenance(tier, 'if-due', true);
2202
+ const hotRows = Math.max(0, plan.hotRows - evicted);
2203
+ return {
2204
+ evictedChunks: evicted,
2205
+ segmentsRewritten: rewritten,
2206
+ remaining: plan.segments - rewritten,
2207
+ hotRows,
2208
+ hotBytes: hotRows * CHUNK_SIZE,
2209
+ };
2210
+ } finally {
2211
+ if (this.tierPass === pass) this.tierPass = null;
2212
+ if (plan?.file !== undefined) plan.file.tierWrittenSince = null;
2213
+ }
2214
+ }
2215
+
2216
+ /** What a pass will evict (synchronous; runs under `exclusive`). */
2217
+ private planTierPass(
2218
+ tier: PageTier,
2219
+ name: string,
2220
+ opts: { force?: boolean; maxSegments?: number },
2221
+ ): TierPassPlan | { skipped: TierSkip } {
2222
+ if (this.snapshots.has(name)) return { skipped: 'snapshot-open' };
2026
2223
  const file = this.persistentByName.get(name);
2027
2224
  if (file !== undefined && file.lock >= SQLITE_LOCK_EXCLUSIVE)
2028
- return none('write-transaction');
2225
+ return { skipped: 'write-transaction' };
2029
2226
  this.ensureSchema();
2030
2227
  if (file !== undefined) this.flush(file);
2031
2228
  const size = file?.size ?? this.readStoredMeta(name)?.size;
2032
- if (size === undefined) return none('no-tier');
2229
+ if (size === undefined) return { skipped: 'no-tier' };
2033
2230
  const totalChunks = Math.ceil(size / CHUNK_SIZE);
2034
2231
  if (file !== undefined) this.persistTouches(file, tier);
2035
2232
  const recent = tier.recentBitmap(
@@ -2058,110 +2255,141 @@ export class DoVfs implements SQLiteVFS {
2058
2255
  if (list) list.push(row.chunkno);
2059
2256
  else bySegment.set(segno, [row.chunkno]);
2060
2257
  }
2061
- const plan = Array.from(bySegment.keys()).sort((a, b) => a - b);
2258
+ const segments = Array.from(bySegment.keys()).sort((a, b) => a - b);
2062
2259
  const cap = opts.maxSegments ?? tier.options.maxSegmentsPerPass;
2063
- const todo = plan.slice(0, cap);
2064
- let evicted = 0;
2065
- this.tierPassInFlight = true;
2066
2260
  if (file !== undefined) file.tierWrittenSince = new Set();
2067
- try {
2068
- for (const segno of todo) {
2069
- const candidates = bySegment.get(segno) ?? [];
2070
- const entry = tier.segmentsOf(name).get(segno);
2071
- const first = segno * SEGMENT_CHUNKS;
2072
- const last = Math.min(totalChunks, first + SEGMENT_CHUNKS) - 1;
2073
- const rows = this.storage.sql
2074
- .exec<ChunkRow>(
2075
- 'SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno <= ?',
2076
- name,
2077
- first,
2078
- last,
2079
- )
2080
- .toArray();
2081
- this.stats.storageReads++;
2082
- this.stats.rowsRead += rows.length;
2083
- const byChunk = new Map<number, ChunkRow>();
2084
- for (const row of rows) byChunk.set(row.chunkno, row);
2085
- // The old object is needed for every slot a full hot row does not cover.
2086
- let needOld = false;
2087
- if (entry !== undefined && entry.mask !== 0) {
2088
- for (let chunkno = first; chunkno <= last; chunkno++) {
2089
- const row = byChunk.get(chunkno);
2090
- const inR2 = (entry.mask & (1 << slotOf(chunkno))) !== 0;
2091
- if (inR2 && (row === undefined || row.mask !== FULL_MASK)) {
2092
- needOld = true;
2093
- break;
2094
- }
2095
- }
2096
- }
2097
- const old =
2098
- needOld && entry !== undefined
2099
- ? await tier.getSegmentAt(name, segno, entry)
2100
- : null;
2101
- const seg = new Uint8Array(SEGMENT_BYTES);
2102
- let mask = 0;
2103
- for (let chunkno = first; chunkno <= last; chunkno++) {
2104
- const slot = slotOf(chunkno);
2105
- const at = slot * CHUNK_SIZE;
2106
- const row = byChunk.get(chunkno);
2107
- const inR2 = entry !== undefined && (entry.mask & (1 << slot)) !== 0;
2108
- if (inR2 && old !== null)
2109
- seg.set(old.subarray(at, at + CHUNK_SIZE), at);
2110
- if (row !== undefined) {
2111
- placeMasked(
2112
- seg,
2113
- at,
2114
- CHUNK_SIZE,
2115
- new Uint8Array(row.data),
2116
- row.mask,
2117
- );
2118
- mask |= 1 << slot;
2119
- } else if (inR2) {
2120
- mask |= 1 << slot;
2121
- }
2122
- }
2123
- const gen = tier.allocateGen(name);
2124
- const newKey = await tier.putSegment(name, segno, gen, seg);
2125
- // Rows a flush wrote while the object was in flight are newer than
2126
- // what we uploaded: keep them hot.
2127
- const written = file?.tierWrittenSince;
2128
- const evict = candidates.filter(
2129
- (chunkno) =>
2130
- written === null || written === undefined || !written.has(chunkno),
2131
- );
2132
- this.storage.transactionSync(() => {
2133
- tier.setSegment(name, segno, { gen, mask });
2134
- if (evict.length > 0) {
2135
- this.storage.sql.exec(
2136
- `DELETE FROM vfs2_chunks WHERE file = ? AND chunkno IN (${evict.map(() => '?').join(', ')})`,
2137
- name,
2138
- ...evict,
2139
- );
2140
- this.stats.rowsWritten += evict.length;
2141
- }
2142
- });
2143
- this.stats.storageWrites++;
2144
- if (entry !== undefined) {
2145
- const oldKey = tier.key(name, segno, entry.gen);
2146
- if (oldKey !== newKey) tier.queueDelete(oldKey);
2261
+ return {
2262
+ file,
2263
+ totalChunks,
2264
+ hotRows: hot.length,
2265
+ bySegment,
2266
+ todo: segments.slice(0, cap),
2267
+ segments: segments.length,
2268
+ epoch: this.layoutEpoch,
2269
+ };
2270
+ }
2271
+
2272
+ /** Whether nothing a pass cannot account for happened to `name` since it planned. */
2273
+ private tierPassStillValid(name: string, plan: TierPassPlan): boolean {
2274
+ return (
2275
+ this.layoutEpoch === plan.epoch &&
2276
+ !this.snapshots.has(name) &&
2277
+ this.persistentByName.get(name) === plan.file &&
2278
+ (plan.file === undefined || plan.file.lock < SQLITE_LOCK_EXCLUSIVE)
2279
+ );
2280
+ }
2281
+
2282
+ /**
2283
+ * Rewrite segment `segno` from its hot rows plus its previous object,
2284
+ * then (under `exclusive`) point the map at the new object and delete the
2285
+ * evicted rows in one storage transaction. Returns the chunks evicted, or
2286
+ * null when the file changed under the pass (nothing committed).
2287
+ */
2288
+ private async evictSegment(
2289
+ tier: PageTier,
2290
+ name: string,
2291
+ segno: number,
2292
+ plan: TierPassPlan,
2293
+ exclusive: Exclusive,
2294
+ ): Promise<number | null> {
2295
+ const candidates = plan.bySegment.get(segno) ?? [];
2296
+ const first = segno * SEGMENT_CHUNKS;
2297
+ const last = Math.min(plan.totalChunks, first + SEGMENT_CHUNKS) - 1;
2298
+ const read = await exclusive(() => {
2299
+ if (!this.tierPassStillValid(name, plan)) return null;
2300
+ const mapped = tier.segmentsOf(name).get(segno);
2301
+ const rows = this.storage.sql
2302
+ .exec<ChunkRow>(
2303
+ 'SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno <= ?',
2304
+ name,
2305
+ first,
2306
+ last,
2307
+ )
2308
+ .toArray();
2309
+ this.stats.storageReads++;
2310
+ this.stats.rowsRead += rows.length;
2311
+ const byChunk = new Map<number, ChunkRow>();
2312
+ for (const row of rows) byChunk.set(row.chunkno, row);
2313
+ const entry: SegmentEntry | undefined =
2314
+ mapped === undefined
2315
+ ? undefined
2316
+ : { gen: mapped.gen, mask: mapped.mask };
2317
+ return { entry, byChunk };
2318
+ });
2319
+ if (read === null) return null;
2320
+ const { entry, byChunk } = read;
2321
+ // The old object is needed for every slot a full hot row does not cover.
2322
+ let needOld = false;
2323
+ if (entry !== undefined && entry.mask !== 0) {
2324
+ for (let chunkno = first; chunkno <= last; chunkno++) {
2325
+ const row = byChunk.get(chunkno);
2326
+ const inR2 = (entry.mask & (1 << slotOf(chunkno))) !== 0;
2327
+ if (inR2 && (row === undefined || row.mask !== FULL_MASK)) {
2328
+ needOld = true;
2329
+ break;
2147
2330
  }
2148
- evicted += evict.length;
2149
2331
  }
2150
- } finally {
2151
- this.tierPassInFlight = false;
2152
- if (file !== undefined) file.tierWrittenSince = null;
2153
2332
  }
2154
- tier.stats.evictedChunks += evicted;
2155
- tier.stats.evictionPasses++;
2156
- if (this.snapshots.size === 0) await tier.maintenance();
2157
- const hotRows = Math.max(0, hot.length - evicted);
2158
- return {
2159
- evictedChunks: evicted,
2160
- segmentsRewritten: todo.length,
2161
- remaining: plan.length - todo.length,
2162
- hotRows,
2163
- hotBytes: hotRows * CHUNK_SIZE,
2164
- };
2333
+ let old: Uint8Array | null = null;
2334
+ if (needOld && entry !== undefined) {
2335
+ try {
2336
+ old = await tier.getSegmentAt(name, segno, entry);
2337
+ } catch (error) {
2338
+ // An import, truncate or wipe meanwhile may have deleted it.
2339
+ if (!this.tierPassStillValid(name, plan)) return null;
2340
+ throw error;
2341
+ }
2342
+ }
2343
+ const seg = new Uint8Array(SEGMENT_BYTES);
2344
+ let mask = 0;
2345
+ for (let chunkno = first; chunkno <= last; chunkno++) {
2346
+ const slot = slotOf(chunkno);
2347
+ const at = slot * CHUNK_SIZE;
2348
+ const row = byChunk.get(chunkno);
2349
+ const inR2 = entry !== undefined && (entry.mask & (1 << slot)) !== 0;
2350
+ if (inR2 && old !== null) seg.set(old.subarray(at, at + CHUNK_SIZE), at);
2351
+ if (row !== undefined) {
2352
+ placeMasked(seg, at, CHUNK_SIZE, new Uint8Array(row.data), row.mask);
2353
+ mask |= 1 << slot;
2354
+ } else if (inR2) {
2355
+ mask |= 1 << slot;
2356
+ }
2357
+ }
2358
+ const gen = tier.allocateGen(name);
2359
+ const newKey = await tier.putSegment(name, segno, gen, seg);
2360
+ return exclusive(() => {
2361
+ if (
2362
+ !this.tierPassStillValid(name, plan) ||
2363
+ !sameSegmentEntry(tier.segmentsOf(name).get(segno), entry)
2364
+ ) {
2365
+ tier.queueDelete(newKey);
2366
+ return null;
2367
+ }
2368
+ // Rows a flush wrote while the object was in flight are newer than
2369
+ // what was uploaded: keep them hot.
2370
+ const written = plan.file?.tierWrittenSince;
2371
+ const evict = candidates.filter(
2372
+ (chunkno) =>
2373
+ written === null || written === undefined || !written.has(chunkno),
2374
+ );
2375
+ this.storage.transactionSync(() => {
2376
+ tier.setSegment(name, segno, { gen, mask });
2377
+ if (evict.length > 0) {
2378
+ this.storage.sql.exec(
2379
+ `DELETE FROM vfs2_chunks WHERE file = ? AND chunkno IN (${evict.map(() => '?').join(', ')})`,
2380
+ name,
2381
+ ...evict,
2382
+ );
2383
+ this.stats.rowsWritten += evict.length;
2384
+ }
2385
+ });
2386
+ this.stats.storageWrites++;
2387
+ if (entry !== undefined) {
2388
+ const oldKey = tier.key(name, segno, entry.gen);
2389
+ if (oldKey !== newKey) tier.queueDelete(oldKey);
2390
+ }
2391
+ return evict.length;
2392
+ });
2165
2393
  }
2166
2394
 
2167
2395
  /**
@@ -2223,17 +2451,58 @@ export class DoVfs implements SQLiteVFS {
2223
2451
  chunks += upserts.length;
2224
2452
  }
2225
2453
  this.storage.transactionSync(() => tier.dropFile(name));
2454
+ this.layoutEpoch++;
2226
2455
  this.cache.dropFile(name);
2227
- if (this.snapshots.size === 0) await tier.maintenance();
2456
+ await this.runTierMaintenance(tier, 'if-due');
2228
2457
  return { chunks };
2229
2458
  }
2230
2459
 
2231
- /** Delete queued/orphaned R2 objects (never while a snapshot reads them). */
2460
+ /**
2461
+ * Delete queued R2 objects, plus an orphan sweep: the whole prefix now
2462
+ * with `sweep`, else one bounded step when one is due (see
2463
+ * `PageTier.sweepDue`).
2464
+ */
2232
2465
  async tierMaintenance(
2233
2466
  opts: { sweep?: boolean } = {},
2234
2467
  ): Promise<{ deleted: number }> {
2235
- if (this.tier === null || this.snapshots.size > 0) return { deleted: 0 };
2236
- return this.tier.maintenance(opts);
2468
+ if (this.tier === null) return { deleted: 0 };
2469
+ return this.runTierMaintenance(
2470
+ this.tier,
2471
+ opts.sweep === true ? 'full' : 'if-due',
2472
+ );
2473
+ }
2474
+
2475
+ /**
2476
+ * The one way into `PageTier.maintenance`. Deletes nothing while a
2477
+ * snapshot or a whole-file read may still fetch an object the map has
2478
+ * moved away from. A sweep needs the tier to itself: never while a pass
2479
+ * other than the caller's own (`byOwnPass`) has uploads in flight, and
2480
+ * no pass starts while it runs.
2481
+ */
2482
+ private async runTierMaintenance(
2483
+ tier: PageTier,
2484
+ sweep: 'if-due' | 'full',
2485
+ byOwnPass = false,
2486
+ ): Promise<{ deleted: number }> {
2487
+ if (this.snapshots.size > 0 || this.windowReaders > 0)
2488
+ return { deleted: 0 };
2489
+ const alone =
2490
+ (byOwnPass || this.tierPass === null) && this.tierSweep === null;
2491
+ const step: SweepStep = !alone
2492
+ ? 'none'
2493
+ : sweep === 'full'
2494
+ ? 'full'
2495
+ : tier.sweepDue()
2496
+ ? 'step'
2497
+ : 'none';
2498
+ if (step === 'none') return tier.maintenance();
2499
+ const token = {};
2500
+ this.tierSweep = token;
2501
+ try {
2502
+ return await tier.maintenance({ sweep: step });
2503
+ } finally {
2504
+ if (this.tierSweep === token) this.tierSweep = null;
2505
+ }
2237
2506
  }
2238
2507
 
2239
2508
  /** Delete every R2 object of this VFS (the working copy is being wiped). */
@@ -2244,6 +2513,24 @@ export class DoVfs implements SQLiteVFS {
2244
2513
  }
2245
2514
  }
2246
2515
 
2516
+ /** Mask of the pages of a chunk that bytes `[from, from + length)` of it touch. */
2517
+ function pagesOf(from: number, length: number): number {
2518
+ const first = Math.floor(from / VFS_PAGE_SIZE);
2519
+ const last = Math.floor((from + Math.max(1, length) - 1) / VFS_PAGE_SIZE);
2520
+ let mask = 0;
2521
+ for (let page = first; page <= last; page++) mask |= 1 << page;
2522
+ return mask;
2523
+ }
2524
+
2525
+ /** Whether two map entries of a segment (absent = no object) are the same. */
2526
+ function sameSegmentEntry(
2527
+ a: SegmentEntry | undefined,
2528
+ b: SegmentEntry | undefined,
2529
+ ): boolean {
2530
+ if (a === undefined || b === undefined) return a === b;
2531
+ return a.gen === b.gen && a.mask === b.mask;
2532
+ }
2533
+
2247
2534
  /** Copy the pages of `mask` from `src` into `dst[offset..offset+n)` (page-aligned buffers). */
2248
2535
  function placeMasked(
2249
2536
  dst: Uint8Array,