@hydranium/glsp-server 1.0.0-next.28 → 1.0.0-next.280

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 +77 -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 +249 -0
  19. package/lib/command/hydranium-glsp-operation-command.d.ts.map +1 -0
  20. package/lib/command/hydranium-glsp-operation-command.js +486 -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 +30 -8
  82. package/lib/node/start-glsp-server.d.ts.map +1 -1
  83. package/lib/node/start-glsp-server.js +49 -10
  84. package/lib/node/start-glsp-server.js.map +1 -1
  85. package/lib/state/abstract-hydranium-glsp-state.d.ts +312 -102
  86. package/lib/state/abstract-hydranium-glsp-state.d.ts.map +1 -1
  87. package/lib/state/abstract-hydranium-glsp-state.js +505 -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 +15 -8
  94. package/lib/state/full-text-hydranium-glsp-state.d.ts.map +1 -1
  95. package/lib/state/full-text-hydranium-glsp-state.js +26 -16
  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 +10 -27
  112. package/lib/state/reconcile-source-model-write.d.ts.map +1 -1
  113. package/lib/state/reconcile-source-model-write.js +39 -32
  114. package/lib/state/reconcile-source-model-write.js.map +1 -1
  115. package/lib/state/reconciling-multi-document-glsp-state.d.ts +123 -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 +239 -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 +35 -27
  120. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts.map +1 -1
  121. package/lib/state/reconciling-transfer-hydranium-glsp-state.js +57 -41
  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 +322 -143
  144. package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
  145. package/lib/storage/hydranium-glsp-storage.js +563 -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 +46 -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 +29 -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 +598 -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 +60 -14
  193. package/src/state/abstract-hydranium-glsp-state.ts +584 -154
  194. package/src/state/diagram-status.ts +41 -0
  195. package/src/state/full-text-hydranium-glsp-state.ts +27 -17
  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 +44 -53
  200. package/src/state/reconciling-multi-document-glsp-state.ts +273 -102
  201. package/src/state/reconciling-transfer-hydranium-glsp-state.ts +65 -44
  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 +639 -223
  208. package/src/storage/index.ts +2 -1
  209. package/src/storage/save-delivery-policy.ts +44 -0
  210. package/src/submission/hydranium-glsp-submission-handler.ts +91 -5
  211. package/src/testing/glsp-harness.ts +62 -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,198 @@ 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
+ maxWrites: this.maxSourceModelWrites
153
237
  });
154
238
  }
155
239
 
