@hydranium/glsp-server 1.0.0-next.7 → 1.0.0-next.71

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 (36) hide show
  1. package/README.md +11 -2
  2. package/lib/launcher/hydranium-glsp-server.d.ts +4 -3
  3. package/lib/launcher/hydranium-glsp-server.d.ts.map +1 -1
  4. package/lib/launcher/hydranium-glsp-server.js +4 -3
  5. package/lib/launcher/hydranium-glsp-server.js.map +1 -1
  6. package/lib/messages/index.d.ts +24 -0
  7. package/lib/messages/index.d.ts.map +1 -0
  8. package/lib/messages/index.js +24 -0
  9. package/lib/messages/index.js.map +1 -0
  10. package/lib/state/reconcile-source-model-write.d.ts.map +1 -1
  11. package/lib/state/reconcile-source-model-write.js +3 -3
  12. package/lib/state/reconcile-source-model-write.js.map +1 -1
  13. package/lib/storage/hydranium-glsp-storage.d.ts +177 -59
  14. package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
  15. package/lib/storage/hydranium-glsp-storage.js +209 -80
  16. package/lib/storage/hydranium-glsp-storage.js.map +1 -1
  17. package/lib/storage/index.d.ts +1 -1
  18. package/lib/storage/index.js +1 -1
  19. package/lib/storage/save-delivery-policy.d.ts +46 -0
  20. package/lib/storage/save-delivery-policy.d.ts.map +1 -0
  21. package/lib/storage/{save-conflict-policy.js → save-delivery-policy.js} +7 -8
  22. package/lib/storage/save-delivery-policy.js.map +1 -0
  23. package/lib/validation/diagnostic-markers.js +7 -0
  24. package/lib/validation/diagnostic-markers.js.map +1 -1
  25. package/package.json +17 -8
  26. package/src/launcher/hydranium-glsp-server.ts +4 -3
  27. package/src/messages/index.ts +25 -0
  28. package/src/state/reconcile-source-model-write.ts +5 -3
  29. package/src/storage/hydranium-glsp-storage.ts +224 -79
  30. package/src/storage/index.ts +1 -1
  31. package/src/storage/save-delivery-policy.ts +44 -0
  32. package/src/validation/diagnostic-markers.ts +8 -1
  33. package/lib/storage/save-conflict-policy.d.ts +0 -56
  34. package/lib/storage/save-conflict-policy.d.ts.map +0 -1
  35. package/lib/storage/save-conflict-policy.js.map +0 -1
  36. package/src/storage/save-conflict-policy.ts +0 -52
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/glsp-server",
3
- "version": "1.0.0-next.7",
3
+ "version": "1.0.0-next.71",
4
4
  "description": "GLSP protocol head for the hydranium framework. Peer of @hydranium/core/lsp and @hydranium/data-server; built on @hydranium/core.",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -48,6 +48,14 @@
48
48
  "types": "./lib/node/index.d.ts",
49
49
  "default": "./lib/node/index.js"
50
50
  },
