@hydranium/glsp-server 1.0.0-next.12 → 1.0.0-next.123

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 (71) hide show
  1. package/README.md +13 -2
  2. package/lib/command/hydranium-glsp-recording-command.d.ts +22 -22
  3. package/lib/command/hydranium-glsp-recording-command.d.ts.map +1 -1
  4. package/lib/command/hydranium-glsp-recording-command.js +25 -26
  5. package/lib/command/hydranium-glsp-recording-command.js.map +1 -1
  6. package/lib/launcher/hydranium-glsp-server.d.ts +4 -3
  7. package/lib/launcher/hydranium-glsp-server.d.ts.map +1 -1
  8. package/lib/launcher/hydranium-glsp-server.js +4 -3
  9. package/lib/launcher/hydranium-glsp-server.js.map +1 -1
  10. package/lib/messages/index.d.ts +24 -0
  11. package/lib/messages/index.d.ts.map +1 -0
  12. package/lib/messages/index.js +24 -0
  13. package/lib/messages/index.js.map +1 -0
  14. package/lib/node/start-glsp-server.js +1 -1
  15. package/lib/node/start-glsp-server.js.map +1 -1
  16. package/lib/state/abstract-hydranium-glsp-state.d.ts +110 -43
  17. package/lib/state/abstract-hydranium-glsp-state.d.ts.map +1 -1
  18. package/lib/state/abstract-hydranium-glsp-state.js +130 -41
  19. package/lib/state/abstract-hydranium-glsp-state.js.map +1 -1
  20. package/lib/state/full-text-hydranium-glsp-state.d.ts +6 -6
  21. package/lib/state/full-text-hydranium-glsp-state.d.ts.map +1 -1
  22. package/lib/state/full-text-hydranium-glsp-state.js +6 -10
  23. package/lib/state/full-text-hydranium-glsp-state.js.map +1 -1
  24. package/lib/state/reconcile-source-model-write.d.ts +9 -8
  25. package/lib/state/reconcile-source-model-write.d.ts.map +1 -1
  26. package/lib/state/reconcile-source-model-write.js +8 -8
  27. package/lib/state/reconcile-source-model-write.js.map +1 -1
  28. package/lib/state/reconciling-multi-document-glsp-state.d.ts +59 -59
  29. package/lib/state/reconciling-multi-document-glsp-state.d.ts.map +1 -1
  30. package/lib/state/reconciling-multi-document-glsp-state.js +84 -82
  31. package/lib/state/reconciling-multi-document-glsp-state.js.map +1 -1
  32. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts +19 -18
  33. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts.map +1 -1
  34. package/lib/state/reconciling-transfer-hydranium-glsp-state.js +26 -30
  35. package/lib/state/reconciling-transfer-hydranium-glsp-state.js.map +1 -1
  36. package/lib/storage/hydranium-glsp-storage.d.ts +271 -76
  37. package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
  38. package/lib/storage/hydranium-glsp-storage.js +383 -87
  39. package/lib/storage/hydranium-glsp-storage.js.map +1 -1
  40. package/lib/storage/index.d.ts +1 -1
  41. package/lib/storage/index.js +1 -1
  42. package/lib/storage/save-delivery-policy.d.ts +46 -0
  43. package/lib/storage/save-delivery-policy.d.ts.map +1 -0
  44. package/lib/storage/{save-conflict-policy.js → save-delivery-policy.js} +7 -8
  45. package/lib/storage/save-delivery-policy.js.map +1 -0
  46. package/lib/validation/diagnostic-markers.d.ts +2 -2
  47. package/lib/validation/diagnostic-markers.d.ts.map +1 -1
  48. package/lib/validation/diagnostic-markers.js +7 -0
  49. package/lib/validation/diagnostic-markers.js.map +1 -1
  50. package/lib/validation/hydranium-glsp-model-validator.d.ts.map +1 -1
  51. package/lib/validation/hydranium-glsp-model-validator.js +1 -1
  52. package/lib/validation/hydranium-glsp-model-validator.js.map +1 -1
  53. package/package.json +17 -8
  54. package/src/command/hydranium-glsp-recording-command.ts +26 -26
  55. package/src/launcher/hydranium-glsp-server.ts +4 -3
  56. package/src/messages/index.ts +30 -0
  57. package/src/node/start-glsp-server.ts +1 -1
  58. package/src/state/abstract-hydranium-glsp-state.ts +162 -51
  59. package/src/state/full-text-hydranium-glsp-state.ts +7 -11
  60. package/src/state/reconcile-source-model-write.ts +18 -15
  61. package/src/state/reconciling-multi-document-glsp-state.ts +86 -83
  62. package/src/state/reconciling-transfer-hydranium-glsp-state.ts +27 -31
  63. package/src/storage/hydranium-glsp-storage.ts +445 -97
  64. package/src/storage/index.ts +1 -1
  65. package/src/storage/save-delivery-policy.ts +44 -0
  66. package/src/validation/diagnostic-markers.ts +10 -3
  67. package/src/validation/hydranium-glsp-model-validator.ts +3 -3
  68. package/lib/storage/save-conflict-policy.d.ts +0 -56
  69. package/lib/storage/save-conflict-policy.d.ts.map +0 -1
  70. package/lib/storage/save-conflict-policy.js.map +0 -1
  71. package/src/storage/save-conflict-policy.ts +0 -52
