@hydranium/core 1.0.0-next.185 → 1.0.0-next.188

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/lib/documents/ast-document-manager.d.ts +26 -44
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +20 -50
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/hydranium-text-documents.d.ts +87 -30
  6. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  7. package/lib/documents/hydranium-text-documents.js +201 -37
  8. package/lib/documents/hydranium-text-documents.js.map +1 -1
  9. package/lib/documents/index.d.ts +2 -0
  10. package/lib/documents/index.d.ts.map +1 -1
  11. package/lib/documents/index.js +2 -0
  12. package/lib/documents/index.js.map +1 -1
  13. package/lib/documents/model-ledger.d.ts +47 -0
  14. package/lib/documents/model-ledger.d.ts.map +1 -0
  15. package/lib/documents/model-ledger.js +30 -0
  16. package/lib/documents/model-ledger.js.map +1 -0
  17. package/lib/documents/version-sync-service.d.ts +134 -0
  18. package/lib/documents/version-sync-service.d.ts.map +1 -0
  19. package/lib/documents/version-sync-service.js +171 -0
  20. package/lib/documents/version-sync-service.js.map +1 -0
  21. package/lib/langium/bootstrap.d.ts.map +1 -1
  22. package/lib/langium/bootstrap.js +16 -0
  23. package/lib/langium/bootstrap.js.map +1 -1
  24. package/lib/langium/document-builder/document-builder.d.ts +31 -25
  25. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  26. package/lib/langium/document-builder/document-builder.js +43 -39
  27. package/lib/langium/document-builder/document-builder.js.map +1 -1
  28. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  29. package/lib/langium/integrity/integrity-service.js +6 -15
  30. package/lib/langium/integrity/integrity-service.js.map +1 -1
  31. package/lib/langium/model-service/client-session.d.ts +14 -10
  32. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  33. package/lib/langium/model-service/client-session.js +18 -15
  34. package/lib/langium/model-service/client-session.js.map +1 -1
  35. package/lib/langium/model-service/model-service.d.ts +91 -41
  36. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  37. package/lib/langium/model-service/model-service.js +127 -50
  38. package/lib/langium/model-service/model-service.js.map +1 -1
  39. package/lib/langium/module.d.ts +6 -0
  40. package/lib/langium/module.d.ts.map +1 -1
  41. package/lib/langium/module.js +5 -1
  42. package/lib/langium/module.js.map +1 -1
  43. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  44. package/lib/langium/residency/cst-residency-service.js +10 -1
  45. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  46. package/lib/langium/shared-services.d.ts +6 -2
  47. package/lib/langium/shared-services.d.ts.map +1 -1
  48. package/lib/langium/shared-services.js.map +1 -1
  49. package/lib/langium/transfer/transfer-encoder.d.ts +19 -10
  50. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  51. package/lib/langium/transfer/transfer-encoder.js +8 -7
  52. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  53. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +31 -1
  54. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  55. package/lib/langium/workspace/hydranium-langium-document-factory.js +54 -0
  56. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  57. package/lib/langium/workspace/langium-documents.d.ts +7 -0
  58. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  59. package/lib/langium/workspace/langium-documents.js +10 -0
  60. package/lib/langium/workspace/langium-documents.js.map +1 -1
  61. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  62. package/lib/lsp/hydranium-document-update-handler.js +1 -0
  63. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  64. package/lib/testing/make-test-services.d.ts +7 -0
  65. package/lib/testing/make-test-services.d.ts.map +1 -1
  66. package/lib/testing/make-test-services.js +10 -2
  67. package/lib/testing/make-test-services.js.map +1 -1
  68. package/lib/testing/stub-ast-document-manager.d.ts +3 -2
  69. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  70. package/lib/testing/stub-ast-document-manager.js +7 -1
  71. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  72. package/lib/testing/stub-hydranium-text-documents.d.ts +2 -2
  73. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  74. package/lib/testing/stub-hydranium-text-documents.js +6 -1
  75. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  76. package/package.json +5 -5
  77. package/src/documents/ast-document-manager.ts +45 -77
  78. package/src/documents/hydranium-text-documents.ts +225 -46
  79. package/src/documents/index.ts +2 -0
  80. package/src/documents/model-ledger.ts +66 -0
  81. package/src/documents/version-sync-service.ts +267 -0
  82. package/src/langium/bootstrap.ts +16 -0
  83. package/src/langium/document-builder/document-builder.ts +76 -37
  84. package/src/langium/integrity/integrity-service.ts +6 -15
  85. package/src/langium/model-service/client-session.ts +29 -21
  86. package/src/langium/model-service/model-service.ts +182 -64
  87. package/src/langium/module.ts +9 -1
  88. package/src/langium/residency/cst-residency-service.ts +10 -1
  89. package/src/langium/shared-services.ts +6 -2
  90. package/src/langium/transfer/transfer-encoder.ts +25 -18
  91. package/src/langium/workspace/hydranium-langium-document-factory.ts +83 -0
  92. package/src/langium/workspace/langium-documents.ts +11 -0
  93. package/src/lsp/hydranium-document-update-handler.ts +3 -0
  94. package/src/testing/make-test-services.ts +15 -2
  95. package/src/testing/stub-ast-document-manager.ts +10 -2
  96. package/src/testing/stub-hydranium-text-documents.ts +8 -3
