@earendil-works/pi-durable 1.4.0 → 1.5.1

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 (270) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +195 -85
  3. package/dist/codemode/codemode.d.ts +6 -5
  4. package/dist/codemode/codemode.d.ts.map +1 -1
  5. package/dist/codemode/codemode.js +10 -49
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.d.ts +2 -1
  8. package/dist/codemode/models.d.ts.map +1 -1
  9. package/dist/codemode/models.js +4 -4
  10. package/dist/codemode/models.js.map +1 -1
  11. package/dist/documents.d.ts +1 -1
  12. package/dist/documents.d.ts.map +1 -1
  13. package/dist/documents.js +1 -1
  14. package/dist/documents.js.map +1 -1
  15. package/dist/entries.d.ts +1 -1
  16. package/dist/entries.d.ts.map +1 -1
  17. package/dist/entries.js.map +1 -1
  18. package/dist/env/index.d.ts +13 -11
  19. package/dist/env/index.d.ts.map +1 -1
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/node-watch.d.ts +4 -4
  22. package/dist/env/node-watch.d.ts.map +1 -1
  23. package/dist/env/node-watch.js +18 -16
  24. package/dist/env/node-watch.js.map +1 -1
  25. package/dist/env/node.d.ts +3 -3
  26. package/dist/env/node.d.ts.map +1 -1
  27. package/dist/env/node.js +1 -1
  28. package/dist/env/node.js.map +1 -1
  29. package/dist/errors.d.ts +18 -2
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js +26 -3
  32. package/dist/errors.js.map +1 -1
  33. package/dist/harness/agent.d.ts.map +1 -1
  34. package/dist/harness/agent.js +37 -3
  35. package/dist/harness/agent.js.map +1 -1
  36. package/dist/harness/compaction.d.ts +8 -3
  37. package/dist/harness/compaction.d.ts.map +1 -1
  38. package/dist/harness/compaction.js +40 -11
  39. package/dist/harness/compaction.js.map +1 -1
  40. package/dist/harness/context.d.ts +24 -8
  41. package/dist/harness/context.d.ts.map +1 -1
  42. package/dist/harness/context.js +71 -25
  43. package/dist/harness/context.js.map +1 -1
  44. package/dist/harness/define.d.ts +8 -4
  45. package/dist/harness/define.d.ts.map +1 -1
  46. package/dist/harness/define.js +4 -0
  47. package/dist/harness/define.js.map +1 -1
  48. package/dist/harness/generation.d.ts +39 -6
  49. package/dist/harness/generation.d.ts.map +1 -1
  50. package/dist/harness/generation.js +451 -100
  51. package/dist/harness/generation.js.map +1 -1
  52. package/dist/harness/harness.d.ts +3 -1
  53. package/dist/harness/harness.d.ts.map +1 -1
  54. package/dist/harness/harness.js +87 -62
  55. package/dist/harness/harness.js.map +1 -1
  56. package/dist/harness/inbox.d.ts +7 -2
  57. package/dist/harness/inbox.d.ts.map +1 -1
  58. package/dist/harness/inbox.js +5 -2
  59. package/dist/harness/inbox.js.map +1 -1
  60. package/dist/harness/json.d.ts +21 -5
  61. package/dist/harness/json.d.ts.map +1 -1
  62. package/dist/harness/json.js +23 -5
  63. package/dist/harness/json.js.map +1 -1
  64. package/dist/harness/live.d.ts +124 -17
  65. package/dist/harness/live.d.ts.map +1 -1
  66. package/dist/harness/live.js +93 -12
  67. package/dist/harness/live.js.map +1 -1
  68. package/dist/harness/partial-changes.d.ts +54 -0
  69. package/dist/harness/partial-changes.d.ts.map +1 -0
  70. package/dist/harness/partial-changes.js +163 -0
  71. package/dist/harness/partial-changes.js.map +1 -0
  72. package/dist/harness/prompt.d.ts +1 -1
  73. package/dist/harness/prompt.d.ts.map +1 -1
  74. package/dist/harness/prompt.js.map +1 -1
  75. package/dist/harness/provider.d.ts +1 -1
  76. package/dist/harness/provider.d.ts.map +1 -1
  77. package/dist/harness/provider.js.map +1 -1
  78. package/dist/harness/registry-reader.d.ts +11 -0
  79. package/dist/harness/registry-reader.d.ts.map +1 -0
  80. package/dist/harness/registry-reader.js +25 -0
  81. package/dist/harness/registry-reader.js.map +1 -0
  82. package/dist/harness/scheduler.d.ts +48 -20
  83. package/dist/harness/scheduler.d.ts.map +1 -1
  84. package/dist/harness/scheduler.js +1006 -380
  85. package/dist/harness/scheduler.js.map +1 -1
  86. package/dist/harness/submissions.d.ts +18 -11
  87. package/dist/harness/submissions.d.ts.map +1 -1
  88. package/dist/harness/submissions.js +63 -26
  89. package/dist/harness/submissions.js.map +1 -1
  90. package/dist/harness/task-graph.d.ts +14 -9
  91. package/dist/harness/task-graph.d.ts.map +1 -1
  92. package/dist/harness/task-graph.js +38 -28
  93. package/dist/harness/task-graph.js.map +1 -1
  94. package/dist/harness/telemetry.d.ts +11 -11
  95. package/dist/harness/telemetry.d.ts.map +1 -1
  96. package/dist/harness/telemetry.js +7 -7
  97. package/dist/harness/telemetry.js.map +1 -1
  98. package/dist/harness/tool.d.ts +3 -1
  99. package/dist/harness/tool.d.ts.map +1 -1
  100. package/dist/harness/tool.js +172 -56
  101. package/dist/harness/tool.js.map +1 -1
  102. package/dist/harness/transcript.d.ts +72 -0
  103. package/dist/harness/transcript.d.ts.map +1 -0
  104. package/dist/harness/transcript.js +264 -0
  105. package/dist/harness/transcript.js.map +1 -0
  106. package/dist/harness/types.d.ts +237 -61
  107. package/dist/harness/types.d.ts.map +1 -1
  108. package/dist/harness/types.js.map +1 -1
  109. package/dist/harness/usage.d.ts +2 -1
  110. package/dist/harness/usage.d.ts.map +1 -1
  111. package/dist/harness/usage.js +1 -1
  112. package/dist/harness/usage.js.map +1 -1
  113. package/dist/harness/util.d.ts +4 -2
  114. package/dist/harness/util.d.ts.map +1 -1
  115. package/dist/harness/util.js +9 -0
  116. package/dist/harness/util.js.map +1 -1
  117. package/dist/harness/view.d.ts +44 -22
  118. package/dist/harness/view.d.ts.map +1 -1
  119. package/dist/harness/view.js +130 -71
  120. package/dist/harness/view.js.map +1 -1
  121. package/dist/index.d.ts +9 -7
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +4 -3
  124. package/dist/index.js.map +1 -1
  125. package/dist/search.d.ts +19 -3
  126. package/dist/search.d.ts.map +1 -1
  127. package/dist/search.js +107 -44
  128. package/dist/search.js.map +1 -1
  129. package/dist/session/cached-storage.d.ts +72 -0
  130. package/dist/session/cached-storage.d.ts.map +1 -0
  131. package/dist/session/cached-storage.js +269 -0
  132. package/dist/session/cached-storage.js.map +1 -0
  133. package/dist/session/conversation-tails.d.ts +33 -0
  134. package/dist/session/conversation-tails.d.ts.map +1 -0
  135. package/dist/session/conversation-tails.js +101 -0
  136. package/dist/session/conversation-tails.js.map +1 -0
  137. package/dist/session/forks.d.ts +1 -1
  138. package/dist/session/forks.d.ts.map +1 -1
  139. package/dist/session/forks.js.map +1 -1
  140. package/dist/session/keys.d.ts +69 -0
  141. package/dist/session/keys.d.ts.map +1 -0
  142. package/dist/session/keys.js +161 -0
  143. package/dist/session/keys.js.map +1 -0
  144. package/dist/session/session.d.ts +169 -26
  145. package/dist/session/session.d.ts.map +1 -1
  146. package/dist/session/session.js +1029 -171
  147. package/dist/session/session.js.map +1 -1
  148. package/dist/session/transaction.d.ts +84 -5
  149. package/dist/session/transaction.d.ts.map +1 -1
  150. package/dist/session/transaction.js +227 -40
  151. package/dist/session/transaction.js.map +1 -1
  152. package/dist/session/writer.d.ts +74 -0
  153. package/dist/session/writer.d.ts.map +1 -0
  154. package/dist/session/writer.js +294 -0
  155. package/dist/session/writer.js.map +1 -0
  156. package/dist/storage/indexeddb.d.ts +12 -3
  157. package/dist/storage/indexeddb.d.ts.map +1 -1
  158. package/dist/storage/indexeddb.js +91 -41
  159. package/dist/storage/indexeddb.js.map +1 -1
  160. package/dist/storage/jsonl/index.d.ts +1 -1
  161. package/dist/storage/jsonl/index.d.ts.map +1 -1
  162. package/dist/storage/jsonl/index.js +1 -1
  163. package/dist/storage/jsonl/index.js.map +1 -1
  164. package/dist/storage/jsonl/node.d.ts +3 -3
  165. package/dist/storage/jsonl/node.d.ts.map +1 -1
  166. package/dist/storage/jsonl/node.js +2 -2
  167. package/dist/storage/jsonl/node.js.map +1 -1
  168. package/dist/storage/jsonl/storage.d.ts +46 -5
  169. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  170. package/dist/storage/jsonl/storage.js +158 -43
  171. package/dist/storage/jsonl/storage.js.map +1 -1
  172. package/dist/storage/memory.d.ts +9 -3
  173. package/dist/storage/memory.d.ts.map +1 -1
  174. package/dist/storage/memory.js +28 -2
  175. package/dist/storage/memory.js.map +1 -1
  176. package/dist/storage/sqlite/commit.d.ts +52 -0
  177. package/dist/storage/sqlite/commit.d.ts.map +1 -0
  178. package/dist/storage/sqlite/commit.js +371 -0
  179. package/dist/storage/sqlite/commit.js.map +1 -0
  180. package/dist/storage/sqlite/rows.d.ts +33 -0
  181. package/dist/storage/sqlite/rows.d.ts.map +1 -0
  182. package/dist/storage/sqlite/rows.js +53 -0
  183. package/dist/storage/sqlite/rows.js.map +1 -0
  184. package/dist/storage/sqlite/statements.d.ts +13 -0
  185. package/dist/storage/sqlite/statements.d.ts.map +1 -0
  186. package/dist/storage/sqlite/statements.js +88 -0
  187. package/dist/storage/sqlite/statements.js.map +1 -0
  188. package/dist/storage/sqlite/storage.d.ts +23 -10
  189. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  190. package/dist/storage/sqlite/storage.js +120 -261
  191. package/dist/storage/sqlite/storage.js.map +1 -1
  192. package/dist/storage/store/commit.d.ts +33 -0
  193. package/dist/storage/store/commit.d.ts.map +1 -0
  194. package/dist/storage/store/commit.js +286 -0
  195. package/dist/storage/store/commit.js.map +1 -0
  196. package/dist/storage/store/documents.d.ts +43 -0
  197. package/dist/storage/store/documents.d.ts.map +1 -0
  198. package/dist/storage/store/documents.js +102 -0
  199. package/dist/storage/store/documents.js.map +1 -0
  200. package/dist/storage/store/index.d.ts +2 -0
  201. package/dist/storage/store/index.d.ts.map +1 -0
  202. package/dist/storage/store/index.js +2 -0
  203. package/dist/storage/store/index.js.map +1 -0
  204. package/dist/storage/store/queries.d.ts +49 -0
  205. package/dist/storage/store/queries.d.ts.map +1 -0
  206. package/dist/storage/store/queries.js +93 -0
  207. package/dist/storage/store/queries.js.map +1 -0
  208. package/dist/storage/store/storage.d.ts +60 -0
  209. package/dist/storage/store/storage.d.ts.map +1 -0
  210. package/dist/storage/store/storage.js +438 -0
  211. package/dist/storage/store/storage.js.map +1 -0
  212. package/dist/storage/store/tables.d.ts +54 -0
  213. package/dist/storage/store/tables.d.ts.map +1 -0
  214. package/dist/storage/store/tables.js +108 -0
  215. package/dist/storage/store/tables.js.map +1 -0
  216. package/dist/testing/commit-writes.d.ts +5 -0
  217. package/dist/testing/commit-writes.d.ts.map +1 -0
  218. package/dist/testing/commit-writes.js +12 -0
  219. package/dist/testing/commit-writes.js.map +1 -0
  220. package/dist/testing/env-conformance.js +7 -7
  221. package/dist/testing/env-conformance.js.map +1 -1
  222. package/dist/testing/index.d.ts +1 -0
  223. package/dist/testing/index.d.ts.map +1 -1
  224. package/dist/testing/index.js +1 -0
  225. package/dist/testing/index.js.map +1 -1
  226. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  227. package/dist/testing/storage-benchmark.js +18 -17
  228. package/dist/testing/storage-benchmark.js.map +1 -1
  229. package/dist/testing/storage-conformance.d.ts.map +1 -1
  230. package/dist/testing/storage-conformance.js +354 -69
  231. package/dist/testing/storage-conformance.js.map +1 -1
  232. package/dist/tool-search/tool-search.d.ts +8 -14
  233. package/dist/tool-search/tool-search.d.ts.map +1 -1
  234. package/dist/tool-search/tool-search.js +25 -13
  235. package/dist/tool-search/tool-search.js.map +1 -1
  236. package/dist/tools/bash.d.ts +13 -13
  237. package/dist/tools/bash.d.ts.map +1 -1
  238. package/dist/tools/bash.js +16 -15
  239. package/dist/tools/bash.js.map +1 -1
  240. package/dist/tools/edit.d.ts +7 -7
  241. package/dist/tools/edit.d.ts.map +1 -1
  242. package/dist/tools/edit.js +9 -9
  243. package/dist/tools/edit.js.map +1 -1
  244. package/dist/tools/file-mutation-queue.d.ts +1 -1
  245. package/dist/tools/file-mutation-queue.d.ts.map +1 -1
  246. package/dist/tools/file-mutation-queue.js.map +1 -1
  247. package/dist/tools/index.d.ts +32 -32
  248. package/dist/tools/path-utils.d.ts +1 -1
  249. package/dist/tools/path-utils.d.ts.map +1 -1
  250. package/dist/tools/path-utils.js.map +1 -1
  251. package/dist/tools/read.d.ts +6 -6
  252. package/dist/tools/read.d.ts.map +1 -1
  253. package/dist/tools/read.js +19 -8
  254. package/dist/tools/read.js.map +1 -1
  255. package/dist/tools/write.d.ts +4 -4
  256. package/dist/tools/write.d.ts.map +1 -1
  257. package/dist/tools/write.js +5 -5
  258. package/dist/tools/write.js.map +1 -1
  259. package/dist/types.d.ts +228 -78
  260. package/dist/types.d.ts.map +1 -1
  261. package/dist/types.js.map +1 -1
  262. package/package.json +19 -12
  263. package/dist/harness/events.d.ts +0 -186
  264. package/dist/harness/events.d.ts.map +0 -1
  265. package/dist/harness/events.js +0 -373
  266. package/dist/harness/events.js.map +0 -1
  267. package/dist/session/observation.d.ts +0 -42
  268. package/dist/session/observation.d.ts.map +0 -1
  269. package/dist/session/observation.js +0 -281
  270. package/dist/session/observation.js.map +0 -1
