@hydranium/core 1.0.0-next.187 → 1.0.0-next.189

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 (96) hide show
  1. package/lib/documents/ast-document-manager.d.ts +26 -44
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +20 -50
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/hydranium-text-documents.d.ts +87 -30
  6. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  7. package/lib/documents/hydranium-text-documents.js +201 -37
  8. package/lib/documents/hydranium-text-documents.js.map +1 -1
  9. package/lib/documents/index.d.ts +2 -0
  10. package/lib/documents/index.d.ts.map +1 -1
  11. package/lib/documents/index.js +2 -0
  12. package/lib/documents/index.js.map +1 -1
  13. package/lib/documents/model-ledger.d.ts +47 -0
  14. package/lib/documents/model-ledger.d.ts.map +1 -0
  15. package/lib/documents/model-ledger.js +30 -0
  16. package/lib/documents/model-ledger.js.map +1 -0
  17. package/lib/documents/version-sync-service.d.ts +134 -0
  18. package/lib/documents/version-sync-service.d.ts.map +1 -0
  19. package/lib/documents/version-sync-service.js +171 -0
  20. package/lib/documents/version-sync-service.js.map +1 -0
  21. package/lib/langium/bootstrap.d.ts.map +1 -1
  22. package/lib/langium/bootstrap.js +16 -0
  23. package/lib/langium/bootstrap.js.map +1 -1
  24. package/lib/langium/document-builder/document-builder.d.ts +31 -25
  25. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  26. package/lib/langium/document-builder/document-builder.js +43 -39
  27. package/lib/langium/document-builder/document-builder.js.map +1 -1
  28. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  29. package/lib/langium/integrity/integrity-service.js +6 -15
  30. package/lib/langium/integrity/integrity-service.js.map +1 -1
  31. package/lib/langium/model-service/client-session.d.ts +14 -10
  32. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  33. package/lib/langium/model-service/client-session.js +18 -15
  34. package/lib/langium/model-service/client-session.js.map +1 -1
  35. package/lib/langium/model-service/model-service.d.ts +91 -41
  36. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  37. package/lib/langium/model-service/model-service.js +127 -50
  38. package/lib/langium/model-service/model-service.js.map +1 -1
  39. package/lib/langium/module.d.ts +6 -0
  40. package/lib/langium/module.d.ts.map +1 -1
  41. package/lib/langium/module.js +5 -1
  42. package/lib/langium/module.js.map +1 -1
  43. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  44. package/lib/langium/residency/cst-residency-service.js +10 -1
  45. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  46. package/lib/langium/shared-services.d.ts +6 -2
  47. package/lib/langium/shared-services.d.ts.map +1 -1
  48. package/lib/langium/shared-services.js.map +1 -1
  49. package/lib/langium/transfer/transfer-encoder.d.ts +19 -10
  50. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  51. package/lib/langium/transfer/transfer-encoder.js +8 -7
  52. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  53. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +31 -1
  54. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  55. package/lib/langium/workspace/hydranium-langium-document-factory.js +54 -0
  56. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  57. package/lib/langium/workspace/langium-documents.d.ts +7 -0
  58. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  59. package/lib/langium/workspace/langium-documents.js +10 -0
  60. package/lib/langium/workspace/langium-documents.js.map +1 -1
  61. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  62. package/lib/lsp/hydranium-document-update-handler.js +1 -0
  63. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  64. package/lib/testing/make-test-services.d.ts +7 -0
  65. package/lib/testing/make-test-services.d.ts.map +1 -1
  66. package/lib/testing/make-test-services.js +10 -2
  67. package/lib/testing/make-test-services.js.map +1 -1
  68. package/lib/testing/stub-ast-document-manager.d.ts +3 -2
  69. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  70. package/lib/testing/stub-ast-document-manager.js +7 -1
  71. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  72. package/lib/testing/stub-hydranium-text-documents.d.ts +2 -2
  73. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  74. package/lib/testing/stub-hydranium-text-documents.js +6 -1
  75. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  76. package/package.json +5 -5
  77. package/src/documents/ast-document-manager.ts +45 -77
  78. package/src/documents/hydranium-text-documents.ts +225 -46
  79. package/src/documents/index.ts +2 -0
  80. package/src/documents/model-ledger.ts +66 -0
  81. package/src/documents/version-sync-service.ts +267 -0
  82. package/src/langium/bootstrap.ts +16 -0
  83. package/src/langium/document-builder/document-builder.ts +76 -37
  84. package/src/langium/integrity/integrity-service.ts +6 -15
  85. package/src/langium/model-service/client-session.ts +29 -21
  86. package/src/langium/model-service/model-service.ts +182 -64
  87. package/src/langium/module.ts +9 -1
  88. package/src/langium/residency/cst-residency-service.ts +10 -1
  89. package/src/langium/shared-services.ts +6 -2
  90. package/src/langium/transfer/transfer-encoder.ts +25 -18
  91. package/src/langium/workspace/hydranium-langium-document-factory.ts +83 -0
  92. package/src/langium/workspace/langium-documents.ts +11 -0
  93. package/src/lsp/hydranium-document-update-handler.ts +3 -0
  94. package/src/testing/make-test-services.ts +15 -2
  95. package/src/testing/stub-ast-document-manager.ts +10 -2
  96. package/src/testing/stub-hydranium-text-documents.ts +8 -3