@@ -12,6 +12,7 @@ import { Emitter, type Event } from 'vscode-jsonrpc';
12
12
  import { inject, injectable, optional } from 'inversify';
13
13
  import { type AstNode, DocumentState, URI } from '@hydranium/langium';
14
14
  import {
15
+ type ClientSession,
15
16
  type HydraniumScopeProvider,
16
17
  type LanguageTarget,
17
18
  type NameProvider,
@@ -19,7 +20,16 @@ import {
19
20
  type ServerLanguageServices,
20
21
  type ServerSharedServices
21
22
  } from '@hydranium/core';
22
- import type { ConflictResolver, Disposable, Logger, Tracer } from '@hydranium/protocol';
23
+ import {
24
+ type BasedOn,
25
+ type SnapshotVersion,
26
+ type ConflictResolver,
27
+ type Disposable,
28
+ type Logger,
29
+ type Tracer,
30
+ asSnapshotVersion,
31
+ NO_MATCHING_VERSION
32
+ } from '@hydranium/protocol';
23
33
  import { type HydraniumGlspIndex } from './hydranium-glsp-index.js';
24
34
  import { HydraniumTypes } from './hydranium-shared-core-services.js';
25
35
  import { ModelReadyTimeoutError } from './model-ready-timeout-error.js';
@@ -87,12 +97,38 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
87
97
  /** Narrows {@link DefaultModelState.index} to the framework-extended index type. */
88
98
  declare readonly index: HydraniumGlspIndex;
89
99
 
100
+ /**
101
+ * The client session this diagram works as, registered under `clientId` by
102
+ * the source-model storage and ended with the GLSP client session.
103
+ * `undefined` for GLSP's placeholder client, and when the id was held by
104
+ * another participant, in which case the diagram does not load.
105
+ *
106
+ * Every write of the diagram goes through this session, which writes only a
107
+ * document it has open and opens nothing. A one-shot write to a document
108
+ * outside the write set goes through
109
+ * {@link ClientSession.withOpen}, and a document the diagram brings into
110
+ * existence through {@link ClientSession.create}.
111
+ */
112
+ modelSession?: ClientSession<AstNode>;
113
+
114
+ /**
115
+ * {@link modelSession}, or a throw without one: the diagram never loaded or
116
+ * has ended. Looking `clientId` up through `ModelService.getSession` instead
117
+ * would write as whichever participant holds that id.
118
+ */
119
+ protected requireModelSession(): ClientSession<AstNode> {
120
+ if (!this.modelSession) {
121
+ throw new Error(`No client session is registered for ${this.clientId}; the diagram cannot write`);
122
+ }
123
+ return this.modelSession;
124
+ }
125
+
90
126
  protected _sourceUri!: string;
91
127
  protected _sourceRoot!: TRoot;
92
128
  protected _tracer?: Tracer;
93
- protected _version: number = 0;
94
- /** Based-on versions of the secondary write set, keyed by URI. See {@link trackSecondaryDocument}. */
95
- protected readonly _secondaryVersions = new Map<string, number>();
129
+ protected _basedOn: SnapshotVersion = asSnapshotVersion(0);
130
+ /** Snapshot versions of the secondary write set, keyed by URI. See {@link trackSecondaryDocument}. */
131
+ protected readonly _secondaryVersions = new Map<string, SnapshotVersion>();
96
132
 
97
133
  /** Backing emitter for {@link onSecondaryUrisChanged}; fired only on a membership change. */
98
134
  protected readonly secondaryUrisChangedEmitter = new Emitter<void>();
@@ -115,7 +151,7 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
115
151
  setSourceRoot(uri: string, root: TRoot): void {
116
152
  this._sourceUri = uri;
117
153
  this._sourceRoot = root;
118
- this._version = this.readDocumentVersion(uri);
154
+ this._basedOn = this.readSnapshotVersion(uri);
119
155
  // Re-read the secondary write set for the same reason the primary is
120
156
  // re-read: the write that brought us here advanced their versions too, and
121
157
  // a stale based-on version would gate the NEXT command against a
@@ -124,7 +160,7 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
124
160
  this.refreshSecondaryVersions();
125
161
  this.set(SOURCE_URI_ARG, uri);
126
162
  this._tracer = this.baseTracer.withUri(uri);
127
- this.tracer.debug(`Captured source root at doc.version=v${this._version}`);
163
+ this.tracer.debug(`Captured source root at doc.version=v${this._basedOn}`);
128
164
  this.checkDeclaredLanguage(uri);
129
165
  this.index.indexSourceRoot(root, uri);
130
166
  }
@@ -241,25 +277,36 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
241
277
  }
