@hydranium/core 1.0.0-next.200 → 1.0.0-next.202

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 (68) hide show
  1. package/lib/documents/ast-document-manager.d.ts +13 -25
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +0 -56
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/client-session-registry.d.ts +3 -2
  6. package/lib/documents/client-session-registry.d.ts.map +1 -1
  7. package/lib/documents/client-session-registry.js +3 -3
  8. package/lib/documents/client-session-registry.js.map +1 -1
  9. package/lib/documents/hydranium-text-documents.js +1 -1
  10. package/lib/documents/hydranium-text-documents.js.map +1 -1
  11. package/lib/langium/document-builder/document-builder.d.ts +10 -0
  12. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  13. package/lib/langium/document-builder/document-builder.js +13 -0
  14. package/lib/langium/document-builder/document-builder.js.map +1 -1
  15. package/lib/langium/model-service/client-session.d.ts +30 -17
  16. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  17. package/lib/langium/model-service/client-session.js +30 -37
  18. package/lib/langium/model-service/client-session.js.map +1 -1
  19. package/lib/langium/model-service/index.d.ts +1 -0
  20. package/lib/langium/model-service/index.d.ts.map +1 -1
  21. package/lib/langium/model-service/index.js +1 -0
  22. package/lib/langium/model-service/index.js.map +1 -1
  23. package/lib/langium/model-service/model-events.d.ts +64 -0
  24. package/lib/langium/model-service/model-events.d.ts.map +1 -0
  25. package/lib/langium/model-service/model-events.js +10 -0
  26. package/lib/langium/model-service/model-events.js.map +1 -0
  27. package/lib/langium/model-service/model-service.d.ts +74 -38
  28. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  29. package/lib/langium/model-service/model-service.js +137 -31
  30. package/lib/langium/model-service/model-service.js.map +1 -1
  31. package/lib/langium/service-registry.d.ts +14 -0
  32. package/lib/langium/service-registry.d.ts.map +1 -1
  33. package/lib/langium/service-registry.js +21 -0
  34. package/lib/langium/service-registry.js.map +1 -1
  35. package/lib/langium/transfer/transfer-encoder.d.ts +2 -3
  36. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  37. package/lib/langium/transfer/transfer-encoder.js +2 -3
  38. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  39. package/lib/langium/workspace/document-uri-policy.d.ts +1 -2
  40. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  41. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  42. package/lib/testing/stub-ast-document-manager.d.ts +7 -51
  43. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  44. package/lib/testing/stub-ast-document-manager.js +0 -57
  45. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  46. package/lib/testing/stub-document-builder.d.ts +3 -3
  47. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  48. package/lib/testing/stub-document-builder.js +22 -2
  49. package/lib/testing/stub-document-builder.js.map +1 -1
  50. package/lib/testing/stub-hydranium-text-documents.js +2 -2
  51. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  52. package/lib/testing/stub-service-registry.d.ts +1 -1
  53. package/package.json +5 -5
  54. package/src/documents/ast-document-manager.ts +14 -76
  55. package/src/documents/client-session-registry.ts +4 -3
  56. package/src/documents/hydranium-text-documents.ts +1 -1
  57. package/src/langium/document-builder/document-builder.ts +14 -0
  58. package/src/langium/model-service/client-session.ts +41 -46
  59. package/src/langium/model-service/index.ts +1 -0
  60. package/src/langium/model-service/model-events.ts +74 -0
  61. package/src/langium/model-service/model-service.ts +204 -42
  62. package/src/langium/service-registry.ts +22 -0
  63. package/src/langium/transfer/transfer-encoder.ts +2 -3
  64. package/src/langium/workspace/document-uri-policy.ts +1 -2
  65. package/src/testing/stub-ast-document-manager.ts +7 -126
  66. package/src/testing/stub-document-builder.ts +23 -5
  67. package/src/testing/stub-hydranium-text-documents.ts +2 -2
  68. package/src/testing/stub-service-registry.ts +1 -1
@@ -27,7 +27,7 @@ import { Disposable } from 'vscode-languageserver';
27
27
  import { TextDocumentIdentifier, type TextDocumentItem } from 'vscode-languageserver-protocol';
28
28
  import { type TextDocument } from 'vscode-languageserver-textdocument';
