@earendil-works/pi-durable 1.3.0 → 1.5.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 (257) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +287 -82
  3. package/dist/codemode/codemode.d.ts +4 -4
  4. package/dist/codemode/codemode.d.ts.map +1 -1
  5. package/dist/codemode/codemode.js +21 -16
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.d.ts.map +1 -1
  8. package/dist/codemode/models.js +6 -4
  9. package/dist/codemode/models.js.map +1 -1
  10. package/dist/documents.d.ts +1 -1
  11. package/dist/documents.d.ts.map +1 -1
  12. package/dist/documents.js.map +1 -1
  13. package/dist/env/index.d.ts +12 -10
  14. package/dist/env/index.d.ts.map +1 -1
  15. package/dist/env/index.js.map +1 -1
  16. package/dist/env/node-watch.d.ts +3 -3
  17. package/dist/env/node-watch.d.ts.map +1 -1
  18. package/dist/env/node-watch.js +18 -16
  19. package/dist/env/node-watch.js.map +1 -1
  20. package/dist/env/node.d.ts +2 -2
  21. package/dist/env/node.d.ts.map +1 -1
  22. package/dist/env/node.js +12 -4
  23. package/dist/env/node.js.map +1 -1
  24. package/dist/errors.d.ts +18 -2
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +26 -3
  27. package/dist/errors.js.map +1 -1
  28. package/dist/harness/agent.d.ts +13 -2
  29. package/dist/harness/agent.d.ts.map +1 -1
  30. package/dist/harness/agent.js +48 -6
  31. package/dist/harness/agent.js.map +1 -1
  32. package/dist/harness/compaction.d.ts +3 -1
  33. package/dist/harness/compaction.d.ts.map +1 -1
  34. package/dist/harness/compaction.js +38 -6
  35. package/dist/harness/compaction.js.map +1 -1
  36. package/dist/harness/context.d.ts +23 -7
  37. package/dist/harness/context.d.ts.map +1 -1
  38. package/dist/harness/context.js +71 -25
  39. package/dist/harness/context.js.map +1 -1
  40. package/dist/harness/define.d.ts +7 -3
  41. package/dist/harness/define.d.ts.map +1 -1
  42. package/dist/harness/define.js +4 -0
  43. package/dist/harness/define.js.map +1 -1
  44. package/dist/harness/generation.d.ts +38 -5
  45. package/dist/harness/generation.d.ts.map +1 -1
  46. package/dist/harness/generation.js +497 -108
  47. package/dist/harness/generation.js.map +1 -1
  48. package/dist/harness/harness.d.ts +7 -2
  49. package/dist/harness/harness.d.ts.map +1 -1
  50. package/dist/harness/harness.js +147 -57
  51. package/dist/harness/harness.js.map +1 -1
  52. package/dist/harness/inbox.d.ts +7 -3
  53. package/dist/harness/inbox.d.ts.map +1 -1
  54. package/dist/harness/inbox.js +7 -4
  55. package/dist/harness/inbox.js.map +1 -1
  56. package/dist/harness/json.d.ts +20 -4
  57. package/dist/harness/json.d.ts.map +1 -1
  58. package/dist/harness/json.js +23 -5
  59. package/dist/harness/json.js.map +1 -1
  60. package/dist/harness/live.d.ts +122 -16
  61. package/dist/harness/live.d.ts.map +1 -1
  62. package/dist/harness/live.js +93 -12
  63. package/dist/harness/live.js.map +1 -1
  64. package/dist/harness/partial-changes.d.ts +54 -0
  65. package/dist/harness/partial-changes.d.ts.map +1 -0
  66. package/dist/harness/partial-changes.js +162 -0
  67. package/dist/harness/partial-changes.js.map +1 -0
  68. package/dist/harness/prompt.d.ts +1 -1
  69. package/dist/harness/prompt.d.ts.map +1 -1
  70. package/dist/harness/prompt.js +1 -1
  71. package/dist/harness/prompt.js.map +1 -1
  72. package/dist/harness/registry-reader.d.ts +11 -0
  73. package/dist/harness/registry-reader.d.ts.map +1 -0
  74. package/dist/harness/registry-reader.js +25 -0
  75. package/dist/harness/registry-reader.js.map +1 -0
  76. package/dist/harness/scheduler.d.ts +86 -14
  77. package/dist/harness/scheduler.d.ts.map +1 -1
  78. package/dist/harness/scheduler.js +1459 -369
  79. package/dist/harness/scheduler.js.map +1 -1
  80. package/dist/harness/submissions.d.ts +23 -11
  81. package/dist/harness/submissions.d.ts.map +1 -1
  82. package/dist/harness/submissions.js +104 -36
  83. package/dist/harness/submissions.js.map +1 -1
  84. package/dist/harness/task-graph.d.ts +28 -8
  85. package/dist/harness/task-graph.d.ts.map +1 -1
  86. package/dist/harness/task-graph.js +85 -36
  87. package/dist/harness/task-graph.js.map +1 -1
  88. package/dist/harness/telemetry.d.ts +307 -0
  89. package/dist/harness/telemetry.d.ts.map +1 -0
  90. package/dist/harness/telemetry.js +136 -0
  91. package/dist/harness/telemetry.js.map +1 -0
  92. package/dist/harness/tool.d.ts +2 -0
  93. package/dist/harness/tool.d.ts.map +1 -1
  94. package/dist/harness/tool.js +223 -61
  95. package/dist/harness/tool.js.map +1 -1
  96. package/dist/harness/transcript.d.ts +72 -0
  97. package/dist/harness/transcript.d.ts.map +1 -0
  98. package/dist/harness/transcript.js +264 -0
  99. package/dist/harness/transcript.js.map +1 -0
  100. package/dist/harness/types.d.ts +302 -48
  101. package/dist/harness/types.d.ts.map +1 -1
  102. package/dist/harness/types.js.map +1 -1
  103. package/dist/harness/util.d.ts +7 -1
  104. package/dist/harness/util.d.ts.map +1 -1
  105. package/dist/harness/util.js +13 -0
  106. package/dist/harness/util.js.map +1 -1
  107. package/dist/harness/view.d.ts +43 -22
  108. package/dist/harness/view.d.ts.map +1 -1
  109. package/dist/harness/view.js +130 -71
  110. package/dist/harness/view.js.map +1 -1
  111. package/dist/index.d.ts +13 -9
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +7 -5
  114. package/dist/index.js.map +1 -1
  115. package/dist/search.d.ts +19 -3
  116. package/dist/search.d.ts.map +1 -1
  117. package/dist/search.js +107 -44
  118. package/dist/search.js.map +1 -1
  119. package/dist/session/cached-storage.d.ts +71 -0
  120. package/dist/session/cached-storage.d.ts.map +1 -0
  121. package/dist/session/cached-storage.js +269 -0
  122. package/dist/session/cached-storage.js.map +1 -0
  123. package/dist/session/conversation-tails.d.ts +33 -0
  124. package/dist/session/conversation-tails.d.ts.map +1 -0
  125. package/dist/session/conversation-tails.js +101 -0
  126. package/dist/session/conversation-tails.js.map +1 -0
  127. package/dist/session/keys.d.ts +68 -0
  128. package/dist/session/keys.d.ts.map +1 -0
  129. package/dist/session/keys.js +161 -0
  130. package/dist/session/keys.js.map +1 -0
  131. package/dist/session/session.d.ts +181 -29
  132. package/dist/session/session.d.ts.map +1 -1
  133. package/dist/session/session.js +1073 -183
  134. package/dist/session/session.js.map +1 -1
  135. package/dist/session/transaction.d.ts +86 -4
  136. package/dist/session/transaction.d.ts.map +1 -1
  137. package/dist/session/transaction.js +250 -38
  138. package/dist/session/transaction.js.map +1 -1
  139. package/dist/session/writer.d.ts +74 -0
  140. package/dist/session/writer.d.ts.map +1 -0
  141. package/dist/session/writer.js +294 -0
  142. package/dist/session/writer.js.map +1 -0
  143. package/dist/storage/indexeddb.d.ts +68 -0
  144. package/dist/storage/indexeddb.d.ts.map +1 -0
  145. package/dist/storage/indexeddb.js +881 -0
  146. package/dist/storage/indexeddb.js.map +1 -0
  147. package/dist/storage/jsonl/storage.d.ts +30 -2
  148. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  149. package/dist/storage/jsonl/storage.js +88 -24
  150. package/dist/storage/jsonl/storage.js.map +1 -1
  151. package/dist/storage/memory.d.ts +7 -2
  152. package/dist/storage/memory.d.ts.map +1 -1
  153. package/dist/storage/memory.js +29 -3
  154. package/dist/storage/memory.js.map +1 -1
  155. package/dist/storage/sqlite/cloudflare.d.ts +2 -51
  156. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -1
  157. package/dist/storage/sqlite/cloudflare.js +2 -133
  158. package/dist/storage/sqlite/cloudflare.js.map +1 -1
  159. package/dist/storage/sqlite/database.d.ts +1 -36
  160. package/dist/storage/sqlite/database.d.ts.map +1 -1
  161. package/dist/storage/sqlite/database.js.map +1 -1
  162. package/dist/storage/sqlite/index.d.ts +1 -1
  163. package/dist/storage/sqlite/index.d.ts.map +1 -1
  164. package/dist/storage/sqlite/index.js.map +1 -1
  165. package/dist/storage/sqlite/migrations.d.ts +2 -5
  166. package/dist/storage/sqlite/migrations.d.ts.map +1 -1
  167. package/dist/storage/sqlite/migrations.js +28 -28
  168. package/dist/storage/sqlite/migrations.js.map +1 -1
  169. package/dist/storage/sqlite/node.d.ts +2 -37
  170. package/dist/storage/sqlite/node.d.ts.map +1 -1
  171. package/dist/storage/sqlite/node.js +3 -174
  172. package/dist/storage/sqlite/node.js.map +1 -1
  173. package/dist/storage/sqlite/schema.d.ts +1 -3
  174. package/dist/storage/sqlite/schema.d.ts.map +1 -1
  175. package/dist/storage/sqlite/schema.js +2 -9
  176. package/dist/storage/sqlite/schema.js.map +1 -1
  177. package/dist/storage/sqlite/storage.d.ts +15 -3
  178. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  179. package/dist/storage/sqlite/storage.js +110 -36
  180. package/dist/storage/sqlite/storage.js.map +1 -1
  181. package/dist/storage/store/commit.d.ts +33 -0
  182. package/dist/storage/store/commit.d.ts.map +1 -0
  183. package/dist/storage/store/commit.js +286 -0
  184. package/dist/storage/store/commit.js.map +1 -0
  185. package/dist/storage/store/documents.d.ts +43 -0
  186. package/dist/storage/store/documents.d.ts.map +1 -0
  187. package/dist/storage/store/documents.js +102 -0
  188. package/dist/storage/store/documents.js.map +1 -0
  189. package/dist/storage/store/index.d.ts +2 -0
  190. package/dist/storage/store/index.d.ts.map +1 -0
  191. package/dist/storage/store/index.js +2 -0
  192. package/dist/storage/store/index.js.map +1 -0
  193. package/dist/storage/store/queries.d.ts +49 -0
  194. package/dist/storage/store/queries.d.ts.map +1 -0
  195. package/dist/storage/store/queries.js +93 -0
  196. package/dist/storage/store/queries.js.map +1 -0
  197. package/dist/storage/store/storage.d.ts +59 -0
  198. package/dist/storage/store/storage.d.ts.map +1 -0
  199. package/dist/storage/store/storage.js +438 -0
  200. package/dist/storage/store/storage.js.map +1 -0
  201. package/dist/storage/store/tables.d.ts +54 -0
  202. package/dist/storage/store/tables.d.ts.map +1 -0
  203. package/dist/storage/store/tables.js +108 -0
  204. package/dist/storage/store/tables.js.map +1 -0
  205. package/dist/testing/commit-writes.d.ts +5 -0
  206. package/dist/testing/commit-writes.d.ts.map +1 -0
  207. package/dist/testing/commit-writes.js +12 -0
  208. package/dist/testing/commit-writes.js.map +1 -0
  209. package/dist/testing/env-conformance.js +7 -7
  210. package/dist/testing/env-conformance.js.map +1 -1
  211. package/dist/testing/index.d.ts +1 -0
  212. package/dist/testing/index.d.ts.map +1 -1
  213. package/dist/testing/index.js +1 -0
  214. package/dist/testing/index.js.map +1 -1
  215. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  216. package/dist/testing/storage-benchmark.js +18 -17
  217. package/dist/testing/storage-benchmark.js.map +1 -1
  218. package/dist/testing/storage-conformance.d.ts.map +1 -1
  219. package/dist/testing/storage-conformance.js +369 -67
  220. package/dist/testing/storage-conformance.js.map +1 -1
  221. package/dist/timers.d.ts +21 -0
  222. package/dist/timers.d.ts.map +1 -0
  223. package/dist/timers.js +72 -0
  224. package/dist/timers.js.map +1 -0
  225. package/dist/tool-search/tool-search.d.ts +6 -12
  226. package/dist/tool-search/tool-search.d.ts.map +1 -1
  227. package/dist/tool-search/tool-search.js +7 -7
  228. package/dist/tool-search/tool-search.js.map +1 -1
  229. package/dist/tools/bash.d.ts +12 -12
  230. package/dist/tools/bash.d.ts.map +1 -1
  231. package/dist/tools/bash.js +16 -15
  232. package/dist/tools/bash.js.map +1 -1
  233. package/dist/tools/edit.d.ts +7 -7
  234. package/dist/tools/edit.d.ts.map +1 -1
  235. package/dist/tools/edit.js +9 -9
  236. package/dist/tools/edit.js.map +1 -1
  237. package/dist/tools/index.d.ts +32 -32
  238. package/dist/tools/read.d.ts +6 -6
  239. package/dist/tools/read.d.ts.map +1 -1
  240. package/dist/tools/read.js +19 -8
  241. package/dist/tools/read.js.map +1 -1
  242. package/dist/tools/write.d.ts +4 -4
  243. package/dist/tools/write.d.ts.map +1 -1
  244. package/dist/tools/write.js +5 -5
  245. package/dist/tools/write.js.map +1 -1
  246. package/dist/types.d.ts +331 -64
  247. package/dist/types.d.ts.map +1 -1
  248. package/dist/types.js.map +1 -1
  249. package/package.json +27 -10
  250. package/dist/harness/events.d.ts +0 -186
  251. package/dist/harness/events.d.ts.map +0 -1
  252. package/dist/harness/events.js +0 -373
  253. package/dist/harness/events.js.map +0 -1
  254. package/dist/session/observation.d.ts +0 -42
  255. package/dist/session/observation.d.ts.map +0 -1
  256. package/dist/session/observation.js +0 -281
  257. package/dist/session/observation.js.map +0 -1
