@hydranium/data-server 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/data-server",
3
- "version": "1.0.0-next.200",
3
+ "version": "1.0.0-next.202",
4
4
  "description": "Typed-RPC protocol head for the hydranium framework. Peer of @hydranium/core/lsp and @hydranium/glsp-server; built on @hydranium/core.",
5
5
  "keywords": [
6
6
  "data-server",
@@ -78,18 +78,18 @@
78
78
  "watch": "tsc -b -w --preserveWatchOutput"
79
79
  },
80
80
  "devDependencies": {
81
- "@hydranium/core": "1.0.0-next.200",
82
- "@hydranium/langium": "1.0.0-next.200",
83
- "@hydranium/protocol": "1.0.0-next.200",
81
+ "@hydranium/core": "1.0.0-next.202",
82
+ "@hydranium/langium": "1.0.0-next.202",
83
+ "@hydranium/protocol": "1.0.0-next.202",
84
84
  "rimraf": "^5.0.0",
85
85
  "typescript": "^7.0.2",
86
86
  "vscode-jsonrpc": "9.0.1",
87
87
  "vscode-languageserver-textdocument": "^1.0.12"
88
88
  },
89
89
  "peerDependencies": {
90
- "@hydranium/core": "1.0.0-next.200",
91
- "@hydranium/langium": "1.0.0-next.200",
92
- "@hydranium/protocol": "1.0.0-next.200",
90
+ "@hydranium/core": "1.0.0-next.202",
91
+ "@hydranium/langium": "1.0.0-next.202",
92
+ "@hydranium/protocol": "1.0.0-next.202",
93
93
  "vscode-jsonrpc": "9.0.1",
94
94
  "vscode-languageserver-textdocument": "^1.0.12"
95
95
  },
