@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.
@@ -38,6 +38,7 @@ import {
38
38
  type TransferDiagnostic,
39
39
  TransferDocument,
40
40
  type TransferElement,
41
+ UNKNOWN_CLIENT_ID,
41
42
  asSnapshotVersion
42
43
  } from '@hydranium/protocol';
43
44
  import {
@@ -63,7 +64,7 @@ import {
63
64
  type TransferUpdateDocumentArgs,
64
65
  type TransferUpdateDocumentsArgs
65
66
  } from '@hydranium/protocol/data';
66
- import { REVERT_ON_CLOSE_CLIENT_ID, UNKNOWN_CLIENT_ID } from '@hydranium/core';
67
+ import { REVERT_ON_CLOSE_CLIENT_ID } from '@hydranium/core';
67
68
  import { defaultDataServerDiagnostics } from './default-diagnostics.js';
68
69
 
69
70
  /**
@@ -231,12 +232,13 @@ export interface DataServerOptions extends LogNameOptions {
231
232
  * (`DataClientProtocol.onDocumentUpdated`).
232
233
  *
233
234
  * **Subscription dispatch only** — distinct from the synchronous RPC
234
- * response phase. The lifecycle reads/writes (`getModelDocument` /
235
- * `updateModelDocument` / `saveModelDocument`) settle at the
236
- * integrity-settled landmark (`IntegrityService.SettledState`); a
237
- * read additionally upgrades to `Validated` per call when
238
- * {@link GetModelDocumentArgs.includeDiagnostics} is set. This option
239
- * controls only the async notification phase.
235
+ * response phase. A read (`getModelDocument`) settles at the
236
+ * integrity-settled landmark (`IntegrityService.SettledState`), or at
237
+ * `Validated` when {@link GetModelDocumentArgs.includeDiagnostics} is set.
238
+ * A write (`updateModelDocument` / `updateModelDocuments` /
239
+ * `saveModelDocument`) answers once its document is validated, as a
240
+ * session's write does. This option controls only the async notification
241
+ * phase.
240
242
  *
241
243
  * Default: `DocumentState.Validated` — validation is the last builder
242
244
  * phase and produces the full diagnostic set, so subscription events fired
@@ -374,6 +376,33 @@ export interface ResolvedDataServerOptions {
374
376
  readonly referenceSettleTimeoutMs: number;
375
377
  }
376
378
 
379
+ /**
380
+ * What a {@link DataServer} keeps per canonical URI, in the `protected`
381
+ * {@link DataServer.uriWatchRecords}. A record lives while it has a watcher
382
+ * or a revert mark; {@link DataServer.pruneUriWatchRecord} enforces that on
383
+ * every path that removes either, so a record with no watcher has a revert
384
+ * pending. Test the size of {@link DataServerUriWatchRecord.watchers}, not the
385
+ * record's presence, for "watched".
386
+ */
387
+ export interface DataServerUriWatchRecord {
388
+ /** The client ids watching the URI; save, dirty and phase events go out only while it is non-empty, a pending revert aside. */
389
+ readonly watchers: Set<string>;
390
+ /** Digest of the last emitted state, or of the state a first watch found; see {@link DataServer.dispatchPhaseEvent} for why it de-duplicates, {@link DataServer.computeDocumentFingerprint} for its inputs. */
391
+ fingerprint?: string;
392
+ /** Set by {@link DataServer.subscribeToTextDocumentCloses}; the next phase event broadcasts even without a watcher. */
393
+ revertPending?: boolean;
394
+ /**
395
+ * The version of an open document the last event sent was built at. The
396
+ * version sent again goes out as `'rebuilt'` from no client: the manager
397
+ * counts a version delivered only once its `Validated` listeners ran, so a
398
+ * build cancelled after the subscription phase would send it as a change
399
+ * twice. Only while the store has the document open: without it, a
400
+ * document can keep one version through changes of its file, so its events
401
+ * keep the manager's attribution.
402
+ */
403
+ sentVersion?: number;
404
+ }
405
+
377
406
  /**
378
407
  * Typed-RPC protocol head for the hydranium framework. The data-server
379
408
  * is a PEER of `@hydranium/core/lsp` and `@hydranium/glsp-server` —
@@ -437,8 +466,8 @@ export class DataServer<
437
466
  };
438
467
 
439
468
  protected readonly options: ResolvedDataServerOptions;
440
- /** Subscription bookkeeping: URI → set of clientIds that subscribed for that URI. */
441
- protected readonly subscriptions = new Map<string, Set<string>>();
469
+ /** This connection's {@link DataServerUriWatchRecord} per canonical URI; see there for when a record lives. */
470
+ protected readonly uriWatchRecords = new Map<string, DataServerUriWatchRecord>();
442
471
  /**
443
472
  * The client sessions this connection registered, by client id. A document
444
473
  * request carrying one of these ids acts as that session; one carrying any
@@ -452,67 +481,6 @@ export class DataServer<
452
481
  /** Typed client proxy — sends `data-server/on*` notifications back over the same wire. */
453
482
  protected readonly clientProxy: DataClientProtocol<TTransfer, TDiagnostic, TProject>;
454
483
  protected readonly disposables = new DisposableCollection();
455
- /**
456
- * Snapshot of the most recent `DocumentBuilder.onUpdate` event. Drives
457
- * `reason` discrimination on outbound `onDocumentUpdated` notifications:
458
- * a URI in the `changed` list emits `'changed'`, otherwise `'rebuilt'`
459
- * (cascade rebuild from a dependent URI's change). The same mechanism
460
- * `AstDocumentManager.onUpdate` uses on the LSP side, so reason fidelity
461
- * stays consistent between heads. The `deleted` half is read by
462
- * {@link dispatchDeleteEvents} rather than by the reason discrimination,
463
- * a deletion travelling on its own channel.
464
- */
465
- protected lastBuildUpdate?: { changed: readonly URI[]; deleted: readonly URI[] };
466
- /**
467
- * Per-URI hash of the last emitted (text + diagnostics) state,
468
- * used by {@link dispatchPhaseEvent} to suppress duplicate
469
- * `onDocumentUpdated` notifications for rebuilds that produce no
470
- * observable change since the last emit.
471
- *
472
- * Two scenarios routinely produce such rebuilds:
473
- * 1. **Refresh-triggered rebuilds.** When a second client attaches to a
474
- * document already open in another client,
475
- * `HydraniumTextDocuments.refreshContent` fires `onDidChangeContent`
476
- * purely to re-trigger the build pipeline — the text and diagnostics
477
- * are identical to the prior emit.
478
- * 2. **Cascade rebuilds with no diagnostic delta.** Langium rebuilds a
479
- * document when a dependency changes; if the rebuild produces the
480
- * same diagnostics, there is nothing new to communicate to subscribers.
481
- *
482
- * Without this filter, both scenarios reach subscribers as wire-side
483
- * `'changed'` events. A subscriber that interprets `'changed'` as
484
- * "another client wrote new content" and resets its in-memory root to
485
- * the server view then misreads the spurious event as a concurrent
486
- * third-party write and loses the user's edits.
487
- *
488
- * Initialised on {@link watchModelDocument} to the current
489
- * document fingerprint so the FIRST phase event after a fresh
490
- * subscription is also de-duplicated against the state subscribers
491
- * obtained via `getModelDocument` — they do not need a redundant phase
492
- * notification for the state they just fetched.
493
- *
494
- * Cleared in {@link unwatchModelDocument} when the last
495
- * subscriber for a URI leaves so memory does not accumulate.
496
- *
497
- * Stored as a cyrb53 hex digest (16 chars per URI) rather than the raw
498
- * text + diagnostics serialisation so the memory cost is constant in
499
- * document size. See {@link computeDocumentFingerprint} for the inputs.
500
- */
501
- protected readonly lastEmittedFingerprint = new Map<string, string>();
502
- /**
503
- * URIs the text store just released after their last close. The store
504
- * rebuilds such a document from its disk content (discarding unsaved
505
- * in-session edits), but {@link dispatchPhaseEvent} gates on the
506
- * subscription map — and the last close typically also removed the last
507
- * watcher, so consumers that only ever fetch via `getModelDocument` would
508
- * keep showing the discarded state forever. Marked on the release (see
509
- * {@link subscribeToTextDocumentCloses}) and consumed by
510
- * {@link dispatchPhaseEvent}, which broadcasts the following rebuild's
511
- * phase event even without a subscription — de-duplicated against
512
- * {@link lastEmittedFingerprint} where an entry survives, so a close
513
- * whose disk state equals the last emitted state stays silent.
514
- */
515
- protected readonly pendingRevertBroadcasts = new Set<string>();
516
484
  protected readonly tracer: Tracer;
517
485
  /** The in-flight interactive profile capture, held between {@link startProfiling} and {@link stopProfiling}. */
518
486
  protected activeProfile?: DataServerProfileCapture;
@@ -568,12 +536,7 @@ export class DataServer<
568
536
  // framework's own rejections.
569
537
  renderErrorMessage: error => this.services.MessageRenderer.renderError(error)
570
538
  });
