@earendil-works/pi-durable 1.1.0 → 1.2.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 (184) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +147 -11
  3. package/dist/codemode/codemode.d.ts +173 -0
  4. package/dist/codemode/codemode.d.ts.map +1 -0
  5. package/dist/codemode/codemode.js +886 -0
  6. package/dist/codemode/codemode.js.map +1 -0
  7. package/dist/codemode/describe.d.ts +38 -0
  8. package/dist/codemode/describe.d.ts.map +1 -0
  9. package/dist/codemode/describe.js +119 -0
  10. package/dist/codemode/describe.js.map +1 -0
  11. package/dist/codemode/index.d.ts +3 -0
  12. package/dist/codemode/index.d.ts.map +1 -0
  13. package/dist/codemode/index.js +3 -0
  14. package/dist/codemode/index.js.map +1 -0
  15. package/dist/codemode/models.d.ts +28 -0
  16. package/dist/codemode/models.d.ts.map +1 -0
  17. package/dist/codemode/models.js +231 -0
  18. package/dist/codemode/models.js.map +1 -0
  19. package/dist/codemode/output.d.ts +41 -0
  20. package/dist/codemode/output.d.ts.map +1 -0
  21. package/dist/codemode/output.js +167 -0
  22. package/dist/codemode/output.js.map +1 -0
  23. package/dist/env/index.d.ts +42 -0
  24. package/dist/env/index.d.ts.map +1 -1
  25. package/dist/env/index.js.map +1 -1
  26. package/dist/errors.d.ts +20 -3
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +34 -5
  29. package/dist/errors.js.map +1 -1
  30. package/dist/harness/agent.d.ts +5 -2
  31. package/dist/harness/agent.d.ts.map +1 -1
  32. package/dist/harness/agent.js +69 -25
  33. package/dist/harness/agent.js.map +1 -1
  34. package/dist/harness/compaction.d.ts +6 -1
  35. package/dist/harness/compaction.d.ts.map +1 -1
  36. package/dist/harness/compaction.js +10 -7
  37. package/dist/harness/compaction.js.map +1 -1
  38. package/dist/harness/events.d.ts +35 -15
  39. package/dist/harness/events.d.ts.map +1 -1
  40. package/dist/harness/events.js +77 -23
  41. package/dist/harness/events.js.map +1 -1
  42. package/dist/harness/generation.d.ts +2 -0
  43. package/dist/harness/generation.d.ts.map +1 -1
  44. package/dist/harness/generation.js +31 -8
  45. package/dist/harness/generation.js.map +1 -1
  46. package/dist/harness/harness.d.ts +7 -0
  47. package/dist/harness/harness.d.ts.map +1 -1
  48. package/dist/harness/harness.js +53 -26
  49. package/dist/harness/harness.js.map +1 -1
  50. package/dist/harness/inbox.d.ts +16 -4
  51. package/dist/harness/inbox.d.ts.map +1 -1
  52. package/dist/harness/inbox.js +26 -3
  53. package/dist/harness/inbox.js.map +1 -1
  54. package/dist/harness/live.d.ts +74 -17
  55. package/dist/harness/live.d.ts.map +1 -1
  56. package/dist/harness/live.js +66 -14
  57. package/dist/harness/live.js.map +1 -1
  58. package/dist/harness/scheduler.d.ts +18 -5
  59. package/dist/harness/scheduler.d.ts.map +1 -1
  60. package/dist/harness/scheduler.js +696 -163
  61. package/dist/harness/scheduler.js.map +1 -1
  62. package/dist/harness/submissions.d.ts +7 -4
  63. package/dist/harness/submissions.d.ts.map +1 -1
  64. package/dist/harness/submissions.js +32 -13
  65. package/dist/harness/submissions.js.map +1 -1
  66. package/dist/harness/task-graph.d.ts +1 -1
  67. package/dist/harness/task-graph.d.ts.map +1 -1
  68. package/dist/harness/task-graph.js +15 -10
  69. package/dist/harness/task-graph.js.map +1 -1
  70. package/dist/harness/tool.d.ts +45 -5
  71. package/dist/harness/tool.d.ts.map +1 -1
  72. package/dist/harness/tool.js +377 -62
  73. package/dist/harness/tool.js.map +1 -1
  74. package/dist/harness/types.d.ts +153 -13
  75. package/dist/harness/types.d.ts.map +1 -1
  76. package/dist/harness/types.js.map +1 -1
  77. package/dist/harness/util.d.ts +6 -1
  78. package/dist/harness/util.d.ts.map +1 -1
  79. package/dist/harness/util.js +5 -2
  80. package/dist/harness/util.js.map +1 -1
  81. package/dist/harness/view.d.ts +6 -2
  82. package/dist/harness/view.d.ts.map +1 -1
  83. package/dist/harness/view.js +22 -12
  84. package/dist/harness/view.js.map +1 -1
  85. package/dist/images/cloudflare.d.ts +4 -0
  86. package/dist/images/cloudflare.d.ts.map +1 -0
  87. package/dist/images/cloudflare.js +9 -0
  88. package/dist/images/cloudflare.js.map +1 -0
  89. package/dist/images/exif.d.ts +3 -0
  90. package/dist/images/exif.d.ts.map +1 -0
  91. package/dist/images/exif.js +71 -0
  92. package/dist/images/exif.js.map +1 -0
  93. package/dist/images/index.d.ts +18 -0
  94. package/dist/images/index.d.ts.map +1 -0
  95. package/dist/images/index.js +125 -0
  96. package/dist/images/index.js.map +1 -0
  97. package/dist/images/node-worker.d.ts +17 -0
  98. package/dist/images/node-worker.d.ts.map +1 -0
  99. package/dist/images/node-worker.js +16 -0
  100. package/dist/images/node-worker.js.map +1 -0
  101. package/dist/images/node.d.ts +21 -0
  102. package/dist/images/node.d.ts.map +1 -0
  103. package/dist/images/node.js +121 -0
  104. package/dist/images/node.js.map +1 -0
  105. package/dist/index.d.ts +6 -6
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +3 -3
  108. package/dist/index.js.map +1 -1
  109. package/dist/search.d.ts +13 -0
  110. package/dist/search.d.ts.map +1 -0
  111. package/dist/search.js +85 -0
  112. package/dist/search.js.map +1 -0
  113. package/dist/session/observation.d.ts +10 -3
  114. package/dist/session/observation.d.ts.map +1 -1
  115. package/dist/session/observation.js +24 -34
  116. package/dist/session/observation.js.map +1 -1
  117. package/dist/session/session.d.ts +68 -5
  118. package/dist/session/session.d.ts.map +1 -1
  119. package/dist/session/session.js +461 -49
  120. package/dist/session/session.js.map +1 -1
  121. package/dist/session/transaction.d.ts.map +1 -1
  122. package/dist/session/transaction.js +1 -0
  123. package/dist/session/transaction.js.map +1 -1
  124. package/dist/storage/jsonl/storage.d.ts +4 -0
  125. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  126. package/dist/storage/jsonl/storage.js +2 -1
  127. package/dist/storage/jsonl/storage.js.map +1 -1
  128. package/dist/storage/memory.d.ts.map +1 -1
  129. package/dist/storage/memory.js +8 -10
  130. package/dist/storage/memory.js.map +1 -1
  131. package/dist/storage/scan.d.ts.map +1 -1
  132. package/dist/storage/scan.js +5 -4
  133. package/dist/storage/scan.js.map +1 -1
  134. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  135. package/dist/storage/sqlite/storage.js +10 -12
  136. package/dist/storage/sqlite/storage.js.map +1 -1
  137. package/dist/testing/env-conformance.d.ts.map +1 -1
  138. package/dist/testing/env-conformance.js +100 -0
  139. package/dist/testing/env-conformance.js.map +1 -1
  140. package/dist/testing/runner.d.ts +1 -1
  141. package/dist/testing/runner.d.ts.map +1 -1
  142. package/dist/testing/runner.js.map +1 -1
  143. package/dist/testing/storage-conformance.d.ts.map +1 -1
  144. package/dist/testing/storage-conformance.js +3 -2
  145. package/dist/testing/storage-conformance.js.map +1 -1
  146. package/dist/testing/types.d.ts +2 -0
  147. package/dist/testing/types.d.ts.map +1 -1
  148. package/dist/testing/types.js.map +1 -1
  149. package/dist/tool-search/index.d.ts +2 -0
  150. package/dist/tool-search/index.d.ts.map +1 -0
  151. package/dist/tool-search/index.js +2 -0
  152. package/dist/tool-search/index.js.map +1 -0
  153. package/dist/tool-search/tool-search.d.ts +23 -0
  154. package/dist/tool-search/tool-search.d.ts.map +1 -0
  155. package/dist/tool-search/tool-search.js +84 -0
  156. package/dist/tool-search/tool-search.js.map +1 -0
  157. package/dist/tools/bash.d.ts +10 -2
  158. package/dist/tools/bash.d.ts.map +1 -1
  159. package/dist/tools/bash.js +27 -9
  160. package/dist/tools/bash.js.map +1 -1
  161. package/dist/tools/edit.js +1 -1
  162. package/dist/tools/edit.js.map +1 -1
  163. package/dist/tools/image-processor.d.ts +47 -0
  164. package/dist/tools/image-processor.d.ts.map +1 -0
  165. package/dist/tools/image-processor.js +30 -0
  166. package/dist/tools/image-processor.js.map +1 -0
  167. package/dist/tools/image.d.ts +8 -0
  168. package/dist/tools/image.d.ts.map +1 -1
  169. package/dist/tools/image.js +47 -0
  170. package/dist/tools/image.js.map +1 -1
  171. package/dist/tools/index.d.ts +25 -2
  172. package/dist/tools/index.d.ts.map +1 -1
  173. package/dist/tools/index.js +13 -5
  174. package/dist/tools/index.js.map +1 -1
  175. package/dist/tools/read.d.ts +10 -2
  176. package/dist/tools/read.d.ts.map +1 -1
  177. package/dist/tools/read.js +87 -28
  178. package/dist/tools/read.js.map +1 -1
  179. package/dist/tools/write.js +1 -1
  180. package/dist/tools/write.js.map +1 -1
  181. package/dist/types.d.ts +59 -5
  182. package/dist/types.d.ts.map +1 -1
  183. package/dist/types.js.map +1 -1
  184. package/package.json +43 -4