@@ -1,14 +1,33 @@
1
- import { replicatedState } from "@earendil-works/chord";
2
1
  import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
3
- import { track } from "@earendil-works/chord/delta";
2
+ import { PendingOps, track } from "@earendil-works/chord/delta";
3
+ import { Watch } from "@earendil-works/chord/watch";
4
4
  import { addressId, checkRecordScope, checkRecordVersion, materializeDocument, resolveAddress, } from "../documents.js";
5
- import { SessionFailed, StorageRequestError } from "../errors.js";
5
+ import { ReadOnlySession, SessionFailed, StorageRequestError } from "../errors.js";
6
6
  import { idFromNumber } from "../ids.js";
7
7
  import { hostTimers } from "../timers.js";
8
- import { CommittedStateSource, CommittedWatch, RETIREMENT_OPERATIONS, } from "./observation.js";
9
- import { Transaction } from "./transaction.js";
8
+ import { CachedStorage, scopeOf } from "./cached-storage.js";
9
+ import { ConversationTails } from "./conversation-tails.js";
10
+ import { ALL_KEYS, appendKey, conversationKey, documentKey, GatedStorage, incarnationKey } from "./keys.js";
11
+ import { storeHeld, Transaction, } from "./transaction.js";
12
+ import { Writer } from "./writer.js";
13
+ const NO_HOLDS = [];
14
+ const NO_OPERATIONS = [];
15
+ const NO_KEYS = [];
16
+ /**
17
+ * Jobs admitted to the line whose outcome is not delivered yet, beyond which the line waits. A count, not bytes: what
18
+ * waits is bounded by what one commit can hold (tool output by its output limits), times the count.
19
+ */
20
+ const MAX_OUTSTANDING = 256;
21
+ const ignore = () => { };
22
+ /**
23
+ * Queues a commit's host dispatch as one microtask through `then`: in the same queue as `queueMicrotask()`, which Node
24
+ * wraps in an async resource that costs about twice the memory and three times the time per stored commit.
25
+ */
26
+ const SETTLED = Promise.resolve();
10
27
  /** The default source of a report: a listener, which never fails what ran it. */
11
- const LISTENER = { type: "listener_error" };
28
+ const LISTENER = { kind: "listener_error" };
29
+ /** A telemetry adapter that threw on a commit's event, which must not fail the durable commit. */
30
+ const TELEMETRY = { kind: "host_error", callback: "telemetry" };
12
31
  /**
13
32
  * Open a Session kernel over one storage backend. `now` is the wall clock for task times; default `Date.now`.
14
33
  * `setTimer` arms the timers that evict idle documents; default `setTimeout` (see `HarnessOptions.setTimer`). The first
@@ -16,7 +35,8 @@ const LISTENER = { type: "listener_error" };
16
35
  */
17
36
  export function createSession(storage, options) {
18
37
  const retention = options?.documentRetentionMs;
19
- return new SessionImpl(storage, options?.now, retention === undefined ? undefined : () => retention, options?.setTimer);
38
+ const within = options?.persistWithinMs;
39
+ return new SessionImpl(storage, options?.now, retention === undefined ? undefined : () => retention, options?.setTimer, false, () => within);
20
40
  }
21
41
  /** Default document retention: ten minutes, as for kept contexts. */
22
42
  let defaultRetention = 600_000;