571
- this.disposables.push(
572
- this.services.workspace.DocumentBuilder.onUpdate((changed, deleted) => {
573
- this.lastBuildUpdate = { changed, deleted };
574
- this.dispatchDeleteEvents(deleted);
575
- })
576
- );
539
+ this.disposables.push(this.services.workspace.DocumentBuilder.onUpdate((_changed, deleted) => this.dispatchDeleteEvents(deleted)));
577
540
  this.subscribeToDocumentBuilder();
578
541
  this.subscribeToTextDocumentSaves();
579
542
  this.subscribeToDirtyChanges();
@@ -589,10 +552,9 @@ export class DataServer<
589
552
 
590
553
  /**
591
554
  * Release everything the constructor wired up — the bound protocol
592
- * handlers and every listener — and clear the subscription map and the
593
- * per-URI emission-fingerprint cache, so a long-lived shared services
594
- * bundle does not retain per-connection memory after the connection
595
- * closes. Also ends every session this connection registered, which closes
555
+ * handlers and every listener — and clear {@link uriWatchRecords}, so a
556
+ * long-lived shared services bundle does not retain per-connection memory
557
+ * after the connection closes. Also ends every session this connection registered, which closes
596
558
  * every document it has open: the SHARED store's state rather than this
597
559
  * server's, which outlives the connection unless released here.
598
560
  *
@@ -621,9 +583,7 @@ export class DataServer<
621
583
  for (const [clientId, session] of this.clientSessions) {
622
584
  this.endSession(clientId, session, cause);
623
585
  }
624
- this.subscriptions.clear();
625
- this.lastEmittedFingerprint.clear();
626
- this.pendingRevertBroadcasts.clear();
586
+ this.uriWatchRecords.clear();
627
587
  }
628
588
 
629
589
  // ============================================================
@@ -677,10 +637,9 @@ export class DataServer<
677
637
  session: ClientSession<AstNode, AstDiagnostic, TTransfer>,
678
638
  cause: SessionEndCause = 'closed'
679
639
  ): void {
680
- for (const [uri, watchers] of this.subscriptions) {
681
- if (watchers.delete(clientId) && watchers.size === 0) {
682
- this.subscriptions.delete(uri);
683
- this.lastEmittedFingerprint.delete(uri);
640
+ for (const [uri, record] of this.uriWatchRecords) {
641
+ if (record.watchers.delete(clientId)) {
642
+ this.pruneUriWatchRecord(uri);
684
643
  }
685
644
  }
686
645
  session.dispose(cause);
@@ -821,28 +780,24 @@ export class DataServer<
821
780
  * fan out to the wired `clientProxy.onDocumentUpdated`. The
822
781
  * bidirectional pattern means the event channel is the client
823
782
  * notification surface, not a returned handle — this method only
824
- * registers the URI in the dispatch table.
783
+ * registers the watcher in {@link uriWatchRecords}.
825
784
  *
826
- * Also baselines the per-URI emission fingerprint (see
827
- * {@link lastEmittedFingerprint}) to the document's current state when
828
- * the first watcher for the URI registers. This guarantees that any
829
- * phase event firing immediately after the watch with no observable
830
- * change is suppressed — watchers obtain initial state via
831
- * `getModelDocument` (or {@link openModelDocument}) and do not need a
832
- * redundant phase notification for that same state.
785
+ * Also baselines the URI's emission fingerprint (see
786
+ * {@link dispatchPhaseEvent}) to the document's current state when the
787
+ * first watcher for the URI registers. This guarantees that any phase event firing immediately after
788
+ * the watch with no observable change is suppressed — watchers obtain
789
+ * initial state via `getModelDocument` (or {@link openModelDocument}) and
790
+ * do not need a redundant phase notification for that same state.
833
791
  */
