@hydranium/core 1.0.0-next.117 → 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 (108) hide show
  1. package/lib/documents/ast-document-manager.d.ts +8 -44
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +22 -41
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/client-session-errors.d.ts +4 -33
  6. package/lib/documents/client-session-errors.d.ts.map +1 -1
  7. package/lib/documents/client-session-errors.js +4 -46
  8. package/lib/documents/client-session-errors.js.map +1 -1
  9. package/lib/documents/client-session-registry.d.ts +40 -20
  10. package/lib/documents/client-session-registry.d.ts.map +1 -1
  11. package/lib/documents/client-session-registry.js +33 -20
  12. package/lib/documents/client-session-registry.js.map +1 -1
  13. package/lib/documents/file-system-task-queue.d.ts +49 -0
  14. package/lib/documents/file-system-task-queue.d.ts.map +1 -0
  15. package/lib/documents/file-system-task-queue.js +34 -0
  16. package/lib/documents/file-system-task-queue.js.map +1 -0
  17. package/lib/documents/hydranium-text-documents.d.ts +138 -13
  18. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  19. package/lib/documents/hydranium-text-documents.js +283 -40
  20. package/lib/documents/hydranium-text-documents.js.map +1 -1
  21. package/lib/documents/index.d.ts +1 -0
  22. package/lib/documents/index.d.ts.map +1 -1
  23. package/lib/documents/index.js +1 -0
  24. package/lib/documents/index.js.map +1 -1
  25. package/lib/langium/document-builder/document-builder.d.ts +15 -2
  26. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  27. package/lib/langium/document-builder/document-builder.js +15 -2
  28. package/lib/langium/document-builder/document-builder.js.map +1 -1
  29. package/lib/langium/integrity/integrity-service.d.ts +3 -2
  30. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  31. package/lib/langium/integrity/integrity-service.js +9 -6
  32. package/lib/langium/integrity/integrity-service.js.map +1 -1
  33. package/lib/langium/language-module.d.ts +2 -2
  34. package/lib/langium/language-module.js +2 -2
  35. package/lib/langium/language-module.js.map +1 -1
  36. package/lib/langium/model-service/client-session.d.ts +203 -24
  37. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  38. package/lib/langium/model-service/client-session.js +233 -29
  39. package/lib/langium/model-service/client-session.js.map +1 -1
  40. package/lib/langium/model-service/model-service.d.ts +62 -217
  41. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  42. package/lib/langium/model-service/model-service.js +54 -357
  43. package/lib/langium/model-service/model-service.js.map +1 -1
  44. package/lib/langium/module.d.ts +11 -1
  45. package/lib/langium/module.d.ts.map +1 -1
  46. package/lib/langium/module.js +5 -1
  47. package/lib/langium/module.js.map +1 -1
  48. package/lib/langium/update-rewrite/update-rewrite.d.ts +1 -1
  49. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  50. package/lib/langium/workspace/in-memory-file-system-provider.js +10 -2
  51. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  52. package/lib/langium/workspace/write-lock-scope.d.ts +1 -1
  53. package/lib/langium/workspace/write-lock-scope.js +1 -1
  54. package/lib/lsp/hydranium-document-update-handler.d.ts +69 -67
  55. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  56. package/lib/lsp/hydranium-document-update-handler.js +111 -89
  57. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  58. package/lib/lsp/shared-module.d.ts +4 -8
  59. package/lib/lsp/shared-module.d.ts.map +1 -1
  60. package/lib/lsp/shared-module.js.map +1 -1
  61. package/lib/lsp/start-language-server.d.ts +3 -4
  62. package/lib/lsp/start-language-server.d.ts.map +1 -1
  63. package/lib/lsp/start-language-server.js +3 -4
  64. package/lib/lsp/start-language-server.js.map +1 -1
  65. package/lib/testing/make-test-services.d.ts +16 -4
  66. package/lib/testing/make-test-services.d.ts.map +1 -1
  67. package/lib/testing/make-test-services.js +6 -2
  68. package/lib/testing/make-test-services.js.map +1 -1
  69. package/lib/testing/node/lsp-server-connection.d.ts +1 -1
  70. package/lib/testing/node/scratch-workspace.js +1 -1
  71. package/lib/testing/run-update-pipeline.d.ts +4 -4
  72. package/lib/testing/run-update-pipeline.d.ts.map +1 -1
  73. package/lib/testing/stub-ast-document-manager.d.ts +5 -5
  74. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  75. package/lib/testing/stub-ast-document-manager.js +6 -8
  76. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  77. package/lib/testing/stub-hydranium-text-documents.d.ts +10 -5
  78. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  79. package/lib/testing/stub-hydranium-text-documents.js +16 -9
  80. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  81. package/lib/testing/stub-model-service.d.ts +2 -2
  82. package/lib/testing/stub-model-service.js +2 -2
  83. package/package.json +5 -5
  84. package/src/documents/ast-document-manager.ts +27 -78
  85. package/src/documents/client-session-errors.ts +11 -43
  86. package/src/documents/client-session-registry.ts +54 -28
  87. package/src/documents/file-system-task-queue.ts +68 -0
  88. package/src/documents/hydranium-text-documents.ts +348 -44
  89. package/src/documents/index.ts +1 -0
  90. package/src/langium/document-builder/document-builder.ts +16 -2
  91. package/src/langium/integrity/integrity-service.ts +9 -6
  92. package/src/langium/language-module.ts +4 -4
  93. package/src/langium/model-service/client-session.ts +363 -39
  94. package/src/langium/model-service/model-service.ts +91 -420
  95. package/src/langium/module.ts +14 -2
  96. package/src/langium/update-rewrite/update-rewrite.ts +1 -1
  97. package/src/langium/workspace/in-memory-file-system-provider.ts +11 -2
  98. package/src/langium/workspace/write-lock-scope.ts +1 -1
  99. package/src/lsp/hydranium-document-update-handler.ts +138 -92
  100. package/src/lsp/shared-module.ts +4 -8
  101. package/src/lsp/start-language-server.ts +3 -4
  102. package/src/testing/make-test-services.ts +25 -6
  103. package/src/testing/node/lsp-server-connection.ts +1 -1
  104. package/src/testing/node/scratch-workspace.ts +1 -1
  105. package/src/testing/run-update-pipeline.ts +4 -4
  106. package/src/testing/stub-ast-document-manager.ts +10 -24
  107. package/src/testing/stub-hydranium-text-documents.ts +29 -16
  108. package/src/testing/stub-model-service.ts +2 -2
