@hydranium/data-server 1.0.0-next.135 → 1.0.0-next.140

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.
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
- import { createRpcProxy, defineMessage, DisposableCollection, HYDRANIUM_ERROR_CODES, isDocumentSource, isElementSource, isSyntheticSource, messageData, messageError, ReferenceSource, SessionClosedError, TIMED_OUT, TransferDocument, asSnapshotVersion } from '@hydranium/protocol';
9
+ import { createRpcProxy, defineMessage, DisposableCollection, HYDRANIUM_ERROR_CODES, isDocumentSource, isElementSource, isSyntheticSource, messageData, messageError, ReferenceSource, SessionClosedError, TIMED_OUT, TransferDocument, UNKNOWN_CLIENT_ID, asSnapshotVersion } from '@hydranium/protocol';
10
10
  import { DATA_SERVER_DIAGNOSTICS_METHODS, DATA_SERVER_PROTOCOL_METHODS, DATA_SERVER_WIRE_PREFIX } from '@hydranium/protocol/data';
11
- import { REVERT_ON_CLOSE_CLIENT_ID, UNKNOWN_CLIENT_ID } from '@hydranium/core';
11
+ import { REVERT_ON_CLOSE_CLIENT_ID } from '@hydranium/core';
12
12
  import { defaultDataServerDiagnostics } from './default-diagnostics.js';
13
13
  /**
14
14
  * Raised when a profiling command arrives with no capture running.
@@ -160,8 +160,8 @@ export class DataServer {
160
160
  referenceSettleTimeoutMs: 60_000
161
161
  };
162
162
  options;
163
- /** Subscription bookkeeping: URI → set of clientIds that subscribed for that URI. */
164
- subscriptions = new Map();
163
+ /** This connection's {@link DataServerUriWatchRecord} per canonical URI; see there for when a record lives. */
164
+ uriWatchRecords = new Map();
165
165
  /**
166
166
  * The client sessions this connection registered, by client id. A document
167
167
  * request carrying one of these ids acts as that session; one carrying any
@@ -175,67 +175,6 @@ export class DataServer {
175
175
  /** Typed client proxy — sends `data-server/on*` notifications back over the same wire. */
176
176
  clientProxy;
177
177
  disposables = new DisposableCollection();
178
- /**
179
- * Snapshot of the most recent `DocumentBuilder.onUpdate` event. Drives
180
- * `reason` discrimination on outbound `onDocumentUpdated` notifications:
181
- * a URI in the `changed` list emits `'changed'`, otherwise `'rebuilt'`
182
- * (cascade rebuild from a dependent URI's change). The same mechanism
183
- * `AstDocumentManager.onUpdate` uses on the LSP side, so reason fidelity
184
- * stays consistent between heads. The `deleted` half is read by
185
- * {@link dispatchDeleteEvents} rather than by the reason discrimination,
186
- * a deletion travelling on its own channel.
187
- */
188
- lastBuildUpdate;
189
- /**
190
- * Per-URI hash of the last emitted (text + diagnostics) state,
191
- * used by {@link dispatchPhaseEvent} to suppress duplicate
192
- * `onDocumentUpdated` notifications for rebuilds that produce no
193
- * observable change since the last emit.
194
- *
195
- * Two scenarios routinely produce such rebuilds:
196
- * 1. **Refresh-triggered rebuilds.** When a second client attaches to a
197
- * document already open in another client,
198
- * `HydraniumTextDocuments.refreshContent` fires `onDidChangeContent`
199
- * purely to re-trigger the build pipeline — the text and diagnostics
200
- * are identical to the prior emit.
201
- * 2. **Cascade rebuilds with no diagnostic delta.** Langium rebuilds a
202
- * document when a dependency changes; if the rebuild produces the
203
- * same diagnostics, there is nothing new to communicate to subscribers.
204
- *
205
- * Without this filter, both scenarios reach subscribers as wire-side
206
- * `'changed'` events. A subscriber that interprets `'changed'` as
207
- * "another client wrote new content" and resets its in-memory root to
208
- * the server view then misreads the spurious event as a concurrent
209
- * third-party write and loses the user's edits.
210
- *
211
- * Initialised on {@link watchModelDocument} to the current
212
- * document fingerprint so the FIRST phase event after a fresh
213
- * subscription is also de-duplicated against the state subscribers
214
- * obtained via `getModelDocument` — they do not need a redundant phase
215
- * notification for the state they just fetched.
216
- *
217
- * Cleared in {@link unwatchModelDocument} when the last
218
- * subscriber for a URI leaves so memory does not accumulate.
219
- *
220
- * Stored as a cyrb53 hex digest (16 chars per URI) rather than the raw
221
- * text + diagnostics serialisation so the memory cost is constant in
222
- * document size. See {@link computeDocumentFingerprint} for the inputs.
223
- */
224
- lastEmittedFingerprint = new Map();
225
- /**
226
- * URIs the text store just released after their last close. The store
227
- * rebuilds such a document from its disk content (discarding unsaved
228
- * in-session edits), but {@link dispatchPhaseEvent} gates on the
229
- * subscription map — and the last close typically also removed the last
230
- * watcher, so consumers that only ever fetch via `getModelDocument` would
231
- * keep showing the discarded state forever. Marked on the release (see
232
- * {@link subscribeToTextDocumentCloses}) and consumed by
233
- * {@link dispatchPhaseEvent}, which broadcasts the following rebuild's
234
- * phase event even without a subscription — de-duplicated against
235
- * {@link lastEmittedFingerprint} where an entry survives, so a close
236
- * whose disk state equals the last emitted state stays silent.
237
- */
238
- pendingRevertBroadcasts = new Set();
239
178
  tracer;