@@ -1,50 +1,151 @@
1
1
  import { replicatedState } from "@earendil-works/chord";
2
- import { awaitWithContext, withoutAbortSignal } from "@earendil-works/chord/context";
2
+ import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
3
3
  import { track } from "@earendil-works/chord/delta";
4
- import { checkRecordScope, checkRecordVersion, materializeDocument, resolveAddress, } from "../documents.js";
5
- import { StorageRejected } from "../errors.js";
4
+ import { addressId, checkRecordScope, checkRecordVersion, materializeDocument, resolveAddress, } from "../documents.js";
5
+ import { SessionFailed, StorageRequestError } from "../errors.js";
6
6
  import { idFromNumber } from "../ids.js";
7
7
  import { CommittedStateSource, CommittedWatch, RETIREMENT_OPERATIONS, } from "./observation.js";
8
8
  import { Transaction } from "./transaction.js";
9
- /** Open a Session kernel over one storage backend. `now` is the wall clock for task times; default `Date.now`. */
9
+ /**
10
+ * Open a Session kernel over one storage backend. `now` is the wall clock for task times; default `Date.now`. The first
11
+ * error a Storage method throws fails the Session (`SessionFailed`); see `SessionImpl`.
12
+ */
10
13
  export function createSession(storage, options) {
11
- return new SessionImpl(storage, options?.now);
14
+ const retention = options?.documentRetentionMs;
15
+ return new SessionImpl(storage, options?.now, retention === undefined ? undefined : () => retention);
16
+ }
17
+ /** Default document retention: ten minutes, as for kept contexts. */
18
+ let defaultRetention = 600_000;
19
+ /** Internal, for tests: the retention of every Session that keeps the default, as a suite run with eviction at once. */
20
+ export function setDefaultDocumentRetention(ms) {
21
+ defaultRetention = ms;
12
22
  }