@@ -8,13 +8,14 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import {
11
- type BasedOn,
11
+ type BaseVersion,
12
12
  ConflictError,
13
- isSnapshotVersion,
13
+ isModelVersion,
14
14
  Logger,
15
15
  type MaybeObservableValue,
16
16
  type MaybePromise,
17
17
  ObservableValue,
18
+ type TextVersion,
18
19
  type Tracer,
19
20
  type TransferElement
20
21
  } from '@hydranium/protocol';
@@ -35,11 +36,11 @@ export interface ClientSessionWriteArgs<TTransfer> {
35
36
  /** The whole structured model root, or its serialised textual form. */
36
37
  model: TTransfer | string;
37
38
  /**
38
- * The version the write was authored against, or `'anything'` to write
39
+ * The version the write was authored against, or `'any'` to write
39
40
  * unconditionally. Required, so an ungated write is a decision the caller
40
41
  * makes rather than a field it forgot.
41
42
  */
42
- basedOn: BasedOn;
43
+ baseVersion: BaseVersion;
43
44
  }
44
45
 
45
46
  /** What a session's `updateAll` writes, all or none. */
@@ -89,8 +90,12 @@ export interface ClientSession<
89
90
  * disk on the first `save`. Fails when the file exists, any client has the
90
91
  * URI open, or the URI waits out the revert grace, and of two creates of one
91
92
  * URI at most one succeeds.
93
+ *
94
+ * Resolves with the version the created document took, which a write based
95
+ * on it names: once this resolves, the store's version may already be
96
+ * another client's write.
92
97
  */
93
- create(uri: string, text: string): Promise<void>;
98
+ create(uri: string, text: string): Promise<TextVersion>;
94
99
  /**
95
100
  * Write `args.model` into a document this session has open. Resolves to the
96
101
  * rebuilt document once it is validated, in whichever build carried the
@@ -221,7 +226,7 @@ export class DefaultClientSession<
221
226
  : undefined;
222
227
  }
223
228
 
224
- create(uri: string, text: string): Promise<void> {
229
+ create(uri: string, text: string): Promise<TextVersion> {
225
230
  this.assertLive();
226
231
  return this.createDocument(uri, text);
227
232
  }
@@ -279,19 +284,19 @@ export class DefaultClientSession<
279
284
  }
280
285
 
281
286
  /**
282
- * Throw `ConflictError` when `basedOn` names a version other than
287
+ * Throw `ConflictError` when `baseVersion` names a version other than
283
288
  * `currentVersion`. A write calls it at the door and again in the
284
289
  * synchronous step that applies the text, and has to call it synchronously
285
290
  * there: a check separated from the apply by an await lets two writes based
286
291
  * on one version both apply.
287
292
  */