242
278
 
243
279
  /**
244
- * Text-document version captured at the last {@link setSourceRoot}
245
- * call. Mirrors the server-side `TextDocuments.version(uri)` counter
246
- * at that point in time — frozen until the next `setSourceRoot`, then
247
- * re-read from `LangiumDocuments`.
280
+ * Snapshot version taken at the last {@link setSourceRoot} call — frozen
281
+ * until the next one, then re-read from the document store.
248
282
  *
249
283
  * Threaded by `HydraniumGlspRecordingCommand` into
250
- * {@link updateSourceModel}'s optional `version` parameter so the
251
- * downstream `ModelService.update` / `.save` call can opt into the
252
- * conflict gate against the captured based-on version. See
284
+ * {@link updateSourceModel} so the downstream session `update` / `save`
285
+ * gates against what the command was authored on. See
253
286
  * `@hydranium/protocol#ConflictError` for the detection contract.
254
287
  *
288
+ * **This, not a number read at write time, is what a write must be gated
289
+ * on.** A version read when the write is issued is the store's current one,
290
+ * so it matches by construction and the gate can never fire.
291
+ */
292
+ get basedOn(): SnapshotVersion {
293
+ return this._basedOn;
294
+ }
295
+
296
+ /**
297
+ * Text-document version of {@link basedOn}. Mirrors the server-side
298
+ * `TextDocuments.version(uri)` counter as of the snapshot, or is
299
+ * `NO_MATCHING_VERSION` when the store's text could not be tied to the
300
+ * root (see {@link readSnapshotVersion}).
301
+ *
255
302
  * Falls back to `0` when the document is absent from `LangiumDocuments`
256
- * at capture time — the same observable state callers see for a doc
257
- * that was never opened. Callers that need to distinguish
303
+ * when the snapshot is taken — the same observable state callers see for a
304
+ * doc that was never opened. Callers that need to distinguish
258
305
  * "never-versioned" from "v0" must consult the document registry
259
306
  * directly.
260
307
  */
261
308
  get version(): number {
262
- return this._version;
309
+ return this._basedOn;
263
310
  }
264
311
 
265
312
  // ============================================================
@@ -267,8 +314,8 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
267
314
  // ============================================================
268
315
 
269
316
  /**
270
- * Register `uri` as a document this state also writes, capturing its current
271
- * text-document version.
317
+ * Register `uri` as a document this state also writes, snapshotting its
318
+ * current text-document version.
272
319
  *
273
320
  * A diagram whose layout lives in its own file, or whose canvas creates
274
321
  * elements in a referenced semantic document, writes more than the document
@@ -281,7 +328,7 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
281
328
  * identified once the operation names the node. What the framework supplies
282
329
  * is the part an adopter cannot reconstruct after the fact — the version each
283
330
  * document was at BEFORE the command mutated anything (see
284
- * {@link capturedVersionOf}). Idempotent; registering the primary is ignored,
331
+ * {@link snapshotVersionOf}). Idempotent; registering the primary is ignored,
285
332
  * since {@link version} already tracks it. Registering a URI not already in
286
333
  * the set fires {@link onSecondaryUrisChanged}.
287
334
  */