29
29
  import { type ServerSharedServices } from '../langium/module.js';
30
- import { type HydraniumDocumentBuilder, labelPhaseListener } from '../langium/document-builder/index.js';
30
+ import { type HydraniumDocumentBuilder } from '../langium/document-builder/index.js';
31
31
  import { UNKNOWN_CLIENT_ID } from './client-ids.js';
32
32
  import { type HydraniumTextDocuments } from './hydranium-text-documents.js';
33
33
  import { type DocumentUriPolicy } from '../langium/workspace/document-uri-policy.js';
@@ -38,9 +38,8 @@ export interface UpdateInfo {
38
38
  }
39
39
 
40
40
  /**
41
- * Server-internal document envelope at the AST layer. Emitted by
42
- * {@link AstDocumentManager.onUpdate} / {@link AstDocumentManager.onSave}
43
- * for in-process subscribers (`ModelService`, integrity service, etc.).
41
+ * Server-internal document envelope at the AST layer, which `ModelService`
42
+ * reads and events hand to in-process callers.
44
43
  *
45
44
  * `root` is the live AST the build holds, not an encoded copy like
46
45
  * `TransferDocument.model`, so mutating it changes the model every reader sees.
@@ -83,12 +82,12 @@ export namespace AstDocument {
83
82
  }
84
83
  }
85
84
 
86
- /** Update event delivered by {@link AstDocumentManager.onUpdate} — typed alias over the generic event wrapper. */
85
+ /** The update event a document's build delivers, typed over the generic event wrapper. */
87
86
  export type AstDocumentUpdatedEvent<TAst extends AstNode, TDiagnostic extends AstDiagnostic = AstDiagnostic> = TransferUpdatedEvent<
88
87
  AstDocument<TAst, TDiagnostic>
89
88
  >;
90
89
 
91
- /** Save event delivered by {@link AstDocumentManager.onSave} — typed alias over the generic event wrapper. */
90
+ /** The event a save delivers, typed over the generic event wrapper. */
92
91
  export type AstDocumentSavedEvent<TAst extends AstNode, TDiagnostic extends AstDiagnostic = AstDiagnostic> = TransferSavedEvent<
93
92
  AstDocument<TAst, TDiagnostic>
94
93
  >;
@@ -120,8 +119,8 @@ export interface AstDocumentManagerOptions extends LogNameOptions {
120
119
 
121
120
  /**
122
121
  * The document slot the model server and the integrity service talk to:
123
- * open/close/update/save plus the AST-typed event streams, with the LSP
124
- * plumbing hidden.
122
+ * open/close/update/save and the attribution of update events, with the LSP
123
+ * plumbing hidden. Events are `ModelService`'s, which builds them from here.
125
124
  *
126
125
  * Generic over `<TAst extends AstNode, TDiagnostic>` so each consumer projects
127
126
  * its own AST root type and diagnostic shape into the emitted
@@ -152,10 +151,6 @@ export interface AstDocumentManager<TAst extends AstNode, TDiagnostic extends As
152
151
  */
153
152
  save(uri: string, clientId: string): Promise<TextVersion>;
154
153
 
155
- onUpdate(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable;
156
- onSave(uri: string, listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void | Promise<void>): Disposable;
157
- onClientClosed(uri: string, clientId: string, listener: () => void): Disposable;
158
-
159
154
  getDocument(uri: string): LangiumDocument | undefined;
160
155
 
161
156
  /**
@@ -174,12 +169,13 @@ export interface AstDocumentManager<TAst extends AstNode, TDiagnostic extends As
174
169
 
175
170
  /**
176
171
  * The reason, source and cause of an update event for `document` emitted
177
- * now, from a phase listener of the build that carries it. Every head takes
178
- * its update events' attribution from here, so they name the same client
179
- * for one build. For a document a client opened, while rebuilds validate,
180
- * the answer changes once its `Validated` listeners have all run: a later
181
- * event at that version is `'rebuilt'`. `TransferDocumentUpdateReason`
182
- * names the cases that fall back to the last update.
172
+ * now, from a phase listener of the build that carries it. `ModelService`
173
+ * takes its update events' attribution from here, once per document and
174
+ * phase, so every subscriber names the same client for one build. For a
175
+ * document a client opened, while rebuilds validate, the answer changes
176
+ * once its `Validated` listeners have all run: a later event at that
177
+ * version is `'rebuilt'`. `TransferDocumentUpdateReason` names the cases
178
+ * that fall back to the last update.
183
179
  */
184
180
  attributeUpdate(document: LangiumDocument): UpdateAttribution;
185
181
  }
@@ -329,64 +325,6 @@ export class DefaultAstDocumentManager<
329
325
  return languages.length === 1 ? languages[0].LanguageMetaData.languageId : 'plaintext';
330
326
  }
