@hydranium/core 1.0.0-next.114 → 1.0.0-next.117

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 (66) hide show
  1. package/README.md +3 -1
  2. package/lib/documents/ast-document-manager.d.ts +52 -2
  3. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  4. package/lib/documents/ast-document-manager.js +67 -5
  5. package/lib/documents/ast-document-manager.js.map +1 -1
  6. package/lib/documents/client-session-errors.d.ts +44 -0
  7. package/lib/documents/client-session-errors.d.ts.map +1 -0
  8. package/lib/documents/client-session-errors.js +57 -0
  9. package/lib/documents/client-session-errors.js.map +1 -0
  10. package/lib/documents/client-session-registry.d.ts +118 -0
  11. package/lib/documents/client-session-registry.d.ts.map +1 -0
  12. package/lib/documents/client-session-registry.js +171 -0
  13. package/lib/documents/client-session-registry.js.map +1 -0
  14. package/lib/documents/hydranium-text-documents.d.ts +77 -25
  15. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  16. package/lib/documents/hydranium-text-documents.js +143 -52
  17. package/lib/documents/hydranium-text-documents.js.map +1 -1
  18. package/lib/documents/index.d.ts +2 -0
  19. package/lib/documents/index.d.ts.map +1 -1
  20. package/lib/documents/index.js +2 -0
  21. package/lib/documents/index.js.map +1 -1
  22. package/lib/langium/integrity/integrity-rule.d.ts +13 -5
  23. package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
  24. package/lib/langium/integrity/integrity-rule.js.map +1 -1
  25. package/lib/langium/integrity/integrity-service.d.ts +37 -6
  26. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  27. package/lib/langium/integrity/integrity-service.js +81 -17
  28. package/lib/langium/integrity/integrity-service.js.map +1 -1
  29. package/lib/langium/model-service/client-session.d.ts +120 -0
  30. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  31. package/lib/langium/model-service/client-session.js +175 -0
  32. package/lib/langium/model-service/client-session.js.map +1 -0
  33. package/lib/langium/model-service/index.d.ts +1 -0
  34. package/lib/langium/model-service/index.d.ts.map +1 -1
  35. package/lib/langium/model-service/index.js +1 -0
  36. package/lib/langium/model-service/index.js.map +1 -1
  37. package/lib/langium/model-service/model-service.d.ts +76 -8
  38. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  39. package/lib/langium/model-service/model-service.js +148 -21
  40. package/lib/langium/model-service/model-service.js.map +1 -1
  41. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
  42. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  43. package/lib/lsp/hydranium-document-update-handler.js +20 -3
  44. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  45. package/lib/testing/stub-ast-document-manager.d.ts +4 -3
  46. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  47. package/lib/testing/stub-ast-document-manager.js +3 -0
  48. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  49. package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
  50. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  51. package/lib/testing/stub-hydranium-text-documents.js +45 -14
  52. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  53. package/package.json +5 -5
  54. package/src/documents/ast-document-manager.ts +107 -6
  55. package/src/documents/client-session-errors.ts +54 -0
  56. package/src/documents/client-session-registry.ts +211 -0
  57. package/src/documents/hydranium-text-documents.ts +158 -55
  58. package/src/documents/index.ts +2 -0
  59. package/src/langium/integrity/integrity-rule.ts +13 -5
  60. package/src/langium/integrity/integrity-service.ts +84 -17
  61. package/src/langium/model-service/client-session.ts +257 -0
  62. package/src/langium/model-service/index.ts +1 -0
  63. package/src/langium/model-service/model-service.ts +176 -21
  64. package/src/lsp/hydranium-document-update-handler.ts +22 -4
  65. package/src/testing/stub-ast-document-manager.ts +7 -2
  66. package/src/testing/stub-hydranium-text-documents.ts +62 -18