@@ -290,7 +337,7 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
290
337
  return;
291
338
  }
292
339
  const added = !this._secondaryVersions.has(uri);
293
- this._secondaryVersions.set(uri, this.readDocumentVersion(uri));
340
+ this._secondaryVersions.set(uri, this.readSnapshotVersion(uri));
294
341
  if (added) {
295
342
  this.secondaryUrisChangedEmitter.fire();
296
343
  }
@@ -310,14 +357,15 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
310
357
 
311
358
  /**
312
359
  * Fires whenever the secondary write set gains or loses a URI — never when a
313
- * tracked document's captured version is merely refreshed, which happens on
360
+ * tracked document's snapshot version is merely refreshed, which happens on
314
361
  * every {@link setSourceRoot} and changes nothing a subscriber cares about.
315
362
  *
316
363
  * **This exists so no caller has to know when the set can change.** The set is
317
364
  * usually discovered rather than known, and the discovery point is an
318
- * operation handler naming a node — not a capture. A subscriber that instead
319
- * re-derived the set after each capture would miss exactly that case, and miss
320
- * it silently: the document would simply never be watched, with nothing logged.
365
+ * operation handler naming a node — not a read of the source root. A
366
+ * subscriber that instead re-derived the set after each such read would miss
367
+ * exactly that case, and miss it silently: the document would simply never be
368
+ * watched, with nothing logged.
321
369
  * Announcing the change here moves the obligation to the one place that can
322
370
  * always discharge it.
323
371
  *
@@ -336,37 +384,69 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
336
384
  }
337
385
 
338
386
  /**
339
- * Based-on version captured for `uri` — {@link version} for the primary, the
340
- * value captured at {@link trackSecondaryDocument} (refreshed by
341
- * {@link setSourceRoot}) for a secondary, `undefined` for anything untracked.
387
+ * Snapshot version for `uri` — {@link basedOn} for the primary, the version
388
+ * taken at {@link trackSecondaryDocument} (refreshed by {@link setSourceRoot})
389
+ * for a secondary, `undefined` for anything untracked.
342
390
  *
343
- * `undefined` rather than `0` for an untracked URI deliberately: `0` is a real
391
+ * `undefined` rather than v0 for an untracked URI deliberately: `0` is a real
344
392
  * version meaning "present but never edited", so collapsing the two would let
345
- * a caller gate a write against a document it never captured.
393
+ * a caller gate a write against a document this state never read.
346
394
  */