331
327
 
332
- /**
333
- * Subscribe to the save event of the document at `uri`. The callback fires only when the
334
- * URI of the saved document matches `uri` (compared by canonical form).
335
- */
336
- onSave(uri: string, listener: (event: TransferSavedEvent<AstDocument<TAst, TDiagnostic>>) => void | Promise<void>): Disposable {
337
- const target = this.uriPolicy.canonicalUri(uri);
338
- return this.textDocuments.onDidSave(async event => {
339
- if (this.uriPolicy.canonicalUri(event.document.uri) !== target) {
340
- return undefined;
341
- }
342
- // Look the document up by its canonical URI: the adopter's
343
- // `LangiumDocuments` keys by the canonical form, while the saved
344
- // event carries the (possibly symlinked) client-facing URI.
345
- const documentURI = UriUtils.toUri(target);
346
- if (documentURI !== undefined && this.langiumDocs.hasDocument(documentURI)) {
347
- const document = await this.langiumDocs.getOrCreateDocument(documentURI);
348
- return listener({
349
- document: this.toAstDocument(document),
350
- sourceClientId: event.clientId
351
- });
352
- }
353
- return undefined;
354
- });
355
- }
356
-
357
- /** Fires when `clientId` detaches from the document at `uri` (matched by canonical form). */
358
- onClientClosed(uri: string, clientId: string, listener: () => void): Disposable {
359
- const target = this.uriPolicy.canonicalUri(uri);
360
- return this.textDocuments.onDidClose(event => {
361
- if (event.clientId === clientId && this.uriPolicy.canonicalUri(event.document.uri) === target) {
362
- listener();
363
- }
364
- });
365
- }
366
-
367
- /** Fires when the document at `uri` reaches `Validated` after each rebuild. */
368
- onUpdate(uri: string, listener: (event: TransferUpdatedEvent<AstDocument<TAst, TDiagnostic>>) => void): Disposable {
369
- const target = this.uriPolicy.canonicalUri(uri);
370
- const emitUpdate = (document: LangiumDocument): void => {
371
- if (this.uriPolicy.canonicalUri(document.uri) !== target) {
372
- return;
373
- }
374
- // No `'deleted'` reason: `DocumentBuilder.update` drops a deleted
375
- // document from `LangiumDocuments` before deriving the rebuild set
376
- // from it, so a deleted URI is never built and never reaches this
377
- // phase listener. A subscriber needing deletions has to be told on
378
- // a channel that does not require a built document.
379
- const event: TransferUpdatedEvent<AstDocument<TAst, TDiagnostic>> = {
380
- document: this.toAstDocument(document),
381
- ...this.attributeUpdate(document)
382
- };
383
- this.tracer.with(uri).trace(`emitUpdate start: source=${event.sourceClientId}, reason=${event.reason}, cause=${event.causedBy}`);
384
- listener(event);
385
- this.tracer.with(uri).trace('emitUpdate listener returned');
386
- };
387
- return this.documentBuilder.onDocumentPhase(DocumentState.Validated, labelPhaseListener(emitUpdate, 'AstDocumentManager.onUpdate'));
388
- }
389
-
390
328
  /**
391
329
  * The client that authored `document`'s current version, or `undefined` when
392
330
  * no client wrote it: a document no client has open. A rebuild that keeps
@@ -36,6 +36,7 @@ export type ClientSessionState = 'live' | 'closing';
36
36
  /** Delivered by {@link ClientSessionRegistry.onDidCloseSession}. */
37
37
  export interface ClientSessionClosedEvent {
38
38
  readonly clientId: string;
39
+ readonly cause: SessionEndCause;
39
40
  }