@@ -8,6 +8,7 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import {
11
+ type BasedOn,
11
12
  type CanonicalUri,
12
13
  type CloseModelArgs,
13
14
  ConflictError,
@@ -34,7 +35,10 @@ import { type LogNameOptions } from '../diagnostics/logger.js';
34
35
  import { IntegrityService } from '../integrity/integrity-service.js';
35
36
  import { labelPhaseListener } from '../document-builder/labeled-phase-listener.js';
36
37
  import { LANGUAGE_CLIENT_ID } from '../../documents/client-ids.js';
38
+ import { DocumentNotOpenError } from '../../documents/client-session-errors.js';
39
+ import { type OpenOptions } from '../../documents/client-session-registry.js';
37
40
  import { type ServerSharedServices } from '../module.js';
41
+ import { type ClientSession, DefaultClientSession } from './client-session.js';
38
42
 
39
43
  /**
40
44
  * The undo-stack entry for a server-authored write pushed to the editor.
@@ -274,6 +278,21 @@ export interface ModelService<
274
278
  onModelUpdated(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable;
275
279
  onModelSaved(uri: string, listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void): Disposable;
276
280
  onClientClosed(uri: string, clientId: string, listener: () => void): Disposable;
281
+
282
+ /**
283
+ * Start a client session. The id defaults to `label#` plus a random UUID;
284
+ * a fixed `clientId` is taken as given. Throws `DuplicateClientIdError` when
285
+ * the id is reserved by the framework, held by another live session, or has
286
+ * documents open under it as a client that is not a session.
287
+ *
288
+ * `TOpenOptions` types the options the session's `open` takes.
289
+ */
290
+ createSession<TOpenOptions extends OpenOptions = OpenOptions>(
291
+ label?: string,
292
+ clientId?: string
293
+ ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
294
+ /** The live session started under `clientId`, or `undefined` once it has ended or was never started. */
295
+ getSession(clientId: string): ClientSession<TAst, TDiagnostic, TTransfer> | undefined;
277
296
  }
278
297
 
279
298
  export class DefaultModelService<
@@ -318,6 +337,20 @@ export class DefaultModelService<
318
337
  protected readonly syncChains = new Map<string, Promise<void>>();
319
338
  protected readonly pendingSync = new Map<string, string>();
320
339
 
340
+ /**
341
+ * The live sessions this service started, by client id. {@link update}
342
+ * reads it to tell a session's write, which must find its document open,
343
+ * from a write under a plain client id, which opens it.
344
+ */
345
+ protected readonly sessions = new Map<string, ClientSession<TAst, TDiagnostic, TTransfer>>();
346
+ /**
347
+ * Drops an ended session from {@link sessions}. Subscribed by the first
348
+ * {@link createSession} rather than at construction, so a services tree with
349
+ * no text store, or one that knows nothing of sessions, still constructs
350
+ * this service.
351
+ */
352
+ protected sessionCloseListener?: Disposable;
353
+
321
354
  /**
322
355
  * Workspace-level readiness gate. Resolves when the framework has
323
356
  * finished its first workspace build cycle and the `ModelService`
@@ -681,8 +714,19 @@ export class DefaultModelService<
681
714
  * with vN's specific settled snapshot, intermediate-phase
682
715
  * observability, slow-warn / hard-timeout behaviour) override this
683
716
  * method.
717
+ *
718
+ * **Upsert, except for a session.** Under a plain client id the write opens
719
+ * the document for that client first, creating it from the payload when no
720
+ * file exists. Under the id of a live session from {@link createSession} it
721
+ * opens nothing, and fails with `DocumentNotOpenError` unless the session
722
+ * has the URI open at the moment the text is applied.
684
723
  */
685
724
  async update(args: TransferUpdateArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
725
+ // Read before the first await, while the call is still the caller's: a
726
+ // session that ends during the serialise would otherwise read as a plain
727
+ // client id here, and the write would open the document for an id no
728
+ // session owns any more and land there.
729
+ const bySession = this.sessions.has(args.clientId);
686
730
  const stopwatch = this.slowUpdateWarn !== undefined ? this.services.Clock.stopwatch() : undefined;
687
731
  // Per-stage self-time breakdown of the update/reconcile chain (serialise →
688
732
  // open → apply → rebuild), opt-in at debug — the default path skips the
@@ -731,16 +775,34 @@ export class DefaultModelService<
731
775
  // reason to close a document it never asked to open. Serialisation is
732
776
  // held back too; it is the adopter's code and need not be side-effect
733
777
  // free.
734
- const currentVersion = this.services.workspace.TextDocuments.version(uri);
735
- if (isSnapshotVersion(args.basedOn) && currentVersion !== args.basedOn) {
736
- // Distinct from the post-build "superseded" debug line below: this is a
737
- // based-on-stale rejection (the write never applies), not two writes racing.
738
- this.tracer.debug(`Conflict on ${uri}: based-on v${args.basedOn} stale, server at v${currentVersion}`);
739
- throw new ConflictError(uri, args.basedOn, currentVersion);
740
- }
778
+ const textDocuments = this.services.workspace.TextDocuments;
779
+ this.checkBasedOn(uri, args.basedOn, textDocuments.version(uri));
780
+ // Whether this call's own open is the one that creates the document, read
781
+ // in the same step as the check above so no other open can land between.
782
+ const opensCold = !bySession && textDocuments.get(uri) === undefined;
741
783
  const text = await run('serialize', () => this.modelToText(uri, args.model, cancelToken));
742
- await run('open', () => this.open({ uri, clientId: args.clientId, text }));
743
- const appliedVersion = await run('apply', () => this.services.workspace.AstDocumentManager.update(uri, text, args.clientId));
784
+ if (!bySession) {
785
+ await run('open', () => this.open({ uri, clientId: args.clientId, text }));
786
+ }
787
+ const appliedVersion = await run('apply', () => {
788
+ // A session's write never opens its document. The check sits in the
789
+ // same synchronous step as the apply: made before the serialize await
790
+ // instead, it passes for a write whose session closes the document
791
+ // during that await, and the write then lands on a document the
792
+ // session no longer has open.
793
+ if (bySession && !textDocuments.isOpenInClient(uri, args.clientId)) {
794
+ throw new DocumentNotOpenError(uri, args.clientId);
795
+ }
796
+ // The gate again, in the same step as the apply: two writes based on
797
+ // one version both pass the check at the door while they serialise,
798
+ // and without this both apply. The version a cold open assigned from
799
+ // this write's own text is not a conflict; the store holding exactly
800
+ // that text is how it shows.
801
+ if (!(opensCold && textDocuments.get(uri)?.getText() === text)) {
802
+ this.checkBasedOn(uri, args.basedOn, textDocuments.version(uri));
803
+ }
804
+ return this.services.workspace.AstDocumentManager.update(uri, text, args.clientId);
805
+ });
744
806
  // Dispatch through the public `rebuild` (which re-canonicalizes the already-
745
807
  // canonical `uri` once, idempotently) rather than `rebuildCanonical`, so an
746
808
  // adopter `rebuild` override stays in the update path. The redundant call is
@@ -765,6 +827,23 @@ export class DefaultModelService<
765
827
  return doc;
766
828
  }
767
829
 
830
+ /**
831
+ * Throw {@link ConflictError} when `basedOn` names a version other than
832
+ * `currentVersion`. {@link update} calls it at the door and again in the
833
+ * synchronous step that applies the text, and has to call it synchronously
834
+ * there: a check separated from the apply by an await lets two writes based
835
+ * on one version both apply.
836
+ */
837
+ protected checkBasedOn(uri: string, basedOn: BasedOn, currentVersion: number): void {
838
+ if (isSnapshotVersion(basedOn) && currentVersion !== basedOn) {
839
+ // Distinct from the post-build "superseded" debug line in `update`: this
840
+ // is a based-on-stale rejection (the write never applies), not two
841
+ // writes racing.
842
+ this.tracer.debug(`Conflict on ${uri}: based-on v${basedOn} stale, server at v${currentVersion}`);
843
+ throw new ConflictError(uri, basedOn, currentVersion);
844
+ }
845
+ }
846
+
768
847
  /**
769
848
  * Persist `uri` to disk. Same content-change + settled-phase flow as
770
849
  * {@link update}, then writes via the
@@ -773,8 +852,16 @@ export class DefaultModelService<
773
852
  * the `onDidSave` event regardless of who originated the persist).
774
853
  *
775
854
  * Returns the post-save AST snapshot.
855
+ *
856
+ * Under the id of a live session the save also fails with
857
+ * `DocumentNotOpenError` when the session closes the URI while the text is
858
+ * being built, and writes nothing. Once the manager has taken the text, the
859
+ * write completes whatever the session does next.
776
860
  */
777
861
  async save(args: TransferSaveArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
862
+ // Read before the first await, as in `update`: a session that ends during
863
+ // the rebuild would otherwise read as a plain client id below.
864
+ const bySession = this.sessions.has(args.clientId);
778
865
  // Dispatch through `update` (not its internals) so an adopter `update`
779
866
  // override — version-matched resolution, etc. — applies to saves too.
780
867
  const doc = await this.update(args, cancelToken);
@@ -782,6 +869,12 @@ export class DefaultModelService<
782
869
  // the canonical (real) path follows any symlink to the same file, and the
783
870
  // `onDidSave` keys the one canonical registration.
784
871
  const uri = this.uriPolicy.canonicalUri(args.uri);
872
+ // In the same synchronous step as the manager taking the text: checked
873
+ // any earlier, a session that closes the URI during the rebuild still has
874
+ // the shared text, other clients' edits included, written in its name.
875
+ if (bySession && !this.services.workspace.TextDocuments.isOpenInClient(uri, args.clientId)) {
876
+ throw new DocumentNotOpenError(uri, args.clientId);
877
+ }
785
878
  await this.services.workspace.AstDocumentManager.save(uri, args.clientId);
786
879
  return doc;
787
880
  }
@@ -906,6 +999,55 @@ export class DefaultModelService<
906
999
  return this.services.workspace.AstDocumentManager.getDocument(uri);
907
1000
  }
