@hydranium/protocol 1.0.0-next.185 → 1.0.0-next.188
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/lib/client/data-connection.js +2 -2
- package/lib/client/data-connection.js.map +1 -1
- package/lib/client/data-port.d.ts +1 -1
- package/lib/client/data-session.d.ts +39 -21
- package/lib/client/data-session.d.ts.map +1 -1
- package/lib/client/data-session.js +69 -43
- package/lib/client/data-session.js.map +1 -1
- package/lib/data/data-server-protocol.d.ts +2 -4
- package/lib/data/data-server-protocol.d.ts.map +1 -1
- package/lib/data/events.d.ts +11 -5
- package/lib/data/events.d.ts.map +1 -1
- package/lib/data/requests.d.ts +4 -4
- package/lib/data/requests.d.ts.map +1 -1
- package/lib/errors.d.ts +31 -25
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +58 -23
- package/lib/errors.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/messages/index.d.ts +2 -2
- package/lib/messages/index.d.ts.map +1 -1
- package/lib/messages/index.js +3 -2
- package/lib/messages/index.js.map +1 -1
- package/lib/model-service/base-version.d.ts +64 -0
- package/lib/model-service/base-version.d.ts.map +1 -0
- package/lib/model-service/{based-on.js → base-version.js} +21 -15
- package/lib/model-service/base-version.js.map +1 -0
- package/lib/model-service/index.d.ts +1 -1
- package/lib/model-service/index.d.ts.map +1 -1
- package/lib/model-service/index.js +2 -2
- package/lib/model-service/index.js.map +1 -1
- package/lib/patch-merge.d.ts +11 -9
- package/lib/patch-merge.d.ts.map +1 -1
- package/lib/patch-merge.js +2 -2
- package/lib/patch-merge.js.map +1 -1
- package/lib/reconcile-write.d.ts +65 -0
- package/lib/reconcile-write.d.ts.map +1 -0
- package/lib/reconcile-write.js +67 -0
- package/lib/reconcile-write.js.map +1 -0
- package/lib/rpc/create-rpc-proxy.d.ts +3 -0
- package/lib/rpc/create-rpc-proxy.d.ts.map +1 -1
- package/lib/rpc/create-rpc-proxy.js +6 -1
- package/lib/rpc/create-rpc-proxy.js.map +1 -1
- package/lib/transfer-document.d.ts +59 -61
- package/lib/transfer-document.d.ts.map +1 -1
- package/lib/transfer-document.js +15 -20
- package/lib/transfer-document.js.map +1 -1
- package/lib/util.d.ts +1 -1
- package/lib/util.js +1 -1
- package/package.json +1 -1
- package/src/client/data-connection.ts +2 -2
- package/src/client/data-port.ts +1 -1
- package/src/client/data-session.ts +89 -51
- package/src/data/data-server-protocol.ts +2 -4
- package/src/data/events.ts +11 -5
- package/src/data/requests.ts +4 -4
- package/src/errors.ts +71 -31
- package/src/index.ts +1 -0
- package/src/messages/index.ts +2 -2
- package/src/model-service/base-version.ts +72 -0
- package/src/model-service/index.ts +2 -2
- package/src/patch-merge.ts +11 -9
- package/src/reconcile-write.ts +124 -0
- package/src/rpc/create-rpc-proxy.ts +6 -1
- package/src/transfer-document.ts +70 -65
- package/src/util.ts +1 -1
- package/lib/model-service/based-on.d.ts +0 -57
- package/lib/model-service/based-on.d.ts.map +0 -1
- package/lib/model-service/based-on.js.map +0 -1
- package/src/model-service/based-on.ts +0 -63
package/src/errors.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { ResponseError } from 'vscode-jsonrpc';
|
|
11
11
|
import { defineMessage, type HydraniumMessageData, messageData } from './messages/primitives';
|
|
12
|
+
import { asModelVersion, type ModelVersion, type TextVersion } from './model-service/base-version';
|
|
12
13
|
|
|
13
14
|
/*
|
|
14
15
|
* Every error here is a typed error: a caller reacts to it, so it has a class,
|
|
@@ -32,8 +33,8 @@ import { defineMessage, type HydraniumMessageData, messageData } from './message
|
|
|
32
33
|
* That tier only ever works untranslated, which is why it is the last resort
|
|
33
34
|
* behind the numeric code rather than the primary check.
|
|
34
35
|
*/
|
|
35
|
-
export const
|
|
36
|
-
'hydranium/protocol/stale-
|
|
36
|
+
export const STALE_BASE_VERSION_UPDATE = defineMessage(
|
|
37
|
+
'hydranium/protocol/stale-base-version-update',
|
|
37
38
|
'The edit to {uri} was not applied: it was made to an older version of the document.'
|
|
38
39
|
);
|
|
39
40
|
|
|
@@ -69,35 +70,31 @@ export const HYDRANIUM_ERROR_CODES = {
|
|
|
69
70
|
*/
|
|
70
71
|
export const CONFLICT_ERROR_CODE = HYDRANIUM_ERROR_CODES.conflict;
|
|
71
72
|
|
|
72
|
-
/**
|
|
73
|
-
* Structured payload carried in {@link ConflictError.data}, and the only place
|
|
74
|
-
* a post-RPC caller can read the version mismatch from.
|
|
75
|
-
*/
|
|
73
|
+
/** Structured payload carried in {@link ConflictError.data}. */
|
|
76
74
|
export interface ConflictErrorData extends HydraniumMessageData {
|
|
77
75
|
readonly uri: string;
|
|
78
|
-
/** The
|
|
79
|
-
readonly
|
|
76
|
+
/** The base version the caller authored against. */
|
|
77
|
+
readonly baseVersion: ModelVersion;
|
|
80
78
|
/** The server's current text-document version at the time of the throw. */
|
|
81
|
-
readonly actualVersion:
|
|
79
|
+
readonly actualVersion: TextVersion;
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
/**
|
|
85
83
|
* Thrown by a client session's `update` / `save` when the caller-
|
|
86
|
-
* supplied
|
|
84
|
+
* supplied base version no longer matches the server's current text-
|
|
87
85
|
* document version for the same URI — i.e. the snapshot the caller
|
|
88
86
|
* authored against has been superseded by an intervening edit.
|
|
89
87
|
*
|
|
90
88
|
* Extends vscode-jsonrpc's {@link ResponseError} so the typed
|
|
91
89
|
* {@link ConflictErrorData} payload rides on the standard JSON-RPC
|
|
92
90
|
* error envelope (`code`, `message`, `data`) — all three fields are
|
|
93
|
-
* preserved by RPC reconstruction.
|
|
94
|
-
* the receiving side
|
|
95
|
-
* `err.data`
|
|
96
|
-
* `ResponseError`, so subclass getters / fields do not survive).
|
|
91
|
+
* preserved by RPC reconstruction. `createRpcProxy` revives it into
|
|
92
|
+
* this class on the receiving side; a path that does not leaves a
|
|
93
|
+
* plain `ResponseError`, readable through `err.data` only.
|
|
97
94
|
*
|
|
98
|
-
* Detection is driven by the required `
|
|
99
|
-
* request; a caller with no meaningful
|
|
100
|
-
* `'
|
|
95
|
+
* Detection is driven by the required `baseVersion` field of every write
|
|
96
|
+
* request; a caller with no meaningful base version passes
|
|
97
|
+
* `'any'` and gets no gating.
|
|
101
98
|
*
|
|
102
99
|
* Three reasonable adopter recovery strategies:
|
|
103
100
|
*
|
|
@@ -111,22 +108,22 @@ export interface ConflictErrorData extends HydraniumMessageData {
|
|
|
111
108
|
* No auto-retry or auto-merge ships by default.
|
|
112
109
|
*/
|
|
113
110
|
export class ConflictError extends ResponseError<ConflictErrorData> {
|
|
114
|
-
constructor(uri: string,
|
|
111
|
+
constructor(uri: string, baseVersion: ModelVersion, actualVersion: TextVersion) {
|
|
115
112
|
// The identity rides alongside the typed payload rather than replacing
|
|
116
113
|
// it: `isConflictError`'s name check is surface an adopter may bind, so
|
|
117
114
|
// adding the identity widens the payload rather than reshaping it.
|
|
118
|
-
super(CONFLICT_ERROR_CODE,
|
|
115
|
+
super(CONFLICT_ERROR_CODE, STALE_BASE_VERSION_UPDATE.format({ uri }), {
|
|
119
116
|
uri,
|
|
120
|
-
|
|
117
|
+
baseVersion,
|
|
121
118
|
actualVersion,
|
|
122
|
-
...messageData(
|
|
119
|
+
...messageData(STALE_BASE_VERSION_UPDATE, { uri })
|
|
123
120
|
});
|
|
124
121
|
this.name = 'ConflictError';
|
|
125
122
|
// ResponseError's constructor calls `Object.setPrototypeOf(this,
|
|
126
123
|
// ResponseError.prototype)` to keep its own prototype chain intact across
|
|
127
124
|
// transpilation targets; that resets us to ResponseError, hiding the
|
|
128
125
|
// ConflictError-specific getters. Restore the prototype here so
|
|
129
|
-
// `err.uri` / `.
|
|
126
|
+
// `err.uri` / `.baseVersion` / `.actualVersion` resolve through this
|
|
130
127
|
// class.
|
|
131
128
|
Object.setPrototypeOf(this, ConflictError.prototype);
|
|
132
129
|
}
|
|
@@ -136,19 +133,19 @@ export class ConflictError extends ResponseError<ConflictErrorData> {
|
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
/**
|
|
139
|
-
* The
|
|
136
|
+
* The base version the caller authored against.
|
|
140
137
|
*
|
|
141
138
|
* Must not be renamed to `expected`, nor its sibling to `actual`: a test
|
|
142
139
|
* reporter reads an error carrying both as an assertion failure, and
|
|
143
140
|
* vitest's formatter then ASSIGNS to them, which throws on an accessor and
|
|
144
141
|
* replaces the real failure with a `TypeError`.
|
|
145
142
|
*/
|
|
146
|
-
get
|
|
147
|
-
return this.data!.
|
|
143
|
+
get baseVersion(): ModelVersion {
|
|
144
|
+
return this.data!.baseVersion;
|
|
148
145
|
}
|
|
149
146
|
|
|
150
|
-
/** The server's version at the time of the throw. Not `actual` — see {@link
|
|
151
|
-
get actualVersion():
|
|
147
|
+
/** The server's version at the time of the throw. Not `actual` — see {@link baseVersion}. */
|
|
148
|
+
get actualVersion(): TextVersion {
|
|
152
149
|
return this.data!.actualVersion;
|
|
153
150
|
}
|
|
154
151
|
}
|
|
@@ -299,9 +296,52 @@ export class ReservedClientIdError extends ResponseError<{ readonly clientId: st
|
|
|
299
296
|
}
|
|
300
297
|
}
|
|
301
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Rebuilds one typed error from the `data` that crossed the wire, or
|
|
301
|
+
* `undefined` when `data` lacks the fields the class reads.
|
|
302
|
+
*/
|
|
303
|
+
type ProtocolErrorReviver = (data: Readonly<Record<string, unknown>>) => ResponseError<unknown> | undefined;
|
|
304
|
+
|
|
305
|
+
/** One entry per typed error class, keyed by its code; a class without one reaches a caller as a plain `ResponseError`. */
|
|
306
|
+
const PROTOCOL_ERROR_REVIVERS: ReadonlyMap<number, ProtocolErrorReviver> = new Map<number, ProtocolErrorReviver>([
|
|
307
|
+
[
|
|
308
|
+
CONFLICT_ERROR_CODE,
|
|
309
|
+
({ uri, baseVersion, actualVersion }) =>
|
|
310
|
+
typeof uri === 'string' && typeof baseVersion === 'number' && typeof actualVersion === 'number'
|
|
311
|
+
? new ConflictError(uri, asModelVersion(baseVersion), actualVersion)
|
|
312
|
+
: undefined
|
|
313
|
+
],
|
|
314
|
+
[SESSION_CLOSED_ERROR_CODE, ({ clientId }) => (typeof clientId === 'string' ? new SessionClosedError(clientId) : undefined)],
|
|
315
|
+
[
|
|
316
|
+
DOCUMENT_NOT_OPEN_ERROR_CODE,
|
|
317
|
+
({ uri, clientId }) => (typeof uri === 'string' && typeof clientId === 'string' ? new DocumentNotOpenError(uri, clientId) : undefined)
|
|
318
|
+
],
|
|
319
|
+
[DUPLICATE_CLIENT_ID_ERROR_CODE, ({ clientId }) => (typeof clientId === 'string' ? new DuplicateClientIdError(clientId) : undefined)],
|
|
320
|
+
[RESERVED_CLIENT_ID_ERROR_CODE, ({ clientId }) => (typeof clientId === 'string' ? new ReservedClientIdError(clientId) : undefined)]
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* `error` as an instance of the typed error class its code names, with its
|
|
325
|
+
* message, data and stack kept, or `error` itself when its code names none or
|
|
326
|
+
* its data lacks the fields that class reads.
|
|
327
|
+
* An RPC reconstructs every rejection as a plain `ResponseError`, which has
|
|
328
|
+
* none of the class getters.
|
|
329
|
+
*/
|
|
330
|
+
export function reviveProtocolError(error: unknown): unknown {
|
|
331
|
+
if (!(error instanceof ResponseError) || error.data === null || typeof error.data !== 'object') {
|
|
332
|
+
return error;
|
|
333
|
+
}
|
|
334
|
+
const revived = PROTOCOL_ERROR_REVIVERS.get(error.code)?.(error.data);
|
|
335
|
+
if (!revived) {
|
|
336
|
+
return error;
|
|
337
|
+
}
|
|
338
|
+
return Object.assign(revived, { message: error.message, data: error.data, stack: error.stack });
|
|
339
|
+
}
|
|
340
|
+
|
|
302
341
|
/**
|
|
303
342
|
* Whether `error` is a {@link SessionClosedError}: by name in-process, by code
|
|
304
|
-
*
|
|
343
|
+
* when it arrives as a plain `ResponseError`, through a path that does not
|
|
344
|
+
* {@link reviveProtocolError revive} it.
|
|
305
345
|
*/
|
|
306
346
|
export function isSessionClosedError(error: unknown): error is SessionClosedError {
|
|
307
347
|
return hasErrorIdentity(error, 'SessionClosedError', SESSION_CLOSED_ERROR_CODE);
|
|
@@ -340,12 +380,12 @@ const CONFLICT_ERROR_MESSAGE_MARKER = ': it was made to an older version of the
|
|
|
340
380
|
* canonical wire-side check; the JSON-RPC `code` field is preserved
|
|
341
381
|
* across reconstruction, so any adopter catching after an RPC call
|
|
342
382
|
* hits this branch.
|
|
343
|
-
* 3. `error.message` contains the marker {@link
|
|
383
|
+
* 3. `error.message` contains the marker {@link STALE_BASE_VERSION_UPDATE}'s
|
|
344
384
|
* English carries — fallback for transports that re-wrap the message
|
|
345
385
|
* and drop the code (rare).
|
|
346
386
|
*
|
|
347
|
-
* `instanceof ConflictError` alone
|
|
348
|
-
*
|
|
387
|
+
* `instanceof ConflictError` alone returns `false` on a rejection that was
|
|
388
|
+
* not {@link reviveProtocolError revived}, so callers do not use it.
|
|
349
389
|
*/
|
|
350
390
|
export function isConflictError(error: unknown): error is ConflictError {
|
|
351
391
|
if (!(error instanceof Error)) {
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export * from './latency-collector';
|
|
|
31
31
|
// the modules that raise them.
|
|
32
32
|
export * from './messages/primitives';
|
|
33
33
|
export * from './patch-merge';
|
|
34
|
+
export * from './reconcile-write';
|
|
34
35
|
export * from './noop-logger';
|
|
35
36
|
export * from './observable-value';
|
|
36
37
|
export * from './profile-session';
|
package/src/messages/index.ts
CHANGED
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
export * from './primitives';
|
|
27
27
|
|
|
28
|
-
export { DOCUMENT_NOT_OPEN, DUPLICATE_CLIENT_ID, SESSION_CLOSED,
|
|
28
|
+
export { DOCUMENT_NOT_OPEN, DUPLICATE_CLIENT_ID, SESSION_CLOSED, STALE_BASE_VERSION_UPDATE } from '../errors';
|
|
29
29
|
export { DATA_SERVER_CONNECT_FAILED, DATA_SERVER_NOT_READY } from '../client/rpc-connection';
|
|
30
|
-
export { DATA_SESSION_RESTORE_FAILED, DATA_SESSION_UNSAVED_LOST } from '../client/data-session';
|
|
30
|
+
export { DATA_SESSION_ANSWER_WITHOUT_MODEL, DATA_SESSION_RESTORE_FAILED, DATA_SESSION_UNSAVED_LOST } from '../client/data-session';
|
|
31
31
|
export {
|
|
32
32
|
RELAY_REPLAY_FAILED,
|
|
33
33
|
RELAY_TRANSPORT_OPEN_FAILED,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
declare const snapshotMarker: unique symbol;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The version of the text a model was parsed from: a snapshot of the store's
|
|
14
|
+
* {@link TextVersion}, not a counter of its own. A document envelope carries
|
|
15
|
+
* it, and it cannot move once the envelope exists.
|
|
16
|
+
*
|
|
17
|
+
* A plain number at runtime, usable anywhere a number is, and it travels the
|
|
18
|
+
* wire as one. The marker exists only so the compiler can tell it apart from a
|
|
19
|
+
* version read off a LIVE document, which is the same number type and is the
|
|
20
|
+
* defect the conflict gate exists to prevent: read at write time it is whatever
|
|
21
|
+
* the server is at now, which is the number the gate is about to compare it
|
|
22
|
+
* against, so the gate passes unconditionally and a concurrent edit is
|
|
23
|
+
* overwritten with nothing logged.
|
|
24
|
+
*/
|
|
25
|
+
export type ModelVersion = number & { readonly [snapshotMarker]: true };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The store's version of a document's text, which moves with every change of it.
|
|
29
|
+
* Unbranded, so a write refuses it as `baseVersion`: it can name text newer than
|
|
30
|
+
* the caller's model, and a write based on it would pass the gate with older content.
|
|
31
|
+
*/
|
|
32
|
+
export type TextVersion = number;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The version a write was authored against, from a snapshot read, or `'any'`: a
|
|
36
|
+
* precondition that always holds, so the write overwrites whatever the server has.
|
|
37
|
+
* `'any'` is a lie for a write authored against a document a read handed over.
|
|
38
|
+
*/
|
|
39
|
+
export type BaseVersion = ModelVersion | 'any';
|
|
40
|
+
|
|
41
|
+
/** Whether `baseVersion` names a version, and so arms the gate. */
|
|
42
|
+
export function isModelVersion(baseVersion: BaseVersion): baseVersion is ModelVersion {
|
|
43
|
+
return typeof baseVersion === 'number';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mark a version as having come from a snapshot read.
|
|
48
|
+
*
|
|
49
|
+
* **For the envelope constructors, not for callers.** Every door that builds a
|
|
50
|
+
* document envelope applies it on the way in, so anything a read returns
|
|
51
|
+
* already carries it and a writer never needs this. A caller reaching for it is
|
|
52
|
+
* asserting a provenance the compiler was about to deny — the read-late defect
|
|
53
|
+
* written out where a reviewer can see it.
|
|
54
|
+
*/
|
|
55
|
+
export function asModelVersion(version: number): ModelVersion {
|
|
56
|
+
return version as ModelVersion;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The model version of a root no document factory path built, so nothing
|
|
61
|
+
* records the text it came from. No write matches it, so a write based on it
|
|
62
|
+
* conflicts.
|
|
63
|
+
*/
|
|
64
|
+
export const UNRECORDED_VERSION: ModelVersion = asModelVersion(-2);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The model version of a root parsed from text the store holds under no
|
|
68
|
+
* version: the file, read while the store held the document open with other
|
|
69
|
+
* text. Below every text version, so the model reads as behind and a write
|
|
70
|
+
* based on it conflicts.
|
|
71
|
+
*/
|
|
72
|
+
export const STALE_VERSION: ModelVersion = asModelVersion(-1);
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
|
-
// Generic payload types the data protocol shares with core: the
|
|
10
|
+
// Generic payload types the data protocol shares with core: the base
|
|
11
11
|
// version, which the server's model service checks writes against, and the
|
|
12
12
|
// reference candidates, which core's scope and completion code produce.
|
|
13
13
|
|
|
14
|
-
export * from './
|
|
14
|
+
export * from './base-version';
|
|
15
15
|
export * from './reference-candidate';
|
package/src/patch-merge.ts
CHANGED
|
@@ -52,8 +52,9 @@ export function augmentWithTestOps(base: object, userPatch: ReadonlyArray<JsonPa
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Outcome of {@link reconcileByPatchReplay}. The caller persists `merged` and
|
|
55
|
-
* takes it as the new base, drops + surfaces the `theirs` root on `conflict`,
|
|
56
|
-
* decides its own fallback (e.g.
|
|
55
|
+
* takes it as the new base, drops + surfaces the `theirs` root on `conflict`,
|
|
56
|
+
* catches up with the server on `no-op`, and decides its own fallback (e.g.
|
|
57
|
+
* force-retry) on `unavailable`.
|
|
57
58
|
*/
|
|
58
59
|
export type ReconcileOutcome<T> =
|
|
59
60
|
| {
|
|
@@ -68,9 +69,10 @@ export type ReconcileOutcome<T> =
|
|
|
68
69
|
}
|
|
69
70
|
| {
|
|
70
71
|
/**
|
|
71
|
-
* The user's root
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* The user's root equalled the base, so the gate fired on another writer's
|
|
73
|
+
* change and the caller is behind the server (`ConflictError.actualVersion`).
|
|
74
|
+
* Nothing was refetched. Catch up from the update at that version or a later
|
|
75
|
+
* one, or refetch; forcing the write overwrites the other writer's change.
|
|
74
76
|
*/
|
|
75
77
|
status: 'no-op';
|
|
76
78
|
}
|
|
@@ -98,8 +100,8 @@ export type ReconcileOutcome<T> =
|
|
|
98
100
|
* (`base → ours`), refetch the server's current root (theirs), and replay the
|
|
99
101
|
* intent on top under strict, {@link augmentWithTestOps}-guarded `applyPatch`.
|
|
100
102
|
*
|
|
101
|
-
* - `no-op` — the user's root equals the base
|
|
102
|
-
*
|
|
103
|
+
* - `no-op` — the user's root equals the base: nothing to replay, and no
|
|
104
|
+
* refetch; the caller's document is behind the server's.
|
|
103
105
|
* - `unavailable` — the refetch produced nothing; caller falls back.
|
|
104
106
|
* - `merged` — the foreign writer touched only paths the user did not; the
|
|
105
107
|
* merged root carries both intents.
|
|
@@ -187,8 +189,8 @@ export interface ConflictResolver {
|
|
|
187
189
|
/**
|
|
188
190
|
* Reconcile the user's `base → ours` intent against the current
|
|
189
191
|
* server state (theirs, from `refetch`), returning a {@link ReconcileOutcome} the caller
|
|
190
|
-
* acts on (persist `merged`, drop on `conflict`,
|
|
191
|
-
* `unavailable`).
|
|
192
|
+
* acts on (persist `merged`, drop on `conflict`, catch up on `no-op`, fall
|
|
193
|
+
* back on `unavailable`).
|
|
192
194
|
*/
|
|
193
195
|
resolve<T extends object>(base: T, ours: T, refetch: () => Promise<T | undefined>): Promise<ReconcileOutcome<T>>;
|
|
194
196
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { type ConflictError, isConflictError } from './errors';
|
|
11
|
+
import { type BaseVersion, type ModelVersion } from './model-service/base-version';
|
|
12
|
+
import { type ConflictResolver } from './patch-merge';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX_WRITES = 3;
|
|
15
|
+
|
|
16
|
+
/** A model and the version of the text it was read from, which a write based on it names. */
|
|
17
|
+
export interface VersionedModel<TModel> {
|
|
18
|
+
readonly model: TModel;
|
|
19
|
+
readonly baseVersion: ModelVersion;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The I/O and policy a {@link reconcileWrite} call needs. */
|
|
23
|
+
export interface ReconcileWriteHooks<TModel> {
|
|
24
|
+
/**
|
|
25
|
+
* Write `model`. Called with the caller's own `baseVersion` first, with the
|
|
26
|
+
* refetch's on a merged write, and with `'any'` for a merge that refetched
|
|
27
|
+
* nothing, where the resolver has already decided to win.
|
|
28
|
+
*/
|
|
29
|
+
persist(model: TModel, baseVersion: BaseVersion): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Current server-side model, or `undefined` when unavailable. Its version is
|
|
32
|
+
* read in the tick its text is: a later one lets a merged write overwrite an
|
|
33
|
+
* edit the merge never saw.
|
|
34
|
+
*/
|
|
35
|
+
refetch(): Promise<VersionedModel<TModel> | undefined>;
|
|
36
|
+
/** The last in-sync model the user's intent is measured against. */
|
|
37
|
+
readonly base: TModel;
|
|
38
|
+
readonly conflictResolver: ConflictResolver;
|
|
39
|
+
/** Writes, the first included, after which a still-conflicting edit is dropped; 3 when absent. */
|
|
40
|
+
readonly maxWrites?: number;
|
|
41
|
+
/** Runs when the refetch is unavailable; absent, the edit is left unwritten. */
|
|
42
|
+
onUnavailable?(model: TModel, conflict: ConflictError): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What {@link reconcileWrite} did. Every status but `persisted` follows a
|
|
47
|
+
* conflict, and carries the last one and the writes made, the first included.
|
|
48
|
+
*/
|
|
49
|
+
export type ReconcileWriteOutcome =
|
|
50
|
+
/** The first write landed. */
|
|
51
|
+
| { readonly status: 'persisted' }
|
|
52
|
+
| {
|
|
53
|
+
/**
|
|
54
|
+
* `merged`: a merged write landed. `unchanged`: the edit was empty, so the
|
|
55
|
+
* caller is behind the server. `conflict`: the resolver found a same-path
|
|
56
|
+
* collision. `dropped`: the writes ran out. `unavailable`: the refetch
|
|
57
|
+
* produced nothing and {@link ReconcileWriteHooks.onUnavailable} ran.
|
|
58
|
+
*/
|
|
59
|
+
readonly status: 'merged' | 'unchanged' | 'conflict' | 'dropped' | 'unavailable';
|
|
60
|
+
readonly conflict: ConflictError;
|
|
61
|
+
readonly writes: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Persist `model`, and on a `ConflictError` reconcile the user's `base → model`
|
|
66
|
+
* intent onto the refetched server model and write the merge, gated on the
|
|
67
|
+
* refetch's version, again on each conflict up to `maxWrites` writes. A
|
|
68
|
+
* non-conflict error is re-thrown.
|
|
69
|
+
*/
|
|
70
|
+
export async function reconcileWrite<TModel extends object>(
|
|
71
|
+
model: TModel,
|
|
72
|
+
baseVersion: BaseVersion,
|
|
73
|
+
hooks: ReconcileWriteHooks<TModel>
|
|
74
|
+
): Promise<ReconcileWriteOutcome> {
|
|
75
|
+
let candidate = model;
|
|
76
|
+
let candidateBaseVersion = baseVersion;
|
|
77
|
+
let previous: ConflictError | undefined;
|
|
78
|
+
const maxWrites = hooks.maxWrites ?? DEFAULT_MAX_WRITES;
|
|
79
|
+
for (let writes = 1; ; writes++) {
|
|
80
|
+
const conflict = await persistOrConflict(hooks, candidate, candidateBaseVersion);
|
|
81
|
+
if (!conflict) {
|
|
82
|
+
return previous ? { status: 'merged', conflict: previous, writes } : { status: 'persisted' };
|
|
83
|
+
}
|
|
84
|
+
previous = conflict;
|
|
85
|
+
if (writes >= maxWrites) {
|
|
86
|
+
return { status: 'dropped', conflict, writes };
|
|
87
|
+
}
|
|
88
|
+
let refetched: VersionedModel<TModel> | undefined;
|
|
89
|
+
const outcome = await hooks.conflictResolver.resolve(hooks.base, model, async () => {
|
|
90
|
+
refetched = await hooks.refetch();
|
|
91
|
+
return refetched?.model;
|
|
92
|
+
});
|
|
93
|
+
switch (outcome.status) {
|
|
94
|
+
case 'merged':
|
|
95
|
+
candidate = outcome.merged;
|
|
96
|
+
candidateBaseVersion = refetched?.baseVersion ?? 'any';
|
|
97
|
+
continue;
|
|
98
|
+
case 'no-op':
|
|
99
|
+
return { status: 'unchanged', conflict, writes };
|
|
100
|
+
case 'conflict':
|
|
101
|
+
return { status: 'conflict', conflict, writes };
|
|
102
|
+
case 'unavailable':
|
|
103
|
+
await hooks.onUnavailable?.(model, conflict);
|
|
104
|
+
return { status: 'unavailable', conflict, writes };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The `ConflictError` the write raised, or `undefined` when it landed. */
|
|
110
|
+
async function persistOrConflict<TModel>(
|
|
111
|
+
hooks: ReconcileWriteHooks<TModel>,
|
|
112
|
+
model: TModel,
|
|
113
|
+
baseVersion: BaseVersion
|
|
114
|
+
): Promise<ConflictError | undefined> {
|
|
115
|
+
try {
|
|
116
|
+
await hooks.persist(model, baseVersion);
|
|
117
|
+
return undefined;
|
|
118
|
+
} catch (err: unknown) {
|
|
119
|
+
if (!isConflictError(err)) {
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
return err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
10
|
import { Emitter, type Event, type MessageConnection } from 'vscode-jsonrpc';
|
|
11
|
+
import { reviveProtocolError } from '../errors';
|
|
11
12
|
import { type BindRpcMethodsOptions, bindRpcMethods } from './bind-rpc-methods';
|
|
12
13
|
import { assertValidMethodNamespace } from './wire-prefix';
|
|
13
14
|
|
|
@@ -198,6 +199,9 @@ function assertSingleArg(wireName: string, args: unknown[]): void {
|
|
|
198
199
|
* params, results and errors, and a second layer here would double every
|
|
199
200
|
* traced line.
|
|
200
201
|
*
|
|
202
|
+
* A rejection whose code names one of the framework's typed errors is rethrown
|
|
203
|
+
* as that class, through {@link reviveProtocolError}.
|
|
204
|
+
*
|
|
201
205
|
* Accepts either a ready connection or a `Promise<MessageConnection>` —
|
|
202
206
|
* proxy methods called before the promise resolves queue until it does,
|
|
203
207
|
* then dispatch, so adopters can wire the proxy before its underlying
|
|
@@ -293,7 +297,8 @@ export function createRpcProxy<T extends object, TLocal extends object = never>(
|
|
|
293
297
|
const capturedError = new Error(`RPC request '${wireName}' failed`);
|
|
294
298
|
return resolvedConnection
|
|
295
299
|
.then(connection => connection.sendRequest(wireName, args[0]))
|
|
296
|
-
.catch((
|
|
300
|
+
.catch((rejection: unknown) => {
|
|
301
|
+
const err = reviveProtocolError(rejection);
|
|
297
302
|
if (err instanceof Error && capturedError.stack) {
|
|
298
303
|
err.stack = `${err.stack ?? err.message}\nCaused by request from:\n${capturedError.stack}`;
|
|
299
304
|
}
|