@hydranium/data-server 1.0.0-next.201 → 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.
@@ -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>> {
@@ -801,7 +761,7 @@ export class DataServer<
801
761
  this.uriWatchRecords.set(uri, record);
802
762
  record.watchers.add(args.clientId);
803
763
  if (record.fingerprint === undefined) {
804
- const document = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
764
+ const document = this.modelService.getDocument(args.uri);
805
765
  if (document && !this.services.workspace.ModelLedger.isPlaceholder(document.parseResult.value)) {
806
766
  record.fingerprint = this.computeDocumentFingerprint(document.parseResult.value, this.encoder.toTransferDocument(document));
807
767
  record.fingerprintVersion = this.services.workspace.ModelLedger.versionOf(document.parseResult.value);
@@ -856,6 +816,7 @@ export class DataServer<
856
816
  * LSP head resolves.
857
817
  *
858
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.
859
820
  */
860
821
  protected canonicalKey(uri: string): string {
861
822
  return this.services.workspace.DocumentUriPolicy.canonicalUri(uri);
@@ -1019,79 +980,32 @@ export class DataServer<
1019
980
  }
1020
981
 
1021
982
  /**
1022
- * 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.
1023
988
  *
1024
- * 0. A {@link isSyntheticSource} naming its own `language` routes there.
1025
- * First because every step below infers, and an inference must not
1026
- * override a caller that has said which grammar it means.
1027
- * 1. A URI-bearing source ({@link isDocumentSource}/{@link isSyntheticSource})
1028
- * whose URI resolves to a registered language routes by URI.
1029
- * 2. A single-language workspace always routes to that one language — so
1030
- * single-language adopters never reach the steps below, and never pay
1031
- * for them.
1032
- * 3. An {@link isElementSource} source carries no URI, so in a
1033
- * multi-language workspace it is routed via the document that holds the
1034
- * element (see {@link findElementDocumentUri}).
1035
- * 4. A source carrying an AST type is routed by that type when exactly one
1036
- * registered grammar can produce it (see
1037
- * {@link resolveReferenceLanguageByType}, over the registry's own type
1038
- * index). This is what resolves a create-element flow's synthetic source
1039
- * on a bare directory URI without asking the adopter.
1040
- * 5. Anything still unresolved falls to {@link fallbackReferenceLanguage},
1041
- * 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.
1042
992
  */
1043
993
  protected resolveReferenceLanguage(source: ReferenceSource): HydraniumLanguageServices | undefined {
1044
994
  const registry = this.services.ServiceRegistry;
1045
- if (isSyntheticSource(source) && source.language !== undefined) {
1046
- const declared = registry.getServicesById(source.language);
1047
- if (declared) {
1048
- return declared;
1049
- }
1050
- // Falling through on an UNREGISTERED id rather than failing: the id
1051
- // crosses the wire, so a client built against a head with one more
1052
- // grammar would otherwise lose queries the steps below can answer.
1053
- }
1054
- // `getServicesFor` (non-throwing, one ladder walk) gates the URI lookup:
1055
- // an extensionless / unregistered URI falls through to the steps below
1056
- // rather than throwing "no services for the extension ''".
1057
- const uri = isDocumentSource(source) || isSyntheticSource(source) ? UriUtils.toUri(source.uri) : undefined;
1058
- const byUri = uri && registry.getServicesFor(uri);
1059
- if (byUri) {
1060
- return byUri;
1061
- }
1062
- const all = registry.all;
1063
- if (all.length === 1) {
1064
- return all[0];
1065
- }
1066
995
  if (isElementSource(source)) {
1067
- const documentUri = this.findElementDocumentUri(source);
1068
- const byDocument = documentUri && registry.getServicesFor(documentUri);
1069
- if (byDocument) {
1070
- return byDocument;
1071
- }
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
+ );
1072
1002
  }
1073
- const byType = this.resolveReferenceLanguageByType(source);
1074
- return byType ?? this.fallbackReferenceLanguage(source);
1075
- }
1076
-
1077
- /**
1078
- * Route a reference source by the AST type it carries — a
1079
- * {@link isSyntheticSource}'s `type` (the transient node being created) or an
1080
- * {@link isElementSource}'s optional narrowing `type`.
1081
- *
1082
- * Answers only when EXACTLY ONE registered grammar can produce the type.
1083
- * Several can when the type comes from a grammar both import, and then the
1084
- * type genuinely does not identify a language — that is a fall-through to
1085
- * adopter policy, not a coin toss. Note this asks which grammar can *produce*
1086
- * the type, not which mentions it: a grammar that merely cross-references a
1087
- * type can never hold a node of it.
1088
- */
1089
- protected resolveReferenceLanguageByType(source: ReferenceSource): HydraniumLanguageServices | undefined {
1090
- const type = isSyntheticSource(source) ? source.type : isElementSource(source) ? source.type : undefined;
1091
- // The registry owns the type index — it is the one place that knows when
1092
- // the registered set changed, so unlike a private memo here it cannot go
1093
- // stale on a language registered after the first lookup.
1094
- 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);
1095
1009
  }
1096
1010
 
1097
1011
  /**
@@ -1101,15 +1015,14 @@ export class DataServer<
1101
1015
  * disambiguates names that repeat across types (honouring grammar
1102
1016
  * subtyping through `AstReflection.isSubtype`).
1103
1017
  *
1104
- * Only reached on the multi-language, name-based path (step 3 of
1105
- * {@link resolveReferenceLanguage}); single-language adopters return at
1106
- * step 2.
1018
+ * Only reached in a multi-language workspace, once the declared language
1019
+ * and the URI have abstained.
1107
1020
  *
1108
1021
  * Abstains when the name matches elements in more than one DOCUMENT — the
1109
1022
  * index spans every language and is filled in build order, so "the
1110
- * first match" would be file-watch order rather than an answer. Step 3
1111
- * then falls through to the type-based step 4, which abstains on ties
1112
- * 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.
1113
1026
  */
1114
1027
  protected findElementDocumentUri(source: ElementSource): URI | undefined {
1115
1028
  const matches = this.services.workspace.IndexManager.getElementsByName(source.name, source.type);
@@ -1226,24 +1139,15 @@ export class DataServer<
1226
1139
  }
1227
1140
 
1228
1141
  /**
1229
- * Subscribe one listener at the configured {@link DataServerOptions.subscriptionPhase}.
1230
- * The listener dispatches subscription events for every matching URI. A
1231
- * single listener (rather than one per subscription) keeps the cost flat
1232
- * 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.
1233
1145
  */
1234
- protected subscribeToDocumentBuilder(): void {
1235
- this.disposables.push(
1236
- this.services.workspace.DocumentBuilder.onDocumentPhase(this.options.subscriptionPhase, (document, cancelToken) =>
1237
- this.dispatchPhaseEvent(document, cancelToken)
1238
- )
1239
- );
1240
- // The BUILD-phase hook, not the per-document one: this notification is one
1241
- // message per build rather than one per document, and Langium hands the
1242
- // whole batch over here. It also does not fire for a cancelled build,
1243
- // which is what the per-document dispatch has to check by hand.
1244
- this.disposables.push(
1245
- this.services.workspace.DocumentBuilder.onBuildPhase(this.options.subscriptionPhase, built => this.dispatchBuiltEvent(built))
1246
- );
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 }));
1247
1151
  }