240
179
  /** The in-flight interactive profile capture, held between {@link startProfiling} and {@link stopProfiling}. */
241
180
  activeProfile;
@@ -286,10 +225,7 @@ export class DataServer {
286
225
  // framework's own rejections.
287
226
  renderErrorMessage: error => this.services.MessageRenderer.renderError(error)
288
227
  });
289
- this.disposables.push(this.services.workspace.DocumentBuilder.onUpdate((changed, deleted) => {
290
- this.lastBuildUpdate = { changed, deleted };
291
- this.dispatchDeleteEvents(deleted);
292
- }));
228
+ this.disposables.push(this.services.workspace.DocumentBuilder.onUpdate((_changed, deleted) => this.dispatchDeleteEvents(deleted)));
293
229
  this.subscribeToDocumentBuilder();
294
230
  this.subscribeToTextDocumentSaves();
295
231
  this.subscribeToDirtyChanges();
@@ -304,10 +240,9 @@ export class DataServer {
304
240
  }
305
241
  /**
306
242
  * Release everything the constructor wired up — the bound protocol
307
- * handlers and every listener — and clear the subscription map and the
308
- * per-URI emission-fingerprint cache, so a long-lived shared services
309
- * bundle does not retain per-connection memory after the connection
310
- * closes. Also ends every session this connection registered, which closes
243
+ * handlers and every listener — and clear {@link uriWatchRecords}, so a
244
+ * long-lived shared services bundle does not retain per-connection memory
245
+ * after the connection closes. Also ends every session this connection registered, which closes
311
246
  * every document it has open: the SHARED store's state rather than this
312
247
  * server's, which outlives the connection unless released here.
313
248
  *
@@ -336,9 +271,7 @@ export class DataServer {
336
271
  for (const [clientId, session] of this.clientSessions) {
337
272
  this.endSession(clientId, session, cause);
338
273
  }
339
- this.subscriptions.clear();
340
- this.lastEmittedFingerprint.clear();
341
- this.pendingRevertBroadcasts.clear();
274
+ this.uriWatchRecords.clear();
342
275
  }
343
276
  // ============================================================
344
277
  // DataServerProtocol implementation
@@ -384,10 +317,9 @@ export class DataServer {
384
317
  }
385
318
  /** End `session`, registered here under `clientId`, with `cause`, and drop its watches on this connection. */