908
1001
 
1002
+ // ============================================================
1003
+ // Client sessions
1004
+ // ============================================================
1005
+
1006
+ createSession<TOpenOptions extends OpenOptions = OpenOptions>(
1007
+ label?: string,
1008
+ clientId?: string
1009
+ ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
1010
+ const sessionLabel = label ?? 'session';
1011
+ const id = clientId ?? `${sessionLabel}#${globalThis.crypto.randomUUID()}`;
1012
+ const textDocuments = this.services.workspace.TextDocuments;
1013
+ textDocuments.registerSession(id);
1014
+ this.sessionCloseListener ??= textDocuments.onDidCloseSession(event => {
1015
+ // Also reached when the store ends a session directly; disposing the
1016
+ // handle makes its later calls fail rather than write under an id
1017
+ // this service no longer treats as a session.
1018
+ this.sessions.get(event.clientId)?.dispose();
1019
+ this.sessions.delete(event.clientId);
1020
+ });
1021
+ let session: ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
1022
+ try {
1023
+ session = this.newSession<TOpenOptions>(id, sessionLabel);
1024
+ } catch (err: unknown) {
1025
+ textDocuments.closeSession(id);
1026
+ throw err;
1027
+ }
1028
+ this.sessions.set(id, session);
1029
+ return session;
1030
+ }
1031
+
1032
+ getSession(clientId: string): ClientSession<TAst, TDiagnostic, TTransfer> | undefined {
1033
+ return this.sessions.get(clientId);
1034
+ }
1035
+
1036
+ /**
1037
+ * Build the handle for a session {@link createSession} has already
1038
+ * registered. The override point for a custom session class; narrow the
1039
+ * return type to it.
1040
+ *
1041
+ * Registration and the uniqueness check have already happened when this
1042
+ * runs, so an override cannot skip them.
1043
+ */
1044
+ protected newSession<TOpenOptions extends OpenOptions = OpenOptions>(
1045
+ clientId: string,
1046
+ label: string
1047
+ ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
1048
+ return new DefaultClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>(this, this.services, clientId, label);
1049
+ }
1050
+
909
1051
  // ============================================================