1248
1152
 
1249
1153
  /**
@@ -1274,17 +1178,9 @@ export class DataServer<
1274
1178
  * dependents produces nothing at all, and workspace initialisation produces
1275
1179
  * nothing because it does not build to this phase. The ceiling is a
1276
1180
  * whole-workspace rebuild at the subscription phase: one message, URIs only.
1277
- *
1278
- * The URI is canonicalised for the same reason {@link uriWatchRecords} is keyed
1279
- * that way, and is untested for the same reason as its twin in
1280
- * {@link dispatchDeleteEvents}: the builder reports URIs out of its own
1281
- * store, so a non-canonical one cannot be produced without a fixture
1282
- * asserting a shape the real system never emits.
1283
1181
  */
1284
- protected dispatchBuiltEvent(built: readonly LangiumDocument[]): void {
1285
- const uris = built
1286
- .map(document => this.canonicalKey(document.uri.toString()))
1287
- .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);
1288
1184
  if (uris.length === 0) {
1289
1185
  return;
1290
1186
  }
@@ -1293,13 +1189,12 @@ export class DataServer<
1293
1189
  }
1294
1190
 
1295
1191
  /**
1296
- * Subscribe to the universal save event on `HydraniumTextDocuments` so a
1297
- * single `onDocumentSaved` wire notification fires for ANY save of a
1298
- * subscribed URI — regardless of whether the save originated from the
1299
- * data-server's RPC `saveModelDocument`, the LSP head's text-editor save,
1300
- * 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.
1301
1196
  *
1302
- * Architectural symmetry with {@link subscribeToDocumentBuilder}: every
1197
+ * Architectural symmetry with {@link subscribeToModelUpdates}: every
1303
1198
  * subscribed client sees every state change to documents they care about,
1304
1199
  * regardless of which client triggered it. Firing `onDocumentSaved` only
1305
1200
  * from the data-server's own RPC path is a bug, not an optimisation: an
@@ -1307,12 +1202,12 @@ export class DataServer<
1307
1202
  * clients, which never clear their dirty state.
1308
1203
  */
