@earendil-works/pi-durable 1.0.4 → 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 (196) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +149 -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 +72 -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 +11 -8
  37. package/dist/harness/compaction.js.map +1 -1
  38. package/dist/harness/context.d.ts +29 -1
  39. package/dist/harness/context.d.ts.map +1 -1
  40. package/dist/harness/context.js +125 -24
  41. package/dist/harness/context.js.map +1 -1
  42. package/dist/harness/events.d.ts +35 -15
  43. package/dist/harness/events.d.ts.map +1 -1
  44. package/dist/harness/events.js +77 -23
  45. package/dist/harness/events.js.map +1 -1
  46. package/dist/harness/generation.d.ts +2 -0
  47. package/dist/harness/generation.d.ts.map +1 -1
  48. package/dist/harness/generation.js +34 -11
  49. package/dist/harness/generation.js.map +1 -1
  50. package/dist/harness/harness.d.ts +7 -0
  51. package/dist/harness/harness.d.ts.map +1 -1
  52. package/dist/harness/harness.js +56 -28
  53. package/dist/harness/harness.js.map +1 -1
  54. package/dist/harness/inbox.d.ts +16 -4
  55. package/dist/harness/inbox.d.ts.map +1 -1
  56. package/dist/harness/inbox.js +26 -3
  57. package/dist/harness/inbox.js.map +1 -1
  58. package/dist/harness/live.d.ts +74 -17
  59. package/dist/harness/live.d.ts.map +1 -1
  60. package/dist/harness/live.js +66 -14
  61. package/dist/harness/live.js.map +1 -1
  62. package/dist/harness/scheduler.d.ts +18 -5
  63. package/dist/harness/scheduler.d.ts.map +1 -1
  64. package/dist/harness/scheduler.js +794 -168
  65. package/dist/harness/scheduler.js.map +1 -1
  66. package/dist/harness/submissions.d.ts +7 -4
  67. package/dist/harness/submissions.d.ts.map +1 -1
  68. package/dist/harness/submissions.js +32 -13
  69. package/dist/harness/submissions.js.map +1 -1
  70. package/dist/harness/task-graph.d.ts +1 -1
  71. package/dist/harness/task-graph.d.ts.map +1 -1
  72. package/dist/harness/task-graph.js +15 -10
  73. package/dist/harness/task-graph.js.map +1 -1
  74. package/dist/harness/tool.d.ts +45 -5
  75. package/dist/harness/tool.d.ts.map +1 -1
  76. package/dist/harness/tool.js +388 -63
  77. package/dist/harness/tool.js.map +1 -1
  78. package/dist/harness/types.d.ts +171 -15
  79. package/dist/harness/types.d.ts.map +1 -1
  80. package/dist/harness/types.js.map +1 -1
  81. package/dist/harness/util.d.ts +6 -1
  82. package/dist/harness/util.d.ts.map +1 -1
  83. package/dist/harness/util.js +5 -2
  84. package/dist/harness/util.js.map +1 -1
  85. package/dist/harness/view.d.ts +6 -2
  86. package/dist/harness/view.d.ts.map +1 -1
  87. package/dist/harness/view.js +22 -12
  88. package/dist/harness/view.js.map +1 -1
  89. package/dist/images/cloudflare.d.ts +4 -0
  90. package/dist/images/cloudflare.d.ts.map +1 -0
  91. package/dist/images/cloudflare.js +9 -0
  92. package/dist/images/cloudflare.js.map +1 -0
  93. package/dist/images/exif.d.ts +3 -0
  94. package/dist/images/exif.d.ts.map +1 -0
  95. package/dist/images/exif.js +71 -0
  96. package/dist/images/exif.js.map +1 -0
  97. package/dist/images/index.d.ts +18 -0
  98. package/dist/images/index.d.ts.map +1 -0
  99. package/dist/images/index.js +125 -0
  100. package/dist/images/index.js.map +1 -0
  101. package/dist/images/node-worker.d.ts +17 -0
  102. package/dist/images/node-worker.d.ts.map +1 -0
  103. package/dist/images/node-worker.js +16 -0
  104. package/dist/images/node-worker.js.map +1 -0
  105. package/dist/images/node.d.ts +21 -0
  106. package/dist/images/node.d.ts.map +1 -0
  107. package/dist/images/node.js +121 -0
  108. package/dist/images/node.js.map +1 -0
  109. package/dist/index.d.ts +6 -6
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +3 -3
  112. package/dist/index.js.map +1 -1
  113. package/dist/search.d.ts +13 -0
  114. package/dist/search.d.ts.map +1 -0
  115. package/dist/search.js +85 -0
  116. package/dist/search.js.map +1 -0
  117. package/dist/session/observation.d.ts +10 -3
  118. package/dist/session/observation.d.ts.map +1 -1
  119. package/dist/session/observation.js +24 -34
  120. package/dist/session/observation.js.map +1 -1
  121. package/dist/session/session.d.ts +71 -6
  122. package/dist/session/session.d.ts.map +1 -1
  123. package/dist/session/session.js +463 -50
  124. package/dist/session/session.js.map +1 -1
  125. package/dist/session/transaction.d.ts +2 -0
  126. package/dist/session/transaction.d.ts.map +1 -1
  127. package/dist/session/transaction.js +19 -1
  128. package/dist/session/transaction.js.map +1 -1
  129. package/dist/storage/jsonl/storage.d.ts +8 -0
  130. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  131. package/dist/storage/jsonl/storage.js +2 -1
  132. package/dist/storage/jsonl/storage.js.map +1 -1
  133. package/dist/storage/memory.d.ts +2 -0
  134. package/dist/storage/memory.d.ts.map +1 -1
  135. package/dist/storage/memory.js +79 -27
  136. package/dist/storage/memory.js.map +1 -1
  137. package/dist/storage/scan.d.ts +18 -0
  138. package/dist/storage/scan.d.ts.map +1 -0
  139. package/dist/storage/scan.js +29 -0
  140. package/dist/storage/scan.js.map +1 -0
  141. package/dist/storage/sqlite/cloudflare.d.ts +46 -0
  142. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -0
  143. package/dist/storage/sqlite/cloudflare.js +106 -0
  144. package/dist/storage/sqlite/cloudflare.js.map +1 -0
  145. package/dist/storage/sqlite/storage.d.ts +2 -0
  146. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  147. package/dist/storage/sqlite/storage.js +77 -27
  148. package/dist/storage/sqlite/storage.js.map +1 -1
  149. package/dist/testing/env-conformance.d.ts.map +1 -1
  150. package/dist/testing/env-conformance.js +100 -0
  151. package/dist/testing/env-conformance.js.map +1 -1
  152. package/dist/testing/runner.d.ts +1 -1
  153. package/dist/testing/runner.d.ts.map +1 -1
  154. package/dist/testing/runner.js.map +1 -1
  155. package/dist/testing/storage-conformance.d.ts.map +1 -1
  156. package/dist/testing/storage-conformance.js +61 -2
  157. package/dist/testing/storage-conformance.js.map +1 -1
  158. package/dist/testing/types.d.ts +2 -0
  159. package/dist/testing/types.d.ts.map +1 -1
  160. package/dist/testing/types.js.map +1 -1
  161. package/dist/tool-search/index.d.ts +2 -0
  162. package/dist/tool-search/index.d.ts.map +1 -0
  163. package/dist/tool-search/index.js +2 -0
  164. package/dist/tool-search/index.js.map +1 -0
  165. package/dist/tool-search/tool-search.d.ts +23 -0
  166. package/dist/tool-search/tool-search.d.ts.map +1 -0
  167. package/dist/tool-search/tool-search.js +84 -0
  168. package/dist/tool-search/tool-search.js.map +1 -0
  169. package/dist/tools/bash.d.ts +10 -2
  170. package/dist/tools/bash.d.ts.map +1 -1
  171. package/dist/tools/bash.js +27 -9
  172. package/dist/tools/bash.js.map +1 -1
  173. package/dist/tools/edit.js +1 -1
  174. package/dist/tools/edit.js.map +1 -1
  175. package/dist/tools/image-processor.d.ts +47 -0
  176. package/dist/tools/image-processor.d.ts.map +1 -0
  177. package/dist/tools/image-processor.js +30 -0
  178. package/dist/tools/image-processor.js.map +1 -0
  179. package/dist/tools/image.d.ts +8 -0
  180. package/dist/tools/image.d.ts.map +1 -1
  181. package/dist/tools/image.js +47 -0
  182. package/dist/tools/image.js.map +1 -1
  183. package/dist/tools/index.d.ts +25 -2
  184. package/dist/tools/index.d.ts.map +1 -1
  185. package/dist/tools/index.js +13 -5
  186. package/dist/tools/index.js.map +1 -1
  187. package/dist/tools/read.d.ts +10 -2
  188. package/dist/tools/read.d.ts.map +1 -1
  189. package/dist/tools/read.js +87 -28
  190. package/dist/tools/read.js.map +1 -1
  191. package/dist/tools/write.js +1 -1
  192. package/dist/tools/write.js.map +1 -1
  193. package/dist/types.d.ts +89 -12
  194. package/dist/types.d.ts.map +1 -1
  195. package/dist/types.js.map +1 -1
  196. package/package.json +50 -5