288
- protected assertBasedOn(uri: string, basedOn: BasedOn, currentVersion: number): void {
289
- if (isSnapshotVersion(basedOn) && currentVersion !== basedOn) {
293
+ protected assertBaseVersion(uri: string, baseVersion: BaseVersion, currentVersion: number): void {
294
+ if (isModelVersion(baseVersion) && currentVersion !== baseVersion) {
290
295
  // Distinct from the post-build "superseded" debug line of an update: this
291
- // is a based-on-stale rejection (the write never applies), not two
296
+ // is a stale-base-version rejection (the write never applies), not two
292
297
  // writes racing.
293
- this.tracer.debug(`Conflict on ${uri}: based-on v${basedOn} stale, server at v${currentVersion}`);
294
- throw new ConflictError(uri, basedOn, currentVersion);
298
+ this.tracer.debug(`Conflict on ${uri}: stale base, model v${baseVersion} / text v${currentVersion}`);
299
+ throw new ConflictError(uri, baseVersion, currentVersion);
295
300
  }
296
301
  }
297
302
 
@@ -299,7 +304,7 @@ export class DefaultClientSession<
299
304
  * Serialise `args.model`, apply it to the store under this session's id,
300
305
  * rebuild, and resolve to the rebuilt document.
301
306
  *
302
- * The open check and the `basedOn` gate run at the door, so a write that
307
+ * The open check and the `baseVersion` gate run at the door, so a write that
303
308
  * cannot land is refused before any adopter serialiser runs, and again in
304
309
  * the synchronous step that applies the text: made only before the
305
310
  * serialiser's await, a close during it lets the write land on a document
@@ -321,11 +326,12 @@ export class DefaultClientSession<
321
326
  const run = async <T>(stage: string, fn: () => MaybePromise<T>): Promise<T> => (profile ? profile.scope(stage, fn) : fn());
322
327
  const textDocuments = this.services.workspace.TextDocuments;
323
328
  this.assertOpen(uri);
324
- this.assertBasedOn(uri, args.basedOn, textDocuments.version(uri));
329
+ this.assertBaseVersion(uri, args.baseVersion, textDocuments.version(uri));
330
+ service.assertCanBuild(uri);
325
331
  const text = await run('serialize', () => service.modelToText(uri, args.model, cancelToken));
326
332
  const appliedVersion = await run('apply', () => {
327
333
  this.assertOpen(uri);
328
- this.assertBasedOn(uri, args.basedOn, textDocuments.version(uri));
334
+ this.assertBaseVersion(uri, args.baseVersion, textDocuments.version(uri));
329
335
  return this.services.workspace.AstDocumentManager.update(uri, text, this.clientId);
330
336
  });
331
337
  // Through the public `rebuild`, so an override of it stays in the path.
@@ -352,7 +358,7 @@ export class DefaultClientSession<
352
358
 
353
359
  /**
354
360
  * Write every document of `args.updates`, all or none: every model is
355
- * serialised first, then every open check and `basedOn` gate runs and every
361
+ * serialised first, then every open check and `baseVersion` gate runs and every
356
362
  * text is applied in one synchronous step. Checked at the door as well, so
357
363
  * a stale set is refused before any adopter serialiser runs.
358
364
  *
@@ -374,7 +380,8 @@ export class DefaultClientSession<
374
380
  const check = (): void =>
375
381
  updates.forEach((update, i) => {
376
382
  this.assertOpen(uris[i]);
377
- this.assertBasedOn(uris[i], update.basedOn, textDocuments.version(uris[i]));
383
+ this.assertBaseVersion(uris[i], update.baseVersion, textDocuments.version(uris[i]));
384
+ service.assertCanBuild(uris[i]);
378
385
  });
379
386
  check();
380
387
  const texts: string[] = [];
@@ -455,7 +462,7 @@ export class DefaultClientSession<
455
462
  return this.services.workspace.DocumentUriPolicy.canonicalUri(uri);
456
463
  }
457
464
 
458
- protected async createDocument(uri: string, text: string): Promise<void> {
465
+ protected async createDocument(uri: string, text: string): Promise<TextVersion> {
459
466
  if (await this.services.workspace.FileSystemProvider.exists(UriUtils.toUri(uri))) {
460
467
  throw new Error(`Cannot create ${uri}: the file exists`);
461
468
  }
@@ -473,10 +480,10 @@ export class DefaultClientSession<
473
480
  // document holds `text`; the client list afterwards cannot, since a
474
481
  // client that registered first may have closed again by then.
475
482
  const key = this.canonicalKey(uri);
476
- let created = false;
483
+ let created: TextVersion | undefined;
477
484
  const listener = this.services.workspace.TextDocuments.onDidOpen(event => {
478
485
  if (event.clientId === this.clientId && event.document.uri === key) {
479
- created = true;
486
+ created = event.document.version;
480
487
  }
481
488
  });
482
489
  try {
@@ -485,10 +492,11 @@ export class DefaultClientSession<
485
492
  listener.dispose();
486
493
  }
487
494
  await this.rejectIfEnded(uri, false);
488
- if (!created) {
495
+ if (created === undefined) {
489
496
  await this.closeDocument(uri);
490
497
  throw new Error(`Cannot create ${uri}: it is open in a client`);
491
498
  }
499
+ return created;
492
500
  }
493
501
 
494
502
  protected async runWithOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T> {
@@ -15,13 +15,15 @@ import {
15
15
  ObservableValue,
16
16
  TIMED_OUT,
17
17
  type TransferElement,
18
- type Tracer
18
+ type TextVersion,
19
+ type Tracer,
20
+ UNRECORDED_VERSION
19
21
  } from '@hydranium/protocol';
20
- import { type AstNode, DocumentState, type LangiumDocument, UriUtils, type URI } from '@hydranium/langium';
22
+ import { type AstNode, DocumentState, type LangiumDocument, OperationCancelled, UriUtils, type URI } from '@hydranium/langium';
21
23
  import { type AstDiagnostic } from '../validation/document-validator.js';
22
24
  import { type DocumentUriPolicy } from '../workspace/document-uri-policy.js';
23
25
  import { ReentrantWriteLockError, isInsideWriteLock, isWriteLockScopeInstalled } from '../workspace/write-lock-scope.js';
24
- import { type CancellationToken, type Disposable } from 'vscode-languageserver';
26
+ import { CancellationToken, type Disposable } from 'vscode-languageserver';
25
27
  import { AstDocument, type AstDocumentSavedEvent, type AstDocumentUpdatedEvent } from '../../documents/ast-document-manager.js';
26
28
  import { isConnectionGoneError } from '../../util/connection-liveness.js';
27
29
  import { type LogNameOptions } from '../diagnostics/logger.js';
@@ -86,6 +88,17 @@ export interface ModelServiceOptions extends LogNameOptions {
86
88
  readonly allowReentrantBuilds?: MaybeObservableValue<boolean>;
87
89
  }
88
90
 
91
+ /** Options for a {@link DefaultModelService} wait on one document. */
92
+ export interface SyncedWaitOptions {
93
+ /**
94
+ * Build a root parsed from text older than the store's at the call, and
95
+ * reject once that build, or one the builder re-queues, is given up. Without
96
+ * it, such a root is left to other builds, and a wait they never end stays
97
+ * pending until its token is cancelled.
98
+ */
99
+ readonly sync?: boolean;
100
+ }
101
+
89
102
  /**
90
103
  * The seam every non-LSP head talks to: the data server, the GLSP head and an
91
104
  * adopter's own services reach documents through this slot rather than through
@@ -151,11 +164,22 @@ export interface ModelServiceOptions extends LogNameOptions {
151
164
  * by URI happens here so consumers can subscribe per-document without
152
165
  * implementing the URI gate at each callsite.
153
166
  *
154
- * **Two families, and the distinction matters more than the names suggest.**
155
- * `waitFor*` requests no build of its own: a wait on a URI with no document
156
- * rejects, and one on a document no build will carry is re-queued by the
157
- * builder's wait. `ensureDocumentState` and the phase shorthands over it
158
- * *dispatch*: warm documents are awaited, cold ones are built.
167
+ * **Two families.** `waitFor*` only waits: it rejects for a URI with no
168
+ * document, and for a root behind the store's text it waits for whatever
169
+ * builds it next, so it never ends if nothing does. `ensureDocumentState` and
170
+ * the phase shorthands over it build a missing document, and build a root
171
+ * behind its text, rejecting when that build fails twice. Both re-queue a
172
+ * document the builder's last build left short of the state.
173
+ *
174
+ * Both resolve at the state with a root parsed from text no older than the
175
+ * store's version at the call; inside the write lock, at the state alone, since
176
+ * the build that would sync the document to that version cannot start until
177
+ * it is released.
178
+ *
179
+ * Do not await one inside a build phase listener: a browser host installs no
180
+ * write-lock scope, so the wait cannot tell it is inside a build and waits for
181
+ * one that cannot start. Nor inside a `WorkspaceLock.read`: the lock starts no
182
+ * write, the build included, while a read runs.
159
183
  *
160
184
  * **Diagnostics are typed `never` below `Validated`.** Validation is the last
161
185
  * phase, so at any earlier landmark the array either is not yet computed or
@@ -171,8 +195,8 @@ export interface ModelServiceOptions extends LogNameOptions {
171
195
  * The waits resolve at or ABOVE their target, so an already-validated document
172
196
  * does carry usable diagnostics and the `never` over-forbids there. That
173
197
  * direction is the safe one: the alternative permits stale reads silently. A
174
- * member taking a phase as a PARAMETER cannot judge statically and so returns
175
- * `TDiagnostic`, leaving the choice to the caller.
198
+ * member taking a phase as a PARAMETER returns `TDiagnostic`, and leaves them
199
+ * absent for a phase below `Validated`.
176
200
  *
177
201
  * **Generic parameters.**
178
202
  * - `TAst` — the AST root type each consumer expects on the returned
@@ -203,14 +227,20 @@ export interface ModelService<
203
227
  */
204
228
  readonly ready: Promise<void>;
205
229
 
206
- // Pure waits — never trigger a build.
230
+ // Wait only; a root behind its text is left to another build.
207
231
  waitForDocumentState(uri: string, state: DocumentState, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
208
232
  waitForDocumentSettled(uri: string, cancelToken?: CancellationToken): Promise<AstDocument<TAst, never>>;
209
233
  waitForBuilderState(state: DocumentState, cancelToken?: CancellationToken): Promise<void>;
210
234
 
211
- // Wait if warm, build if cold.
235
+ // Build what is missing or behind its text, then wait.
212
236
  ensureDocumentState(uri: string, state?: DocumentState, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
213
237
  rebuild(uri: string, state?: DocumentState, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
238
+ /**
239
+ * Throw where {@link rebuild} would reject as reentrant. A write calls it
240
+ * before applying its text: a text applied and then refused stays applied
241
+ * with no build to follow it.
242
+ */
243
+ assertCanBuild(uri: string): void;
214
244
  parsed(uri: string, cancelToken?: CancellationToken): Promise<AstDocument<TAst, never>>;
215
245
  linked(uri: string, cancelToken?: CancellationToken): Promise<AstDocument<TAst, never>>;
216
246
  settled(uri: string, cancelToken?: CancellationToken): Promise<AstDocument<TAst, never>>;
@@ -379,9 +409,12 @@ export class DefaultModelService<
379
409
  // ============================================================
380
410
 
381
411
  /**
382
- * Wait for the document at `uri` to reach `state`. Pure wait — does
383
- * not trigger a build. If `uri` is not yet in the document registry
384
- * the call rejects; for the cold-start case use {@link rebuild} instead.
412
+ * Wait for the document at `uri` to reach `state` with a root no older than
413
+ * the store's text at the call. Rejects if `uri` is not in the document
414
+ * registry. A root behind its text is not built: the wait lasts until
415
+ * something else builds it, so use {@link ensureDocumentState} where
416
+ * nothing may. Builds that keep failing leave the wait pending, to its
417
+ * cancellation token.
385
418
  *
386
419
  * Wrapped in a debug-level timing log via {@link Tracer.time} so
387
420
  * slow per-URI waits surface in build telemetry; the URI is
@@ -395,15 +428,12 @@ export class DefaultModelService<
395
428
  * Wait for the document at `uri` to reach the integrity-settled landmark
396
429
  * ({@link IntegrityService.SettledState}) — the earliest phase at which the
397
430
  * AST + serialised text are stable post-integrity. Convenience wrapper over
398
- * {@link waitForDocumentState} for the common "wait until content is stable"
399
- * case (e.g. settling a save). Pure wait — does not trigger a build.
431
+ * {@link waitForDocumentState}, and like it builds nothing behind its text.
400
432
  */
401
433
  async waitForDocumentSettled(uri: string, cancelToken?: CancellationToken): Promise<AstDocument<TAst, never>> {
402
- return (await this.waitForDocumentStateCanonical(
403
- this.uriPolicy.canonicalUri(uri),
404
- IntegrityService.SettledState,
405
- cancelToken
406
- )) as AstDocument<TAst, never>;
434
+ return this.withoutDiagnostics(
435
+ await this.waitForDocumentStateCanonical(this.uriPolicy.canonicalUri(uri), IntegrityService.SettledState, cancelToken)
436
+ );
407
437
  }
408
438
 
409
439
  /**
@@ -415,21 +445,108 @@ export class DefaultModelService<
415
445
  * the identity once instead of re-running the (filesystem-touching) `realpath`
416
446
  * at every wait. The `CanonicalUri` parameter type enforces that — a raw
417
447
  * `string` cannot be passed without minting through the URI policy.
448
+ *
449
+ * With `options.sync`, a root behind the store's text is built, as in
450
+ * {@link ensureDocumentState}; without it, the root is left to other builds,
451
+ * as in {@link waitForDocumentState}. Below `Validated` the envelope has no
452
+ * diagnostics: see {@link withoutDiagnostics}.
418
453
  */
419
454
  protected async waitForDocumentStateCanonical(
420
455
  uri: CanonicalUri,
421
456
  state: DocumentState,
422
- cancelToken?: CancellationToken
457
+ cancelToken?: CancellationToken,
458
+ options: SyncedWaitOptions = {}
423
459
  ): Promise<AstDocument<TAst, TDiagnostic>> {
424
460
  const documentUri = UriUtils.toUri(uri);
461
+ // Read at call time: a later edit must not extend this wait.
462
+ const textVersion = isInsideWriteLock() ? undefined : this.services.workspace.TextDocuments.textState(uri)?.version;
425
463
  await this.tracer
426
464
  .withUri(uri)
427
465
  .time(
428
466
  `Wait for document state '${DocumentState[state]}'`,
429
- () => this.services.workspace.DocumentBuilder.waitUntil(state, documentUri, cancelToken),
467
+ () => this.waitUntilSynced(documentUri, state, textVersion, cancelToken, options),
430
468
  'debug'
431
469
  );
432
- return this.toAstDocument(documentUri);
470
+ const document = this.toAstDocument(documentUri);
471
+ return state >= DocumentState.Validated ? document : this.withoutDiagnostics(document);
472
+ }
473
+
474
+ /**
475
+ * Wait for `state` with a root parsed from text at `textVersion` or later. A
476
+ * root no factory recorded counts as synced, since no build records it.
477
+ * With `options.sync`, a root still behind is built through
478
+ * `VersionSyncService.syncTo`, and the wait rejects once that build, or one
479
+ * the builder re-queues to reach `state`, is given up. Without it, the wait
480
+ * lasts until another build parses the text, and stays pending when the
481
+ * builder gives up re-queuing the document.
482
+ */
483
+ protected async waitUntilSynced(
484
+ uri: URI,
485
+ state: DocumentState,
486
+ textVersion: TextVersion | undefined,
487
+ cancelToken?: CancellationToken,
488
+ options: SyncedWaitOptions = {}
489
+ ): Promise<void> {
490
+ const builder = this.services.workspace.DocumentBuilder;
491
+ const sync = this.services.workspace.VersionSyncService;
492
+ const waitOptions = { rejectWhenStuck: options.sync === true };
493
+ await builder.waitUntil(state, uri, cancelToken, waitOptions);
494
+ if (textVersion === undefined) {
495
+ return;
496
+ }
497
+ while (!sync.isSyncedTo(uri, textVersion)) {
498
+ const build = options.sync === true ? sync.syncTo(uri, textVersion) : undefined;
499
+ this.tracer
500
+ .withUri(uri.toString())
501
+ .debug(`waiting for ${uri.toString()} to reach ${DocumentState[state]} at v${textVersion} or later`);
502
+ await this.nextParseOrDeletion(uri, cancelToken, build);
503
+ await builder.waitUntil(state, uri, cancelToken, waitOptions);
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Resolves once a parse of `uri` has been recorded or a build has deleted
509
+ * it, rejecting with `OperationCancelled` when `cancelToken` is cancelled
510
+ * first, and with an error when `build`, the build requested for it, is
511
+ * given up. A `Parsed` phase listener cannot serve: a cancel right after the
512
+ * parse skips it, and the resumed build does not parse again.
513
+ */
514
+ protected nextParseOrDeletion(
515
+ uri: URI,
516
+ cancelToken: CancellationToken = CancellationToken.None,
517
+ build?: Promise<boolean>
518
+ ): Promise<void> {
519
+ const builder = this.services.workspace.DocumentBuilder;
520
+ return new Promise<void>((resolve, reject) => {
521
+ // A cancelled token's listener runs a macrotask later, after a build
522
+ // that started meanwhile may have parsed.
523
+ if (cancelToken.isCancellationRequested) {
524
+ reject(OperationCancelled);
525
+ return;
526
+ }
527
+ const done = (settle: () => void): void => {
528
+ parsed.dispose();
529
+ deleted.dispose();
530
+ cancelled.dispose();
531
+ settle();
532
+ };
533
+ const parsed = this.services.workspace.VersionSyncService.onDidRecordModel(document => {
534
+ if (UriUtils.equals(document.uri, uri)) {
535
+ done(resolve);
536
+ }
537
+ });
538
+ const deleted = builder.onUpdate((_changed, deletedUris) => {
539
+ if (deletedUris.some(deletedUri => UriUtils.equals(deletedUri, uri))) {
540
+ done(resolve);
541
+ }
542
+ });
543
+ const cancelled = cancelToken.onCancellationRequested(() => done(() => reject(OperationCancelled)));
544
+ void build?.then(built => {
545
+ if (!built) {
546
+ done(() => reject(new Error(`The build that would sync ${uri.toString()} to its text failed`)));
547
+ }
548
+ });
549
+ });
433
550
  }
434
551
 
435
552
  /**
@@ -523,28 +640,28 @@ export class DefaultModelService<
523
640
  // A caller already inside the lock would cancel its own build and stall in
524
641
  // the wait below; `allowReentrantBuilds` builds it unlocked, and without a
525
642
  // tracker it cannot be told apart from any other caller.
526
- const inside = isInsideWriteLock();
527
- if (this.allowReentrantBuilds.value && (inside || !isWriteLockScopeInstalled())) {
643
+ if (this.allowReentrantBuilds.value && (isInsideWriteLock() || !isWriteLockScopeInstalled())) {
528
644
  await this.services.workspace.DocumentBuilder.update([documentUri], [], cancelToken);
529
645
  } else {
530
- if (inside) {
531
- throw new ReentrantWriteLockError(uri);
532
- }
646
+ this.assertCanBuild(uri);
533
647
  // The build runs on the lock's own token, which a later write cancels;
534
648
  // the caller's token governs only the phase wait below.
535
649
  await this.services.workspace.DocumentBuilder.scheduleUpdate([documentUri], []);
536
650
  }
537
- return this.waitForDocumentStateCanonical(uri, state ?? IntegrityService.SettledState, cancelToken);
651
+ return this.waitForDocumentStateCanonical(uri, state ?? IntegrityService.SettledState, cancelToken, { sync: true });
652
+ }
653
+
654
+ assertCanBuild(uri: string): void {
655
+ if (isInsideWriteLock() && !this.allowReentrantBuilds.value) {
656
+ throw new ReentrantWriteLockError(uri);
657
+ }
538
658
  }
539
659
 
540
660
  /**
541
661
  * Per-state typed convenience methods. Each ensures the document at
542
662
  * `uri` reaches the named phase and returns the AST envelope with
543
- * the narrowest accurate diagnostics type for that phase. Smart
544
- * dispatch internally: warm path (URI already in the document
545
- * registry) just waits via {@link waitForDocumentState}; cold path
546
- * triggers a build via {@link rebuild}. Consumers do not need to
547
- * know which path was taken.
663
+ * the narrowest accurate diagnostics type for that phase, building as
664
+ * {@link ensureDocumentState} does.
548
665
  *
549
666
  * Phase invariants encoded in the return type:
550
667
  * - `parsed` / `linked` / `settled` / `indexed` return
@@ -590,18 +707,13 @@ export class DefaultModelService<
590
707
  /**
591
708
  * Ensure the document at `uri` reaches `state` (or the integrity-settled
592
709
  * landmark {@link IntegrityService.SettledState} if omitted) and return its
593
- * AST envelope. Smart
594
- * dispatch: warm path (URI already in `LangiumDocuments`) just waits
595
- * via {@link waitForDocumentState}; cold path forces a build via
596
- * {@link rebuild}.
597
- *
598
- * Pairs with {@link rebuild} — same default phase, but `rebuild`
599
- * always builds while this skips the build for an already-loaded
600
- * document. Also pairs with {@link waitForDocumentState} — the verb
601
- * difference (`ensure` vs `waitFor`) signals the side-effect
602
- * difference. The per-state convenience methods (`parsed` / `linked`
603
- * / `settled` / `indexed` / `validated`) all delegate here with an
604
- * explicit phase.
710
+ * AST envelope, with a root no older than the store's text at the call.
711
+ * A document not in `LangiumDocuments` is built via {@link rebuild}; a
712
+ * loaded one whose root is behind its text is built through
713
+ * `VersionSyncService.syncTo`. Until `state` is reached, the call rejects
714
+ * once that build, or one the builder re-queues, has failed twice, or the
715
+ * builder stops re-queuing the document. A loaded, current document is only
716
+ * awaited.
605
717
  */
606
718
  async ensureDocumentState(uri: string, state?: DocumentState, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
607
719
  return this.ensureDocumentStateCanonical(this.uriPolicy.canonicalUri(uri), state, cancelToken);
@@ -624,13 +736,14 @@ export class DefaultModelService<
624
736
  ): Promise<AstDocument<TAst, TDiagnostic>> {
625
737
  const target = state ?? IntegrityService.SettledState;
626
738
  if (this.services.workspace.LangiumDocuments.hasDocument(UriUtils.toUri(uri))) {
627
- return this.waitForDocumentStateCanonical(uri, target, cancelToken);
739
+ return this.waitForDocumentStateCanonical(uri, target, cancelToken, { sync: true });
628
740
  }
629
741
  return this.rebuild(uri, target, cancelToken);
630
742
  }
631
743
 
632
744
  /**
633
- * Wait for the document at `uri` to reach the integrity-settled landmark and
745
+ * Wait for the document at `uri` to reach the integrity-settled landmark,
746
+ * building a root behind its text as {@link ensureDocumentState} does, and
634
747
  * drain any in-flight write-path applyEdit sync chain (see {@link syncChains})
635
748
  * so every language client reflects the latest content before a save returns.
636
749
  * No-op tail for headless adopters — `syncChains` is empty without an LSP
@@ -648,7 +761,7 @@ export class DefaultModelService<
648
761
  // One race over both waits, so the bound is one deadline for the pair;
649
762
  // a race per wait would let the save take twice the bound.
650
763
  const settled = (async (): Promise<void> => {
651
- await this.waitForDocumentStateCanonical(canonical, IntegrityService.SettledState, cancelToken);
764
+ await this.waitForDocumentStateCanonical(canonical, IntegrityService.SettledState, cancelToken, { sync: true });
652
765
  await this.syncChains.get(key);
653
766
  })();
654
767
  try {
@@ -673,7 +786,9 @@ export class DefaultModelService<
673
786
 
674
787
  /**
675
788
  * Snapshot of `uri` as it stands RIGHT NOW — the synchronous sibling of the
676
- * phase reads, which all wait. `undefined` when no document is registered.
789
+ * phase reads, which all wait. `undefined` when no document is registered,
790
+ * or only the builder's placeholder for one its build has not parsed yet:
791
+ * that root is no parse of any text, and its version gates no write.
677
792
  *
678
793
  * **This is what a writer wants, and {@link getDocument} is not.** The
679
794
  * envelope's `version` is copied by value at projection time, so it cannot
@@ -681,18 +796,18 @@ export class DefaultModelService<
681
796
  * whatever the server is at *now*, which is the number an optimistic gate is
682
797
  * about to compare it against.
683
798
  *
684
- * Diagnostics only from a document that has reached `Validated`, and an
685
- * empty array otherwise. Unlike the phase reads this one names no phase, so
799
+ * Diagnostics only from a document that has reached `Validated`, and absent
800
+ * otherwise. Unlike the phase reads this one names no phase, so
686
801
  * the state it finds is the only thing that can say whether the array
687
802
  * describes the content being handed back or whatever an earlier build left.
688
803
  * A caller that needs them unconditionally waits, via {@link validated}.
689
804
  */
690
805
  snapshot(uri: string): AstDocument<TAst, TDiagnostic> | undefined {
691
806
  const document = this.getDocument(uri);
692
- if (!document) {
807
+ if (!document || this.services.workspace.ModelLedger.isPlaceholder(document.parseResult.value)) {
693
808
  return undefined;
694
809
  }
695
- const envelope = AstDocument.from<TAst, TDiagnostic>(document);
810
+ const envelope = this.services.workspace.AstDocumentManager.toAstDocument(document) as AstDocument<TAst, TDiagnostic>;
696
811
  return document.state >= DocumentState.Validated ? envelope : this.withoutDiagnostics(envelope);
697
812
  }
698
813
 
@@ -705,7 +820,7 @@ export class DefaultModelService<
705
820
  * still resolves to the one document the build keys by its real path. Returns
706
821
  * `undefined` if no document is registered for `uri`.
707
822
  *
708
- * **Live, so do not take a based-on version off it.** `textDocument` is the
823
+ * **Live, so do not take a base version off it.** `textDocument` is the
709
824
  * store's own object rather than a copy, so `.version` read here answers for
710
825
  * the moment of the READ, not the moment of the earlier content — pass it to
711
826
  * a write and the server compares its current version against itself, the
@@ -1037,21 +1152,23 @@ export class DefaultModelService<
1037
1152
 
1038
1153
  /**
1039
1154
  * Build an {@link AstDocument} envelope from the current
1040
- * {@link LangiumDocument} state via the shared {@link AstDocument.from}
1041
- * projection. Returns an empty envelope (built via
1155
+ * {@link LangiumDocument} state via `AstDocumentManager.toAstDocument`,
1156
+ * the projection events use too. Returns an empty envelope (built via
1042
1157
  * {@link AstDocument.create}) when the document is absent from the
1043
- * registry — adopters that prefer to throw override on their subclass.
1158
+ * registry — adopters that prefer to throw override on their subclass. Its
1159
+ * version is {@link UNRECORDED_VERSION}, so a write based on it conflicts.
1044
1160
  */
1045
1161
  protected toAstDocument(uri: URI): AstDocument<TAst, TDiagnostic> {
1046
1162
  const document = this.services.workspace.LangiumDocuments.getDocument(uri);
1047
1163
  return document
1048
- ? AstDocument.from<TAst, TDiagnostic>(document)
1049
- : AstDocument.create<TAst, TDiagnostic>(uri.toString(), 0, undefined as unknown as TAst, []);
1164
+ ? (this.services.workspace.AstDocumentManager.toAstDocument(document) as AstDocument<TAst, TDiagnostic>)
1165
+ : AstDocument.create<TAst, TDiagnostic>(uri.toString(), UNRECORDED_VERSION, undefined as unknown as TAst);
1050
1166
  }
1051
1167
 
1052
1168
  /**
1053
1169
  * The same envelope with no diagnostics, for a read that names a phase below
1054
- * `Validated`.
1170
+ * `Validated`. Absent rather than `[]`, which says the document was validated
1171
+ * and found clean.
1055
1172
  *
1056
1173
  * Langium fills `LangiumDocument.diagnostics` from inside `validateDocument`
1057
1174
  * and from nowhere else, so below that phase the array holds whatever an
@@ -1065,6 +1182,7 @@ export class DefaultModelService<
1065
1182
  * one it also keeps.
1066
1183
  */
1067
1184
  protected withoutDiagnostics(document: AstDocument<TAst, TDiagnostic>): AstDocument<TAst, never> {
1068
- return { ...document, diagnostics: [] };
1185
+ const { diagnostics: _diagnostics, ...rest } = document;
1186
+ return rest;
1069
1187
  }
1070
1188
  }
@@ -37,6 +37,8 @@ import { HydraniumTextDocuments } from '../documents/hydranium-text-documents.js
37
37
  import { DefaultAstDocumentManager, type AstDocumentManager, type WritableFileSystemProvider } from '../documents/ast-document-manager.js';
38
38
  import { DefaultSelfSaveRegistry, type SelfSaveRegistry } from '../documents/self-save-registry.js';
39
39
  import { DefaultFileSystemTaskQueue, type FileSystemTaskQueue } from '../documents/file-system-task-queue.js';
40
+ import { DefaultModelLedger, type ModelLedger } from '../documents/model-ledger.js';
41
+ import { DefaultVersionSyncService, type VersionSyncService } from '../documents/version-sync-service.js';
40
42
  import {
41
43
  DefaultEmptyFileSystemProvider,
42
44
  DefaultFileSystemProviderRegistry,
@@ -279,6 +281,10 @@ export interface ServerAddedSharedServices<
279
281
  * strategy (`shed-closed-when-idle`) to enable shedding.
280
282
  */
281
283
  CstResidencyService: CstResidencyService;
284
+ /** The version of the text each root was parsed from. */
285
+ ModelLedger: ModelLedger;
286
+ /** Reconciles each produced root with the store, and owns every build that syncs a root to its text or recovers one. */
287
+ VersionSyncService: VersionSyncService;
282
288
  };
283
289
  /**
284
290
  * In-process workspace facade ({@link ModelService}) + AST→transfer
@@ -553,7 +559,9 @@ export function createServerSharedModule(
553
559
  BuildPhasePassService: services => new DefaultBuildPhasePassService(services),
554
560
  // Eagerly constructed, so its `Validated` pass registers before the
555
561
  // first build.
556
- CstResidencyService: services => new DefaultCstResidencyService(services)
562
+ CstResidencyService: services => new DefaultCstResidencyService(services),
563
+ ModelLedger: () => new DefaultModelLedger(),
564
+ VersionSyncService: services => new DefaultVersionSyncService(services)
557
565
  },
558
566
  model: {
559
567
  // Generic walker — adopters with a typed `$type → wire shape` overlay
@@ -7,7 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { asMutable, type Clock, Debouncer, Format, type Tracer } from '@hydranium/protocol';
10
+ import { asMutable, type Clock, Debouncer, Format, type Tracer, UNRECORDED_VERSION } from '@hydranium/protocol';
11
11
  import {
12
12
  type AstNode,
13
13
  AstUtils,
@@ -405,6 +405,15 @@ export class DefaultCstResidencyService implements CstResidencyService {
405
405
  if (root === undefined || root.$cstNode === undefined) {
406
406
  return 0;
407
407
  }
408
+ // Readers of the parsed text, the `'text-diagnostics'` fingerprint among
409
+ // them, otherwise see none once the CST is gone. Recorded in the ledger
410
+ // directly: no root is produced, and reconciling it with the store would
411
+ // step a closed document's sequence for a root behind its text.
412
+ const ledger = this.services.workspace.ModelLedger;
413
+ const version = ledger.versionOf(root);
414
+ if (version !== UNRECORDED_VERSION) {
415
+ ledger.record(root, version, ledger.textOf(root));
416
+ }
408
417
  let cleared = 0;
409
418
  for (const node of AstUtils.streamAst(root)) {
410
419
  if (node.$cstNode) {