834
792
  async watchModelDocument(args: WatchModelDocumentArgs): Promise<void> {
835
793
  const uri = this.canonicalKey(args.uri);
836
- let subscribers = this.subscriptions.get(uri);
837
- if (!subscribers) {
838
- subscribers = new Set();
839
- this.subscriptions.set(uri, subscribers);
840
- }
841
- subscribers.add(args.clientId);
842
- if (!this.lastEmittedFingerprint.has(uri)) {
794
+ const record = this.uriWatchRecords.get(uri) ?? { watchers: new Set<string>() };
795
+ this.uriWatchRecords.set(uri, record);
796
+ record.watchers.add(args.clientId);
797
+ if (record.fingerprint === undefined) {
843
798
  const document = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
844
799
  if (document) {
845
- this.lastEmittedFingerprint.set(uri, this.computeDocumentFingerprint(document));
800
+ record.fingerprint = this.computeDocumentFingerprint(document);
846
801
  }
847
802
  }
848
803
  }
@@ -850,29 +805,40 @@ export class DataServer<
850
805
  /**
851
806
  * Remove a watch for `(uri, clientId)` previously created by
852
807
  * {@link watchModelDocument}. Idempotent — unwatching twice is a no-op.
853
- * Dispatch for `uri` stops once no watchers remain, at which point the
854
- * per-URI emission fingerprint is also cleared so the next first-watch
855
- * re-baselines against the then-current document state rather than a
856
- * stale snapshot from the previous watch.
808
+ * Dispatch for `uri` stops once no watchers remain, and the emission
809
+ * fingerprint goes with the last watcher (see {@link pruneUriWatchRecord}).
857
810
  */
858
811
  async unwatchModelDocument(args: WatchModelDocumentArgs): Promise<void> {
859
812
  const uri = this.canonicalKey(args.uri);
860
- const subscribers = this.subscriptions.get(uri);
861
- if (!subscribers) {
813
+ if (this.uriWatchRecords.get(uri)?.watchers.delete(args.clientId)) {
814
+ this.pruneUriWatchRecord(uri);
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Drop what `uri` no longer needs: the
820
+ * {@link DataServerUriWatchRecord.fingerprint} once no client watches it,
821
+ * and the whole record once it holds no
822
+ * {@link DataServerUriWatchRecord.revertPending} mark either. Every path
823
+ * that removes a watcher or consumes a mark ends here.
824
+ */
825
+ protected pruneUriWatchRecord(uri: string): void {
826
+ const record = this.uriWatchRecords.get(uri);
827
+ if (!record || record.watchers.size > 0) {
862
828
  return;
863
829
  }
864
- subscribers.delete(args.clientId);
865
- if (subscribers.size === 0) {
866
- this.subscriptions.delete(uri);
867
- this.lastEmittedFingerprint.delete(uri);
830
+ if (record.revertPending) {
831
+ record.fingerprint = undefined;
832
+ } else {
833
+ this.uriWatchRecords.delete(uri);
868
834
  }
869
835
  }
870
836
 
871
837
  /**
872
- * Canonicalise a URI string for use as a subscription / fingerprint
873
- * map key, via the shared `DocumentUriPolicy`. Callers may send
874
- * non-canonical URIs (drive-letter casing, percent-encoding differences,
875
- * or a symlink path) over the wire; the dispatch side keys by
838
+ * Canonicalise a URI string for use as a {@link uriWatchRecords} key, via the
839
+ * shared `DocumentUriPolicy`. Callers may send non-canonical URIs
840
+ * (drive-letter casing, percent-encoding differences, or a symlink path)
841
+ * over the wire; the dispatch side keys by
876
842
  * `document.uri.toString()` from `LangiumDocuments`, so writer keys must
877
843
  * canonicalise to the same form or events silently fail to deliver.
878
844
  * Routing through the seam (rather than a bare `UriUtils.normalize`)
@@ -1247,7 +1213,7 @@ export class DataServer<
1247
1213
  *
1248
1214
  * Payload-free on purpose: URIs only, so a client re-reads what it displays
1249
1215
  * rather than being handed transfer documents it did not ask for. That is the
1250
- * property the subscription map protects, and it is preserved here by
1216
+ * property the watcher gate protects, and it is preserved here by
1251
1217
  * carrying no document rather than by gating the message.
1252
1218
  *
1253
1219
  * Quiet in the common case. Editing a document that an editor has open leaves
@@ -1256,14 +1222,16 @@ export class DataServer<
1256
1222
  * nothing because it does not build to this phase. The ceiling is a
1257
1223
  * whole-workspace rebuild at the subscription phase: one message, URIs only.
1258
1224
  *
1259
- * The URI is canonicalised for the same reason the subscription map is keyed
1225
+ * The URI is canonicalised for the same reason {@link uriWatchRecords} is keyed
1260
1226
  * that way, and is untested for the same reason as its twin in
1261
1227
  * {@link dispatchDeleteEvents}: the builder reports URIs out of its own
1262
1228
  * store, so a non-canonical one cannot be produced without a fixture
1263
1229
  * asserting a shape the real system never emits.
1264
1230
  */
1265
1231
  protected dispatchBuiltEvent(built: readonly LangiumDocument[]): void {
1266
- const uris = built.map(document => this.canonicalKey(document.uri.toString())).filter(uri => !this.subscriptions.has(uri));
1232
+ const uris = built
1233
+ .map(document => this.canonicalKey(document.uri.toString()))
1234
+ .filter(uri => !this.uriWatchRecords.get(uri)?.watchers.size);
1267
1235
  if (uris.length === 0) {
1268
1236
  return;
1269
1237
  }
@@ -1295,29 +1263,41 @@ export class DataServer<
1295
1263
  }
1296
1264
 
1297
1265
  /**
1298
- * Send a dirty flip to the connection, gated by the subscription map as
1266
+ * Send a dirty flip to the connection, gated on the URI's watchers as
1299
1267
  * {@link dispatchSaveEvent} is: the answer at any one moment travels on
1300
1268
  * every document sent, so a client that watches nothing reads it there.
1301
1269
  */
1302
1270
  protected dispatchDirtyEvent(event: DocumentDirtyChangedEvent): void {
1303
1271
  const uri = this.canonicalKey(event.uri);
1304
- if (this.subscriptions.has(uri)) {
1272
+ if (this.uriWatchRecords.get(uri)?.watchers.size) {
1305
1273
  this.clientProxy.onDocumentDirtyChanged({ uri, dirty: event.dirty });
1306
1274
  }
1307
1275
  }
1308
1276
 
1309
1277
  /**
1310
- * Mark each document the store releases after its last close (see
1311
- * {@link pendingRevertBroadcasts}). A release, not the close itself: a
1312
- * document whose last client lost its connection is released only once
1313
- * the revert grace runs out, or when another client opens it meanwhile, and
1314
- * not at all when a client lost from it opens it again within its own
1315
- * grace.
1278
+ * Mark each document the store releases after its last close, so
1279
+ * {@link dispatchPhaseEvent} broadcasts the following rebuild even without
1280
+ * a watcher. The store rebuilds such a document from its disk content,
1281
+ * discarding unsaved in-session edits, and the last close typically also
1282
+ * removed the last watcher: without the broadcast, a consumer that only
1283
+ * ever fetches via `getModelDocument` keeps showing the discarded state.
1284
+ * The broadcast is de-duplicated against the fingerprint while a watcher
1285
+ * holds one, so a close whose disk state equals the last emitted state
1286
+ * stays silent; with no watcher there is no fingerprint, and every revert
1287
+ * is sent.
1288
+ *
1289
+ * A release, not the close itself: a document whose last client lost its
1290
+ * connection is released only once the revert grace runs out, or when
1291
+ * another client opens it meanwhile, and not at all when a client lost
1292
+ * from it opens it again within its own grace.
1316
1293
  */
1317
1294
  protected subscribeToTextDocumentCloses(): void {
1318
1295
  this.disposables.push(
1319
1296
  this.services.workspace.TextDocuments.onDidCloseLastOpen(event => {
1320
- this.pendingRevertBroadcasts.add(this.canonicalKey(event.uri));
1297
+ const uri = this.canonicalKey(event.uri);
1298
+ const record = this.uriWatchRecords.get(uri) ?? { watchers: new Set<string>() };
1299
+ record.revertPending = true;
1300
+ this.uriWatchRecords.set(uri, record);
1321
1301
  })
1322
1302
  );
1323
1303
  }
@@ -1328,7 +1308,7 @@ export class DataServer<
1328
1308
  *
1329
1309
  * **Deliberately ungated, where {@link dispatchPhaseEvent} and
1330
1310
  * {@link dispatchSaveEvent} are gated.** Those two carry a built document and
1331
- * fire on every build, so the subscription map is what keeps bandwidth
1311
+ * fire on every build, so the watcher gate is what keeps bandwidth
1332
1312
  * proportional to what a client asked for. A deletion is neither: it is one
1333
1313
  * URI, it is rare, and it reports the workspace's STRUCTURE rather than a
1334
1314
  * document's content. Gating it forces any consumer that displays the
@@ -1338,7 +1318,7 @@ export class DataServer<
1338
1318
  * care filters on the URI, which costs it a comparison.
1339
1319
  *
1340
1320
  * The precedent is the last-close revert broadcast (see
1341
- * {@link pendingRevertBroadcasts}), where the same judgement was already made
1321
+ * {@link subscribeToTextDocumentCloses}), where the same judgement was already made
1342
1322
  * in the other direction: a transition that matters enough is delivered
1343
1323
  * without a subscription.
1344
1324
  *
@@ -1352,35 +1332,38 @@ export class DataServer<
1352
1332
  * `deleteDocuments` resolves a directory URI to the documents beneath it — so
1353
1333
  * no caller has to handle a directory here.
1354
1334
  *
1355
- * Per-URI derived state is dropped for the same reason it is dropped
1356
- * anywhere: it describes a document that no longer exists.
1357
- * {@link lastEmittedFingerprint} in particular can outlive its subscriptions
1358
- * through the last-close revert path — left behind, it is adopted as the
1359
- * baseline by the next {@link watchModelDocument} and suppresses the first
1360
- * emit after the file returns with its previous content.
1335
+ * The URI's fingerprint and revert mark are dropped: they describe a
1336
+ * document that no longer exists. A kept fingerprint is adopted as the
1337
+ * baseline and suppresses the first emit after the file returns with its
1338
+ * previous content.
1361
1339
  *
1362
- * The subscription itself SURVIVES: a recreated file resumes delivering to
1363
- * the same watchers with no re-subscription, and a client that answers the
1364
- * deletion by closing releases the watch through `closeModelDocument` anyway.
1340
+ * The watchers SURVIVE: a recreated file resumes delivering to them with no
1341
+ * re-subscription, and a client that answers the deletion by closing
1342
+ * releases the watch through `closeModelDocument` anyway.
1365
1343
  */
1366
1344
  protected dispatchDeleteEvents(deleted: readonly URI[]): void {
1367
1345
  for (const removed of deleted) {
1368
1346
  const uri = this.canonicalKey(removed.toString());
1369
- this.lastEmittedFingerprint.delete(uri);
1370
- this.pendingRevertBroadcasts.delete(uri);
1347
+ const record = this.uriWatchRecords.get(uri);
1348
+ if (record) {
1349
+ record.fingerprint = undefined;
1350
+ record.revertPending = undefined;
1351
+ record.sentVersion = undefined;
1352
+ this.pruneUriWatchRecord(uri);
1353
+ }
1371
1354
  this.clientProxy.onDocumentDeleted({ uri });
1372
1355
  }
1373
1356
  }
1374
1357
 
1375
- /** Fan out a save event for the document's URI, gated by the subscription map. */
1358
+ /** Fan out a save event for the document's URI, gated on the URI's watchers. */
1376
1359
  protected dispatchSaveEvent(event: ClientTextDocumentChangeEvent<TextDocument>): void {
1377
1360
  // The save event arrives under the CLIENT URI the text store keys by (e.g. a
1378
- // symlink path S); the subscription map and `dispatchPhaseEvent` key by the
1361
+ // symlink path S); `uriWatchRecords` and `dispatchPhaseEvent` key by the
1379
1362
  // CANONICAL identity R. Canonicalize before both the gate and the envelope so
1380
1363
  // a save of a symlinked file isn't silently dropped (and the envelope resolves
1381
1364
  // the R-keyed document rather than missing into an empty one).
1382
1365
  const uri = this.canonicalKey(event.document.uri);
1383
- if (!this.subscriptions.has(uri)) {
1366
+ if (!this.uriWatchRecords.get(uri)?.watchers.size) {
1384
1367
  return;
1385
1368
  }
1386
1369
  const response = this.envelope(UriUtils.toUri(uri));
@@ -1400,13 +1383,25 @@ export class DataServer<
1400
1383
  * out to multiple local subscribers with an `Emitter<T>` — the
1401
1384
  * framework deliberately does NOT promise multi-listener semantics.
1402
1385
  *
1403
- * The dispatch is guarded by two filters:
1404
- * 1. **Subscription map**: events for URIs no subscriber registered for
1405
- * are NOT sent over the wire (bandwidth scales with subscribed URIs,
1406
- * not phase events).
1407
- * 2. **Emission fingerprint**: events for rebuilds that produce no
1408
- * observable change since the last emit are suppressed. See
1409
- * {@link lastEmittedFingerprint} for the rationale.
1386
+ * An event for a URI no client watches is NOT sent over the wire, so
1387
+ * bandwidth scales with watched URIs rather than with phase events; a
1388
+ * pending revert mark is the exception (see
1389
+ * {@link subscribeToTextDocumentCloses}).
1390
+ *
1391
+ * An event for a rebuild with no observable change since the last emit is
1392
+ * suppressed against the URI's fingerprint. Such rebuilds are routine: a
1393
+ * second client attaching to an open document makes
1394
+ * `HydraniumTextDocuments.refreshContent` fire `onDidChangeContent` purely
1395
+ * to re-trigger the build, and Langium rebuilds a document when a
1396
+ * dependency changes even if its diagnostics come out the same. Sent at a
1397
+ * version this head has not sent, they reach watchers as `'changed'`, and a
1398
+ * watcher that reads `'changed'` as another client's write and resets its
1399
+ * root to the server view loses the user's edits. The first {@link watchModelDocument} baselines the
1400
+ * fingerprint, so the first event after a watch is de-duplicated against
1401
+ * the state the watcher just fetched; it goes with the last watcher, so the
1402
+ * next first watch re-baselines rather than adopting a stale digest. A
1403
+ * cyrb53 digest keeps its memory constant in document size; see
1404
+ * {@link computeDocumentFingerprint} for the inputs.
1410
1405
  */
1411
1406
  protected dispatchPhaseEvent(document: LangiumDocument, cancelToken: CancellationToken): void {
1412
1407
  if (cancelToken.isCancellationRequested) {
@@ -1418,16 +1413,23 @@ export class DataServer<
1418
1413
  return;
1419
1414
  }
1420
1415
  const uri = document.uri.toString();
1421
- // Consume the last-close mark even when subscriptions exist — the
1422
- // regular dispatch below serves those watchers, and the mark's author
1423
- // attribution is more precise than the post-close `UNKNOWN_CLIENT_ID`
1424
- // the author lookup would yield.
1425
- const revertedOnClose = this.pendingRevertBroadcasts.delete(uri);
1426
- if (!this.subscriptions.has(uri) && !revertedOnClose) {
1416
+ const record = this.uriWatchRecords.get(uri);
1417
+ const revertedOnClose = record?.revertPending === true;
1418
+ if (!record || (record.watchers.size === 0 && !revertedOnClose)) {
1427
1419
  return;
1428
1420
  }
1421
+ if (revertedOnClose) {
1422
+ // Consumed even when watchers exist — the regular dispatch below
1423
+ // serves them, and the mark's attribution is more precise than the
1424
+ // post-close unknown client the manager's attribution would yield.
1425
+ // With no watcher the record goes now, so the fingerprint written
1426
+ // below lands on a detached record and the next first watch
1427
+ // re-baselines instead of adopting it.
1428
+ record.revertPending = undefined;
1429
+ this.pruneUriWatchRecord(uri);
1430
+ }
1429
1431
  const fingerprint = this.computeDocumentFingerprint(document);
1430
- if (this.lastEmittedFingerprint.get(uri) === fingerprint) {
1432
+ if (record.fingerprint === fingerprint) {
1431
1433
  // A rebuild that produced no observable change since the last emit —
1432
1434
  // suppressed so RPC subscribers don't see a no-op broadcast. Logged at
1433
1435
  // debug so a *needed* re-broadcast wrongly suppressed by this dedup
@@ -1435,11 +1437,20 @@ export class DataServer<
1435
1437
  this.tracer.withUri(uri).debug(`Suppress onDocumentUpdated v${document.textDocument.version}: fingerprint unchanged`);
1436
1438
  return;
1437
1439
  }
1438
- this.lastEmittedFingerprint.set(uri, fingerprint);
1440
+ record.fingerprint = fingerprint;
1441
+ // The manager's attribution, so this head names the same client as the
1442
+ // in-process heads do for one build, except for a version this head
1443
+ // already sent; see `DataServerUriWatchRecord.sentVersion`.
1444
+ const version = this.services.workspace.AstDocumentManager.isOpen(uri) ? document.textDocument.version : undefined;
1445
+ const { reason, sourceClientId } =
1446
+ version !== undefined && record.sentVersion === version
1447
+ ? { reason: 'rebuilt' as const, sourceClientId: UNKNOWN_CLIENT_ID }
1448
+ : this.services.workspace.AstDocumentManager.attributeUpdate(document);
1449
+ record.sentVersion = version;
1439
1450
  const event: TransferDocumentUpdatedEvent<TTransfer, TDiagnostic> = {
1440
1451
  document: this.envelope(document.uri),
1441
- sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : this.resolveSourceClientId(document),
1442
- reason: this.resolveUpdateReason(document.uri)
1452
+ sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : sourceClientId,
1453
+ reason
1443
1454
  };
1444
1455
  this.tracer
1445
1456
  .withUri(uri)
@@ -1487,24 +1498,6 @@ export class DataServer<
1487
1498
  return [];
1488
1499
  }
1489
1500
 
1490
- /**
1491
- * Discriminate the reason for a phase-event-driven update notification.
1492
- * Uses the most recent `DocumentBuilder.onUpdate` snapshot:
1493
- * - URI in the `changed` list → `'changed'` (the URI was passed to
1494
- * `documentBuilder.update(changed, deleted)`, which spans `didChange`
1495
- * text-document events and programmatic `update([uri], [])` calls).
1496
- * - Otherwise → `'rebuilt'` (cascade re-derivation: this URI was rebuilt
1497
- * because something it depends on changed; its own text wasn't flagged).
1498
- *
1499
- * `'saved'` is NOT emitted here — saves take the dedicated
1500
- * `DataClientProtocol.onDocumentSaved` channel, and adopters wanting a
1501
- * unified stream synthesise it in their bridge layer. A deletion is not a
1502
- * reason at all; see {@link dispatchDeleteEvents}.
1503
- */
1504
- protected resolveUpdateReason(uri: URI): TransferDocumentUpdatedEvent<TTransfer, TDiagnostic>['reason'] {
1505
- return this.lastBuildUpdate?.changed.some(changed => UriUtils.equals(changed, uri)) ? 'changed' : 'rebuilt';
1506
- }
1507
-
1508
1501
  /**
1509
1502
  * Subscribe to the project tier's change channel and re-fan registry
1510
1503
  * diffs into per-project wire notifications. The internal
@@ -1548,20 +1541,6 @@ export class DataServer<
1548
1541
  }
1549
1542
  }
1550
1543
 
1551
- /**
1552
- * Resolve the wire-level `sourceClientId` for `document`'s events — the client
1553
- * that authored its current version, from the version-author history on
1554
- * `HydraniumTextDocuments`. The protocol-level counterpart of the internal
1555
- * `AstDocumentManager.getAuthor`: a framework-internal rebuild has no author,
1556
- * so this surfaces the {@link UNKNOWN_CLIENT_ID} presentation default. Adopters
1557
- * that rebuild through non-text-document channels override to derive a
1558
- * source id of their own.
1559
- */
1560
- protected resolveSourceClientId(document: LangiumDocument): string {
1561
- const author = this.services.workspace.TextDocuments.getAuthor(document.textDocument.uri, document.textDocument.version);
1562
- return author ?? UNKNOWN_CLIENT_ID;
1563
- }
1564
-
1565
1544
  // --- Diagnostics (DataServerDiagnosticsProtocol) ---------------------------
1566
1545
  // Computed in THIS process: the data-server child holds the model store, so
1567
1546
  // these snapshots reflect the heap that actually carries the workspace