156
240
  /**
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.
241
+ * Write hook — every document of `model` that changed, opened first through
242
+ * {@link openForWrite}, in one `updateAll` on the diagram's session: the
243
+ * primary gated on `baseVersion`, each secondary on its entry in
244
+ * `secondaryVersions` (a refetch's) or else {@link secondaryBaseVersion}, or
245
+ * every document on `'any'` when `baseVersion` is `'any'`. Resolves to
246
+ * the primary's root, the one already captured when the primary did not
247
+ * change. A write that bypasses `updateAll` gives up the all-or-none
248
+ * guarantee the class describes. Throws without a session
249
+ * ({@link requireModelSession}). An override that serializes otherwise than
250
+ * `ModelService.modelToText` overrides {@link normalizeTransition} alongside.
251
+ *
252
+ * A write that fails closes every document {@link createSecondaryDocument}
253
+ * created for it that {@link closesCreatedDocument} accepts.
161
254
  */
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.
255
+ protected async persist(
256
+ model: MultiDocumentSourceModel<TPrimary>,
257
+ baseVersion: BaseVersion,
258
+ secondaryVersions?: Readonly<Record<string, ModelVersion>>
259
+ ): Promise<{ root: TRoot }> {
260
+ const primaryChanged = this.hasChanged(this.base.primary, model.primary);
261
+ const changedSecondaries = Object.entries(model.secondaries).filter(([uri, secondary]) =>
262
+ this.hasChanged(this.base.secondaries[uri], secondary)
263
+ );
264
+ if (!primaryChanged && changedSecondaries.length === 0) {
171
265
  return { root: this._sourceRoot };
172
266
  }
173
- return this.persistPrimary(model.primary, baseVersion);
267
+ const session = this.requireModelSession();
268
+ this.createdForWrite.clear();
269
+ try {
270
+ if (primaryChanged) {
271
+ await this.openForWrite(session, this._sourceUri);
272
+ }
273
+ for (const [uri] of changedSecondaries) {
274
+ await this.openForWrite(session, uri);
275
+ }
276
+ // Based after the opens, so a secondary created there is gated on the version it was created at.
277
+ const updates: ClientSessionWriteArgs<TransferElement>[] = primaryChanged
278
+ ? [{ uri: this._sourceUri, model: model.primary, baseVersion }]
279
+ : [];
280
+ for (const [uri, secondary] of changedSecondaries) {
281
+ updates.push({
282
+ uri,
283
+ model: secondary,
284
+ baseVersion: baseVersion === 'any' ? 'any' : (secondaryVersions?.[uri] ?? this.secondaryBaseVersion(uri))
285
+ });
286
+ }
287
+ const documents = await session.updateAll({ updates });
288
+ return { root: primaryChanged ? (documents[0].root as unknown as TRoot) : this._sourceRoot };
289
+ } catch (error: unknown) {
290
+ for (const [uri, created] of this.createdForWrite) {
291
+ try {
292
+ if (this.closesCreatedDocument(uri, created)) {
293
+ await session.close(uri);
294
+ }
295
+ } catch (closeError: unknown) {
296
+ this.logger.warn(`Could not close ${uri}, created for a write that failed: ${describeErrorWithStack(closeError)}`);
297
+ }
298
+ }
299
+ throw error;
300
+ } finally {
301
+ this.createdForWrite.clear();
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Whether a write that failed closes `uri`, which
307
+ * {@link createSecondaryDocument} created for it at `createdVersion`.
308
+ * Default: while it still holds the text it was created with. Left open, it
309
+ * would stay in the diagram's session, and the diagram's next save would
310
+ * write it to disk though no write landed in it. One whose text moved on,
311
+ * because the set failed after applying or another client wrote to it,
312
+ * stays open: closing it would drop that text.
313
+ */
314
+ protected closesCreatedDocument(uri: string, createdVersion: TextVersion): boolean {
315
+ return this.sharedServices.workspace.TextDocuments.version(uri) === createdVersion;
174
316
  }
175
317
 
176
318
  /**
177
- * Whether `candidate` differs from the baseline projection of the same
319
+ * Whether `candidate` differs from the base projection of the same
178
320
  * document, and therefore needs writing.
179
321
  *
180
322
  * **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.
323
+ * write re-serializes from the AST, so writing a document that did not
324
+ * change still rewrites its text in the serializer's own layout. Persisting
325
+ * the whole write set unconditionally therefore means a pure layout drag
326
+ * reflows the semantic file, a change the user never asked for and would
327
+ * struggle to attribute. Comments survive that rewrite, but the
328
+ * hand-formatting around them does not.
187
329
  *
188
330
  * Compared by serialised form. Both sides come from the same encoder walking
189
331
  * the same shape, so key order is stable and a string compare is sound here;
190
332
  * it is also cheap enough to run per document per write.
191
333
  */
192
- protected hasChanged(baseline: object | undefined, candidate: object): boolean {
193
- return baseline === undefined || JSON.stringify(baseline) !== JSON.stringify(candidate);
334
+ protected hasChanged(base: object | undefined, candidate: object): boolean {
335
+ return base === undefined || JSON.stringify(base) !== JSON.stringify(candidate);
194
336
  }
195
337
 
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 };
338
+ /**
339
+ * Make sure the diagram's session has `uri` open before the write set is
340
+ * written. Default: open it, a no-op for a document the session already
341
+ * has open. Override to create a document the write set names before it
342
+ * exists, through {@link createSecondaryDocument}: `updateAll` opens nothing.
343
+ */
344
+ protected openForWrite(session: ClientSession<AstNode>, uri: string): Promise<void> {
345
+ return session.open(uri);
205
346
  }
206
347
 
207
348
  /**
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.
349
+ * Create the secondary `uri` holding `text` through `session.create`, and
350
+ * base its write on the version the created document took. The version
351
+ * recorded while it did not exist matches no write, so the write conflicts.
211
352
  */
212
- protected async persistSecondary(uri: string, model: TransferElement): Promise<void> {
213
- await this.sharedServices.model.ModelService.update({ uri, model, clientId: this.clientId });
353
+ protected async createSecondaryDocument(session: ClientSession<AstNode>, uri: string, text: string): Promise<void> {
354
+ const created = await session.create(uri, text);
355
+ this.createdForWrite.set(uri, created);
356
+ if (this._secondaryVersions.has(uri)) {
357
+ this._secondaryVersions.set(uri, asModelVersion(created));
358
+ }
214
359
  }
215
360
 
216
361
  /**
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.
362
+ * What a secondary write declares it was based on. Default: the version the
363
+ * secondary had when the source root was last read
364
+ * ({@link AbstractHydraniumGlspState.baseVersionOf}), so a foreign edit
365
+ * to it since is reported as a conflict and reconciled rather than
366
+ * overwritten; `'any'` for a secondary with no recorded version.
367
+ * Return `'any'` to force secondary writes.
222
368
  */
223
- protected async refetch(): Promise<MultiDocumentSourceModel<TPrimary> | undefined> {
224
- const fresh = await this.sharedServices.model.ModelService.validated(this._sourceUri).catch(() => undefined);
225
- if (!fresh) {
369
+ protected secondaryBaseVersion(uri: string): BaseVersion {
370
+ return this.baseVersionOf(uri) ?? 'any';
371
+ }
372
+
373
+ /**
374
+ * Refetch hook — the current projection across the write set, each document
375
+ * read through {@link AbstractHydraniumGlspState.readCurrentRoot}, with the
376
+ * versions read alongside: theirs, which the conflict resolver replays the
377
+ * user's intent onto. Returns `undefined` when the PRIMARY cannot be read,
378
+ * since a reconcile without it has nothing to merge into; an unreadable
379
+ * secondary is omitted the same way {@link sourceModel} omits one.
380
+ */
381
+ protected async refetch(): Promise<VersionedMultiDocumentSourceModel<TPrimary> | undefined> {
382
+ const theirs = await this.readCurrentRoot(this._sourceUri);
383
+ if (!theirs) {
226
384
  return undefined;
227
385
  }
228
386
  const secondaries: Record<string, TransferElement> = {};
387
+ const secondaryVersions: Record<string, ModelVersion> = {};
229
388
  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);
389
+ const read = await this.readCurrentRoot(uri);
390
+ if (read) {
391
+ secondaries[uri] = this.projectRoot(read.root);
392
+ secondaryVersions[uri] = read.version;
233
393
  }
234
394
  }
235
- return { primary: this.projectRoot(fresh.root as unknown as AstNode) as TPrimary, secondaries };
395
+ return { model: { primary: this.projectRoot<TPrimary>(theirs.root), secondaries }, baseVersion: theirs.version, secondaryVersions };
236
396
  }
237
397
 
238
- /** Project a currently-loaded document's root, or `undefined` when it is not loaded. */
398
+ /**
399
+ * Project a currently-loaded document's root, or `undefined` when it is not
400
+ * loaded: the open operation's copy of it when there is one, which is where
401
+ * a handler's edit of it is, else the root its version was read from.
402
+ */
239
403
  protected projectDocument(uri: string): TransferElement | undefined {
240
- const root = this.sharedServices.model.ModelService.getDocument(uri)?.parseResult?.value;
404
+ const root =
405
+ this.existingWorkingRootOf(uri) ??
406
+ this.capturedRootOf(uri) ??
407
+ this.sharedServices.model.ModelService.getDocument(uri)?.parseResult?.value;
241
408
  return root ? this.projectRoot(root) : undefined;
242
409
  }
243
410
 
@@ -246,3 +413,7 @@ export class ReconcilingMultiDocumentGlspState<TRoot extends AstNode, TPrimary e
246
413
  return this.sharedServices.model.TransferEncoder.toTransfer(root, 'grammar') as unknown as T;
247
414
  }
248
415
  }
416
+
417
+ function isMultiDocumentModel(value: object): value is MultiDocumentSourceModel {
418
+ return 'secondaries' in value && typeof value.secondaries === 'object' && value.secondaries !== null;
419
+ }