386
319
  endSession(clientId, session, cause = 'closed') {
387
- for (const [uri, watchers] of this.subscriptions) {
388
- if (watchers.delete(clientId) && watchers.size === 0) {
389
- this.subscriptions.delete(uri);
390
- this.lastEmittedFingerprint.delete(uri);
320
+ for (const [uri, record] of this.uriWatchRecords) {
321
+ if (record.watchers.delete(clientId)) {
322
+ this.pruneUriWatchRecord(uri);
391
323
  }
392
324
  }
393
325
  session.dispose(cause);
@@ -517,56 +449,63 @@ export class DataServer {
517
449
  * fan out to the wired `clientProxy.onDocumentUpdated`. The
518
450
  * bidirectional pattern means the event channel is the client
519
451
  * notification surface, not a returned handle — this method only
520
- * registers the URI in the dispatch table.
452
+ * registers the watcher in {@link uriWatchRecords}.
521
453
  *
522
- * Also baselines the per-URI emission fingerprint (see
523
- * {@link lastEmittedFingerprint}) to the document's current state when
524
- * the first watcher for the URI registers. This guarantees that any
525
- * phase event firing immediately after the watch with no observable
526
- * change is suppressed — watchers obtain initial state via
527
- * `getModelDocument` (or {@link openModelDocument}) and do not need a
528
- * redundant phase notification for that same state.
454
+ * Also baselines the URI's emission fingerprint (see
455
+ * {@link dispatchPhaseEvent}) to the document's current state when the
456
+ * first watcher for the URI registers. This guarantees that any phase event firing immediately after
457
+ * the watch with no observable change is suppressed — watchers obtain
458
+ * initial state via `getModelDocument` (or {@link openModelDocument}) and
459
+ * do not need a redundant phase notification for that same state.
529
460
  */
530
461
  async watchModelDocument(args) {
531
462
  const uri = this.canonicalKey(args.uri);
532
- let subscribers = this.subscriptions.get(uri);
533
- if (!subscribers) {
534
- subscribers = new Set();
535
- this.subscriptions.set(uri, subscribers);
536
- }
537
- subscribers.add(args.clientId);
538
- if (!this.lastEmittedFingerprint.has(uri)) {
463
+ const record = this.uriWatchRecords.get(uri) ?? { watchers: new Set() };
464
+ this.uriWatchRecords.set(uri, record);
465
+ record.watchers.add(args.clientId);
466
+ if (record.fingerprint === undefined) {
539
467
  const document = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
540
468
  if (document) {
541
- this.lastEmittedFingerprint.set(uri, this.computeDocumentFingerprint(document));
469
+ record.fingerprint = this.computeDocumentFingerprint(document);
542
470
  }
543
471
  }
544
472
  }
545
473
  /**
546
474
  * Remove a watch for `(uri, clientId)` previously created by
547
475
  * {@link watchModelDocument}. Idempotent — unwatching twice is a no-op.
548
- * Dispatch for `uri` stops once no watchers remain, at which point the
549
- * per-URI emission fingerprint is also cleared so the next first-watch
550
- * re-baselines against the then-current document state rather than a
551
- * stale snapshot from the previous watch.
476
+ * Dispatch for `uri` stops once no watchers remain, and the emission
477
+ * fingerprint goes with the last watcher (see {@link pruneUriWatchRecord}).
552
478
  */
553
479
  async unwatchModelDocument(args) {
554
480
  const uri = this.canonicalKey(args.uri);
555
- const subscribers = this.subscriptions.get(uri);
556
- if (!subscribers) {
481
+ if (this.uriWatchRecords.get(uri)?.watchers.delete(args.clientId)) {
482
+ this.pruneUriWatchRecord(uri);
483
+ }
484
+ }
485
+ /**
486
+ * Drop what `uri` no longer needs: the
487
+ * {@link DataServerUriWatchRecord.fingerprint} once no client watches it,
488
+ * and the whole record once it holds no
489
+ * {@link DataServerUriWatchRecord.revertPending} mark either. Every path
490
+ * that removes a watcher or consumes a mark ends here.
491
+ */
492
+ pruneUriWatchRecord(uri) {
493
+ const record = this.uriWatchRecords.get(uri);
494
+ if (!record || record.watchers.size > 0) {
557
495
  return;
558
496
  }
559
- subscribers.delete(args.clientId);
560
- if (subscribers.size === 0) {
561
- this.subscriptions.delete(uri);
562
- this.lastEmittedFingerprint.delete(uri);
497
+ if (record.revertPending) {
498
+ record.fingerprint = undefined;
499
+ }
500
+ else {
501
+ this.uriWatchRecords.delete(uri);
563
502
  }
564
503
  }
565
504
  /**
566
- * Canonicalise a URI string for use as a subscription / fingerprint
567
- * map key, via the shared `DocumentUriPolicy`. Callers may send
568
- * non-canonical URIs (drive-letter casing, percent-encoding differences,
569
- * or a symlink path) over the wire; the dispatch side keys by
505
+ * Canonicalise a URI string for use as a {@link uriWatchRecords} key, via the
506
+ * shared `DocumentUriPolicy`. Callers may send non-canonical URIs
507
+ * (drive-letter casing, percent-encoding differences, or a symlink path)
508
+ * over the wire; the dispatch side keys by
570
509
  * `document.uri.toString()` from `LangiumDocuments`, so writer keys must
571
510
  * canonicalise to the same form or events silently fail to deliver.
572
511
  * Routing through the seam (rather than a bare `UriUtils.normalize`)
@@ -914,7 +853,7 @@ export class DataServer {
914
853
  *
915
854
  * Payload-free on purpose: URIs only, so a client re-reads what it displays
916
855
  * rather than being handed transfer documents it did not ask for. That is the
917
- * property the subscription map protects, and it is preserved here by
856
+ * property the watcher gate protects, and it is preserved here by
918
857
  * carrying no document rather than by gating the message.
919
858
  *
920
859
  * Quiet in the common case. Editing a document that an editor has open leaves
@@ -923,14 +862,16 @@ export class DataServer {
923
862
  * nothing because it does not build to this phase. The ceiling is a
924
863
  * whole-workspace rebuild at the subscription phase: one message, URIs only.
925
864
  *
926
- * The URI is canonicalised for the same reason the subscription map is keyed
865
+ * The URI is canonicalised for the same reason {@link uriWatchRecords} is keyed
927
866
  * that way, and is untested for the same reason as its twin in
928
867
  * {@link dispatchDeleteEvents}: the builder reports URIs out of its own
929
868
  * store, so a non-canonical one cannot be produced without a fixture
930
869
  * asserting a shape the real system never emits.
931
870
  */
932
871
  dispatchBuiltEvent(built) {
933
- const uris = built.map(document => this.canonicalKey(document.uri.toString())).filter(uri => !this.subscriptions.has(uri));
872
+ const uris = built
873
+ .map(document => this.canonicalKey(document.uri.toString()))
874
+ .filter(uri => !this.uriWatchRecords.get(uri)?.watchers.size);
934
875
  if (uris.length === 0) {
935
876
  return;
936
877
  }
@@ -959,27 +900,39 @@ export class DataServer {
959
900
  this.disposables.push(this.services.workspace.TextDocuments.onDidChangeDirty(event => this.dispatchDirtyEvent(event)));
960
901
  }
961
902
  /**
962
- * Send a dirty flip to the connection, gated by the subscription map as
903
+ * Send a dirty flip to the connection, gated on the URI's watchers as
963
904
  * {@link dispatchSaveEvent} is: the answer at any one moment travels on
964
905
  * every document sent, so a client that watches nothing reads it there.
965
906
  */
966
907
  dispatchDirtyEvent(event) {
967
908
  const uri = this.canonicalKey(event.uri);
968
- if (this.subscriptions.has(uri)) {
909
+ if (this.uriWatchRecords.get(uri)?.watchers.size) {
969
910
  this.clientProxy.onDocumentDirtyChanged({ uri, dirty: event.dirty });
970
911
  }
971
912
  }
972
913
  /**
973
- * Mark each document the store releases after its last close (see
974
- * {@link pendingRevertBroadcasts}). A release, not the close itself: a
975
- * document whose last client lost its connection is released only once
976
- * the revert grace runs out, or when another client opens it meanwhile, and
977
- * not at all when a client lost from it opens it again within its own
978
- * grace.
914
+ * Mark each document the store releases after its last close, so
915
+ * {@link dispatchPhaseEvent} broadcasts the following rebuild even without
916
+ * a watcher. The store rebuilds such a document from its disk content,
917
+ * discarding unsaved in-session edits, and the last close typically also
918
+ * removed the last watcher: without the broadcast, a consumer that only
919
+ * ever fetches via `getModelDocument` keeps showing the discarded state.
920
+ * The broadcast is de-duplicated against the fingerprint while a watcher
921
+ * holds one, so a close whose disk state equals the last emitted state
922
+ * stays silent; with no watcher there is no fingerprint, and every revert
923
+ * is sent.
924
+ *
925
+ * A release, not the close itself: a document whose last client lost its
926
+ * connection is released only once the revert grace runs out, or when
927
+ * another client opens it meanwhile, and not at all when a client lost
928
+ * from it opens it again within its own grace.
979
929
  */
980
930
  subscribeToTextDocumentCloses() {
981
931
  this.disposables.push(this.services.workspace.TextDocuments.onDidCloseLastOpen(event => {
982
- this.pendingRevertBroadcasts.add(this.canonicalKey(event.uri));
932
+ const uri = this.canonicalKey(event.uri);
933
+ const record = this.uriWatchRecords.get(uri) ?? { watchers: new Set() };
934
+ record.revertPending = true;
935
+ this.uriWatchRecords.set(uri, record);
983
936
  }));
984
937
  }
985
938
  /**
@@ -988,7 +941,7 @@ export class DataServer {
988
941
  *
989
942
  * **Deliberately ungated, where {@link dispatchPhaseEvent} and
990
943
  * {@link dispatchSaveEvent} are gated.** Those two carry a built document and
991
- * fire on every build, so the subscription map is what keeps bandwidth
944
+ * fire on every build, so the watcher gate is what keeps bandwidth
992
945
  * proportional to what a client asked for. A deletion is neither: it is one
993
946
  * URI, it is rare, and it reports the workspace's STRUCTURE rather than a
994
947
  * document's content. Gating it forces any consumer that displays the
@@ -998,7 +951,7 @@ export class DataServer {
998
951
  * care filters on the URI, which costs it a comparison.
999
952
  *
1000
953
  * The precedent is the last-close revert broadcast (see
1001
- * {@link pendingRevertBroadcasts}), where the same judgement was already made
954
+ * {@link subscribeToTextDocumentCloses}), where the same judgement was already made
1002
955
  * in the other direction: a transition that matters enough is delivered
1003
956
  * without a subscription.
1004
957
  *
@@ -1012,34 +965,37 @@ export class DataServer {
1012
965
  * `deleteDocuments` resolves a directory URI to the documents beneath it — so
1013
966
  * no caller has to handle a directory here.
1014
967
  *
1015
- * Per-URI derived state is dropped for the same reason it is dropped
1016
- * anywhere: it describes a document that no longer exists.
1017
- * {@link lastEmittedFingerprint} in particular can outlive its subscriptions
1018
- * through the last-close revert path — left behind, it is adopted as the
1019
- * baseline by the next {@link watchModelDocument} and suppresses the first
1020
- * emit after the file returns with its previous content.
968
+ * The URI's fingerprint and revert mark are dropped: they describe a
969
+ * document that no longer exists. A kept fingerprint is adopted as the
970
+ * baseline and suppresses the first emit after the file returns with its
971
+ * previous content.
1021
972
  *
1022
- * The subscription itself SURVIVES: a recreated file resumes delivering to
1023
- * the same watchers with no re-subscription, and a client that answers the
1024
- * deletion by closing releases the watch through `closeModelDocument` anyway.
973
+ * The watchers SURVIVE: a recreated file resumes delivering to them with no
974
+ * re-subscription, and a client that answers the deletion by closing
975
+ * releases the watch through `closeModelDocument` anyway.
1025
976
  */
1026
977
  dispatchDeleteEvents(deleted) {
1027
978
  for (const removed of deleted) {
1028
979
  const uri = this.canonicalKey(removed.toString());
1029
- this.lastEmittedFingerprint.delete(uri);
1030
- this.pendingRevertBroadcasts.delete(uri);
980
+ const record = this.uriWatchRecords.get(uri);
981
+ if (record) {
982
+ record.fingerprint = undefined;
983
+ record.revertPending = undefined;
984
+ record.sentVersion = undefined;
985
+ this.pruneUriWatchRecord(uri);
986
+ }
1031
987
  this.clientProxy.onDocumentDeleted({ uri });
1032
988
  }
1033
989
  }
1034
- /** Fan out a save event for the document's URI, gated by the subscription map. */
990
+ /** Fan out a save event for the document's URI, gated on the URI's watchers. */
1035
991
  dispatchSaveEvent(event) {
1036
992
  // The save event arrives under the CLIENT URI the text store keys by (e.g. a
1037
- // symlink path S); the subscription map and `dispatchPhaseEvent` key by the
993
+ // symlink path S); `uriWatchRecords` and `dispatchPhaseEvent` key by the
1038
994
  // CANONICAL identity R. Canonicalize before both the gate and the envelope so
1039
995
  // a save of a symlinked file isn't silently dropped (and the envelope resolves
1040
996
  // the R-keyed document rather than missing into an empty one).
1041
997
  const uri = this.canonicalKey(event.document.uri);
1042
- if (!this.subscriptions.has(uri)) {
998
+ if (!this.uriWatchRecords.get(uri)?.watchers.size) {
1043
999
  return;
1044
1000
  }
1045
1001
  const response = this.envelope(UriUtils.toUri(uri));
@@ -1058,13 +1014,25 @@ export class DataServer {
1058
1014
  * out to multiple local subscribers with an `Emitter<T>` — the
1059
1015
  * framework deliberately does NOT promise multi-listener semantics.
1060
1016
  *
1061
- * The dispatch is guarded by two filters:
1062
- * 1. **Subscription map**: events for URIs no subscriber registered for
1063
- * are NOT sent over the wire (bandwidth scales with subscribed URIs,
1064
- * not phase events).
1065
- * 2. **Emission fingerprint**: events for rebuilds that produce no
1066
- * observable change since the last emit are suppressed. See
1067
- * {@link lastEmittedFingerprint} for the rationale.
1017
+ * An event for a URI no client watches is NOT sent over the wire, so
1018
+ * bandwidth scales with watched URIs rather than with phase events; a
1019
+ * pending revert mark is the exception (see
1020
+ * {@link subscribeToTextDocumentCloses}).
1021
+ *
1022
+ * An event for a rebuild with no observable change since the last emit is
1023
+ * suppressed against the URI's fingerprint. Such rebuilds are routine: a
1024
+ * second client attaching to an open document makes
1025
+ * `HydraniumTextDocuments.refreshContent` fire `onDidChangeContent` purely
1026
+ * to re-trigger the build, and Langium rebuilds a document when a
1027
+ * dependency changes even if its diagnostics come out the same. Sent at a
1028
+ * version this head has not sent, they reach watchers as `'changed'`, and a
1029
+ * watcher that reads `'changed'` as another client's write and resets its
1030
+ * root to the server view loses the user's edits. The first {@link watchModelDocument} baselines the
1031
+ * fingerprint, so the first event after a watch is de-duplicated against
1032
+ * the state the watcher just fetched; it goes with the last watcher, so the
1033
+ * next first watch re-baselines rather than adopting a stale digest. A
1034
+ * cyrb53 digest keeps its memory constant in document size; see
1035
+ * {@link computeDocumentFingerprint} for the inputs.
1068
1036
  */
1069
1037
  dispatchPhaseEvent(document, cancelToken) {
1070
1038
  if (cancelToken.isCancellationRequested) {
@@ -1076,16 +1044,23 @@ export class DataServer {
1076
1044
  return;
1077
1045
  }
1078
1046
  const uri = document.uri.toString();
1079
- // Consume the last-close mark even when subscriptions exist — the
1080
- // regular dispatch below serves those watchers, and the mark's author
1081
- // attribution is more precise than the post-close `UNKNOWN_CLIENT_ID`
1082
- // the author lookup would yield.
1083
- const revertedOnClose = this.pendingRevertBroadcasts.delete(uri);
1084
- if (!this.subscriptions.has(uri) && !revertedOnClose) {
1047
+ const record = this.uriWatchRecords.get(uri);
1048
+ const revertedOnClose = record?.revertPending === true;
1049
+ if (!record || (record.watchers.size === 0 && !revertedOnClose)) {
1085
1050
  return;
1086
1051
  }
1052
+ if (revertedOnClose) {
1053
+ // Consumed even when watchers exist — the regular dispatch below
1054
+ // serves them, and the mark's attribution is more precise than the
1055
+ // post-close unknown client the manager's attribution would yield.
1056
+ // With no watcher the record goes now, so the fingerprint written
1057
+ // below lands on a detached record and the next first watch
1058
+ // re-baselines instead of adopting it.
1059
+ record.revertPending = undefined;
1060
+ this.pruneUriWatchRecord(uri);
1061
+ }
1087
1062
  const fingerprint = this.computeDocumentFingerprint(document);
1088
- if (this.lastEmittedFingerprint.get(uri) === fingerprint) {
1063
+ if (record.fingerprint === fingerprint) {
1089
1064
  // A rebuild that produced no observable change since the last emit —
1090
1065
  // suppressed so RPC subscribers don't see a no-op broadcast. Logged at
1091
1066
  // debug so a *needed* re-broadcast wrongly suppressed by this dedup
@@ -1093,11 +1068,19 @@ export class DataServer {
1093
1068
  this.tracer.withUri(uri).debug(`Suppress onDocumentUpdated v${document.textDocument.version}: fingerprint unchanged`);
1094
1069
  return;
1095
1070
  }
1096
- this.lastEmittedFingerprint.set(uri, fingerprint);
1071
+ record.fingerprint = fingerprint;
1072
+ // The manager's attribution, so this head names the same client as the
1073
+ // in-process heads do for one build, except for a version this head
1074
+ // already sent; see `DataServerUriWatchRecord.sentVersion`.
1075
+ const version = this.services.workspace.AstDocumentManager.isOpen(uri) ? document.textDocument.version : undefined;
1076
+ const { reason, sourceClientId } = version !== undefined && record.sentVersion === version
1077
+ ? { reason: 'rebuilt', sourceClientId: UNKNOWN_CLIENT_ID }
1078
+ : this.services.workspace.AstDocumentManager.attributeUpdate(document);
1079
+ record.sentVersion = version;
1097
1080
  const event = {
1098
1081
  document: this.envelope(document.uri),
1099
- sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : this.resolveSourceClientId(document),
1100
- reason: this.resolveUpdateReason(document.uri)
1082
+ sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : sourceClientId,
1083
+ reason
1101
1084
  };
1102
1085
  this.tracer
1103
1086
  .withUri(uri)
@@ -1136,23 +1119,6 @@ export class DataServer {
1136
1119
  additionalFingerprintInputs(_document) {
1137
1120
  return [];
1138
1121
  }
1139
- /**
1140
- * Discriminate the reason for a phase-event-driven update notification.
1141
- * Uses the most recent `DocumentBuilder.onUpdate` snapshot:
1142
- * - URI in the `changed` list → `'changed'` (the URI was passed to
1143
- * `documentBuilder.update(changed, deleted)`, which spans `didChange`
1144
- * text-document events and programmatic `update([uri], [])` calls).
1145
- * - Otherwise → `'rebuilt'` (cascade re-derivation: this URI was rebuilt
1146
- * because something it depends on changed; its own text wasn't flagged).
1147
- *
1148
- * `'saved'` is NOT emitted here — saves take the dedicated
1149
- * `DataClientProtocol.onDocumentSaved` channel, and adopters wanting a
1150
- * unified stream synthesise it in their bridge layer. A deletion is not a
1151
- * reason at all; see {@link dispatchDeleteEvents}.
1152
- */
1153
- resolveUpdateReason(uri) {
1154
- return this.lastBuildUpdate?.changed.some(changed => UriUtils.equals(changed, uri)) ? 'changed' : 'rebuilt';
1155
- }
1156
1122
  /**
1157
1123
  * Subscribe to the project tier's change channel and re-fan registry
1158
1124
  * diffs into per-project wire notifications. The internal
@@ -1194,19 +1160,6 @@ export class DataServer {
1194
1160
  this.clientProxy.onProjectsChanged({ project: entry.snapshot, reason: 'removed' });
1195
1161
  }
1196
1162
  }
1197
- /**
1198
- * Resolve the wire-level `sourceClientId` for `document`'s events — the client
1199
- * that authored its current version, from the version-author history on
1200
- * `HydraniumTextDocuments`. The protocol-level counterpart of the internal
1201
- * `AstDocumentManager.getAuthor`: a framework-internal rebuild has no author,
1202
- * so this surfaces the {@link UNKNOWN_CLIENT_ID} presentation default. Adopters
1203
- * that rebuild through non-text-document channels override to derive a
1204
- * source id of their own.
1205
- */
1206
- resolveSourceClientId(document) {
1207
- const author = this.services.workspace.TextDocuments.getAuthor(document.textDocument.uri, document.textDocument.version);
1208
- return author ?? UNKNOWN_CLIENT_ID;
1209
- }
1210
1163
  // --- Diagnostics (DataServerDiagnosticsProtocol) ---------------------------
1211
1164
  // Computed in THIS process: the data-server child holds the model store, so
1212
1165
  // these snapshots reflect the heap that actually carries the workspace