910
1052
  // LSP-client sync (mirror non-LSP-client changes back to Monaco)
911
1053
  // ============================================================
@@ -917,15 +1059,20 @@ export class DefaultModelService<
917
1059
  * `HydraniumTextDocuments.applyEditToLanguageClient` and
918
1060
  * `HydraniumTextDocuments.stagePendingContent`.
919
1061
  *
920
- * Routes to one of two mechanisms by language-client registration, because
921
- * an open and a closed document answer different questions:
1062
+ * Routes by client registration, because an open and a closed document
1063
+ * answer different questions:
922
1064
  *
923
1065
  * - **Open in the language client** → {@link syncOpenDocument}: mirror the
924
1066
  * settled text via a coalesced `applyEditToLanguageClient`, routed purely by
925
1067
  * **content**.
926
- * - **Closed in the language client** → {@link stageClosedDocument}: stage the
927
- * text for the eventual first `didOpen`, gated by **provenance**
928
- * ({@link isNonLanguageClientEdit}).
1068
+ * - **Open only in another client** (the data or GLSP head) → nothing. A
1069
+ * language client opening it joins the existing entry and is refreshed from
1070
+ * the store, and only a FIRST open reads a stage, so staging here would
1071
+ * leave text nothing reads.
1072
+ * - **Closed in every client** → {@link stageClosedDocument}, gated by
1073
+ * **provenance** ({@link isNonLanguageClientEdit}). With the default text
1074
+ * store this never stages; see {@link stageClosedDocument} for why, and
1075
+ * for the store it serves.
929
1076
  *