40
41
 
41
42
  /**
@@ -128,12 +129,12 @@ export class ClientSessionRegistry {
128
129
  return this.opensOf(clientId);
129
130
  }
130
131
 
131
- /** Remove `clientId` from the session table and announce it. A no-op for an id that is not registered. */
132
- unregister(clientId: string): void {
132
+ /** Remove `clientId` from the session table and announce it with `cause`. A no-op for an id that is not registered. */
133
+ unregister(clientId: string, cause: SessionEndCause = 'closed'): void {
133
134
  if (!this.sessions.delete(clientId)) {
134
135
  return;
135
136
  }
136
- this.sessionClosedEmitter.fire(Object.freeze({ clientId }));
137
+ this.sessionClosedEmitter.fire(Object.freeze({ clientId, cause }));
137
138
  }
138
139
 
139
140
  /**
@@ -1685,7 +1685,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1685
1685
  this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId, cause);
1686
1686
  }
1687
1687
  } finally {
1688
- this.__sessions.unregister(clientId);
1688
+ this.__sessions.unregister(clientId, cause);
1689
1689
  this.tracer.info(`Session closed: ${this.formatClientId(clientId)}`);
1690
1690
  this.tracer.trace(`Session closed: ${clientId}`);
1691
1691
  }
@@ -30,6 +30,7 @@ import { CancellationToken, Diagnostic, Disposable } from 'vscode-languageserver
30
30
  import type { HydraniumTextDocuments } from '../../documents/hydranium-text-documents.js';
31
31
  import { type VersionSyncService } from '../../documents/version-sync-service.js';
32
32
  import { type LogNameOptions } from '../diagnostics/logger.js';
33
+ import { IntegrityService } from '../integrity/integrity-service.js';
33
34
  import type { MessageRenderer } from '../../messages/renderer.js';
34
35
  import { CST_REHYDRATION_RESET_STATE, isCstShed } from '../residency/cst-residency-service.js';
35
36
  import { type ExtendedServiceRegistry } from '../service-registry.js';
@@ -357,6 +358,19 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
357
358
  return `current state: '${docState}', last phase: ${lastPhase}, active build: ${this.formatSession(this.activeSession)}`;
358
359
  }
359
360
 
361
+ /**
362
+ * The last phase a rebuild reaches under {@link updateBuildOptions}:
363
+ * `Validated`, or {@link IntegrityService.SettledState} when rebuilds do
364
+ * not validate, since no build then reaches `Validated` and a wait for it
365
+ * would never end. A document a `shouldValidate` override skips stops
366
+ * short of it; waiting on it is this builder's to resolve. An override that
367
+ * returns a phase rebuilds never reach makes every wait for it hang, the
368
+ * session writes that answer at it included.
369
+ */
370
+ finalBuildState(): DocumentState {
371
+ return this.updateBuildOptions.validation ? DocumentState.Validated : IntegrityService.SettledState;
372
+ }
373
+
360
374
  /** Render a session for a status line. `undefined` — no build in progress — reads as `none`. */
361
375
  protected formatSession(session: BuildSession | undefined): string {
362
376
  if (!session) {
@@ -19,13 +19,12 @@ import {
19
19
  type Tracer,
20
20
  type TransferElement
21
21
  } from '@hydranium/protocol';
22
- import { type AstNode, type DocumentBuilder, DocumentState, UriUtils } from '@hydranium/langium';
23
- import { type CancellationToken, type Disposable } from 'vscode-languageserver';
22
+ import { type AstNode, UriUtils } from '@hydranium/langium';
23
+ import { type CancellationToken, type Disposable, Emitter, type Event } from 'vscode-languageserver';
24
24
  import { type AstDocument, type SavedAstDocument } from '../../documents/ast-document-manager.js';
25
25
  import { DocumentNotOpenError, SessionClosedError } from '../../documents/client-session-errors.js';
26
26
  import { type OpenOptions, type SessionEndCause } from '../../documents/client-session-registry.js';
27
27
  import { type LogNameOptions } from '../diagnostics/logger.js';
28
- import { IntegrityService } from '../integrity/integrity-service.js';
29
28
  import { type ServerSharedServices } from '../module.js';
30
29
  import { type AstDiagnostic } from '../validation/document-validator.js';
31
30
  import { type ModelService } from './model-service.js';
@@ -118,11 +117,11 @@ export interface ClientSession<
118
117
  save(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<SavedAstDocument<TAst, TDiagnostic>>;
119
118
  /**
120
119
  * Persist the text the store holds for `args.uri` as it is, with no update
121
- * and no serialisation, and resolve to the document as {@link save} does.
122
- * The saved event names this session, also when another client wrote the
123
- * text.
120
+ * and no serialisation. Resolves with the version of the text written, once
121
+ * it is on disk, without waiting for a build. The saved event names this
122
+ * session, also when another client wrote the text.
124
123
  */
125
- persist(args: ClientSessionPersistArgs, cancelToken?: CancellationToken): Promise<SavedAstDocument<TAst, TDiagnostic>>;
124
+ persist(args: ClientSessionPersistArgs): Promise<TextVersion>;
126
125
  /** Close this session's open of `uri`, at once. A no-op when it does not have `uri` open. */
127
126
  close(uri: string): Promise<void>;
128
127
  /**
@@ -142,21 +141,12 @@ export interface ClientSession<
142
141
  * reason reverts such a document at once.
143
142
  */
144
143
  dispose(cause?: SessionEndCause): void;
145
- }
146
-
147
- /**
148
- * The phase a session write waits for before it answers: `Validated`, or
149
- * {@link IntegrityService.SettledState} when rebuilds do not validate, since
150
- * no build then reaches `Validated` and the wait would never end. Read from
151
- * `updateBuildOptions`, which every rebuild takes; a document a
152
- * `shouldValidate` override skips is the builder's to resolve.
153
- *
154
- * Waiting for the write's own build is not enough: a later write cancels it,
155
- * and the build that takes over validates the document after the answer was
156
- * taken.
157
- */
158
- function answerState(builder: DocumentBuilder): DocumentState {
159
- return builder.updateBuildOptions.validation ? DocumentState.Validated : IntegrityService.SettledState;
144
+ /**
145
+ * Fires once the session has ended, its documents closed, with the cause it
146
+ * ended with. A takeover through `createSession` ends it as `'lost'` from
147
+ * another caller, so a holder that keeps per-session state releases it here.
148
+ */
149
+ readonly onDidDispose: Event<SessionEndCause>;
160
150
  }
161
151
 
162
152
  /**
@@ -197,6 +187,7 @@ export class DefaultClientSession<
197
187
  protected readonly openOptionsByUri = new Map<string, TOpenOptions | undefined>();
198
188
  /** Drops an open's options from {@link openOptionsByUri} when the store closes the open. */
199
189
  protected readonly closeListener: Disposable;
190
+ protected readonly disposeEmitter = new Emitter<SessionEndCause>();
200
191
  /** Logs under `logName`, `ClientSession` when none is given, with this session's id in a bracket of its own, so a line names the session that wrote. */
201
192
  protected readonly tracer: Tracer;
202
193
  /** The service bound on `model.ModelService`, which serialises and rebuilds for this session. */
@@ -256,9 +247,9 @@ export class DefaultClientSession<
256
247
  return this.saveDocument(args, cancelToken);
257
248
  }
258
249
 
259
- persist(args: ClientSessionPersistArgs, cancelToken?: CancellationToken): Promise<SavedAstDocument<TAst, TDiagnostic>> {
250
+ persist(args: ClientSessionPersistArgs): Promise<TextVersion> {
260
251
  this.assertLive();
261
- return this.persistDocument(args, cancelToken);
252
+ return this.persistDocument(args);
262
253
  }
263
254
 
264
255
  close(uri: string): Promise<void> {
@@ -283,6 +274,12 @@ export class DefaultClientSession<
283
274
  this.disposed = true;
284
275
  this.services.workspace.TextDocuments.closeSession(this.clientId, cause);
285
276
  this.closeListener.dispose();
277
+ this.disposeEmitter.fire(cause ?? 'closed');
278
+ this.disposeEmitter.dispose();
279
+ }
280
+
281
+ get onDidDispose(): Event<SessionEndCause> {
282
+ return this.disposeEmitter.event;
286
283
  }
287
284
 
288
285
  protected assertLive(): void {
@@ -328,7 +325,10 @@ export class DefaultClientSession<
328
325
  *
329
326
  * Resolves with the latest build of the document, which may already carry
330
327
  * a newer write; that is logged at debug, and a slow update at warn when
331
- * `ClientSessionFactoryOptions.slowUpdateWarnMs` is set.
328
+ * `ClientSessionFactoryOptions.slowUpdateWarnMs` is set. It waits for the
329
+ * builder's final phase rather than the write's own build: a later write
330
+ * cancels that build, and the one that takes over validates the document
331
+ * after the answer was taken.
332
332
  */
333
333
  protected async updateDocument(
334
334
  args: ClientSessionWriteArgs<TTransfer>,
@@ -353,7 +353,7 @@ export class DefaultClientSession<
353
353
  // An override that awaits before calling the base can outlast the build
354
354
  // this write already has, and then builds it twice; see
355
355
  // `ModelService.rebuild`.
356
- const doc = await run('rebuild', () => service.rebuild(uri, answerState(this.services.workspace.DocumentBuilder), cancelToken));
356
+ const doc = await run('rebuild', () => service.rebuild(uri, this.services.workspace.DocumentBuilder.finalBuildState(), cancelToken));
357
357
  const finalVersion = textDocuments.version(uri);
358
358
  if (finalVersion > appliedVersion) {
359
359
  this.tracer.debug(`Update to v${appliedVersion} ready at v${finalVersion} (changed again before it settled)`);
@@ -409,50 +409,45 @@ export class DefaultClientSession<
409
409
  check();
410
410
  const applied = uris.map((uri, i) => this.services.workspace.AstDocumentManager.update(uri, texts[i], this.clientId));
411
411
  await Promise.all(applied);
412
- const state = answerState(this.services.workspace.DocumentBuilder);
412
+ const state = this.services.workspace.DocumentBuilder.finalBuildState();
413
413
  return Promise.all(uris.map(uri => service.rebuild(uri, state, cancelToken)));
414
414
  }
415
415
 
416
416
  /**
417
- * Write `args.model` through {@link updateDocument}, so an override of it
418
- * applies to saves too, then persist the document.
417
+ * Write `args.model` through {@link updateDocument}, then persist it through
418
+ * {@link persistDocument}, so an override of either applies to saves too.
419
419
  *
420
420
  * Fails with `DocumentNotOpenError` and writes nothing when this session
421
- * closes the URI while the text is being built: the check sits in the same
422
- * synchronous step as the manager taking the text. Checked any earlier, a
423
- * session that closes the URI during the rebuild still has the shared
424
- * text, other clients' edits included, written in its name. Once taken,
425
- * the write completes whatever the session does next.
421
+ * closes the URI while the text is being built. Persisted at `'any'`: the
422
+ * update already gated on `args.baseVersion` and moved the text past it.
426
423
  */
427
424
  protected async saveDocument(
428
425
  args: ClientSessionWriteArgs<TTransfer>,
429
426
  cancelToken?: CancellationToken
430
427
  ): Promise<SavedAstDocument<TAst, TDiagnostic>> {
431
428
  const doc = await this.updateDocument(args, cancelToken);
432
- const uri = this.canonicalKey(args.uri);
433
- this.assertOpen(uri);
434
- const version = await this.services.workspace.AstDocumentManager.save(uri, this.clientId);
429
+ const version = await this.persistDocument({ uri: args.uri, baseVersion: 'any' });
435
430
  return { ...doc, persisted: { version } };
436
431
  }
437
432
 
438
433
  /**
439
- * Persist the store's text for `args.uri` under this session's id.
434
+ * Persist the store's text for `args.uri` under this session's id. Every
435
+ * write to disk a session makes goes through here, so an override sees each
436
+ * of them.
440
437
  *
441
438
  * The open check and the `baseVersion` gate sit in the same synchronous step
442
439
  * as the manager taking the text: an await between them lets another
443
440
  * client's write land after the gate, and its text is then persisted under
444
- * this session's id without this session ever having seen it.
441
+ * this session's id without this session ever having seen it. An override
442
+ * that awaits before calling the base lets a diagram save take its
443
+ * documents' texts at different moments, and a diagram that ends during the
444
+ * await fails the documents not yet taken with `DocumentNotOpenError`.
445
445
  */
446
- protected async persistDocument(
447
- args: ClientSessionPersistArgs,
448
- cancelToken?: CancellationToken
449
- ): Promise<SavedAstDocument<TAst, TDiagnostic>> {
446
+ protected async persistDocument(args: ClientSessionPersistArgs): Promise<TextVersion> {
450
447
  const uri = this.canonicalKey(args.uri);
451
448
  this.assertOpen(uri);
452
449
  this.assertBaseVersion(uri, args.baseVersion, this.services.workspace.TextDocuments.version(uri));
453
- const version = await this.services.workspace.AstDocumentManager.save(uri, this.clientId);
454
- const doc = await this.modelService.ensureDocumentState(uri, answerState(this.services.workspace.DocumentBuilder), cancelToken);
455
- return { ...doc, persisted: { version } };
450
+ return this.services.workspace.AstDocumentManager.save(uri, this.clientId);
456
451
  }
457
452
 
458
453
  protected async closeDocument(uri: string): Promise<void> {
@@ -7,5 +7,6 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
+ export * from './model-events.js';
10
11
  export * from './model-service.js';
11
12
  export * from './client-session.js';
@@ -0,0 +1,74 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { type CanonicalUri } from '@hydranium/protocol';
11
+ import { type AstNode, type DocumentState } from '@hydranium/langium';
12
+ import { type AstDocumentUpdatedEvent } from '../../documents/ast-document-manager.js';
13
+ import { type DocumentDirtyChangedEvent } from '../../documents/hydranium-text-documents.js';
14
+ import { type AstDiagnostic } from '../validation/document-validator.js';
15
+
16
+ /** Which documents a `ModelService` subscription hears about. */
17
+ export interface ModelEventFilter {
18
+ /** One document, in any spelling; matched by canonical form. Absent for every document. */
19
+ readonly uri?: string;
20
+ }
21
+
22
+ /** A {@link ModelEventFilter} for an event a build phase fires. */
23
+ export interface ModelPhaseFilter extends ModelEventFilter {
24
+ /** Defaults to `Validated`. */
25
+ readonly phase?: DocumentState;
26
+ }
27
+
28
+ /**
29
+ * A document reaching {@link phase} in a build.
30
+ *
31
+ * `document` holds for the listener's synchronous run: its root is the one the
32
+ * build carried to the phase, and at `Validated` its diagnostics are that
33
+ * build's, copied. A later build can unlink that root in place or replace it,
34
+ * so a listener that awaits first reads the document again through the phase
35
+ * reads or `snapshot`.
36
+ *
37
+ * Below `Validated` the document carries no diagnostics: any it held would be
38
+ * an earlier build's.
39
+ */
40
+ export interface ModelUpdatedEvent<TAst extends AstNode, TDiagnostic extends AstDiagnostic = AstDiagnostic> extends AstDocumentUpdatedEvent<
41
+ TAst,
42
+ TDiagnostic
43
+ > {
44
+ readonly phase: DocumentState;
45
+ }
46
+
47
+ /**
48
+ * The documents one build carried to {@link phase}. Not fired for a cancelled
49
+ * build, so a document it built shows up in the build that takes over.
50
+ */
51
+ export interface ModelsBuiltEvent {
52
+ readonly uris: readonly CanonicalUri[];
53
+ readonly phase: DocumentState;
54
+ }
55
+
56
+ /**
57
+ * A document's file was removed. No build reaches a deleted document, so no
58
+ * {@link ModelUpdatedEvent} reports it.
59
+ */
60
+ export interface ModelDeletedEvent {
61
+ readonly uri: CanonicalUri;
62
+ }
63
+
64
+ /** A document's text started or stopped differing from its file. */
65
+ export type ModelDirtyChangedEvent = DocumentDirtyChangedEvent;
66
+
67
+ /**
68
+ * A document no client has open any more was released and reverts to disk.
69
+ * The rebuild that reverts it follows as a {@link ModelUpdatedEvent}, unless a
70
+ * client opens the document first or its file is deleted.
71
+ */
72
+ export interface ModelReleasedEvent {
73
+ readonly uri: CanonicalUri;
74
+ }