@hydranium/glsp-server 1.0.0-next.31 → 1.0.0-next.315

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +80 -88
  2. package/lib/browser/start-glsp-server-in-worker.d.ts +43 -42
  3. package/lib/browser/start-glsp-server-in-worker.d.ts.map +1 -1
  4. package/lib/browser/start-glsp-server-in-worker.js +62 -13
  5. package/lib/browser/start-glsp-server-in-worker.js.map +1 -1
  6. package/lib/command/diagram-edit-dropped-error.d.ts +31 -0
  7. package/lib/command/diagram-edit-dropped-error.d.ts.map +1 -0
  8. package/lib/command/diagram-edit-dropped-error.js +48 -0
  9. package/lib/command/diagram-edit-dropped-error.js.map +1 -0
  10. package/lib/command/hydranium-glsp-command-stack.d.ts +33 -0
  11. package/lib/command/hydranium-glsp-command-stack.d.ts.map +1 -0
  12. package/lib/command/hydranium-glsp-command-stack.js +63 -0
  13. package/lib/command/hydranium-glsp-command-stack.js.map +1 -0
  14. package/lib/command/hydranium-glsp-operation-action-handler.d.ts +64 -0
  15. package/lib/command/hydranium-glsp-operation-action-handler.d.ts.map +1 -0
  16. package/lib/command/hydranium-glsp-operation-action-handler.js +142 -0
  17. package/lib/command/hydranium-glsp-operation-action-handler.js.map +1 -0
  18. package/lib/command/hydranium-glsp-operation-command.d.ts +250 -0
  19. package/lib/command/hydranium-glsp-operation-command.d.ts.map +1 -0
  20. package/lib/command/hydranium-glsp-operation-command.js +491 -0
  21. package/lib/command/hydranium-glsp-operation-command.js.map +1 -0
  22. package/lib/command/hydranium-glsp-recording-command.d.ts +26 -87
  23. package/lib/command/hydranium-glsp-recording-command.d.ts.map +1 -1
  24. package/lib/command/hydranium-glsp-recording-command.js +40 -117
  25. package/lib/command/hydranium-glsp-recording-command.js.map +1 -1
  26. package/lib/command/hydranium-glsp-undo-redo-action-handler.d.ts +23 -0
  27. package/lib/command/hydranium-glsp-undo-redo-action-handler.d.ts.map +1 -0
  28. package/lib/command/hydranium-glsp-undo-redo-action-handler.js +41 -0
  29. package/lib/command/hydranium-glsp-undo-redo-action-handler.js.map +1 -0
  30. package/lib/command/index.d.ts +5 -0
  31. package/lib/command/index.d.ts.map +1 -1
  32. package/lib/command/index.js +5 -0
  33. package/lib/command/index.js.map +1 -1
  34. package/lib/computed-bounds/hydranium-glsp-computed-bounds-action-handler.d.ts +1 -1
  35. package/lib/computed-bounds/hydranium-glsp-computed-bounds-action-handler.d.ts.map +1 -1
  36. package/lib/computed-bounds/hydranium-glsp-computed-bounds-action-handler.js +2 -2
  37. package/lib/computed-bounds/hydranium-glsp-computed-bounds-action-handler.js.map +1 -1
  38. package/lib/dispatcher/server-action-dispatcher.d.ts +25 -24
  39. package/lib/dispatcher/server-action-dispatcher.d.ts.map +1 -1
  40. package/lib/dispatcher/server-action-dispatcher.js +38 -26
  41. package/lib/dispatcher/server-action-dispatcher.js.map +1 -1
  42. package/lib/index.d.ts +1 -0
  43. package/lib/index.d.ts.map +1 -1
  44. package/lib/index.js +1 -0
  45. package/lib/index.js.map +1 -1
  46. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts +35 -59
  47. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts.map +1 -1
  48. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js +60 -71
  49. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js.map +1 -1
  50. package/lib/launcher/drop-notifications-to-gone-client.d.ts +23 -0
  51. package/lib/launcher/drop-notifications-to-gone-client.d.ts.map +1 -0
  52. package/lib/launcher/drop-notifications-to-gone-client.js +43 -0
  53. package/lib/launcher/drop-notifications-to-gone-client.js.map +1 -0
  54. package/lib/launcher/glsp-app-module.d.ts +1 -2
  55. package/lib/launcher/glsp-app-module.d.ts.map +1 -1
  56. package/lib/launcher/glsp-app-module.js.map +1 -1
  57. package/lib/launcher/glsp-connection-logger.d.ts +19 -0
  58. package/lib/launcher/glsp-connection-logger.d.ts.map +1 -0
  59. package/lib/launcher/glsp-connection-logger.js +24 -0
  60. package/lib/launcher/glsp-connection-logger.js.map +1 -0
  61. package/lib/launcher/glsp-server-overrides.d.ts +10 -8
  62. package/lib/launcher/glsp-server-overrides.d.ts.map +1 -1
  63. package/lib/launcher/glsp-server-overrides.js +25 -11
  64. package/lib/launcher/glsp-server-overrides.js.map +1 -1
  65. package/lib/launcher/hydranium-glsp-server.d.ts +60 -15
  66. package/lib/launcher/hydranium-glsp-server.d.ts.map +1 -1
  67. package/lib/launcher/hydranium-glsp-server.js +145 -22
  68. package/lib/launcher/hydranium-glsp-server.js.map +1 -1
  69. package/lib/launcher/index.d.ts +2 -0
  70. package/lib/launcher/index.d.ts.map +1 -1
  71. package/lib/launcher/index.js +5 -4
  72. package/lib/launcher/index.js.map +1 -1
  73. package/lib/logging/glsp-client-logger.d.ts +10 -6
  74. package/lib/logging/glsp-client-logger.d.ts.map +1 -1
  75. package/lib/logging/glsp-client-logger.js +14 -7
  76. package/lib/logging/glsp-client-logger.js.map +1 -1
  77. package/lib/messages/index.d.ts +11 -6
  78. package/lib/messages/index.d.ts.map +1 -1
  79. package/lib/messages/index.js +11 -6
  80. package/lib/messages/index.js.map +1 -1
  81. package/lib/node/start-glsp-server.d.ts +50 -15
  82. package/lib/node/start-glsp-server.d.ts.map +1 -1
  83. package/lib/node/start-glsp-server.js +97 -30
  84. package/lib/node/start-glsp-server.js.map +1 -1
  85. package/lib/state/abstract-hydranium-glsp-state.d.ts +326 -102
  86. package/lib/state/abstract-hydranium-glsp-state.d.ts.map +1 -1
  87. package/lib/state/abstract-hydranium-glsp-state.js +529 -139
  88. package/lib/state/abstract-hydranium-glsp-state.js.map +1 -1
  89. package/lib/state/diagram-status.d.ts +38 -0
  90. package/lib/state/diagram-status.d.ts.map +1 -0
  91. package/lib/state/diagram-status.js +27 -0
  92. package/lib/state/diagram-status.js.map +1 -0
  93. package/lib/state/full-text-hydranium-glsp-state.d.ts +22 -9
  94. package/lib/state/full-text-hydranium-glsp-state.d.ts.map +1 -1
  95. package/lib/state/full-text-hydranium-glsp-state.js +45 -18
  96. package/lib/state/full-text-hydranium-glsp-state.js.map +1 -1
  97. package/lib/state/hydranium-glsp-index.d.ts +40 -29
  98. package/lib/state/hydranium-glsp-index.d.ts.map +1 -1
  99. package/lib/state/hydranium-glsp-index.js +82 -45
  100. package/lib/state/hydranium-glsp-index.js.map +1 -1
  101. package/lib/state/hydranium-shared-core-services.d.ts +16 -29
  102. package/lib/state/hydranium-shared-core-services.d.ts.map +1 -1
  103. package/lib/state/hydranium-shared-core-services.js +15 -28
  104. package/lib/state/hydranium-shared-core-services.js.map +1 -1
  105. package/lib/state/index.d.ts +1 -0
  106. package/lib/state/index.d.ts.map +1 -1
  107. package/lib/state/index.js +1 -0
  108. package/lib/state/index.js.map +1 -1
  109. package/lib/state/model-ready-timeout-error.d.ts.map +1 -1
  110. package/lib/state/model-ready-timeout-error.js.map +1 -1
  111. package/lib/state/reconcile-source-model-write.d.ts +13 -27
  112. package/lib/state/reconcile-source-model-write.d.ts.map +1 -1
  113. package/lib/state/reconcile-source-model-write.js +44 -32
  114. package/lib/state/reconcile-source-model-write.js.map +1 -1
  115. package/lib/state/reconciling-multi-document-glsp-state.d.ts +125 -69
  116. package/lib/state/reconciling-multi-document-glsp-state.d.ts.map +1 -1
  117. package/lib/state/reconciling-multi-document-glsp-state.js +242 -100
  118. package/lib/state/reconciling-multi-document-glsp-state.js.map +1 -1
  119. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts +36 -28
  120. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts.map +1 -1
  121. package/lib/state/reconciling-transfer-hydranium-glsp-state.js +59 -42
  122. package/lib/state/reconciling-transfer-hydranium-glsp-state.js.map +1 -1
  123. package/lib/status/diagram-status-reporter.d.ts +44 -0
  124. package/lib/status/diagram-status-reporter.d.ts.map +1 -0
  125. package/lib/status/diagram-status-reporter.js +88 -0
  126. package/lib/status/diagram-status-reporter.js.map +1 -0
  127. package/lib/status/hydranium-glsp-request-model-action-handler.d.ts +27 -0
  128. package/lib/status/hydranium-glsp-request-model-action-handler.d.ts.map +1 -0
  129. package/lib/status/hydranium-glsp-request-model-action-handler.js +52 -0
  130. package/lib/status/hydranium-glsp-request-model-action-handler.js.map +1 -0
  131. package/lib/status/hydranium-glsp-set-edit-mode-action-handler.d.ts +27 -0
  132. package/lib/status/hydranium-glsp-set-edit-mode-action-handler.d.ts.map +1 -0
  133. package/lib/status/hydranium-glsp-set-edit-mode-action-handler.js +53 -0
  134. package/lib/status/hydranium-glsp-set-edit-mode-action-handler.js.map +1 -0
  135. package/lib/status/index.d.ts +12 -0
  136. package/lib/status/index.d.ts.map +1 -0
  137. package/lib/status/index.js +12 -0
  138. package/lib/status/index.js.map +1 -0
  139. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts +30 -0
  140. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts.map +1 -0
  141. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js +47 -0
  142. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js.map +1 -0
  143. package/lib/storage/hydranium-glsp-storage.d.ts +324 -143
  144. package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
  145. package/lib/storage/hydranium-glsp-storage.js +569 -213
  146. package/lib/storage/hydranium-glsp-storage.js.map +1 -1
  147. package/lib/storage/index.d.ts +2 -1
  148. package/lib/storage/index.d.ts.map +1 -1
  149. package/lib/storage/index.js +2 -1
  150. package/lib/storage/index.js.map +1 -1
  151. package/lib/storage/save-delivery-policy.d.ts +51 -0
  152. package/lib/storage/save-delivery-policy.d.ts.map +1 -0
  153. package/lib/storage/{save-conflict-policy.js → save-delivery-policy.js} +7 -8
  154. package/lib/storage/save-delivery-policy.js.map +1 -0
  155. package/lib/submission/hydranium-glsp-submission-handler.d.ts +44 -4
  156. package/lib/submission/hydranium-glsp-submission-handler.d.ts.map +1 -1
  157. package/lib/submission/hydranium-glsp-submission-handler.js +82 -6
  158. package/lib/submission/hydranium-glsp-submission-handler.js.map +1 -1
  159. package/lib/testing/glsp-harness.d.ts +34 -7
  160. package/lib/testing/glsp-harness.d.ts.map +1 -1
  161. package/lib/testing/glsp-harness.js +47 -19
  162. package/lib/testing/glsp-harness.js.map +1 -1
  163. package/lib/validation/diagnostic-markers.d.ts +3 -3
  164. package/lib/validation/diagnostic-markers.d.ts.map +1 -1
  165. package/lib/validation/diagnostic-markers.js +1 -1
  166. package/lib/validation/diagnostic-markers.js.map +1 -1
  167. package/lib/validation/hydranium-glsp-model-validator.d.ts +1 -2
  168. package/lib/validation/hydranium-glsp-model-validator.d.ts.map +1 -1
  169. package/lib/validation/hydranium-glsp-model-validator.js +1 -1
  170. package/lib/validation/hydranium-glsp-model-validator.js.map +1 -1
  171. package/package.json +31 -52
  172. package/src/browser/start-glsp-server-in-worker.ts +79 -51
  173. package/src/command/diagram-edit-dropped-error.ts +51 -0
  174. package/src/command/hydranium-glsp-command-stack.ts +47 -0
  175. package/src/command/hydranium-glsp-operation-action-handler.ts +141 -0
  176. package/src/command/hydranium-glsp-operation-command.ts +602 -0
  177. package/src/command/hydranium-glsp-recording-command.ts +40 -121
  178. package/src/command/hydranium-glsp-undo-redo-action-handler.ts +29 -0
  179. package/src/command/index.ts +10 -0
  180. package/src/computed-bounds/hydranium-glsp-computed-bounds-action-handler.ts +2 -2
  181. package/src/dispatcher/server-action-dispatcher.ts +39 -31
  182. package/src/index.ts +1 -0
  183. package/src/launcher/abstract-hydranium-glsp-diagram-module.ts +79 -91
  184. package/src/launcher/drop-notifications-to-gone-client.ts +47 -0
  185. package/src/launcher/glsp-app-module.ts +1 -2
  186. package/src/launcher/glsp-connection-logger.ts +27 -0
  187. package/src/launcher/glsp-server-overrides.ts +27 -11
  188. package/src/launcher/hydranium-glsp-server.ts +153 -23
  189. package/src/launcher/index.ts +5 -4
  190. package/src/logging/glsp-client-logger.ts +16 -9
  191. package/src/messages/index.ts +16 -6
  192. package/src/node/start-glsp-server.ts +133 -40
  193. package/src/state/abstract-hydranium-glsp-state.ts +609 -154
  194. package/src/state/diagram-status.ts +41 -0
  195. package/src/state/full-text-hydranium-glsp-state.ts +46 -18
  196. package/src/state/hydranium-glsp-index.ts +85 -41
  197. package/src/state/hydranium-shared-core-services.ts +16 -29
  198. package/src/state/index.ts +1 -0
  199. package/src/state/reconcile-source-model-write.ts +51 -53
  200. package/src/state/reconciling-multi-document-glsp-state.ts +276 -102
  201. package/src/state/reconciling-transfer-hydranium-glsp-state.ts +67 -45
  202. package/src/status/diagram-status-reporter.ts +106 -0
  203. package/src/status/hydranium-glsp-request-model-action-handler.ts +43 -0
  204. package/src/status/hydranium-glsp-set-edit-mode-action-handler.ts +38 -0
  205. package/src/status/index.ts +12 -0
  206. package/src/storage/hydranium-glsp-request-save-model-action-handler.ts +40 -0
  207. package/src/storage/hydranium-glsp-storage.ts +647 -223
  208. package/src/storage/index.ts +2 -1
  209. package/src/storage/save-delivery-policy.ts +49 -0
  210. package/src/submission/hydranium-glsp-submission-handler.ts +91 -5
  211. package/src/testing/glsp-harness.ts +67 -25
  212. package/src/validation/diagnostic-markers.ts +3 -3
  213. package/src/validation/hydranium-glsp-model-validator.ts +4 -5
  214. package/lib/storage/save-conflict-policy.d.ts +0 -56
  215. package/lib/storage/save-conflict-policy.d.ts.map +0 -1
  216. package/lib/storage/save-conflict-policy.js.map +0 -1
  217. package/src/storage/save-conflict-policy.ts +0 -52