23
+ /**
24
+ * A retention as given, when it is a number of milliseconds, `0` or more (`Infinity` keeps documents); otherwise the
25
+ * default.
26
+ */
27
+ export function documentRetention(ms) {
28
+ return typeof ms === "number" && ms >= 0 ? ms : defaultRetention;
29
+ }
30
+ /** Longest delay `setTimeout` supports; a longer retention waits in several steps. */
31
+ const MAX_TIMER_DELAY = 2_147_483_647;
13
32
  /**
14
33
  * Session kernel: one mutation line, the loaded document tracker cache, and committed publication.
15
34
  *
35
+ * The cache is bounded by use: a document with no watch or document state attached leaves it once it was not read or
36
+ * written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation
37
+ * line, so no transaction ever holds a tracker that leaves.
38
+ *
16
39
  * Only committed state is observable. Every commit callback, preparation, Storage settlement, adoption, and
17
40
  * publication enqueue runs while the line is held; listeners run later.
41
+ *
42
+ * Storage failures are final. Every Storage call goes through one guard: the first error a Storage method throws, other
43
+ * than for a cancelled caller, fails the Session, as does a commit it cannot adopt. That call gets the error; every later
44
+ * call and every Storage call still underway gets `SessionFailed`; close listeners run at once with the error, and the
45
+ * Session then closes itself, Storage included, and `closed` settles. Nothing retries: transient errors are the
46
+ * Storage's to retry. Reopening recovers from what was committed.
18
47
  */
19
48
  export class SessionImpl {
20
49
  #storage;
21
50
  #documents = new Map();
51
+ /** Cached documents with no observer attached, by address, oldest use first, with the time of that use. */
52
+ #idleDocuments = new Map();
53
+ /** Observers attached per document incarnation (record ID); a cached incarnation with any is never evicted. */
54
+ #attachedDocuments = new Map();
55
+ #clock;
56
+ #documentRetentionMs;
57
+ /** The pending eviction: a timer for the oldest idle document, or a line job when the retention is 0. */
58
+ #documentSweep;
59
+ #sweepJobs = 0;
60
+ /** Set once a timer could not be unreferenced: then none is kept, and the end of each commit evicts what expired. */
61
+ #documentTimersUnavailable = false;
22
62
  #commitListeners = new Set();
63
+ /** The Session's own listeners, such as its scheduler's: they keep memory in step with storage, so a throw fails it. */
64
+ #internalListeners = new Set();
23
65
  #closeListeners = new Set();
24
66
  #host;
25
67
  #tail = Promise.resolve();
26
68
  #closing;
27
- #poison;
28
- constructor(storage, now = Date.now) {
29
- this.#storage = storage;
69
+ #failure;
70
+ /** Set when close reaches the backend: calls that fail from then on fail because it closes. */
71
+ #failed = Promise.withResolvers();
72
+ #closed = Promise.withResolvers();
73
+ get closed() {
74
+ return this.#closed.promise;
75
+ }
76
+ /** Internal: the error that failed the Session, if one did. */
77
+ get failure() {
78
+ return this.#failure;
79
+ }
80
+ /** Internal: rejects with `SessionFailed` the moment the Session fails, before work underway has ended; never resolves. */
81
+ get failed() {
82
+ return this.#failed.promise;
83
+ }
84
+ constructor(storage, now = Date.now, documentRetentionMs = () => undefined) {
85
+ // Waits race it; none has to.
86
+ this.#failed.promise.catch(() => { });
87
+ this.#storage = new GuardedStorage(storage, this);
88
+ // Cache bookkeeping must never change the outcome of the operation it follows: a clock or a retention setting
89
+ // that throws counts as now, and as the default.
90
+ this.#clock = () => {
91
+ try {
92
+ return now();
93
+ }
94
+ catch {
95
+ return Date.now();
96
+ }
97
+ };
98
+ this.#documentRetentionMs = () => {
99
+ try {
100
+ return documentRetention(documentRetentionMs());
101
+ }
102
+ catch {
103
+ return documentRetention(undefined);
104
+ }
105
+ };
30
106
  this.#host = {
31
- storage,
107
+ storage: this.#storage,
32
108
  now,
33
- cached: (id) => this.#documents.get(id),
109
+ cached: (id) => {
110
+ const cached = this.#documents.get(id);
111
+ if (cached !== undefined)
112
+ this.#touchDocument(id);
113
+ return cached;
114
+ },
34
115
  load: (definition, addressId, address, context) => this.#loadDocument(definition, addressId, address, context),
35
116
  install: (document) => {
36
117
  this.#documents.set(document.addressId, document);
118
+ this.#touchDocument(document.addressId);
37
119
  },
38
120
  evict: (id, recordId) => {
39
121
  if (this.#documents.get(id)?.record.id === recordId)
40
- this.#documents.delete(id);
122
+ this.#dropDocument(id);
41
123
  },
42
124
  conversationCreated: (tx, record) => this.conversationCreated(tx, record),
43
125
  };
44
126
  }