@@ -1,18 +1,42 @@
1
- import { replicatedState } from "@earendil-works/chord";
1
+ import { Watch } from "@earendil-works/chord";
2
2
  import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
3
- import { track } from "@earendil-works/chord/delta";
3
+ import { PendingOps, track } from "@earendil-works/chord/delta";
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
- import { CommittedStateSource, CommittedWatch, RETIREMENT_OPERATIONS, } from "./observation.js";
8
- import { Transaction } from "./transaction.js";
7
+ import { hostTimers } from "../timers.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 = [];
9
16
  /**
10
- * Open a Session kernel over one storage backend. `now` is the wall clock for task times; default `Date.now`. The first
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();
27
+ /** The default source of a report: a listener, which never fails what ran it. */
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" };
31
+ /**
32
+ * Open a Session kernel over one storage backend. `now` is the wall clock for task times; default `Date.now`.
33
+ * `setTimer` arms the timers that evict idle documents; default `setTimeout` (see `HarnessOptions.setTimer`). The first
11
34
  * error a Storage method throws fails the Session (`SessionFailed`); see `SessionImpl`.
12
35
  */
13
36
  export function createSession(storage, options) {
14
37
  const retention = options?.documentRetentionMs;
15
- return new SessionImpl(storage, options?.now, retention === undefined ? undefined : () => retention);
38
+ const within = options?.persistWithinMs;
39
+ return new SessionImpl(storage, options?.now, retention === undefined ? undefined : () => retention, options?.setTimer, false, () => within);
16
40
  }