@@ -7,11 +7,13 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { DefaultModelState, type MaybePromise, SOURCE_URI_ARG } from '@eclipse-glsp/server';
10
+ import { DefaultModelState, EditMode, type MaybePromise, SOURCE_URI_ARG } from '@eclipse-glsp/server';
11
11
  import { Emitter, type Event } from 'vscode-jsonrpc';
12
- import { inject, injectable, optional } from 'inversify';
13
- import { type AstNode, DocumentState, URI } from '@hydranium/langium';
12
+ import { inject, injectable } from 'inversify';
13
+ import { type AstNode, AstUtils, DocumentState, isAstNode, type Reference, type ReferenceInfo, URI } from '@hydranium/langium';
14
14
  import {
15
+ buildAstNode,
16
+ type ClientSession,
15
17
  type HydraniumScopeProvider,
16
18
  type LanguageTarget,
17
19
  type NameProvider,
@@ -19,7 +21,21 @@ import {
19
21
  type ServerLanguageServices,
20
22
  type ServerSharedServices
21
23
  } from '@hydranium/core';
22
- import type { ConflictResolver, Disposable, Logger, Tracer } from '@hydranium/protocol';
24
+ import {
25
+ type BaseVersion,
26
+ type ModelVersion,
27
+ type ConflictResolver,
28
+ type Logger,
29
+ type Tracer,
30
+ type TransferElement,
31
+ asModelVersion,
32
+ TIMED_OUT,
33
+ UNRECORDED_VERSION,
34
+ describeError,
35
+ describeErrorWithStack
36
+ } from '@hydranium/protocol';
37
+ import { type OperationTransition, openOperationOf } from '../command/hydranium-glsp-operation-command.js';
38
+ import { type DiagramStatus, type DiagramStatusEntry } from './diagram-status.js';
23
39
  import { type HydraniumGlspIndex } from './hydranium-glsp-index.js';
24
40
  import { HydraniumTypes } from './hydranium-shared-core-services.js';
25
41
  import { ModelReadyTimeoutError } from './model-ready-timeout-error.js';
@@ -49,7 +65,7 @@ import { ModelReadyTimeoutError } from './model-ready-timeout-error.js';
49
65
  * override only to change the error shape (richer per-adopter output comes
50
66
  * from overriding `wsRelativePath` / `formatBuildStatus`).
51
67
  * - logging — see {@link baseTracer}. Adopters wanting a different label
52
- * override `setSourceRoot` and derive via
68
+ * override `captureSourceRoot` and derive via
53
69
  * `this.baseTracer.for('Name').withUri(uri)`.
54
70
  *
55
71
  * **Lifecycle ordering.** Reading {@link sourceUri} or {@link sourceRoot}
@@ -58,7 +74,7 @@ import { ModelReadyTimeoutError } from './model-ready-timeout-error.js';
58
74
  * (typically from the storage `loadSourceModel` flow) before reading them.
59
75
  * The {@link logger}/{@link tracer} are the exception: the injected
60
76
  * {@link baseTracer} is always available, so the getters fall back to it
61
- * before {@link setSourceRoot} derives the URI-tagged {@link _tracer} —
77
+ * before {@link captureSourceRoot} derives the URI-tagged {@link _tracer} —
62
78
  * logging is never `undefined`.
63
79
  */
64
80
  @injectable()
@@ -76,9 +92,15 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
76
92
  */
77
93
  @inject(HydraniumTypes.ConflictResolver) readonly conflictResolver!: ConflictResolver;
78
94
 
95
+ /**
96
+ * Writes of one diagram edit, the first included, that may conflict before
97
+ * the edit is dropped with a warning; each further write reconciles again.
98
+ */
99
+ protected readonly maxSourceModelWrites: number = 3;
100
+
79
101
  /**
80
102
  * Per-class tracer, caller-tagged with this state's runtime subclass name by
81
- * the `HydraniumTypes.Tracer` binding. {@link setSourceRoot} derives the
103
+ * the `HydraniumTypes.Tracer` binding. {@link captureSourceRoot} derives the
82
104
  * URI-tagged {@link _tracer} from it; the {@link tracer}/{@link logger}
83
105
  * getters fall back to it before then, so logging is never `undefined`.
84
106
  */
@@ -87,12 +109,62 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
87
109
  /** Narrows {@link DefaultModelState.index} to the framework-extended index type. */
88
110
  declare readonly index: HydraniumGlspIndex;
89
111
 
112
+ /**
113
+ * The client session this diagram works as, registered under `clientId` by
114
+ * the source-model storage's load and ended with the GLSP client session,
115
+ * or by a resume that takes it over. `undefined` before the load, for
116
+ * GLSP's placeholder client, and when another participant held the id, in
117
+ * which case the diagram does not load.
118
+ *
119
+ * Every write of the diagram goes through this session, which writes only a
120
+ * document it has open and opens nothing. A one-shot write to a document
121
+ * outside the write set goes through
122
+ * {@link ClientSession.withOpen}, and a document the diagram brings into
123
+ * existence through {@link ClientSession.create}.
124
+ */
125
+ modelSession?: ClientSession<AstNode>;
126
+
127
+ /**
128
+ * {@link modelSession}, or a throw without one: the diagram never loaded or
129
+ * has ended. Looking `clientId` up through `ModelService.getSession` instead
130
+ * would write as whichever participant holds that id.
131
+ */
132
+ protected requireModelSession(): ClientSession<AstNode> {
133
+ if (!this.modelSession) {
134
+ throw new Error(`No client session is registered for ${this.clientId}; the diagram cannot write`);
135
+ }
136
+ return this.modelSession;
137
+ }
138
+
90
139
  protected _sourceUri!: string;
140
+ /**
141
+ * The language {@link _sourceUri} routed to at the last capture, or
142
+ * `undefined` when it routes nowhere. Captured rather than looked up on
143
+ * each {@link diagramLanguage} read, which a GModel factory may make once
144
+ * per element, because the lookup parses the URI and walks the registry.
145
+ */
146
+ protected _sourceLanguage?: ServerLanguageServices;
147
+ /**
148
+ * The root the last capture took, which every reader of the document
149
+ * shares. A projection that must see an operation's edits reads
150
+ * {@link sourceRoot}, which is a copy of it during an operation.
151
+ */
91
152
  protected _sourceRoot!: TRoot;
153
+ /** Settles when the last {@link runExclusive} call queued so far has. */
154
+ protected exclusiveTail: Promise<unknown> = Promise.resolve();
92
155
  protected _tracer?: Tracer;
93
- protected _version: number = 0;
94
- /** Based-on versions of the secondary write set, keyed by URI. See {@link trackSecondaryDocument}. */
95
- protected readonly _secondaryVersions = new Map<string, number>();
156
+ protected _baseVersion: ModelVersion = asModelVersion(0);
157
+ /**
158
+ * The built root each secondary's version was read from, keyed by
159
+ * canonical URI, and during an operation the root of any other document it
160
+ * first reached. An operation copies a secondary from here, so the copy,
161
+ * the base a conflict reconciles from and the version its write is gated
162
+ * on describe one revision; the registry's current root can be a later
163
+ * build's.
164
+ */
165
+ protected readonly capturedRoots = new Map<string, AstNode>();
166
+ /** Model versions of the secondary write set, keyed by URI. See {@link trackSecondaryDocument}. */
167
+ protected readonly _secondaryVersions = new Map<string, ModelVersion>();
96
168
 
97
169
  /** Backing emitter for {@link onSecondaryUrisChanged}; fired only on a membership change. */
98
170
  protected readonly secondaryUrisChangedEmitter = new Emitter<void>();
@@ -104,29 +176,54 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
104
176
  protected readyTimeoutMs: number = AbstractHydraniumGlspState.DEFAULT_READY_TIMEOUT_MS;
105
177
 
106
178
  /**
107
- * Capture a freshly built source root alongside its URI, refresh the
108
- * logger to label log lines with the URI, and re-index the AST so
109
- * downstream GModel projection can resolve elements back to nodes.
179
+ * Capture a freshly built source root alongside its URI through
180
+ * {@link captureSourceRoot}, and index the AST so downstream GModel
181
+ * projection can resolve elements back to nodes.
110
182
  *
111
- * Also mirrors `uri` into the inherited properties map under
112
- * {@link SOURCE_URI_ARG} so legacy GLSP code paths (`state.get(...)`)
113
- * stay consistent with the typed field.
183
+ * Capturing the document already captured keeps what the last GModel build
184
+ * registered in the index, which only the next build makes again, and drops
185
+ * an id added through `indexSemanticElement` whose node a rebuild replaced.
186
+ *
187
+ * Throws during an operation: a capture there swaps the root out from under
188
+ * the nodes a handler resolved, and the operation captures when it ends.
114
189
  */
115
190
  setSourceRoot(uri: string, root: TRoot): void {
191
+ if (openOperationOf(this)) {
192
+ throw new Error(`setSourceRoot(${uri}) during an operation: the operation captures the source root when it ends`);
193
+ }
194
+ const recapture = uri === this._sourceUri;
195
+ this.captureSourceRoot(uri, root);
196
+ if (recapture) {
197
+ this.index.reindexSemanticElements(root, uri);
198
+ this.index.remapSemanticAliases(node => (this.isCurrentBuiltNode(node) ? node : undefined));
199
+ } else {
200
+ this.index.indexSourceRoot(root, uri);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Take `root` as the source root and read everything derived from it: the
206
+ * base version, every tracked secondary version, the URI mirrored into the
207
+ * inherited properties map under {@link SOURCE_URI_ARG}, and the
208
+ * URI-labelled logger. Everything but the index, which
209
+ * {@link setSourceRoot} sets. Override to derive more from a capture.
210
+ */
211
+ protected captureSourceRoot(uri: string, root: TRoot): void {
116
212
  this._sourceUri = uri;
213
+ this._sourceLanguage = this.sharedServices.ServiceRegistry.getServicesFor(uri);
117
214
  this._sourceRoot = root;
118
- this._version = this.readDocumentVersion(uri);
119
- // Re-read the secondary write set for the same reason the primary is
120
- // re-read: the write that brought us here advanced their versions too, and
121
- // a stale based-on version would gate the NEXT command against a
122
- // superseded number — reporting a conflict that is really this state's own
123
- // last write.
215
+ // The root's own version: the registry's current root can be a later
216
+ // build's, and a write based on its version passes the gate over edits
217
+ // `root` never saw.
218
+ this._baseVersion = this.sharedServices.workspace.ModelLedger.versionOf(root);
219
+ // Re-read the secondary write set: the write that brought us here
220
+ // advanced their versions too, and a stale base version would gate the
221
+ // NEXT command against a superseded number — reporting a conflict that is
222
+ // really this state's own last write.
124
223
  this.refreshSecondaryVersions();
125
224
  this.set(SOURCE_URI_ARG, uri);
126
225
  this._tracer = this.baseTracer.withUri(uri);
127
- this.tracer.debug(`Captured source root at doc.version=v${this._version}`);
128
- this.checkDeclaredLanguage(uri);
129
- this.index.indexSourceRoot(root, uri);
226
+ this.tracer.debug(`Captured source root at doc.version=v${this._baseVersion}`);
130
227
  }
131
228
 
132
229
  /**
@@ -138,34 +235,244 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
138
235
  return this._sourceUri;
139
236
  }
140
237
 
238
+ /** The root operation handlers edit: during an operation a copy of the built root, outside one the built root. */
141
239
  get sourceRoot(): TRoot {
142
- return this._sourceRoot;
240
+ return openOperationOf(this) && this._sourceRoot !== undefined ? (this.workingRootOf(this._sourceUri) as TRoot) : this._sourceRoot;
241
+ }
242
+
243
+ /**
244
+ * Run `run` once every earlier call has settled, thrown or not: the
245
+ * boundary that orders operations, undo and redo, and the storage's capture
246
+ * and render. Code running inside it never calls this again, since the
247
+ * call would wait for the section it runs in.
248
+ */
249
+ runExclusive<T>(run: () => MaybePromise<T>): Promise<T> {
250
+ const result = this.exclusiveTail.then(() => run());
251
+ this.exclusiveTail = result.catch(() => undefined);
252
+ return result;
253
+ }
254
+
255
+ /**
256
+ * The root of `uri` that operation handlers edit: during an operation a
257
+ * copy made on the first request and returned for the rest of it, outside
258
+ * one the root itself. The root is the built root of `uri`, or `orElse()`
259
+ * when `uri` has no document; `undefined` when neither gives one.
260
+ *
261
+ * A handler that edits another document than the source reaches its root
262
+ * through this, or the edit lands on the root every reader shares. An id
263
+ * added through `indexSemanticElement` for a node of that root names the
264
+ * node's copy from then on.
265
+ */
266
+ workingRootOf(uri: string, orElse?: () => AstNode | undefined): AstNode | undefined {
267
+ const key = this.workingKey(uri);
268
+ const primary = this._sourceUri !== undefined && key === this.workingKey(this._sourceUri);
269
+ const operation = openOperationOf(this);
270
+ if (!operation) {
271
+ return primary ? this._sourceRoot : (this.sharedServices.model.ModelService.getDocument(uri)?.parseResult.value ?? orElse?.());
272
+ }
273
+ let built: AstNode | undefined = primary ? this._sourceRoot : this.capturedRoots.get(key);
274
+ if (built === undefined) {
275
+ // First reached during the operation: frozen now, for the rest of it.
276
+ built = this.readBuiltRoot(uri);
277
+ if (built !== undefined) {
278
+ this.capturedRoots.set(key, built);
279
+ }
280
+ }
281
+ built ??= orElse?.();
282
+ return built === undefined ? undefined : operation.workingRootOf(key, built);
283
+ }
284
+
285
+ /** The copy of `uri` the open operation made, without making one. */
286
+ protected existingWorkingRootOf(uri: string): AstNode | undefined {
287
+ return openOperationOf(this)?.existingWorkingRootOf(this.workingKey(uri));
288
+ }
289
+
290
+ /**
291
+ * The key of `uri` among an operation's copies: its canonical form, so two
292
+ * spellings of one document share one copy.
293
+ */
294
+ protected workingKey(uri: string): string {
295
+ return this.sharedServices.workspace.DocumentUriPolicy.canonicalUri(uri);
296
+ }
297
+
298
+ /**
299
+ * The built node `node` was copied from during an operation; `node` itself
300
+ * for a built node, for a node the operation created, and outside one.
301
+ *
302
+ * A copy has no `$document`, so a lookup that reads its document, scope or
303
+ * project throws or answers for no document. Handlers pass the built node
304
+ * there and keep editing the copy. A candidate query takes
305
+ * {@link referenceInfoOf} instead, since a created node has no built node.
306
+ */
307
+ builtNodeOf<T extends AstNode>(node: T): T {
308
+ return openOperationOf(this)?.builtNodeOf(node) ?? node;
309
+ }
310
+
311
+ /**
312
+ * A reference to `target` whose `ref` is `target` itself, a copy node
313
+ * included, so an identity comparison later in the operation finds it.
314
+ * `undefined` when no reference can be formed. Outside an operation the
315
+ * nodes are built ones and this is the `ReferenceBuilder`'s answer as is.
316
+ *
317
+ * With `tier: 'own'` the `$refText` is the target's own name as
318
+ * `toOwnReference` gives it, read off `target` itself, so a name the
319
+ * operation edited is the one written; the builder is the target's
320
+ * language's, which a copy node, having no `$document`, would not otherwise
321
+ * reach. `source` plays no part in that tier.
322
+ *
323
+ * By default it is `getReferenceName` from `source`'s context, with the
324
+ * builder of `source`'s language, since `$refText` encoding follows the
325
+ * grammar writing it. That call reads both nodes' projects, to pick the
326
+ * qualification and to refuse a reference across projects the target's is
327
+ * not visible to, and a copy node has none; so it is asked about the built
328
+ * target, which names a target as built, before any rename in the
329
+ * operation, and about {@link builtContextOf} the source. A target the
330
+ * operation created has no built node, and is asked about without a
331
+ * project.
332
+ */
333
+ referenceTo<T extends AstNode>(target: T, source?: AstNode, options: { readonly tier?: 'own' } = {}): Reference<T> | undefined {
334
+ const refText =
335
+ options.tier === 'own'
336
+ ? this.languageServicesFor(target)?.references.ReferenceBuilder.toOwnReference(target)?.$refText
337
+ : this.languageServicesFor(source ?? target)?.references.ReferenceBuilder.getReferenceName(
338
+ this.builtNodeOf(target),
339
+ source === undefined ? undefined : this.builtContextOf(source)
340
+ );
341
+ return refText === undefined ? undefined : { ref: target, $refText: refText };
342
+ }
343
+
344
+ /**
345
+ * A candidate query for `property` of `node`, to pass to
346
+ * `candidateProviderFor(node)?.find`. It is asked from the built node, and
347
+ * for a node the operation created from a stand-in of its type under the
348
+ * built containers it sits in, one per created level: a scope reads local
349
+ * names off every container up the chain, so anchoring higher drops them.
350
+ * A protocol context cannot express this: an element source resolves only
351
+ * through a `resolveElementByName` override, and through the index only
352
+ * for the nodes it exports. `undefined` for a node whose `$container` chain
353
+ * reaches no document and no copy, such as a node built with no
354
+ * `$container` that is not a document's root.
355
+ *
356
+ * A node not created yet is asked about through a stand-in built with
357
+ * `buildAstNode`: its type, `$container` the container it would join, and
358
+ * `$containerProperty`, left unattached.
359
+ */
360
+ referenceInfoOf(node: AstNode, property: string): ReferenceInfo | undefined {
361
+ let container = this.builtContextOf(node);
362
+ if (!AstUtils.findRootNode(container).$document) {
363
+ return undefined;
364
+ }
365
+ const created: AstNode[] = [];
366
+ for (let level: AstNode | undefined = node; level && this.builtNodeOf(level) !== container; level = level.$container) {
367
+ created.unshift(level);
368
+ }
369
+ for (const level of created) {
370
+ container = buildAstNode(this.sharedServices.AstReflection, level.$type, {
371
+ $container: container,
372
+ $containerProperty: level.$containerProperty,
373
+ $containerIndex: level.$containerIndex
374
+ });
375
+ }
376
+ return { reference: { $refText: '', ref: undefined }, container, property };
377
+ }
378
+
379
+ /**
380
+ * The built node of `node`, or of its nearest container that has one: a
381
+ * node the operation created has no built node, but its container's
382
+ * document and project are its own.
383
+ */
384
+ protected builtContextOf(node: AstNode): AstNode {
385
+ const operation = openOperationOf(this);
386
+ for (let current: AstNode | undefined = node; current; current = current.$container) {
387
+ const built: AstNode = this.builtNodeOf(current);
388
+ if (built !== current || operation?.workingUriOf(current) === undefined) {
389
+ return built;
390
+ }
391
+ }
392
+ return node;
393
+ }
394
+
395
+ /**
396
+ * Take `root`, which a write of this state's own produced, as the source
397
+ * root; during an operation the operation takes it and captures it when it
398
+ * ends.
399
+ */
400
+ protected captureWrittenRoot(root: TRoot): void {
401
+ const operation = openOperationOf(this);
402
+ if (operation) {
403
+ operation.recordWrittenRoot(root);
404
+ } else {
405
+ this.setSourceRoot(this._sourceUri, root);
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Resync after a write whose reconcile dropped the edit: during an
411
+ * operation the operation undoes its side effects, pushes nothing, and
412
+ * captures the document's current root when it ends.
413
+ */
414
+ protected writeDropped(): void {
415
+ const operation = openOperationOf(this);
416
+ if (operation) {
417
+ operation.recordDropped(this.sharedServices.model.ModelService.getDocument(this._sourceUri)?.parseResult.value);
418
+ } else {
419
+ this.refreshSourceRoot();
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Take the document's built root as the source root; during an operation,
425
+ * as the root the operation closes on. A write the document already held,
426
+ * and an undo or redo that wrote nothing while its steps may have rebuilt
427
+ * the document, leave this state on a root that predates the document. The
428
+ * built root can lag the text until the next build, whose refresh captures
429
+ * that, as after {@link writeDropped}.
430
+ */
431
+ captureBuiltRoot(): void {
432
+ const operation = openOperationOf(this);
433
+ if (!operation) {
434
+ this.refreshSourceRoot();
435
+ return;
436
+ }
437
+ const current = this.sharedServices.model.ModelService.getDocument(this._sourceUri)?.parseResult.value;
438
+ if (current !== undefined) {
439
+ operation.recordWrittenRoot(current);
440
+ }
143
441
  }
144
442
 
145
443
  /**
146
- * The language services of the grammar this DIAGRAM TYPE edits, as declared
147
- * by `AbstractHydraniumGlspDiagramModule.declareLanguage`.
444
+ * Whether `node` belongs to the root its document is built to now, or to no
445
+ * document; a root a rebuild replaced holds only stale nodes.
446
+ */
447
+ protected isCurrentBuiltNode(node: AstNode): boolean {
448
+ const root = AstUtils.findRootNode(node);
449
+ const document = root.$document;
450
+ return (
451
+ document === undefined || this.sharedServices.model.ModelService.getDocument(document.uri.toString())?.parseResult.value === root
452
+ );
453
+ }
454
+
455
+ /**
456
+ * The language services of the diagram document: the language
457
+ * {@link sourceUri} routed to at the last {@link setSourceRoot}.
148
458
  *
149
- * **Named for what it is, not for "the language".** Reach for it only when
150
- * the thing you are asking about lives in the diagram document itself.
151
- * Anything reached *through* a reference is decided by its own document: use
152
- * {@link languageServicesFor}. Making that choice explicit at the call site
153
- * is the point; a neutral-looking `languageServices` invites the diagram's
154
- * grammar to be applied to a foreign node, which is the defect this whole
155
- * seam exists to prevent.
459
+ * `undefined` until a document is loaded, as GLSP builds a session's
460
+ * handlers at `InitializeClientSession`, before `RequestModelAction`
461
+ * supplies the URI: read it when called, not at construction.
156
462
  *
157
- * Injected rather than derived from {@link sourceUri} so it is available
158
- * before the first {@link setSourceRoot} — GLSP constructs operation
159
- * handlers at `InitializeClientSession`, well before `RequestModelAction`.
160
- * `@optional()` so a harness that binds no diagram module still resolves;
161
- * `undefined` then means no diagram module declared a grammar.
463
+ * Reach for it only when the thing you are asking about lives in the
464
+ * diagram document itself. Anything reached through a reference is decided
465
+ * by its own document: use {@link languageServicesFor}.
162
466
  */
163
- @inject(HydraniumTypes.DiagramLanguage) @optional() readonly diagramLanguage?: ServerLanguageServices;
467
+ get diagramLanguage(): ServerLanguageServices | undefined {
468
+ return this._sourceLanguage;
469
+ }
164
470
 
165
471
  /**
166
472
  * The language services owning `target` — an AST node (routed by its
167
- * document), a URI, or a URI string — falling back to
168
- * {@link diagramLanguage} when `target` routes nowhere.
473
+ * document), a URI, or a URI string — falling back, for a node, to the
474
+ * language the index read from the diagram's root, and otherwise to
475
+ * {@link diagramLanguage}, when `target` routes nowhere.
169
476
  *
170
477
  * Use this wherever the thing being named, keyed or completed may live in
171
478
  * another document than the diagram. Two registered languages are free to
@@ -175,8 +482,14 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
175
482
  * diagnostic. The fallback covers the genuinely unroutable case, a synthetic
176
483
  * node not yet attached to a document, where the diagram's own language is
177
484
  * the only defensible answer.
485
+ *
486
+ * A node routes through {@link HydraniumGlspIndex.languageServicesFor}, so
487
+ * an override of the index's routing applies here too.
178
488
  */
179
489
  languageServicesFor(target: LanguageTarget | undefined): ServerLanguageServices | undefined {
490
+ if (isAstNode(target)) {
491
+ return this.index.languageServicesFor(target) ?? this.diagramLanguage;
492
+ }
180
493
  return this.sharedServices.ServiceRegistry.getServicesFor(target) ?? this.diagramLanguage;
181
494
  }
182
495
 
@@ -206,60 +519,42 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
206
519
  return this.languageServicesFor(target)?.references.CandidateProvider;
207
520
  }
208
521
 
209
- /**
210
- * Warn when the loaded document does not route to the grammar this diagram
211
- * module declared.
212
- *
213
- * The declared language is the one fact the module states that the grammar
214
- * itself does not, so it is the one that can drift — a diagram type pointed
215
- * at the wrong `LanguageMetaData`, or a file extension reassigned. Both
216
- * would otherwise surface much later as references resolving against the
217
- * wrong scope. Only a warning, not a throw: the document is loaded and
218
- * usable either way, and a hard failure here would take out a diagram over
219
- * a mismatch that may be intentional in an adopter serving one diagram type
220
- * over several grammars.
221
- */
222
- protected checkDeclaredLanguage(uri: string): void {
223
- const declared = this.diagramLanguage?.LanguageMetaData.languageId;
224
- if (declared === undefined) {
225
- return;
226
- }
227
- const actual = this.sharedServices.ServiceRegistry.getServicesFor(uri)?.LanguageMetaData.languageId;
228
- if (actual !== undefined && actual !== declared) {
229
- this.tracer.warn(`Diagram module declares language '${declared}' but this document routes to '${actual}'`);
230
- }
231
- }
232
-
233
522
  /** The observability handle as a plain {@link Logger} (a {@link Tracer} is-a Logger). */
234
523
  get logger(): Logger {
235
524
  return this.tracer;
236
525
  }
237
526
 
238
- /** URI-tagged once {@link setSourceRoot} has run; the class-tagged injected {@link baseTracer} before that. */
527
+ /** URI-tagged once a source root is captured; the class-tagged injected {@link baseTracer} before that. */
239
528
  get tracer(): Tracer {
240
529
  return this._tracer ?? this.baseTracer;
241
530
  }
242
531
 
243
532
  /**
244
- * Text-document version captured at the last {@link setSourceRoot}
245
- * call. Mirrors the server-side `TextDocuments.version(uri)` counter
246
- * at that point in time — frozen until the next `setSourceRoot`, then
247
- * re-read from `LangiumDocuments`.
533
+ * Model version of the root the last {@link captureSourceRoot} took,
534
+ * frozen until the next one.
248
535
  *
249
- * Threaded by `HydraniumGlspRecordingCommand` into
250
- * {@link updateSourceModel}'s optional `version` parameter so the
251
- * downstream `ModelService.update` / `.save` call can opt into the
252
- * conflict gate against the captured based-on version. See
536
+ * The base an operation's write is gated on, taken when the operation
537
+ * opens; nothing captures during an operation, so it is the version of the
538
+ * root the operation's copies were made from. See
253
539
  * `@hydranium/protocol#ConflictError` for the detection contract.
254
540
  *
255
- * Falls back to `0` when the document is absent from `LangiumDocuments`
256
- * at capture time — the same observable state callers see for a doc
257
- * that was never opened. Callers that need to distinguish
258
- * "never-versioned" from "v0" must consult the document registry
259
- * directly.
541
+ * **This, not a number read at write time, is what a write must be gated
542
+ * on.** A version read when the write is issued is the store's current one,
543
+ * so it matches by construction and the gate can never fire.
544
+ */
545
+ get baseVersion(): ModelVersion {
546
+ return this._baseVersion;
547
+ }
548
+
549
+ /**
550
+ * {@link baseVersion} as a plain number: the store's version of the text the
551
+ * root was parsed from.
552
+ *
553
+ * `UNRECORDED_VERSION` when the document was not built when the snapshot
554
+ * was taken, which no write matches.
260
555
  */
261
556
  get version(): number {
262
- return this._version;
557
+ return this._baseVersion;
263
558
  }
264
559
 
265
560
  // ============================================================
@@ -267,8 +562,8 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
267
562
  // ============================================================
268
563
 
269
564
  /**
270
- * Register `uri` as a document this state also writes, capturing its current
271
- * text-document version.
565
+ * Register `uri` as a document this state also writes, snapshotting its
566
+ * current text-document version.
272
567
  *
273
568
  * A diagram whose layout lives in its own file, or whose canvas creates
274
569
  * elements in a referenced semantic document, writes more than the document
@@ -281,7 +576,7 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
281
576
  * identified once the operation names the node. What the framework supplies
282
577
  * is the part an adopter cannot reconstruct after the fact — the version each
283
578
  * document was at BEFORE the command mutated anything (see
284
- * {@link capturedVersionOf}). Idempotent; registering the primary is ignored,
579
+ * {@link baseVersionOf}). Idempotent; registering the primary is ignored,
285
580
  * since {@link version} already tracks it. Registering a URI not already in
286
581
  * the set fires {@link onSecondaryUrisChanged}.
287
582
  */
@@ -290,7 +585,10 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
290
585
  return;
291
586
  }
292
587
  const added = !this._secondaryVersions.has(uri);
293
- this._secondaryVersions.set(uri, this.readDocumentVersion(uri));
588
+ const key = this.workingKey(uri);
589
+ // During an operation, the root the operation copied or first reached, not a later build's.
590
+ const root = (openOperationOf(this) ? this.capturedRoots.get(key) : undefined) ?? this.readBuiltRoot(uri);
591
+ this.recordSecondary(uri, root);
294
592
  if (added) {
295
593
  this.secondaryUrisChangedEmitter.fire();
296
594
  }
@@ -310,14 +608,15 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
310
608
 
311
609
  /**
312
610
  * Fires whenever the secondary write set gains or loses a URI — never when a
313
- * tracked document's captured version is merely refreshed, which happens on
314
- * every {@link setSourceRoot} and changes nothing a subscriber cares about.
611
+ * tracked document's model version is merely refreshed, which happens on
612
+ * every {@link captureSourceRoot} and changes nothing a subscriber cares about.
315
613
  *
316
614
  * **This exists so no caller has to know when the set can change.** The set is
317
615
  * usually discovered rather than known, and the discovery point is an
318
- * operation handler naming a node — not a capture. A subscriber that instead
319
- * re-derived the set after each capture would miss exactly that case, and miss
320
- * it silently: the document would simply never be watched, with nothing logged.
616
+ * operation handler naming a node — not a read of the source root. A
617
+ * subscriber that instead re-derived the set after each such read would miss
618
+ * exactly that case, and miss it silently: the document would simply never be
619
+ * watched, with nothing logged.
321
620
  * Announcing the change here moves the obligation to the one place that can
322
621
  * always discharge it.
323
622
  *
@@ -335,39 +634,115 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
335
634
  return [...this._secondaryVersions.keys()];
336
635
  }
337
636
 
637
+ /** Active statuses, least recently set first. See {@link setStatus}. */
638
+ protected readonly statuses = new Map<DiagramStatus, DiagramStatusEntry>();
639
+
640
+ /** Backing emitter for {@link onStatusChanged}. */
641
+ protected readonly statusChangedEmitter = new Emitter<void>();
642
+
338
643
  /**
339
- * Based-on version captured for `uri` — {@link version} for the primary, the
340
- * value captured at {@link trackSecondaryDocument} (refreshed by
341
- * {@link setSourceRoot}) for a secondary, `undefined` for anything untracked.
644
+ * Set `status`'s entry, or withdraw it with `undefined`, and recompute
645
+ * {@link currentStatus} and {@link editMode} from every active entry.
342
646
  *
343
- * `undefined` rather than `0` for an untracked URI deliberately: `0` is a real
647
+ * `editMode` is derived here and nowhere else: a writer that assigns it
648
+ * directly is overwritten by the next status change, and its write is not
649
+ * announced on {@link onStatusChanged}.
650
+ */
651
+ setStatus(status: DiagramStatus, entry: DiagramStatusEntry | undefined): void {
652
+ const previousEntry = this.statuses.get(status);
653
+ if (previousEntry === undefined && entry === undefined) {
654
+ return;
655
+ }
656
+ const previousStatus = this.currentStatus;
657
+ const previousEditMode = this.editMode;
658
+ // Re-inserted so iteration order is recency, which breaks a severity tie.
659
+ this.statuses.delete(status);
660
+ if (entry !== undefined) {
661
+ this.statuses.set(status, entry);
662
+ }
663
+ this.editMode = [...this.statuses.values()].some(active => active.readonly) ? EditMode.READONLY : EditMode.EDITABLE;
664
+ this.logger.debug(`Status ${status}: ${describeStatusEntry(previousEntry)} → ${describeStatusEntry(entry)}`);
665
+ if (this.currentStatus !== previousStatus || this.editMode !== previousEditMode) {
666
+ this.statusChangedEmitter.fire();
667
+ }
668
+ }
669
+
670
+ /** The active entry whose message the client shows: highest severity, then most recently set. */
671
+ get currentStatus(): DiagramStatusEntry | undefined {
672
+ let winner: DiagramStatusEntry | undefined;
673
+ for (const entry of this.statuses.values()) {
674
+ if (entry.message !== undefined && (!winner || severityRank(entry) >= severityRank(winner))) {
675
+ winner = entry;
676
+ }
677
+ }
678
+ return winner;
679
+ }
680
+
681
+ /** The active statuses that block editing; empty while the diagram is editable. */
682
+ get readonlyStatuses(): DiagramStatus[] {
683
+ return [...this.statuses].filter(([, entry]) => entry.readonly).map(([status]) => status);
684
+ }
685
+
686
+ /** The status that supplies {@link currentStatus}, for a log line naming it. */
687
+ get currentStatusSource(): DiagramStatus | undefined {
688
+ const current = this.currentStatus;
689
+ return [...this.statuses].find(([, entry]) => entry === current)?.[0];
690
+ }
691
+
692
+ /** Fires when {@link currentStatus} or {@link editMode} changes. */
693
+ get onStatusChanged(): Event<void> {
694
+ return this.statusChangedEmitter.event;
695
+ }
696
+
697
+ /**
698
+ * The base version of a write of `uri`: {@link baseVersion} for the primary,
699
+ * the version taken at {@link trackSecondaryDocument} (refreshed by
700
+ * {@link captureSourceRoot}) for a secondary, `undefined` for anything untracked.
701
+ *
702
+ * `undefined` rather than v0 for an untracked URI deliberately: `0` is a real
344
703
  * version meaning "present but never edited", so collapsing the two would let
345
- * a caller gate a write against a document it never captured.
704
+ * a caller gate a write against a document this state never read.
346
705
  */
347
- capturedVersionOf(uri: string): number | undefined {
706
+ baseVersionOf(uri: string): ModelVersion | undefined {
348
707
  if (uri === this._sourceUri) {
349
- return this._version;
708
+ return this._baseVersion;
350
709
  }
351
710
  return this._secondaryVersions.get(uri);
352
711
  }
353
712
 
713
+ /** Re-take every registered secondary's built root and the model version of that root. */
714
+ protected refreshSecondaryVersions(): void {
715
+ this.capturedRoots.clear();
716
+ for (const uri of [...this._secondaryVersions.keys()]) {
717
+ this.recordSecondary(uri, this.readBuiltRoot(uri));
718
+ }
719
+ }
720
+
354
721
  /**
355
- * Read a document's current text-document version through the model service's
356
- * canonicalizing gateway, so a URI spelled through a symlink still resolves to
357
- * the document keyed by its real path rather than stranding at `0` (which
358
- * would silently weaken the conflict gate). Optional-chained on `textDocument`
359
- * so a fixture that omits it does not throw; real Langium documents always
360
- * populate it.
722
+ * The built root of `uri`, looked up through the model service's
723
+ * canonicalizing gateway so a symlinked URI does not strand without one;
724
+ * `undefined` for a URI with no parsed document, whose version is then
725
+ * `UNRECORDED_VERSION`, which no write matches. The store counts a file it
726
+ * has not built at `0`, and so does the placeholder the builder registers
727
+ * before its parse, and a write based on `0` overwrites text this state
728
+ * never read.
361
729
  */
362
- protected readDocumentVersion(uri: string): number {
363
- return this.sharedServices.model.ModelService.getDocument(uri)?.textDocument?.version ?? 0;
730
+ protected readBuiltRoot(uri: string): AstNode | undefined {
731
+ const root = this.sharedServices.model.ModelService.getDocument(uri)?.parseResult.value;
732
+ return root && !this.sharedServices.workspace.ModelLedger.isPlaceholder(root) ? root : undefined;
364
733
  }
365
734
 
366
- /** Re-read every registered secondary's version from the document store. */
367
- protected refreshSecondaryVersions(): void {
368
- for (const uri of [...this._secondaryVersions.keys()]) {
369
- this._secondaryVersions.set(uri, this.readDocumentVersion(uri));
735
+ /** Take `root` as the secondary `uri` was read at, and its version, `UNRECORDED_VERSION` without one. */
736
+ protected recordSecondary(uri: string, root: AstNode | undefined): void {
737
+ if (root !== undefined) {
738
+ this.capturedRoots.set(this.workingKey(uri), root);
370
739
  }
740
+ this._secondaryVersions.set(uri, root ? this.sharedServices.workspace.ModelLedger.versionOf(root) : UNRECORDED_VERSION);
741
+ }
742
+
743
+ /** The root the secondary `uri` was read at; see {@link capturedRoots}. */
744
+ protected capturedRootOf(uri: string): AstNode | undefined {
745
+ return this.capturedRoots.get(this.workingKey(uri));
371
746
  }
372
747
 
373
748
  /**
@@ -378,7 +753,8 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
378
753
  * handlers) must see the root the freshly built document carries.
379
754
  */
380
755
  protected refreshSourceRoot(): void {
381
- if (!this._sourceUri) {
756
+ // The operation captures when it ends.
757
+ if (!this._sourceUri || openOperationOf(this)) {
382
758
  return;
383
759
  }
384
760
  const document = this.sharedServices.model.ModelService.getDocument(this._sourceUri);
@@ -392,10 +768,45 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
392
768
  }
393
769
  }
394
770
 
771
+ /**
772
+ * The root that `uri`'s text parses to (the text store's, or the built
773
+ * document's when the store holds none) and the store's version of that
774
+ * text, read once the document has validated; `undefined` when it cannot be
775
+ * read. The version is read in the tick the text is, so a write based on it
776
+ * conflicts with any edit made after.
777
+ *
778
+ * **Parsed afresh, never the built root.** The built root can lag the
779
+ * store, and a root behind the text it is versioned with makes the replay
780
+ * drop the edits it lacks. The root is parsed, not linked: an encoder hook
781
+ * reading a reference's `ref` sees `undefined` on it.
782
+ */
783
+ protected async readCurrentRoot(uri: string): Promise<{ root: AstNode; version: ModelVersion } | undefined> {
784
+ const modelService = this.sharedServices.model.ModelService;
785
+ // Unreadable here is the conflict the caller then raises, which is logged where it ends; the reason is diagnosis.
786
+ const validated = await modelService.validated(uri).catch((error: unknown) => {
787
+ this.logger.debug(`The current text of ${uri} cannot be read: ${describeError(error)}`);
788
+ return undefined;
789
+ });
790
+ if (!validated) {
791
+ return undefined;
792
+ }
793
+ const store = this.sharedServices.workspace.TextDocuments;
794
+ const text = store.get(uri)?.getText() ?? modelService.getDocument(uri)?.textDocument.getText();
795
+ if (text === undefined) {
796
+ return undefined;
797
+ }
798
+ const version = asModelVersion(store.version(uri));
799
+ return { root: this.sharedServices.workspace.LangiumDocumentFactory.fromString(text, URI.parse(uri)).parseResult.value, version };
800
+ }
801
+
395
802
  /**
396
803
  * Wait until the captured document reaches `state`, then run
397
804
  * {@link onReadyRefreshed} so consumers see a fresh AST. Wraps the
398
805
  * wait in {@link Tracer.time} for observability.
806
+ *
807
+ * A source not in the document registry is built first, through
808
+ * `ModelService.rebuild`. For a source with neither a file nor text, that
809
+ * build leaves no document, and this rejects rather than timing out.
399
810
  */
400
811
  async ready(state: DocumentState): Promise<void> {
401
812
  await this.tracer.time(`Wait for state '${DocumentState[state]}'`, () => this.readyWithTimeout(state), 'debug');
@@ -403,40 +814,43 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
403
814
  }
404
815
 
405
816
  /**
406
- * Race `ModelService.waitForDocumentState` against
407
- * {@link readyTimeoutMs}. If the timeout fires but the document has
408
- * already reached `state` silently (Langium build-phase event race),
409
- * log a warning and resolve; otherwise raise
410
- * {@link ModelReadyTimeoutError} via {@link buildReadyTimeoutError}.
817
+ * Race `ModelService.ensureDocumentState`, which builds a root behind its
818
+ * text, against {@link readyTimeoutMs}. If the timeout fires but the
819
+ * document has already reached `state`, log a warning naming the cause, a
820
+ * root behind its text or a missed phase event, and resolve; otherwise
821
+ * raise {@link ModelReadyTimeoutError} via {@link buildReadyTimeoutError}.
411
822
  */
412
823
  protected async readyWithTimeout(state: DocumentState): Promise<void> {
413
824
  const uri = this._sourceUri;
414
825
  const stopwatch = this.sharedServices.Clock.stopwatch();
415
- let timer: Disposable | undefined;
416
-
417
- const timeout = new Promise<void>((resolve, reject) => {
418
- timer = this.sharedServices.Clock.setTimer(() => {
419
- const elapsed = Math.round(stopwatch.elapsedMs);
420
- // Via the canonicalizing gateway (see `readDocumentVersion`), so a
421
- // symlinked `_sourceUri` is not reported as a hard timeout.
422
- const doc = this.sharedServices.model.ModelService.getDocument(uri);
423
- if (doc && doc.state >= state) {
424
- this.logger.warn(
425
- `Missed '${DocumentState[state]}' notification after ${elapsed}ms; document is already at state ` +
426
- `'${DocumentState[doc.state]}'. Likely a Langium build-phase event race.`
427
- );
428
- resolve();
429
- } else {
430
- reject(this.buildReadyTimeoutError(state, elapsed));
431
- }
432
- }, this.readyTimeoutMs);
433
- });
434
-
435
- try {
436
- await Promise.race([this.sharedServices.model.ModelService.waitForDocumentState(uri, state), timeout]);
437
- } finally {
438
- timer?.dispose();
826
+ const reached = this.sharedServices.model.ModelService.ensureDocumentState(uri, state);
827
+ if ((await this.sharedServices.Clock.raceTimer(reached, this.readyTimeoutMs)) !== TIMED_OUT) {
828
+ return;
829
+ }
830
+ // The race has settled, so a later failure of the wait, such as a given-up sync build, reaches no one else.
831
+ void reached.catch((error: unknown) =>
832
+ this.logger.error(`The wait for '${DocumentState[state]}' failed after it timed out: ${describeErrorWithStack(error)}`)
833
+ );
834
+ const elapsed = Math.round(stopwatch.elapsedMs);
835
+ // Through the model service's canonicalizing gateway, so a symlinked
836
+ // `_sourceUri` is not reported as a hard timeout.
837
+ const doc = this.sharedServices.model.ModelService.getDocument(uri);
838
+ if (!doc || doc.state < state) {
839
+ throw this.buildReadyTimeoutError(state, elapsed);
840
+ }
841
+ const built = this.sharedServices.workspace.ModelLedger.versionOf(doc.parseResult.value);
842
+ const text = this.sharedServices.workspace.TextDocuments.version(uri);
843
+ if (built !== UNRECORDED_VERSION && built < text) {
844
+ this.logger.warn(
845
+ `No build caught the root up after ${elapsed}ms: the document is at state '${DocumentState[doc.state]}', ` +
846
+ `but its root was parsed from v${built} and the text is at v${text}.`
847
+ );
848
+ return;
439
849
  }
850
+ this.logger.warn(
851
+ `Missed '${DocumentState[state]}' notification after ${elapsed}ms; document is already at state ` +
852
+ `'${DocumentState[doc.state]}'. Likely a Langium build-phase event race.`
853
+ );
440
854
  }
441
855
 
442
856
  /**
@@ -466,11 +880,13 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
466
880
  }
467
881
 
468
882
  /**
469
- * Persist a new source-model representation back to the document store
470
- * after an interactive edit. Adopters wire this against their
471
- * language-services facade. The `HydraniumGlspRecordingCommand` calls
472
- * it from its `postChange` hook to commit the edited AST back to the
473
- * document store.
883
+ * Persist a new source-model representation back to the document store after
884
+ * an interactive edit. Adopters wire this against their language-services
885
+ * facade. An operation calls it once, with the model projected from its
886
+ * copies; an undo or redo calls it with the transition resolved onto the
887
+ * current model. An implementation takes the root it wrote through
888
+ * {@link captureWrittenRoot}, which during an operation leaves the capture
889
+ * to the operation.
474
890
  *
475
891
  * Adopters whose source model is a structured transfer projection
476
892
  * round-tripped through `ModelService` should extend
@@ -491,13 +907,52 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
491
907
  * `JsonModelState` shape meet the framework state via structural
492
908
  * intersection at the recording-command call site.
493
909
  *
494
- * `version` is the based-on text-document version captured at command
495
- * start by `HydraniumGlspRecordingCommand.execute`. Adopters that
496
- * route through `ModelService.update` / `.save` pass it as the args'
497
- * `version` field to opt into the conflict gate; adopters that don't
498
- * care (or whose write path doesn't go through `ModelService`) ignore
499
- * the parameter. Undefined when the writer is not the recording-command
500
- * (e.g. external storage refresh paths).
501
- */
502
- abstract updateSourceModel(model: TSourceModel, version?: number): MaybePromise<void>;
910
+ * `baseVersion` is what the write was authored against: an operation's
911
+ * write, and an undo's or redo's, passes the {@link baseVersion} it read
912
+ * the model at. Adopters that write through the diagram's session forward
913
+ * it as the args' `baseVersion` field; adopters whose write path doesn't go
914
+ * through the session ignore it.
915
+ *
916
+ * **Optional, and every implementation must default it to {@link baseVersion}
917
+ * rather than to `'any'`.** Optional is forced: GLSP's `JsonModelState`
918
+ * declares a one-parameter `updateSourceModel` and calls it with one
919
+ * argument, so a second required parameter makes the state unassignable to
920
+ * the slot it has to fill. Defaulting to the state's own model version is
921
+ * what keeps that from costing anything — an operation handler calling
922
+ * `updateSourceModel(model)` gets the gate, and skipping it has to be typed
923
+ * out as `'any'`.
924
+ */
925
+ abstract updateSourceModel(model: TSourceModel, baseVersion?: BaseVersion): MaybePromise<void>;
926
+
927
+ /**
928
+ * `transition` as the documents would hold both ends after a write. A
929
+ * serializer may normalize what it writes, provided normalizing again
930
+ * changes nothing; an end left as recorded makes an undo or redo conflict
931
+ * with the write it follows. Default: unchanged.
932
+ */
933
+ async normalizeTransition(transition: OperationTransition<TSourceModel>): Promise<OperationTransition<TSourceModel>> {
934
+ return transition;
935
+ }
936
+
937
+ /** The root `model` parses back to once serialized for `uri`, as a write would leave it; nothing is written. */
938
+ protected async roundTrip(uri: string, model: TransferElement): Promise<AstNode> {
939
+ const text = await this.sharedServices.model.ModelService.modelToText(uri, model);
940
+ return this.sharedServices.workspace.LangiumDocumentFactory.fromString(text, URI.parse(uri)).parseResult.value;
941
+ }
942
+ }
943
+
944
+ const SEVERITY_RANK: Readonly<Record<NonNullable<DiagramStatusEntry['severity']>, number>> = { INFO: 0, WARNING: 1, ERROR: 2, FATAL: 3 };
945
+
946
+ /** A message without a severity ranks as `INFO`. */
947
+ function severityRank(entry: DiagramStatusEntry): number {
948
+ return SEVERITY_RANK[entry.severity ?? 'INFO'];
949
+ }
950
+
951
+ function describeStatusEntry(entry: DiagramStatusEntry | undefined): string {
952
+ if (entry === undefined) {
953
+ return 'inactive';
954
+ }
955
+ const flags = [entry.message === undefined ? undefined : (entry.severity ?? 'INFO'), entry.readonly ? 'readonly' : undefined];
956
+ const message = entry.message === undefined ? '' : ` "${entry.message}"`;
957
+ return `${flags.filter(flag => flag !== undefined).join(' ') || 'active'}${message}`;
503
958
  }