1309
1204
  protected subscribeToTextDocumentSaves(): void {
1310
- this.disposables.push(this.services.workspace.TextDocuments.onDidSave(event => this.dispatchSaveEvent(event)));
1205
+ this.disposables.push(this.modelService.onModelSaved(event => this.dispatchSaveEvent(event)));
1311
1206
  }
1312
1207
 
1313
1208
  /** Relay each change of a document's dirty state; see {@link dispatchDirtyEvent}. */
1314
1209
  protected subscribeToDirtyChanges(): void {
1315
- this.disposables.push(this.services.workspace.TextDocuments.onDidChangeDirty(event => this.dispatchDirtyEvent(event)));
1210
+ this.disposables.push(this.modelService.onDirtyChanged(event => this.dispatchDirtyEvent(event)));
1316
1211
  }
1317
1212
 
1318
1213
  /**
@@ -1320,7 +1215,7 @@ export class DataServer<
1320
1215
  * {@link dispatchSaveEvent} is: the answer at any one moment travels on
1321
1216
  * every document sent, so a client that watches nothing reads it there.
1322
1217
  */
1323
- protected dispatchDirtyEvent(event: DocumentDirtyChangedEvent): void {
1218
+ protected dispatchDirtyEvent(event: ModelDirtyChangedEvent): void {
1324
1219
  const uri = this.canonicalKey(event.uri);
1325
1220
  if (this.uriWatchRecords.get(uri)?.watchers.size) {
1326
1221
  this.clientProxy.onDocumentDirtyChanged({ uri, text: event.text });
@@ -1346,7 +1241,7 @@ export class DataServer<
1346
1241
  */
1347
1242
  protected subscribeToTextDocumentCloses(): void {
1348
1243
  this.disposables.push(
1349
- this.services.workspace.TextDocuments.onDidCloseLastOpen(event => {
1244
+ this.modelService.onModelReleased(event => {
1350
1245
  const uri = this.canonicalKey(event.uri);
1351
1246
  const record = this.uriWatchRecords.get(uri) ?? { watchers: new Set<string>() };
1352
1247
  record.revertPending = true;
@@ -1375,15 +1270,9 @@ export class DataServer<
1375
1270
  * in the other direction: a transition that matters enough is delivered
1376
1271
  * without a subscription.
1377
1272
  *
1378
- * Runs from the `DocumentBuilder.onUpdate` listener rather than from a phase
1379
- * listener, which is the only place a deletion is observable: `update`
1380
- * removes the document before deriving the rebuild set, so it is never built.
1381
- * That ordering also puts this notification ahead of the `'rebuilt'` events
1382
- * for the dependents whose references the deletion just broke.
1383
- *
1384
- * The `deleted` list arrives already expanded to concrete document URIs —
1385
- * `deleteDocuments` resolves a directory URI to the documents beneath it — so
1386
- * 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.
1387
1276
  *
1388
1277
  * The URI's fingerprint and revert mark are dropped: they describe a
1389
1278
  * document that no longer exists. A kept fingerprint is adopted as the
@@ -1394,36 +1283,29 @@ export class DataServer<
1394
1283
  * re-subscription, and a client that answers the deletion by closing
1395
1284
  * releases the watch through `closeModelDocument` anyway.
1396
1285
  */
1397
- protected dispatchDeleteEvents(deleted: readonly URI[]): void {
1398
- for (const removed of deleted) {
1399
- const uri = this.canonicalKey(removed.toString());
1400
- const record = this.uriWatchRecords.get(uri);
1401
- if (record) {
1402
- record.fingerprint = undefined;
1403
- record.fingerprintVersion = undefined;
1404
- record.revertPending = undefined;
1405
- record.sentVersion = undefined;
1406
- this.pruneUriWatchRecord(uri);
1407
- }
1408
- 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);
1409
1295
  }
1296
+ this.clientProxy.onDocumentDeleted({ uri });
1410
1297
  }
1411
1298
 
1412
1299
  /** Fan out a save event for the document's URI, gated on the URI's watchers. */
1413
- protected dispatchSaveEvent(event: ClientTextDocumentChangeEvent<TextDocument>): void {
1414
- // The save event arrives under the CLIENT URI the text store keys by (e.g. a
1415
- // symlink path S); `uriWatchRecords` and `dispatchPhaseEvent` key by the
1416
- // CANONICAL identity R. Canonicalize before both the gate and the envelope so
1417
- // a save of a symlinked file isn't silently dropped (and the envelope resolves
1418
- // the R-keyed document rather than missing into an empty one).
1300
+ protected dispatchSaveEvent(event: AstDocumentSavedEvent<AstNode>): void {
1419
1301
  const uri = this.canonicalKey(event.document.uri);
1420
1302
  if (!this.uriWatchRecords.get(uri)?.watchers.size) {
1421
1303
  return;
1422
1304
  }
1423
- const response = this.envelope(UriUtils.toUri(uri));
1305
+ const response = this.envelope(UriUtils.toUri(event.document.uri));
1424
1306
  const wireEvent: TransferDocumentSavedEvent<TTransfer, TDiagnostic> = {
1425
1307
  document: response,
1426
- sourceClientId: event.clientId
1308
+ sourceClientId: event.sourceClientId
1427
1309
  };
1428
1310
  this.clientProxy.onDocumentSaved(wireEvent);
1429
1311
  }
@@ -1459,16 +1341,8 @@ export class DataServer<
1459
1341
  * digest keeps its memory constant in document size; see
1460
1342
  * {@link computeDocumentFingerprint} for the inputs.
1461
1343
  */
1462
- protected dispatchPhaseEvent(document: LangiumDocument, cancelToken: CancellationToken): void {
1463
- if (cancelToken.isCancellationRequested) {
1464
- // Build preempted by a concurrent write lock (or other cancel source) —
1465
- // the subscription event is stale by the time it would fire. Skip
1466
- // emission so RPC subscribers don't surface intermediate states.
1467
- // A pending revert mark is deliberately NOT consumed here: the
1468
- // follow-up build re-fires this phase and broadcasts then.
1469
- return;
1470
- }
1471
- const uri = document.uri.toString();
1344
+ protected dispatchPhaseEvent(event: ModelUpdatedEvent<AstNode>): void {
1345
+ const uri = this.canonicalKey(event.document.uri);
1472
1346
  const record = this.uriWatchRecords.get(uri);
1473
1347
  const revertedOnClose = record?.revertPending === true;
1474
1348
  if (!record || (record.watchers.size === 0 && !revertedOnClose)) {
@@ -1484,6 +1358,12 @@ export class DataServer<
1484
1358
  record.revertPending = undefined;
1485
1359
  this.pruneUriWatchRecord(uri);
1486
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
+ }
1487
1367
  const fingerprint = this.computeDocumentFingerprint(document.parseResult.value, this.encoder.toTransferDocument(document));
1488
1368
  const stamped = this.services.workspace.ModelLedger.versionOf(document.parseResult.value);
1489
1369
  if (record.fingerprint === fingerprint && record.fingerprintVersion === stamped) {
@@ -1496,22 +1376,24 @@ export class DataServer<
1496
1376
  }
1497
1377
  record.fingerprint = fingerprint;
1498
1378
  record.fingerprintVersion = stamped;
1499
- // 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
1500
1380
  // in-process heads do for one build, except for a version this head
1501
1381
  // already sent; see `DataServerUriWatchRecord.sentVersion`.
1502
- 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;
1503
1383
  const { reason, sourceClientId } =
1504
1384
  version !== undefined && record.sentVersion === version
1505
1385
  ? { reason: 'rebuilt' as const, sourceClientId: UNKNOWN_CLIENT_ID }
1506
- : this.services.workspace.AstDocumentManager.attributeUpdate(document);
1386
+ : event;
1507
1387
  record.sentVersion = version;
1508
- const event: TransferDocumentUpdatedEvent<TTransfer, TDiagnostic> = {
1388
+ const wireEvent: TransferDocumentUpdatedEvent<TTransfer, TDiagnostic> = {
1509
1389
  document: this.envelope(document.uri, fingerprint),
1510
1390
  sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : sourceClientId,
1511
1391
  reason
1512
1392
  };
1513
- this.tracer.withUri(uri).debug(`Emit onDocumentUpdated v${stamped} (reason=${event.reason}, sourceClientId=${event.sourceClientId})`);
1514
- 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);
1515
1397
  }
1516
1398
 
1517
1399
  /**