@@ -31,15 +51,35 @@ export function setDefaultDocumentRetention(ms) {
31
51
  export function documentRetention(ms) {
32
52
  return typeof ms === "number" && ms >= 0 ? ms : defaultRetention;
33
53
  }
54
+ /** Default `withinMs` of a deferred commit, `settings.progress.persistWithinMs`. */
55
+ const DEFAULT_PERSIST_WITHIN_MS = 1_000;
56
+ /**
57
+ * A deferred commit's default window as given, when it is a number of milliseconds, `0` or more (`Infinity` waits for a
58
+ * reading or writing commit or close); otherwise the default.
59
+ */
60
+ export function persistWithin(ms) {
61
+ return typeof ms === "number" && ms >= 0 ? ms : DEFAULT_PERSIST_WITHIN_MS;
62
+ }
34
63
  /**
35
64
  * Session kernel: one mutation line, the loaded document tracker cache, and committed publication.
36
65
  *
37
- * The cache is bounded by use: a document with no watch or document state attached leaves it once it was not read or
66
+ * The cache is bounded by use: a document with no watch attached leaves it once it was not read or
38
67
  * written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation
39
68
  * line, so no transaction ever holds a tracker that leaves.
40
69
  *
41
- * Only committed state is observable. Every commit callback, preparation, Storage settlement, adoption, and
42
- * publication enqueue runs while the line is held; listeners run later.
70
+ * Only committed state is observable. A deferred commit (`CommitPersistence`) is committed in memory first: it is
71
+ * adopted and shown to the Session's own observers, and its changes wait, folded per document and pinned in the cache,
72
+ * for the first commit that reads or writes the document, the document's deadline, a drop of its tracker, or close,
73
+ * which store them.
74
+ *
75
+ * Group commit: the line runs one job at a time, a commit's callback and settlement; a commit with writes then joins
76
+ * the writer's pending batch (logical admission, `Writer`) and the line goes on. The writer stores every commit admitted
77
+ * while no call is in flight in one `commit()` call, off the line, and publishes them one by one, in line order, a
78
+ * macrotask apart: the read caches, adoption, telemetry events, the Session's own observers, and one microtask later
79
+ * `observeCommits()` observers, before every wait the commit resolves and every abort it queues. Host listeners and
80
+ * watch listeners have no order. A deferred commit is published when the commits before it are. A job's every read
81
+ * touches its key (`touch()`); while an unpublished commit writes that key, the job waits for its publication, so no
82
+ * job observes state that is not stored. Outcomes are delivered in line order (`#enqueue()`).
43
83
  *
44
84
  * Storage failures are final. Every Storage call goes through one guard: the first error a Storage method throws, other
45
85
  * than for a cancelled caller, fails the Session, as does a commit it cannot adopt. That call gets the error; every later
@@ -49,6 +89,13 @@ export function documentRetention(ms) {
49
89
  */
50
90
  export class SessionImpl {
51
91
  #storage;
92
+ /** Newest entry and head marker per conversation; none for a read-only Session, whose Storage another process writes. */
93
+ #tails;
94
+ /**
95
+ * The current documents of each live task this Session created, so the commit that ends a task retires them without
96
+ * a scan. An entry starts with its task's creation and goes when the task is terminal: bounded by live tasks.
97
+ */
98
+ #taskDocuments = new Map();
52
99
  #documents = new Map();
53
100
  /** Cached documents with no observer attached, by address, oldest use first, with the time of that use. */
54
101
  #idleDocuments = new Map();
@@ -56,18 +103,45 @@ export class SessionImpl {
56
103
  #attachedDocuments = new Map();
57
104
  #clock;
58
105
  #documentRetentionMs;
106
+ #persistWithinMs;
107
+ /** Documents with held changes, by incarnation (record ID); each is pinned in the cache until they are stored. */
108
+ #holds = new Map();
109
+ #holdTimer;
110
+ /** Refuse every commit with writes (`HarnessOptions.readOnly`). */
111
+ #readOnly;
59
112
  #timers;
60
113
  /** The pending eviction: a timer for the oldest idle document, or a line job when the retention is 0. */
61
114
  #documentSweep;
62
115
  #sweepJobs = 0;
63
116
  /** Set once the host declined a background timer: then none is kept, and the end of each commit evicts what expired. */
64
117
  #documentTimersUnavailable = false;
118
+ /** Host commit observers (`observeCommits()`), each behind `guardObserver()`. */
65
119
  #commitListeners = new Set();
66
120
  /** The Session's own listeners, such as its scheduler's: they keep memory in step with storage, so a throw fails it. */
67
121
  #internalListeners = new Set();
68
122
  #closeListeners = new Set();
69
123
  #host;
124
+ /** The record cache under the guard, which the writer applies each batch to at its publication. */
125
+ #cached;
126
+ #writer;
127
+ /** The Storage jobs on the line read through: each read waits while its key has an unpublished writer. */
128
+ #gated;
129
+ /** The end of the last line job: the line runs one job at a time. */
70
130
  #tail = Promise.resolve();
131
+ /** Line index of the next job; outcomes are delivered in this order. */
132
+ #nextIndex = 0;
133
+ /** The job on the line, while one runs: its index, and whether it waited at a gate. */
134
+ #current;
135
+ /** Settles once every job so far has its outcome delivered. */
136
+ #fence = Promise.resolve();
137
+ #lastDelivered = -1;
138
+ /** Jobs that started on the line and have no outcome delivered yet (`MAX_OUTSTANDING`). */
139
+ #outstanding = 0;
140
+ #room;
141
+ /** The line index whose outcome the failure is: every later job's outcome is `SessionFailed`, except `#ownOutcomes`. */
142
+ #failAt;
143
+ /** Later jobs whose outcome is their own error all the same: the other parts of a storage call that rejected. */
144
+ #ownOutcomes = new Set();
71
145
  #closing;
72
146
  #failure;
73
147
  /** Set when close reaches the backend: calls that fail from then on fail because it closes. */
@@ -84,11 +158,27 @@ export class SessionImpl {
84
158
  get failed() {
85
159
  return this.#failed.promise;
86
160
  }
87
- constructor(storage, now = Date.now, documentRetentionMs = () => undefined, setTimer) {
161
+ constructor(storage, now = Date.now, documentRetentionMs = () => undefined, setTimer, readOnly = false, persistWithinMs = () => undefined) {
88
162
  // Waits race it; none has to.
89
163
  this.#failed.promise.catch(() => { });
90
- this.#storage = new GuardedStorage(storage, this);
91
- this.#timers = hostTimers(setTimer, (error) => this.report(error, { type: "host_error", callback: "setTimer" }));
164
+ this.#readOnly = readOnly;
165
+ // A read-only Session shows a Storage another process writes, so nothing it reads stays current.
166
+ this.#cached = readOnly ? undefined : new CachedStorage(storage);
167
+ this.#storage = new GuardedStorage(this.#cached ?? storage, this);
168
+ this.#gated = new GatedStorage(this.#storage, (key) => this.touch(key));
169
+ this.#writer = new Writer({
170
+ commit: (batches, receive, context) => this.#storage.commit(batches, receive, context),
171
+ stored: (writes) => {
172
+ this.#cached?.published(writes);
173
+ this.#tails?.apply(writes);
174
+ },
175
+ publishing: (publish, context) => this.#publishing(publish, context),
176
+ failure: () => this.#failure,
177
+ fail: (error, at, own) => this.fail(error, undefined, at, own),
178
+ report: (error, context) => this.report(error, LISTENER, context),
179
+ });
180
+ this.#tails = readOnly ? undefined : new ConversationTails();
181
+ this.#timers = hostTimers(setTimer, (error) => this.report(error, { kind: "host_error", callback: "setTimer" }));
92
182
  // Cache bookkeeping must never change the outcome of the operation it follows: a clock or a retention setting
93
183
  // that throws counts as now, and as the default.
94
184
  this.#clock = () => {
@@ -107,8 +197,17 @@ export class SessionImpl {
107
197
  return documentRetention(undefined);
108
198
  }
109
199
  };
200
+ this.#persistWithinMs = () => {
201
+ try {
202
+ return persistWithin(persistWithinMs());
203
+ }
204
+ catch {
205
+ return persistWithin(undefined);
206
+ }
207
+ };
110
208
  this.#host = {
111
- storage: this.#storage,
209
+ storage: this.#gated,
210
+ touch: (key) => this.touch(key),
112
211
  now,
113
212
  cached: (id) => {
114
213
  const cached = this.#documents.get(id);
@@ -126,45 +225,167 @@ export class SessionImpl {
126
225
  this.#dropDocument(id);
127
226
  },
128
227
  conversationCreated: (tx, record) => this.conversationCreated(tx, record),
228
+ taskDocuments: (taskId) => this.#taskDocuments.get(taskId)?.values(),
229
+ latestEntryId: (conversationId, context) => this.latestEntryId(conversationId, context),
230
+ latestHeadMarker: (conversationId, context) => this.latestHeadMarker(conversationId, undefined, context),
129
231
  };
130
232
  }
131
233
  /** The Storage behind the failure guard; every component reads and writes through it. */
132
234
  get storage() {
133
235
  return this.#storage;
134
236
  }
135
- commit(change, context) {
136
- return this.commitWith(change, context);
237
+ /**
238
+ * Internal, on the Session line: the newest entry ID visible from `conversationId`. Known from the Session's own
239
+ * commits when it owns its Storage, so it costs no Storage read after the first.
240
+ */
241
+ latestEntryId(conversationId, context) {
242
+ const gate = this.#touchEntries(conversationId);
243
+ const latest = () => {
244
+ const read = async () => (await this.#storage.scanEntries({ conversationId }, 1, undefined, context)).items[0]?.id;
245
+ return this.#tails === undefined ? read() : this.#tails.latest(conversationId, read);
246
+ };
247
+ return gate === undefined ? latest() : gate.then(latest);
248
+ }
249
+ /**
250
+ * Internal: the newest entry ID of `conversationId` as known from the Session's commits right now, without reading;
251
+ * `undefined` when not known (and always for a read-only Session).
252
+ */
253
+ knownLatestEntryId(conversationId) {
254
+ return this.#tails?.peek(conversationId);
255
+ }
256
+ /**
257
+ * Internal, on the Session line: the newest visible head marker of `conversationId` at or before `atOrBefore`, any
258
+ * when `undefined`; known from the Session's own commits as `latestEntryId()` is.
259
+ */
260
+ latestHeadMarker(conversationId, atOrBefore, context) {
261
+ const gate = this.#touchEntries(conversationId);
262
+ const marker = () => {
263
+ const read = () => this.#storage.findLatestHeadMarker(conversationId, atOrBefore, context);
264
+ return this.#tails === undefined ? read() : this.#tails.marker(conversationId, atOrBefore, read);
265
+ };
266
+ return gate === undefined ? marker() : gate.then(marker);
267
+ }
268
+ /**
269
+ * On the line: wait for what an unpublished commit wrote of a conversation's entries, its appends and its creation,
270
+ * as reading them from Storage fails for a conversation not stored yet.
271
+ */
272
+ #touchEntries(conversationId) {
273
+ const created = this.touch(conversationKey(conversationId));
274
+ const appended = this.touch(appendKey(conversationId));
275
+ if (created === undefined)
276
+ return appended;
277
+ return appended === undefined ? created : Promise.all([created, appended]).then(ignore);
278
+ }
279
+ /**
280
+ * Internal, on the line: wait, while `key` has an unpublished writer (for `ALL_KEYS`, while anything is unpublished),
281
+ * until it is published, then check that the Session is healthy; `undefined` when there is nothing to wait for. A
282
+ * job's every read touches its key first, so it never observes state an earlier job admitted and the writer has not
283
+ * published.
284
+ */
285
+ touch(key) {
286
+ const gate = key === ALL_KEYS ? this.#writer.drained() : this.#writer.gate(key);
287
+ if (gate === undefined)
288
+ return undefined;
289
+ if (this.#current !== undefined)
290
+ this.#current.hit = true;
291
+ return gate.then(() => this.#assertHealthy());
292
+ }
293
+ commit(change, context, options) {
294
+ return this.commitWith(change, context, undefined, options);
137
295
  }
138
296
  /**
139
297
  * Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run
140
298
  * now; they read the error from `failure`. The Storage guard calls this; the Harness also calls it for a throw in its
141
- * scheduler's own commits.
299
+ * scheduler's own commits. `at` is the line index of the job whose outcome the failure is, by default the job on the
300
+ * line: later jobs' outcomes become `SessionFailed`.
142
301
  */
143
- fail(error, context) {
302
+ fail(error, context, at, own) {
144
303
  if (this.#failure !== undefined)
145
304
  return;
146
305
  this.#failure = { error };
306
+ // The job that caused it keeps its own outcome; without one, every outcome not delivered yet is `SessionFailed`.
307
+ this.#failAt = at ?? this.#current?.index ?? this.#lastDelivered;
308
+ if (own !== undefined)
309
+ for (const index of own)
310
+ this.#ownOutcomes.add(index);
147
311
  this.#failed.reject(new SessionFailed(error));
148
312
  // Seal first, so a report handler that calls back finds the Session failed. Closing runs the close listeners
149
313
  // synchronously; a failing backend close is the caller's to see, and here nobody waits.
150
314
  this.close(BACKGROUND_CONTEXT).catch(() => { });
151
- this.report(error, { type: "session_failed" }, context);
315
+ this.report(error, { kind: "storage_failed" }, context);
152
316
  }
153
317
  /**
154
318
  * Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root
155
319
  * bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to
156
320
  * appended entries.
157
321
  */
158
- commitWith(change, context, scope) {
322
+ commitWith(change, context, scope, options) {
323
+ let withinMs;
159
324
  try {
160
325
  this.assertUsable();
326
+ withinMs = this.#deferral(options?.persistence);
161
327
  }
162
328
  catch (error) {
163
329
  return Promise.reject(error);
164
330
  }
165
- return this.#enqueue(() => this.#runCommit(change, context, scope));
331
+ return this.#enqueue((index) => this.#runCommit(change, context, scope, withinMs, index));
166
332
  }
167
- /** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */
333
+ /**
334
+ * Internal: `commitWith()` for a commit of the scheduler's own whose outcome goes only to scheduler code. When it
335
+ * writes nothing and waited at no gate, it is equivalent to having run before every unpublished commit, so its
336
+ * outcome is delivered at once, ahead of theirs. One that writes is never exempt: its outcome waits for its own
337
+ * publication and every earlier outcome, so no invocation starts on a reservation that is not durable.
338
+ */
339
+ commitInternal(change, context) {
340
+ try {
341
+ this.assertUsable();
342
+ }
343
+ catch (error) {
344
+ return Promise.reject(error);
345
+ }
346
+ return this.#enqueue((index) => this.#runCommit(change, context, undefined, undefined, index), true);
347
+ }
348
+ /**
349
+ * Internal, on the line: call `end` once the outcome of the job on the line settles, its commit published or never to
350
+ * be; at once off the line.
351
+ */
352
+ onJobEnd(end) {
353
+ const job = this.#current;
354
+ if (job === undefined) {
355
+ end();
356
+ return;
357
+ }
358
+ if (job.ends === undefined)
359
+ job.ends = [end];
360
+ else
361
+ job.ends.push(end);
362
+ }
363
+ /** Internal: the Storage jobs on the line read through (`touch()`); off the line, use the plain one. */
364
+ get lineStorage() {
365
+ return this.#gated;
366
+ }
367
+ /** How long a commit may stay in memory before it is stored; `undefined` when it is immediate. */
368
+ #deferral(persistence) {
369
+ if (persistence?.kind !== "deferred")
370
+ return undefined;
371
+ const ms = persistence.withinMs;
372
+ if (ms === undefined)
373
+ return this.#persistWithinMs();
374
+ // Public options: checked here, as the types cannot.
375
+ if (typeof ms !== "number" || !(ms >= 0)) {
376
+ throw new TypeError("CommitPersistence.withinMs must be a number of milliseconds, 0 or more");
377
+ }
378
+ return ms;
379
+ }
380
+ /** Internal: whether `tx` is the transaction of one of this Session's commits. */
381
+ ownsTransaction(tx) {
382
+ return Transaction.ownedBy(tx, this.#host);
383
+ }
384
+ /**
385
+ * Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. It reads
386
+ * through `storage`, whose reads wait for the publication of what the line admitted before; its outcome comes after
387
+ * theirs.
388
+ */
168
389
  readOnLine(job) {
169
390
  try {
170
391
  this.assertUsable();
@@ -174,7 +395,7 @@ export class SessionImpl {
174
395
  }
175
396
  return this.#enqueue(async () => {
176
397
  this.#assertHealthy();
177
- return job();
398
+ return { value: await job(this.#gated) };
178
399
  });
179
400
  }
180
401
  /**
@@ -203,7 +424,7 @@ export class SessionImpl {
203
424
  ? cached
204
425
  : await this.#enqueue(async () => {
205
426
  this.#assertHealthy();
206
- return this.#loadDocument(definition, resolved.id, resolved.address, context);
427
+ return { value: await this.#loadDocument(definition, resolved.id, resolved.address, context) };
207
428
  });
208
429
  if (loaded === undefined)
209
430
  return undefined;
@@ -211,66 +432,40 @@ export class SessionImpl {
211
432
  checkRecordVersion(definition, loaded.record, loaded.storedVersion);
212
433
  return loaded.tracker.value;
213
434
  }
214
- documentState(token, ...args) {
435
+ watchDoc(token, ...args) {
436
+ // Not async, so an attachment costs one async frame, `#attachDocument`'s; a throw here still rejects.
215
437
  try {
216
438
  this.assertUsable();
217
439
  const definition = token.definition;
218
440
  const resolved = resolveAddress(definition, args);
219
441
  const context = args[resolved.nextArgument];
220
- return this.#enqueue(async () => {
221
- this.#assertHealthy();
222
- const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
223
- if (loaded === undefined)
224
- return undefined;
225
- const { observer: source, detach } = this.#attachDocument(definition, loaded, (value, release) => new CommittedStateSource(value, release));
226
- try {
227
- return replicatedState(source, { onError: (error) => this.report(error) });
228
- }
229
- catch (error) {
230
- detach();
231
- throw error;
232
- }
233
- });
442
+ const signal = context.abortSignal;
443
+ return this.#attachDocument(definition, resolved, context, (loaded) => {
444
+ const watch = this.#observeDocument(definition, loaded, createWatch, endWatch);
445
+ if (signal !== undefined)
446
+ watch.observeAbort(signal);
447
+ return watch;
448
+ }, abortWatch);
234
449
  }
235
450
  catch (error) {
236
451
  return Promise.reject(error);
237
452
  }
238
453
  }
239
- async watchDoc(token, ...args) {
240
- this.assertUsable();
241
- const definition = token.definition;
242
- const resolved = resolveAddress(definition, args);
243
- const context = args[resolved.nextArgument];
244
- const signal = context.abortSignal;
245
- let cancelled = signal?.aborted ?? false;
246
- const markCancelled = () => {
247
- cancelled = true;
248
- };
249
- signal?.addEventListener("abort", markCancelled, { once: true });
454
+ observeDoc(token, ...args) {
455
+ // Not async, as `watchDoc()`; a throw here still rejects.
250
456
  try {
251
- const watch = await this.#enqueue(async () => {
252
- this.#assertHealthy();
253
- if (cancelled)
254
- throw cancellationError(signal);
255
- const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
256
- if (cancelled)
257
- throw cancellationError(signal);
258
- if (loaded === undefined)
259
- return undefined;
260
- return this.#attachDocument(definition, loaded, (value, release) => new CommittedWatch(value, release, (error) => this.report(error))).observer;
261
- });
262
- if (watch === undefined)
263
- return undefined;
264
- if (cancelled) {
265
- watch.cancel();
266
- throw cancellationError(signal);
267
- }
268
- if (signal !== undefined)
269
- watch.observeCancellation(signal);
270
- return watch;
457
+ this.assertUsable();
458
+ const definition = token.definition;
459
+ const resolved = resolveAddress(definition, args);
460
+ const observer = args[resolved.nextArgument];
461
+ const context = args[resolved.nextArgument + 1];
462
+ return this.#attachDocument(definition, resolved, context, (loaded) => {
463
+ const guarded = this.#observeDocument(definition, loaded, (_value, detach, report) => guardObserver(observer, detach, report), endGuarded);
464
+ return observation(loaded.tracker.value, guarded);
465
+ }, detachObservation);
271
466
  }
272
- finally {
273
- signal?.removeEventListener("abort", markCancelled);
467
+ catch (error) {
468
+ return Promise.reject(error);
274
469
  }
275
470
  }
276
471
  async snapshotAsOf(token, ...args) {
@@ -289,7 +484,7 @@ export class SessionImpl {
289
484
  const context = args[resolved.nextArgument + 1];
290
485
  return this.#enqueue(async () => {
291
486
  this.#assertHealthy();
292
- const storedEntry = await this.#storage.entry(conversationId, at, context);
487
+ const storedEntry = await this.#gated.entry(conversationId, at, context);
293
488
  if (storedEntry === undefined) {
294
489
  throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);
295
490
  }
@@ -297,14 +492,14 @@ export class SessionImpl {
297
492
  ...resolved.address,
298
493
  scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId },
299
494
  };
300
- const record = await this.#storage.findDocument(address, storedEntry.commitSeq, context);
495
+ const record = await this.#gated.findDocument(address, storedEntry.commitSeq, context);
301
496
  if (record === undefined)
302
- return undefined;
303
- const stored = await this.#storage.document(record.id, storedEntry.commitSeq, context);
497
+ return { value: undefined };
498
+ const stored = await this.#gated.document(record.id, storedEntry.commitSeq, context);
304
499
  if (stored === undefined) {
305
500
  throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`);
306
501
  }
307
- return materializeDocument(definition, stored);
502
+ return { value: materializeDocument(definition, stored) };
308
503
  });
309
504
  }
310
505
  close(context) {
@@ -313,12 +508,30 @@ export class SessionImpl {
313
508
  // Seal admission before anything else runs, then stop observers; admitted work settles before Storage closes.
314
509
  this.#closing = Promise.resolve()
315
510
  .then(() => this.beforeClose())
316
- .then(() => this.#enqueue(async () => {
511
+ .then(() => this.#maintain(async () => {
512
+ await this.#drained();
513
+ // Last, after the writes at close (`writeAtClose()`), while the trackers are still here. A failed Session
514
+ // writes nothing; a failing store fails it, and the backend still closes.
515
+ let unstored;
516
+ // Only with something to store: otherwise the backend's close begins in this very job.
517
+ if (this.#failure === undefined && this.#holds.size > 0) {
518
+ await this.#storeHolds(() => [...this.#holds.values()], cleanup).catch((error) => {
519
+ unstored = { error };
520
+ });
521
+ }
522
+ const observers = [...this.#commitListeners];
317
523
  this.#commitListeners.clear();
524
+ // After the last publication: the commits admitted before the seal have settled, held changes are stored.
525
+ // Commit observers are synchronous observers, all of them.
526
+ const end = closeEnds(this.#failure).observer;
527
+ for (const observer of observers)
528
+ observer.end(end);
318
529
  this.#internalListeners.clear();
319
530
  this.#documents.clear();
320
531
  this.#idleDocuments.clear();
321
532
  this.#attachedDocuments.clear();
533
+ this.#holds.clear();
534
+ this.#taskDocuments.clear();
322
535
  try {
323
536
  await this.#storage.close(cleanup);
324
537
  }
@@ -326,14 +539,16 @@ export class SessionImpl {
326
539
  // A Storage that cannot close is a failed one; an earlier failure stays the cause.
327
540
  if (this.#failure === undefined) {
328
541
  this.#failure = { error };
329
- this.report(error, { type: "session_failed" });
542
+ this.report(error, { kind: "storage_failed" });
330
543
  }
331
544
  throw error;
332
545
  }
546
+ if (unstored !== undefined)
547
+ throw unstored.error;
333
548
  }))
334
549
  .finally(() => {
335
550
  const failure = this.#failure;
336
- this.#closed.resolve(failure === undefined ? { reason: "closed" } : { reason: "failed", error: failure.error });
551
+ this.#closed.resolve(failure === undefined ? { kind: "closed" } : { kind: "failed", error: failure.error });
337
552
  });
338
553
  this.#notifyClose();
339
554
  }
@@ -343,6 +558,8 @@ export class SessionImpl {
343
558
  #notifyClose() {
344
559
  this.#documentSweep?.disarm?.();
345
560
  this.#documentSweep = undefined;
561
+ this.#holdTimer?.disarm();
562
+ this.#holdTimer = undefined;
346
563
  const listeners = [...this.#closeListeners];
347
564
  this.#closeListeners.clear();
348
565
  for (const listener of listeners)
@@ -366,29 +583,55 @@ export class SessionImpl {
366
583
  * writes nothing.
367
584
  */
368
585
  writeAtClose(writes) {
369
- return this.#enqueue(async () => {
586
+ return this.#maintain(async (index) => {
587
+ await this.#drained();
370
588
  if (this.#failure !== undefined)
371
589
  return;
372
590
  const batch = writes();
373
- if (batch.length > 0)
374
- await this.#storage.commit(batch, BACKGROUND_CONTEXT);
591
+ if (batch.length === 0)
592
+ return;
593
+ const context = BACKGROUND_CONTEXT;
594
+ await this.#writer.admit({
595
+ index,
596
+ writes: batch,
597
+ keys: [],
598
+ context,
599
+ publish: () => undefined,
600
+ discard: ignore,
601
+ }).outcome;
375
602
  });
376
603
  }
604
+ /**
605
+ * Keys a commit writes besides its records' (`Transaction.writeKeys()`), at its admission, on the line: a Harness
606
+ * adds the ownership memberships its task changes write. A plain Session adds none.
607
+ */
608
+ membershipKeys(_tx) {
609
+ return NO_KEYS;
610
+ }
377
611
  /** Runs after close seals admission and before the line closes Storage; must not reject. */
378
612
  beforeClose() {
379
613
  return Promise.resolve();
380
614
  }
381
- /** Register a synchronous post-adoption listener. It must not block or call Session operations; a throw is reported. */
382
- subscribeCommits(listener) {
615
+ /**
616
+ * Attach a synchronous observer of every stored commit, with exactly what it stored (`CommitPublication`). Its
617
+ * `advance` runs one microtask after the commit's publication, which follows its storage call, in line order, before
618
+ * every wait the commit resolves and every abort it queues (in no order with watch listeners); it must not block or
619
+ * call Session operations. A throw is reported, detaches it, and ends it `listener_error`; `end` gets the Session's
620
+ * close after its last publication.
621
+ */
622
+ observeCommits(observer) {
383
623
  this.assertUsable();
384
- this.#commitListeners.add(listener);
385
- return () => this.#commitListeners.delete(listener);
624
+ const guarded = guardObserver(observer, () => this.#commitListeners.delete(guarded), (error, context) => this.report(error, LISTENER, context));
625
+ this.#commitListeners.add(guarded);
626
+ return { detach: () => guarded.detach() };
386
627
  }
387
628
  /**
388
- * Internal: `subscribeCommits()` for the Session's own components, whose state follows each commit. A throw leaves
389
- * that state behind storage, so it fails the Session instead of being reported. They run before host listeners.
629
+ * Internal: follow every commit as memory adopts it (`ObservedCommit`), for the Session's own components, whose state
630
+ * follows memory. A throw leaves that state behind, so it fails the Session instead of being reported. They run at
631
+ * the commit's publication, synchronously, so they call no Storage method, run no task code, and contain every host
632
+ * callback; the waits they resolve and the aborts they queue run after host observers.
390
633
  */
391
- observeCommits(listener) {
634
+ followCommits(listener) {
392
635
  this.assertUsable();
393
636
  this.#internalListeners.add(listener);
394
637
  return () => this.#internalListeners.delete(listener);
@@ -402,16 +645,19 @@ export class SessionImpl {
402
645
  this.#closeListeners.add(listener);
403
646
  return () => this.#closeListeners.delete(listener);
404
647
  }
405
- /** Drop every loaded tracker on the mutation line; later access cold-loads from Storage. */
648
+ /** Drop every loaded tracker on the mutation line, held changes stored first; later access cold-loads from Storage. */
406
649
  unloadDocuments() {
407
- return this.#enqueue(async () => {
650
+ return this.#maintain(async () => {
651
+ if (this.#failure === undefined)
652
+ await this.#storeHolds(() => [...this.#holds.values()], BACKGROUND_CONTEXT);
408
653
  this.#documents.clear();
409
654
  this.#idleDocuments.clear();
410
655
  });
411
656
  }
412
657
  /**
413
- * Internal, for tests: how many documents the cache holds, how many are idle, how many incarnations are pinned, and how
414
- * many sweep jobs were queued on the line so far.
658
+ * Internal, for tests: how many documents the cache holds, how many are idle, how many incarnations are pinned, how
659
+ * many sweep jobs were queued on the line so far, how many documents have held changes, how many live tasks have
660
+ * their documents indexed, and how many conversations have their newest entry kept.
415
661
  */
416
662
  get documentCacheSizes() {
417
663
  return {
@@ -419,6 +665,20 @@ export class SessionImpl {
419
665
  idle: this.#idleDocuments.size,
420
666
  attached: this.#attachedDocuments.size,
421
667
  sweeps: this.#sweepJobs,
668
+ held: this.#holds.size,
669
+ taskDocuments: this.#taskDocuments.size,
670
+ tails: this.#tails?.size ?? 0,
671
+ };
672
+ }
673
+ /**
674
+ * Internal, for tests that bound memory: jobs whose outcome is not delivered yet, keys with an unpublished writer,
675
+ * and record reads in flight in the cache. All three are 0 once the line is idle.
676
+ */
677
+ get lineSizes() {
678
+ return {
679
+ outstanding: this.#outstanding,
680
+ unpublished: this.#writer.unpublished,
681
+ reads: this.#cached?.readsInFlight ?? 0,
422
682
  };
423
683
  }
424
684
  /** Note a use of a cached document: an unattached one becomes the newest idle one, and an eviction is scheduled. */
@@ -451,10 +711,17 @@ export class SessionImpl {
451
711
  #scheduleDocumentSweep() {
452
712
  if (this.#closing !== undefined)
453
713
  return;
454
- const oldest = this.#idleDocuments.values().next();
455
- if (oldest.done === true)
714
+ // Documents with unpublished changes are not swept: their publication touches them again.
715
+ let oldest;
716
+ for (const [addressId, since] of this.#idleDocuments) {
717
+ if (this.#writer.gate(documentKey(addressId)) !== undefined)
718
+ continue;
719
+ oldest = since;
720
+ break;
721
+ }
722
+ if (oldest === undefined)
456
723
  return;
457
- const at = oldest.value + this.#documentRetentionMs();
724
+ const at = oldest + this.#documentRetentionMs();
458
725
  if (this.#documentSweep !== undefined && this.#documentSweep.at <= at)
459
726
  return;
460
727
  this.#documentSweep?.disarm?.();
@@ -462,7 +729,7 @@ export class SessionImpl {
462
729
  const sweep = { at, disarm: undefined };
463
730
  const run = () => {
464
731
  this.#sweepJobs++;
465
- this.#enqueue(async () => {
732
+ this.#maintain(async () => {
466
733
  if (this.#documentSweep === sweep)
467
734
  this.#documentSweep = undefined;
468
735
  if (this.#closing === undefined)
@@ -494,6 +761,9 @@ export class SessionImpl {
494
761
  for (const [addressId, since] of this.#idleDocuments) {
495
762
  if (since > expired)
496
763
  break;
764
+ // A document with an unpublished change stays until its publication touches it.
765
+ if (this.#writer.gate(documentKey(addressId)) !== undefined)
766
+ continue;
497
767
  this.#dropDocument(addressId);
498
768
  }
499
769
  this.#scheduleDocumentSweep();
@@ -517,7 +787,7 @@ export class SessionImpl {
517
787
  this.#touchDocument(addressId);
518
788
  };
519
789
  }
520
- async #runCommit(change, context, scope) {
790
+ async #runCommit(change, context, scope, withinMs, index) {
521
791
  this.#assertHealthy();
522
792
  context.abortSignal?.throwIfAborted();
523
793
  const tx = new Transaction(this.#host, context, scope);
@@ -544,51 +814,249 @@ export class SessionImpl {
544
814
  if (writes.length === 0) {
545
815
  tx.discard();
546
816
  this.#sweepDocuments();
547
- return result;
817
+ return { value: result };
548
818
  }
549
- let seq;
550
- try {
551
- // Once admitted, caller cancellation does not interrupt Storage settlement.
552
- seq = await this.#storage.commit(writes, withoutAbortSignal(context));
553
- }
554
- catch (error) {
555
- // The guard has failed the Session.
819
+ if (this.#readOnly) {
820
+ // Refused before Storage: the Session stays healthy, and the caller sees why.
556
821
  tx.discard();
557
- throw error;
822
+ this.#sweepDocuments();
823
+ throw new ReadOnlySession("a commit with writes");
558
824
  }
825
+ const discard = () => tx.discard();
826
+ if (withinMs !== undefined) {
827
+ const held = tx.holdable();
828
+ if (held !== undefined) {
829
+ // Adopted in memory when the commits before it are published, as a part of no storage call.
830
+ const part = this.#writer.admit({
831
+ index,
832
+ writes: undefined,
833
+ keys: tx.writeKeys(),
834
+ context,
835
+ publish: () => this.#hold(tx, held, withinMs, context),
836
+ discard,
837
+ });
838
+ this.#sweepDocuments();
839
+ return { value: result, after: part.outcome };
840
+ }
841
+ }
842
+ let membership = this.membershipKeys(tx);
843
+ if (!Array.isArray(membership)) {
844
+ try {
845
+ membership = await membership;
846
+ }
847
+ catch (error) {
848
+ tx.discard();
849
+ this.#sweepDocuments();
850
+ throw error;
851
+ }
852
+ }
853
+ // Logical admission. Held changes leave their queue only here, once nothing but Storage can fail; a commit never
854
+ // stores a hold adopted after it in line order. Taken before the keys, so the documents it stores held changes of
855
+ // are its writes too: a later job that reads one waits for it instead of storing them again.
856
+ const taken = this.#holds.size === 0 ? NO_HOLDS : tx.takeHeld((id) => this.#holds.get(id));
857
+ const keys = tx.writeKeys();
858
+ for (const key of membership)
859
+ keys.push(key);
860
+ const part = this.#writer.admit({
861
+ index,
862
+ writes,
863
+ keys,
864
+ context,
865
+ publish: (seq) => this.#adopt(tx, writes, taken, seq, context),
866
+ discard,
867
+ });
868
+ this.#sweepDocuments();
869
+ return { value: result, after: part.outcome };
870
+ }
871
+ /**
872
+ * Publish a stored commit (`Writer`), after the read caches took its writes: adopt it in memory and run its
873
+ * publication. A throw of the adoption leaves memory behind durable state; the writer fails the Session with it.
874
+ */
875
+ #adopt(tx, writes, taken, seq, context) {
559
876
  let documents;
560
877
  try {
561
878
  documents = tx.adopt(seq);
562
879
  }
563
880
  catch (error) {
564
- // Storage already committed; a failed adoption leaves memory behind durable state.
565
- this.fail(error);
566
- throw error;
881
+ return { kind: "adoption", error };
567
882
  }
568
- for (const document of documents)
883
+ this.#indexTaskDocuments(tx, writes, documents, seq);
884
+ const failure = this.#publish(seq, writes, documents, context);
885
+ if (taken.length > 0)
886
+ this.#release(taken);
887
+ for (const document of documents.stored)
569
888
  this.#touchDocument(addressId(document.record));
570
- this.#publish(seq, writes, documents, context);
571
- this.#sweepDocuments();
572
- return result;
889
+ return failure === undefined ? undefined : { kind: "listener", error: failure.error };
890
+ }
891
+ /**
892
+ * At publication: keep `#taskDocuments` in step with a stored commit: the tasks it creates start with no documents,
893
+ * task documents it creates join their task, ones it retires leave, and tasks it ends leave the index.
894
+ */
895
+ #indexTaskDocuments(tx, writes, documents, seq) {
896
+ for (const id of tx.createdTaskIds())
897
+ this.#taskDocuments.set(id, new Map());
898
+ if (this.#taskDocuments.size === 0)
899
+ return;
900
+ for (const change of documents.stored) {
901
+ if (change.type !== "document" || change.record.scope.kind !== "task")
902
+ continue;
903
+ const known = this.#taskDocuments.get(change.record.scope.taskId);
904
+ if (known === undefined)
905
+ continue;
906
+ if (change.kind === "retire")
907
+ known.delete(change.record.id);
908
+ else if (change.record.createdAt === seq)
909
+ known.set(change.record.id, change.record);
910
+ }
911
+ for (const write of writes) {
912
+ if (write.type === "task" && write.value.state.status === "terminal")
913
+ this.#taskDocuments.delete(write.value.id);
914
+ }
573
915
  }
916
+ /**
917
+ * Hold a deferred commit (`Transaction.holdable()`): adopt it in memory, queue its changes per document, pinned, by
918
+ * the earliest deadline of the document's held commits, and show it to the Session's own observers.
919
+ */
920
+ #hold(tx, held, withinMs, context) {
921
+ let documents;
922
+ try {
923
+ documents = tx.adoptHeld();
924
+ }
925
+ catch (error) {
926
+ // As a stored commit's: memory could not adopt it; the writer fails the Session.
927
+ return { kind: "adoption", error };
928
+ }
929
+ const deadline = this.#clock() + withinMs;
930
+ for (const { loaded, ops, base } of held) {
931
+ const id = loaded.record.id;
932
+ let hold = this.#holds.get(id);
933
+ if (hold === undefined) {
934
+ const unpin = this.#pinDocument(loaded.addressId, id);
935
+ hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
936
+ this.#holds.set(id, hold);
937
+ }
938
+ hold.pending.push(ops);
939
+ if (base)
940
+ hold.base = true;
941
+ if (deadline < hold.deadline)
942
+ hold.deadline = deadline;
943
+ }
944
+ this.#armHoldTimer();
945
+ const publication = { kind: "memory", changes: documents };
946
+ let failure;
947
+ for (const listener of [...this.#internalListeners]) {
948
+ // Our own observers are synchronous.
949
+ try {
950
+ listener(publication, context);
951
+ }
952
+ catch (error) {
953
+ failure ??= { kind: "listener", error };
954
+ }
955
+ }
956
+ return failure;
957
+ }
958
+ /**
959
+ * Store the held changes `select` returns in one storage transaction, through the writer, from a job on the line. A
960
+ * barrier: they are selected once every commit admitted before is published. Memory already has them, so only
961
+ * `observeCommits()` observers see it, published as the writer publishes it. A failure fails the Session, as every
962
+ * Storage failure does.
963
+ */
964
+ async #storeHolds(select, context) {
965
+ await this.#drained();
966
+ const holds = select();
967
+ if (holds.length === 0)
968
+ return;
969
+ const writes = [];
970
+ const changes = [];
971
+ const keys = [];
972
+ for (const hold of holds) {
973
+ const { record, tracker, valueVersion: version } = hold.loaded;
974
+ const value = tracker.value;
975
+ const stored = storeHeld(hold, NO_OPERATIONS, value, version);
976
+ writes.push({ type: "document.change", id: record.id, content: stored.content });
977
+ keys.push(documentKey(hold.loaded.addressId), incarnationKey(record.id));
978
+ // Held documents are conversation documents.
979
+ const conversationId = record.scope.kind === "conversation" ? record.scope.conversationId : undefined;
980
+ changes.push({ type: "document", kind: "change", record, conversationId, version, value, ops: stored.ops });
981
+ }
982
+ const publish = (seq) => {
983
+ for (const [index, hold] of holds.entries()) {
984
+ if (writes[index].content.kind === "base")
985
+ hold.loaded.deltasSinceBase = 0;
986
+ else
987
+ hold.loaded.deltasSinceBase++;
988
+ }
989
+ if (this.#commitListeners.size > 0)
990
+ this.#notifyHosts({ seq: seq, changes }, context);
991
+ this.#release(holds);
992
+ return undefined;
993
+ };
994
+ // Run from a job on the line, which a failure it causes is the outcome of.
995
+ const index = this.#current.index;
996
+ await this.#writer.admit({ index, writes, keys, context, publish, discard: ignore }).outcome;
997
+ }
998
+ /** After their store: forget `holds`, unpin their documents, and move the timer to the next deadline. */
999
+ #release(holds) {
1000
+ for (const hold of holds) {
1001
+ this.#holds.delete(hold.loaded.record.id);
1002
+ hold.unpin();
1003
+ }
1004
+ this.#armHoldTimer();
1005
+ }
1006
+ /**
1007
+ * Keep a foreground timer (Workers refuse background ones) for the earliest deadline of the held documents. When it
1008
+ * fires, every held document is stored, in one storage transaction: a deadline is the latest time, and the others
1009
+ * cost little more in the same transaction. Once fired, it stays until its job ran.
1010
+ */
1011
+ #armHoldTimer() {
1012
+ if (this.#closing !== undefined)
1013
+ return;
1014
+ let at = Infinity;
1015
+ for (const hold of this.#holds.values())
1016
+ if (hold.deadline < at)
1017
+ at = hold.deadline;
1018
+ const armed = this.#holdTimer;
1019
+ if (armed !== undefined && (armed.fired || armed.at === at))
1020
+ return;
1021
+ armed?.disarm();
1022
+ this.#holdTimer = undefined;
1023
+ if (at === Infinity)
1024
+ return;
1025
+ const timer = { at, disarm: () => { }, fired: false };
1026
+ const run = () => {
1027
+ timer.fired = true;
1028
+ this.#maintain(async () => {
1029
+ if (this.#holdTimer === timer)
1030
+ this.#holdTimer = undefined;
1031
+ // Close stores what is held itself.
1032
+ if (this.#closing !== undefined || this.#failure !== undefined)
1033
+ return;
1034
+ await this.#storeHolds(() => [...this.#holds.values()], BACKGROUND_CONTEXT);
1035
+ }).catch(() => { });
1036
+ };
1037
+ this.#holdTimer = timer;
1038
+ timer.disarm = this.#timers.foreground(run, Math.max(0, at - this.#clock()));
1039
+ }
1040
+ /**
1041
+ * At publication: publish a stored commit. Its telemetry events first, then the dispatch to `observeCommits()`
1042
+ * observers, queued before the waits and aborts the observers wake, then the Session's own observers. Never throws:
1043
+ * returns the first error of an own observer, which fails the Session once its storage call settled.
1044
+ */
574
1045
  #publish(seq, writes, documents, context) {
575
1046
  const changes = this.#commitListeners.size > 0 || this.#internalListeners.size > 0 ? [] : undefined;
576
1047
  for (const write of writes) {
577
1048
  switch (write.type) {
578
1049
  case "entry":
579
- context.telemetry.addEvent("pi.durable.entry", {
580
- "entry.id": write.value.id,
581
- "entry.kind": write.value.kind,
582
- });
1050
+ this.#event("pi.durable.entry", { "entry.id": write.value.id, "entry.kind": write.value.kind }, context);
583
1051
  changes?.push(write);
584
1052
  break;
585
1053
  case "submission":
586
1054
  if (write.value.status === "done" || write.value.status === "unanswered") {
587
- context.telemetry.addEvent("pi.durable.submission", {
1055
+ this.#event("pi.durable.submission", {
588
1056
  "submission.id": write.value.id,
589
1057
  status: write.value.status,
590
1058
  "answer.entry_id": write.value.answer,
591
- });
1059
+ }, context);
592
1060
  }
593
1061
  changes?.push(write);
594
1062
  break;
@@ -598,71 +1066,208 @@ export class SessionImpl {
598
1066
  }
599
1067
  }
600
1068
  if (changes === undefined)
601
- return;
602
- for (const document of documents)
1069
+ return undefined;
1070
+ const tables = changes.length;
1071
+ for (const document of documents.observed)
603
1072
  changes.push(document);
604
- const publication = { seq, changes };
605
- // The commit is durable: a failing listener neither fails the commit nor skips the others.
1073
+ const observed = { kind: "stored", seq, changes };
1074
+ if (this.#commitListeners.size > 0) {
1075
+ // One publication for both, unless the commit also stored held changes, which only host listeners get.
1076
+ const stored = documents.stored === documents.observed
1077
+ ? observed
1078
+ : { seq, changes: [...changes.slice(0, tables), ...documents.stored] };
1079
+ this.#notifyHosts(stored, context);
1080
+ }
1081
+ // The commit is durable: a failing observer neither fails the commit nor skips the others.
1082
+ let failure;
606
1083
  for (const listener of [...this.#internalListeners]) {
607
- contained(() => listener(publication, context), (error) => this.fail(error, context));
1084
+ // Our own observers are synchronous.
1085
+ try {
1086
+ listener(observed, context);
1087
+ }
1088
+ catch (error) {
1089
+ failure ??= { error };
1090
+ }
1091
+ }
1092
+ return failure;
1093
+ }
1094
+ /** At publication: record a commit's telemetry event. A throwing adapter is reported, as publication must not throw. */
1095
+ #event(name, attributes, context) {
1096
+ try {
1097
+ context.telemetry.addEvent(name, attributes);
1098
+ }
1099
+ catch (error) {
1100
+ this.report(error, TELEMETRY, context);
1101
+ }
1102
+ }
1103
+ /**
1104
+ * At publication: call the `observeCommits()` observers with `publication` one microtask from now, and, queued
1105
+ * before the Session's own observers run, before the waits they resolve and the aborts they queue. The observers are
1106
+ * those attached when it runs; their guards report a throw.
1107
+ */
1108
+ #notifyHosts(publication, context) {
1109
+ void SETTLED.then(() => {
1110
+ for (const observer of [...this.#commitListeners])
1111
+ observer.advance(publication, context);
1112
+ });
1113
+ }
1114
+ /**
1115
+ * On the line, as one job: load the document at `resolved` and `attach` to it, so the attachment sees every commit
1116
+ * after the value it starts from; `undefined` when absent. Cancelling `context` meanwhile rejects, after `cancel`
1117
+ * undid an attachment made.
1118
+ */
1119
+ async #attachDocument(definition, resolved, context, attach, cancel) {
1120
+ const signal = context.abortSignal;
1121
+ let cancelled = signal?.aborted ?? false;
1122
+ const markCancelled = () => {
1123
+ cancelled = true;
1124
+ };
1125
+ signal?.addEventListener("abort", markCancelled, { once: true });
1126
+ try {
1127
+ const attached = await this.#enqueue(async () => {
1128
+ this.#assertHealthy();
1129
+ if (cancelled)
1130
+ throw cancellationError(signal);
1131
+ const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
1132
+ if (cancelled)
1133
+ throw cancellationError(signal);
1134
+ return { value: loaded === undefined ? undefined : attach(loaded) };
1135
+ });
1136
+ if (attached === undefined)
1137
+ return undefined;
1138
+ if (cancelled) {
1139
+ cancel(attached);
1140
+ throw cancellationError(signal);
1141
+ }
1142
+ return attached;
608
1143
  }
609
- for (const listener of [...this.#commitListeners]) {
610
- contained(() => listener(publication, context), (error) => this.report(error, LISTENER, context));
1144
+ finally {
1145
+ signal?.removeEventListener("abort", markCancelled);
611
1146
  }
612
1147
  }
613
1148
  /**
614
- * Attach an observer to one committed incarnation: check the definition, then forward this incarnation's committed
615
- * changes and close. `detach` removes both subscriptions.
1149
+ * Follow the address of the incarnation `loaded`, after checking the definition, with the observer `create` makes
1150
+ * from its value, the idempotent detach, and the report of a listener error; returns that observer. It gets each
1151
+ * committed change there, a retirement as a `retired` change with the last value it was given and no ops (a change
1152
+ * in the retiring commit is not published), and a later incarnation as a root replacement. When the commit makes the
1153
+ * owner task of a task document terminal, no later commit can change the address: after its changes the commit
1154
+ * subscription and the pin go, and `ownerEnded` tells the observer, which stays subscribed to the Session's close
1155
+ * until it ends (its detach drops that). `end` gets the Session's close. The current incarnation is pinned in the
1156
+ * cache. The detach runs before `end`; once it ran, nothing more of the commit underway reaches the observer or pins
1157
+ * anything for it. `create` and `ownerEnded` are module functions where they can be, so a watch keeps no closure of
1158
+ * its caller.
616
1159
  */
617
- #attachDocument(definition, loaded, create) {
1160
+ #observeDocument(definition, loaded, create, ownerEnded) {
618
1161
  checkRecordScope(definition, loaded.record);
619
1162
  checkRecordVersion(definition, loaded.record, loaded.storedVersion);
1163
+ // The closures below keep only the address, as the cache's ID and first record, which the cache shares while the
1164
+ // document is cached: `loaded` holds its incarnation's tracker, which a retirement frees.
1165
+ const addressId = loaded.addressId;
1166
+ const address = loaded.record;
1167
+ let incarnation = loaded.record.id;
1168
+ // The value of the incarnation followed; none while the address is absent, so the Session keeps no retired value.
1169
+ let value = loaded.tracker.value;
1170
+ // The definition version the observer's value has: a change of another version replaces it whole.
1171
+ let version = loaded.valueVersion;
1172
+ // The incarnation observed stays cached; once it retires or the observer detaches, it is idle again.
1173
+ let unpin = this.#pinDocument(addressId, incarnation);
620
1174
  let unsubscribeCommit = () => { };
621
1175
  let unsubscribeClose = () => { };
622
- // Attached, the incarnation stays cached; once its last observer detaches, or it retires, it is idle again.
623
- const unpin = this.#pinDocument(loaded.addressId, loaded.record.id);
1176
+ let attached = true;
624
1177
  const detach = () => {
1178
+ if (!attached)
1179
+ return;
1180
+ attached = false;
625
1181
  unsubscribeCommit();
626
1182
  unsubscribeClose();
627
1183
  unpin();
628
1184
  };
629
- let observer;
630
- try {
631
- observer = create(loaded.tracker.value, detach);
632
- }
633
- catch (error) {
634
- unpin();
635
- throw error;
636
- }
637
- const observed = { version: loaded.valueVersion };
638
- unsubscribeCommit = this.observeCommits((publication, context) => {
1185
+ const observer = create(loaded.tracker.value, detach, (error) => this.report(error));
1186
+ unsubscribeCommit = this.followCommits((publication, context) => {
1187
+ // Listeners run from a copy of the set: another observer's call may have detached this one.
1188
+ if (!attached)
1189
+ return;
1190
+ let ownerEnds = false;
639
1191
  for (const change of publication.changes) {
640
- if (change.type !== "document" || change.record.id !== loaded.record.id)
1192
+ if (change.type === "task") {
1193
+ const scope = address.scope;
1194
+ if (scope.kind === "task" &&
1195
+ change.value.id === scope.taskId &&
1196
+ change.value.state.status === "terminal") {
1197
+ ownerEnds = true;
1198
+ }
641
1199
  continue;
642
- // A document state's frames carry no caller cancellation; a watch observes its own cancellation.
643
- const frameContext = observer instanceof CommittedStateSource ? withoutAbortSignal(context) : context;
644
- const ops = observedOperations(observed, change);
645
- // A migration-only base changes nothing for an observer of the new version.
646
- if (ops.length === 0)
1200
+ }
1201
+ // The incarnation followed is at the address by definition; only another record needs the address compared.
1202
+ if (change.type !== "document")
647
1203
  continue;
648
- // A retired incarnation never changes again: its observers keep their last value but pin nothing.
649
- if (change.value === null)
1204
+ if (change.record.id !== incarnation && !atAddress(change.record, address))
1205
+ continue;
1206
+ if (change.kind === "retire") {
1207
+ if (change.record.id !== incarnation)
1208
+ continue;
1209
+ // Set with every incarnation.
1210
+ const last = value;
1211
+ incarnation = undefined;
1212
+ value = undefined;
650
1213
  unpin();
651
- observer.advance(change.value, ops, frameContext);
1214
+ observer.advance({ kind: "value", value: last, ops: NO_OPERATIONS, retired: true }, context);
1215
+ }
1216
+ else if (change.record.id !== incarnation || change.version !== version) {
1217
+ // A new incarnation at the address, or a value of another definition version, whose shape the observer's
1218
+ // value does not have: shown whole.
1219
+ if (change.record.id !== incarnation) {
1220
+ incarnation = change.record.id;
1221
+ unpin();
1222
+ unpin = this.#pinDocument(addressId, incarnation);
1223
+ }
1224
+ version = change.version;
1225
+ value = change.value;
1226
+ observer.advance({ kind: "value", value, ops: [["r", value]], retired: false }, context);
1227
+ }
1228
+ else {
1229
+ // A migration-only base changes nothing for an observer of the new version.
1230
+ if (change.ops.length === 0)
1231
+ continue;
1232
+ value = change.value;
1233
+ observer.advance({ kind: "value", value, ops: change.ops, retired: false }, context);
1234
+ }
1235
+ // The observer may detach in its call, or throw and be detached: the rest of the commit is not its.
1236
+ if (!attached)
1237
+ return;
652
1238
  }
1239
+ if (!ownerEnds)
1240
+ return;
1241
+ unsubscribeCommit();
1242
+ unpin();
1243
+ ownerEnded(observer);
1244
+ });
1245
+ unsubscribeClose = this.subscribeClose(() => {
1246
+ detach();
1247
+ observer.end(closeEndOf(observer, closeEnds(this.#failure)));
653
1248
  });
654
- unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure));
655
- return { observer, detach };
1249
+ return observer;
656
1250
  }
657
1251
  async #loadDocument(definition, addressId, address, context) {
1252
+ // What an unpublished commit wrote at the address is in neither the cache nor what Storage shows yet.
1253
+ const gate = this.touch(documentKey(addressId));
1254
+ if (gate !== undefined)
1255
+ await gate;
658
1256
  const cached = this.#documents.get(addressId);
659
1257
  // A tracker serves only tokens of the version its value was materialized for; others reload from Storage.
660
1258
  if (cached?.valueVersion === definition.version) {
661
1259
  this.#touchDocument(addressId);
662
1260
  return cached;
663
1261
  }
664
- if (cached !== undefined)
1262
+ if (cached !== undefined) {
1263
+ // The reload must find what memory holds: held changes are stored before their tracker goes, after a barrier.
1264
+ const id = cached.record.id;
1265
+ await this.#storeHolds(() => {
1266
+ const hold = this.#holds.get(id);
1267
+ return hold === undefined ? NO_HOLDS : [hold];
1268
+ }, context);
665
1269
  this.#dropDocument(addressId);
1270
+ }
666
1271
  const record = await this.#storage.findDocument(address, "current", context);
667
1272
  if (record === undefined)
668
1273
  return undefined;
@@ -682,10 +1287,139 @@ export class SessionImpl {
682
1287
  this.#touchDocument(addressId);
683
1288
  return loaded;
684
1289
  }
685
- #enqueue(job) {
686
- const run = this.#tail.then(job);
687
- this.#tail = run.then(() => undefined, () => undefined);
688
- return run;
1290
+ /**
1291
+ * Run `job` on the line, after the jobs before it, and deliver its outcome in line order: once the jobs before it
1292
+ * have theirs, and once its commit, if it admitted one, is published. With `exempt`, a job that admitted no commit and
1293
+ * waited at no gate is delivered as soon as it has its outcome: it is equivalent to having run before every
1294
+ * unpublished commit. A job
1295
+ * whose outcome comes after the one a failure is gets `SessionFailed` instead. At most `MAX_OUTSTANDING` jobs wait
1296
+ * for their delivery; beyond it the line waits.
1297
+ */
1298
+ #enqueue(work, exempt = false) {
1299
+ const job = { index: this.#nextIndex++, hit: false };
1300
+ const ran = this.#tail.then(async () => {
1301
+ while (this.#outstanding >= MAX_OUTSTANDING) {
1302
+ this.#room ??= Promise.withResolvers();
1303
+ await this.#room.promise;
1304
+ }
1305
+ this.#outstanding++;
1306
+ return this.#onLine(job, work);
1307
+ });
1308
+ this.#tail = ran.then(ignore, ignore);
1309
+ const outcome = ran.then((result) => {
1310
+ if (result.after === undefined) {
1311
+ this.#ended(job);
1312
+ return result.value;
1313
+ }
1314
+ return result.after.then(() => {
1315
+ this.#ended(job);
1316
+ return result.value;
1317
+ }, (error) => {
1318
+ this.#ended(job);
1319
+ throw error;
1320
+ });
1321
+ }, (error) => {
1322
+ this.#ended(job);
1323
+ throw error;
1324
+ });
1325
+ // Delivered later, after the outcomes before it: handled now, so an early rejection is not reported unhandled.
1326
+ outcome.catch(ignore);
1327
+ const fence = this.#fence;
1328
+ this.#fence = fence.then(() => outcome).then(ignore, ignore);
1329
+ const index = job.index;
1330
+ const deliver = (fenced) => (fenced ? fence.then(() => outcome) : outcome).then((value) => {
1331
+ this.#delivered(index, fenced);
1332
+ if (this.#poisoned(index))
1333
+ throw new SessionFailed(this.#failure.error);
1334
+ return value;
1335
+ }, (error) => {
1336
+ this.#delivered(index, fenced);
1337
+ throw this.#poisoned(index) ? new SessionFailed(this.#failure.error) : error;
1338
+ });
1339
+ // Only a job without writes is exempt: one with writes waits for its own publication and the outcomes before it.
1340
+ return exempt
1341
+ ? ran.then((result) => deliver(job.hit || result.after !== undefined), () => deliver(job.hit))
1342
+ : deliver(true);
1343
+ }
1344
+ /** Whether job `index` comes after the failure's boundary without an outcome of its own: it gets `SessionFailed`. */
1345
+ #poisoned(index) {
1346
+ return this.#failAt !== undefined && index > this.#failAt && !this.#ownOutcomes.has(index);
1347
+ }
1348
+ /** The job's outcome settled: run what waited for it (`onJobEnd()`). */
1349
+ #ended(job) {
1350
+ const ends = job.ends;
1351
+ if (ends === undefined)
1352
+ return;
1353
+ job.ends = undefined;
1354
+ for (const end of ends)
1355
+ end();
1356
+ }
1357
+ /** Run a job of the Session's own upkeep on the line: no outcome to deliver, so no fence and no `SessionFailed`. */
1358
+ #maintain(work) {
1359
+ const job = { index: this.#nextIndex++, hit: false };
1360
+ const ran = this.#tail.then(() => this.#onLine(job, work));
1361
+ this.#tail = ran.then(ignore, ignore);
1362
+ return ran;
1363
+ }
1364
+ /** Hold the line for job `index` until its line part, through admission, is done. Gates are its touches' own. */
1365
+ async #onLine(job, work) {
1366
+ this.#current = job;
1367
+ try {
1368
+ return await work(job.index);
1369
+ }
1370
+ finally {
1371
+ this.#current = undefined;
1372
+ }
1373
+ }
1374
+ /** A `*` barrier: once every part admitted so far is published, or never will be. */
1375
+ async #drained() {
1376
+ const drained = this.#writer.drained();
1377
+ if (drained !== undefined)
1378
+ await drained;
1379
+ }
1380
+ /**
1381
+ * Run one commit's publication inside the Storage's own scope (`Storage.publishing`), such as a store host's batch;
1382
+ * directly without one. `undefined` when it ran at once, else settles once it ran. A Storage that throws instead of
1383
+ * running it is reported, and the publication runs directly: the commit is stored, so it must be published.
1384
+ */
1385
+ #publishing(publish, context) {
1386
+ const publishing = this.#storage.publishing;
1387
+ if (publishing === undefined) {
1388
+ publish();
1389
+ return undefined;
1390
+ }
1391
+ const done = Promise.withResolvers();
1392
+ let ran = false;
1393
+ const once = () => {
1394
+ if (ran)
1395
+ return;
1396
+ ran = true;
1397
+ try {
1398
+ publish();
1399
+ }
1400
+ finally {
1401
+ done.resolve();
1402
+ }
1403
+ };
1404
+ try {
1405
+ publishing(once, context);
1406
+ }
1407
+ catch (error) {
1408
+ this.report(error, LISTENER, context);
1409
+ once();
1410
+ }
1411
+ return done.promise;
1412
+ }
1413
+ /** Job `index` has its outcome: let the line go on past `MAX_OUTSTANDING`. */
1414
+ #delivered(index, fenced) {
1415
+ if (fenced)
1416
+ this.#lastDelivered = index;
1417
+ this.#outstanding--;
1418
+ const room = this.#room;
1419
+ if (room !== undefined && this.#outstanding < MAX_OUTSTANDING) {
1420
+ this.#room = undefined;
1421
+ room.resolve();
1422
+ }
689
1423
  }
690
1424
  /** Internal: throw `SessionFailed` when failed, else an error when closing. */
691
1425
  assertUsable() {
@@ -714,16 +1448,123 @@ export function contained(callback, onError) {
714
1448
  }
715
1449
  }
716
1450
  /**
717
- * Operations an observer applies for one committed change. An observer hydrated under another definition version holds
718
- * a differently shaped value, so it receives the new value as a root replacement instead of operations for that shape.
1451
+ * `observer`, a host's, behind the rule for observers that run at a commit's publication: one that throws, or whose
1452
+ * `advance` returns a promise that rejects, is reported through `report`, detached, and ended `listener_error`, so the
1453
+ * throw never reaches `adopt`; nothing reaches it after its detach or end, and from then on the guard references
1454
+ * neither it nor its source. `detach` is the source's; the returned `detach()` is the observation's.
1455
+ */
1456
+ export function guardObserver(observer, detach, report) {
1457
+ let attached = { observer, detach, report };
1458
+ /** Detach from the source once, and hand back what it was attached with. */
1459
+ const release = () => {
1460
+ const released = attached;
1461
+ attached = undefined;
1462
+ released?.detach();
1463
+ return released;
1464
+ };
1465
+ /** Report a throw of `current`'s `advance`, then detach and end it, unless that happened meanwhile. */
1466
+ const fault = (current, error, context) => {
1467
+ current.report(error, context);
1468
+ if (release() !== undefined) {
1469
+ const end = { kind: "listener_error", error: toError(error) };
1470
+ contained(() => current.observer.end(end), current.report);
1471
+ }
1472
+ };
1473
+ return {
1474
+ advance: (call, context) => {
1475
+ const current = attached;
1476
+ if (current === undefined)
1477
+ return;
1478
+ try {
1479
+ const result = current.observer.advance(call, context);
1480
+ // Any thenable, as `contained()` takes it: a rejection is the observer's throw, after the fact.
1481
+ if (typeof result?.then === "function") {
1482
+ Promise.resolve(result).catch((error) => fault(current, error, context));
1483
+ }
1484
+ }
1485
+ catch (error) {
1486
+ fault(current, error, context);
1487
+ }
1488
+ },
1489
+ end: (end) => {
1490
+ const ending = release();
1491
+ if (ending !== undefined)
1492
+ contained(() => ending.observer.end(end), ending.report);
1493
+ },
1494
+ detach: () => {
1495
+ release();
1496
+ },
1497
+ };
1498
+ }
1499
+ /** A listener's throw as the `Error` a `listener_error` end carries, as Chord's watch makes it. */
1500
+ function toError(error) {
1501
+ if (error instanceof Error)
1502
+ return error;
1503
+ try {
1504
+ return new Error(String(error));
1505
+ }
1506
+ catch {
1507
+ return new Error(Object.prototype.toString.call(error));
1508
+ }
1509
+ }
1510
+ /** The end of an observer whose source closed. */
1511
+ export const ENDED = { kind: "ended" };
1512
+ /**
1513
+ * How a Session's close ends what follows it (decision "Slice 8 S1"), made once per close: an app's watch `ended`; a
1514
+ * synchronous observer (an adapter following the Session, which may open it again) `failed` with "session closed", so
1515
+ * it follows the next Session; `ended` stays for an observed owner that ended (a task document's task). A failed
1516
+ * Session ends both `failed` with its error.
1517
+ */
1518
+ export function closeEnds(failure) {
1519
+ if (failure !== undefined) {
1520
+ const failed = { kind: "failed", error: failure.error };
1521
+ return { watch: failed, observer: failed };
1522
+ }
1523
+ return { watch: ENDED, observer: { kind: "failed", error: new Error("session closed") } };
1524
+ }
1525
+ /** The end `ends` give `follower`: a watch's, or an observer's. */
1526
+ export function closeEndOf(follower, ends) {
1527
+ return follower instanceof Watch ? ends.watch : ends.observer;
1528
+ }
1529
+ /**
1530
+ * The `Observation` of a guarded observer attached at `value`. Built outside the attaching closure, so a detached
1531
+ * observation keeps nothing but `value` and the spent guard alive.
719
1532
  */
720
- function observedOperations(observed, change) {
721
- if (change.value === null)
722
- return RETIREMENT_OPERATIONS;
723
- if (change.version === observed.version)
724
- return change.ops;
725
- observed.version = change.version;
726
- return [["r", change.value]];
1533
+ export function observation(value, guarded) {
1534
+ return { value, detach: () => guarded.detach() };
1535
+ }
1536
+ /** A document watch over its source's detach and report. */
1537
+ function createWatch(value, detach, report) {
1538
+ return new Watch(value, detach, report);
1539
+ }
1540
+ /** Undo a watch attached while its acquisition was aborted. */
1541
+ function abortWatch(watch) {
1542
+ watch.abort();
1543
+ }
1544
+ /** Undo an observation attached while its attachment was cancelled. */
1545
+ function detachObservation(attached) {
1546
+ attached.detach();
1547
+ }
1548
+ /** A task document's watch delivers the retirement that ends its task, then ends. */
1549
+ function endWatch(watch) {
1550
+ watch.endAfterDelivery();
1551
+ }
1552
+ /** A task document's observer ends right after the retirement that ends its task. */
1553
+ function endGuarded(guarded) {
1554
+ guarded.end(ENDED);
1555
+ }
1556
+ /** Whether `record` is an incarnation at `address`. */
1557
+ function atAddress(record, address) {
1558
+ if (record.kind !== address.kind || record.key !== address.key)
1559
+ return false;
1560
+ switch (record.scope.kind) {
1561
+ case "session":
1562
+ return address.scope.kind === "session";
1563
+ case "conversation":
1564
+ return address.scope.kind === "conversation" && address.scope.conversationId === record.scope.conversationId;
1565
+ case "task":
1566
+ return address.scope.kind === "task" && address.scope.taskId === record.scope.taskId;
1567
+ }
727
1568
  }
728
1569
  function cancellationError(signal) {
729
1570
  return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
@@ -745,9 +1586,26 @@ class GuardedStorage {
745
1586
  this.#storage = storage;
746
1587
  this.#latch = latch;
747
1588
  }
748
- /** Never exempt, whatever it throws: once admitted, whether a failed batch committed is unknown. */
749
- commit(writes, context) {
750
- return this.#call(undefined, () => this.#storage.commit(writes, context));
1589
+ /** The backend's own scope for a publication (`Storage.publishing`), when it has one; not a call to guard. */
1590
+ get publishing() {
1591
+ return scopeOf(this.#storage);
1592
+ }
1593
+ /**
1594
+ * Guarded like every call, but its outcome is the Session writer's to judge: it publishes the batches received first,
1595
+ * then fails the Session for a refusal or a rejection, and for a Session that failed meanwhile.
1596
+ */
1597
+ async commit(batches, receive, context) {
1598
+ this.#assertHealthy();
1599
+ if (this.#closing)
1600
+ throw new Error("Session is closed");
1601
+ const call = (async () => this.#storage.commit(batches, receive, context))();
1602
+ this.#underway.add(call);
1603
+ try {
1604
+ return await call;
1605
+ }
1606
+ finally {
1607
+ this.#underway.delete(call);
1608
+ }
751
1609
  }
752
1610
  mintId() {
753
1611
  return this.#call(undefined, () => this.#storage.mintId());