@@ -1,49 +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. */
10
- export function createSession(storage) {
11
- return new SessionImpl(storage);
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
+ */
13
+ export function createSession(storage, options) {
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) {
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,
32
- cached: (id) => this.#documents.get(id),
107
+ storage: this.#storage,
108
+ now,
109
+ cached: (id) => {
110
+ const cached = this.#documents.get(id);
111
+ if (cached !== undefined)
112
+ this.#touchDocument(id);
113
+ return cached;
114
+ },
33
115
  load: (definition, addressId, address, context) => this.#loadDocument(definition, addressId, address, context),
34
116
  install: (document) => {
35
117
  this.#documents.set(document.addressId, document);
118
+ this.#touchDocument(document.addressId);
36
119
  },
37
120
  evict: (id, recordId) => {
38
121
  if (this.#documents.get(id)?.record.id === recordId)
39
- this.#documents.delete(id);
122
+ this.#dropDocument(id);
40
123
  },
41
124
  conversationCreated: (tx, record) => this.conversationCreated(tx, record),
42
125
  };
43
126
  }
127
+ /** The Storage behind the failure guard; every component reads and writes through it. */
128
+ get storage() {
129
+ return this.#storage;
130
+ }
44
131
  commit(change, context) {
45
132
  return this.commitWith(change, context);
46
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
+ }
47
149
  /**
48
150
  * Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root
49
151
  * bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to
@@ -51,7 +153,7 @@ export class SessionImpl {
51
153
  */
52
154
  commitWith(change, context, scope) {
53
155
  try {
54
- this.#assertUsable();
156
+ this.assertUsable();
55
157
  }
56
158
  catch (error) {
57
159
  return Promise.reject(error);
@@ -61,7 +163,7 @@ export class SessionImpl {
61
163
  /** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */
62
164
  readOnLine(job) {
63
165
  try {
64
- this.#assertUsable();
166
+ this.assertUsable();
65
167
  }
66
168
  catch (error) {
67
169
  return Promise.reject(error);
@@ -86,11 +188,13 @@ export class SessionImpl {
86
188
  return { record: loaded.record, version: loaded.valueVersion, value: loaded.tracker.value };
87
189
  }
88
190
  async snapshot(token, ...args) {
89
- this.#assertUsable();
191
+ this.assertUsable();
90
192
  const definition = token.definition;
91
193
  const resolved = resolveAddress(definition, args);
92
194
  const context = args[resolved.nextArgument];
93
195
  const cached = this.#documents.get(resolved.id);
196
+ if (cached?.valueVersion === definition.version)
197
+ this.#touchDocument(resolved.id);
94
198
  const loaded = cached?.valueVersion === definition.version
95
199
  ? cached
96
200
  : await this.#enqueue(async () => {
@@ -105,7 +209,7 @@ export class SessionImpl {
105
209
  }
106
210
  documentState(token, ...args) {
107
211
  try {
108
- this.#assertUsable();
212
+ this.assertUsable();
109
213
  const definition = token.definition;
110
214
  const resolved = resolveAddress(definition, args);
111
215
  const context = args[resolved.nextArgument];
@@ -116,7 +220,7 @@ export class SessionImpl {
116
220
  return undefined;
117
221
  const { observer: source, detach } = this.#attachDocument(definition, loaded, (value, release) => new CommittedStateSource(value, release));
118
222
  try {
119
- return replicatedState(source);
223
+ return replicatedState(source, { onError: (error) => this.report(error) });
120
224
  }
121
225
  catch (error) {
122
226
  detach();
@@ -129,7 +233,7 @@ export class SessionImpl {
129
233
  }
130
234
  }
131
235
  async watchDoc(token, ...args) {
132
- this.#assertUsable();
236
+ this.assertUsable();
133
237
  const definition = token.definition;
134
238
  const resolved = resolveAddress(definition, args);
135
239
  const context = args[resolved.nextArgument];
@@ -149,7 +253,7 @@ export class SessionImpl {
149
253
  throw cancellationError(signal);
150
254
  if (loaded === undefined)
151
255
  return undefined;
152
- 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;
153
257
  });
154
258
  if (watch === undefined)
155
259
  return undefined;
@@ -166,7 +270,7 @@ export class SessionImpl {
166
270
  }
167
271
  }
168
272
  async snapshotAsOf(token, ...args) {
169
- this.#assertUsable();
273
+ this.assertUsable();
170
274
  const definition = token.definition;
171
275
  const resolved = resolveAddress(definition, args);
172
276
  if (resolved.address.scope.kind !== "conversation") {
@@ -207,16 +311,42 @@ export class SessionImpl {
207
311
  .then(() => this.beforeClose())
208
312
  .then(() => this.#enqueue(async () => {
209
313
  this.#commitListeners.clear();
314
+ this.#internalListeners.clear();
210
315
  this.#documents.clear();
211
- await this.#storage.close(cleanup);
212
- }));
213
- const listeners = [...this.#closeListeners];
214
- this.#closeListeners.clear();
215
- for (const listener of listeners)
216
- 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();
217
335
  }
218
336
  return awaitWithContext(this.#closing, context);
219
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) { }
220
350
  /**
221
351
  * Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
222
352
  * plain Session stages nothing; a Harness stages its built-in documents.
@@ -228,15 +358,27 @@ export class SessionImpl {
228
358
  beforeClose() {
229
359
  return Promise.resolve();
230
360
  }
231
- /** 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. */
232
362
  subscribeCommits(listener) {
233
- this.#assertUsable();
363
+ this.assertUsable();
234
364
  this.#commitListeners.add(listener);
235
365
  return () => this.#commitListeners.delete(listener);
236
366
  }
237
- /** 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
+ */
238
380
  subscribeClose(listener) {
239
- this.#assertUsable();
381
+ this.assertUsable();
240
382
  this.#closeListeners.add(listener);
241
383
  return () => this.#closeListeners.delete(listener);
242
384
  }
@@ -244,8 +386,120 @@ export class SessionImpl {
244
386
  unloadDocuments() {
245
387
  return this.#enqueue(async () => {
246
388
  this.#documents.clear();
389
+ this.#idleDocuments.clear();
247
390
  });
248
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
+ }
249
503
  async #runCommit(change, context, scope) {
250
504
  this.#assertHealthy();
251
505
  context.abortSignal?.throwIfAborted();
@@ -253,14 +507,26 @@ export class SessionImpl {
253
507
  let result;
254
508
  try {
255
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();
256
512
  }
257
513
  catch (error) {
258
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();
259
525
  throw error;
260
526
  }
261
- const writes = await tx.settleSuccess();
262
527
  if (writes.length === 0) {
263
528
  tx.discard();
529
+ this.#sweepDocuments();
264
530
  return result;
265
531
  }
266
532
  let seq;
@@ -269,10 +535,8 @@ export class SessionImpl {
269
535
  seq = await this.#storage.commit(writes, withoutAbortSignal(context));
270
536
  }
271
537
  catch (error) {
538
+ // The guard has failed the Session.
272
539
  tx.discard();
273
- // Callback errors never reach this branch; StorageRejected alone guarantees that no batch effect committed.
274
- if (!(error instanceof StorageRejected))
275
- this.#poison = { error };
276
540
  throw error;
277
541
  }
278
542
  let documents;
@@ -281,14 +545,17 @@ export class SessionImpl {
281
545
  }
282
546
  catch (error) {
283
547
  // Storage already committed; a failed adoption leaves memory behind durable state.
284
- this.#poison = { error };
548
+ this.fail(error);
285
549
  throw error;
286
550
  }
551
+ for (const document of documents)
552
+ this.#touchDocument(addressId(document.record));
287
553
  this.#publish(seq, writes, documents, context);
554
+ this.#sweepDocuments();
288
555
  return result;
289
556
  }
290
557
  #publish(seq, writes, documents, context) {
291
- if (this.#commitListeners.size === 0)
558
+ if (this.#commitListeners.size === 0 && this.#internalListeners.size === 0)
292
559
  return;
293
560
  const changes = [];
294
561
  for (const write of writes) {
@@ -303,8 +570,13 @@ export class SessionImpl {
303
570
  for (const document of documents)
304
571
  changes.push(document);
305
572
  const publication = { seq, changes };
306
- for (const listener of [...this.#commitListeners])
307
- 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
+ }
308
580
  }
309
581
  /**
310
582
  * Attach an observer to one committed incarnation: check the definition, then forward this incarnation's committed
@@ -315,13 +587,23 @@ export class SessionImpl {
315
587
  checkRecordVersion(definition, loaded.record, loaded.storedVersion);
316
588
  let unsubscribeCommit = () => { };
317
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);
318
592
  const detach = () => {
319
593
  unsubscribeCommit();
320
594
  unsubscribeClose();
595
+ unpin();
321
596
  };
322
- 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
+ }
323
605
  const observed = { version: loaded.valueVersion };
324
- unsubscribeCommit = this.subscribeCommits((publication, context) => {
606
+ unsubscribeCommit = this.observeCommits((publication, context) => {
325
607
  for (const change of publication.changes) {
326
608
  if (change.type !== "document" || change.record.id !== loaded.record.id)
327
609
  continue;
@@ -331,19 +613,24 @@ export class SessionImpl {
331
613
  // A migration-only base changes nothing for an observer of the new version.
332
614
  if (ops.length === 0)
333
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();
334
619
  observer.advance(change.value, ops, frameContext);
335
620
  }
336
621
  });
337
- unsubscribeClose = this.subscribeClose(() => observer.closeSession());
622
+ unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure));
338
623
  return { observer, detach };
339
624
  }
340
625
  async #loadDocument(definition, addressId, address, context) {
341
626
  const cached = this.#documents.get(addressId);
342
627
  // A tracker serves only tokens of the version its value was materialized for; others reload from Storage.
343
- if (cached?.valueVersion === definition.version)
628
+ if (cached?.valueVersion === definition.version) {
629
+ this.#touchDocument(addressId);
344
630
  return cached;
631
+ }
345
632
  if (cached !== undefined)
346
- this.#documents.delete(addressId);
633
+ this.#dropDocument(addressId);
347
634
  const record = await this.#storage.findDocument(address, "current", context);
348
635
  if (record === undefined)
349
636
  return undefined;
@@ -360,6 +647,7 @@ export class SessionImpl {
360
647
  tracker: track(value),
361
648
  };
362
649
  this.#documents.set(addressId, loaded);
650
+ this.#touchDocument(addressId);
363
651
  return loaded;
364
652
  }
365
653
  #enqueue(job) {
@@ -367,17 +655,30 @@ export class SessionImpl {
367
655
  this.#tail = run.then(() => undefined, () => undefined);
368
656
  return run;
369
657
  }
370
- #assertUsable() {
658
+ /** Internal: throw `SessionFailed` when failed, else an error when closing. */
659
+ assertUsable() {
660
+ this.#assertHealthy();
371
661
  if (this.#closing !== undefined)
372
662
  throw new Error("Session is closed");
373
- this.#assertHealthy();
374
663
  }
375
664
  #assertHealthy() {
376
- if (this.#poison !== undefined) {
377
- throw new Error("Session is poisoned by a failed commit after storage admission; reopen it", {
378
- cause: this.#poison.error,
379
- });
380
- }
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);
381
682
  }
382
683
  }
383
684
  /**
@@ -395,4 +696,116 @@ function observedOperations(observed, change) {
395
696
  function cancellationError(signal) {
396
697
  return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
397
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
+ }
398
811
  //# sourceMappingURL=session.js.map