@@ -8,21 +8,13 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import {
11
- type BasedOn,
12
11
  type CanonicalUri,
13
- type CloseModelArgs,
14
- ConflictError,
15
- isSnapshotVersion,
16
12
  defineMessage,
17
- Logger,
18
13
  type MaybeObservableValue,
19
14
  type MaybePromise,
20
15
  ObservableValue,
21
16
  type TransferElement,
22
- type OpenModelArgs,
23
- type Tracer,
24
- type TransferSaveArgs,
25
- type TransferUpdateArgs
17
+ type Tracer
26
18
  } from '@hydranium/protocol';
27
19
  import { type AstNode, DocumentState, type LangiumDocument, UriUtils, type URI } from '@hydranium/langium';
28
20
  import { type AstDiagnostic } from '../validation/document-validator.js';
@@ -35,10 +27,9 @@ import { type LogNameOptions } from '../diagnostics/logger.js';
35
27
  import { IntegrityService } from '../integrity/integrity-service.js';
36
28
  import { labelPhaseListener } from '../document-builder/labeled-phase-listener.js';
37
29
  import { LANGUAGE_CLIENT_ID } from '../../documents/client-ids.js';
38
- import { DocumentNotOpenError } from '../../documents/client-session-errors.js';
39
30
  import { type OpenOptions } from '../../documents/client-session-registry.js';
40
31
  import { type ServerSharedServices } from '../module.js';
41
- import { type ClientSession, DefaultClientSession } from './client-session.js';
32
+ import { type ClientSession } from './client-session.js';
42
33
 
43
34
  /**
44
35
  * The undo-stack entry for a server-authored write pushed to the editor.
@@ -74,29 +65,6 @@ class SaveSettleTimeoutError extends Error {}
74
65
  * and the defaults are the behaviour described on each one.
75
66
  */