347
- capturedVersionOf(uri: string): number | undefined {
395
+ snapshotVersionOf(uri: string): SnapshotVersion | undefined {
348
396
  if (uri === this._sourceUri) {
349
- return this._version;
397
+ return this._basedOn;
350
398
  }
351
399
  return this._secondaryVersions.get(uri);
352
400
  }
353
401
 
354
402
  /**
355
- * Read a document's current text-document version through the model service's
356
- * canonicalizing gateway, so a URI spelled through a symlink still resolves to
357
- * the document keyed by its real path rather than stranding at `0` (which
358
- * would silently weaken the conflict gate). Optional-chained on `textDocument`
359
- * so a fixture that omits it does not throw; real Langium documents always
360
- * populate it.
403
+ * Read a document's version out of the live store and mark it as a snapshot,
404
+ * through the model service's canonicalizing gateway so a URI spelled through
405
+ * a symlink still resolves to the document keyed by its real path rather than
406
+ * stranding at `0` (which would silently weaken the conflict gate).
407
+ *
408
+ * **Call this only at the point the source root is read.** It is the one
409
+ * place the framework turns a live version into a snapshot one, and the value
410
+ * is true only because that is where it is called. Called at write time it
411
+ * answers with the version the write is about to be compared against, so the
412
+ * gate passes unconditionally.
413
+ *
414
+ * Falls back to v0 for a URI neither store knows, matching what
415
+ * {@link version} reports for a document that was never opened.
416
+ *
417
+ * **The version of the text store, which the gate compares against.**
418
+ * When the store holds the text the root was parsed from, that is its
419
+ * version, even while the built document still carries an older number.
420
+ * When the store holds other text, `NO_MATCHING_VERSION`, so the write
421
+ * conflicts instead of overwriting text the root never saw: the
422
+ * reconciling states reconcile it, `FullTextHydraniumGlspState` throws the
423
+ * `ConflictError`. When the root keeps no syntax tree to read its text
424
+ * from, the built document's text stands in for it, unless the built
425
+ * document IS the store's, whose text says nothing about what was parsed;
426
+ * that also gets `NO_MATCHING_VERSION`.
361
427
  */
362
- protected readDocumentVersion(uri: string): number {
363
- return this.sharedServices.model.ModelService.getDocument(uri)?.textDocument?.version ?? 0;
428
+ protected readSnapshotVersion(uri: string): SnapshotVersion {
429
+ const document = this.sharedServices.model.ModelService.getDocument(uri);
430
+ const stored = document && this.sharedServices.workspace.TextDocuments.get(uri);
431
+ if (stored) {
432
+ const parsed = document.parseResult.value.$cstNode?.root.fullText;
433
+ if (parsed !== undefined) {
434
+ return parsed === stored.getText() ? asSnapshotVersion(stored.version) : NO_MATCHING_VERSION;
435
+ }
436
+ if (document.textDocument === stored) {
437
+ return NO_MATCHING_VERSION;
438
+ }
439
+ if (stored.version !== document.textDocument.version && stored.getText() === document.textDocument.getText()) {
440
+ return asSnapshotVersion(stored.version);
441
+ }
442
+ }
443
+ return this.sharedServices.model.ModelService.snapshot(uri)?.version ?? asSnapshotVersion(0);
364
444
  }
365
445
 
366
- /** Re-read every registered secondary's version from the document store. */
446
+ /** Re-take every registered secondary's snapshot version from the document store. */
367
447
  protected refreshSecondaryVersions(): void {
368
448
  for (const uri of [...this._secondaryVersions.keys()]) {
369
- this._secondaryVersions.set(uri, this.readDocumentVersion(uri));
449
+ this._secondaryVersions.set(uri, this.readSnapshotVersion(uri));
370
450
  }
371
451
  }
372
452
 
@@ -392,6 +472,28 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
392
472
  }
393
473
  }
394
474
 
475
+ /**
476
+ * The root that `uri`'s text parses to (the text store's, or the built
477
+ * document's when the store holds none), read once the document has
478
+ * validated; `undefined` when it cannot be read.
479
+ *
480
+ * **Parsed afresh, never the built root.** Operation handlers edit the
481
+ * built root in place, so until a rebuild replaces it, it already holds
482
+ * the edit being reconciled, and replaying the edit onto it applies it
483
+ * twice. The root is parsed, not linked: an encoder hook reading a
484
+ * reference's `ref` sees `undefined` on it.
485
+ */
486
+ protected async readCurrentRoot(uri: string): Promise<AstNode | undefined> {
487
+ const modelService = this.sharedServices.model.ModelService;
488
+ if (!(await modelService.validated(uri).catch(() => undefined))) {
489
+ return undefined;
490
+ }
491
+ const text = this.sharedServices.workspace.TextDocuments.get(uri)?.getText() ?? modelService.getDocument(uri)?.textDocument.getText();
492
+ return text === undefined
493
+ ? undefined
494
+ : this.sharedServices.workspace.LangiumDocumentFactory.fromString(text, URI.parse(uri)).parseResult.value;
495
+ }
496
+
395
497
  /**
396
498
  * Wait until the captured document reaches `state`, then run
397
499
  * {@link onReadyRefreshed} so consumers see a fresh AST. Wraps the
@@ -417,8 +519,8 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
417
519
  const timeout = new Promise<void>((resolve, reject) => {
418
520
  timer = this.sharedServices.Clock.setTimer(() => {
419
521
  const elapsed = Math.round(stopwatch.elapsedMs);
420
- // Via the canonicalizing gateway (see `readDocumentVersion`), so a
421
- // symlinked `_sourceUri` is not reported as a hard timeout.
522
+ // Through the model service's canonicalizing gateway, so a symlinked
523
+ // `_sourceUri` is not reported as a hard timeout.
422
524
  const doc = this.sharedServices.model.ModelService.getDocument(uri);
423
525
  if (doc && doc.state >= state) {
424
526
  this.logger.warn(
@@ -491,13 +593,22 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
491
593
  * `JsonModelState` shape meet the framework state via structural
492
594
  * intersection at the recording-command call site.
493
595
  *
494
- * `version` is the based-on text-document version captured at command
495
- * start by `HydraniumGlspRecordingCommand.execute`. Adopters that
496
- * route through `ModelService.update` / `.save` pass it as the args'
497
- * `version` field to opt into the conflict gate; adopters that don't
498
- * care (or whose write path doesn't go through `ModelService`) ignore
499
- * the parameter. Undefined when the writer is not the recording-command
500
- * (e.g. external storage refresh paths).
596
+ * `basedOn` is the snapshot version taken at command start by
597
+ * `HydraniumGlspRecordingCommand.execute`. Adopters that write through the
598
+ * diagram's session forward it as the args' `basedOn` field; adopters whose
599
+ * write path doesn't go through the session ignore it.
600
+ * `'anything'` when the writer is not the recording command (an external
601
+ * storage refresh, an undo replaying a recorded patch), which is a write
602
+ * authored against no particular server version.
603
+ *
604
+ * **Optional, and every implementation must default it to {@link basedOn}
605
+ * rather than to `'anything'`.** Optional is forced: GLSP's `JsonModelState`
606
+ * declares a one-parameter `updateSourceModel` and calls it with one
607
+ * argument, so a second required parameter makes the state unassignable to
608
+ * the slot it has to fill. Defaulting to the state's own snapshot version is
609
+ * what keeps that from costing anything — an operation handler calling
610
+ * `updateSourceModel(model)` gets the gate, and skipping it has to be typed
611
+ * out as `'anything'`.
501
612
  */
