@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
@@ -10,8 +10,18 @@
10
10
  import { type JsonModelState } from '@eclipse-glsp/server';
11
11
  import { injectable } from 'inversify';
12
12
  import { type AstNode } from '@hydranium/langium';
13
- import { type TransferElement } from '@hydranium/protocol';
13
+ import { type ClientSession, type ClientSessionWriteArgs } from '@hydranium/core';
14
+ import {
15
+ asModelVersion,
16
+ type BaseVersion,
17
+ type ModelVersion,
18
+ type TextVersion,
19
+ type TransferElement,
20
+ type VersionedModel,
21
+ describeErrorWithStack
22
+ } from '@hydranium/protocol';
14
23
  import { AbstractHydraniumGlspState } from './abstract-hydranium-glsp-state.js';
24
+ import { type OperationTransition, openOperationOf } from '../command/hydranium-glsp-operation-command.js';
15
25
  import { reconcileSourceModelWrite } from './reconcile-source-model-write.js';
16
26
 
17
27
  /**
@@ -31,6 +41,13 @@ export interface MultiDocumentSourceModel<TPrimary extends TransferElement = Tra
31
41
  secondaries: Record<string, TransferElement>;
32
42
  }
33
43
 
44
+ /** A write set, with the version of each secondary's text it was projected from, keyed by URI. */
45
+ export interface VersionedMultiDocumentSourceModel<TPrimary extends TransferElement = TransferElement> extends VersionedModel<
46
+ MultiDocumentSourceModel<TPrimary>
47
+ > {
48
+ readonly secondaryVersions: Readonly<Record<string, ModelVersion>>;
49
+ }
50
+
34
51
  /**
35
52
  * Editable GLSP state for a diagram whose edits span SEVERAL Langium documents —
36
53
  * a diagram file plus the semantic file it references, say, where creating a node
@@ -44,35 +61,23 @@ export interface MultiDocumentSourceModel<TPrimary extends TransferElement = Tra
44
61
  * defines them over the primary plus the secondary write set registered through
45
62
  * {@link AbstractHydraniumGlspState.trackSecondaryDocument}.
46
63
  *
47
- * **Writes are NOT atomic, deliberately, and the window is real.** Each document
48
- * goes through its own `ModelService.update`, so a failure after the first write
49
- * has landed leaves the set inconsistent on disk. There is no multi-document
50
- * transaction to lean on: `WorkspaceLock` serialises builds but does not roll
51
- * back, and the document store commits per URI. An adopter that cannot accept the
52
- * window should not span documents in one operation.
64
+ * **The write set is written all or none.** {@link persist} hands every
65
+ * changed document to one `ClientSession.updateAll` on the diagram's session,
66
+ * so a conflict on any document leaves every document of the set as it was,
67
+ * and write order carries no meaning.
53
68
  *
54
- * **What makes the window tolerable is write ORDER, and the rule is
55
- * referenced-before-referencing** — not "secondaries first" as such. Write the
56
- * document whose content the others point AT before the documents that point at
57
- * it, so a partial write leaves an unreferenced element (inert, and the direction
58
- * an integrity rule can repair) rather than a reference to something that does
59
- * not exist (a linking error). {@link persist} implements that as
60
- * secondaries-then-primary, which is correct for the common shape where the
61
- * primary is the DIAGRAM and the semantics it references are secondaries: the
62
- * user opened the diagram, so it is also the document the conflict gate should
63
- * guard. **An adopter whose primary is the referenced document — a semantic file
64
- * as the diagram source, with layout in a secondary — has the ordering backwards
65
- * and must override {@link persist} to write the primary first.** Overriding it
66
- * is the supported route; the base order is a default for the common case, not an
67
- * invariant of the class.
69
+ * **Every document of the set is gated.** The primary on the caller's
70
+ * `baseVersion`, each secondary on {@link secondaryBaseVersion}, by default the version
71
+ * it had when the source root was last read. A conflict on any of them is
72
+ * reconciled against the whole write set, and the merged retry is gated on
73
+ * the versions its refetch read. A write based on `'any'` — an undo or redo
74
+ * applying a recorded patch — forces every document of the set.
68
75
  *
69
- * **The conflict gate covers the primary only.** Secondaries are written without
70
- * a based-on version, so a concurrent foreign edit to one is overwritten rather
71
- * than reconciled. Gating them too would need a per-document reconcile whose
72
- * outcomes can disagree (merge one, conflict another) with no way to un-write the
73
- * merged one — the atomicity problem again, one layer up. The captured versions
74
- * ARE available via {@link AbstractHydraniumGlspState.capturedVersionOf}, so an
75
- * adopter that wants a coarser check can compare before writing.
76
+ * A secondary is written only while the diagram's session has it open. The
77
+ * storage opens it as it joins the write set, and {@link openForWrite} opens it
78
+ * again before each write; an adopter whose write set can name a document that
79
+ * does not exist yet overrides {@link openForWrite} to create it through
80
+ * {@link createSecondaryDocument}.
76
81
  */