17
41
  /** Default document retention: ten minutes, as for kept contexts. */
18
42
  let defaultRetention = 600_000;
@@ -27,17 +51,35 @@ export function setDefaultDocumentRetention(ms) {
27
51
  export function documentRetention(ms) {
28
52
  return typeof ms === "number" && ms >= 0 ? ms : defaultRetention;
29
53
  }
30
- /** Longest delay `setTimeout` supports; a longer retention waits in several steps. */
31
- const MAX_TIMER_DELAY = 2_147_483_647;
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
+ }
32
63
  /**
33
64
  * Session kernel: one mutation line, the loaded document tracker cache, and committed publication.
34
65
  *
35
- * 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
36
67
  * written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation
37
68
  * line, so no transaction ever holds a tracker that leaves.
38
69
  *
39
- * Only committed state is observable. Every commit callback, preparation, Storage settlement, adoption, and
40
- * 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()`).
41
83
  *
42
84
  * Storage failures are final. Every Storage call goes through one guard: the first error a Storage method throws, other
43
85
  * than for a cancelled caller, fails the Session, as does a commit it cannot adopt. That call gets the error; every later
@@ -47,6 +89,13 @@ const MAX_TIMER_DELAY = 2_147_483_647;
47
89
  */
48
90
  export class SessionImpl {
49
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();
50
99
  #documents = new Map();
51
100
  /** Cached documents with no observer attached, by address, oldest use first, with the time of that use. */
52
101
  #idleDocuments = new Map();
@@ -54,17 +103,45 @@ export class SessionImpl {
54
103
  #attachedDocuments = new Map();
55
104
  #clock;
56
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;
112
+ #timers;
57
113
  /** The pending eviction: a timer for the oldest idle document, or a line job when the retention is 0. */
58
114
  #documentSweep;
59
115
  #sweepJobs = 0;
60
- /** Set once a timer could not be unreferenced: then none is kept, and the end of each commit evicts what expired. */
116
+ /** Set once the host declined a background timer: then none is kept, and the end of each commit evicts what expired. */
61
117
  #documentTimersUnavailable = false;
118
+ /** Host commit observers (`observeCommits()`), each behind `guardObserver()`. */
62
119
  #commitListeners = new Set();
63
120
  /** The Session's own listeners, such as its scheduler's: they keep memory in step with storage, so a throw fails it. */
64
121
  #internalListeners = new Set();
65
122
  #closeListeners = new Set();
66
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. */
67
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();
68
145
  #closing;
69
146
  #failure;
70
147
  /** Set when close reaches the backend: calls that fail from then on fail because it closes. */
@@ -81,10 +158,27 @@ export class SessionImpl {
81
158
  get failed() {
82
159
  return this.#failed.promise;
83
160
  }
84
- constructor(storage, now = Date.now, documentRetentionMs = () => undefined) {
161
+ constructor(storage, now = Date.now, documentRetentionMs = () => undefined, setTimer, readOnly = false, persistWithinMs = () => undefined) {
85
162
  // Waits race it; none has to.
86
163
  this.#failed.promise.catch(() => { });
87
- this.#storage = new GuardedStorage(storage, this);
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" }));
88
182
  // Cache bookkeeping must never change the outcome of the operation it follows: a clock or a retention setting
89
183
  // that throws counts as now, and as the default.
90
184
  this.#clock = () => {
@@ -103,8 +197,17 @@ export class SessionImpl {
103
197
  return documentRetention(undefined);
104
198
  }
105
199
  };
200
+ this.#persistWithinMs = () => {
201
+ try {
202
+ return persistWithin(persistWithinMs());
203
+ }
204
+ catch {
205
+ return persistWithin(undefined);
206
+ }
207
+ };
106
208
  this.#host = {
107
- storage: this.#storage,
209
+ storage: this.#gated,
210
+ touch: (key) => this.touch(key),
108
211
  now,
109
212
  cached: (id) => {
110
213
  const cached = this.#documents.get(id);
@@ -122,45 +225,167 @@ export class SessionImpl {
122
225
  this.#dropDocument(id);
123
226
  },
124
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),
125
231
  };
126
232
  }
127
233
  /** The Storage behind the failure guard; every component reads and writes through it. */
128
234
  get storage() {
129
235
  return this.#storage;
130
236
  }
131
- commit(change, context) {
132
- 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);
133
295
  }