502
- abstract updateSourceModel(model: TSourceModel, version?: number): MaybePromise<void>;
613
+ abstract updateSourceModel(model: TSourceModel, basedOn?: BasedOn): MaybePromise<void>;
503
614
  }
@@ -11,7 +11,7 @@ import { type JsonModelState } from '@eclipse-glsp/server';
11
11
  import { injectable } from 'inversify';
12
12
  import { type AstNode } from '@hydranium/langium';
13
13
  import { URI } from '@hydranium/langium';
14
- import { isPromiseLike, type MaybePromise } from '@hydranium/protocol';
14
+ import { type BasedOn, isPromiseLike, type MaybePromise } from '@hydranium/protocol';
15
15
  import { AbstractHydraniumGlspState } from './abstract-hydranium-glsp-state.js';
16
16
 
17
17
  /** Whole-document-text source model: the serialised text of the source root. */
@@ -32,11 +32,11 @@ export interface FullTextSourceModel {
32
32
  * `serializer.Serializer` reached via `sharedServices.ServiceRegistry`
33
33
  * (so multi-grammar workspaces route to the right serializer). `MaybePromise`
34
34
  * because serialization may be async; the sync fast path is preserved.
35
- * - {@link updateSourceModel} pushes the text back through
36
- * `ModelService.update` (which accepts a raw text payload) and captures the
37
- * re-parsed root.
35
+ * - {@link updateSourceModel} pushes the text back through the diagram's
36
+ * session (`update` accepts a raw text payload) and captures the re-parsed
37
+ * root, gated on the caller's `basedOn`; it throws without a session.
38
38
  *
39
- * No `baseline` / conflict reconcile: a whole-document model has exactly one
39
+ * No `base` / conflict reconcile: a whole-document model has exactly one
40
40
  * field, so every concurrent edit is a same-field collision and undo / redo
41
41
  * degrade to drop-on-divergence. Adopters that need field-level undo use
42
42
  * `ReconcilingTransferHydraniumGlspState` instead.
@@ -52,12 +52,8 @@ export class FullTextHydraniumGlspState<TRoot extends AstNode>
52
52
  return isPromiseLike(text) ? text.then(value => ({ text: value })) : { text };
53
53
  }
54
54
 
55
- async updateSourceModel(model: FullTextSourceModel): Promise<void> {
56
- const document = await this.sharedServices.model.ModelService.update({
57
- uri: this._sourceUri,
58
- model: model.text,
59
- clientId: this.clientId
60
- });
55
+ async updateSourceModel(model: FullTextSourceModel, basedOn: BasedOn = this.basedOn): Promise<void> {
56
+ const document = await this.requireModelSession().update({ uri: this._sourceUri, model: model.text, basedOn });
61
57
  this.setSourceRoot(this._sourceUri, document.root as TRoot);
62
58
  }
63
59
  }