@@ -23,6 +23,7 @@ import {
23
23
  type CloseModelArgs,
24
24
  type HydraniumMessageData,
25
25
  type HydraniumResponseError,
26
+ type CanonicalUri,
26
27
  type Disposable,
27
28
  type ElementSource,
28
29
  type FindNextNameArgs,
@@ -120,22 +121,23 @@ import type { DataServerDiagnosticsProvider, DataServerProfileCapture } from './
120
121
  import type {
121
122
  ClientSession,
122
123
  ClientSessionWriteArgs,
123
- ClientTextDocumentChangeEvent,
124
- DocumentDirtyChangedEvent,
125
124
  HydraniumLanguageServices,
126
125
  LogNameOptions,
127
126
  AstDiagnostic,
128
127
  AstDocument,
128
+ AstDocumentSavedEvent,
129
129
  EncodedTransferDocument,
130
+ ModelDeletedEvent,
131
+ ModelDirtyChangedEvent,
130
132
  ModelService,
133
+ ModelUpdatedEvent,
131
134
  ProjectChangeEvent,
132
135
  ServerSharedServices,
133
136
  SessionEndCause,
134
137
  TransferEncoder
135
138
  } from '@hydranium/core';
136
- import type { TextDocument } from 'vscode-languageserver-textdocument';
137
- import { type AstNode, DocumentState, type LangiumDocument, UriUtils, type URI } from '@hydranium/langium';
138
- import { type CancellationToken, type MessageConnection, ResponseError } from 'vscode-jsonrpc';
139
+ import { type AstNode, DocumentState, UriUtils, type URI } from '@hydranium/langium';
140
+ import { type MessageConnection, ResponseError } from 'vscode-jsonrpc';
139
141
 
140
142
  /**
141
143
  * Domain separator between text and diagnostics inputs of
@@ -152,18 +154,6 @@ const FINGERPRINT_SEPARATOR = '\0';
152
154
  */
153
155
  const SERVER_CANCELLED = -32802;
154
156
 
155
- /**
156
- * A session a data connection registered with a resume token, and how to end
157
- * it from another connection. See {@link DataServer.resumableSessions}.
158
- */
159
- export interface ResumableSession {
160
- readonly token: string;
161
- /** End the session as its connection closing would. */
162
- readonly end: () => void;
163
- }
164
-
165
- const resumableByModelService = new WeakMap<object, Map<string, ResumableSession>>();
166
-
167
157
  /**
168
158
  * Which observable state {@link DataServer.computeDocumentFingerprint} hashes to
169
159
  * de-dup `onDocumentUpdated` emissions.
@@ -428,11 +418,11 @@ export interface DataServerUriWatchRecord {
428
418
  * any names supplied via {@link DataServerOptions.additionalMethods}) and
429
419
  * builds the {@link DataClientProtocol} notification proxy on the same
430
420
  * connection in one {@link createRpcProxy} call (binding `this` as its
431
- * `localTarget`) under the configured namespace, and subscribes two listeners
432
- * at the configured phase: a `DocumentBuilder.onDocumentPhase` one dispatching
433
- * per-document `clientProxy.onDocumentUpdated` for watched URIs, and a
434
- * `DocumentBuilder.onBuildPhase` one dispatching a single
435
- * `clientProxy.onDocumentsBuilt` naming the batch's unwatched documents.
421
+ * `localTarget`) under the configured namespace, and follows documents
422
+ * through `ModelService`'s events: at the configured phase, `onModelUpdated`
423
+ * dispatches per-document `clientProxy.onDocumentUpdated` for watched URIs and
424
+ * `onModelsBuilt` a single `clientProxy.onDocumentsBuilt` naming the batch's
425
+ * unwatched documents.
436
426
  *
437
427
  * The data-server requires the full {@link ServerSharedServices}
438
428
  * shape — `HydraniumTextDocuments` for client-attributed updates,
@@ -536,8 +526,8 @@ export class DataServer<
536
526
  // framework's own rejections.
537
527
  renderErrorMessage: error => this.services.MessageRenderer.renderError(error)
538
528
  });
539
- this.disposables.push(this.services.workspace.DocumentBuilder.onUpdate((_changed, deleted) => this.dispatchDeleteEvents(deleted)));
540
- this.subscribeToDocumentBuilder();
529
+ this.disposables.push(this.modelService.onModelDeleted(event => this.dispatchDeleteEvent(event)));
530
+ this.subscribeToModelUpdates();
541
531
  this.subscribeToTextDocumentSaves();
542
532
  this.subscribeToDirtyChanges();
543
533
  this.subscribeToTextDocumentCloses();
@@ -577,7 +567,7 @@ export class DataServer<
577
567
  }
578
568
  // AFTER `disposables.dispose()`, deliberately: ending a session releases
579
569
  // each document it was the last to hold, and this server's own
580
- // `onDidCloseLastOpen` listener would otherwise mark a revert broadcast
570
+ // `onModelReleased` subscription would otherwise mark a revert broadcast
581
571
  // for a connection that is already gone.
582
572
  this.disposables.dispose();
583
573
  for (const [clientId, session] of this.clientSessions) {
@@ -603,20 +593,11 @@ export class DataServer<
603
593
  if (this.disposed) {
604
594
  throw new SessionClosedError(args.clientId);
605
595
  }
606
- const resumable = this.resumableSessions();
607
- const previous = resumable.get(args.clientId);
608
- if (previous && args.resumeToken !== undefined && previous.token === args.resumeToken) {
609
- previous.end();
610
- }
611
- const session = this.modelService.createSession(args.label, args.clientId);
596
+ const session = this.modelService.createSession(args.label, args.clientId, { resumeToken: args.resumeToken });
612
597
  this.clientSessions.set(args.clientId, session);
613
- if (args.resumeToken !== undefined) {
614
- // Taken over by a client registering again after its connection
615
- // dropped, so the old session ends as lost: the documents it was the
616
- // last to have open wait out the grace, and the new session's opens
617
- // find their unsaved text.
618
- resumable.set(args.clientId, { token: args.resumeToken, end: () => this.endSession(args.clientId, session, 'lost') });
619
- }
598
+ // A client registering again on another connection ends this session
599
+ // there, and its watches here would otherwise outlive it.
600
+ session.onDidDispose(() => this.dropWatches(args.clientId));
620
601
  }
621
602
 
622
603
  /**
@@ -637,37 +618,16 @@ export class DataServer<
637
618
  session: ClientSession<AstNode, AstDiagnostic, TTransfer>,
638
619
  cause: SessionEndCause = 'closed'
639
620
  ): void {
621
+ this.dropWatches(clientId);
622
+ session.dispose(cause);
623
+ }
624
+
625
+ protected dropWatches(clientId: string): void {
640
626
  for (const [uri, record] of this.uriWatchRecords) {
641
627
  if (record.watchers.delete(clientId)) {
642
628
  this.pruneUriWatchRecord(uri);
643
629
  }
644
630
  }
645
- session.dispose(cause);
646
- }
647
-
648
- /**
649
- * The resumable sessions of this services tree, by client id, across all of
650
- * its data connections: a client resumes on another connection than the one
651
- * that registered it. An entry leaves when its session ends, by any path, so
652
- * it keeps no ended connection alive.
653
- *
654
- * A takeover by resume token ends the old session even when its connection
655
- * is still alive, so two clients that shared an id and its token would end
656
- * each other's sessions. And the token is a guard against colliding with a
657
- * session the server has not yet seen end, not a secret: the wire carries no
658
- * authentication, and a peer that learns the token can end the session.
659
- */
660
- protected resumableSessions(): Map<string, ResumableSession> {
661
- let sessions = resumableByModelService.get(this.modelService);
662
- if (!sessions) {
663
- const created = new Map<string, ResumableSession>();
664
- // Subscribed once per services tree, and never disposed: it lives as
665
- // long as the text store it listens to.
666
- this.services.workspace.TextDocuments.onDidCloseSession(event => created.delete(event.clientId));
667
- resumableByModelService.set(this.modelService, created);
668
- sessions = created;
669
- }
670
- return sessions;
671
631
  }
672
632
 
673
633
  async createModelDocument(args: CreateModelDocumentArgs): Promise<TransferDocument<TTransfer, TDiagnostic>> {
@@ -776,8 +736,9 @@ export class DataServer<
776
736
  }
777
737
 
778
738
  async persistModelDocument(args: TransferPersistDocumentArgs): Promise<TransferSavedDocument<TTransfer, TDiagnostic>> {
779
- const astDocument = await this.requireSession(args.clientId).persist({ uri: args.uri, baseVersion: args.baseVersion });
780
- return { ...this.encodeDocument(astDocument), persisted: astDocument.persisted };
739
+ const version = await this.requireSession(args.clientId).persist({ uri: args.uri, baseVersion: args.baseVersion });
740
+ const astDocument = await this.modelService.ensureDocumentState(args.uri, this.services.workspace.DocumentBuilder.finalBuildState());
741
+ return { ...this.encodeDocument(astDocument), persisted: { version } };
781
742
  }
782
743
 
783
744
  /**
@@ -800,7 +761,7 @@ export class DataServer<
800
761
  this.uriWatchRecords.set(uri, record);
801
762
  record.watchers.add(args.clientId);
802
763
  if (record.fingerprint === undefined) {
803
- const document = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
764
+ const document = this.modelService.getDocument(args.uri);
804
765
  if (document && !this.services.workspace.ModelLedger.isPlaceholder(document.parseResult.value)) {
805
766
  record.fingerprint = this.computeDocumentFingerprint(document.parseResult.value, this.encoder.toTransferDocument(document));
806
767
  record.fingerprintVersion = this.services.workspace.ModelLedger.versionOf(document.parseResult.value);
@@ -855,6 +816,7 @@ export class DataServer<
855
816
  * LSP head resolves.
856
817
  *
857
818
  * Adopters can still override for head-specific URI policy by subclassing.
819
+ * The result keys watches only; a document is looked up by its own URI.
858
820
  */
859
821
  protected canonicalKey(uri: string): string {
860
822
  return this.services.workspace.DocumentUriPolicy.canonicalUri(uri);
@@ -1018,79 +980,32 @@ export class DataServer<
1018
980
  }
1019
981
 
1020
982
  /**
1021
- * Pick the language that owns a reference source, in order:
983
+ * Pick the language that owns a reference source: the registry's
984
+ * `getLanguageFor` over the source's declared language, URI and AST type,
985
+ * with an {@link isElementSource} routed by the document that holds the
986
+ * element (see {@link findElementDocumentUri}) before its type, and
987
+ * {@link fallbackReferenceLanguage}, adopter policy, last.
1022
988
  *
1023
- * 0. A {@link isSyntheticSource} naming its own `language` routes there.
1024
- * First because every step below infers, and an inference must not
1025
- * override a caller that has said which grammar it means.
1026
- * 1. A URI-bearing source ({@link isDocumentSource}/{@link isSyntheticSource})
1027
- * whose URI resolves to a registered language routes by URI.
1028
- * 2. A single-language workspace always routes to that one language — so
1029
- * single-language adopters never reach the steps below, and never pay
1030
- * for them.
1031
- * 3. An {@link isElementSource} source carries no URI, so in a
1032
- * multi-language workspace it is routed via the document that holds the
1033
- * element (see {@link findElementDocumentUri}).
1034
- * 4. A source carrying an AST type is routed by that type when exactly one
1035
- * registered grammar can produce it (see
1036
- * {@link resolveReferenceLanguageByType}, over the registry's own type
1037
- * index). This is what resolves a create-element flow's synthetic source
1038
- * on a bare directory URI without asking the adopter.
1039
- * 5. Anything still unresolved falls to {@link fallbackReferenceLanguage},
1040
- * which is adopter policy.
989
+ * An unregistered declared language falls through rather than failing: the
990
+ * id crosses the wire, so a client built against a head with one more
991
+ * grammar would otherwise lose queries the other signals answer.
1041
992
  */
1042
993
  protected resolveReferenceLanguage(source: ReferenceSource): HydraniumLanguageServices | undefined {
1043
994
  const registry = this.services.ServiceRegistry;
1044
- if (isSyntheticSource(source) && source.language !== undefined) {
1045
- const declared = registry.getServicesById(source.language);
1046
- if (declared) {
1047
- return declared;
1048
- }
1049
- // Falling through on an UNREGISTERED id rather than failing: the id
1050
- // crosses the wire, so a client built against a head with one more
1051
- // grammar would otherwise lose queries the steps below can answer.
1052
- }
1053
- // `getServicesFor` (non-throwing, one ladder walk) gates the URI lookup:
1054
- // an extensionless / unregistered URI falls through to the steps below
1055
- // rather than throwing "no services for the extension ''".
1056
- const uri = isDocumentSource(source) || isSyntheticSource(source) ? UriUtils.toUri(source.uri) : undefined;
1057
- const byUri = uri && registry.getServicesFor(uri);
1058
- if (byUri) {
1059
- return byUri;
1060
- }
1061
- const all = registry.all;
1062
- if (all.length === 1) {
1063
- return all[0];
1064
- }
1065
995
  if (isElementSource(source)) {
1066
- const documentUri = this.findElementDocumentUri(source);
1067
- const byDocument = documentUri && registry.getServicesFor(documentUri);
1068
- if (byDocument) {
1069
- return byDocument;
1070
- }
996
+ // The index lookup runs only once the cheaper signals abstained.
997
+ return (
998
+ registry.getLanguageFor({}) ??
999
+ registry.getLanguageFor({ target: this.findElementDocumentUri(source), type: source.type }) ??
1000
+ this.fallbackReferenceLanguage(source)
1001
+ );
1071
1002
  }
1072
- const byType = this.resolveReferenceLanguageByType(source);
1073
- return byType ?? this.fallbackReferenceLanguage(source);
1074
- }
1075
-
1076
- /**
1077
- * Route a reference source by the AST type it carries — a
1078
- * {@link isSyntheticSource}'s `type` (the transient node being created) or an
1079
- * {@link isElementSource}'s optional narrowing `type`.
1080
- *
1081
- * Answers only when EXACTLY ONE registered grammar can produce the type.
1082
- * Several can when the type comes from a grammar both import, and then the
1083
- * type genuinely does not identify a language — that is a fall-through to
1084
- * adopter policy, not a coin toss. Note this asks which grammar can *produce*
1085
- * the type, not which mentions it: a grammar that merely cross-references a
1086
- * type can never hold a node of it.
1087
- */
1088
- protected resolveReferenceLanguageByType(source: ReferenceSource): HydraniumLanguageServices | undefined {
1089
- const type = isSyntheticSource(source) ? source.type : isElementSource(source) ? source.type : undefined;
1090
- // The registry owns the type index — it is the one place that knows when
1091
- // the registered set changed, so unlike a private memo here it cannot go
1092
- // stale on a language registered after the first lookup.
1093
- return type ? this.services.ServiceRegistry.soleServicesByType(type) : undefined;
1003
+ const hint = isSyntheticSource(source)
1004
+ ? { languageId: source.language, target: source.uri, type: source.type }
1005
+ : isDocumentSource(source)
1006
+ ? { target: source.uri }
1007
+ : {};
1008
+ return registry.getLanguageFor(hint) ?? this.fallbackReferenceLanguage(source);
1094
1009
  }
1095
1010
 
1096
1011
  /**
@@ -1100,15 +1015,14 @@ export class DataServer<
1100
1015
  * disambiguates names that repeat across types (honouring grammar
1101
1016
  * subtyping through `AstReflection.isSubtype`).
1102
1017
  *
1103
- * Only reached on the multi-language, name-based path (step 3 of
1104
- * {@link resolveReferenceLanguage}); single-language adopters return at
1105
- * step 2.
1018
+ * Only reached in a multi-language workspace, once the declared language
1019
+ * and the URI have abstained.
1106
1020
  *
1107
1021
  * Abstains when the name matches elements in more than one DOCUMENT — the
1108
1022
  * index spans every language and is filled in build order, so "the
1109
- * first match" would be file-watch order rather than an answer. Step 3
1110
- * then falls through to the type-based step 4, which abstains on ties
1111
- * in the same way, and finally to adopter policy.
1023
+ * first match" would be file-watch order rather than an answer. Routing
1024
+ * then falls through to the element's type, which abstains on ties in the
1025
+ * same way, and finally to adopter policy.
1112
1026
  */
1113
1027
  protected findElementDocumentUri(source: ElementSource): URI | undefined {
1114
1028
  const matches = this.services.workspace.IndexManager.getElementsByName(source.name, source.type);
@@ -1225,24 +1139,15 @@ export class DataServer<
1225
1139
  }
1226
1140
 
1227
1141
  /**
1228
- * Subscribe one listener at the configured {@link DataServerOptions.subscriptionPhase}.
1229
- * The listener dispatches subscription events for every matching URI. A
1230
- * single listener (rather than one per subscription) keeps the cost flat
1231
- * regardless of subscriber count.
1142
+ * Follow every document at the configured
1143
+ * {@link DataServerOptions.subscriptionPhase}, once per connection rather
1144
+ * than once per watch, so the cost stays flat in the number of watches.
1232
1145
  */
1233
- protected subscribeToDocumentBuilder(): void {
1234
- this.disposables.push(
1235
- this.services.workspace.DocumentBuilder.onDocumentPhase(this.options.subscriptionPhase, (document, cancelToken) =>
1236
- this.dispatchPhaseEvent(document, cancelToken)
1237
- )
1238
- );
1239
- // The BUILD-phase hook, not the per-document one: this notification is one
1240
- // message per build rather than one per document, and Langium hands the
1241
- // whole batch over here. It also does not fire for a cancelled build,
1242
- // which is what the per-document dispatch has to check by hand.
1243
- this.disposables.push(
1244
- this.services.workspace.DocumentBuilder.onBuildPhase(this.options.subscriptionPhase, built => this.dispatchBuiltEvent(built))
1245
- );
1146
+ protected subscribeToModelUpdates(): void {
1147
+ const phase = this.options.subscriptionPhase;
1148
+ this.disposables.push(this.modelService.onModelUpdated(event => this.dispatchPhaseEvent(event), { phase }));
1149
+ // One message per build rather than one per document.
1150
+ this.disposables.push(this.modelService.onModelsBuilt(event => this.dispatchBuiltEvent(event.uris), { phase }));
1246
1151
  }
1247
1152
 
1248
1153
  /**
@@ -1273,17 +1178,9 @@ export class DataServer<
1273
1178
  * dependents produces nothing at all, and workspace initialisation produces
1274
1179
  * nothing because it does not build to this phase. The ceiling is a
1275
1180
  * whole-workspace rebuild at the subscription phase: one message, URIs only.
1276
- *
1277
- * The URI is canonicalised for the same reason {@link uriWatchRecords} is keyed
1278
- * that way, and is untested for the same reason as its twin in
1279
- * {@link dispatchDeleteEvents}: the builder reports URIs out of its own
1280
- * store, so a non-canonical one cannot be produced without a fixture
1281
- * asserting a shape the real system never emits.
1282
1181
  */
1283
- protected dispatchBuiltEvent(built: readonly LangiumDocument[]): void {
1284
- const uris = built
1285
- .map(document => this.canonicalKey(document.uri.toString()))
1286
- .filter(uri => !this.uriWatchRecords.get(uri)?.watchers.size);
1182
+ protected dispatchBuiltEvent(built: readonly CanonicalUri[]): void {
1183
+ const uris = built.map(uri => this.canonicalKey(uri)).filter(uri => !this.uriWatchRecords.get(uri)?.watchers.size);
1287
1184
  if (uris.length === 0) {
1288
1185
  return;
1289
1186
  }
@@ -1292,13 +1189,12 @@ export class DataServer<
1292
1189
  }
1293
1190
 
1294
1191
  /**
1295
- * Subscribe to the universal save event on `HydraniumTextDocuments` so a
1296
- * single `onDocumentSaved` wire notification fires for ANY save of a
1297
- * subscribed URI — regardless of whether the save originated from the
1298
- * data-server's RPC `saveModelDocument`, the LSP head's text-editor save,
1299
- * or any other client writing through `notifyDidSaveTextDocument`.
1192
+ * Follow every save so a single `onDocumentSaved` wire notification fires
1193
+ * for ANY save of a subscribed URI — regardless of whether the save
1194
+ * originated from the data-server's RPC `saveModelDocument`, the LSP head's
1195
+ * text-editor save, or any other client.
1300
1196
  *
1301
- * Architectural symmetry with {@link subscribeToDocumentBuilder}: every
1197
+ * Architectural symmetry with {@link subscribeToModelUpdates}: every
1302
1198
  * subscribed client sees every state change to documents they care about,
1303
1199
  * regardless of which client triggered it. Firing `onDocumentSaved` only
1304
1200
  * from the data-server's own RPC path is a bug, not an optimisation: an
@@ -1306,12 +1202,12 @@ export class DataServer<
1306
1202
  * clients, which never clear their dirty state.
1307
1203
  */
1308
1204
  protected subscribeToTextDocumentSaves(): void {
1309
- this.disposables.push(this.services.workspace.TextDocuments.onDidSave(event => this.dispatchSaveEvent(event)));
1205
+ this.disposables.push(this.modelService.onModelSaved(event => this.dispatchSaveEvent(event)));
1310
1206
  }
1311
1207
 
1312
1208
  /** Relay each change of a document's dirty state; see {@link dispatchDirtyEvent}. */
1313
1209
  protected subscribeToDirtyChanges(): void {
1314
- this.disposables.push(this.services.workspace.TextDocuments.onDidChangeDirty(event => this.dispatchDirtyEvent(event)));
1210
+ this.disposables.push(this.modelService.onDirtyChanged(event => this.dispatchDirtyEvent(event)));
1315
1211
  }
1316
1212
 
1317
1213
  /**
@@ -1319,7 +1215,7 @@ export class DataServer<
1319
1215
  * {@link dispatchSaveEvent} is: the answer at any one moment travels on
1320
1216
  * every document sent, so a client that watches nothing reads it there.
1321
1217
  */
1322
- protected dispatchDirtyEvent(event: DocumentDirtyChangedEvent): void {
1218
+ protected dispatchDirtyEvent(event: ModelDirtyChangedEvent): void {
1323
1219
  const uri = this.canonicalKey(event.uri);
1324
1220
  if (this.uriWatchRecords.get(uri)?.watchers.size) {
1325
1221
  this.clientProxy.onDocumentDirtyChanged({ uri, text: event.text });
@@ -1345,7 +1241,7 @@ export class DataServer<
1345
1241
  */
1346
1242
  protected subscribeToTextDocumentCloses(): void {
1347
1243
  this.disposables.push(
1348
- this.services.workspace.TextDocuments.onDidCloseLastOpen(event => {
1244
+ this.modelService.onModelReleased(event => {
1349
1245
  const uri = this.canonicalKey(event.uri);
1350
1246
  const record = this.uriWatchRecords.get(uri) ?? { watchers: new Set<string>() };
1351
1247
  record.revertPending = true;
@@ -1374,15 +1270,9 @@ export class DataServer<
1374
1270
  * in the other direction: a transition that matters enough is delivered
1375
1271
  * without a subscription.
1376
1272
  *
1377
- * Runs from the `DocumentBuilder.onUpdate` listener rather than from a phase
1378
- * listener, which is the only place a deletion is observable: `update`
1379
- * removes the document before deriving the rebuild set, so it is never built.
1380
- * That ordering also puts this notification ahead of the `'rebuilt'` events
1381
- * for the dependents whose references the deletion just broke.
1382
- *
1383
- * The `deleted` list arrives already expanded to concrete document URIs —
1384
- * `deleteDocuments` resolves a directory URI to the documents beneath it — so
1385
- * no caller has to handle a directory here.
1273
+ * A deletion is announced before the build that follows it, so this goes
1274
+ * out ahead of the `'rebuilt'` events for the dependents whose references
1275
+ * the deletion just broke.
1386
1276
  *
1387
1277
  * The URI's fingerprint and revert mark are dropped: they describe a
1388
1278
  * document that no longer exists. A kept fingerprint is adopted as the
@@ -1393,36 +1283,29 @@ export class DataServer<
1393
1283
  * re-subscription, and a client that answers the deletion by closing
1394
1284
  * releases the watch through `closeModelDocument` anyway.
1395
1285
  */
1396
- protected dispatchDeleteEvents(deleted: readonly URI[]): void {
1397
- for (const removed of deleted) {
1398
- const uri = this.canonicalKey(removed.toString());
1399
- const record = this.uriWatchRecords.get(uri);
1400
- if (record) {
1401
- record.fingerprint = undefined;
1402
- record.fingerprintVersion = undefined;
1403
- record.revertPending = undefined;
1404
- record.sentVersion = undefined;
1405
- this.pruneUriWatchRecord(uri);
1406
- }
1407
- this.clientProxy.onDocumentDeleted({ uri });
1286
+ protected dispatchDeleteEvent(event: ModelDeletedEvent): void {
1287
+ const uri = this.canonicalKey(event.uri);
1288
+ const record = this.uriWatchRecords.get(uri);
1289
+ if (record) {
1290
+ record.fingerprint = undefined;
1291
+ record.fingerprintVersion = undefined;
1292
+ record.revertPending = undefined;
1293
+ record.sentVersion = undefined;
1294
+ this.pruneUriWatchRecord(uri);
1408
1295
  }
1296
+ this.clientProxy.onDocumentDeleted({ uri });
1409
1297
  }
1410
1298
 
1411
1299
  /** Fan out a save event for the document's URI, gated on the URI's watchers. */
1412
- protected dispatchSaveEvent(event: ClientTextDocumentChangeEvent<TextDocument>): void {
1413
- // The save event arrives under the CLIENT URI the text store keys by (e.g. a
1414
- // symlink path S); `uriWatchRecords` and `dispatchPhaseEvent` key by the
1415
- // CANONICAL identity R. Canonicalize before both the gate and the envelope so
1416
- // a save of a symlinked file isn't silently dropped (and the envelope resolves
1417
- // the R-keyed document rather than missing into an empty one).
1300
+ protected dispatchSaveEvent(event: AstDocumentSavedEvent<AstNode>): void {
1418
1301
  const uri = this.canonicalKey(event.document.uri);
1419
1302
  if (!this.uriWatchRecords.get(uri)?.watchers.size) {
1420
1303
  return;
1421
1304
  }
1422
- const response = this.envelope(UriUtils.toUri(uri));
1305
+ const response = this.envelope(UriUtils.toUri(event.document.uri));
1423
1306
  const wireEvent: TransferDocumentSavedEvent<TTransfer, TDiagnostic> = {
1424
1307
  document: response,
1425
- sourceClientId: event.clientId
1308
+ sourceClientId: event.sourceClientId
1426
1309
  };
1427
1310
  this.clientProxy.onDocumentSaved(wireEvent);
1428
1311
  }
@@ -1458,16 +1341,8 @@ export class DataServer<
1458
1341
  * digest keeps its memory constant in document size; see
1459
1342
  * {@link computeDocumentFingerprint} for the inputs.
1460
1343
  */
1461
- protected dispatchPhaseEvent(document: LangiumDocument, cancelToken: CancellationToken): void {
1462
- if (cancelToken.isCancellationRequested) {
1463
- // Build preempted by a concurrent write lock (or other cancel source) —
1464
- // the subscription event is stale by the time it would fire. Skip
1465
- // emission so RPC subscribers don't surface intermediate states.
1466
- // A pending revert mark is deliberately NOT consumed here: the
1467
- // follow-up build re-fires this phase and broadcasts then.
1468
- return;
1469
- }
1470
- const uri = document.uri.toString();
1344
+ protected dispatchPhaseEvent(event: ModelUpdatedEvent<AstNode>): void {
1345
+ const uri = this.canonicalKey(event.document.uri);
1471
1346
  const record = this.uriWatchRecords.get(uri);
1472
1347
  const revertedOnClose = record?.revertPending === true;
1473
1348
  if (!record || (record.watchers.size === 0 && !revertedOnClose)) {
@@ -1483,6 +1358,12 @@ export class DataServer<
1483
1358
  record.revertPending = undefined;
1484
1359
  this.pruneUriWatchRecord(uri);
1485
1360
  }
1361
+ // The encoder reads the built document, which the event's holds for this
1362
+ // synchronous run.
1363
+ const document = this.modelService.getDocument(event.document.uri);
1364
+ if (!document) {
1365
+ return;
1366
+ }
1486
1367
  const fingerprint = this.computeDocumentFingerprint(document.parseResult.value, this.encoder.toTransferDocument(document));
1487
1368
  const stamped = this.services.workspace.ModelLedger.versionOf(document.parseResult.value);
1488
1369
  if (record.fingerprint === fingerprint && record.fingerprintVersion === stamped) {
@@ -1495,22 +1376,24 @@ export class DataServer<
1495
1376
  }
1496
1377
  record.fingerprint = fingerprint;
1497
1378
  record.fingerprintVersion = stamped;
1498
- // The manager's attribution, so this head names the same client as the
1379
+ // The event's attribution, so this head names the same client as the
1499
1380
  // in-process heads do for one build, except for a version this head
1500
1381
  // already sent; see `DataServerUriWatchRecord.sentVersion`.
1501
- const version = this.services.workspace.AstDocumentManager.isOpen(uri) ? document.textDocument.version : undefined;
1382
+ const version = this.modelService.isOpen(event.document.uri) ? document.textDocument.version : undefined;
1502
1383
  const { reason, sourceClientId } =
1503
1384
  version !== undefined && record.sentVersion === version
1504
1385
  ? { reason: 'rebuilt' as const, sourceClientId: UNKNOWN_CLIENT_ID }
1505
- : this.services.workspace.AstDocumentManager.attributeUpdate(document);
1386
+ : event;
1506
1387
  record.sentVersion = version;
1507
- const event: TransferDocumentUpdatedEvent<TTransfer, TDiagnostic> = {
1388
+ const wireEvent: TransferDocumentUpdatedEvent<TTransfer, TDiagnostic> = {
1508
1389
  document: this.envelope(document.uri, fingerprint),
1509
1390
  sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : sourceClientId,
1510
1391
  reason
1511
1392
  };
1512
- this.tracer.withUri(uri).debug(`Emit onDocumentUpdated v${stamped} (reason=${event.reason}, sourceClientId=${event.sourceClientId})`);
1513
- this.clientProxy.onDocumentUpdated(event);
1393
+ this.tracer
1394
+ .withUri(uri)
1395
+ .debug(`Emit onDocumentUpdated v${stamped} (reason=${wireEvent.reason}, sourceClientId=${wireEvent.sourceClientId})`);
1396
+ this.clientProxy.onDocumentUpdated(wireEvent);
1514
1397
  }
1515
1398
 
1516
1399
  /**