51
+ "./messages": {
52
+ "types": "./lib/messages/index.d.ts",
53
+ "default": "./lib/messages/index.js"
54
+ },
55
+ "./lib/messages": {
56
+ "types": "./lib/messages/index.d.ts",
57
+ "default": "./lib/messages/index.js"
58
+ },
51
59
  "./testing": {
52
60
  "types": "./lib/testing/index.d.ts",
53
61
  "default": "./lib/testing/index.js"
@@ -79,9 +87,9 @@
79
87
  "devDependencies": {
80
88
  "@eclipse-glsp/protocol": "2.7.0",
81
89
  "@eclipse-glsp/server": "2.7.0",
82
- "@hydranium/core": "1.0.0-next.7",
83
- "@hydranium/langium": "1.0.0-next.7",
84
- "@hydranium/protocol": "1.0.0-next.7",
90
+ "@hydranium/core": "1.0.0-next.71",
91
+ "@hydranium/langium": "1.0.0-next.71",
92
+ "@hydranium/protocol": "1.0.0-next.71",
85
93
  "inversify": "6.2.2",
86
94
  "reflect-metadata": "0.2.2",
87
95
  "rimraf": "^5.0.0",
@@ -92,12 +100,12 @@
92
100
  "peerDependencies": {
93
101
  "@eclipse-glsp/protocol": "^2.6.0",
94
102
  "@eclipse-glsp/server": "^2.6.0",
95
- "@hydranium/core": "1.0.0-next.7",
96
- "@hydranium/langium": "1.0.0-next.7",
97
- "@hydranium/protocol": "1.0.0-next.7",
103
+ "@hydranium/core": "1.0.0-next.71",
104
+ "@hydranium/langium": "1.0.0-next.71",
105
+ "@hydranium/protocol": "1.0.0-next.71",
98
106
  "inversify": "^6.0.0",
99
107
  "reflect-metadata": "^0.2.2",
100
- "vscode-jsonrpc": "^8.0.0 || ^9.0.0",
108
+ "vscode-jsonrpc": "9.0.1",
101
109
  "vscode-languageserver-types": "^3.17.5"
102
110
  },
103
111
  "engines": {
@@ -106,6 +114,7 @@
106
114
  "publishConfig": {
107
115
  "access": "public"
108
116
  },
117
+ "//peerDependencies": "`vscode-jsonrpc` is EXACT rather than a range, because it is one link of an atomic chain with no independently movable link: `vscode-languageserver-protocol` depends on it at exactly 9.0.1, so any other value an adopter supplies is a SECOND physical copy rather than an upgrade. This wire stack breaks on copy identity rather than on structure — `ParameterStructures.auto` is a singleton compared by `===` in connection.js, so a request type built by one copy and sent over a connection owned by the other throws `Unknown parameter structure auto`. A caret or union range was rejected because npm then resolves a `vscode-jsonrpc@8.2.0` tree SILENTLY, with no diagnostic at all, and the failure surfaces only later at server init. Exact does not fail the install either — npm downgrades an unsatisfiable peer to a warning — but it is a NAMED ERESOLVE warning printing the required version beside the found one, and a hard error for anyone installing with `--strict-peer-deps`. **This is the head where it is not enough**: `@eclipse-glsp/*` carries its own exact `vscode-jsonrpc@8.2.0` dependency and root `overrides` do not ship, so a first install of this package ALWAYS lands two copies and the adopter must pin the chain in their own manifest. docs/adopting/requirements.md carries the block to paste.",
109
118
  "//prepack": "The publish guard, and it deliberately is NOT a `prepare`: npm runs a workspace `prepare` BEFORE the root `postinstall` that applies patches/vscode-jsonrpc+9.0.1.patch, so building there fails on a cold clone and npm rolls the entire install back. `prepack` runs only when a tarball is made (`npm pack`, `npm publish`) and never on install, so it cannot break the install it has no business touching. It FAILS rather than rebuilds, because the rebuild is exactly the part that ordering defeats. What it defends against: `files` lists `lib`, `lib` is gitignored, and a `files` entry matching nothing is skipped SILENTLY — so `npm publish` from an unbuilt tree emits a tarball of `src` and nothing else, with no error.",
110
119
  "//sideEffects": "The harness bare-imports `reflect-metadata`, which installs a global `Reflect` polyfill the inversify decorators in this package need before any container is built. That effect is invisible from the module's exports, so a bundler that treats the file as pure may reorder or drop the import and every `@injectable` resolution then fails at runtime. The rest of the package constructs no module-level state beyond frozen constants and DI symbols."
111
120
  }
@@ -26,9 +26,10 @@ import { injectable } from 'inversify';
26
26
  * `error.cause?.toString()` whenever the error is a {@link GLSPServerError},
27
27
  * and that one value feeds both the server log and the client's
28
28
  * {@link RejectAction}. A {@link GLSPServerError} carrying no `cause` therefore
29
- * reaches no reader at allnot the toast, not the details pane, not the log —
30
- * while a plain `Error` survives, because the other branch reads the error
31
- * itself. The typed error is the trap, and it is the one upstream's own
29
+ * reaches its readers as `undefined` — the log line and the client's
30
+ * action-dispatcher warning both print it — while a plain `Error` survives,
31
+ * because the other branch reads the error itself. The typed error is the trap,
32
+ * and it is the one upstream's own
32
33
  * `getOrThrow` helper raises: that helper's signature cannot pass a `cause`, so
33
34
  * an adopter using the sanctioned helper has no fix available on their side.
34
35
  * Restating the cause at each throw site is the alternative, and it costs
@@ -0,0 +1,25 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /**
11
+ * Every user-facing message `@hydranium/glsp-server` raises.
12
+ *
13
+ * **Each one is rendered AT ITS RAISE SITE**, through the shared
14
+ * `MessageRenderer`, because GLSP's action protocol carries no slot for an
15
+ * identity on any of its actions — so the identity is gone the moment a raise
16
+ * site formats its text, and there is nothing at the error boundary to render
17
+ * from. That makes this head the one place where a new message can ship
18
+ * unrendered without any single chokepoint noticing, which is why the package's
19
+ * own test suite scans for a declaration reached by `.format()` instead.
20
+ *
21
+ * The barrel is also how an adopter rendering their own diagram chrome
22
+ * discovers the codes exist.
23
+ */
24
+
25
+ export { DIAGRAM_READONLY_PARSE_ERROR, SAVE_TARGET_UNKNOWN, SOURCE_URI_MISSING } from '../storage/hydranium-glsp-storage.js';
@@ -59,17 +59,19 @@ export async function reconcileSourceModelWrite<TModel extends object>(
59
59
  await hooks.persist(outcome.merged);
60
60
  return;
61
61
  case 'no-op':
62
- hooks.logger.debug(`updateSourceModel no-op (v${err.expected} → v${err.actual}); already in sync`);
62
+ hooks.logger.debug(`updateSourceModel no-op (v${err.expectedVersion} → v${err.actualVersion}); already in sync`);
63
63
  return;
64
64
  case 'conflict':
65
65
  hooks.logger.warn(
66
- `updateSourceModel conflict (v${err.expected} → v${err.actual}); dropping the diagram edit — ` +
66
+ `updateSourceModel conflict (v${err.expectedVersion} → v${err.actualVersion}); dropping the diagram edit — ` +
67
67
  'a foreign writer changed the same field'
68
68
  );
69
69
  hooks.onConflictDropped();
70
70
  return;
71
71
  case 'unavailable':
72
- hooks.logger.warn(`updateSourceModel refetch unavailable (v${err.expected} → v${err.actual}); forcing without version`);
72
+ hooks.logger.warn(
73
+ `updateSourceModel refetch unavailable (v${err.expectedVersion} → v${err.actualVersion}); forcing without version`
74
+ );
73
75
  await hooks.persist(model);
74
76
  return;
75
77
  }
@@ -7,7 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { MarkersReason, SetMarkersAction } from '@eclipse-glsp/protocol';
10
+ import { MarkersReason, SetMarkersAction, StatusAction } from '@eclipse-glsp/protocol';
11
11
  import {
12
12
  type Action,
13
13
  ActionDispatcher,
@@ -31,7 +31,7 @@ import {
31
31
  type SourceModelStorage,
32
32
  TEMPORARY_CLIENT_ID
33
33
  } from '@eclipse-glsp/server';
34
- import { Debouncer, DisposableCollection } from '@hydranium/protocol';
34
+ import { Debouncer, defineMessage, DisposableCollection } from '@hydranium/protocol';
35
35
  import { inject, injectable, optional, postConstruct } from 'inversify';
36
36
  import { type AstNode } from '@hydranium/langium';
37
37
  import { URI } from '@hydranium/langium';
@@ -40,7 +40,58 @@ import { DiagnosticSeverity } from 'vscode-languageserver-types';
40
40
  import { type AbstractHydraniumGlspState } from '../state/abstract-hydranium-glsp-state.js';
41
41
  import { type HydraniumGlspSubmissionHandler } from '../submission/hydranium-glsp-submission-handler.js';
42
42
  import { HydraniumTypes } from '../state/hydranium-shared-core-services.js';
43
- import { DEFAULT_SAVE_CONFLICT_POLICY, SaveConflictPolicy } from './save-conflict-policy.js';
43
+ import { DEFAULT_SAVE_DELIVERY_POLICY, SaveDeliveryPolicy } from './save-delivery-policy.js';
44
+
45
+ /**
46
+ * A save action arrived with nowhere to write to. A save action carries no
47
+ * request id, so it falls through to the error handler and `message` becomes
48
+ * the toast text.
49
+ *
50
+ * Rendered at the raise site rather than by a carrier method, because GLSP's
51
+ * action protocol has no slot for an identity anywhere — every member of its
52
+ * message, status and reject actions is prose or an enum, and
53
+ * `MessageAction.details` is not a substitute since it is prose populated from
54
+ * `cause?.toString?.()`, and a Theia host shows it to a user on demand. So the
55
+ * throw is the last place that still knows which message this is.
56
+ */
57
+ export const SAVE_TARGET_UNKNOWN = defineMessage(
58
+ 'hydranium/glsp-server/save-target-unknown',
59
+ 'Could not determine where to save this model'
60
+ );
61
+
62
+ /**
63
+ * A model request arrived without the source URI it is required to carry.
64
+ * Rendered at the raise site, like its save-path sibling and for the same
65
+ * reason.
66
+ */
67
+ export const SOURCE_URI_MISSING = defineMessage(
68
+ 'hydranium/glsp-server/source-uri-missing',
69
+ 'Could not open this model: the request did not say which document to load'
70
+ );
71
+
72
+ /**
73
+ * Why the canvas has stopped accepting edits.
74
+ *
75
+ * **A READONLY canvas is otherwise indistinguishable from a broken one.** The
76
+ * client's answer to {@link SetEditModeAction} is to withdraw the tool palette,
77
+ * so the surface a reader was working in silently loses the only control it had,
78
+ * with the cause — a syntax error in a document that may not even be open —
79
+ * nowhere on screen. The mode flip is the mechanism; this is the only part of it
80
+ * a user can see.
81
+ *
82
+ * Rendered at the raise site, like its two siblings above and for the same
83
+ * reason: every member of GLSP's status action is prose or an enum.
84
+ *
85
+ * It names the recovery rather than the fault, because the fault already has a
86
+ * surface — the squiggle and the problems list, both of which say WHICH
87
+ * character — and repeating it here would put a second, less precise account of
88
+ * the same error on screen. What no other surface says is that the diagram is
89
+ * waiting on it.
90
+ */
91
+ export const DIAGRAM_READONLY_PARSE_ERROR = defineMessage(
92
+ 'hydranium/glsp-server/diagram-readonly-parse-error',
93
+ 'Read-only: this document has a syntax error. Fix it to edit the diagram again.'
94
+ );
44
95
 
45
96
  /** Window (ms) over which back-to-back external rebuilds collapse into one resubmit. */
46
97
  const EXTERNAL_SUBMIT_DEBOUNCE_MS = 250;
@@ -90,17 +141,14 @@ function isStructuralDiagnostic(diagnostic: unknown): boolean {
90
141
  *
91
142
  * Adopters with richer needs override the seams ({@link isStructurallyBroken},
92
143
  * {@link onParseErrorChanged}, {@link onSourceModelSettled}) rather than the
93
- * whole flow, and select a {@link SaveConflictPolicy} via the bound option to
94
- * tune how {@link saveSourceModel} reacts to a concurrent edit.
144
+ * whole flow, and select a {@link SaveDeliveryPolicy} via the bound option to
145
+ * tune how {@link saveSourceModel} delivers its result.
95
146
  *
96
- * **Default `saveSourceModel` flow.** Delegates to `ModelService.save`, which
97
- * serialises through the language-specific `Serializer` bound at
98
- * `services.serializer.Serializer` (resolved per-URI via `ServiceRegistry`),
99
- * updates the multi-client text-document store, drives a rebuild, and writes the
100
- * result via the `WritableFileSystemProvider`. The bound
101
- * {@link SaveConflictPolicy} (default {@link DEFAULT_SAVE_CONFLICT_POLICY},
102
- * `overwrite`) decides the based-on-version guard, await-vs-fire-and-forget, and
103
- * failure handling.
147
+ * **Default `saveSourceModel` flow.** Flushes the stored text of the primary and
148
+ * every tracked secondary via `AstDocumentManager.save`, with no serializer in
149
+ * the path the update path already put the settled text in the store. The
150
+ * bound {@link SaveDeliveryPolicy} (default {@link DEFAULT_SAVE_DELIVERY_POLICY},
151
+ * `await`) decides await-vs-fire-and-forget and failure handling.
104
152
  *
105
153
  * **tempId filter.** GLSP's `DefaultGlobalActionProvider` spins up a
106
154
  * throwaway per-diagram-type container with upstream's {@link TEMPORARY_CLIENT_ID}
@@ -137,14 +185,13 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
137
185
  @inject(ModelValidator) @optional() protected readonly modelValidator?: ModelValidator;
138
186
 
139
187
  /**
140
- * Selected {@link SaveConflictPolicy} for {@link saveSourceModel}. Bind the
141
- * {@link SaveConflictPolicy} token in a `DiagramModule` to choose a policy;
142
- * left unbound it resolves to {@link DEFAULT_SAVE_CONFLICT_POLICY}
143
- * (`overwrite`, last-write-wins). Read via {@link saveConflictPolicy} so the
144
- * fallback is applied even when inversify injects `undefined` for an unbound
145
- * `@optional()` member.
188
+ * Selected {@link SaveDeliveryPolicy} for {@link saveSourceModel}. Bind the
189
+ * {@link SaveDeliveryPolicy} token in a `DiagramModule` to choose a policy;
190
+ * left unbound it resolves to {@link DEFAULT_SAVE_DELIVERY_POLICY}. Read via
191
+ * {@link saveDeliveryPolicy} so the fallback is applied even when inversify
192
+ * injects `undefined` for an unbound `@optional()` member.
146
193
  */
147
- @inject(SaveConflictPolicy) @optional() protected readonly boundSaveConflictPolicy?: SaveConflictPolicy;
194
+ @inject(SaveDeliveryPolicy) @optional() protected readonly boundSaveDeliveryPolicy?: SaveDeliveryPolicy;
148
195
 
149
196
  /** Disposables created during {@link doLoadSourceModel}; drained on session disposal. */
150
197
  protected toDispose = new DisposableCollection();
@@ -235,12 +282,29 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
235
282
  }
236
283
 
237
284
  /**
238
- * Capture the initial settled root and apply the initial edit mode. `settled()`
239
- * strips diagnostics by phase contract, so the first edit mode is `EDITABLE`;
240
- * the first {@link handleModelUpdated} (at `Validated`, with diagnostics) flips
241
- * it READONLY if the document has structural parse errors. Edit-mode +
242
- * settle-hook actions are dispatched on a macrotask so the initial
285
+ * Capture the initial settled root and apply the initial edit mode. Edit-mode
286
+ * + settle-hook actions are dispatched on a macrotask so the initial
243
287
  * `requestModel → setModel` handshake isn't perturbed.
288
+ *
289
+ * **Whether the settled document carries diagnostics depends on how far it
290
+ * had already got, so the initial edit mode has two correct outcomes rather
291
+ * than one.** `settled()` strips nothing: it resolves AT OR ABOVE the
292
+ * integrity landmark and hands back the live document's own array. Its
293
+ * `AstDocument<TAst, never>` return type asserts emptiness for a document the
294
+ * wait had to DRIVE to that landmark, which is pre-validation — and for that
295
+ * one the mode here is `EDITABLE` and the flip to READONLY arrives with the
296
+ * first {@link handleModelUpdated}. A document already past `Validated` when
297
+ * the session opened resolves immediately with its diagnostics intact, and is
298
+ * decided correctly here with no later update owed. Any host that validates
299
+ * its workspace before a diagram is opened produces the second case, so
300
+ * neither is exceptional.
301
+ *
302
+ * **A correct decision is not a delivered one.** This dispatch happens inside
303
+ * the initial `requestModel`, before the client has the model — so the UI
304
+ * extensions that answer {@link onParseErrorChanged}'s actions are not yet
305
+ * constructed, and a canvas that opens READONLY over a broken document shows
306
+ * none of that feedback. The same timer carries {@link onSourceModelSettled},
307
+ * so an adopter's own initial actions have it too.
244
308
  */
245
309
  protected async captureSettledRoot(rootUri: string, document: AstDocument<AstNode, never>): Promise<void> {
246
310
  this.state.setSourceRoot(rootUri, document.root as TRoot);
@@ -333,9 +397,10 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
333
397
  *
334
398
  * Synchronous by construction — the phase-agnostic lookup door plus the shared
335
399
  * projection — so reacting to a secondary neither waits nor can force a build.
336
- * `settled()` is not an alternative: it strips diagnostics by phase contract,
337
- * and diagnostics are the reason {@link doUpdateAndSubmit} takes a separate
338
- * event document at all.
400
+ * `settled()` is not an alternative: besides being asynchronous, it cannot be
401
+ * relied on to carry diagnostics a document it has to drive to the landmark
402
+ * arrives pre-validation with an empty array — and diagnostics are the reason
403
+ * {@link doUpdateAndSubmit} takes a separate event document at all.
339
404
  */
340
405
  protected currentPrimaryDocument(): AstDocument<AstNode, unknown> | undefined {
341
406
  const document = this.sharedServices.model.ModelService.getDocument(this.state.sourceUri);
@@ -418,8 +483,10 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
418
483
  /**
419
484
  * Re-settle to a guaranteed fully-linked + reprojected root, capture it, and
420
485
  * (unless suppressed) resubmit a deduped external GModel. Diagnostics for the
421
- * edit-mode decision come from the event document (`settled()` strips them by
422
- * phase contract).
486
+ * edit-mode decision come from the event document, because the re-settled one
487
+ * cannot be relied on to have any: a document driven to the landmark arrives
488
+ * pre-validation, and this path re-settles precisely to escape a transient
489
+ * mid-rebuild snapshot.
423
490
  */
424
491
  protected async doUpdateAndSubmit(rootUri: string, eventDocument: AstDocument<AstNode, unknown>): Promise<Action[]> {
425
492
  // Settle-gate the capture: never setSourceRoot off the event's possibly-transient
@@ -481,15 +548,48 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
481
548
  }
482
549
 
483
550
  /**
484
- * Seam: actions to emit when the structural-broken state transitions. Default:
485
- * a single {@link SetEditModeAction} toggling READONLY ↔ EDITABLE — editing is
486
- * disabled while the syntax is broken but re-enabled once it parses, a sound
487
- * generic for any Langium-backed diagram. Adopters override to add or replace
488
- * the transition feedback. The {@link AbstractHydraniumGlspState.editMode}
489
- * flip itself is owned by {@link refreshEditMode}.
551
+ * Seam: actions to emit when the structural-broken state transitions.
552
+ * Default: a {@link SetEditModeAction} toggling READONLY ↔ EDITABLE — editing
553
+ * is disabled while the syntax is broken but re-enabled once it parses, a
554
+ * sound generic for any Langium-backed diagram plus the
555
+ * {@link DIAGRAM_READONLY_PARSE_ERROR} band that says why, cleared on the way
556
+ * back. Adopters override to add or replace the transition feedback. The
557
+ * {@link AbstractHydraniumGlspState.editMode} flip itself is owned by
558
+ * {@link refreshEditMode}.
559
+ *
560
+ * **Both actions or neither, which is why they are one seam rather than
561
+ * two.** The mode flip removes the tool palette and the band is the only
562
+ * account of that, so an override that keeps one and drops the other produces
563
+ * either an unexplained loss of the palette or a warning about a restriction
564
+ * that is not in force.
565
+ *
566
+ * **The status is dispatched with no `timeout`, so it persists** — a
567
+ * time-limited band would describe a condition that outlasts it.
568
+ *
569
+ * **The slot is SHARED, and the other writer wins on one path.**
570
+ * `ModelSubmissionHandler`'s live validation writes the same slot, clearing
571
+ * it with `severity: 'NONE'` when it finishes, and an adopter dispatching its
572
+ * own `StatusAction` replaces this one too. Nothing re-asserts it. That is
573
+ * harmless while a document is being edited, because a resubmit is skipped
574
+ * once the AST is broken (see {@link doUpdateAndSubmit}) — but NOT at load:
575
+ * the initial `requestModel` submit is not that skipped path, so a diagram
576
+ * opened on an already-broken document shows this band and then loses it to
577
+ * the validation clear a moment later. Measured on a browser host, polling the
578
+ * overlay: no element, empty, the sentence, empty again.
490
579
  */
491
580
  protected onParseErrorChanged(_document: AstDocument<AstNode, unknown>, broken: boolean): Action[] {
492
- return [SetEditModeAction.create(broken ? EditMode.READONLY : EditMode.EDITABLE)];
581
+ return [
582
+ SetEditModeAction.create(broken ? EditMode.READONLY : EditMode.EDITABLE),
583
+ broken
584
+ ? StatusAction.create(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_READONLY_PARSE_ERROR), {
585
+ severity: 'WARNING'
586
+ })
587
+ : // `NONE` is the client's own spelling for "clear", not a severity it
588
+ // renders: `StatusOverlay.handle` branches on it before touching the
589
+ // DOM. An empty message at any other severity leaves an empty band
590
+ // with a warning icon standing on the canvas.
591
+ StatusAction.create('', { severity: 'NONE' })
592
+ ];
493
593
  }
494
594
 
495
595
  /**
@@ -500,32 +600,47 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
500
600
  return [];
501
601
  }
502
602
 
503
- /** The effective {@link SaveConflictPolicy}: the bound option, or {@link DEFAULT_SAVE_CONFLICT_POLICY} when unbound. */
504
- protected get saveConflictPolicy(): SaveConflictPolicy {
505
- return this.boundSaveConflictPolicy ?? DEFAULT_SAVE_CONFLICT_POLICY;
603
+ /** The effective {@link SaveDeliveryPolicy}: the bound option, or {@link DEFAULT_SAVE_DELIVERY_POLICY} when unbound. */
604
+ protected get saveDeliveryPolicy(): SaveDeliveryPolicy {
605
+ return this.boundSaveDeliveryPolicy ?? DEFAULT_SAVE_DELIVERY_POLICY;
506
606
  }
507
607
 
508
608
  /**
509
- * Default save flow: route through `ModelService.save`. The framework
510
- * serialise-and-persist machinery handles serialisation (via the
511
- * language-specific `Serializer` bound at `services.serializer.Serializer`),
512
- * the multi-client text-document update, the rebuild, and the eventual
513
- * `WritableFileSystemProvider.writeFile`.
609
+ * Default save flow: persist the store's current text for every document this
610
+ * diagram owns.
611
+ *
612
+ * **A save persists, it does not author.** Every diagram gesture already
613
+ * reached the store through `ModelService.update`, so the store holds the
614
+ * settled text and disk is the only thing behind. Re-serializing from the AST
615
+ * here cannot improve on that text and can only damage it: a serializer
616
+ * normalises formatting and carries no comments, so a save would reflow and
617
+ * strip a document nothing changed — which is what a pure bounds drag does to
618
+ * the semantic file when only its layout moved.
619
+ *
620
+ * **The write set is the primary plus every tracked secondary**
621
+ * (`AbstractHydraniumGlspState.trackSecondaryDocument`), so a document the
622
+ * diagram wrote is persisted whether or not it is the one the client named.
623
+ * The primary is flushed first, then secondaries in tracking order. A
624
+ * document no client holds open is skipped — there is no stored text to
625
+ * persist.
514
626
  *
515
- * The configured {@link SaveConflictPolicy} (see {@link saveConflictPolicy})
516
- * decides how a concurrent edit that advanced the document is handled:
517
- * - `overwrite` (default): no based-on guard, await, propagate failures
518
- * last-write-wins, correct for a single-editor head.
519
- * - `reject`: guard on the captured `state.version`, await, surface a
520
- * `ConflictError` (and any other failure) to the GLSP save action.
521
- * - `drop-and-log`: guard, fire-and-forget, log + swallow failures — a stale
522
- * diagram save is dropped because a concurrent form/code edit already wrote
523
- * the truth, and GLSP exposes no save-failure back-channel.
627
+ * **A save therefore names documents the gesture did not aim at**, which is
628
+ * why an unchanged one is not rewritten even though the user asked for a
629
+ * save: the mtime would move on a file they never touched.
630
+ * `AstDocumentManager.save` decides that per document and announces the save
631
+ * either way.
632
+ *
633
+ * The configured {@link SaveDeliveryPolicy} (see {@link saveDeliveryPolicy})
634
+ * decides await-vs-fire-and-forget and failure handling. It carries no
635
+ * based-on guard: the guard exists to stop a stale writer overwriting a newer
636
+ * document, and a flush persists the store — which already holds every other
637
+ * client's change, including the one that advanced the version.
524
638
  *
525
639
  * Returns `MaybePromise<void>` to match upstream `SourceModelStorage`:
526
- * resolves with the persist under `overwrite`/`reject`, returns synchronously
527
- * under `drop-and-log`. Adopters that bypass `ModelService` (writing through
528
- * `WritableFileSystemProvider` directly) still override the whole method.
640
+ * resolves with the flush under `await`, returns synchronously under
641
+ * `fire-and-forget`. Adopters that bypass `AstDocumentManager` (writing
642
+ * through `WritableFileSystemProvider` directly) still override the whole
643
+ * method.
529
644
  */
530
645
  saveSourceModel(action: SaveModelAction): MaybePromise<void> {
531
646
  // Normalised for the same reason the load path is: `SaveModelAction.fileUri`
@@ -535,26 +650,36 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
535
650
  // the call site rather than inside `getFileUri` so that method's contract —
536
651
  // return what the action said — is unchanged for adopters overriding it.
537
652
  const uri = this.toSourceModelUri(this.getFileUri(action));
538
- const policy = this.saveConflictPolicy;
539
- const persisted = this.sharedServices.model.ModelService.save({
540
- uri,
541
- model: this.state.sourceRoot,
542
- clientId: this.state.clientId,
543
- baseVersion: policy.kind === 'overwrite' ? undefined : this.state.version
544
- }).then(() => undefined);
545
-
546
- if (policy.kind === 'drop-and-log') {
547
- // Fire-and-forget: the diagram save lost the race to a concurrent edit
548
- // that already persisted the truth, so it must neither block the action
549
- // nor surface. Log rather than leave an unhandled rejection.
653
+ const policy = this.saveDeliveryPolicy;
654
+ const persisted = this.flushWriteSet(uri);
655
+
656
+ if (policy.kind === 'fire-and-forget') {
657
+ // Log rather than leave an unhandled rejection: the promise is not
658
+ // returned, so nothing else will observe a failure.
550
659
  persisted.catch(error => this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`));
551
660
  return undefined;
552
661
  }
553
- // overwrite / reject: await; a ConflictError (reject) or any other failure
554
- // propagates to GLSP's save-action handler.
662
+ // Awaited: any failure propagates to GLSP's save-action handler.
555
663
  return persisted;
556
664
  }
557
665
 
666
+ /**
667
+ * Save the stored text of `primaryUri` and every tracked secondary.
668
+ *
669
+ * Deduplicated, because a state that tracks its own primary as a secondary
670
+ * would otherwise save it twice and fire two save notifications for one
671
+ * save. A document no client holds open is skipped: there is no stored text
672
+ * to persist, and `AstDocumentManager.save` throws on one.
673
+ */
674
+ protected async flushWriteSet(primaryUri: string): Promise<void> {
675
+ const documents = this.sharedServices.workspace.AstDocumentManager;
676
+ for (const target of new Set([primaryUri, ...this.state.secondaryUris])) {
677
+ if (documents.isOpen(target)) {
678
+ await documents.save(target, this.state.clientId);
679
+ }
680
+ }
681
+ }
682
+
558
683
  /**
559
684
  * Normalise GLSP's `sourceUri` — which may be a filesystem PATH or a URI
560
685
  * string — to the URI string the workspace keys documents by.
@@ -584,11 +709,25 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
584
709
  *
585
710
  * Returns it VERBATIM, in whatever form the client sent;
586
711
  * {@link toSourceModelUri} is what normalises it.
712
+ *
713
+ * A model request DOES carry a request id, so a throw here takes the
714
+ * client-request path rather than the toast path — and on that path `detail`
715
+ * comes from `cause?.toString?.()`. A single-argument throw therefore reaches
716
+ * neither the server log nor the client console: both print `undefined`. So
717
+ * the two readers are addressed separately, as on the save path: `message`
718
+ * names what failed in the user's terms and is rendered, `cause` names the
719
+ * action and the option key that was missing and stays English. On THIS path
720
+ * the cause reaches only logs — `RejectAction.detail` is read by nothing but
721
+ * the client action-dispatcher's `logger.warn` — which is not what makes it
722
+ * English; its content is.
587
723
  */
588
724
  protected getSourceUri(action: RequestModelAction): string {
589
725
  const sourceUri = action.options?.[SOURCE_URI_ARG];
590
726
  if (typeof sourceUri !== 'string') {
591
- throw new GLSPServerError(`Invalid RequestModelAction! Missing argument with key '${SOURCE_URI_ARG}'`);
727
+ throw new GLSPServerError(
728
+ this.sharedServices.MessageRenderer.renderMessage(SOURCE_URI_MISSING),
729
+ `no '${SOURCE_URI_ARG}' option on the ${action.kind} action (received ${typeof sourceUri})`
730
+ );
592
731
  }
593
732
  return sourceUri;
594
733
  }
@@ -600,18 +739,24 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
600
739
  * {@link AbstractHydraniumGlspState.setSourceRoot}.
601
740
  *
602
741
  * A save action carries no request id, so a throw here reaches the user as a
603
- * toast built from `message`, with `cause` behind its details control. The
604
- * two therefore address different readers and must not be collapsed: the
605
- * message names what failed in the user's terms, and the cause names which
606
- * lookups came back empty for whom. Neither can name the URI not having
607
- * one IS the failure, and both sources are written together, so the typed
608
- * `sourceUri` is equally empty whenever this fires.
742
+ * toast built from `message`, while `cause` travels in
743
+ * `MessageAction.details` **which a Theia host DOES put in front of a
744
+ * user**, as a "Show details" button on the toast opening a dialog. So the
745
+ * two are not split by reachability; they are split by AUDIENCE, which is
746
+ * the older rule and the one that holds here. The message names what failed
747
+ * in the user's terms and is rendered; the cause names which lookups came
748
+ * back empty for whom, and stays English because a wire action kind, an
749
+ * option key and a client id address whoever composes the system — a
750
+ * translated developer string is a worse outcome than an untranslated one.
751
+ * Neither can name the URI — not having one IS the failure, and both sources
752
+ * are written together, so the typed `sourceUri` is equally empty whenever
753
+ * this fires.
609
754
  */
610
755
  protected getFileUri(action: SaveModelAction): string {
611
756
  const uri = action.fileUri ?? this.state.get<string>(SOURCE_URI_ARG);
612
757
  if (!uri) {
613
758
  throw new GLSPServerError(
614
- 'Could not determine where to save this model',
759
+ this.sharedServices.MessageRenderer.renderMessage(SAVE_TARGET_UNKNOWN),
615
760
  `no fileUri on the save action and no ${SOURCE_URI_ARG} in the model state (clientId=${this.state.clientId})`
616
761
  );
617
762
  }
@@ -8,4 +8,4 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  export * from './hydranium-glsp-storage.js';
11
- export * from './save-conflict-policy.js';
11
+ export * from './save-delivery-policy.js';
@@ -0,0 +1,44 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /**
11
+ * How `HydraniumGlspStorage.saveSourceModel` delivers its result: whether the
12
+ * GLSP save action awaits the write, and what happens on failure.
13
+ *
14
+ * The two are not independent — fire-and-forget plus rethrow would leave an
15
+ * unhandled rejection — so each arm bundles them into one coherent, nameable
16
+ * policy an adopter selects whole. It is a *selection of configuration*,
17
+ * delivered as a bound option (see the {@link SaveDeliveryPolicy} symbol) rather
18
+ * than a behaviour hook.
19
+ *
20
+ * **Neither arm guards on a based-on version.** A save flushes the store's
21
+ * settled text rather than authoring from the diagram's captured model, and the
22
+ * store already holds every other client's change — so a guard here refuses a
23
+ * write whose content is already correct. The gate that does exist sits on the
24
+ * update path, per operation, where the diagram authors.
25
+ */
26
+ export type SaveDeliveryPolicy =
27
+ /** Await the write and propagate any failure to the GLSP save action. The default. */
28
+ | { kind: 'await' }
29
+ /**
30
+ * Fire-and-forget, logging and swallowing every failure. Correct when a
31
+ * failed diagram save must neither block the action nor surface as an error.
32
+ */
33
+ | { kind: 'fire-and-forget' };
34
+
35
+ /** The policy applied when no {@link SaveDeliveryPolicy} option is bound. */
36
+ export const DEFAULT_SAVE_DELIVERY_POLICY: SaveDeliveryPolicy = { kind: 'await' };
37
+
38
+ /**
39
+ * DI token for the {@link SaveDeliveryPolicy} option. Bind a constant value in a
40
+ * `DiagramModule` to select a non-default policy; left unbound,
41
+ * `HydraniumGlspStorage` falls back to {@link DEFAULT_SAVE_DELIVERY_POLICY}.
42
+ * Shares its name with the type, the way a `class` is both a value and a type.
43
+ */
44
+ export const SaveDeliveryPolicy = Symbol('SaveDeliveryPolicy');
@@ -65,8 +65,15 @@ export function diagnosticsToMarkers(diagnostics: readonly TransferLspDiagnostic
65
65
  * The ids of the elements drawn on this diagram for the diagnostic's target,
66
66
  * starting at the node it points at and walking up its containers; empty when
67
67
  * the path does not resolve or nothing on the chain is rendered.
68
+ *
69
+ * @param elementPath absent for a lexer or parser error, which Langium pushes
70
+ * onto the document without one — such a diagnostic points at text no node
71
+ * was built from, so it marks nothing.
68
72
  */
69
- function renderedElementIds(elementPath: string, lookups: DiagnosticMarkerLookups): readonly string[] {
73
+ function renderedElementIds(elementPath: string | undefined, lookups: DiagnosticMarkerLookups): readonly string[] {
74
+ if (elementPath === undefined) {
75
+ return [];
76
+ }
70
77
  let node: AstNode | undefined = lookups.resolveElement(elementPath);
71
78
  while (node !== undefined) {
72
79
  const ids = lookups.renderedIdsFor(node);