@mdbase-dev/connect 0.1.0-beta.110 → 0.1.0-beta.112

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.
@@ -0,0 +1,150 @@
1
+ import { connectProblem } from "./errors.js";
2
+ import { connectFailure, connectSuccess } from "./outcomes.js";
3
+ import { MdbaseRecordSession } from "./record-session.js";
4
+ /**
5
+ * Editable record sessions for one connection: `connection.records`. Every
6
+ * view that opens the same path shares one session, so a record never has
7
+ * two competing writers in one application.
8
+ */
9
+ export class MdbaseRecords {
10
+ connection;
11
+ pendingUpdate;
12
+ entries = new Map();
13
+ opening = new Map();
14
+ constructor(connection,
15
+ /** Finds this record's interrupted update, which survives reloads in durable storage. */
16
+ pendingUpdate = async () => null) {
17
+ this.connection = connection;
18
+ this.pendingUpdate = pendingUpdate;
19
+ }
20
+ /**
21
+ * Read a record and hold its editing session. Session options apply when
22
+ * the session is first created; later opens share it as it is. A save
23
+ * interrupted before a reload resumes as exact recovery.
24
+ */
25
+ async open(path, options = {}) {
26
+ for (;;) {
27
+ const existing = this.entries.get(path);
28
+ if (existing)
29
+ return connectSuccess(this.lease(existing));
30
+ const shared = this.opening.get(path);
31
+ const opening = shared ?? this.load(path, options);
32
+ if (!shared) {
33
+ this.opening.set(path, opening);
34
+ void opening.then(() => this.opening.delete(path));
35
+ }
36
+ const opened = await opening;
37
+ if (opened.ok)
38
+ return connectSuccess(this.lease(opened.value));
39
+ // Another caller's cancellation or deadline does not end this one's open.
40
+ const foreign = shared && (opened.problem.code === "operation_cancelled" || opened.problem.code === "timeout");
41
+ if (!foreign || options.signal?.aborted)
42
+ return opened;
43
+ }
44
+ }
45
+ /**
46
+ * Keep open sessions current from a collection watch: changed records are
47
+ * refreshed, renamed records are followed and deleted records are marked.
48
+ * Returns a function that stops following.
49
+ */
50
+ follow(watch) {
51
+ return watch.subscribe((change) => this.apply(change), (status) => {
52
+ // Changes were missed: every open record may be stale.
53
+ if (status.state === "reset_required") {
54
+ for (const entry of this.entries.values())
55
+ void entry.session.refresh();
56
+ }
57
+ });
58
+ }
59
+ async load(path, options) {
60
+ const { autosave, ...request } = options;
61
+ const read = await this.connection.read({ path }, request);
62
+ if (!read.ok)
63
+ return read;
64
+ const existing = this.entries.get(read.value.path);
65
+ if (existing)
66
+ return connectSuccess(existing);
67
+ const entry = { path: read.value.path, leases: 0 };
68
+ entry.session = new MdbaseRecordSession(read.value, this.adapter(entry), { autosave });
69
+ const interrupted = await this.pendingUpdate(entry.path);
70
+ if (interrupted)
71
+ entry.session.resumeRecovery(interrupted);
72
+ this.entries.set(entry.path, entry);
73
+ return connectSuccess(entry);
74
+ }
75
+ /** Writes and reads follow the entry's current path, so a followed rename needs no new session. */
76
+ adapter(entry) {
77
+ return {
78
+ revision: (record) => record.revision,
79
+ body: (record) => record.body ?? "",
80
+ frontmatter: (record) => record.frontmatter,
81
+ write: (base, change, options) => this.connection.update({
82
+ path: entry.path,
83
+ ifRevision: base.revision,
84
+ patch: (change.patch ?? {}),
85
+ ...(change.body === undefined ? {} : { body: change.body })
86
+ }, options),
87
+ read: (_base, options) => this.connection.read({ path: entry.path }, options),
88
+ recover: async (requestId, options) => {
89
+ const pending = this.connection.pendingMutation(requestId);
90
+ return pending
91
+ ? pending.recover(options)
92
+ : connectFailure(connectProblem("no_pending_mutation", "The interrupted write is no longer pending."));
93
+ },
94
+ isPending: (requestId) => this.connection.pendingMutation(requestId) !== null
95
+ };
96
+ }
97
+ lease(entry) {
98
+ entry.leases += 1;
99
+ entry.stopRetiring?.();
100
+ entry.stopRetiring = undefined;
101
+ let released = false;
102
+ return {
103
+ session: entry.session,
104
+ release: () => {
105
+ if (released)
106
+ return;
107
+ released = true;
108
+ entry.leases -= 1;
109
+ this.retire(entry);
110
+ }
111
+ };
112
+ }
113
+ /** Drop an unheld session once it has nothing left to save. */
114
+ retire(entry) {
115
+ if (entry.leases > 0)
116
+ return;
117
+ const drop = () => {
118
+ const { state } = entry.session.snapshot;
119
+ if (state !== "saved" && state !== "deleted")
120
+ return false;
121
+ entry.stopRetiring?.();
122
+ entry.stopRetiring = undefined;
123
+ if (this.entries.get(entry.path) === entry)
124
+ this.entries.delete(entry.path);
125
+ return true;
126
+ };
127
+ if (!drop())
128
+ entry.stopRetiring = entry.session.subscribe(() => void drop());
129
+ }
130
+ apply(change) {
131
+ const { payload } = change;
132
+ if (change.type === "mdbase.record.renamed") {
133
+ const entry = typeof payload.from === "string" ? this.entries.get(payload.from) : undefined;
134
+ if (!entry || typeof payload.to !== "string")
135
+ return;
136
+ this.entries.delete(entry.path);
137
+ entry.path = payload.to;
138
+ this.entries.set(entry.path, entry);
139
+ void entry.session.refresh();
140
+ return;
141
+ }
142
+ if (change.type !== "mdbase.record.modified" && change.type !== "mdbase.record.deleted")
143
+ return;
144
+ const entry = typeof payload.path === "string" ? this.entries.get(payload.path) : undefined;
145
+ // The echo of this session's own acknowledged write needs no read.
146
+ if (!entry || payload.revision === entry.session.snapshot.record.revision)
147
+ return;
148
+ void entry.session.refresh();
149
+ }
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdbase-dev/connect",
3
- "version": "0.1.0-beta.110",
3
+ "version": "0.1.0-beta.112",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@mdbase-dev/connect-protocol": "0.1.0-beta.110"
36
+ "@mdbase-dev/connect-protocol": "0.1.0-beta.112"
37
37
  },
38
38
  "devDependencies": {
39
39
  "esbuild": "^0.28.1",