@@ -7,7 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { type ConflictResolver, type Logger, isConflictError } from '@hydranium/protocol';
10
+ import { type BasedOn, type ConflictResolver, type Logger, isConflictError } from '@hydranium/protocol';
11
11
 
12
12
  /**
13
13
  * The I/O and policy a {@link reconcileSourceModelWrite} call needs. Deliberately
@@ -18,15 +18,16 @@ import { type ConflictResolver, type Logger, isConflictError } from '@hydranium/
18
18
  */
19
19
  export interface SourceModelWriteHooks<TModel> {
20
20
  /**
21
- * Write `model` and capture the resulting state. Called with a `baseVersion`
22
- * on the first attempt (opting into the `ConflictError` gate) and without one
23
- * on the merged / forced retries, where the point is to land the write.
21
+ * Write `model` and record the resulting state. Called with the caller's own
22
+ * `basedOn` on the first attempt (opting into the `ConflictError` gate) and
23
+ * with `'anything'` on the merged / forced retries, where the reconcile has
24
+ * already decided to win and the point is to land the write.
24
25
  */
25
- persist(model: TModel, baseVersion?: number): Promise<void>;
26
+ persist(model: TModel, basedOn: BasedOn): Promise<void>;
26
27
  /** Current server-side projection, or `undefined` when unavailable. */
27
28
  refetch(): Promise<TModel | undefined>;
28
- /** Last in-sync projection the user's intent is measured against. */
29
- readonly baseline: TModel | undefined;
29
+ /** The last in-sync projection the user's intent is measured against. */
30
+ readonly base: TModel | undefined;
30
31
  readonly conflictResolver: ConflictResolver;
31
32
  readonly logger: Logger;
32
33
  /** Invoked when the edit is dropped, so the caller can resync its own state. */
@@ -44,33 +45,35 @@ export interface SourceModelWriteHooks<TModel> {
44
45
  */
45
46
  export async function reconcileSourceModelWrite<TModel extends object>(
46
47
  model: TModel,
47
- version: number | undefined,
48
+ basedOn: BasedOn,
48
49
  hooks: SourceModelWriteHooks<TModel>
49
50
  ): Promise<void> {
50
51
  try {
51
- await hooks.persist(model, version);
52
+ await hooks.persist(model, basedOn);
52
53
  } catch (err) {
53
54
  if (!isConflictError(err)) {
54
55
  throw err;
55
56
  }
56
- const outcome = await hooks.conflictResolver.resolve(hooks.baseline ?? model, model, () => hooks.refetch());
57
+ const outcome = await hooks.conflictResolver.resolve(hooks.base ?? model, model, () => hooks.refetch());
57
58
  switch (outcome.status) {
58
59
  case 'merged':
59
- await hooks.persist(outcome.merged);
60
+ await hooks.persist(outcome.merged, 'anything');
60
61
  return;
61
62
  case 'no-op':
62
- hooks.logger.debug(`updateSourceModel no-op (v${err.expected} → v${err.actual}); already in sync`);
63
+ hooks.logger.debug(`updateSourceModel no-op (v${err.expectedVersion} → v${err.actualVersion}); already in sync`);
63
64
  return;
64
65
  case 'conflict':
65
66
  hooks.logger.warn(
66
- `updateSourceModel conflict (v${err.expected} → v${err.actual}); dropping the diagram edit — ` +
67
+ `updateSourceModel conflict (v${err.expectedVersion} → v${err.actualVersion}); dropping the diagram edit — ` +
67
68
  'a foreign writer changed the same field'
68
69
  );
69
70
  hooks.onConflictDropped();
70
71
  return;
71
72
  case 'unavailable':
72
- hooks.logger.warn(`updateSourceModel refetch unavailable (v${err.expected} → v${err.actual}); forcing without version`);
73
- await hooks.persist(model);
73
+ hooks.logger.warn(
74
+ `updateSourceModel refetch unavailable (v${err.expectedVersion} → v${err.actualVersion}); forcing without version`
75
+ );
76
+ await hooks.persist(model, 'anything');
74
77
  return;
75
78
  }
76
79
  }