77
82
  @injectable()
78
83
  export class ReconcilingMultiDocumentGlspState<TRoot extends AstNode, TPrimary extends TransferElement = TransferElement>
@@ -80,11 +85,14 @@ export class ReconcilingMultiDocumentGlspState<TRoot extends AstNode, TPrimary e
80
85
  implements JsonModelState<MultiDocumentSourceModel<TPrimary>>
81
86
  {
82
87
  /**
83
- * Last in-sync projection across the whole write set, captured on every
84
- * {@link setSourceRoot}. Same role as the single-document baseline: the state a
85
- * forward-write conflict reconciles the user's intent against.
88
+ * The projection across the whole write set that a forward-write conflict
89
+ * reconciles the user's intent from, taken from the built roots on every
90
+ * capture.
86
91
  */
87
- protected baseline?: MultiDocumentSourceModel<TPrimary>;
92
+ protected base!: MultiDocumentSourceModel<TPrimary>;
93
+
94
+ /** The documents {@link createSecondaryDocument} created for the {@link persist} under way, with the version each was created at. */
95
+ protected readonly createdForWrite = new Map<string, TextVersion>();
88
96
 
89
97
  /**
90
98
  * Projection of the primary plus every registered secondary, in the framework
@@ -105,27 +113,96 @@ export class ReconcilingMultiDocumentGlspState<TRoot extends AstNode, TPrimary e
105
113
  secondaries[uri] = projection;
106
114
  }
107
115
  }
108
- return { primary: this.projectRoot(this._sourceRoot), secondaries };
116
+ return { primary: this.projectRoot(this.sourceRoot), secondaries };
109
117
  }
110
118
 
111
- override setSourceRoot(uri: string, root: TRoot): void {
112
- super.setSourceRoot(uri, root);
119
+ protected override captureSourceRoot(uri: string, root: TRoot): void {
120
+ super.captureSourceRoot(uri, root);
113
121
  this.trackWriteSet(uri);
114
- this.baseline = this.sourceModel;
122
+ this.base = this.sourceModel;
123
+ }
124
+
125
+ /**
126
+ * Each document whose ends differ, either end serialized for its URI and
127
+ * parsed back; a document equal on both ends is left as it is, since it
128
+ * stays out of the transition's patch anyway.
129
+ */
130
+ override async normalizeTransition(
131
+ transition: OperationTransition<MultiDocumentSourceModel<TPrimary>>
132
+ ): Promise<OperationTransition<MultiDocumentSourceModel<TPrimary>>> {
133
+ const from = { primary: transition.from.primary, secondaries: { ...transition.from.secondaries } };
134
+ const to = { primary: transition.to.primary, secondaries: { ...transition.to.secondaries } };
135
+ if (JSON.stringify(from.primary) !== JSON.stringify(to.primary)) {
136
+ from.primary = this.projectRoot<TPrimary>(await this.roundTrip(this._sourceUri, from.primary));
137
+ to.primary = this.projectRoot<TPrimary>(await this.roundTrip(this._sourceUri, to.primary));
138
+ }
139
+ for (const uri of new Set([...Object.keys(from.secondaries), ...Object.keys(to.secondaries)])) {
140
+ if (JSON.stringify(from.secondaries[uri]) === JSON.stringify(to.secondaries[uri])) {
141
+ continue;
142
+ }
143
+ for (const end of [from, to]) {
144
+ const secondary = end.secondaries[uri];
145
+ if (secondary !== undefined) {
146
+ end.secondaries[uri] = this.projectRoot(await this.roundTrip(uri, secondary));
147
+ }
148
+ }
149
+ }
150
+ return { from, to };
151
+ }
152
+
153
+ /**
154
+ * Also takes a secondary first tracked during an operation into the base
155
+ * and into the operation's projection from before its command, both from
156
+ * the root its version was read from: a conflict on it reconciles from the
157
+ * revision the operation's copy describes, and undoing the operation
158
+ * restores it. A document that does not exist yet has no projection to
159
+ * restore, so an undo leaves what the operation wrote into it.
160
+ */
161
+ override trackSecondaryDocument(uri: string): void {
162
+ const added = uri !== this._sourceUri && !this.secondaryUris.includes(uri);
163
+ super.trackSecondaryDocument(uri);
164
+ const root = this.capturedRootOf(uri);
165
+ if (root === undefined) {
166
+ return;
167
+ }
168
+ const projection = this.projectRoot(root);
169
+ if (this.base !== undefined && !(uri in this.base.secondaries)) {
170
+ this.base = { ...this.base, secondaries: { ...this.base.secondaries, [uri]: projection } };
171
+ }
172
+ if (added) {
173
+ openOperationOf(this)?.amendBefore(before =>
174
+ isMultiDocumentModel(before) ? { ...before, secondaries: { ...before.secondaries, [uri]: projection } } : before
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * During an operation, also drops the untracked documents from its
181
+ * projection from before its command: they are not written, so the
182
+ * operation's undo and redo leave them as they are.
183
+ */
184
+ override untrackSecondaryDocuments(): void {
185
+ const untracked = this.secondaryUris;
186
+ super.untrackSecondaryDocuments();
187
+ openOperationOf(this)?.amendBefore(before =>
188
+ isMultiDocumentModel(before)
189
+ ? { ...before, secondaries: Object.fromEntries(Object.entries(before.secondaries).filter(([uri]) => !untracked.includes(uri))) }
190
+ : before
191
+ );
115
192
  }
116
193
 
117
194
  /**
118
195
  * Register the secondary documents that belong to the primary at `uri`, via
119
196
  * {@link AbstractHydraniumGlspState.trackSecondaryDocument}. Called on every
120
- * {@link setSourceRoot}, after the primary is captured (so `sourceUri` is
121
- * current) and BEFORE the baseline is taken (so the baseline includes them).
197
+ * capture, after the primary is captured (so `sourceUri` is current) and
198
+ * BEFORE the base is taken (so the base includes them).
122
199
  * Default: no secondaries.
123
200
  *
124
201
  * This hook exists because that ordering is a trap an adopter would otherwise
125
- * hit silently. Registering from an overridden `setSourceRoot` *after*
126
- * `super.setSourceRoot(...)` runs too late — the baseline has already been
202
+ * hit silently. Registering from an overridden `captureSourceRoot` *after*
203
+ * `super.captureSourceRoot(...)` runs too late — the base has already been
127
204
  * captured without the secondaries, so the first conflict reconcile measures
128
- * the user's intent against a baseline missing half the write set and the
205
+ * the user's intent against a base missing half the write set and the
129
206
  * secondary edits look like foreign changes. Registering *before* the super
130
207
  * call is too early for a URI derived from the new primary. Overriding this
131
208
  * instead removes the choice.
@@ -136,108 +213,201 @@ export class ReconcilingMultiDocumentGlspState<TRoot extends AstNode, TPrimary e
136
213
 
137
214
  /**
138
215
  * Persist the whole write set, then capture the resulting primary root. On a
139
- * `ConflictError` from the primary, reconcile via the injected policy exactly
140
- * as the single-document state does — the orchestration is shared.
216
+ * `ConflictError` from any document of the set, reconcile via the injected
217
+ * policy exactly as the single-document state does — the orchestration is
218
+ * shared.
141
219
  */
142
- async updateSourceModel(model: MultiDocumentSourceModel<TPrimary>, version?: number): Promise<void> {
143
- return reconcileSourceModelWrite<MultiDocumentSourceModel<TPrimary>>(model, version, {
144
- persist: async (candidate, baseVersion) => {
145
- const { root } = await this.persist(candidate, baseVersion);
146
- this.setSourceRoot(this._sourceUri, root);
220
+ async updateSourceModel(model: MultiDocumentSourceModel<TPrimary>, baseVersion: BaseVersion = this.baseVersion): Promise<void> {
221
+ let secondaryVersions: Readonly<Record<string, ModelVersion>> | undefined;
222
+ return reconcileSourceModelWrite<MultiDocumentSourceModel<TPrimary>>(model, baseVersion, {
223
+ persist: async (candidate, candidateBaseVersion) => {
224
+ const { root } = await this.persist(candidate, candidateBaseVersion, secondaryVersions);
225
+ this.captureWrittenRoot(root);
226
+ },
227
+ refetch: async () => {
228
+ const refetched = await this.refetch();
229
+ secondaryVersions = refetched?.secondaryVersions;
230
+ return refetched;
147
231
  },
148
- refetch: () => this.refetch(),
149
- baseline: this.baseline,
232
+ base: this.base,
150
233
  conflictResolver: this.conflictResolver,
151
234
  logger: this.logger,
152
- onConflictDropped: () => this.refreshSourceRoot()
235
+ onConflictDropped: () => this.writeDropped(),
236
+ onAlreadyApplied: () => this.captureBuiltRoot(),
237
+ maxWrites: this.maxSourceModelWrites
153
238
  });
154
239
  }
155
240
 
156
241
  /**
157
- * Write hook — secondaries first (ungated), primary last (gated on
158
- * `baseVersion`). The order is the failure-mode choice documented on the
159
- * class, not incidental: override this when the primary is the document the
160
- * others REFERENCE, since then this order writes the references first.
242
+ * Write hook — every document of `model` that changed, opened first through
243
+ * {@link openForWrite}, in one `updateAll` on the diagram's session: the
244
+ * primary gated on `baseVersion`, each secondary on its entry in
245
+ * `secondaryVersions` (a refetch's) or else {@link secondaryBaseVersion}, or
246
+ * every document on `'any'` when `baseVersion` is `'any'`. Resolves to
247
+ * the primary's root, the one already captured when the primary did not
248
+ * change. A write that bypasses `updateAll` gives up the all-or-none
249
+ * guarantee the class describes. Throws without a session
250
+ * ({@link requireModelSession}). An override that serializes otherwise than
251
+ * `ModelService.modelToText` overrides {@link normalizeTransition} alongside.
252
+ *
253
+ * A write that fails closes every document {@link createSecondaryDocument}
254
+ * created for it that {@link closesCreatedDocument} accepts.
161
255
  */
162
- protected async persist(model: MultiDocumentSourceModel<TPrimary>, baseVersion?: number): Promise<{ root: TRoot }> {
163
- for (const [uri, secondary] of Object.entries(model.secondaries)) {
164
- if (this.hasChanged(this.baseline?.secondaries[uri], secondary)) {
165
- await this.persistSecondary(uri, secondary);
166
- }
167
- }
168
- if (!this.hasChanged(this.baseline?.primary, model.primary)) {
169
- // Nothing to write, so nothing to gate either — return the root already
170
- // captured rather than round-tripping the document for no reason.
256
+ protected async persist(
257
+ model: MultiDocumentSourceModel<TPrimary>,
258
+ baseVersion: BaseVersion,
259
+ secondaryVersions?: Readonly<Record<string, ModelVersion>>
260
+ ): Promise<{ root: TRoot }> {
261
+ const primaryChanged = this.hasChanged(this.base.primary, model.primary);
262
+ const changedSecondaries = Object.entries(model.secondaries).filter(([uri, secondary]) =>
263
+ this.hasChanged(this.base.secondaries[uri], secondary)
264
+ );
265
+ if (!primaryChanged && changedSecondaries.length === 0) {
171
266
  return { root: this._sourceRoot };
172
267
  }
173
- return this.persistPrimary(model.primary, baseVersion);
268
+ const session = this.requireModelSession();
269
+ this.createdForWrite.clear();
270
+ try {
271
+ if (primaryChanged) {
272
+ await this.openForWrite(session, this._sourceUri);
273
+ }
274
+ for (const [uri] of changedSecondaries) {
275
+ await this.openForWrite(session, uri);
276
+ }
277
+ // Based after the opens, so a secondary created there is gated on the version it was created at.
278
+ const updates: ClientSessionWriteArgs<TransferElement>[] = primaryChanged
279
+ ? [{ uri: this._sourceUri, model: model.primary, baseVersion }]
280
+ : [];
281
+ for (const [uri, secondary] of changedSecondaries) {
282
+ updates.push({
283
+ uri,
284
+ model: secondary,
285
+ baseVersion: baseVersion === 'any' ? 'any' : (secondaryVersions?.[uri] ?? this.secondaryBaseVersion(uri))
286
+ });
287
+ }
288
+ const documents = await session.updateAll({ updates });
289
+ return { root: primaryChanged ? (documents[0].root as unknown as TRoot) : this._sourceRoot };
290
+ } catch (error: unknown) {
291
+ for (const [uri, created] of this.createdForWrite) {
292
+ try {
293
+ if (this.closesCreatedDocument(uri, created)) {
294
+ await session.close(uri);
295
+ }
296
+ } catch (closeError: unknown) {
297
+ this.logger.warn(`Could not close ${uri}, created for a write that failed: ${describeErrorWithStack(closeError)}`);
298
+ }
299
+ }
300
+ throw error;
301
+ } finally {
302
+ this.createdForWrite.clear();
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Whether a write that failed closes `uri`, which
308
+ * {@link createSecondaryDocument} created for it at `createdVersion`.
309
+ * Default: while it still holds the text it was created with. Left open, it
310
+ * would stay in the diagram's session, and the diagram's next save would
311
+ * write it to disk though no write landed in it. One whose text moved on,
312
+ * because the set failed after applying or another client wrote to it,
313
+ * stays open: closing it would drop that text.
314
+ */
315
+ protected closesCreatedDocument(uri: string, createdVersion: TextVersion): boolean {
316
+ return this.sharedServices.workspace.TextDocuments.version(uri) === createdVersion;
174
317
  }
175
318
 
176
319
  /**
177
- * Whether `candidate` differs from the baseline projection of the same
320
+ * Whether `candidate` differs from the base projection of the same
178
321
  * document, and therefore needs writing.
179
322
  *
180
323
  * **Skipping unchanged documents is correctness, not an optimisation.** A
181
- * write goes through `ModelService.update`, which re-serializes from the AST —
182
- * so writing a document that did not change still rewrites its text, and a
183
- * serializer is free to normalise formatting and cannot preserve comments.
184
- * Persisting the whole write set unconditionally therefore means a pure layout
185
- * drag reflows the semantic file and strips its comments, which is a data
186
- * loss the user never asked for and would struggle to attribute.
324
+ * write re-serializes from the AST, so writing a document that did not
325
+ * change still rewrites its text in the serializer's own layout. Persisting
326
+ * the whole write set unconditionally therefore means a pure layout drag
327
+ * reflows the semantic file, a change the user never asked for and would
328
+ * struggle to attribute. Comments survive that rewrite, but the
329
+ * hand-formatting around them does not.
187
330
  *
188
331
  * Compared by serialised form. Both sides come from the same encoder walking
189
332
  * the same shape, so key order is stable and a string compare is sound here;
190
333
  * it is also cheap enough to run per document per write.
191
334
  */
192
- protected hasChanged(baseline: object | undefined, candidate: object): boolean {
193
- return baseline === undefined || JSON.stringify(baseline) !== JSON.stringify(candidate);
335
+ protected hasChanged(base: object | undefined, candidate: object): boolean {
336
+ return base === undefined || JSON.stringify(base) !== JSON.stringify(candidate);
194
337
  }
195
338
 
196
- /** Write the primary document, opting into the conflict gate when `baseVersion` is given. */
197
- protected async persistPrimary(model: TPrimary, baseVersion?: number): Promise<{ root: TRoot }> {
198
- const document = await this.sharedServices.model.ModelService.update({
199
- uri: this._sourceUri,
200
- model,
201
- clientId: this.clientId,
202
- baseVersion
203
- });
204
- return document as unknown as { root: TRoot };
339
+ /**
340
+ * Make sure the diagram's session has `uri` open before the write set is
341
+ * written. Default: open it, a no-op for a document the session already
342
+ * has open. Override to create a document the write set names before it
343
+ * exists, through {@link createSecondaryDocument}: `updateAll` opens nothing.
344
+ */
345
+ protected openForWrite(session: ClientSession<AstNode>, uri: string): Promise<void> {
346
+ return session.open(uri);
205
347
  }
206
348
 
207
349
  /**
208
- * Write one secondary document. Ungated by design (see the class doc); override
209
- * alongside {@link persistPrimary} when the round-trip differs per document
210
- * role — a layout file and a semantic file need not share a serializer.
350
+ * Create the secondary `uri` holding `text` through `session.create`, and
351
+ * base its write on the version the created document took. The version
352
+ * recorded while it did not exist matches no write, so the write conflicts.
353
+ * Make `text` with `ModelService.modelToText`, not the bare serializer,
354
+ * which leaves the file without the ending a write would give it.
211
355
  */
212
- protected async persistSecondary(uri: string, model: TransferElement): Promise<void> {
213
- await this.sharedServices.model.ModelService.update({ uri, model, clientId: this.clientId });
356
+ protected async createSecondaryDocument(session: ClientSession<AstNode>, uri: string, text: string): Promise<void> {
357
+ const created = await session.create(uri, text);
358
+ this.createdForWrite.set(uri, created);
359
+ if (this._secondaryVersions.has(uri)) {
360
+ this._secondaryVersions.set(uri, asModelVersion(created));
361
+ }
214
362
  }
215
363
 
216
364
  /**
217
- * Refetch hook — the current settled projection across the write set, used by
218
- * the conflict resolver to replay the user's intent against fresh state.
219
- * Returns `undefined` when the PRIMARY cannot be read, since a reconcile
220
- * without it has nothing to merge into; an unreadable secondary is omitted the
221
- * same way {@link sourceModel} omits one.
365
+ * What a secondary write declares it was based on. Default: the version the
366
+ * secondary had when the source root was last read
367
+ * ({@link AbstractHydraniumGlspState.baseVersionOf}), so a foreign edit
368
+ * to it since is reported as a conflict and reconciled rather than
369
+ * overwritten; `'any'` for a secondary with no recorded version.
370
+ * Return `'any'` to force secondary writes.
222
371
  */
223
- protected async refetch(): Promise<MultiDocumentSourceModel<TPrimary> | undefined> {
224
- const fresh = await this.sharedServices.model.ModelService.validated(this._sourceUri).catch(() => undefined);
225
- if (!fresh) {
372
+ protected secondaryBaseVersion(uri: string): BaseVersion {
373
+ return this.baseVersionOf(uri) ?? 'any';
374
+ }
375
+
376
+ /**
377
+ * Refetch hook — the current projection across the write set, each document
378
+ * read through {@link AbstractHydraniumGlspState.readCurrentRoot}, with the
379
+ * versions read alongside: theirs, which the conflict resolver replays the
380
+ * user's intent onto. Returns `undefined` when the PRIMARY cannot be read,
381
+ * since a reconcile without it has nothing to merge into; an unreadable
382
+ * secondary is omitted the same way {@link sourceModel} omits one.
383
+ */
384
+ protected async refetch(): Promise<VersionedMultiDocumentSourceModel<TPrimary> | undefined> {
385
+ const theirs = await this.readCurrentRoot(this._sourceUri);
386
+ if (!theirs) {
226
387
  return undefined;
227
388
  }
228
389
  const secondaries: Record<string, TransferElement> = {};
390
+ const secondaryVersions: Record<string, ModelVersion> = {};
229
391
  for (const uri of this.secondaryUris) {
230
- const settled = await this.sharedServices.model.ModelService.validated(uri).catch(() => undefined);
231
- if (settled) {
232
- secondaries[uri] = this.projectRoot(settled.root as unknown as AstNode);
392
+ const read = await this.readCurrentRoot(uri);
393
+ if (read) {
394
+ secondaries[uri] = this.projectRoot(read.root);
395
+ secondaryVersions[uri] = read.version;
233
396
  }
234
397
  }
235
- return { primary: this.projectRoot(fresh.root as unknown as AstNode) as TPrimary, secondaries };
398
+ return { model: { primary: this.projectRoot<TPrimary>(theirs.root), secondaries }, baseVersion: theirs.version, secondaryVersions };
236
399
  }
237
400
 
238
- /** Project a currently-loaded document's root, or `undefined` when it is not loaded. */
401
+ /**
402
+ * Project a currently-loaded document's root, or `undefined` when it is not
403
+ * loaded: the open operation's copy of it when there is one, which is where
404
+ * a handler's edit of it is, else the root its version was read from.
405
+ */
239
406
  protected projectDocument(uri: string): TransferElement | undefined {
240
- const root = this.sharedServices.model.ModelService.getDocument(uri)?.parseResult?.value;
407
+ const root =
408
+ this.existingWorkingRootOf(uri) ??
409
+ this.capturedRootOf(uri) ??
410
+ this.sharedServices.model.ModelService.getDocument(uri)?.parseResult?.value;
241
411
  return root ? this.projectRoot(root) : undefined;
242
412
  }
243
413
 
@@ -246,3 +416,7 @@ export class ReconcilingMultiDocumentGlspState<TRoot extends AstNode, TPrimary e
246
416
  return this.sharedServices.model.TransferEncoder.toTransfer(root, 'grammar') as unknown as T;
247
417
  }
248
418
  }
419
+
420
+ function isMultiDocumentModel(value: object): value is MultiDocumentSourceModel {
421
+ return 'secondaries' in value && typeof value.secondaries === 'object' && value.secondaries !== null;
422
+ }