76
67
  export interface ModelServiceOptions extends LogNameOptions {
77
- /**
78
- * When set, {@link ModelService.update} logs a `warn` line if its
79
- * end-to-end wait (serialise + content-change apply + rebuild +
80
- * settled-phase wait) exceeds this many milliseconds. Default
81
- * `undefined` (no warn line ever emitted; the underlying
82
- * `Logger.time` debug timing log is unchanged either way).
83
- *
84
- * Pure observability — does NOT abort the update, does NOT change
85
- * resolution semantics. Adopters wanting a hard timeout that throws
86
- * instead override {@link ModelService.update} on their subclass and
87
- * race the parent call against their own deadline.
88
- *
89
- * Recommended starting threshold: 2-5 seconds for interactive paths
90
- * (form save, diagram edit). Workspaces with very large documents
91
- * or slow validation may legitimately exceed 5s on the cold path —
92
- * tune per workspace.
93
- *
94
- * Accepts a {@link MaybeObservableValue} so the threshold can be a
95
- * fixed constant or bound to a user setting via `Settings.number`
96
- * and retuned live. Leaving it unset disables the warn line entirely
97
- * (and skips the per-update stopwatch).
98
- */
99
- readonly slowUpdateWarnMs?: MaybeObservableValue<number>;
100
68
  /**
101
69
  * Serialise the facade's own build under the workspace WRITE lock, the way
102
70
  * Langium's `DefaultDocumentUpdateHandler` dispatches its build. Default
@@ -119,9 +87,9 @@ export interface ModelServiceOptions extends LogNameOptions {
119
87
  * option removes is the wasted pass, not just its visible symptom.
120
88
  *
121
89
  * **What `false` buys.** `WorkspaceLock` is not reentrant. A caller that
122
- * reaches `update` / `save` / `rebuild` while already holding the write lock —
123
- * an integrity rule or build-phase pass that writes through this facade —
124
- * deadlocks: acquiring the lock cancels the running holder, and the new
90
+ * reaches a session's `update` / `save` or `rebuild` while already holding
91
+ * the write lock — an integrity rule or build-phase pass that writes
92
+ * back — deadlocks: acquiring the lock cancels the running holder, and the new
125
93
  * acquisition then waits for that holder to release while the holder waits
126
94
  * for this call. On `true` that shape is DETECTED and rejected with
127
95
  * {@link ReentrantWriteLockError} rather than hanging, wherever a host
@@ -144,10 +112,11 @@ export interface ModelServiceOptions extends LogNameOptions {
144
112
  *
145
113
  * In-process facade over the framework's document plumbing
146
114
  * (`HydraniumTextDocuments`, `LangiumDocuments`,
147
- * `DocumentBuilder`, `WritableFileSystemProvider`). Owns the
148
- * `open / request / update / save / ready` lifecycle that protocol heads
149
- * (LSP, data-server, GLSP) delegate to so coordinating those primitives
150
- * doesn't have to be re-implemented per-head.
115
+ * `DocumentBuilder`, `WritableFileSystemProvider`). Owns the document
116
+ * lifecycle the data-server and GLSP heads delegate to — client sessions that
117
+ * open, update, save and close documents, the phase reads, and `ready` — so
118
+ * coordinating those primitives doesn't have to be re-implemented per head.
119
+ * Every open and write goes through a session from {@link createSession}.
151
120
  *
152
121
  * "Model" here means the parsed AST — distinct from the wire-shape
153
122
  * `TransferDocument` in `@hydranium/protocol`.
@@ -157,9 +126,9 @@ export interface ModelServiceOptions extends LogNameOptions {
157
126
  * Multiple in-process consumers want the same workspace-level
158
127
  * operations:
159
128
  * - The data-server head turns these into typed RPC methods.
160
- * - The GLSP head uses the same lifecycle for diagram-driven edits;
161
- * GModel operation handlers route through `update` for AST mutation,
162
- * `waitForDocumentState` for indexed-phase waits before reads, etc.
129
+ * - The GLSP head uses the same lifecycle for diagram-driven edits: GModel
130
+ * operation handlers write through the diagram's session and wait on phases
131
+ * before reading.
163
132
  * - Server-internal callers (integrity service, ad-hoc bridges, tests)
164
133
  * want the same operations without the wire serialisation step.
165
134
  *
@@ -172,15 +141,16 @@ export interface ModelServiceOptions extends LogNameOptions {
172
141
  *
173
142
  * **Read-latest supersession**
174
143
  *
175
- * The default `update` and `save` flows use Langium's per-URI
144
+ * A session's default `update` and `save` use Langium's per-URI
176
145
  * `DocumentBuilder.waitUntil` to wait for the integrity-settled landmark
177
146
  * ({@link IntegrityService.SettledState}), then read the post-build state. Concurrent
178
147
  * in-process callers on the same URI all see the latest post-build
179
148
  * snapshot — none deadlock waiting for a specific version's settled
180
149
  * event. Adopters wanting strict version-matched semantics (resolve
181
150
  * with vN's snapshot specifically, log "vN superseded by vM at vN+1")
182
- * override {@link update} to attach a phase-listener with explicit
183
- * version checks; the framework default doesn't need it for safety.
151
+ * override `DefaultClientSession.updateDocument` to attach a phase-listener
152
+ * with explicit version checks; the framework default doesn't need it for
153
+ * safety.
184
154
  *
185
155
  * **Returns AST snapshots, not wire envelopes**
186
156
  *
@@ -232,7 +202,7 @@ export interface ModelServiceOptions extends LogNameOptions {
232
202
  * **Not the `TransferEncoder`'s parameter of the same name**, which is
233
203
  * that encoder's OUTPUT and so names the wire shape. This one names its
234
204
  * input, and an adopter binds the two to different types.
235
- * - `TTransfer` — transfer-model root accepted by `update` / `save`
205
+ * - `TTransfer` — transfer-model root a session's `update` / `save` accept
236
206
  * args. Constrained to {@link TransferElement}. Defaults to the
237
207
  * structural base.
238
208
  */
@@ -266,13 +236,13 @@ export interface ModelService<
266
236
  indexed(uri: string, cancelToken?: CancellationToken): Promise<AstDocument<TAst, never>>;
267
237
  validated(uri: string, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
268
238
 
269
- update(args: TransferUpdateArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
270
- save(args: TransferSaveArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
271
-
272
- open(args: OpenModelArgs): Promise<Disposable>;
273
- close(args: CloseModelArgs): Promise<void>;
274
239
  isOpen(uri: string): boolean;
275
240
  snapshot(uri: string): AstDocument<TAst, TDiagnostic> | undefined;
241
+ /**
242
+ * The text a session's write of `model` to `uri` applies: a textual model
243
+ * as given, a structured one rewritten and serialised.
244
+ */
245
+ modelToText(uri: string, model: TTransfer | string, cancelToken?: CancellationToken): Promise<string>;
276
246
  getDocument(uri: string): LangiumDocument | undefined;
277
247
 
278
248
  onModelUpdated(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable;
@@ -280,12 +250,17 @@ export interface ModelService<
280
250
  onClientClosed(uri: string, clientId: string, listener: () => void): Disposable;
281
251
 
282
252
  /**
283
- * Start a client session. The id defaults to `label#` plus a random UUID;
284
- * a fixed `clientId` is taken as given. Throws `DuplicateClientIdError` when
285
- * the id is reserved by the framework, held by another live session, or has
253
+ * Start a client session, the only way to open and write documents through
254
+ * this service. Pass a `label` naming the participant; without one it is
255
+ * `session`. The id defaults to `label#` plus a random UUID; a fixed
256
+ * `clientId` is taken as given. Throws `DuplicateClientIdError` when the id
257
+ * is reserved by the framework, held by another live session, or has
286
258
  * documents open under it as a client that is not a session.
287
259
  *
288
- * `TOpenOptions` types the options the session's `open` takes.
260
+ * `TOpenOptions` types the options the session's `open` takes and its
261
+ * `openOptions` returns. The narrowing is an unchecked cast, and it holds
262
+ * only for the caller that started the session: a holder reached through
263
+ * {@link getSession} sees plain `OpenOptions`.
289
264
  */
290
265
  createSession<TOpenOptions extends OpenOptions = OpenOptions>(
291
266
  label?: string,
@@ -299,7 +274,7 @@ export class DefaultModelService<
299
274
  TAst extends AstNode,
300
275
  TDiagnostic extends AstDiagnostic = AstDiagnostic,
301
276
  /**
302
- * Structured payload accepted by `update` / `save`. Constrained to
277
+ * Structured payload a session's `update` / `save` accept. Constrained to
303
278
  * {@link TransferElement} — the minimal `{ readonly $type: string }`
304
279
  * shape. Both transfer-model overlay roots (which extend
305
280
  * `TransferElement` explicitly) and AST root types (which have
@@ -319,12 +294,6 @@ export class DefaultModelService<
319
294
  * (see {@link DocumentUriPolicy}).
320
295
  */
321
296
  protected readonly uriPolicy: DocumentUriPolicy;
322
- /**
323
- * Live slow-update-warn threshold cell, or `undefined` when the option
324
- * was not supplied (warn line + stopwatch disabled). Reads `.value` per
325
- * update so a setting-bound threshold retunes without reconstruction.
326
- */
327
- protected readonly slowUpdateWarn?: ObservableValue<number>;
328
297
  /** See {@link ModelServiceOptions.serializeBuilds}. Defaults to `true`. */
329
298
  protected readonly serializeBuilds: ObservableValue<boolean>;
330
299
 
@@ -337,11 +306,7 @@ export class DefaultModelService<
337
306
  protected readonly syncChains = new Map<string, Promise<void>>();
338
307
  protected readonly pendingSync = new Map<string, string>();
339
308
 
340
- /**
341
- * The live sessions this service started, by client id. {@link update}
342
- * reads it to tell a session's write, which must find its document open,
343
- * from a write under a plain client id, which opens it.
344
- */
309
+ /** The live sessions this service started, by client id, for {@link getSession}. */
345
310
  protected readonly sessions = new Map<string, ClientSession<TAst, TDiagnostic, TTransfer>>();
346
311
  /**
347
312
  * Drops an ended session from {@link sessions}. Subscribed by the first
@@ -391,7 +356,6 @@ export class DefaultModelService<
391
356
  ) {
392
357
  this.tracer = services.Tracer.for(options.logName ?? 'ModelService').trace('instantiated');
393
358
  this.uriPolicy = services.workspace.DocumentUriPolicy;
394
- this.slowUpdateWarn = options.slowUpdateWarnMs !== undefined ? ObservableValue.from(options.slowUpdateWarnMs) : undefined;
395
359
  this.serializeBuilds = ObservableValue.from(options.serializeBuilds ?? true);
396
360
  // Optional-chain so a harness that binds no WorkspaceManager awaits
397
361
  // `undefined` and resolves immediately; production hosts always have it
@@ -533,6 +497,12 @@ export class DefaultModelService<
533
497
  * {@link ModelServiceOptions.serializeBuilds} — see there for the
534
498
  * non-reentrancy hazard that is the reason the opt-out exists.
535
499
  *
500
+ * An override calls the base before it awaits. Under an LSP connection the
501
+ * store's change event has already asked the update handler to build a
502
+ * session's write; an override whose await outlasts that build makes the
503
+ * base's request start a build of its own, so the write is built and
504
+ * delivered twice.
505
+ *
536
506
  * Returns an empty `{ root, diagnostics }` envelope when the document
537
507
  * cannot be loaded; adopters that want to throw override this method
538
508
  * on their subclass.
@@ -696,189 +666,6 @@ export class DefaultModelService<
696
666
  return this.rebuild(uri, target, cancelToken);
697
667
  }
698
668
 
699
- /**
700
- * Apply an update for `uri`. The structured-or-textual `model` payload
701
- * is serialised (via {@link serialize} after {@link rewriteModel} when
702
- * structured), pushed into the multi-client text-document store with
703
- * a fresh version, drives a build to the target phase, and returns
704
- * the post-build AST snapshot.
705
- *
706
- * **Read-latest supersession**: concurrent callers on the same URI
707
- * all see the same post-build state once `waitUntil` resolves; none
708
- * deadlock waiting for a specific version's settled event. The
709
- * framework emits a post-resolution `debug` log line distinguishing
710
- * "vN ready" from "vN ready at vM (superseded)" so callers can
711
- * observe when their write was overtaken by a newer one before
712
- * settling — purely observability, doesn't change resolution
713
- * semantics. Adopters wanting version-matched resolution (resolve
714
- * with vN's specific settled snapshot, intermediate-phase
715
- * observability, slow-warn / hard-timeout behaviour) override this
716
- * method.
717
- *
718
- * **Upsert, except for a session.** Under a plain client id the write opens
719
- * the document for that client first, creating it from the payload when no
720
- * file exists. Under the id of a live session from {@link createSession} it
721
- * opens nothing, and fails with `DocumentNotOpenError` unless the session
722
- * has the URI open at the moment the text is applied.
723
- */
724
- async update(args: TransferUpdateArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
725
- // Read before the first await, while the call is still the caller's: a
726
- // session that ends during the serialise would otherwise read as a plain
727
- // client id here, and the write would open the document for an id no
728
- // session owns any more and land there.
729
- const bySession = this.sessions.has(args.clientId);
730
- const stopwatch = this.slowUpdateWarn !== undefined ? this.services.Clock.stopwatch() : undefined;
731
- // Per-stage self-time breakdown of the update/reconcile chain (serialise →
732
- // open → apply → rebuild), opt-in at debug — the default path skips the
733
- // session and `run` calls the stage directly. `update` is a per-operation
734
- // method (one user save / diagram edit), not a per-node hot loop, so the
735
- // stage closures `run` allocates on the non-debug path are negligible.
736
- // Canonicalize the write URI once at the door and thread the resulting
737
- // CanonicalUri through the chain. The text store keys documents by their
738
- // canonical identity, so any spelling of a file — a canonical
739
- // (server-identity) URI from a GLSP cross-document save derived from
740
- // `findDocument(node).uri`, or the symlink path an editor opened — collapses
741
- // to the one registration; there is no second registration to fork. The
742
- // build step reuses the canonical wait core (`rebuildCanonical`) so the
743
- // identity is not re-resolved downstream.
744
- const uri = this.uriPolicy.canonicalUri(args.uri);
745
- const session = Logger.isLevelEnabled('debug') ? this.tracer.profile(`model-update ${uri}`) : undefined;
746
- const run = async <T>(stage: string, fn: () => MaybePromise<T>): Promise<T> => (session ? session.scope(stage, fn) : fn());
747
- // Open WITH the new text so a cold URI (no open editor, no file on disk) is
748
- // created from the payload rather than read from the filesystem — `update`
749
- // is an upsert. For an already-open document `open` only attaches the
750
- // client (the text is ignored on that branch), and the actual update is
751
- // `AstDocumentManager.update`'s. `version` is intentionally NOT
752
- // forwarded to `open`, so a cold create stays at its initial version
753
- // rather than adopting a number the caller chose.
754
- //
755
- // The gate reads the version and decides BEFORE the open, and both halves
756
- // of that matter.
757
- //
758
- // Reading first, because for a document no client holds open the open
759
- // assigns the shared version from the INCOMING text, so a version read
760
- // afterwards has already absorbed the caller's own write. Gating on it
761
- // rejects every modifying write to a closed document, comparing the
762
- // caller's `basedOn` against a number the caller itself produced — and a
763
- // serialised round-trip that is not byte-identical to the stored text is
764
- // enough to trigger that. Reading first keeps both cases the gate exists
765
- // for: an unknown URI answers 0, so a based-on-version update of a
766
- // not-yet-existing document still trips it, and a genuine conflict still
767
- // trips it, another writer having advanced the sequence past the version
768
- // the caller read.
769
- //
770
- // Deciding first, because the open is a mutation and `update` is an
771
- // upsert: for a cold URI it installs the payload in the shared text store
772
- // and records a hold for the writing client. Throwing after it hands the
773
- // caller its rejection while the server keeps the refused text — and the
774
- // hold outlives the call, because a client whose write failed has no
775
- // reason to close a document it never asked to open. Serialisation is
776
- // held back too; it is the adopter's code and need not be side-effect
777
- // free.
778
- const textDocuments = this.services.workspace.TextDocuments;
779
- this.checkBasedOn(uri, args.basedOn, textDocuments.version(uri));
780
- // Whether this call's own open is the one that creates the document, read
781
- // in the same step as the check above so no other open can land between.
782
- const opensCold = !bySession && textDocuments.get(uri) === undefined;
783
- const text = await run('serialize', () => this.modelToText(uri, args.model, cancelToken));
784
- if (!bySession) {
785
- await run('open', () => this.open({ uri, clientId: args.clientId, text }));
786
- }
787
- const appliedVersion = await run('apply', () => {
788
- // A session's write never opens its document. The check sits in the
789
- // same synchronous step as the apply: made before the serialize await
790
- // instead, it passes for a write whose session closes the document
791
- // during that await, and the write then lands on a document the
792
- // session no longer has open.
793
- if (bySession && !textDocuments.isOpenInClient(uri, args.clientId)) {
794
- throw new DocumentNotOpenError(uri, args.clientId);
795
- }
796
- // The gate again, in the same step as the apply: two writes based on
797
- // one version both pass the check at the door while they serialise,
798
- // and without this both apply. The version a cold open assigned from
799
- // this write's own text is not a conflict; the store holding exactly
800
- // that text is how it shows.
801
- if (!(opensCold && textDocuments.get(uri)?.getText() === text)) {
802
- this.checkBasedOn(uri, args.basedOn, textDocuments.version(uri));
803
- }
804
- return this.services.workspace.AstDocumentManager.update(uri, text, args.clientId);
805
- });
806
- // Dispatch through the public `rebuild` (which re-canonicalizes the already-
807
- // canonical `uri` once, idempotently) rather than `rebuildCanonical`, so an
808
- // adopter `rebuild` override stays in the update path. The redundant call is
809
- // a single kernel-cached `realpath`; correctness of the override contract
810
- // wins over shaving it.
811
- const doc = await run('rebuild', () => this.rebuild(uri, undefined, cancelToken));
812
- const finalVersion = this.services.workspace.TextDocuments.version(uri);
813
- if (finalVersion > appliedVersion) {
814
- this.tracer.debug(`Update to v${appliedVersion} ready at v${finalVersion} (superseded)`);
815
- } else {
816
- this.tracer.debug(`Update to v${appliedVersion} ready`);
817
- }
818
- if (this.slowUpdateWarn !== undefined && stopwatch) {
819
- const elapsed = Math.round(stopwatch.elapsedMs);
820
- const threshold = this.slowUpdateWarn.value;
821
- if (elapsed >= threshold) {
822
- this.tracer.withUri(uri).warn(`Slow update: ${elapsed}ms ≥ ${threshold}ms (v${appliedVersion}, client=${args.clientId})`);
823
- }
824
- }
825
- // One line per stage (serialise / open / apply / rebuild) + unaccounted — only when profiling.
826
- session?.report('debug');
827
- return doc;
828
- }
829
-
830
- /**
831
- * Throw {@link ConflictError} when `basedOn` names a version other than
832
- * `currentVersion`. {@link update} calls it at the door and again in the
833
- * synchronous step that applies the text, and has to call it synchronously
834
- * there: a check separated from the apply by an await lets two writes based
835
- * on one version both apply.
836
- */
837
- protected checkBasedOn(uri: string, basedOn: BasedOn, currentVersion: number): void {
838
- if (isSnapshotVersion(basedOn) && currentVersion !== basedOn) {
839
- // Distinct from the post-build "superseded" debug line in `update`: this
840
- // is a based-on-stale rejection (the write never applies), not two
841
- // writes racing.
842
- this.tracer.debug(`Conflict on ${uri}: based-on v${basedOn} stale, server at v${currentVersion}`);
843
- throw new ConflictError(uri, basedOn, currentVersion);
844
- }
845
- }
846
-
847
- /**
848
- * Persist `uri` to disk. Same content-change + settled-phase flow as
849
- * {@link update}, then writes via the
850
- * `WritableFileSystemProvider` and notifies the multi-client
851
- * text-document store of the save (so any open LSP-side editor sees
852
- * the `onDidSave` event regardless of who originated the persist).
853
- *
854
- * Returns the post-save AST snapshot.
855
- *
856
- * Under the id of a live session the save also fails with
857
- * `DocumentNotOpenError` when the session closes the URI while the text is
858
- * being built, and writes nothing. Once the manager has taken the text, the
859
- * write completes whatever the session does next.
860
- */
861
- async save(args: TransferSaveArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
862
- // Read before the first await, as in `update`: a session that ends during
863
- // the rebuild would otherwise read as a plain client id below.
864
- const bySession = this.sessions.has(args.clientId);
865
- // Dispatch through `update` (not its internals) so an adopter `update`
866
- // override — version-matched resolution, etc. — applies to saves too.
867
- const doc = await this.update(args, cancelToken);
868
- // Persist under the same canonical identity `update` operated on. Writing
869
- // the canonical (real) path follows any symlink to the same file, and the
870
- // `onDidSave` keys the one canonical registration.
871
- const uri = this.uriPolicy.canonicalUri(args.uri);
872
- // In the same synchronous step as the manager taking the text: checked
873
- // any earlier, a session that closes the URI during the rebuild still has
874
- // the shared text, other clients' edits included, written in its name.
875
- if (bySession && !this.services.workspace.TextDocuments.isOpenInClient(uri, args.clientId)) {
876
- throw new DocumentNotOpenError(uri, args.clientId);
877
- }
878
- await this.services.workspace.AstDocumentManager.save(uri, args.clientId);
879
- return doc;
880
- }
881
-
882
669
  /**
883
670
  * Wait for the document at `uri` to reach the integrity-settled landmark and
884
671
  * drain any in-flight write-path applyEdit sync chain (see {@link syncChains})
@@ -914,38 +701,6 @@ export class DefaultModelService<
914
701
  }
915
702
  }
916
703
 
917
- /**
918
- * Open the document at `args.uri` on behalf of `args.clientId`.
919
- * Multi-client: each (uri, clientId) pair is tracked as one
920
- * registration; the underlying document stays open until the last
921
- * client closes it. If `args.text` is omitted the document content
922
- * is read from the `FileSystemProvider`.
923
- *
924
- * Returns a {@link Disposable} that closes the registration when
925
- * disposed — useful for `using` blocks and shutdown cleanup.
926
- *
927
- * Delegates to the framework-bound
928
- * `services.workspace.AstDocumentManager`. Adopter subclasses with
929
- * extra open-time behaviour (logging, sync-chain bootstrapping)
930
- * override on their `ModelService` subclass and call `super.open`.
931
- *
932
- * The open path does not thread cancellation: `AstDocumentManager.open`
933
- * and the filesystem read behind it take no token, so a cancelled caller
934
- * still completes the open.
935
- */
936
- async open(args: OpenModelArgs): Promise<Disposable> {
937
- return this.services.workspace.AstDocumentManager.open(args);
938
- }
939
-
940
- /**
941
- * Close the document at `args.uri` for `args.clientId`. Counterpart
942
- * to {@link open}; the underlying document stays open until every
943
- * registered client has closed.
944
- */
945
- async close(args: CloseModelArgs): Promise<void> {
946
- return this.services.workspace.AstDocumentManager.close(args);
947
- }
948
-
949
704
  /**
950
705
  * True when the document at `uri` is currently open for at least one
951
706
  * client. Pure read; no side effects.
@@ -1020,7 +775,15 @@ export class DefaultModelService<
1020
775
  });
1021
776
  let session: ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
1022
777
  try {
1023
- session = this.newSession<TOpenOptions>(id, sessionLabel);
778
+ // Unchecked: the session keeps whatever options its `open` is handed,
779
+ // and the factory types it for every grammar, so the narrowed type
780
+ // holds only for the caller that started it.
781
+ session = this.services.model.ClientSessionFactory.create(id, sessionLabel) as ClientSession<
782
+ TAst,
783
+ TDiagnostic,
784
+ TTransfer,
785
+ TOpenOptions
786
+ >;
1024
787
  } catch (err: unknown) {
1025
788
  textDocuments.closeSession(id);
1026
789
  throw err;
@@ -1034,18 +797,35 @@ export class DefaultModelService<
1034
797
  }
1035
798
 
1036
799
  /**
1037
- * Build the handle for a session {@link createSession} has already
1038
- * registered. The override point for a custom session class; narrow the
1039
- * return type to it.
800
+ * Convert a structured-or-textual `model` payload to its textual form.
1040
801
  *
1041
- * Registration and the uniqueness check have already happened when this
1042
- * runs, so an override cannot skip them.
802
+ * Textual payloads (LSP / pre-serialised callers) pass through untouched.
803
+ * Structured payloads run the per-language `UpdateRewriteService` chain
804
+ * (transfer-model transforms; diff-aware rewrites see the previous AST root),
805
+ * then serialise. The chain is the single transfer-model-transform seam — a
806
+ * unary normalisation is just a rewrite that ignores `previous`, as
807
+ * `NormalizeEmptyStringsContribution` does. The chain is empty by
808
+ * default, so this is a no-op for adopters that register none.
1043
809
  */
1044
- protected newSession<TOpenOptions extends OpenOptions = OpenOptions>(
1045
- clientId: string,
1046
- label: string
1047
- ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
1048
- return new DefaultClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>(this, this.services, clientId, label);
810
+ async modelToText(uri: string, model: TTransfer | string, cancelToken?: CancellationToken): Promise<string> {
811
+ if (typeof model === 'string') {
812
+ return model;
813
+ }
814
+ const rewritten = await this.rewriteModel(uri, model, cancelToken);
815
+ const target = UriUtils.toUri(uri);
816
+ const trivia = this.services.ServiceRegistry?.getServices(target)?.trivia?.TriviaService;
817
+ // Extracted BEFORE serializing, so it reads the document the write is
818
+ // about to replace rather than whatever a concurrent build left behind.
819
+ let document = this.services.workspace.LangiumDocuments.getDocument(target);
820
+ if (trivia !== undefined && document === undefined) {
821
+ const source = await this.textToTakeTriviaFrom(uri, target);
822
+ if (source !== undefined) {
823
+ document = this.services.workspace.LangiumDocumentFactory.fromString(source, target);
824
+ }
825
+ }
826
+ const extracted = trivia !== undefined && document !== undefined ? trivia.extract(document) : undefined;
827
+ const serialized = await this.serialize(uri, rewritten);
828
+ return extracted === undefined ? serialized : trivia!.apply(serialized, extracted, target);
1049
829
  }
1050
830
 
1051
831
  // ============================================================
@@ -1056,23 +836,14 @@ export class DefaultModelService<
1056
836
  * Mirror a server-side change of `document` back to the LSP textual language
1057
837
  * client, run per document as it reaches the post-integrity settled phase.
1058
838
  * The single framework caller of
1059
- * `HydraniumTextDocuments.applyEditToLanguageClient` and
1060
- * `HydraniumTextDocuments.stagePendingContent`.
839
+ * `HydraniumTextDocuments.applyEditToLanguageClient`.
1061
840
  *
1062
- * Routes by client registration, because an open and a closed document
1063
- * answer different questions:
1064
- *
1065
- * - **Open in the language client** → {@link syncOpenDocument}: mirror the
1066
- * settled text via a coalesced `applyEditToLanguageClient`, routed purely by
1067
- * **content**.
1068
- * - **Open only in another client** (the data or GLSP head) → nothing. A
1069
- * language client opening it joins the existing entry and is refreshed from
1070
- * the store, and only a FIRST open reads a stage, so staging here would
1071
- * leave text nothing reads.
1072
- * - **Closed in every client** → {@link stageClosedDocument}, gated by
1073
- * **provenance** ({@link isNonLanguageClientEdit}). With the default text
1074
- * store this never stages; see {@link stageClosedDocument} for why, and
1075
- * for the store it serves.
841
+ * Only a document open in the language client is mirrored, through
842
+ * {@link syncOpenDocument}, routed purely by **content**. A document open
843
+ * only in another client needs nothing: a language client opening it joins
844
+ * the existing entry and is refreshed from the store. A session writes only
845
+ * what it has open, so no session edit of a closed document waits here for
846
+ * the language client's next open.
1076
847
  *
1077
848
  * The decision is **re-derived from the current settled state every time** (it
1078
849
  * is not a one-shot enrolment), which is what makes it self-healing: a doc
@@ -1092,11 +863,8 @@ export class DefaultModelService<
1092
863
  // the canonical key to the recorded language-client URI(s); driving the chain
1093
864
  // in client space here would key it under a URI `settleSave` never computes, so
1094
865
  // the drain would miss for a divergent open path.
1095
- const textDocuments = this.services.workspace.TextDocuments;
1096
- if (textDocuments.isOpenInLanguageClient(document.textDocument.uri)) {
866
+ if (this.services.workspace.TextDocuments.isOpenInLanguageClient(document.textDocument.uri)) {
1097
867
  this.syncOpenDocument(document.textDocument.uri, document.textDocument.getText());
1098
- } else if (!textDocuments.isOpenInAnyClient(document.textDocument.uri)) {
1099
- this.stageClosedDocument(document);
1100
868
  }
1101
869
  }
1102
870
 
@@ -1115,53 +883,6 @@ export class DefaultModelService<
1115
883
  this.queueSync(uri, text);
1116
884
  }
1117
885
 
1118
- /**
1119
- * Stage the settled text of a document no client holds so the eventual
1120
- * first `didOpen` sees this in-memory text instead of stale disk —
1121
- * but only for a {@link isNonLanguageClientEdit genuine non-language-client edit}.
1122
- * A document rebuilt by an internal build is skipped, leaving disk authoritative
1123
- * on the next open.
1124
- *
1125
- * The default `HydraniumTextDocuments` never gets this far. Its own paths
1126
- * record a version's author only while a client holds the document, and the
1127
- * last close drops that history with the tracking record, so a document no
1128
- * client holds reports no author and the provenance gate refuses. The stage
1129
- * is reachable for a text store that keeps authorship past the last close,
1130
- * or a subclass overriding {@link isNonLanguageClientEdit}.
1131
- */
1132
- protected stageClosedDocument(document: LangiumDocument): void {
1133
- if (this.isNonLanguageClientEdit(document)) {
1134
- this.services.workspace.TextDocuments.stagePendingContent(document.textDocument.uri, document.textDocument.getText());
1135
- }
1136
- }
1137
-
1138
- /**
1139
- * Whether `document`'s settled state is a genuine edit by a client *other than
1140
- * the language client* — a write a form / GLSP / integrity client actually made.
1141
- * Excludes two non-edits: the **language client** itself (the LSP/Monaco text
1142
- * client — it already holds its own edits, and the staging here exists to feed
1143
- * it) and an **internal build** (workspace startup, a cascade relink, a
1144
- * `didClose`-reload). This is the gate for {@link stageClosedDocument}: staging
1145
- * an internal build would
1146
- * (a) pre-stage every file on boot and (b) re-stage discarded content after
1147
- * close (e.g. a disposing GLSP session's debounced submit firing after close),
1148
- * which then shadows clean disk on the next open.
1149
- *
1150
- * The signal is "a known client other than the language client authored this
1151
- * version **and** the URI was in the last build's changed set
1152
- * (`isTriggeringEdit`)". A framework-internal rebuild reports no author
1153
- * (`getAuthor` → `undefined`), so it fails `hasKnownAuthor` without comparing
1154
- * against a sentinel. This is NOT redundant with content/registration — it
1155
- * distinguishes "client edited" from "framework rebuilt", which neither the
1156
- * shadow nor `isTriggeringEdit` alone can.
1157
- */
1158
- protected isNonLanguageClientEdit(document: LangiumDocument): boolean {
1159
- const documents = this.services.workspace.AstDocumentManager;
1160
- const author = documents.getAuthor(document);
1161
- const hasKnownAuthor = !!author && author !== LANGUAGE_CLIENT_ID;
1162
- return hasKnownAuthor && documents.isTriggeringEdit(document.textDocument.uri);
1163
- }
1164
-
1165
886
  /**
1166
887
  * Enqueue a sync to the language client. The pending slot per URI holds only
1167
888
  * the latest text: if a newer settle fires while the current RPC is in
@@ -1307,13 +1028,13 @@ export class DefaultModelService<
1307
1028
  * Adopters whose serializer call shape differs (custom service
1308
1029
  * names, generator-driven YAML pretty printers, etc.) override this
1309
1030
  * method. Routes through `Serializer.serializeTransfer` because
1310
- * `ModelService.update` / `ModelService.save` always receive a
1031
+ * a session's `update` / `save` always receive a
1311
1032
  * transfer-model shape (cross-references as plain strings) from
1312
1033
  * adopter callers — the AST-shape entry point is `serializeAst`.
1313
1034
  *
1314
1035
  * Returns {@link MaybePromise} so adopter `Serializer` overrides can be
1315
1036
  * async (remote schema lookup, external canonical-value resolution); the
1316
- * single caller ({@link modelToText} → {@link update}) is already `async`,
1037
+ * single caller ({@link modelToText}) is already `async`,
1317
1038
  * so a naive `await` covers both branches without extra ceremony.
1318
1039
  */
1319
1040
  protected serialize(uri: string, root: TTransfer): MaybePromise<string> {
@@ -1326,62 +1047,12 @@ export class DefaultModelService<
1326
1047
  // ============================================================
1327
1048
 
1328
1049
  /**
1329
- * Convert a structured-or-textual `model` payload to its textual form.
1330
- *
1331
- * Textual payloads (LSP / pre-serialised callers) pass through untouched.
1332
- * Structured payloads run the per-language `UpdateRewriteService` chain
1333
- * (transfer-model transforms; diff-aware rewrites see the previous AST root),
1334
- * then serialise. The chain is the single transfer-model-transform seam — a
1335
- * unary normalisation is just a rewrite that ignores `previous`, as
1336
- * `NormalizeEmptyStringsContribution` does. The chain is empty by
1337
- * default, so this is a no-op for adopters that register none.
1338
- */
1339
- protected async modelToText(uri: string, model: TTransfer | string, cancelToken?: CancellationToken): Promise<string> {
1340
- if (typeof model === 'string') {
1341
- return model;
1342
- }
1343
- const rewritten = await this.rewriteModel(uri, model, cancelToken);
1344
- const target = UriUtils.toUri(uri);
1345
- const trivia = this.services.ServiceRegistry?.getServices(target)?.trivia?.TriviaService;
1346
- // Extracted BEFORE serializing, so it reads the document the write is
1347
- // about to replace rather than whatever a concurrent build left behind.
1348
- let document = this.services.workspace.LangiumDocuments.getDocument(target);
1349
- if (trivia !== undefined && document === undefined) {
1350
- const source = await this.textToTakeTriviaFrom(uri, target);
1351
- if (source !== undefined) {
1352
- document = this.services.workspace.LangiumDocumentFactory.fromString(source, target);
1353
- }
1354
- }
1355
- const extracted = trivia !== undefined && document !== undefined ? trivia.extract(document) : undefined;
1356
- const serialized = await this.serialize(uri, rewritten);
1357
- return extracted === undefined ? serialized : trivia!.apply(serialized, extracted, target);
1358
- }
1359
-
1360
- /**
1361
- * The text a write into an unloaded document should take its trivia from —
1362
- * the open editor's if one holds it, the file's otherwise, and `undefined`
1363
- * when neither can supply it.
1364
- *
1365
- * **A read that fails answers `undefined` rather than throwing.** `getDocument`
1366
- * is a store read, so an ordinary create answers nothing here and must still
1367
- * write; letting a permission error or a path that turned into a directory
1368
- * escape would fail the user's write instead, having found nothing to preserve
1369
- * in a file the write is about to replace anyway. Losing the comments is
1370
- * recoverable, and traced; losing the write is neither.
1050
+ * The text a write into a document not yet built should take its trivia
1051
+ * from: the store's, which a write always has, since it writes only a
1052
+ * document its client has open.
1371
1053
  */
1372
- protected async textToTakeTriviaFrom(uri: string, target: URI): Promise<string | undefined> {
1373
- const open = this.services.workspace.TextDocuments.get(uri)?.getText();
1374
- if (open !== undefined) {
1375
- return open;
1376
- }
1377
- const fileSystem = this.services.workspace.FileSystemProvider;
1378
- try {
1379
- return (await fileSystem.exists(target)) ? await fileSystem.readFile(target) : undefined;
1380
- } catch (err: unknown) {
1381
- const detail = err instanceof Error ? err.message : String(err);
1382
- this.tracer.withUri(uri).debug(`Could not read the file to take trivia from; writing as emitted. ${detail}`);
1383
- return undefined;
1384
- }
1054
+ protected async textToTakeTriviaFrom(uri: string, _target: URI): Promise<string | undefined> {
1055
+ return this.services.workspace.TextDocuments.get(uri)?.getText();
1385
1056
  }
1386
1057
 
1387
1058
  /**