@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.
- package/README.md +47 -0
- package/dist/advanced.d.ts +2 -0
- package/dist/base64.js +11 -3
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +4 -3
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/connection-transport.d.ts +2 -0
- package/dist/connection-transport.js +24 -19
- package/dist/connection.d.ts +3 -0
- package/dist/connection.js +4 -0
- package/dist/crypto.js +3 -10
- package/dist/direct-access.js +13 -8
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/internal-types.d.ts +2 -0
- package/dist/local-file-transport.js +4 -4
- package/dist/operation-helpers.d.ts +1 -1
- package/dist/operation-helpers.js +7 -2
- package/dist/outcomes.d.ts +5 -5
- package/dist/outcomes.js +1 -0
- package/dist/pending-mutation-store.d.ts +3 -0
- package/dist/pending-mutation-store.js +10 -0
- package/dist/record-digest.d.ts +14 -0
- package/dist/record-digest.js +20 -0
- package/dist/record-session.d.ts +164 -0
- package/dist/record-session.js +449 -0
- package/dist/records.d.ts +56 -0
- package/dist/records.js +150 -0
- package/package.json +2 -2
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { ConnectProblem, JsonObject } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import type { ConnectRequestOptions } from "./operation-types.js";
|
|
3
|
+
import { type ConnectOutcome } from "./outcomes.js";
|
|
4
|
+
/**
|
|
5
|
+
* - `saved`: the draft is the acknowledged record.
|
|
6
|
+
* - `unsaved`: local changes wait for autosave, `save()` or `flush()`.
|
|
7
|
+
* - `saving`: a write or exact recovery is in flight.
|
|
8
|
+
* - `conflict`: a newer record changed what was edited locally; see `remote`.
|
|
9
|
+
* - `recovery`: a write's outcome is unknown; no new write is sent until it is recovered.
|
|
10
|
+
* - `error`: the last write failed; local changes are kept.
|
|
11
|
+
* - `deleted`: the record no longer exists; local changes are kept.
|
|
12
|
+
*/
|
|
13
|
+
export type MdbaseRecordSessionState = "saved" | "unsaved" | "saving" | "conflict" | "recovery" | "error" | "deleted";
|
|
14
|
+
/** The parts of a record a session writes. Only locally changed parts are present. */
|
|
15
|
+
export interface MdbaseRecordChange {
|
|
16
|
+
body?: string;
|
|
17
|
+
patch?: JsonObject;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* How a session reads and writes one record. `R` is the application's record
|
|
21
|
+
* value; the session reads it only through these accessors. Every expected
|
|
22
|
+
* failure is a `ConnectOutcome` failure; an adapter that throws has a bug.
|
|
23
|
+
*/
|
|
24
|
+
export interface MdbaseRecordSessionAdapter<R> {
|
|
25
|
+
revision(record: R): string;
|
|
26
|
+
body(record: R): string;
|
|
27
|
+
frontmatter?(record: R): JsonObject;
|
|
28
|
+
/** Apply `change` to the record at `base`'s revision, or fail without writing. */
|
|
29
|
+
write(base: R, change: MdbaseRecordChange, options?: ConnectRequestOptions): Promise<ConnectOutcome<R>>;
|
|
30
|
+
/** The current record. A `file_not_found` failure means it was deleted. */
|
|
31
|
+
read?(base: R, options?: ConnectRequestOptions): Promise<ConnectOutcome<R>>;
|
|
32
|
+
/** Exact continuation of a write whose outcome is unknown. Never a new write. */
|
|
33
|
+
recover?(requestId: string, options?: ConnectRequestOptions): Promise<ConnectOutcome<R>>;
|
|
34
|
+
/** Whether that write's durable pending mutation is still unsettled. */
|
|
35
|
+
isPending?(requestId: string): boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface MdbaseRecordSessionOptions {
|
|
38
|
+
/** Write after this much idle time following an edit. Default `{ idleMs: 1000 }`. */
|
|
39
|
+
autosave?: {
|
|
40
|
+
idleMs: number;
|
|
41
|
+
} | false;
|
|
42
|
+
}
|
|
43
|
+
export interface MdbaseRecordSessionSnapshot<R> {
|
|
44
|
+
readonly state: MdbaseRecordSessionState;
|
|
45
|
+
readonly body: string;
|
|
46
|
+
/** The record's frontmatter with local patches applied. */
|
|
47
|
+
readonly frontmatter: JsonObject;
|
|
48
|
+
/** The latest record this session has accepted as its base. */
|
|
49
|
+
readonly record: R;
|
|
50
|
+
/** The newer record that conflicts with local changes, while `state` is `conflict`. */
|
|
51
|
+
readonly remote: R | null;
|
|
52
|
+
/** Local changes not yet acknowledged, including a write in flight or awaiting recovery. */
|
|
53
|
+
readonly dirty: boolean;
|
|
54
|
+
/** Why the last write or recovery failed. */
|
|
55
|
+
readonly problem: ConnectProblem | null;
|
|
56
|
+
/** The interrupted write's request ID, while `state` is `recovery`. */
|
|
57
|
+
readonly pendingRequestId?: string;
|
|
58
|
+
}
|
|
59
|
+
export type MdbaseRecordResolution = {
|
|
60
|
+
keep: "mine" | "theirs";
|
|
61
|
+
} | {
|
|
62
|
+
body: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* One open, editable record: debounced autosave, one write queue, own
|
|
66
|
+
* acknowledgements recognized by revision after each write settles,
|
|
67
|
+
* field-level conflict detection, and exact recovery of writes whose outcome
|
|
68
|
+
* is unknown. Share one session between every view of the same record.
|
|
69
|
+
*/
|
|
70
|
+
export declare class MdbaseRecordSession<R> {
|
|
71
|
+
private readonly adapter;
|
|
72
|
+
private record;
|
|
73
|
+
/** The local text `record` acknowledges; it differs from the record's body after server normalization. */
|
|
74
|
+
private baseBody;
|
|
75
|
+
private body;
|
|
76
|
+
private patch;
|
|
77
|
+
private remote;
|
|
78
|
+
private problem;
|
|
79
|
+
private deleted;
|
|
80
|
+
private busy;
|
|
81
|
+
private pending;
|
|
82
|
+
private incoming;
|
|
83
|
+
private readonly seen;
|
|
84
|
+
private readonly listeners;
|
|
85
|
+
private tail;
|
|
86
|
+
private queued;
|
|
87
|
+
private inFlight;
|
|
88
|
+
private timer;
|
|
89
|
+
private lastEdit;
|
|
90
|
+
private readonly idleMs;
|
|
91
|
+
private current;
|
|
92
|
+
constructor(record: R, adapter: MdbaseRecordSessionAdapter<R>, options?: MdbaseRecordSessionOptions);
|
|
93
|
+
get snapshot(): MdbaseRecordSessionSnapshot<R>;
|
|
94
|
+
getSnapshot: () => MdbaseRecordSessionSnapshot<R>;
|
|
95
|
+
/** Listeners run synchronously after every change, including each edit. */
|
|
96
|
+
subscribe: (listener: () => void) => (() => void);
|
|
97
|
+
setBody(body: string): void;
|
|
98
|
+
patchFrontmatter(patch: JsonObject): void;
|
|
99
|
+
/**
|
|
100
|
+
* Offer a draft kept outside the session, such as a crash-recovery copy. It
|
|
101
|
+
* is never written by itself: call `autosave()` or `save()`. A draft whose
|
|
102
|
+
* base is unknown or has since changed is a conflict with the current record.
|
|
103
|
+
*/
|
|
104
|
+
restore(draft: {
|
|
105
|
+
body: string;
|
|
106
|
+
baseBody?: string;
|
|
107
|
+
}): void;
|
|
108
|
+
/**
|
|
109
|
+
* Continue a write interrupted before this session existed, such as one
|
|
110
|
+
* found in durable storage after a reload. No new write is sent until it
|
|
111
|
+
* settles; with autosave, recovery is attempted after one idle interval.
|
|
112
|
+
*/
|
|
113
|
+
resumeRecovery(requestId: string): void;
|
|
114
|
+
/** Schedule an autosave one idle interval from now. */
|
|
115
|
+
autosave(): void;
|
|
116
|
+
/**
|
|
117
|
+
* A record published by another view or cache. It may be stale, so a
|
|
118
|
+
* revision this session has already seen is ignored, and records arriving
|
|
119
|
+
* while a write is unsettled wait until that write's acknowledgement is known.
|
|
120
|
+
*/
|
|
121
|
+
receive(record: R): void;
|
|
122
|
+
/**
|
|
123
|
+
* Read the current record after any queued write and apply it. Unlike a
|
|
124
|
+
* publication this is never stale, so a revision seen before is still
|
|
125
|
+
* applied (revisions repeat when content is reverted).
|
|
126
|
+
*/
|
|
127
|
+
refresh(options?: ConnectRequestOptions): Promise<ConnectOutcome<R>>;
|
|
128
|
+
/**
|
|
129
|
+
* Accept a record this application produced outside the session's writes,
|
|
130
|
+
* such as a rename's result. It is never a conflict: local changes made
|
|
131
|
+
* meanwhile are kept on top of it.
|
|
132
|
+
*/
|
|
133
|
+
accept(record: R): void;
|
|
134
|
+
markDeleted(): void;
|
|
135
|
+
/**
|
|
136
|
+
* Write local changes now, or continue an interrupted write's exact
|
|
137
|
+
* recovery. Joins a save already in flight. Resolves with the accepted record.
|
|
138
|
+
*/
|
|
139
|
+
save(options?: ConnectRequestOptions): Promise<ConnectOutcome<R>>;
|
|
140
|
+
/** Save until every local change is acknowledged, or report why it cannot be. */
|
|
141
|
+
flush(options?: ConnectRequestOptions): Promise<ConnectOutcome<R>>;
|
|
142
|
+
/** Run another operation on this record (rename, delete, …) in the session's write queue. */
|
|
143
|
+
run<Result>(operation: () => Promise<Result>): Promise<Result>;
|
|
144
|
+
/** Settle a conflict: keep local changes on top of the remote record, take the remote record, or use merged text. */
|
|
145
|
+
resolve(choice: MdbaseRecordResolution): void;
|
|
146
|
+
/** Drop local changes and return to the latest record. */
|
|
147
|
+
discard(): void;
|
|
148
|
+
private edited;
|
|
149
|
+
private schedule;
|
|
150
|
+
private dirtyLocally;
|
|
151
|
+
private write;
|
|
152
|
+
private recoverPending;
|
|
153
|
+
/** An adapter that throws breaks its contract: rethrow, but leave the queue usable. */
|
|
154
|
+
private guard;
|
|
155
|
+
private failure;
|
|
156
|
+
private acknowledge;
|
|
157
|
+
/** Read once to learn whether a failed write met a conflict, a convergent edit or deletion. */
|
|
158
|
+
private classifyFailure;
|
|
159
|
+
private settle;
|
|
160
|
+
/** Field-level three-way comparison of a newer record with the base and local changes. */
|
|
161
|
+
private classify;
|
|
162
|
+
private compute;
|
|
163
|
+
private emit;
|
|
164
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { connectProblem } from "./errors.js";
|
|
2
|
+
import { connectFailure, connectSuccess } from "./outcomes.js";
|
|
3
|
+
/**
|
|
4
|
+
* One open, editable record: debounced autosave, one write queue, own
|
|
5
|
+
* acknowledgements recognized by revision after each write settles,
|
|
6
|
+
* field-level conflict detection, and exact recovery of writes whose outcome
|
|
7
|
+
* is unknown. Share one session between every view of the same record.
|
|
8
|
+
*/
|
|
9
|
+
export class MdbaseRecordSession {
|
|
10
|
+
adapter;
|
|
11
|
+
record;
|
|
12
|
+
/** The local text `record` acknowledges; it differs from the record's body after server normalization. */
|
|
13
|
+
baseBody;
|
|
14
|
+
body;
|
|
15
|
+
patch = {};
|
|
16
|
+
remote = null;
|
|
17
|
+
problem = null;
|
|
18
|
+
deleted = false;
|
|
19
|
+
busy = false;
|
|
20
|
+
pending;
|
|
21
|
+
incoming = [];
|
|
22
|
+
seen = new Set();
|
|
23
|
+
listeners = new Set();
|
|
24
|
+
tail = Promise.resolve();
|
|
25
|
+
queued = 0;
|
|
26
|
+
inFlight;
|
|
27
|
+
timer;
|
|
28
|
+
lastEdit = 0;
|
|
29
|
+
idleMs;
|
|
30
|
+
current;
|
|
31
|
+
constructor(record, adapter, options = {}) {
|
|
32
|
+
this.adapter = adapter;
|
|
33
|
+
this.record = record;
|
|
34
|
+
this.baseBody = this.body = adapter.body(record);
|
|
35
|
+
this.seen.add(adapter.revision(record));
|
|
36
|
+
this.idleMs = options.autosave === false ? undefined : options.autosave?.idleMs ?? 1000;
|
|
37
|
+
this.current = this.compute();
|
|
38
|
+
}
|
|
39
|
+
get snapshot() {
|
|
40
|
+
return this.current;
|
|
41
|
+
}
|
|
42
|
+
getSnapshot = () => this.current;
|
|
43
|
+
/** Listeners run synchronously after every change, including each edit. */
|
|
44
|
+
subscribe = (listener) => {
|
|
45
|
+
this.listeners.add(listener);
|
|
46
|
+
return () => void this.listeners.delete(listener);
|
|
47
|
+
};
|
|
48
|
+
setBody(body) {
|
|
49
|
+
if (body === this.body)
|
|
50
|
+
return;
|
|
51
|
+
this.body = body;
|
|
52
|
+
this.edited();
|
|
53
|
+
}
|
|
54
|
+
patchFrontmatter(patch) {
|
|
55
|
+
this.patch = { ...this.patch, ...patch };
|
|
56
|
+
this.edited();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Offer a draft kept outside the session, such as a crash-recovery copy. It
|
|
60
|
+
* is never written by itself: call `autosave()` or `save()`. A draft whose
|
|
61
|
+
* base is unknown or has since changed is a conflict with the current record.
|
|
62
|
+
*/
|
|
63
|
+
restore(draft) {
|
|
64
|
+
const current = this.adapter.body(this.record);
|
|
65
|
+
if (draft.body === current)
|
|
66
|
+
return;
|
|
67
|
+
this.body = draft.body;
|
|
68
|
+
if (draft.baseBody !== current)
|
|
69
|
+
this.remote = this.record;
|
|
70
|
+
this.emit();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Continue a write interrupted before this session existed, such as one
|
|
74
|
+
* found in durable storage after a reload. No new write is sent until it
|
|
75
|
+
* settles; with autosave, recovery is attempted after one idle interval.
|
|
76
|
+
*/
|
|
77
|
+
resumeRecovery(requestId) {
|
|
78
|
+
if (this.pending || this.busy)
|
|
79
|
+
return;
|
|
80
|
+
this.pending = { requestId, sent: "restored" };
|
|
81
|
+
this.emit();
|
|
82
|
+
this.autosave();
|
|
83
|
+
}
|
|
84
|
+
/** Schedule an autosave one idle interval from now. */
|
|
85
|
+
autosave() {
|
|
86
|
+
this.lastEdit = Date.now();
|
|
87
|
+
this.schedule();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A record published by another view or cache. It may be stale, so a
|
|
91
|
+
* revision this session has already seen is ignored, and records arriving
|
|
92
|
+
* while a write is unsettled wait until that write's acknowledgement is known.
|
|
93
|
+
*/
|
|
94
|
+
receive(record) {
|
|
95
|
+
if (this.seen.has(this.adapter.revision(record)))
|
|
96
|
+
return;
|
|
97
|
+
if (this.busy || this.pending) {
|
|
98
|
+
this.incoming.push(record);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.seen.add(this.adapter.revision(record));
|
|
102
|
+
this.classify(record);
|
|
103
|
+
this.emit();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read the current record after any queued write and apply it. Unlike a
|
|
107
|
+
* publication this is never stale, so a revision seen before is still
|
|
108
|
+
* applied (revisions repeat when content is reverted).
|
|
109
|
+
*/
|
|
110
|
+
refresh(options) {
|
|
111
|
+
return this.run(async () => {
|
|
112
|
+
const read = this.adapter.read;
|
|
113
|
+
if (!read || this.pending)
|
|
114
|
+
return connectSuccess(this.record);
|
|
115
|
+
const outcome = await read(this.record, options);
|
|
116
|
+
if (!outcome.ok) {
|
|
117
|
+
if (outcome.problem.code === "file_not_found")
|
|
118
|
+
this.markDeleted();
|
|
119
|
+
return outcome;
|
|
120
|
+
}
|
|
121
|
+
this.seen.add(this.adapter.revision(outcome.value));
|
|
122
|
+
this.classify(outcome.value);
|
|
123
|
+
this.emit();
|
|
124
|
+
return connectSuccess(this.record);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Accept a record this application produced outside the session's writes,
|
|
129
|
+
* such as a rename's result. It is never a conflict: local changes made
|
|
130
|
+
* meanwhile are kept on top of it.
|
|
131
|
+
*/
|
|
132
|
+
accept(record) {
|
|
133
|
+
this.seen.add(this.adapter.revision(record));
|
|
134
|
+
const body = this.adapter.body(record);
|
|
135
|
+
if (this.body === this.baseBody)
|
|
136
|
+
this.body = body;
|
|
137
|
+
this.baseBody = body;
|
|
138
|
+
const matter = this.adapter.frontmatter?.(record) ?? {};
|
|
139
|
+
for (const [key, value] of Object.entries(this.patch)) {
|
|
140
|
+
if (sameJson(matter[key], value))
|
|
141
|
+
delete this.patch[key];
|
|
142
|
+
}
|
|
143
|
+
this.record = record;
|
|
144
|
+
this.remote = null;
|
|
145
|
+
this.problem = null;
|
|
146
|
+
this.emit();
|
|
147
|
+
}
|
|
148
|
+
markDeleted() {
|
|
149
|
+
clearTimeout(this.timer);
|
|
150
|
+
this.deleted = true;
|
|
151
|
+
this.emit();
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Write local changes now, or continue an interrupted write's exact
|
|
155
|
+
* recovery. Joins a save already in flight. Resolves with the accepted record.
|
|
156
|
+
*/
|
|
157
|
+
save(options) {
|
|
158
|
+
clearTimeout(this.timer);
|
|
159
|
+
if (this.inFlight)
|
|
160
|
+
return this.inFlight;
|
|
161
|
+
const write = this.run(() => this.pending ? this.recoverPending(options) : this.write(options, true));
|
|
162
|
+
this.inFlight = write;
|
|
163
|
+
const settle = () => { if (this.inFlight === write)
|
|
164
|
+
this.inFlight = undefined; };
|
|
165
|
+
write.then(settle, settle);
|
|
166
|
+
return write;
|
|
167
|
+
}
|
|
168
|
+
/** Save until every local change is acknowledged, or report why it cannot be. */
|
|
169
|
+
async flush(options) {
|
|
170
|
+
for (;;) {
|
|
171
|
+
if (this.deleted)
|
|
172
|
+
return connectFailure(deletedProblem());
|
|
173
|
+
if (this.remote)
|
|
174
|
+
return connectFailure(conflictProblem());
|
|
175
|
+
if (!this.inFlight && !this.pending && !this.dirtyLocally())
|
|
176
|
+
return connectSuccess(this.record);
|
|
177
|
+
const saved = await this.save(options);
|
|
178
|
+
if (!saved.ok)
|
|
179
|
+
return saved;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Run another operation on this record (rename, delete, …) in the session's write queue. */
|
|
183
|
+
run(operation) {
|
|
184
|
+
// An idle queue starts synchronously, so a write is in flight before the caller's next edit.
|
|
185
|
+
const result = this.queued === 0 ? operation() : this.tail.then(operation);
|
|
186
|
+
this.queued += 1;
|
|
187
|
+
const done = () => { this.queued -= 1; };
|
|
188
|
+
this.tail = result.then(done, done);
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
/** Settle a conflict: keep local changes on top of the remote record, take the remote record, or use merged text. */
|
|
192
|
+
resolve(choice) {
|
|
193
|
+
const remote = this.remote;
|
|
194
|
+
if (!remote || this.busy)
|
|
195
|
+
return;
|
|
196
|
+
this.record = remote;
|
|
197
|
+
this.remote = null;
|
|
198
|
+
this.problem = null;
|
|
199
|
+
this.baseBody = this.adapter.body(remote);
|
|
200
|
+
if ("body" in choice)
|
|
201
|
+
this.body = choice.body;
|
|
202
|
+
else if (choice.keep === "theirs") {
|
|
203
|
+
this.body = this.baseBody;
|
|
204
|
+
this.patch = {};
|
|
205
|
+
}
|
|
206
|
+
this.emit();
|
|
207
|
+
if (this.dirtyLocally())
|
|
208
|
+
this.autosave();
|
|
209
|
+
}
|
|
210
|
+
/** Drop local changes and return to the latest record. */
|
|
211
|
+
discard() {
|
|
212
|
+
if (this.busy)
|
|
213
|
+
return;
|
|
214
|
+
clearTimeout(this.timer);
|
|
215
|
+
this.record = this.remote ?? this.record;
|
|
216
|
+
this.remote = null;
|
|
217
|
+
this.problem = null;
|
|
218
|
+
this.baseBody = this.body = this.adapter.body(this.record);
|
|
219
|
+
this.patch = {};
|
|
220
|
+
this.emit();
|
|
221
|
+
}
|
|
222
|
+
edited() {
|
|
223
|
+
this.problem = null;
|
|
224
|
+
this.lastEdit = Date.now();
|
|
225
|
+
this.emit();
|
|
226
|
+
this.schedule();
|
|
227
|
+
}
|
|
228
|
+
schedule() {
|
|
229
|
+
clearTimeout(this.timer);
|
|
230
|
+
if (this.idleMs === undefined || this.remote || this.deleted)
|
|
231
|
+
return;
|
|
232
|
+
const delay = Math.max(0, this.lastEdit + this.idleMs - Date.now());
|
|
233
|
+
this.timer = setTimeout(() => void this.save(), delay);
|
|
234
|
+
}
|
|
235
|
+
dirtyLocally() {
|
|
236
|
+
return this.body !== this.baseBody || Object.keys(this.patch).length > 0;
|
|
237
|
+
}
|
|
238
|
+
async write(options, rebaseOnRejection) {
|
|
239
|
+
if (this.remote)
|
|
240
|
+
return connectFailure(conflictProblem());
|
|
241
|
+
if (this.deleted)
|
|
242
|
+
return connectFailure(deletedProblem());
|
|
243
|
+
if (!this.dirtyLocally())
|
|
244
|
+
return connectSuccess(this.record);
|
|
245
|
+
const sent = {};
|
|
246
|
+
if (this.body !== this.baseBody)
|
|
247
|
+
sent.body = this.body;
|
|
248
|
+
if (Object.keys(this.patch).length)
|
|
249
|
+
sent.patch = structuredClone(this.patch);
|
|
250
|
+
this.busy = true;
|
|
251
|
+
this.emit();
|
|
252
|
+
const outcome = await this.guard(() => this.adapter.write(this.record, { ...sent }, options));
|
|
253
|
+
if (outcome.ok) {
|
|
254
|
+
this.acknowledge(outcome.value, sent);
|
|
255
|
+
return connectSuccess(this.record);
|
|
256
|
+
}
|
|
257
|
+
const requestId = outcomeUnknownRequestId(outcome.problem);
|
|
258
|
+
if (requestId) {
|
|
259
|
+
this.busy = false;
|
|
260
|
+
this.problem = outcome.problem;
|
|
261
|
+
this.pending = { requestId, sent };
|
|
262
|
+
this.emit();
|
|
263
|
+
this.schedule();
|
|
264
|
+
return outcome;
|
|
265
|
+
}
|
|
266
|
+
const classified = await this.classifyFailure(outcome.problem, options);
|
|
267
|
+
if (classified === "resolved")
|
|
268
|
+
return connectSuccess(this.record);
|
|
269
|
+
if (classified === "rebased" && rebaseOnRejection)
|
|
270
|
+
return this.write(options, false);
|
|
271
|
+
return this.failure(outcome.problem);
|
|
272
|
+
}
|
|
273
|
+
async recoverPending(options) {
|
|
274
|
+
const pending = this.pending;
|
|
275
|
+
const recover = this.adapter.recover;
|
|
276
|
+
if (!recover) {
|
|
277
|
+
return connectFailure(connectProblem("pending_mutation_unresolved", "Exact recovery of the interrupted write is unavailable. No new write was attempted."));
|
|
278
|
+
}
|
|
279
|
+
this.busy = true;
|
|
280
|
+
this.emit();
|
|
281
|
+
const outcome = await this.guard(() => recover(pending.requestId, options));
|
|
282
|
+
if (outcome.ok) {
|
|
283
|
+
if (pending.sent === "restored") {
|
|
284
|
+
// Unknown content, so accept it like an out-of-band result: newer typing is kept.
|
|
285
|
+
this.pending = undefined;
|
|
286
|
+
this.busy = false;
|
|
287
|
+
this.accept(outcome.value);
|
|
288
|
+
this.settle();
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
this.acknowledge(outcome.value, pending.sent);
|
|
292
|
+
}
|
|
293
|
+
return connectSuccess(this.record);
|
|
294
|
+
}
|
|
295
|
+
// Only the SDK settling its durable handle proves the original intent was resolved.
|
|
296
|
+
const settled = outcome.problem.operation_outcome !== "unknown"
|
|
297
|
+
&& outcome.problem.code !== "operation_outcome_unknown"
|
|
298
|
+
&& this.adapter.isPending?.(pending.requestId) === false;
|
|
299
|
+
if (!settled) {
|
|
300
|
+
this.busy = false;
|
|
301
|
+
this.problem = outcome.problem;
|
|
302
|
+
this.emit();
|
|
303
|
+
return outcome;
|
|
304
|
+
}
|
|
305
|
+
this.pending = undefined;
|
|
306
|
+
await this.classifyFailure(outcome.problem, options);
|
|
307
|
+
return this.failure(outcome.problem);
|
|
308
|
+
}
|
|
309
|
+
/** An adapter that throws breaks its contract: rethrow, but leave the queue usable. */
|
|
310
|
+
async guard(call) {
|
|
311
|
+
try {
|
|
312
|
+
return await call();
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
this.busy = false;
|
|
316
|
+
this.emit();
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
failure(problem) {
|
|
321
|
+
if (this.deleted)
|
|
322
|
+
return connectFailure(deletedProblem());
|
|
323
|
+
if (this.remote)
|
|
324
|
+
return connectFailure(conflictProblem());
|
|
325
|
+
return connectFailure(problem);
|
|
326
|
+
}
|
|
327
|
+
acknowledge(result, sent) {
|
|
328
|
+
this.seen.add(this.adapter.revision(result));
|
|
329
|
+
this.record = result;
|
|
330
|
+
if (sent.body !== undefined)
|
|
331
|
+
this.baseBody = sent.body;
|
|
332
|
+
for (const [key, value] of Object.entries(sent.patch ?? {})) {
|
|
333
|
+
if (key in this.patch && sameJson(this.patch[key], value))
|
|
334
|
+
delete this.patch[key];
|
|
335
|
+
}
|
|
336
|
+
this.problem = null;
|
|
337
|
+
this.pending = undefined;
|
|
338
|
+
this.settle();
|
|
339
|
+
if (this.dirtyLocally())
|
|
340
|
+
this.schedule();
|
|
341
|
+
}
|
|
342
|
+
/** Read once to learn whether a failed write met a conflict, a convergent edit or deletion. */
|
|
343
|
+
async classifyFailure(problem, options) {
|
|
344
|
+
const base = this.adapter.revision(this.record);
|
|
345
|
+
const read = this.adapter.read;
|
|
346
|
+
const outcome = read ? await this.guard(() => read(this.record, options)) : undefined;
|
|
347
|
+
if (outcome && !outcome.ok && outcome.problem.code === "file_not_found") {
|
|
348
|
+
this.deleted = true;
|
|
349
|
+
this.settle();
|
|
350
|
+
return "failed";
|
|
351
|
+
}
|
|
352
|
+
if (!outcome?.ok || this.adapter.revision(outcome.value) === base) {
|
|
353
|
+
this.problem = problem;
|
|
354
|
+
this.settle();
|
|
355
|
+
return "failed";
|
|
356
|
+
}
|
|
357
|
+
this.seen.add(this.adapter.revision(outcome.value));
|
|
358
|
+
this.classify(outcome.value);
|
|
359
|
+
this.settle();
|
|
360
|
+
if (this.remote)
|
|
361
|
+
return "failed";
|
|
362
|
+
return this.dirtyLocally() ? "rebased" : "resolved";
|
|
363
|
+
}
|
|
364
|
+
settle() {
|
|
365
|
+
this.busy = false;
|
|
366
|
+
for (const record of this.incoming.splice(0)) {
|
|
367
|
+
if (this.seen.has(this.adapter.revision(record)))
|
|
368
|
+
continue;
|
|
369
|
+
this.seen.add(this.adapter.revision(record));
|
|
370
|
+
this.classify(record);
|
|
371
|
+
}
|
|
372
|
+
this.emit();
|
|
373
|
+
}
|
|
374
|
+
/** Field-level three-way comparison of a newer record with the base and local changes. */
|
|
375
|
+
classify(record) {
|
|
376
|
+
if (this.adapter.revision(record) === this.adapter.revision(this.record))
|
|
377
|
+
return;
|
|
378
|
+
const baseBody = this.adapter.body(this.record);
|
|
379
|
+
const remoteBody = this.adapter.body(record);
|
|
380
|
+
const bodyChanged = remoteBody !== baseBody;
|
|
381
|
+
const bodyDirty = this.body !== this.baseBody;
|
|
382
|
+
const baseMatter = this.adapter.frontmatter?.(this.record) ?? {};
|
|
383
|
+
const remoteMatter = this.adapter.frontmatter?.(record) ?? {};
|
|
384
|
+
const conflict = (bodyDirty && bodyChanged && remoteBody !== this.body)
|
|
385
|
+
|| Object.entries(this.patch).some(([key, value]) => !sameJson(remoteMatter[key], baseMatter[key]) && !sameJson(remoteMatter[key], value));
|
|
386
|
+
if (conflict) {
|
|
387
|
+
clearTimeout(this.timer);
|
|
388
|
+
this.remote = record;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
this.remote = null;
|
|
392
|
+
this.record = record;
|
|
393
|
+
// Not a conflict, so a changed remote body either replaces a clean draft or equals it.
|
|
394
|
+
if (bodyChanged)
|
|
395
|
+
this.baseBody = this.body = remoteBody;
|
|
396
|
+
for (const [key, value] of Object.entries(this.patch)) {
|
|
397
|
+
if (sameJson(remoteMatter[key], value))
|
|
398
|
+
delete this.patch[key];
|
|
399
|
+
}
|
|
400
|
+
if (!this.dirtyLocally())
|
|
401
|
+
this.problem = null;
|
|
402
|
+
}
|
|
403
|
+
compute() {
|
|
404
|
+
const dirty = this.busy || Boolean(this.pending) || this.dirtyLocally();
|
|
405
|
+
const state = this.deleted ? "deleted"
|
|
406
|
+
: this.remote ? "conflict"
|
|
407
|
+
: this.busy ? "saving"
|
|
408
|
+
: this.pending ? "recovery"
|
|
409
|
+
: this.problem ? "error"
|
|
410
|
+
: dirty ? "unsaved" : "saved";
|
|
411
|
+
return {
|
|
412
|
+
state,
|
|
413
|
+
body: this.body,
|
|
414
|
+
frontmatter: { ...(this.adapter.frontmatter?.(this.record) ?? {}), ...this.patch },
|
|
415
|
+
record: this.record,
|
|
416
|
+
remote: this.remote,
|
|
417
|
+
dirty,
|
|
418
|
+
problem: this.problem,
|
|
419
|
+
...(this.pending ? { pendingRequestId: this.pending.requestId } : {})
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
emit() {
|
|
423
|
+
this.current = this.compute();
|
|
424
|
+
this.listeners.forEach((listener) => listener());
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
function conflictProblem() {
|
|
428
|
+
return connectProblem("concurrent_modification", "This record changed elsewhere. Local changes are kept until the conflict is resolved.");
|
|
429
|
+
}
|
|
430
|
+
function deletedProblem() {
|
|
431
|
+
return connectProblem("file_not_found", "This record no longer exists. Local changes are kept.");
|
|
432
|
+
}
|
|
433
|
+
function outcomeUnknownRequestId(problem) {
|
|
434
|
+
return problem.code === "operation_outcome_unknown" ? problem.details.request_id : undefined;
|
|
435
|
+
}
|
|
436
|
+
function sameJson(left, right) {
|
|
437
|
+
if (left === right)
|
|
438
|
+
return true;
|
|
439
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
|
|
440
|
+
return false;
|
|
441
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
442
|
+
return Array.isArray(left) && Array.isArray(right) && left.length === right.length
|
|
443
|
+
&& left.every((value, index) => sameJson(value, right[index]));
|
|
444
|
+
}
|
|
445
|
+
const keys = Object.keys(left);
|
|
446
|
+
return keys.length === Object.keys(right).length
|
|
447
|
+
&& keys.every((key) => key in right
|
|
448
|
+
&& sameJson(left[key], right[key]));
|
|
449
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { JsonObject } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import type { ConnectRequestOptions, MdbaseWatchSubscription, PendingMutation, ReadInput, RecordDocument, UpdateInput } from "./operation-types.js";
|
|
3
|
+
import { type CollectionMutationProblemCode, type CollectionReadProblemCode, type ConnectOutcome } from "./outcomes.js";
|
|
4
|
+
import { MdbaseRecordSession, type MdbaseRecordSessionOptions } from "./record-session.js";
|
|
5
|
+
export interface MdbaseRecordOpenOptions extends ConnectRequestOptions, MdbaseRecordSessionOptions {
|
|
6
|
+
}
|
|
7
|
+
/** One view's hold on a shared record session. */
|
|
8
|
+
export interface MdbaseRecordLease<Frontmatter extends JsonObject = JsonObject> {
|
|
9
|
+
readonly session: MdbaseRecordSession<RecordDocument<Frontmatter>>;
|
|
10
|
+
/**
|
|
11
|
+
* Stop using the session from this view. Idempotent. Local changes are
|
|
12
|
+
* still saved; the session is dropped once no view holds it and it is saved.
|
|
13
|
+
*/
|
|
14
|
+
release(): void;
|
|
15
|
+
}
|
|
16
|
+
/** The parts of a connection a record session uses; `MdbaseConnection` satisfies it. */
|
|
17
|
+
interface RecordConnection<Frontmatter extends JsonObject> {
|
|
18
|
+
read(input: ReadInput, options?: ConnectRequestOptions): Promise<ConnectOutcome<RecordDocument<Frontmatter>, CollectionReadProblemCode>>;
|
|
19
|
+
update(input: UpdateInput<Frontmatter>, options?: ConnectRequestOptions): Promise<ConnectOutcome<RecordDocument<Frontmatter>, CollectionMutationProblemCode>>;
|
|
20
|
+
pendingMutation<Result>(requestId: string): PendingMutation<Result> | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Editable record sessions for one connection: `connection.records`. Every
|
|
24
|
+
* view that opens the same path shares one session, so a record never has
|
|
25
|
+
* two competing writers in one application.
|
|
26
|
+
*/
|
|
27
|
+
export declare class MdbaseRecords<Frontmatter extends JsonObject = JsonObject> {
|
|
28
|
+
private readonly connection;
|
|
29
|
+
/** Finds this record's interrupted update, which survives reloads in durable storage. */
|
|
30
|
+
private readonly pendingUpdate;
|
|
31
|
+
private readonly entries;
|
|
32
|
+
private readonly opening;
|
|
33
|
+
constructor(connection: RecordConnection<Frontmatter>,
|
|
34
|
+
/** Finds this record's interrupted update, which survives reloads in durable storage. */
|
|
35
|
+
pendingUpdate?: (path: string) => Promise<string | null>);
|
|
36
|
+
/**
|
|
37
|
+
* Read a record and hold its editing session. Session options apply when
|
|
38
|
+
* the session is first created; later opens share it as it is. A save
|
|
39
|
+
* interrupted before a reload resumes as exact recovery.
|
|
40
|
+
*/
|
|
41
|
+
open(path: string, options?: MdbaseRecordOpenOptions): Promise<ConnectOutcome<MdbaseRecordLease<Frontmatter>, CollectionReadProblemCode>>;
|
|
42
|
+
/**
|
|
43
|
+
* Keep open sessions current from a collection watch: changed records are
|
|
44
|
+
* refreshed, renamed records are followed and deleted records are marked.
|
|
45
|
+
* Returns a function that stops following.
|
|
46
|
+
*/
|
|
47
|
+
follow(watch: Pick<MdbaseWatchSubscription, "subscribe">): () => void;
|
|
48
|
+
private load;
|
|
49
|
+
/** Writes and reads follow the entry's current path, so a followed rename needs no new session. */
|
|
50
|
+
private adapter;
|
|
51
|
+
private lease;
|
|
52
|
+
/** Drop an unheld session once it has nothing left to save. */
|
|
53
|
+
private retire;
|
|
54
|
+
private apply;
|
|
55
|
+
}
|
|
56
|
+
export {};
|