134
296
  /**
135
297
  * Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run
136
298
  * now; they read the error from `failure`. The Storage guard calls this; the Harness also calls it for a throw in its
137
- * 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`.
138
301
  */
139
- fail(error) {
302
+ fail(error, context, at, own) {
140
303
  if (this.#failure !== undefined)
141
304
  return;
142
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);
143
311
  this.#failed.reject(new SessionFailed(error));
144
312
  // Seal first, so a report handler that calls back finds the Session failed. Closing runs the close listeners
145
313
  // synchronously; a failing backend close is the caller's to see, and here nobody waits.
146
314
  this.close(BACKGROUND_CONTEXT).catch(() => { });
147
- this.report(error);
315
+ this.report(error, { kind: "storage_failed" }, context);
148
316
  }
149
317
  /**
150
318
  * Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root
151
319
  * bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to
152
320
  * appended entries.
153
321
  */
154
- commitWith(change, context, scope) {
322
+ commitWith(change, context, scope, options) {
323
+ let withinMs;
155
324
  try {
156
325
  this.assertUsable();
326
+ withinMs = this.#deferral(options?.persistence);
157
327
  }
158
328
  catch (error) {
159
329
  return Promise.reject(error);
160
330
  }
161
- return this.#enqueue(() => this.#runCommit(change, context, scope));
331
+ return this.#enqueue((index) => this.#runCommit(change, context, scope, withinMs, index));
162
332
  }
163
- /** 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
+ */
164
389
  readOnLine(job) {
165
390
  try {
166
391
  this.assertUsable();
@@ -170,7 +395,7 @@ export class SessionImpl {
170
395
  }
171
396
  return this.#enqueue(async () => {
172
397
  this.#assertHealthy();
173
- return job();
398
+ return { value: await job(this.#gated) };
174
399
  });
175
400
  }
176
401
  /**
@@ -199,7 +424,7 @@ export class SessionImpl {
199
424
  ? cached
200
425
  : await this.#enqueue(async () => {
201
426
  this.#assertHealthy();
202
- return this.#loadDocument(definition, resolved.id, resolved.address, context);
427
+ return { value: await this.#loadDocument(definition, resolved.id, resolved.address, context) };
203
428
  });
204
429
  if (loaded === undefined)
205
430
  return undefined;
@@ -207,66 +432,40 @@ export class SessionImpl {
207
432
  checkRecordVersion(definition, loaded.record, loaded.storedVersion);
208
433
  return loaded.tracker.value;
209
434
  }
210
- documentState(token, ...args) {
435
+ watchDoc(token, ...args) {
436
+ // Not async, so an attachment costs one async frame, `#attachDocument`'s; a throw here still rejects.
211
437
  try {
212
438
  this.assertUsable();
213
439
  const definition = token.definition;
214
440
  const resolved = resolveAddress(definition, args);
215
441
  const context = args[resolved.nextArgument];
216
- return this.#enqueue(async () => {
217
- this.#assertHealthy();
218
- const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
219
- if (loaded === undefined)
220
- return undefined;
221
- const { observer: source, detach } = this.#attachDocument(definition, loaded, (value, release) => new CommittedStateSource(value, release));
222
- try {
223
- return replicatedState(source, { onError: (error) => this.report(error) });
224
- }
225
- catch (error) {
226
- detach();
227
- throw error;
228
- }
229
- });
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);
230
449
  }
231
450
  catch (error) {
232
451
  return Promise.reject(error);
233
452
  }
234
453
  }
235
- async watchDoc(token, ...args) {
236
- this.assertUsable();
237
- const definition = token.definition;
238
- const resolved = resolveAddress(definition, args);
239
- const context = args[resolved.nextArgument];
240
- const signal = context.abortSignal;
241
- let cancelled = signal?.aborted ?? false;
242
- const markCancelled = () => {
243
- cancelled = true;
244
- };
245
- signal?.addEventListener("abort", markCancelled, { once: true });
454
+ observeDoc(token, ...args) {
455
+ // Not async, as `watchDoc()`; a throw here still rejects.
246
456
  try {
247
- const watch = await this.#enqueue(async () => {
248
- this.#assertHealthy();
249
- if (cancelled)
250
- throw cancellationError(signal);
251
- const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
252
- if (cancelled)
253
- throw cancellationError(signal);
254
- if (loaded === undefined)
255
- return undefined;
256
- return this.#attachDocument(definition, loaded, (value, release) => new CommittedWatch(value, release, (error) => this.report(error))).observer;
257
- });
258
- if (watch === undefined)
259
- return undefined;
260
- if (cancelled) {
261
- watch.cancel();
262
- throw cancellationError(signal);
263
- }
264
- if (signal !== undefined)
265
- watch.observeCancellation(signal);
266
- 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);
267
466
  }
268
- finally {
269
- signal?.removeEventListener("abort", markCancelled);
467
+ catch (error) {
468
+ return Promise.reject(error);
270
469
  }
271
470
  }
272
471
  async snapshotAsOf(token, ...args) {
@@ -285,7 +484,7 @@ export class SessionImpl {
285
484
  const context = args[resolved.nextArgument + 1];
286
485
  return this.#enqueue(async () => {
287
486
  this.#assertHealthy();
288
- const storedEntry = await this.#storage.entry(conversationId, at, context);
487
+ const storedEntry = await this.#gated.entry(conversationId, at, context);
289
488
  if (storedEntry === undefined) {
290
489
  throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);
291
490
  }
@@ -293,14 +492,14 @@ export class SessionImpl {
293
492
  ...resolved.address,
294
493
  scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId },
295
494
  };
296
- const record = await this.#storage.findDocument(address, storedEntry.commitSeq, context);
495
+ const record = await this.#gated.findDocument(address, storedEntry.commitSeq, context);
297
496
  if (record === undefined)
298
- return undefined;
299
- 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);
300
499
  if (stored === undefined) {
301
500
  throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`);
302
501
  }
303
- return materializeDocument(definition, stored);
502
+ return { value: materializeDocument(definition, stored) };
304
503
  });
305
504
  }
306
505
  close(context) {
@@ -309,12 +508,30 @@ export class SessionImpl {
309
508
  // Seal admission before anything else runs, then stop observers; admitted work settles before Storage closes.
310
509
  this.#closing = Promise.resolve()
311
510
  .then(() => this.beforeClose())
312
- .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];
313
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);
314
529
  this.#internalListeners.clear();
315
530
  this.#documents.clear();
316
531
  this.#idleDocuments.clear();
317
532
  this.#attachedDocuments.clear();
533
+ this.#holds.clear();
534
+ this.#taskDocuments.clear();
318
535
  try {
319
536
  await this.#storage.close(cleanup);
320
537
  }
@@ -322,14 +539,16 @@ export class SessionImpl {
322
539
  // A Storage that cannot close is a failed one; an earlier failure stays the cause.
323
540
  if (this.#failure === undefined) {
324
541
  this.#failure = { error };
325
- this.report(error);
542
+ this.report(error, { kind: "storage_failed" });
326
543
  }
327
544
  throw error;
328
545
  }
546
+ if (unstored !== undefined)
547
+ throw unstored.error;
329
548
  }))
330
549
  .finally(() => {
331
550
  const failure = this.#failure;
332
- 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 });
333
552
  });
334
553
  this.#notifyClose();
335
554
  }
@@ -337,16 +556,20 @@ export class SessionImpl {
337
556
  }
338
557
  /** Run the close listeners once, each on its own: one that fails is reported, and the rest still run. */
339
558
  #notifyClose() {
340
- if (this.#documentSweep?.timer !== undefined)
341
- clearTimeout(this.#documentSweep.timer);
559
+ this.#documentSweep?.disarm?.();
342
560
  this.#documentSweep = undefined;
561
+ this.#holdTimer?.disarm();
562
+ this.#holdTimer = undefined;
343
563
  const listeners = [...this.#closeListeners];
344
564
  this.#closeListeners.clear();
345
565
  for (const listener of listeners)
346
566
  contained(listener, (error) => this.report(error));
347
567
  }
348
- /** Internal: the Session's failure, and errors of listeners, which never fail what ran them. A plain Session drops them. */
349
- report(_error) { }
568
+ /**
569
+ * Internal: the Session's failure, and errors of listeners, which never fail what ran them; `info` defaults to a
570
+ * listener. A plain Session drops them.
571
+ */
572
+ report(_error, _info = LISTENER, _context) { }
350
573
  /**
351
574
  * Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
352
575
  * plain Session stages nothing; a Harness stages its built-in documents.
@@ -354,21 +577,61 @@ export class SessionImpl {
354
577
  conversationCreated(_tx, _record) {
355
578
  return Promise.resolve();
356
579
  }
580
+ /**
581
+ * Internal, for `beforeClose()`: on the line, after every commit admitted before the seal has settled, write what
582
+ * `writes()` returns then straight to Storage. Nothing is published, since the Session is closing; a failed Session
583
+ * writes nothing.
584
+ */
585
+ writeAtClose(writes) {
586
+ return this.#maintain(async (index) => {
587
+ await this.#drained();
588
+ if (this.#failure !== undefined)
589
+ return;
590
+ const batch = writes();
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;
602
+ });
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
+ }
357
611
  /** Runs after close seals admission and before the line closes Storage; must not reject. */
358
612
  beforeClose() {
359
613
  return Promise.resolve();
360
614
  }
361
- /** Register a synchronous post-adoption listener. It must not block or call Session operations; a throw is reported. */
362
- 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) {
363
623
  this.assertUsable();
364
- this.#commitListeners.add(listener);
365
- 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() };
366
627
  }
367
628
  /**
368
- * Internal: `subscribeCommits()` for the Session's own components, whose state follows each commit. A throw leaves
369
- * 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.
370
633
  */
371
- observeCommits(listener) {
634
+ followCommits(listener) {
372
635
  this.assertUsable();
373
636
  this.#internalListeners.add(listener);
374
637
  return () => this.#internalListeners.delete(listener);
@@ -382,16 +645,19 @@ export class SessionImpl {
382
645
  this.#closeListeners.add(listener);
383
646
  return () => this.#closeListeners.delete(listener);
384
647
  }
385
- /** 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. */
386
649
  unloadDocuments() {
387
- return this.#enqueue(async () => {
650
+ return this.#maintain(async () => {
651
+ if (this.#failure === undefined)
652
+ await this.#storeHolds(() => [...this.#holds.values()], BACKGROUND_CONTEXT);
388
653
  this.#documents.clear();
389
654
  this.#idleDocuments.clear();
390
655
  });
391
656
  }
392
657
  /**
393
- * Internal, for tests: how many documents the cache holds, how many are idle, how many incarnations are pinned, and how
394
- * 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.
395
661
  */
396
662
  get documentCacheSizes() {
397
663
  return {
@@ -399,6 +665,20 @@ export class SessionImpl {
399
665
  idle: this.#idleDocuments.size,
400
666
  attached: this.#attachedDocuments.size,
401
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,
402
682
  };
403
683
  }
404
684
  /** Note a use of a cached document: an unattached one becomes the newest idle one, and an eviction is scheduled. */
@@ -425,25 +705,31 @@ export class SessionImpl {
425
705
  }
426
706
  /**
427
707
  * Schedule the eviction of the oldest idle document: on the line at once when the retention is 0, otherwise when it
428
- * expires, by a timer that never keeps the process alive. Where timers cannot be unreferenced, as in Cloudflare
429
- * Workers, none is kept: the end of each commit evicts what expired.
708
+ * expires, by a background timer (`HarnessOptions.setTimer`), which never keeps the process alive. Where the host
709
+ * declines it, as the default does in Cloudflare Workers, none is kept: the end of each commit evicts what expired.
430
710
  */
431
711
  #scheduleDocumentSweep() {
432
712
  if (this.#closing !== undefined)
433
713
  return;
434
- const oldest = this.#idleDocuments.values().next();
435
- 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)
436
723
  return;
437
- const at = oldest.value + this.#documentRetentionMs();
724
+ const at = oldest + this.#documentRetentionMs();
438
725
  if (this.#documentSweep !== undefined && this.#documentSweep.at <= at)
439
726
  return;
440
- if (this.#documentSweep?.timer !== undefined)
441
- clearTimeout(this.#documentSweep.timer);
727
+ this.#documentSweep?.disarm?.();
442
728
  // The pending sweep stays set until its job runs, so touches while the line is busy queue no more.
443
- const sweep = { at, timer: undefined };
729
+ const sweep = { at, disarm: undefined };
444
730
  const run = () => {
445
731
  this.#sweepJobs++;
446
- this.#enqueue(async () => {
732
+ this.#maintain(async () => {
447
733
  if (this.#documentSweep === sweep)
448
734
  this.#documentSweep = undefined;
449
735
  if (this.#closing === undefined)
@@ -460,16 +746,14 @@ export class SessionImpl {
460
746
  this.#documentSweep = undefined;
461
747
  return;
462
748
  }
463
- const timer = setTimeout(run, Math.min(delay, MAX_TIMER_DELAY));
464
- const unref = timer.unref;
465
- if (typeof unref !== "function") {
466
- clearTimeout(timer);
749
+ // A timer that fires early sweeps nothing and arms the next one.
750
+ const disarm = this.#timers.background(run, delay);
751
+ if (disarm === undefined) {
467
752
  this.#documentTimersUnavailable = true;
468
753
  this.#documentSweep = undefined;
469
754
  return;
470
755
  }
471
- unref.call(timer);
472
- sweep.timer = timer;
756
+ sweep.disarm = disarm;
473
757
  }
474
758
  /** On the line: evict the idle documents whose retention passed, oldest first, then schedule the next eviction. */
475
759
  #sweepDocuments() {
@@ -477,6 +761,9 @@ export class SessionImpl {
477
761
  for (const [addressId, since] of this.#idleDocuments) {
478
762
  if (since > expired)
479
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;
480
767
  this.#dropDocument(addressId);
481
768
  }
482
769
  this.#scheduleDocumentSweep();
@@ -500,7 +787,7 @@ export class SessionImpl {
500
787
  this.#touchDocument(addressId);
501
788
  };
502
789
  }
503
- async #runCommit(change, context, scope) {
790
+ async #runCommit(change, context, scope, withinMs, index) {
504
791
  this.#assertHealthy();
505
792
  context.abortSignal?.throwIfAborted();
506
793
  const tx = new Transaction(this.#host, context, scope);
@@ -527,110 +814,460 @@ export class SessionImpl {
527
814
  if (writes.length === 0) {
528
815
  tx.discard();
529
816
  this.#sweepDocuments();
530
- return result;
531
- }
532
- let seq;
533
- try {
534
- // Once admitted, caller cancellation does not interrupt Storage settlement.
535
- seq = await this.#storage.commit(writes, withoutAbortSignal(context));
817
+ return { value: result };
536
818
  }
537
- catch (error) {
538
- // The guard has failed the Session.
819
+ if (this.#readOnly) {
820
+ // Refused before Storage: the Session stays healthy, and the caller sees why.
539
821
  tx.discard();
540
- throw error;
822
+ this.#sweepDocuments();
823
+ throw new ReadOnlySession("a commit with writes");
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
+ }
541
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) {
542
876
  let documents;
543
877
  try {
544
878
  documents = tx.adopt(seq);
545
879
  }
546
880
  catch (error) {
547
- // Storage already committed; a failed adoption leaves memory behind durable state.
548
- this.fail(error);
549
- throw error;
881
+ return { kind: "adoption", error };
550
882
  }
551
- 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)
552
888
  this.#touchDocument(addressId(document.record));
553
- this.#publish(seq, writes, documents, context);
554
- this.#sweepDocuments();
555
- return result;
889
+ return failure === undefined ? undefined : { kind: "listener", error: failure.error };
556
890
  }
557
- #publish(seq, writes, documents, context) {
558
- if (this.#commitListeners.size === 0 && this.#internalListeners.size === 0)
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
+ }
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)
559
968
  return;
969
+ const writes = [];
560
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
+ */
1045
+ #publish(seq, writes, documents, context) {
1046
+ const changes = this.#commitListeners.size > 0 || this.#internalListeners.size > 0 ? [] : undefined;
561
1047
  for (const write of writes) {
562
1048
  switch (write.type) {
563
- case "conversation":
564
1049
  case "entry":
565
- case "task":
1050
+ this.#event("pi.durable.entry", { "entry.id": write.value.id, "entry.kind": write.value.kind }, context);
1051
+ changes?.push(write);
1052
+ break;
566
1053
  case "submission":
567
- changes.push(write);
1054
+ if (write.value.status === "done" || write.value.status === "unanswered") {
1055
+ this.#event("pi.durable.submission", {
1056
+ "submission.id": write.value.id,
1057
+ status: write.value.status,
1058
+ "answer.entry_id": write.value.answer,
1059
+ }, context);
1060
+ }
1061
+ changes?.push(write);
1062
+ break;
1063
+ case "conversation":
1064
+ case "task":
1065
+ changes?.push(write);
568
1066
  }
569
1067
  }
570
- for (const document of documents)
1068
+ if (changes === undefined)
1069
+ return undefined;
1070
+ const tables = changes.length;
1071
+ for (const document of documents.observed)
571
1072
  changes.push(document);
572
- const publication = { seq, changes };
573
- // 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;
574
1083
  for (const listener of [...this.#internalListeners]) {
575
- contained(() => listener(publication, context), (error) => this.fail(error));
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);
576
1098
  }
577
- for (const listener of [...this.#commitListeners]) {
578
- contained(() => listener(publication, context), (error) => this.report(error));
1099
+ catch (error) {
1100
+ this.report(error, TELEMETRY, context);
579
1101
  }
580
1102
  }
581
1103
  /**
582
- * Attach an observer to one committed incarnation: check the definition, then forward this incarnation's committed
583
- * changes and close. `detach` removes both subscriptions.
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.
584
1118
  */
585
- #attachDocument(definition, loaded, create) {
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;
1143
+ }
1144
+ finally {
1145
+ signal?.removeEventListener("abort", markCancelled);
1146
+ }
1147
+ }
1148
+ /**
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.
1159
+ */
1160
+ #observeDocument(definition, loaded, create, ownerEnded) {
586
1161
  checkRecordScope(definition, loaded.record);
587
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);
588
1174
  let unsubscribeCommit = () => { };
589
1175
  let unsubscribeClose = () => { };
590
- // Attached, the incarnation stays cached; once its last observer detaches, or it retires, it is idle again.
591
- const unpin = this.#pinDocument(loaded.addressId, loaded.record.id);
1176
+ let attached = true;
592
1177
  const detach = () => {
1178
+ if (!attached)
1179
+ return;
1180
+ attached = false;
593
1181
  unsubscribeCommit();
594
1182
  unsubscribeClose();
595
1183
  unpin();
596
1184
  };
597
- let observer;
598
- try {
599
- observer = create(loaded.tracker.value, detach);
600
- }
601
- catch (error) {
602
- unpin();
603
- throw error;
604
- }
605
- const observed = { version: loaded.valueVersion };
606
- 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;
607
1191
  for (const change of publication.changes) {
608
- 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
+ }
609
1199
  continue;
610
- // A document state's frames carry no caller cancellation; a watch observes its own cancellation.
611
- const frameContext = observer instanceof CommittedStateSource ? withoutAbortSignal(context) : context;
612
- const ops = observedOperations(observed, change);
613
- // A migration-only base changes nothing for an observer of the new version.
614
- 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")
615
1203
  continue;
616
- // A retired incarnation never changes again: its observers keep their last value but pin nothing.
617
- 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;
618
1213
  unpin();
619
- 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;
620
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)));
621
1248
  });
622
- unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure));
623
- return { observer, detach };
1249
+ return observer;
624
1250
  }