930
1077
  * The decision is **re-derived from the current settled state every time** (it
931
1078
  * is not a one-shot enrolment), which is what makes it self-healing: a doc
@@ -935,9 +1082,9 @@ export class DefaultModelService<
935
1082
  */
936
1083
  protected syncToLanguageClient(document: LangiumDocument): void {
937
1084
  // `document.textDocument.uri` is the server-identity (canonical) URI off the
938
- // build. Route by language-client registration (a presence question —
939
- // `isOpenInLanguageClient` canonicalizes internally, so a divergent open path
940
- // still resolves to the same record), but key the outbound sync by this
1085
+ // build. Route by client registration (presence questions — both
1086
+ // predicates canonicalize internally, so a divergent open path still
1087
+ // resolves to the same record), but key the outbound sync by this
941
1088
  // CANONICAL URI — the same key `settleSave` looks the chain up under —
942
1089
  // so a save-settle can drain the in-flight applyEdit. The canonical→client-URI
943
1090
  // translation (a symlinked path the client opened, or a dual-open fan-out)
@@ -945,9 +1092,10 @@ export class DefaultModelService<
945
1092
  // the canonical key to the recorded language-client URI(s); driving the chain
946
1093
  // in client space here would key it under a URI `settleSave` never computes, so
947
1094
  // the drain would miss for a divergent open path.
948
- if (this.services.workspace.TextDocuments.isOpenInLanguageClient(document.textDocument.uri)) {
1095
+ const textDocuments = this.services.workspace.TextDocuments;
1096
+ if (textDocuments.isOpenInLanguageClient(document.textDocument.uri)) {
949
1097
  this.syncOpenDocument(document.textDocument.uri, document.textDocument.getText());
950
- } else {
1098
+ } else if (!textDocuments.isOpenInAnyClient(document.textDocument.uri)) {
951
1099
  this.stageClosedDocument(document);
952
1100
  }
953
1101
  }
@@ -968,11 +1116,18 @@ export class DefaultModelService<
968
1116
  }
969
1117
 
970
1118
  /**
971
- * Stage the settled text of a document closed in the language client so the
972
- * eventual first `didOpen` sees this in-memory text instead of stale disk —
1119
+ * Stage the settled text of a document no client holds so the eventual
1120
+ * first `didOpen` sees this in-memory text instead of stale disk —
973
1121
  * but only for a {@link isNonLanguageClientEdit genuine non-language-client edit}.
974
1122
  * A document rebuilt by an internal build is skipped, leaving disk authoritative
975
1123
  * on the next open.
1124
+ *
1125
+ * The default `HydraniumTextDocuments` never gets this far. Its own paths
1126
+ * record a version's author only while a client holds the document, and the
1127
+ * last close drops that history with the tracking record, so a document no
1128
+ * client holds reports no author and the provenance gate refuses. The stage
1129
+ * is reachable for a text store that keeps authorship past the last close,
1130
+ * or a subclass overriding {@link isNonLanguageClientEdit}.
976
1131
  */