127
+ /** The Storage behind the failure guard; every component reads and writes through it. */
128
+ get storage() {
129
+ return this.#storage;
130
+ }
45
131
  commit(change, context) {
46
132
  return this.commitWith(change, context);
47
133
  }
134
+ /**
135
+ * Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run
136
+ * 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.
138
+ */
139
+ fail(error) {
140
+ if (this.#failure !== undefined)
141
+ return;
142
+ this.#failure = { error };
143
+ this.#failed.reject(new SessionFailed(error));
144
+ // Seal first, so a report handler that calls back finds the Session failed. Closing runs the close listeners
145
+ // synchronously; a failing backend close is the caller's to see, and here nobody waits.
146
+ this.close(BACKGROUND_CONTEXT).catch(() => { });
147
+ this.report(error);
148
+ }
48
149
  /**
49
150
  * Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root
50
151
  * bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to
@@ -52,7 +153,7 @@ export class SessionImpl {
52
153
  */
53
154
  commitWith(change, context, scope) {
54
155
  try {
55
- this.#assertUsable();
156
+ this.assertUsable();
56
157
  }
57
158
  catch (error) {
58
159
  return Promise.reject(error);
@@ -62,7 +163,7 @@ export class SessionImpl {
62
163
  /** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */
63
164
  readOnLine(job) {
64
165
  try {
65
- this.#assertUsable();
166
+ this.assertUsable();
66
167
  }
67
168
  catch (error) {
68
169
  return Promise.reject(error);
@@ -87,11 +188,13 @@ export class SessionImpl {
87
188
  return { record: loaded.record, version: loaded.valueVersion, value: loaded.tracker.value };
88
189
  }
89
190
  async snapshot(token, ...args) {
90
- this.#assertUsable();
191
+ this.assertUsable();
91
192
  const definition = token.definition;
92
193
  const resolved = resolveAddress(definition, args);
93
194
  const context = args[resolved.nextArgument];
94
195
  const cached = this.#documents.get(resolved.id);
196
+ if (cached?.valueVersion === definition.version)
197
+ this.#touchDocument(resolved.id);
95
198
  const loaded = cached?.valueVersion === definition.version
96
199
  ? cached
97
200
  : await this.#enqueue(async () => {
@@ -106,7 +209,7 @@ export class SessionImpl {
106
209
  }
107
210
  documentState(token, ...args) {
108
211
  try {
109
- this.#assertUsable();
212
+ this.assertUsable();
110
213
  const definition = token.definition;
111
214
  const resolved = resolveAddress(definition, args);
112
215
  const context = args[resolved.nextArgument];
@@ -117,7 +220,7 @@ export class SessionImpl {
117
220
  return undefined;
118
221
  const { observer: source, detach } = this.#attachDocument(definition, loaded, (value, release) => new CommittedStateSource(value, release));
119
222
  try {
120
- return replicatedState(source);
223
+ return replicatedState(source, { onError: (error) => this.report(error) });
121
224
  }
122
225
  catch (error) {
123
226
  detach();
@@ -130,7 +233,7 @@ export class SessionImpl {
130
233
  }
131
234
  }
132
235
  async watchDoc(token, ...args) {
133
- this.#assertUsable();
236
+ this.assertUsable();
134
237
  const definition = token.definition;
135
238
  const resolved = resolveAddress(definition, args);
136
239
  const context = args[resolved.nextArgument];
@@ -150,7 +253,7 @@ export class SessionImpl {
150
253
  throw cancellationError(signal);
151
254
  if (loaded === undefined)
152
255
  return undefined;
153
- return this.#attachDocument(definition, loaded, (value, release) => new CommittedWatch(value, release)).observer;
256
+ return this.#attachDocument(definition, loaded, (value, release) => new CommittedWatch(value, release, (error) => this.report(error))).observer;
154
257
  });
155
258
  if (watch === undefined)
156
259
  return undefined;
@@ -167,7 +270,7 @@ export class SessionImpl {
167
270
  }
168
271
  }
169
272
  async snapshotAsOf(token, ...args) {
170
- this.#assertUsable();
273
+ this.assertUsable();
171
274
  const definition = token.definition;
172
275
  const resolved = resolveAddress(definition, args);
173
276
  if (resolved.address.scope.kind !== "conversation") {
@@ -208,16 +311,42 @@ export class SessionImpl {
208
311
  .then(() => this.beforeClose())
209
312
  .then(() => this.#enqueue(async () => {
210
313
  this.#commitListeners.clear();
314
+ this.#internalListeners.clear();
211
315
  this.#documents.clear();
212
- await this.#storage.close(cleanup);
213
- }));
214
- const listeners = [...this.#closeListeners];
215
- this.#closeListeners.clear();
216
- for (const listener of listeners)
217
- listener();
316
+ this.#idleDocuments.clear();
317
+ this.#attachedDocuments.clear();
318
+ try {
319
+ await this.#storage.close(cleanup);
320
+ }
321
+ catch (error) {
322
+ // A Storage that cannot close is a failed one; an earlier failure stays the cause.
323
+ if (this.#failure === undefined) {
324
+ this.#failure = { error };
325
+ this.report(error);
326
+ }
327
+ throw error;
328
+ }
329
+ }))
330
+ .finally(() => {
331
+ const failure = this.#failure;
332
+ this.#closed.resolve(failure === undefined ? { reason: "closed" } : { reason: "failed", error: failure.error });
333
+ });
334
+ this.#notifyClose();
218
335
  }
219
336
  return awaitWithContext(this.#closing, context);
220
337
  }
338
+ /** Run the close listeners once, each on its own: one that fails is reported, and the rest still run. */
339
+ #notifyClose() {
340
+ if (this.#documentSweep?.timer !== undefined)
341
+ clearTimeout(this.#documentSweep.timer);
342
+ this.#documentSweep = undefined;
343
+ const listeners = [...this.#closeListeners];
344
+ this.#closeListeners.clear();
345
+ for (const listener of listeners)
346
+ contained(listener, (error) => this.report(error));
347
+ }
348
+ /** Internal: the Session's failure, and errors of listeners, which never fail what ran them. A plain Session drops them. */
349
+ report(_error) { }
221
350
  /**
222
351
  * Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
223
352
  * plain Session stages nothing; a Harness stages its built-in documents.
@@ -229,15 +358,27 @@ export class SessionImpl {
229
358
  beforeClose() {
230
359
  return Promise.resolve();
231
360
  }
232
- /** Register a synchronous post-adoption listener. It must not throw, block, or call Session operations. */
361
+ /** Register a synchronous post-adoption listener. It must not block or call Session operations; a throw is reported. */
233
362
  subscribeCommits(listener) {
234
- this.#assertUsable();
363
+ this.assertUsable();
235
364
  this.#commitListeners.add(listener);
236
365
  return () => this.#commitListeners.delete(listener);
237
366
  }
238
- /** Register a listener called synchronously when close begins. It must not throw, block, or call Session operations. */
367
+ /**
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.
370
+ */
371
+ observeCommits(listener) {
372
+ this.assertUsable();
373
+ this.#internalListeners.add(listener);
374
+ return () => this.#internalListeners.delete(listener);
375
+ }
376
+ /**
377
+ * Register a listener called synchronously when close begins, also when a failure closes the Session (`failure` is
378
+ * then set). It must not block or call Session operations; a throw is reported.
379
+ */
239
380
  subscribeClose(listener) {
240
- this.#assertUsable();
381
+ this.assertUsable();
241
382
  this.#closeListeners.add(listener);
242
383
  return () => this.#closeListeners.delete(listener);
243
384
  }
@@ -245,8 +386,120 @@ export class SessionImpl {
245
386
  unloadDocuments() {
246
387
  return this.#enqueue(async () => {
247
388
  this.#documents.clear();
389
+ this.#idleDocuments.clear();
248
390
  });
249
391
  }
392
+ /**
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.
395
+ */
396
+ get documentCacheSizes() {
397
+ return {
398
+ cached: this.#documents.size,
399
+ idle: this.#idleDocuments.size,
400
+ attached: this.#attachedDocuments.size,
401
+ sweeps: this.#sweepJobs,
402
+ };
403
+ }
404
+ /** Note a use of a cached document: an unattached one becomes the newest idle one, and an eviction is scheduled. */
405
+ #touchDocument(addressId) {
406
+ const cached = this.#documents.get(addressId);
407
+ if (cached === undefined || this.#attachedDocuments.has(cached.record.id))
408
+ return;
409
+ const now = this.#clock();
410
+ const since = this.#idleDocuments.get(addressId);
411
+ // A hot document moves to the back at most once per granule, a tenth of the retention up to 1 s: it may then leave
412
+ // up to a granule early, which costs a reload, while a commit-heavy workload skips the reordering.
413
+ if (since !== undefined && now - since < Math.min(1_000, this.#documentRetentionMs() / 10))
414
+ return;
415
+ this.#idleDocuments.delete(addressId);
416
+ this.#idleDocuments.set(addressId, now);
417
+ // A pending sweep for the oldest document comes first unless the retention was shortened since; then it moves up.
418
+ if (this.#documentSweep === undefined || this.#documentSweep.at > now + this.#documentRetentionMs()) {
419
+ this.#scheduleDocumentSweep();
420
+ }
421
+ }
422
+ #dropDocument(addressId) {
423
+ this.#documents.delete(addressId);
424
+ this.#idleDocuments.delete(addressId);
425
+ }
426
+ /**
427
+ * 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.
430
+ */
431
+ #scheduleDocumentSweep() {
432
+ if (this.#closing !== undefined)
433
+ return;
434
+ const oldest = this.#idleDocuments.values().next();
435
+ if (oldest.done === true)
436
+ return;
437
+ const at = oldest.value + this.#documentRetentionMs();
438
+ if (this.#documentSweep !== undefined && this.#documentSweep.at <= at)
439
+ return;
440
+ if (this.#documentSweep?.timer !== undefined)
441
+ clearTimeout(this.#documentSweep.timer);
442
+ // 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 };
444
+ const run = () => {
445
+ this.#sweepJobs++;
446
+ this.#enqueue(async () => {
447
+ if (this.#documentSweep === sweep)
448
+ this.#documentSweep = undefined;
449
+ if (this.#closing === undefined)
450
+ this.#sweepDocuments();
451
+ }).catch(() => { });
452
+ };
453
+ const delay = at - this.#clock();
454
+ this.#documentSweep = sweep;
455
+ if (delay <= 0) {
456
+ queueMicrotask(run);
457
+ return;
458
+ }
459
+ if (this.#documentTimersUnavailable) {
460
+ this.#documentSweep = undefined;
461
+ return;
462
+ }
463
+ const timer = setTimeout(run, Math.min(delay, MAX_TIMER_DELAY));
464
+ const unref = timer.unref;
465
+ if (typeof unref !== "function") {
466
+ clearTimeout(timer);
467
+ this.#documentTimersUnavailable = true;
468
+ this.#documentSweep = undefined;
469
+ return;
470
+ }
471
+ unref.call(timer);
472
+ sweep.timer = timer;
473
+ }
474
+ /** On the line: evict the idle documents whose retention passed, oldest first, then schedule the next eviction. */
475
+ #sweepDocuments() {
476
+ const expired = this.#clock() - this.#documentRetentionMs();
477
+ for (const [addressId, since] of this.#idleDocuments) {
478
+ if (since > expired)
479
+ break;
480
+ this.#dropDocument(addressId);
481
+ }
482
+ this.#scheduleDocumentSweep();
483
+ }
484
+ /** Pin a cached incarnation for one observer; the returned release unpins it once, idle again after the last. */
485
+ #pinDocument(addressId, recordId) {
486
+ this.#attachedDocuments.set(recordId, (this.#attachedDocuments.get(recordId) ?? 0) + 1);
487
+ this.#idleDocuments.delete(addressId);
488
+ let pinned = true;
489
+ return () => {
490
+ if (!pinned)
491
+ return;
492
+ pinned = false;
493
+ const count = (this.#attachedDocuments.get(recordId) ?? 1) - 1;
494
+ if (count > 0) {
495
+ this.#attachedDocuments.set(recordId, count);
496
+ return;
497
+ }
498
+ this.#attachedDocuments.delete(recordId);
499
+ if (this.#documents.get(addressId)?.record.id === recordId)
500
+ this.#touchDocument(addressId);
501
+ };
502
+ }
250
503
  async #runCommit(change, context, scope) {
251
504
  this.#assertHealthy();
252
505
  context.abortSignal?.throwIfAborted();
@@ -254,14 +507,26 @@ export class SessionImpl {
254
507
  let result;
255
508
  try {
256
509
  result = await change(tx);
510
+ // A callback that caught a failed read must not commit, nor succeed as if it had.
511
+ this.#assertHealthy();
257
512
  }
258
513
  catch (error) {
259
514
  await tx.settleFailure();
515
+ this.#sweepDocuments();
516
+ throw error;
517
+ }
518
+ let writes;
519
+ try {
520
+ writes = await tx.settleSuccess();
521
+ }
522
+ catch (error) {
523
+ // Settlement aborted every change already.
524
+ this.#sweepDocuments();
260
525
  throw error;
261
526
  }
262
- const writes = await tx.settleSuccess();
263
527
  if (writes.length === 0) {
264
528
  tx.discard();
529
+ this.#sweepDocuments();
265
530
  return result;
266
531
  }
267
532
  let seq;
@@ -270,10 +535,8 @@ export class SessionImpl {
270
535
  seq = await this.#storage.commit(writes, withoutAbortSignal(context));
271
536
  }
272
537
  catch (error) {
538
+ // The guard has failed the Session.
273
539
  tx.discard();
274
- // Callback errors never reach this branch; StorageRejected alone guarantees that no batch effect committed.
275
- if (!(error instanceof StorageRejected))
276
- this.#poison = { error };
277
540
  throw error;
278
541
  }
279
542
  let documents;
@@ -282,14 +545,17 @@ export class SessionImpl {
282
545
  }
283
546
  catch (error) {
284
547
  // Storage already committed; a failed adoption leaves memory behind durable state.
285
- this.#poison = { error };
548
+ this.fail(error);
286
549
  throw error;
287
550
  }
551
+ for (const document of documents)
552
+ this.#touchDocument(addressId(document.record));
288
553
  this.#publish(seq, writes, documents, context);
554
+ this.#sweepDocuments();
289
555
  return result;
290
556
  }
291
557
  #publish(seq, writes, documents, context) {
292
- if (this.#commitListeners.size === 0)
558
+ if (this.#commitListeners.size === 0 && this.#internalListeners.size === 0)
293
559
  return;
294
560
  const changes = [];
295
561
  for (const write of writes) {
@@ -304,8 +570,13 @@ export class SessionImpl {
304
570
  for (const document of documents)
305
571
  changes.push(document);
306
572
  const publication = { seq, changes };
307
- for (const listener of [...this.#commitListeners])
308
- listener(publication, context);
573
+ // The commit is durable: a failing listener neither fails the commit nor skips the others.
574
+ for (const listener of [...this.#internalListeners]) {
575
+ contained(() => listener(publication, context), (error) => this.fail(error));
576
+ }
577
+ for (const listener of [...this.#commitListeners]) {
578
+ contained(() => listener(publication, context), (error) => this.report(error));
579
+ }
309
580
  }
310
581
  /**
311
582
  * Attach an observer to one committed incarnation: check the definition, then forward this incarnation's committed
@@ -316,13 +587,23 @@ export class SessionImpl {
316
587
  checkRecordVersion(definition, loaded.record, loaded.storedVersion);
317
588
  let unsubscribeCommit = () => { };
318
589
  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);
319
592
  const detach = () => {
320
593
  unsubscribeCommit();
321
594
  unsubscribeClose();
595
+ unpin();
322
596
  };
323
- const observer = create(loaded.tracker.value, detach);
597
+ let observer;
598
+ try {
599
+ observer = create(loaded.tracker.value, detach);
600
+ }
601
+ catch (error) {
602
+ unpin();
603
+ throw error;
604
+ }
324
605
  const observed = { version: loaded.valueVersion };
325
- unsubscribeCommit = this.subscribeCommits((publication, context) => {
606
+ unsubscribeCommit = this.observeCommits((publication, context) => {
326
607
  for (const change of publication.changes) {
327
608
  if (change.type !== "document" || change.record.id !== loaded.record.id)
328
609
  continue;
@@ -332,19 +613,24 @@ export class SessionImpl {
332
613
  // A migration-only base changes nothing for an observer of the new version.
333
614
  if (ops.length === 0)
334
615
  continue;
616
+ // A retired incarnation never changes again: its observers keep their last value but pin nothing.
617
+ if (change.value === null)
618
+ unpin();
335
619
  observer.advance(change.value, ops, frameContext);
336
620
  }
337
621
  });
338
- unsubscribeClose = this.subscribeClose(() => observer.closeSession());
622
+ unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure));
339
623
  return { observer, detach };
340
624
  }
341
625
  async #loadDocument(definition, addressId, address, context) {
342
626
  const cached = this.#documents.get(addressId);
343
627
  // A tracker serves only tokens of the version its value was materialized for; others reload from Storage.
344
- if (cached?.valueVersion === definition.version)
628
+ if (cached?.valueVersion === definition.version) {
629
+ this.#touchDocument(addressId);
345
630
  return cached;
631
+ }
346
632
  if (cached !== undefined)
347
- this.#documents.delete(addressId);
633
+ this.#dropDocument(addressId);
348
634
  const record = await this.#storage.findDocument(address, "current", context);
349
635
  if (record === undefined)
350
636
  return undefined;
@@ -361,6 +647,7 @@ export class SessionImpl {
361
647
  tracker: track(value),
362
648
  };
363
649
  this.#documents.set(addressId, loaded);
650
+ this.#touchDocument(addressId);
364
651
  return loaded;
365
652
  }
366
653
  #enqueue(job) {
@@ -368,17 +655,30 @@ export class SessionImpl {
368
655
  this.#tail = run.then(() => undefined, () => undefined);
369
656
  return run;
370
657
  }
371
- #assertUsable() {
658
+ /** Internal: throw `SessionFailed` when failed, else an error when closing. */
659
+ assertUsable() {
660
+ this.#assertHealthy();
372
661
  if (this.#closing !== undefined)
373
662
  throw new Error("Session is closed");
374
- this.#assertHealthy();
375
663
  }
376
664
  #assertHealthy() {
377
- if (this.#poison !== undefined) {
378
- throw new Error("Session is poisoned by a failed commit after storage admission; reopen it", {
379
- cause: this.#poison.error,
380
- });
381
- }
665
+ if (this.#failure !== undefined)
666
+ throw new SessionFailed(this.#failure.error);
667
+ }
668
+ }
669
+ /**
670
+ * Run a host callback that must not fail its caller: a throw, or a promise it returns that rejects, goes to `onError`,
671
+ * which is not awaited.
672
+ */
673
+ export function contained(callback, onError) {
674
+ try {
675
+ const result = callback();
676
+ // Any thenable, also a promise from another realm; `Promise.resolve` adopts it with handlers of its own.
677
+ if (typeof result?.then === "function")
678
+ Promise.resolve(result).catch(onError);
679
+ }
680
+ catch (error) {
681
+ onError(error);
382
682
  }
383
683
  }
384
684
  /**
@@ -396,4 +696,116 @@ function observedOperations(observed, change) {
396
696
  function cancellationError(signal) {
397
697
  return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
398
698
  }
699
+ /**
700
+ * The Session's Storage behind its failure guard; every component of the Session reads and writes through it. Before a
701
+ * call, a failed Session gets `SessionFailed` without reaching the backend. A call that throws fails the Session and
702
+ * rethrows the backend's error, unless it is a `StorageRequestError` or a read whose caller's context was aborted:
703
+ * neither says the Storage is broken.
704
+ * `close()` always reaches the backend, so a failed Session still closes it.
705
+ */
706
+ class GuardedStorage {
707
+ #storage;
708
+ #latch;
709
+ /** Backend calls underway, also those off the Session line, which close waits for before it closes the backend. */
710
+ #underway = new Set();
711
+ #closing = false;
712
+ constructor(storage, latch) {
713
+ this.#storage = storage;
714
+ this.#latch = latch;
715
+ }
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));
719
+ }
720
+ mintId() {
721
+ return this.#call(undefined, () => this.#storage.mintId());
722
+ }
723
+ conversation(id, context) {
724
+ return this.#call(context, () => this.#storage.conversation(id, context));
725
+ }
726
+ scanConversations(query, limit, cursor, context) {
727
+ return this.#call(context, () => this.#storage.scanConversations(query, limit, cursor, context));
728
+ }
729
+ entry(first, second, third) {
730
+ if (third === undefined) {
731
+ const context = second;
732
+ return this.#call(context, () => this.#storage.entry(first, context));
733
+ }
734
+ return this.#call(third, () => this.#storage.entry(first, second, third));
735
+ }
736
+ findLatestHeadMarker(conversationId, atOrBeforeEntryId, context) {
737
+ return this.#call(context, () => this.#storage.findLatestHeadMarker(conversationId, atOrBeforeEntryId, context));
738
+ }
739
+ scanEntries(query, limit, cursor, context) {
740
+ return this.#call(context, () => this.#storage.scanEntries(query, limit, cursor, context));
741
+ }
742
+ task(id, context) {
743
+ return this.#call(context, () => this.#storage.task(id, context));
744
+ }
745
+ scanTasks(query, limit, cursor, context) {
746
+ return this.#call(context, () => this.#storage.scanTasks(query, limit, cursor, context));
747
+ }
748
+ submission(id, context) {
749
+ return this.#call(context, () => this.#storage.submission(id, context));
750
+ }
751
+ scanSubmissions(query, limit, cursor, context) {
752
+ return this.#call(context, () => this.#storage.scanSubmissions(query, limit, cursor, context));
753
+ }
754
+ submissionByRequest(conversationId, requestId, context) {
755
+ return this.#call(context, () => this.#storage.submissionByRequest(conversationId, requestId, context));
756
+ }
757
+ findDocument(address, at, context) {
758
+ return this.#call(context, () => this.#storage.findDocument(address, at, context));
759
+ }
760
+ document(id, at, context) {
761
+ return this.#call(context, () => this.#storage.document(id, at, context));
762
+ }
763
+ scanDocuments(query, limit, cursor, context) {
764
+ return this.#call(context, () => this.#storage.scanDocuments(query, limit, cursor, context));
765
+ }
766
+ /**
767
+ * Close the backend once every call underway has settled, so none outlives it or fails after `closed` settles. New
768
+ * calls are refused from here on: a paged read must not start its next page while the backend closes.
769
+ */
770
+ async close(context) {
771
+ this.#closing = true;
772
+ await Promise.allSettled([...this.#underway]);
773
+ return this.#storage.close(context);
774
+ }
775
+ /**
776
+ * Run one backend call under the guard. `read` is a read's context; `commit()` and `mintId()` pass none, so nothing
777
+ * exempts their errors: once a batch is admitted, whether it committed is unknown. A call still underway when another
778
+ * fails the Session ends with `SessionFailed` too, whatever it returns: nothing it read or wrote is used.
779
+ */
780
+ async #call(read, run) {
781
+ this.#assertHealthy();
782
+ if (this.#closing)
783
+ throw new Error("Session is closed");
784
+ let result;
785
+ // An async wrapper, so a backend that throws synchronously is guarded too.
786
+ const call = (async () => run())();
787
+ this.#underway.add(call);
788
+ try {
789
+ result = await call;
790
+ }
791
+ catch (error) {
792
+ this.#assertHealthy();
793
+ // An invalid read, or one its caller cancelled, says nothing about the Storage; it fails that call only.
794
+ const exempt = read !== undefined && (error instanceof StorageRequestError || read.abortSignal?.aborted === true);
795
+ if (!exempt)
796
+ this.#latch.fail(error);
797
+ throw error;
798
+ }
799
+ finally {
800
+ this.#underway.delete(call);
801
+ }
802
+ this.#assertHealthy();
803
+ return result;
804
+ }
805
+ #assertHealthy() {
806
+ const failure = this.#latch.failure;
807
+ if (failure !== undefined)
808
+ throw new SessionFailed(failure.error);
809
+ }
810
+ }
399
811
  //# sourceMappingURL=session.js.map