@@ -52,8 +52,12 @@ import {
52
52
  asLanguageClientUri,
53
53
  DisposableCollection,
54
54
  type Stopwatch,
55
+ type TextState,
56
+ type TextVersion,
55
57
  textHash,
56
- type Tracer
58
+ type Tracer,
59
+ STALE_VERSION,
60
+ UNRECORDED_VERSION
57
61
  } from '@hydranium/protocol';
58
62
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
59
63
  import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
@@ -125,8 +129,13 @@ export interface LastOpenClosedEvent {
125
129
  /** Delivered by {@link HydraniumTextDocuments.onDidChangeDirty}. */
126
130
  export interface DocumentDirtyChangedEvent {
127
131
  readonly uri: CanonicalUri;
128
- /** The new answer of {@link HydraniumTextDocuments.isDirty}. */
129
- readonly dirty: boolean;
132
+ /**
133
+ * The text the new answer of {@link HydraniumTextDocuments.isDirty} was
134
+ * decided on. The answer changes with the text, before any build, so it can
135
+ * name text whose model has not been sent yet. Absent when the document no
136
+ * longer exists, or when the build that follows its release failed.
137
+ */
138
+ readonly text?: TextState;
130
139
  }
131
140
 
132
141
  /**
@@ -242,16 +251,17 @@ export type RepairCommit<T extends TextDocument> =
242
251
  | { readonly status: 'not-open' };
243
252
 
244
253
  /**
245
- * Where a URI's shared version sequence left off — written once at last-client
246
- * close, consulted at the next open so the sequence CONTINUES instead of
247
- * restarting at whatever version id the reopening client declares. One entry
254
+ * Where a URI's shared version sequence left off while no client holds it —
255
+ * started by its first build, stepped by builds of changed text, written at
256
+ * last-client close, and consulted at the next open so the sequence CONTINUES
257
+ * instead of restarting at whatever version id the opening client declares. One entry
248
258
  * of {@link HydraniumTextDocuments.__versionSequences} — a `protected` field, so
249
259
  * a subclass reading the map has to name what it holds.
250
260
  */
251
261
  export interface VersionSequence {
252
- /** The shared version at last-client close. */
262
+ /** The shared version of the text last closed or built. */
253
263
  readonly version: number;
254
- /** {@link textHash} of the synced text at last-client close. */
264
+ /** {@link textHash} of that text. */
255
265
  readonly contentHash: string;
256
266
  }
257
267
 
@@ -339,10 +349,12 @@ function isFileNotFound(err: unknown, target: URI): boolean {
339
349
  * - A SERVER-OWNED shared version sequence: per-URI, monotonic across
340
350
  * close/reopen cycles, advancing exactly when the synced content changes.
341
351
  * Client-declared version ids (Monaco's buffer numbering) feed only a
342
- * per-client staleness guard and never leak into the shared sequence —
352
+ * per-client staleness guard and never leak into a running sequence —
343
353
  * the two are different things (an editor's edit-operation counter vs the
344
354
  * document's content-revision number), and splicing them lets versions drift
345
- * silently past based-on gate holders.
355
+ * silently past base-version gate holders. A URI with no sequence, and no
356
+ * root that records a version, starts at its opener's declared id: no version
357
+ * was handed out for it.
346
358
  * - Version-author history so each edit is attributable to its originating client.
347
359
  * - The revert to disk once no client has a document open, for every head
348
360
  * ({@link revertToDisk}), deferred by
@@ -374,21 +386,36 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
374
386
 
375
387
  /**
376
388
  * Per-URI shared-version continuity across close/reopen cycles
377
- * ({@link VersionSequence}), written at last-client close and consulted by
378
- * the next first-client open. DELIBERATELY outside {@link DocumentTrackingRecord}:
379
- * that record is deleted on last close, while the version sequence must
389
+ * ({@link VersionSequence}), kept for every document a build or a client
390
+ * gave the store, and consulted by the next first-client open. A URI
391
+ * with none, and no root that records a version, starts at its opener's
392
+ * declared version.
393
+ * DELIBERATELY outside {@link DocumentTrackingRecord}: that record is deleted on last close, while the version sequence must
380
394
  * survive it — the shared version is a server-owned, monotonic,
381
395
  * advances-iff-content-changes counter that never resets while the server
382
- * lives. That invariant is what makes an optimistic based-on gate
396
+ * lives. That invariant is what makes an optimistic base-version gate
383
397
  * sound: "version unchanged ⇔ content unchanged", with no false conflicts
384
398
  * from close/reopen version resets and no false passes from a reopened
385
399
  * sequence coincidentally landing on a stale writer's number.
386
400
  *
387
- * Never pruned: two small values per URI ever touched — bounded by
388
- * workspace size, not by activity.
401
+ * Never pruned, not even when the file is deleted: a recreated file
402
+ * restarting at `0` would let a write based on the deleted text pass. Two
403
+ * small values per URI ever built, however often it changes.
389
404
  */
390
405
  protected readonly __versionSequences = new Map<CanonicalUri, VersionSequence>();
391
406
 
407
+ /**
408
+ * Released documents last announced dirty. Their clean flip waits for the
409
+ * revert, so it carries the reverted text's version; a first open before the
410
+ * revert takes the entry over, and its own dirty answer decides the flip.
411
+ * Each release enters a token of its own, so the revert of an earlier
412
+ * release cannot announce a later one clean before that one's revert.
413
+ */
414
+ protected readonly __releasedDirty = new Map<CanonicalUri, object>();
415
+
416
+ /** Per held document, the {@link textHash} of its text at the version it was taken. */
417
+ protected readonly __textHashes = new WeakMap<TextDocument, { readonly version: number; readonly hash: string }>();
418
+
392
419
  /**
393
420
  * Texts pushed to the LSP textual language client via
394
421
  * {@link applyEditToLanguageClient} whose echoes have not come back yet
@@ -634,7 +661,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
634
661
  }
635
662
 
636
663
  // The SHARED version advances iff the content actually changes — the
637
- // invariant optimistic based-on gates rely on. The new text is
664
+ // invariant optimistic base-version gates rely on. The new text is
638
665
  // only known after applying the (possibly incremental) changes, so
639
666
  // apply at a tentative +1 and roll the version back on an identical
640
667
  // result (an empty-changes update only re-stamps the version).
@@ -689,12 +716,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
689
716
  * A content-identical write still fires the change event (rebuild): the
690
717
  * authored write's server-side rebuild is correctness-bearing, it just
691
718
  * mints no new version — nothing observable changed, so watchers'
692
- * based-on versions stay valid.
719
+ * base versions stay valid.
693
720
  *
694
721
  * Returns the resulting shared version. Throws when the document is not
695
722
  * open — callers (`AstDocumentManager.update`) open first.
696
723
  */
697
- applyContentChange(uri: DocumentUri, text: string, clientId: string): number {
724
+ applyContentChange(uri: DocumentUri, text: string, clientId: string): TextVersion {
698
725
  const key = this.documentKey(uri);
699
726
  let document = this.__syncedDocuments.get(key);
700
727
  if (document === undefined) {
@@ -833,16 +860,15 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
833
860
  // here at release, not on every change.
834
861
  this.__versionSequences.set(uri, {
835
862
  version: syncedDocument.version,
836
- contentHash: textHash(syncedDocument.getText())
863
+ contentHash: this.heldTextHash(syncedDocument)
837
864
  });
838
- const wasDirty = this.__documents.get(uri)?.dirty === true;
865
+ if (this.__documents.get(uri)?.dirty === true) {
866
+ this.__releasedDirty.set(uri, {});
867
+ }
839
868
  this.__syncedDocuments.delete(uri);
840
869
  // One delete clears every per-URI axis (version history + any staged
841
870
  // pending content) so a future open with the same URI starts fresh.
842
871
  this.__documents.delete(uri);
843
- if (wasDirty) {
844
- this.dirtyChangedEmitter.fire(Object.freeze({ uri, dirty: false }));
845
- }
846
872
  this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
847
873
  }
848
874
 
@@ -867,21 +893,71 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
867
893
  * document meanwhile would have its text rebuilt over, or the document
868
894
  * removed. A document some client has open again, or that waits out a new
869
895
  * grace, is left to that client.
896
+ *
897
+ * A document released dirty is announced clean, so a watcher is never left
898
+ * holding it dirty: once the revert has parsed the file, even if it is then
899
+ * cancelled, or else once the build it requests in its place has parsed it,
900
+ * as the announcement names the store's text. One the revert or that build
901
+ * removed, or that build failed, is announced without text. A reopen before
902
+ * that takes the announcement over.
870
903
  */
871
904
  protected async revertToDisk(uri: CanonicalUri): Promise<void> {
872
905
  const workspace = this.services.workspace;
873
906
  const target = UriUtils.toUri(uri);
874
907
  const reopened = (): boolean => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
908
+ const releasedDirty = this.__releasedDirty.get(uri);
909
+ const owesFlip = (): boolean => releasedDirty !== undefined && this.__releasedDirty.get(uri) === releasedDirty;
910
+ const announceClean = (withText = true): void => {
911
+ if (owesFlip()) {
912
+ this.__releasedDirty.delete(uri);
913
+ // The sequence of a removed document still names the discarded text.
914
+ const text = !withText || workspace.LangiumDocuments.getDocument(target) === undefined ? undefined : this.textState(uri);
915
+ this.dirtyChangedEmitter.fire(Object.freeze(text ? { uri, text } : { uri }));
916
+ }
917
+ };
918
+ let parsedFromFile = false;
919
+ let parses: Disposable | undefined;
920
+ let onDisk: boolean | undefined;
921
+ let cancelled = false;
922
+ let stopWaiting: (() => void) | undefined;
923
+ // Without it, a revert stopped short leaves the root on the released text.
924
+ const buildInstead = (): void => {
925
+ void workspace.VersionSyncService.requestRecoveryBuild(target, {
926
+ deleted: onDisk === false,
927
+ reason: HYDRANIUM_BUILD_REASONS.didClose,
928
+ // The update handler dropped the change it held back at the release.
929
+ ignoreDeferred: true,
930
+ stillNeeded: () => !reopened()
931
+ }).then(built => {
932
+ if (!built) {
933
+ this.tracer.with(uri).error('Build after a revert that stopped short failed; the store keeps the released text');
934
+ // No parse or removal is coming.
935
+ stopWaiting?.();
936
+ announceClean(false);
937
+ }
938
+ });
939
+ };
875
940
  try {
876
941
  // Read before the lock: every build and read waits while the lock is
877
942
  // held, and this read waits on the file's save I/O.
878
- const onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
943
+ onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
879
944
  await workspace.WorkspaceManager?.ready;
945
+ // Queuing the write cancels the running build, even when it then reverts nothing.
946
+ if (reopened()) {
947
+ return;
948
+ }
880
949
  await workspace.WorkspaceLock.write(async token => {
881
950
  if (reopened()) {
882
951
  return;
883
952
  }
953
+ // Observed rather than awaited: a build cancelled after its parse
954
+ // returns early, and the build it yields to does not parse again.
955
+ parses = workspace.VersionSyncService.onDidRecordModel(document => {
956
+ parsedFromFile ||= this.documentKey(document.uri.toString()) === uri;
957
+ });
884
958
  workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
959
+ // Cleared once the build ends: a cancelled one throws, and the lock resolves.
960
+ cancelled = true;
885
961
  try {
886
962
  await (onDisk
887
963
  ? workspace.DocumentBuilder.update([target], [], token)
@@ -895,7 +971,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
895
971
  workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
896
972
  await workspace.DocumentBuilder.update([], [target], token);
897
973
  }
974
+ cancelled = false;
898
975
  });
976
+ // Cancelled before its parse: the write that cancelled it may build nothing.
977
+ if (cancelled && !parsedFromFile && !reopened() && workspace.LangiumDocuments.getDocument(target) !== undefined) {
978
+ buildInstead();
979
+ }
899
980
  } catch (err: unknown) {
900
981
  // A revert that finishes after the LSP peer went away fails its
901
982
  // diagnostics publish, and one that runs after its workspace was torn
@@ -906,6 +987,38 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
906
987
  }
907
988
  const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
908
989
  this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
990
+ // The update handler drops a change still debounced at release, so
991
+ // without this build the root stays behind the store's version. A
992
+ // document known to have no file is removed, as the revert would have.
993
+ if (!reopened()) {
994
+ buildInstead();
995
+ }
996
+ } finally {
997
+ parses?.dispose();
998
+ if (parsedFromFile || workspace.LangiumDocuments.getDocument(target) === undefined) {
999
+ announceClean();
1000
+ } else if (owesFlip()) {
1001
+ // Cancelled before its parse or failed, the revert left the released
1002
+ // text in the store: announced now, the clean flip would name it.
1003
+ stopWaiting = (): void => {
1004
+ parsed.dispose();
1005
+ deleted.dispose();
1006
+ };
1007
+ const done = (): void => {
1008
+ stopWaiting?.();
1009
+ announceClean();
1010
+ };
1011
+ const parsed = workspace.VersionSyncService.onDidRecordModel(document => {
1012
+ if (this.documentKey(document.uri.toString()) === uri) {
1013
+ done();
1014
+ }
1015
+ });
1016
+ const deleted = workspace.DocumentBuilder.onUpdate((_changed, deletedUris) => {
1017
+ if (deletedUris.some(deletedUri => this.documentKey(deletedUri.toString()) === uri)) {
1018
+ done();
1019
+ }
1020
+ });
1021
+ }
909
1022
  }
910
1023
  }
911
1024
 
@@ -1041,13 +1154,16 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1041
1154
  const source = pendingText ? ', source=pending' : '';
1042
1155
  // The SHARED version is server-assigned: continue the persisted
1043
1156
  // sequence — same version when the content is unchanged since the
1044
- // last close (so watchers' based-on versions stay valid), one
1157
+ // last close (so watchers' base versions stay valid), one
1045
1158
  // step when it changed (so no stale pointer can coincidentally pass
1046
- // the optimistic gate). Only a first-ever open adopts the client's
1047
- // declared id as the sequence seed.
1159
+ // the optimistic gate). An open with no sequence yet seeds it from the built root.
1048
1160
  const sequence = this.__versionSequences.get(uri);
1049
1161
  const version =
1050
- sequence === undefined ? td.version : sequence.contentHash === textHash(text) ? sequence.version : sequence.version + 1;
1162
+ sequence === undefined
1163
+ ? this.firstOpenVersion(uri, text, td.version)
1164
+ : sequence.contentHash === textHash(text)
1165
+ ? sequence.version
1166
+ : sequence.version + 1;
1051
1167
  this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
1052
1168
  document = this.configuration.create(uri, td.languageId, version, text);
1053
1169
  this.__syncedDocuments.set(uri, document);
@@ -1056,6 +1172,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1056
1172
  // it from the file, and an editor opened its buffer from there. An
1057
1173
  // editor that opens a buffer it never saved is taken as clean.
1058
1174
  record.diskBaseline = td.text;
1175
+ if (this.__releasedDirty.delete(uri)) {
1176
+ record.dirty = true;
1177
+ }
1059
1178
  this.refreshDirty(uri);
1060
1179
  if (clientId === LANGUAGE_CLIENT_ID) {
1061
1180
  // Baseline the shadow to what Monaco just opened so the next outbound
@@ -1130,6 +1249,26 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1130
1249
  return true;
1131
1250
  }
1132
1251
 
1252
+ /**
1253
+ * The built root's recorded version, one on when `text` differs from the root's;
1254
+ * `declared` when the root records no store version. Seeded from `declared`, a write based
1255
+ * on the root passes the gate over other text, and the same text looks newer than its model.
1256
+ * A built root has no sequence only under a `LangiumDocuments` that does not reconcile at registration.
1257
+ */
1258
+ protected firstOpenVersion(uri: CanonicalUri, text: string, declared: number): number {
1259
+ const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
1260
+ if (built === undefined) {
1261
+ return declared;
1262
+ }
1263
+ const ledger = this.services.workspace.ModelLedger;
1264
+ const root = built.parseResult.value;
1265
+ const recorded = ledger.versionOf(root);
1266
+ if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
1267
+ return declared;
1268
+ }
1269
+ return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
1270
+ }
1271
+
1133
1272
  protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
1134
1273
  this.log(
1135
1274
  uri,
@@ -1214,43 +1353,76 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1214
1353
 
1215
1354
  /**
1216
1355
  * Current SHARED version of the document at `uri`: the open document's
1217
- * version, else where the persisted sequence left off at last close, else
1218
- * `0` for a URI this store has never seen. The shared sequence is
1356
+ * version, else where its persisted sequence left off, else `0` for a URI
1357
+ * this store has never seen. The shared sequence is
1219
1358
  * server-owned and monotonic across close/reopen cycles, and advances
1220
1359
  * exactly when the synced content changes — which is what makes it a sound
1221
- * optimistic-concurrency token (based-on gates): version unchanged ⇔
1360
+ * optimistic-concurrency token (base-version gates): version unchanged ⇔
1222
1361
  * content unchanged.
1223
1362
  */
1224
- version(uri: DocumentUri): number {
1363
+ version(uri: DocumentUri): TextVersion {
1225
1364
  return this.get(uri)?.version ?? this.__versionSequences.get(this.documentKey(uri))?.version ?? 0;
1226
1365
  }
1227
1366
 
1367
+ /**
1368
+ * The text the store holds for `uri`: an open document's, or for a closed
1369
+ * one, the clean text its version sequence left off at, which for a deleted
1370
+ * document is its last text. `undefined` for a URI no build and no client
1371
+ * gave the store.
1372
+ */
1373
+ textState(uri: DocumentUri): TextState | undefined {
1374
+ const document = this.get(uri);
1375
+ if (document) {
1376
+ return { version: document.version, hash: this.heldTextHash(document), dirty: this.isDirty(uri) };
1377
+ }
1378
+ const sequence = this.__versionSequences.get(this.documentKey(uri));
1379
+ return sequence && { version: sequence.version, hash: sequence.contentHash, dirty: false };
1380
+ }
1381
+
1382
+ /**
1383
+ * {@link textHash} of `document`'s text, taken once per version: the store
1384
+ * moves a document's version with every change of its text.
1385
+ */
1386
+ protected heldTextHash(document: T): string {
1387
+ const taken = this.__textHashes.get(document);
1388
+ if (taken?.version === document.version) {
1389
+ return taken.hash;
1390
+ }
1391
+ const hash = textHash(document.getText());
1392
+ this.__textHashes.set(document, { version: document.version, hash });
1393
+ return hash;
1394
+ }
1395
+
1228
1396
  /**
1229
1397
  * Reconcile the persisted version sequence with content that reached the
1230
1398
  * build OUTSIDE the store's write paths — a closed document rebuilt from
1231
1399
  * disk (last-close revert) or replaced by a watched-file change. Steps the
1232
1400
  * sequence iff `text` differs from the sequence's last-known content and
1233
1401
  * returns the resulting sequence version so the caller can re-stamp the
1234
- * rebuilt document (see the `AstDocumentManager` Parsed-phase listener) —
1402
+ * rebuilt document (`VersionSyncService.modelProduced`) —
1235
1403
  * keeping the "version advances iff content changes" invariant for
1236
1404
  * documents no client currently holds.
1237
1405
  *
1238
- * Returns `undefined` — and does nothing — when the document is open (the
1239
- * store's own content is authoritative; external transitions never reach
1240
- * the Langium factory for open documents) or was never tracked (no gate
1241
- * holder can hold a version pointer for it, and the early exit keeps
1242
- * workspace-wide builds from hashing every untouched document).
1406
+ * For an open document, its version when it holds `text`, since a build
1407
+ * that read the file while the document was closed can finish after an
1408
+ * open; else `undefined`, and the caller has to build the document again.
1409
+ *
1410
+ * A document without a sequence starts one at `0` with `text`: snapshots
1411
+ * hand out versions for documents no client opened, so an uncounted change
1412
+ * would let a write based on the old text pass the gate.
1243
1413
  */
1244
1414
  reconcileExternalContent(uri: DocumentUri, text: string): number | undefined {
1245
1415
  const key = this.documentKey(uri);
1246
- if (this.__syncedDocuments.has(key)) {
1247
- return undefined;
1416
+ const open = this.__syncedDocuments.get(key);
1417
+ if (open !== undefined) {
1418
+ return open.getText() === text ? open.version : undefined;
1248
1419
  }
1420
+ const hash = textHash(text);
1249
1421
  const sequence = this.__versionSequences.get(key);
1250
1422
  if (sequence === undefined) {
1251
- return undefined;
1423
+ this.__versionSequences.set(key, { version: 0, contentHash: hash });
1424
+ return 0;
1252
1425
  }
1253
- const hash = textHash(text);
1254
1426
  if (hash === sequence.contentHash) {
1255
1427
  return sequence.version;
1256
1428
  }
@@ -1284,7 +1456,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1284
1456
  *
1285
1457
  * A changed text is a new version authored by {@link INTEGRITY_CLIENT_ID}.
1286
1458
  * Kept at the old version, the repair is invisible to every reader keyed on
1287
- * versions: a write based on the unrepaired version passes the based-on gate
1459
+ * versions: a write based on the unrepaired version passes the base-version gate
1288
1460
  * and replaces the repair, and an echo filter credits the repair to the
1289
1461
  * client whose edit it corrected. No change event is fired: the repair
1290
1462
  * rides the build already under way, and an event would re-enter it.
@@ -1421,7 +1593,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1421
1593
  return this.__documents.get(this.documentKey(uri))?.dirty ?? false;
1422
1594
  }
1423
1595
 
1424
- /** Fires each time the answer of {@link isDirty} changes, the release of a dirty document included. */
1596
+ /**
1597
+ * Fires each time the answer of {@link isDirty} changes. A dirty document's
1598
+ * release fires once a parse of the file reaches the store, at that text's
1599
+ * version, or without text once its revert removed the document or could
1600
+ * not rebuild it, though {@link isDirty} answers clean from the release on.
1601
+ */
1425
1602
  get onDidChangeDirty(): Event<DocumentDirtyChangedEvent> {
1426
1603
  return this.dirtyChangedEmitter.event;
1427
1604
  }
@@ -1472,7 +1649,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1472
1649
  const dirty = document.getText() !== record.diskBaseline;
1473
1650
  if (dirty !== (record.dirty ?? false)) {
1474
1651
  record.dirty = dirty;
1475
- this.dirtyChangedEmitter.fire(Object.freeze({ uri, dirty }));
1652
+ this.dirtyChangedEmitter.fire(
1653
+ Object.freeze({ uri, text: { version: document.version, hash: this.heldTextHash(document), dirty } })
1654
+ );
1476
1655
  }
1477
1656
  }
1478
1657
 
@@ -12,6 +12,8 @@ export * from './client-session-errors.js';
12
12
  export * from './client-session-registry.js';
13
13
  export * from './language-client-text-shadow.js';
14
14
  export * from './hydranium-text-documents.js';
15
+ export * from './model-ledger.js';
16
+ export * from './version-sync-service.js';
15
17
  export * from './ast-document-manager.js';
16
18
  export * from './self-save-registry.js';
17
19
  export * from './file-system-task-queue.js';
@@ -0,0 +1,66 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { type AstNode } from '@hydranium/langium';
11
+ import { asModelVersion, type ModelVersion, UNRECORDED_VERSION } from '@hydranium/protocol';
12
+
13
+ /** What {@link ModelLedger} holds for one root. */
14
+ export interface ModelRecord {
15
+ readonly version: ModelVersion;
16
+ /** For a root whose CST no longer is the text it describes: changed in place, or shed. */
17
+ readonly text?: string;
18
+ }
19
+
20
+ /**
21
+ * The version of the text each root was parsed from, and that text where the
22
+ * root's CST does not hold it. Stores what it is told; the rest of the
23
+ * framework relies on these:
24
+ *
25
+ * - every workspace root is recorded through `VersionSyncService.modelProduced` when produced;
26
+ * - a version is recorded only for text the store held at that version, else `STALE_VERSION`;
27
+ * - the builder's placeholder for a file it has not parsed carries no version;
28
+ * - re-recording a root without text keeps the text recorded before.
29
+ */
30
+ export interface ModelLedger {
31
+ record(root: AstNode, version: number, text?: string): void;
32
+ /** `UNRECORDED_VERSION`, which every write conflicts with, for a root nothing recorded. */
33
+ versionOf(root: AstNode): ModelVersion;
34
+ /** The recorded text, else the root's CST text; `undefined` for a root with neither. */
35
+ textOf(root: AstNode): string | undefined;
36
+ /** Mark `root`, registered before any parse, as no parse of any text, dropping its record. */
37
+ markPlaceholder(root: AstNode): void;
38
+ /** Keyed on the root, not its state: a rebuilt document is reset below `Parsed` but keeps its root. */
39
+ isPlaceholder(root: AstNode): boolean;
40
+ }
41
+
42
+ export class DefaultModelLedger implements ModelLedger {
43
+ protected readonly records = new WeakMap<AstNode, ModelRecord>();
44
+ protected readonly placeholders = new WeakSet<AstNode>();
45
+
46
+ record(root: AstNode, version: number, text?: string): void {
47
+ this.records.set(root, { version: asModelVersion(version), text: text ?? this.records.get(root)?.text });
48
+ }
49
+
50
+ versionOf(root: AstNode): ModelVersion {
51
+ return this.records.get(root)?.version ?? UNRECORDED_VERSION;
52
+ }
53
+
54
+ textOf(root: AstNode): string | undefined {
55
+ return this.records.get(root)?.text ?? root.$cstNode?.root.fullText;
56
+ }
57
+
58
+ markPlaceholder(root: AstNode): void {
59
+ this.placeholders.add(root);
60
+ this.records.delete(root);
61
+ }
62
+
63
+ isPlaceholder(root: AstNode): boolean {
64
+ return this.placeholders.has(root);
65
+ }
66
+ }