625
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;
626
1256
  const cached = this.#documents.get(addressId);
627
1257
  // A tracker serves only tokens of the version its value was materialized for; others reload from Storage.
628
1258
  if (cached?.valueVersion === definition.version) {
629
1259
  this.#touchDocument(addressId);
630
1260
  return cached;
631
1261
  }
632
- 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);
633
1269
  this.#dropDocument(addressId);
1270
+ }
634
1271
  const record = await this.#storage.findDocument(address, "current", context);
635
1272
  if (record === undefined)
636
1273
  return undefined;
@@ -650,10 +1287,139 @@ export class SessionImpl {
650
1287
  this.#touchDocument(addressId);
651
1288
  return loaded;
652
1289
  }
653
- #enqueue(job) {
654
- const run = this.#tail.then(job);
655
- this.#tail = run.then(() => undefined, () => undefined);
656
- 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
+ }
657
1423
  }
658
1424
  /** Internal: throw `SessionFailed` when failed, else an error when closing. */
659
1425
  assertUsable() {
@@ -682,16 +1448,123 @@ export function contained(callback, onError) {
682
1448
  }
683
1449
  }
684
1450
  /**
685
- * Operations an observer applies for one committed change. An observer hydrated under another definition version holds
686
- * 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.
687
1532
  */
688
- function observedOperations(observed, change) {
689
- if (change.value === null)
690
- return RETIREMENT_OPERATIONS;
691
- if (change.version === observed.version)
692
- return change.ops;
693
- observed.version = change.version;
694
- 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
+ }
695
1568
  }
696
1569
  function cancellationError(signal) {
697
1570
  return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
@@ -713,9 +1586,26 @@ class GuardedStorage {
713
1586
  this.#storage = storage;
714
1587
  this.#latch = latch;
715
1588
  }
716
- /** Never exempt, whatever it throws: once admitted, whether a failed batch committed is unknown. */
717
- commit(writes, context) {
718
- 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
+ }
719
1609
  }
720
1610
  mintId() {
721
1611
  return this.#call(undefined, () => this.#storage.mintId());