977
1132
  protected stageClosedDocument(document: LangiumDocument): void {
978
1133
  if (this.isNonLanguageClientEdit(document)) {
@@ -133,7 +133,10 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
133
133
  */
134
134
  protected pendingReason?: string;
135
135
 
136
- constructor(services: ServerSharedServices, options: HydraniumDocumentUpdateHandlerOptions = {}) {
136
+ constructor(
137
+ protected readonly services: ServerSharedServices,
138
+ options: HydraniumDocumentUpdateHandlerOptions = {}
139
+ ) {
137
140
  super(services);
138
141
  this.logger = services.Logger;
139
142
  this.selfSaveRegistry = services.workspace.SelfSaveRegistry;
@@ -201,6 +204,11 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
201
204
  *
202
205
  * Per-client closes (where other clients still hold the URI) are
203
206
  * suppressed via {@link HydraniumTextDocuments.isOpenInAnyClient}.
207
+ *
208
+ * The dispatch waits for the URI's disk queue to drain, because the rebuild
209
+ * re-reads the file: dispatched while a save is still queued, it reads the
210
+ * text from before that save, and the document reverts past it. A client
211
+ * that opens the URI meanwhile holds the text, so no revert follows.
204
212
  */
205
213
  didCloseDocument(change: TextDocumentChangeEvent<TextDocument>): void {
206
214
  if (this.textDocuments.isOpenInAnyClient(change.document.uri)) {
@@ -210,9 +218,19 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
210
218
  if (uri.scheme !== 'file') {
211
219
  return;
212
220
  }
213
- this.nextReason = HYDRANIUM_BUILD_REASONS.didClose;
214
- this.immediateFlush = true;
215
- this.fireDocumentUpdate([uri], []);
221
+ this.services.workspace.AstDocumentManager.queueDiskTask(change.document.uri, async () => undefined)
222
+ .then(() => {
223
+ if (this.textDocuments.isOpenInAnyClient(change.document.uri)) {
224
+ return;
225
+ }
226
+ this.nextReason = HYDRANIUM_BUILD_REASONS.didClose;
227
+ this.immediateFlush = true;
228
+ this.fireDocumentUpdate([uri], []);
229
+ })
230
+ .catch((err: unknown) => {
231
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
232
+ this.logger.error(`Revert on last close dropped for ${change.document.uri}. ${detail}`);
233
+ });
216
234
  }
217
235
 
218
236
  override didChangeWatchedFiles(params: DidChangeWatchedFilesParams): void {
@@ -24,8 +24,9 @@ import type { StubLangiumDocuments } from './stub-langium-documents.js';
24
24
  * Minimal stub of {@link AstDocumentManager} for use in test
25
25
  * harnesses. Implements just the methods the framework's
26
26
  * `ModelService` calls (`open` / `close` / `update` / `save` /
27
- * `isOpen` / `getDocument`); `onSave` / `onClientClosed` forward to the
28
- * underlying stub text-document manager, `onUpdate` is driven explicitly
27
+ * `isOpen` / `getDocument`) and a disk queue that runs each task at once;
28
+ * `onSave` / `onClientClosed` forward to the underlying stub text store,
29
+ * `onUpdate` is driven explicitly
29
30
  * through {@link StubAstDocumentManager.emitUpdate} because the stub tree runs
30
31
  * no build phases to emit from, and the remaining read helpers
31
32
  * (`isTriggeringEdit`, `getAuthor`) throw if invoked — tests that need them
@@ -52,6 +53,7 @@ export interface StubAstDocumentManager<TAst extends AstNode, TDiagnostic extend
52
53
  | 'isOpen'
53
54
  | 'update'
54
55
  | 'save'
56
+ | 'queueDiskTask'
55
57
  | 'onUpdate'
56
58
  | 'onSave'
57
59
  | 'onClientClosed'
@@ -151,6 +153,9 @@ export function makeStubAstDocumentManager<TAst extends AstNode, TDiagnostic ext
151
153
  await fileSystem.writeFile(UriUtils.toUri(uri), text);
152
154
  textDocuments.notifyDidSaveTextDocument({ textDocument: { uri }, text }, clientId);
153
155
  },
156
+ queueDiskTask<T>(_uri: string, task: () => Promise<T>): Promise<T> {
157
+ return task();
158
+ },
154
159
  // Registers the listener but never calls it by itself — the real manager
155
160
  // emits from a `DocumentBuilder.onDocumentPhase(Validated)` hook and the
156
161
  // stub tree runs no phases. `emitUpdate` is the only thing that fires it;
@@ -12,6 +12,7 @@ import type { ApplyWorkspaceEditResult } from 'vscode-languageserver';
12
12
  import { Disposable } from 'vscode-languageserver';
13
13
  import type { TextDocument } from 'vscode-languageserver-textdocument';
14
14
  import { LANGUAGE_CLIENT_ID } from '../documents/client-ids.js';
15
+ import { ClientSessionRegistry } from '../documents/client-session-registry.js';
15
16
  import type { ClientTextDocumentChangeEvent, HydraniumTextDocuments } from '../documents/hydranium-text-documents.js';
16
17
 
17
18
  /** Snapshot of an open document tracked by the stub. */
@@ -27,9 +28,11 @@ export interface StubTextDocumentEntry {
27
28
  * reads from on test paths — the content channel
28
29
  * (`notifyDidChangeTextDocument` / `applyContentChange` / `version` /
29
30
  * `getAuthor`), the open-state probes (`isOpenInLanguageClient` /
30
- * `isOpenInAnyClient` / `openDocuments`), the push channel to the language
31
- * client (`applyEditToLanguageClient` / `stagePendingContent`) and the
32
- * save / close notifications — plus test-only helpers:
31
+ * `isOpenInAnyClient` / `isOpenInClient` / `openDocuments`), the push channel
32
+ * to the language client (`applyEditToLanguageClient` / `stagePendingContent`),
33
+ * the save / close notifications, and the client-session table
34
+ * (`registerSession` / `closeSession` / `onDidCloseSession` and the open
35
+ * options), which is a real `ClientSessionRegistry` — plus test-only helpers:
33
36
  *
34
37
  * - {@link seedOpen} — pre-populate an open document without firing change
35
38
  * events. Use to set up a baseline version before exercising an `update`.
@@ -69,6 +72,12 @@ export interface StubHydraniumTextDocuments extends Pick<
69
72
  | 'applyEditToLanguageClient'
70
73
  | 'stagePendingContent'
71
74
  | 'openDocuments'
75
+ | 'isOpenInClient'
76
+ | 'registerSession'
77
+ | 'closeSession'
78
+ | 'onDidCloseSession'
79
+ | 'openOptions'
80
+ | 'setOpenOptions'
72
81
  > {
73
82
  /**
74
83
  * Stub-tailored read accessor. Returns just the surface the framework's
@@ -93,7 +102,13 @@ export interface StubHydraniumTextDocuments extends Pick<
93
102
  seedOpenInLanguageClient(uri: string): void;
94
103
  /** Synchronously deliver `onDidClose` to subscribers. */
95
104
  fireClose(uri: string, clientId: string): void;
96
- /** Drop recorded state. Useful for `beforeEach` resets. */
105
+ /**
106
+ * Drop recorded state. Useful for `beforeEach` resets.
107
+ *
108
+ * Sessions end without announcing it, so a model service built on this
109
+ * stub still holds the handles it started before the reset. Build a fresh
110
+ * service after a reset rather than reusing one across it.
111
+ */
97
112
  reset(): void;
98
113
  readonly changes: readonly StubTextDocumentEntry[];
99
114
  readonly saves: readonly { uri: string; clientId: string }[];
@@ -125,8 +140,13 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
125
140
  let applyEditHandler: (uri: string, text: string) => ApplyWorkspaceEditResult | undefined = () => ({ applied: true });
126
141
  const saveListeners: Array<(event: { document: { uri: string }; clientId: string }) => void> = [];
127
142
  const closeListeners: Array<(event: ClientTextDocumentChangeEvent<TextDocument>) => void> = [];
143
+ // The real registry, so the session table, the opens, the closing state and
144
+ // the open options behave as the store's do. The stub's plain-string keys
145
+ // stand in for canonical URIs (one stub-boundary cast, like `fireClose`).
146
+ const sessions = new ClientSessionRegistry();
147
+ const key = (uri: string): CanonicalUri => uri as CanonicalUri;
128
148
 
129
- return {
149
+ const stub: StubHydraniumTextDocuments = {
130
150
  get changes() {
131
151
  return changes;
132
152
  },
@@ -207,21 +227,37 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
207
227
  isOpenInAnyClient(uri) {
208
228
  return docs.has(uri) || languageClientOpen.has(uri);
209
229
  },
210
- openDocuments() {
211
- // Merge the two seeding channels: `seedOpen` records the holding client
212
- // on the doc entry, `seedOpenInLanguageClient` marks the LSP client.
213
- const clientsByUri = new Map<string, Set<string>>();
214
- for (const [uri, entry] of docs) {
215
- clientsByUri.set(uri, new Set([entry.clientId]));
230
+ isOpenInClient(uri, clientId) {
231
+ return sessions.isOpenIn(key(uri), clientId);
232
+ },
233
+ registerSession(clientId) {
234
+ sessions.register(clientId);
235
+ },
236
+ closeSession(clientId) {
237
+ if (!sessions.isRegistered(clientId)) {
238
+ return;
216
239
  }
217
- for (const uri of languageClientOpen) {
218
- const clients = clientsByUri.get(uri) ?? new Set<string>();
219
- clients.add(LANGUAGE_CLIENT_ID);
220
- clientsByUri.set(uri, clients);
240
+ try {
241
+ for (const uri of sessions.beginClose(clientId)) {
242
+ stub.fireClose(uri, clientId);
243
+ }
244
+ } finally {
245
+ sessions.unregister(clientId);
221
246
  }
222
- // The real store keys by canonical identity; the stub's plain-string
223
- // keys stand in for it (single stub-boundary cast, like `fireClose`).
224
- return [...clientsByUri].map(([uri, clients]) => ({ uri: uri as CanonicalUri, clients: [...clients] }));
247
+ },
248
+ get onDidCloseSession() {
249
+ return sessions.onDidCloseSession;
250
+ },
251
+ openOptions(uri, clientId) {
252
+ return sessions.openOptions(key(uri), clientId);
253
+ },
254
+ setOpenOptions(uri, clientId, options) {
255
+ sessions.setOpenOptions(key(uri), clientId, options);
256
+ },
257
+ openDocuments() {
258
+ // From the registry that also answers `isOpenInClient`, which both
259
+ // seeding channels feed, so the two never disagree about an open.
260
+ return sessions.openDocuments();
225
261
  },
226
262
  setApplyEditHandler(handler) {
227
263
  applyEditHandler = handler;
@@ -234,9 +270,11 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
234
270
  staged.push({ uri, text });
235
271
  },
236
272
  seedOpen(uri, text, clientId) {
273
+ sessions.addOpen(key(uri), clientId);
237
274
  docs.set(uri, { uri, version: 1, text, clientId });
238
275
  },
239
276
  seedOpenInLanguageClient(uri) {
277
+ sessions.addOpen(key(uri), LANGUAGE_CLIENT_ID);
240
278
  languageClientOpen.add(uri);
241
279
  },
242
280
  fireClose(uri, clientId) {
@@ -247,6 +285,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
247
285
  if (holder && holder.clientId === clientId) {
248
286
  docs.delete(uri);
249
287
  }
288
+ sessions.removeOpen(key(uri), clientId);
250
289
  if (clientId === LANGUAGE_CLIENT_ID) {
251
290
  languageClientOpen.delete(uri);
252
291
  }
@@ -265,6 +304,11 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
265
304
  saveListeners.length = 0;
266
305
  closeListeners.length = 0;
267
306
  applyEditHandler = () => ({ applied: true });
307
+ // Cleared in place rather than replaced: a model service built on the
308
+ // stub subscribed to `onDidCloseSession` once, and a new registry would
309
+ // leave that subscription on one nothing fires.
310
+ sessions.clear();
268
311
  }
269
312
  };
313
+ return stub;
270
314
  }