@ny4n/durable 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +711 -2
- package/dist/documents.d.ts +66 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +98 -0
- package/dist/documents.js.map +1 -0
- package/dist/entries.d.ts +31 -0
- package/dist/entries.d.ts.map +1 -0
- package/dist/entries.js +31 -0
- package/dist/entries.js.map +1 -0
- package/dist/env/decode.d.ts +16 -0
- package/dist/env/decode.d.ts.map +1 -0
- package/dist/env/decode.js +28 -0
- package/dist/env/decode.js.map +1 -0
- package/dist/env/index.d.ts +263 -0
- package/dist/env/index.d.ts.map +1 -0
- package/dist/env/index.js +49 -0
- package/dist/env/index.js.map +1 -0
- package/dist/env/line-scan.d.ts +13 -0
- package/dist/env/line-scan.d.ts.map +1 -0
- package/dist/env/line-scan.js +147 -0
- package/dist/env/line-scan.js.map +1 -0
- package/dist/env/node-watch.d.ts +29 -0
- package/dist/env/node-watch.d.ts.map +1 -0
- package/dist/env/node-watch.js +485 -0
- package/dist/env/node-watch.js.map +1 -0
- package/dist/env/node.d.ts +56 -0
- package/dist/env/node.d.ts.map +1 -0
- package/dist/env/node.js +1166 -0
- package/dist/env/node.js.map +1 -0
- package/dist/errors.d.ts +32 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +53 -0
- package/dist/errors.js.map +1 -0
- package/dist/harness/agent.d.ts +33 -0
- package/dist/harness/agent.d.ts.map +1 -0
- package/dist/harness/agent.js +235 -0
- package/dist/harness/agent.js.map +1 -0
- package/dist/harness/assistant.d.ts +15 -0
- package/dist/harness/assistant.d.ts.map +1 -0
- package/dist/harness/assistant.js +23 -0
- package/dist/harness/assistant.js.map +1 -0
- package/dist/harness/compaction.d.ts +56 -0
- package/dist/harness/compaction.d.ts.map +1 -0
- package/dist/harness/compaction.js +384 -0
- package/dist/harness/compaction.js.map +1 -0
- package/dist/harness/context.d.ts +63 -0
- package/dist/harness/context.d.ts.map +1 -0
- package/dist/harness/context.js +221 -0
- package/dist/harness/context.js.map +1 -0
- package/dist/harness/define.d.ts +18 -0
- package/dist/harness/define.d.ts.map +1 -0
- package/dist/harness/define.js +25 -0
- package/dist/harness/define.js.map +1 -0
- package/dist/harness/events.d.ts +180 -0
- package/dist/harness/events.d.ts.map +1 -0
- package/dist/harness/events.js +370 -0
- package/dist/harness/events.js.map +1 -0
- package/dist/harness/generation.d.ts +54 -0
- package/dist/harness/generation.d.ts.map +1 -0
- package/dist/harness/generation.js +514 -0
- package/dist/harness/generation.js.map +1 -0
- package/dist/harness/harness.d.ts +17 -0
- package/dist/harness/harness.d.ts.map +1 -0
- package/dist/harness/harness.js +332 -0
- package/dist/harness/harness.js.map +1 -0
- package/dist/harness/inbox.d.ts +59 -0
- package/dist/harness/inbox.d.ts.map +1 -0
- package/dist/harness/inbox.js +97 -0
- package/dist/harness/inbox.js.map +1 -0
- package/dist/harness/json.d.ts +10 -0
- package/dist/harness/json.d.ts.map +1 -0
- package/dist/harness/json.js +33 -0
- package/dist/harness/json.js.map +1 -0
- package/dist/harness/live.d.ts +142 -0
- package/dist/harness/live.d.ts.map +1 -0
- package/dist/harness/live.js +149 -0
- package/dist/harness/live.js.map +1 -0
- package/dist/harness/output.d.ts +69 -0
- package/dist/harness/output.d.ts.map +1 -0
- package/dist/harness/output.js +318 -0
- package/dist/harness/output.js.map +1 -0
- package/dist/harness/prompt.d.ts +27 -0
- package/dist/harness/prompt.d.ts.map +1 -0
- package/dist/harness/prompt.js +131 -0
- package/dist/harness/prompt.js.map +1 -0
- package/dist/harness/provider.d.ts +14 -0
- package/dist/harness/provider.d.ts.map +1 -0
- package/dist/harness/provider.js +30 -0
- package/dist/harness/provider.js.map +1 -0
- package/dist/harness/registry.d.ts +6 -0
- package/dist/harness/registry.d.ts.map +1 -0
- package/dist/harness/registry.js +101 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/harness/run.d.ts +17 -0
- package/dist/harness/run.d.ts.map +1 -0
- package/dist/harness/run.js +26 -0
- package/dist/harness/run.js.map +1 -0
- package/dist/harness/scheduler.d.ts +106 -0
- package/dist/harness/scheduler.d.ts.map +1 -0
- package/dist/harness/scheduler.js +1743 -0
- package/dist/harness/scheduler.js.map +1 -0
- package/dist/harness/submissions.d.ts +29 -0
- package/dist/harness/submissions.d.ts.map +1 -0
- package/dist/harness/submissions.js +175 -0
- package/dist/harness/submissions.js.map +1 -0
- package/dist/harness/task-graph.d.ts +49 -0
- package/dist/harness/task-graph.d.ts.map +1 -0
- package/dist/harness/task-graph.js +168 -0
- package/dist/harness/task-graph.js.map +1 -0
- package/dist/harness/tool.d.ts +76 -0
- package/dist/harness/tool.d.ts.map +1 -0
- package/dist/harness/tool.js +736 -0
- package/dist/harness/tool.js.map +1 -0
- package/dist/harness/types.d.ts +713 -0
- package/dist/harness/types.d.ts.map +1 -0
- package/dist/harness/types.js +2 -0
- package/dist/harness/types.js.map +1 -0
- package/dist/harness/usage.d.ts +18 -0
- package/dist/harness/usage.d.ts.map +1 -0
- package/dist/harness/usage.js +57 -0
- package/dist/harness/usage.js.map +1 -0
- package/dist/harness/util.d.ts +19 -0
- package/dist/harness/util.d.ts.map +1 -0
- package/dist/harness/util.js +58 -0
- package/dist/harness/util.js.map +1 -0
- package/dist/harness/view.d.ts +47 -0
- package/dist/harness/view.d.ts.map +1 -0
- package/dist/harness/view.js +206 -0
- package/dist/harness/view.js.map +1 -0
- package/dist/ids.d.ts +6 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +9 -0
- package/dist/ids.js.map +1 -0
- package/dist/images/cloudflare.d.ts +4 -0
- package/dist/images/cloudflare.d.ts.map +1 -0
- package/dist/images/cloudflare.js +9 -0
- package/dist/images/cloudflare.js.map +1 -0
- package/dist/images/exif.d.ts +3 -0
- package/dist/images/exif.d.ts.map +1 -0
- package/dist/images/exif.js +71 -0
- package/dist/images/exif.js.map +1 -0
- package/dist/images/index.d.ts +18 -0
- package/dist/images/index.d.ts.map +1 -0
- package/dist/images/index.js +125 -0
- package/dist/images/index.js.map +1 -0
- package/dist/images/node-worker.d.ts +17 -0
- package/dist/images/node-worker.d.ts.map +1 -0
- package/dist/images/node-worker.js +16 -0
- package/dist/images/node-worker.js.map +1 -0
- package/dist/images/node.d.ts +21 -0
- package/dist/images/node.d.ts.map +1 -0
- package/dist/images/node.js +121 -0
- package/dist/images/node.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/session/forks.d.ts +10 -0
- package/dist/session/forks.d.ts.map +1 -0
- package/dist/session/forks.js +42 -0
- package/dist/session/forks.js.map +1 -0
- package/dist/session/observation.d.ts +42 -0
- package/dist/session/observation.d.ts.map +1 -0
- package/dist/session/observation.js +281 -0
- package/dist/session/observation.js.map +1 -0
- package/dist/session/session.d.ts +133 -0
- package/dist/session/session.d.ts.map +1 -0
- package/dist/session/session.js +811 -0
- package/dist/session/session.js.map +1 -0
- package/dist/session/transaction.d.ts +119 -0
- package/dist/session/transaction.d.ts.map +1 -0
- package/dist/session/transaction.js +795 -0
- package/dist/session/transaction.js.map +1 -0
- package/dist/storage/jsonl/index.d.ts +2 -0
- package/dist/storage/jsonl/index.d.ts.map +1 -0
- package/dist/storage/jsonl/index.js +2 -0
- package/dist/storage/jsonl/index.js.map +1 -0
- package/dist/storage/jsonl/node.d.ts +6 -0
- package/dist/storage/jsonl/node.d.ts.map +1 -0
- package/dist/storage/jsonl/node.js +8 -0
- package/dist/storage/jsonl/node.js.map +1 -0
- package/dist/storage/jsonl/storage.d.ts +143 -0
- package/dist/storage/jsonl/storage.d.ts.map +1 -0
- package/dist/storage/jsonl/storage.js +682 -0
- package/dist/storage/jsonl/storage.js.map +1 -0
- package/dist/storage/memory.d.ts +62 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +733 -0
- package/dist/storage/memory.js.map +1 -0
- package/dist/storage/scan.d.ts +18 -0
- package/dist/storage/scan.d.ts.map +1 -0
- package/dist/storage/scan.js +29 -0
- package/dist/storage/scan.js.map +1 -0
- package/dist/storage/sqlite/cloudflare.d.ts +46 -0
- package/dist/storage/sqlite/cloudflare.d.ts.map +1 -0
- package/dist/storage/sqlite/cloudflare.js +106 -0
- package/dist/storage/sqlite/cloudflare.js.map +1 -0
- package/dist/storage/sqlite/database.d.ts +37 -0
- package/dist/storage/sqlite/database.d.ts.map +1 -0
- package/dist/storage/sqlite/database.js +2 -0
- package/dist/storage/sqlite/database.js.map +1 -0
- package/dist/storage/sqlite/index.d.ts +4 -0
- package/dist/storage/sqlite/index.d.ts.map +1 -0
- package/dist/storage/sqlite/index.js +3 -0
- package/dist/storage/sqlite/index.js.map +1 -0
- package/dist/storage/sqlite/migrations.d.ts +11 -0
- package/dist/storage/sqlite/migrations.d.ts.map +1 -0
- package/dist/storage/sqlite/migrations.js +111 -0
- package/dist/storage/sqlite/migrations.js.map +1 -0
- package/dist/storage/sqlite/node.d.ts +41 -0
- package/dist/storage/sqlite/node.d.ts.map +1 -0
- package/dist/storage/sqlite/node.js +179 -0
- package/dist/storage/sqlite/node.js.map +1 -0
- package/dist/storage/sqlite/storage.d.ts +65 -0
- package/dist/storage/sqlite/storage.d.ts.map +1 -0
- package/dist/storage/sqlite/storage.js +670 -0
- package/dist/storage/sqlite/storage.js.map +1 -0
- package/dist/tasks.d.ts +6 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +5 -0
- package/dist/tasks.js.map +1 -0
- package/dist/testing/assertions.d.ts +5 -0
- package/dist/testing/assertions.d.ts.map +1 -0
- package/dist/testing/assertions.js +25 -0
- package/dist/testing/assertions.js.map +1 -0
- package/dist/testing/env-conformance.d.ts +7 -0
- package/dist/testing/env-conformance.d.ts.map +1 -0
- package/dist/testing/env-conformance.js +431 -0
- package/dist/testing/env-conformance.js.map +1 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +6 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/runner.d.ts +12 -0
- package/dist/testing/runner.d.ts.map +1 -0
- package/dist/testing/runner.js +23 -0
- package/dist/testing/runner.js.map +1 -0
- package/dist/testing/storage-benchmark.d.ts +41 -0
- package/dist/testing/storage-benchmark.d.ts.map +1 -0
- package/dist/testing/storage-benchmark.js +350 -0
- package/dist/testing/storage-benchmark.js.map +1 -0
- package/dist/testing/storage-conformance.d.ts +4 -0
- package/dist/testing/storage-conformance.d.ts.map +1 -0
- package/dist/testing/storage-conformance.js +1213 -0
- package/dist/testing/storage-conformance.js.map +1 -0
- package/dist/testing/types.d.ts +37 -0
- package/dist/testing/types.d.ts.map +1 -0
- package/dist/testing/types.js +2 -0
- package/dist/testing/types.js.map +1 -0
- package/dist/tools/bash.d.ts +55 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +138 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit-diff.d.ts +89 -0
- package/dist/tools/edit-diff.d.ts.map +1 -0
- package/dist/tools/edit-diff.js +386 -0
- package/dist/tools/edit-diff.js.map +1 -0
- package/dist/tools/edit.d.ts +18 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +117 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/env.d.ts +5 -0
- package/dist/tools/env.d.ts.map +1 -0
- package/dist/tools/env.js +7 -0
- package/dist/tools/env.js.map +1 -0
- package/dist/tools/file-mutation-queue.d.ts +9 -0
- package/dist/tools/file-mutation-queue.d.ts.map +1 -0
- package/dist/tools/file-mutation-queue.js +52 -0
- package/dist/tools/file-mutation-queue.js.map +1 -0
- package/dist/tools/image-processor.d.ts +47 -0
- package/dist/tools/image-processor.d.ts.map +1 -0
- package/dist/tools/image-processor.js +30 -0
- package/dist/tools/image-processor.js.map +1 -0
- package/dist/tools/image.d.ts +20 -0
- package/dist/tools/image.d.ts.map +1 -0
- package/dist/tools/image.js +178 -0
- package/dist/tools/image.js.map +1 -0
- package/dist/tools/index.d.ts +46 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/path-utils.d.ts +5 -0
- package/dist/tools/path-utils.d.ts.map +1 -0
- package/dist/tools/path-utils.js +26 -0
- package/dist/tools/path-utils.js.map +1 -0
- package/dist/tools/read.d.ts +25 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +235 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +31 -0
- package/dist/tools/write.js.map +1 -0
- package/dist/truncate.d.ts +64 -0
- package/dist/truncate.d.ts.map +1 -0
- package/dist/truncate.js +154 -0
- package/dist/truncate.js.map +1 -0
- package/dist/types.d.ts +932 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +140 -5
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
import { replicatedState } from "@ny4n/chord";
|
|
2
|
+
import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@ny4n/chord/context";
|
|
3
|
+
import { track } from "@ny4n/chord/delta";
|
|
4
|
+
import { addressId, checkRecordScope, checkRecordVersion, materializeDocument, resolveAddress, } from "../documents.js";
|
|
5
|
+
import { SessionFailed, StorageRequestError } from "../errors.js";
|
|
6
|
+
import { idFromNumber } from "../ids.js";
|
|
7
|
+
import { CommittedStateSource, CommittedWatch, RETIREMENT_OPERATIONS, } from "./observation.js";
|
|
8
|
+
import { Transaction } from "./transaction.js";
|
|
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;
|
|
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;
|
|
32
|
+
/**
|
|
33
|
+
* Session kernel: one mutation line, the loaded document tracker cache, and committed publication.
|
|
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
|
+
*
|
|
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.
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
export class SessionImpl {
|
|
49
|
+
#storage;
|
|
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;
|
|
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();
|
|
65
|
+
#closeListeners = new Set();
|
|
66
|
+
#host;
|
|
67
|
+
#tail = Promise.resolve();
|
|
68
|
+
#closing;
|
|
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
|
+
};
|
|
106
|
+
this.#host = {
|
|
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
|
+
},
|
|
115
|
+
load: (definition, addressId, address, context) => this.#loadDocument(definition, addressId, address, context),
|
|
116
|
+
install: (document) => {
|
|
117
|
+
this.#documents.set(document.addressId, document);
|
|
118
|
+
this.#touchDocument(document.addressId);
|
|
119
|
+
},
|
|
120
|
+
evict: (id, recordId) => {
|
|
121
|
+
if (this.#documents.get(id)?.record.id === recordId)
|
|
122
|
+
this.#dropDocument(id);
|
|
123
|
+
},
|
|
124
|
+
conversationCreated: (tx, record) => this.conversationCreated(tx, record),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** The Storage behind the failure guard; every component reads and writes through it. */
|
|
128
|
+
get storage() {
|
|
129
|
+
return this.#storage;
|
|
130
|
+
}
|
|
131
|
+
commit(change, context) {
|
|
132
|
+
return this.commitWith(change, context);
|
|
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
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root
|
|
151
|
+
* bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to
|
|
152
|
+
* appended entries.
|
|
153
|
+
*/
|
|
154
|
+
commitWith(change, context, scope) {
|
|
155
|
+
try {
|
|
156
|
+
this.assertUsable();
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
return Promise.reject(error);
|
|
160
|
+
}
|
|
161
|
+
return this.#enqueue(() => this.#runCommit(change, context, scope));
|
|
162
|
+
}
|
|
163
|
+
/** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */
|
|
164
|
+
readOnLine(job) {
|
|
165
|
+
try {
|
|
166
|
+
this.assertUsable();
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
return Promise.reject(error);
|
|
170
|
+
}
|
|
171
|
+
return this.#enqueue(async () => {
|
|
172
|
+
this.#assertHealthy();
|
|
173
|
+
return job();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Internal: a conversation document's current incarnation and value, for a job already running on the line (see
|
|
178
|
+
* `readOnLine()`). Absent documents are `undefined`.
|
|
179
|
+
*/
|
|
180
|
+
async conversationDocumentOnLine(token, conversationId, context) {
|
|
181
|
+
const definition = token.definition;
|
|
182
|
+
const resolved = resolveAddress(definition, [conversationId, context]);
|
|
183
|
+
const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
|
|
184
|
+
if (loaded === undefined)
|
|
185
|
+
return undefined;
|
|
186
|
+
checkRecordScope(definition, loaded.record);
|
|
187
|
+
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
|
188
|
+
return { record: loaded.record, version: loaded.valueVersion, value: loaded.tracker.value };
|
|
189
|
+
}
|
|
190
|
+
async snapshot(token, ...args) {
|
|
191
|
+
this.assertUsable();
|
|
192
|
+
const definition = token.definition;
|
|
193
|
+
const resolved = resolveAddress(definition, args);
|
|
194
|
+
const context = args[resolved.nextArgument];
|
|
195
|
+
const cached = this.#documents.get(resolved.id);
|
|
196
|
+
if (cached?.valueVersion === definition.version)
|
|
197
|
+
this.#touchDocument(resolved.id);
|
|
198
|
+
const loaded = cached?.valueVersion === definition.version
|
|
199
|
+
? cached
|
|
200
|
+
: await this.#enqueue(async () => {
|
|
201
|
+
this.#assertHealthy();
|
|
202
|
+
return this.#loadDocument(definition, resolved.id, resolved.address, context);
|
|
203
|
+
});
|
|
204
|
+
if (loaded === undefined)
|
|
205
|
+
return undefined;
|
|
206
|
+
checkRecordScope(definition, loaded.record);
|
|
207
|
+
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
|
208
|
+
return loaded.tracker.value;
|
|
209
|
+
}
|
|
210
|
+
documentState(token, ...args) {
|
|
211
|
+
try {
|
|
212
|
+
this.assertUsable();
|
|
213
|
+
const definition = token.definition;
|
|
214
|
+
const resolved = resolveAddress(definition, args);
|
|
215
|
+
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
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
return Promise.reject(error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
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 });
|
|
246
|
+
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;
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
signal?.removeEventListener("abort", markCancelled);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async snapshotAsOf(token, ...args) {
|
|
273
|
+
this.assertUsable();
|
|
274
|
+
const definition = token.definition;
|
|
275
|
+
const resolved = resolveAddress(definition, args);
|
|
276
|
+
if (resolved.address.scope.kind !== "conversation") {
|
|
277
|
+
throw new TypeError("Session.snapshotAsOf() requires a conversation document");
|
|
278
|
+
}
|
|
279
|
+
const conversationId = resolved.address.scope.conversationId;
|
|
280
|
+
const atValue = args[resolved.nextArgument];
|
|
281
|
+
if (typeof atValue !== "number" || !Number.isSafeInteger(atValue)) {
|
|
282
|
+
throw new TypeError("Session.snapshotAsOf() requires an entry ID");
|
|
283
|
+
}
|
|
284
|
+
const at = idFromNumber(atValue);
|
|
285
|
+
const context = args[resolved.nextArgument + 1];
|
|
286
|
+
return this.#enqueue(async () => {
|
|
287
|
+
this.#assertHealthy();
|
|
288
|
+
const storedEntry = await this.#storage.entry(conversationId, at, context);
|
|
289
|
+
if (storedEntry === undefined) {
|
|
290
|
+
throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);
|
|
291
|
+
}
|
|
292
|
+
const address = {
|
|
293
|
+
...resolved.address,
|
|
294
|
+
scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId },
|
|
295
|
+
};
|
|
296
|
+
const record = await this.#storage.findDocument(address, storedEntry.commitSeq, context);
|
|
297
|
+
if (record === undefined)
|
|
298
|
+
return undefined;
|
|
299
|
+
const stored = await this.#storage.document(record.id, storedEntry.commitSeq, context);
|
|
300
|
+
if (stored === undefined) {
|
|
301
|
+
throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`);
|
|
302
|
+
}
|
|
303
|
+
return materializeDocument(definition, stored);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
close(context) {
|
|
307
|
+
if (this.#closing === undefined) {
|
|
308
|
+
const cleanup = withoutAbortSignal(context);
|
|
309
|
+
// Seal admission before anything else runs, then stop observers; admitted work settles before Storage closes.
|
|
310
|
+
this.#closing = Promise.resolve()
|
|
311
|
+
.then(() => this.beforeClose())
|
|
312
|
+
.then(() => this.#enqueue(async () => {
|
|
313
|
+
this.#commitListeners.clear();
|
|
314
|
+
this.#internalListeners.clear();
|
|
315
|
+
this.#documents.clear();
|
|
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();
|
|
335
|
+
}
|
|
336
|
+
return awaitWithContext(this.#closing, context);
|
|
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) { }
|
|
350
|
+
/**
|
|
351
|
+
* Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
|
|
352
|
+
* plain Session stages nothing; a Harness stages its built-in documents.
|
|
353
|
+
*/
|
|
354
|
+
conversationCreated(_tx, _record) {
|
|
355
|
+
return Promise.resolve();
|
|
356
|
+
}
|
|
357
|
+
/** Runs after close seals admission and before the line closes Storage; must not reject. */
|
|
358
|
+
beforeClose() {
|
|
359
|
+
return Promise.resolve();
|
|
360
|
+
}
|
|
361
|
+
/** Register a synchronous post-adoption listener. It must not block or call Session operations; a throw is reported. */
|
|
362
|
+
subscribeCommits(listener) {
|
|
363
|
+
this.assertUsable();
|
|
364
|
+
this.#commitListeners.add(listener);
|
|
365
|
+
return () => this.#commitListeners.delete(listener);
|
|
366
|
+
}
|
|
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
|
+
*/
|
|
380
|
+
subscribeClose(listener) {
|
|
381
|
+
this.assertUsable();
|
|
382
|
+
this.#closeListeners.add(listener);
|
|
383
|
+
return () => this.#closeListeners.delete(listener);
|
|
384
|
+
}
|
|
385
|
+
/** Drop every loaded tracker on the mutation line; later access cold-loads from Storage. */
|
|
386
|
+
unloadDocuments() {
|
|
387
|
+
return this.#enqueue(async () => {
|
|
388
|
+
this.#documents.clear();
|
|
389
|
+
this.#idleDocuments.clear();
|
|
390
|
+
});
|
|
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
|
+
}
|
|
503
|
+
async #runCommit(change, context, scope) {
|
|
504
|
+
this.#assertHealthy();
|
|
505
|
+
context.abortSignal?.throwIfAborted();
|
|
506
|
+
const tx = new Transaction(this.#host, context, scope);
|
|
507
|
+
let result;
|
|
508
|
+
try {
|
|
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();
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
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();
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
if (writes.length === 0) {
|
|
528
|
+
tx.discard();
|
|
529
|
+
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));
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
// The guard has failed the Session.
|
|
539
|
+
tx.discard();
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
let documents;
|
|
543
|
+
try {
|
|
544
|
+
documents = tx.adopt(seq);
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
// Storage already committed; a failed adoption leaves memory behind durable state.
|
|
548
|
+
this.fail(error);
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
for (const document of documents)
|
|
552
|
+
this.#touchDocument(addressId(document.record));
|
|
553
|
+
this.#publish(seq, writes, documents, context);
|
|
554
|
+
this.#sweepDocuments();
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
#publish(seq, writes, documents, context) {
|
|
558
|
+
if (this.#commitListeners.size === 0 && this.#internalListeners.size === 0)
|
|
559
|
+
return;
|
|
560
|
+
const changes = [];
|
|
561
|
+
for (const write of writes) {
|
|
562
|
+
switch (write.type) {
|
|
563
|
+
case "conversation":
|
|
564
|
+
case "entry":
|
|
565
|
+
case "task":
|
|
566
|
+
case "submission":
|
|
567
|
+
changes.push(write);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
for (const document of documents)
|
|
571
|
+
changes.push(document);
|
|
572
|
+
const publication = { seq, changes };
|
|
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
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
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.
|
|
584
|
+
*/
|
|
585
|
+
#attachDocument(definition, loaded, create) {
|
|
586
|
+
checkRecordScope(definition, loaded.record);
|
|
587
|
+
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
|
588
|
+
let unsubscribeCommit = () => { };
|
|
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);
|
|
592
|
+
const detach = () => {
|
|
593
|
+
unsubscribeCommit();
|
|
594
|
+
unsubscribeClose();
|
|
595
|
+
unpin();
|
|
596
|
+
};
|
|
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) => {
|
|
607
|
+
for (const change of publication.changes) {
|
|
608
|
+
if (change.type !== "document" || change.record.id !== loaded.record.id)
|
|
609
|
+
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)
|
|
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();
|
|
619
|
+
observer.advance(change.value, ops, frameContext);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure));
|
|
623
|
+
return { observer, detach };
|
|
624
|
+
}
|
|
625
|
+
async #loadDocument(definition, addressId, address, context) {
|
|
626
|
+
const cached = this.#documents.get(addressId);
|
|
627
|
+
// A tracker serves only tokens of the version its value was materialized for; others reload from Storage.
|
|
628
|
+
if (cached?.valueVersion === definition.version) {
|
|
629
|
+
this.#touchDocument(addressId);
|
|
630
|
+
return cached;
|
|
631
|
+
}
|
|
632
|
+
if (cached !== undefined)
|
|
633
|
+
this.#dropDocument(addressId);
|
|
634
|
+
const record = await this.#storage.findDocument(address, "current", context);
|
|
635
|
+
if (record === undefined)
|
|
636
|
+
return undefined;
|
|
637
|
+
const stored = await this.#storage.document(record.id, "current", context);
|
|
638
|
+
if (stored === undefined)
|
|
639
|
+
throw new Error(`Current document ${record.id} (${record.kind}) cannot be read`);
|
|
640
|
+
const value = materializeDocument(definition, stored);
|
|
641
|
+
const loaded = {
|
|
642
|
+
addressId,
|
|
643
|
+
record: stored.record,
|
|
644
|
+
storedVersion: stored.version,
|
|
645
|
+
valueVersion: definition.version,
|
|
646
|
+
deltasSinceBase: stored.deltasSinceBase,
|
|
647
|
+
tracker: track(value),
|
|
648
|
+
};
|
|
649
|
+
this.#documents.set(addressId, loaded);
|
|
650
|
+
this.#touchDocument(addressId);
|
|
651
|
+
return loaded;
|
|
652
|
+
}
|
|
653
|
+
#enqueue(job) {
|
|
654
|
+
const run = this.#tail.then(job);
|
|
655
|
+
this.#tail = run.then(() => undefined, () => undefined);
|
|
656
|
+
return run;
|
|
657
|
+
}
|
|
658
|
+
/** Internal: throw `SessionFailed` when failed, else an error when closing. */
|
|
659
|
+
assertUsable() {
|
|
660
|
+
this.#assertHealthy();
|
|
661
|
+
if (this.#closing !== undefined)
|
|
662
|
+
throw new Error("Session is closed");
|
|
663
|
+
}
|
|
664
|
+
#assertHealthy() {
|
|
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);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
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.
|
|
687
|
+
*/
|
|
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]];
|
|
695
|
+
}
|
|
696
|
+
function cancellationError(signal) {
|
|
697
|
+
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
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
|
+
}
|
|
811
|
+
//# sourceMappingURL=session.js.map
|