@hydranium/core 1.0.0-next.115 → 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 (62) 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 +69 -17
  15. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  16. package/lib/documents/hydranium-text-documents.js +135 -44
  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-service.d.ts +8 -7
  23. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  24. package/lib/langium/integrity/integrity-service.js +35 -27
  25. package/lib/langium/integrity/integrity-service.js.map +1 -1
  26. package/lib/langium/model-service/client-session.d.ts +120 -0
  27. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  28. package/lib/langium/model-service/client-session.js +175 -0
  29. package/lib/langium/model-service/client-session.js.map +1 -0
  30. package/lib/langium/model-service/index.d.ts +1 -0
  31. package/lib/langium/model-service/index.d.ts.map +1 -1
  32. package/lib/langium/model-service/index.js +1 -0
  33. package/lib/langium/model-service/index.js.map +1 -1
  34. package/lib/langium/model-service/model-service.d.ts +57 -1
  35. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  36. package/lib/langium/model-service/model-service.js +123 -9
  37. package/lib/langium/model-service/model-service.js.map +1 -1
  38. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
  39. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  40. package/lib/lsp/hydranium-document-update-handler.js +20 -3
  41. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  42. package/lib/testing/stub-ast-document-manager.d.ts +4 -3
  43. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  44. package/lib/testing/stub-ast-document-manager.js +3 -0
  45. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  46. package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
  47. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  48. package/lib/testing/stub-hydranium-text-documents.js +45 -14
  49. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  50. package/package.json +5 -5
  51. package/src/documents/ast-document-manager.ts +107 -6
  52. package/src/documents/client-session-errors.ts +54 -0
  53. package/src/documents/client-session-registry.ts +211 -0
  54. package/src/documents/hydranium-text-documents.ts +150 -47
  55. package/src/documents/index.ts +2 -0
  56. package/src/langium/integrity/integrity-service.ts +36 -26
  57. package/src/langium/model-service/client-session.ts +257 -0
  58. package/src/langium/model-service/index.ts +1 -0
  59. package/src/langium/model-service/model-service.ts +151 -9
  60. package/src/lsp/hydranium-document-update-handler.ts +22 -4
  61. package/src/testing/stub-ast-document-manager.ts +7 -2
  62. package/src/testing/stub-hydranium-text-documents.ts +62 -18
@@ -8,6 +8,7 @@
8
8
  ********************************************************************************/
9
9
  import { Disposable } from 'vscode-languageserver';
10
10
  import { LANGUAGE_CLIENT_ID } from '../documents/client-ids.js';
11
+ import { ClientSessionRegistry } from '../documents/client-session-registry.js';
11
12
  /** Build a {@link StubHydraniumTextDocuments}. */
12
13
  export function makeStubHydraniumTextDocuments() {
13
14
  const docs = new Map();
@@ -19,7 +20,12 @@ export function makeStubHydraniumTextDocuments() {
19
20
  let applyEditHandler = () => ({ applied: true });
20
21
  const saveListeners = [];
21
22
  const closeListeners = [];
22
- return {
23
+ // The real registry, so the session table, the opens, the closing state and
24
+ // the open options behave as the store's do. The stub's plain-string keys
25
+ // stand in for canonical URIs (one stub-boundary cast, like `fireClose`).
26
+ const sessions = new ClientSessionRegistry();
27
+ const key = (uri) => uri;
28
+ const stub = {
23
29
  get changes() {
24
30
  return changes;
25
31
  },
@@ -100,21 +106,38 @@ export function makeStubHydraniumTextDocuments() {
100
106
  isOpenInAnyClient(uri) {
101
107
  return docs.has(uri) || languageClientOpen.has(uri);
102
108
  },
103
- openDocuments() {
104
- // Merge the two seeding channels: `seedOpen` records the holding client
105
- // on the doc entry, `seedOpenInLanguageClient` marks the LSP client.
106
- const clientsByUri = new Map();
107
- for (const [uri, entry] of docs) {
108
- clientsByUri.set(uri, new Set([entry.clientId]));
109
+ isOpenInClient(uri, clientId) {
110
+ return sessions.isOpenIn(key(uri), clientId);
111
+ },
112
+ registerSession(clientId) {
113
+ sessions.register(clientId);
114
+ },
115
+ closeSession(clientId) {
116
+ if (!sessions.isRegistered(clientId)) {
117
+ return;
118
+ }
119
+ try {
120
+ for (const uri of sessions.beginClose(clientId)) {
121
+ stub.fireClose(uri, clientId);
122
+ }
109
123
  }
110
- for (const uri of languageClientOpen) {
111
- const clients = clientsByUri.get(uri) ?? new Set();
112
- clients.add(LANGUAGE_CLIENT_ID);
113
- clientsByUri.set(uri, clients);
124
+ finally {
125
+ sessions.unregister(clientId);
114
126
  }
115
- // The real store keys by canonical identity; the stub's plain-string
116
- // keys stand in for it (single stub-boundary cast, like `fireClose`).
117
- return [...clientsByUri].map(([uri, clients]) => ({ uri: uri, clients: [...clients] }));
127
+ },
128
+ get onDidCloseSession() {
129
+ return sessions.onDidCloseSession;
130
+ },
131
+ openOptions(uri, clientId) {
132
+ return sessions.openOptions(key(uri), clientId);
133
+ },
134
+ setOpenOptions(uri, clientId, options) {
135
+ sessions.setOpenOptions(key(uri), clientId, options);
136
+ },
137
+ openDocuments() {
138
+ // From the registry that also answers `isOpenInClient`, which both
139
+ // seeding channels feed, so the two never disagree about an open.
140
+ return sessions.openDocuments();
118
141
  },
119
142
  setApplyEditHandler(handler) {
120
143
  applyEditHandler = handler;
@@ -127,9 +150,11 @@ export function makeStubHydraniumTextDocuments() {
127
150
  staged.push({ uri, text });
128
151
  },
129
152
  seedOpen(uri, text, clientId) {
153
+ sessions.addOpen(key(uri), clientId);
130
154
  docs.set(uri, { uri, version: 1, text, clientId });
131
155
  },
132
156
  seedOpenInLanguageClient(uri) {
157
+ sessions.addOpen(key(uri), LANGUAGE_CLIENT_ID);
133
158
  languageClientOpen.add(uri);
134
159
  },
135
160
  fireClose(uri, clientId) {
@@ -140,6 +165,7 @@ export function makeStubHydraniumTextDocuments() {
140
165
  if (holder && holder.clientId === clientId) {
141
166
  docs.delete(uri);
142
167
  }
168
+ sessions.removeOpen(key(uri), clientId);
143
169
  if (clientId === LANGUAGE_CLIENT_ID) {
144
170
  languageClientOpen.delete(uri);
145
171
  }
@@ -158,7 +184,12 @@ export function makeStubHydraniumTextDocuments() {
158
184
  saveListeners.length = 0;
159
185
  closeListeners.length = 0;
160
186
  applyEditHandler = () => ({ applied: true });
187
+ // Cleared in place rather than replaced: a model service built on the
188
+ // stub subscribed to `onDidCloseSession` once, and a new registry would
189
+ // leave that subscription on one nothing fires.
190
+ sessions.clear();
161
191
  }
162
192
  };
193
+ return stub;
163
194
  }
164
195
  //# sourceMappingURL=stub-hydranium-text-documents.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"stub-hydranium-text-documents.js","sourceRoot":"","sources":["../../src/testing/stub-hydranium-text-documents.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"stub-hydranium-text-documents.js","sourceRoot":"","sources":["../../src/testing/stub-hydranium-text-documents.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAIlF,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AAqHhF,kDAAkD;AAClD,MAAM,UAAU,8BAA8B;IAC3C,MAAM,IAAI,GAAG,IAAI,GAAG,EAAiC,CAAC;IACtD,MAAM,OAAO,GAA4B,EAAE,CAAC;IAC5C,MAAM,KAAK,GAAwC,EAAE,CAAC;IACtD,MAAM,kBAAkB,GAAG,IAAI,GAAG,EAAU,CAAC;IAC7C,MAAM,YAAY,GAAoD,EAAE,CAAC;IACzE,MAAM,MAAM,GAAoC,EAAE,CAAC;IACnD,IAAI,gBAAgB,GAAwE,GAAG,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IACtH,MAAM,aAAa,GAA4E,EAAE,CAAC;IAClG,MAAM,cAAc,GAAwE,EAAE,CAAC;IAC/F,4EAA4E;IAC5E,0EAA0E;IAC1E,0EAA0E;IAC1E,MAAM,QAAQ,GAAG,IAAI,qBAAqB,EAAE,CAAC;IAC7C,MAAM,GAAG,GAAG,CAAC,GAAW,EAAgB,EAAE,CAAC,GAAmB,CAAC;IAE/D,MAAM,IAAI,GAA+B;QACtC,IAAI,OAAO;YACR,OAAO,OAAO,CAAC;QAClB,CAAC;QACD,IAAI,KAAK;YACN,OAAO,KAAK,CAAC;QAChB,CAAC;QACD,IAAI,YAAY;YACb,OAAO,YAAY,CAAC;QACvB,CAAC;QACD,IAAI,MAAM;YACP,OAAO,MAAM,CAAC;QACjB,CAAC;QACD,GAAG,CAAC,GAAG;YACJ,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC1B,OAAO,GAAG,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;QAC9E,CAAC;QACD,OAAO,CAAC,GAAG;YACR,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,IAAI,CAAC,CAAC;QACtC,CAAC;QACD,2BAA2B,CAAC,KAAK,EAAE,QAAQ,GAAG,kBAAkB;YAC7D,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;YAClD,MAAM,IAAI,GAA0B;gBACjC,GAAG,EAAE,KAAK,CAAC,YAAY,CAAC,GAAG;gBAC3B,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC,OAAO;gBACnC,IAAI,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,EAAE;gBACzC,QAAQ;aACV,CAAC;YACF,IAAI,QAAQ,IAAI,IAAI,CAAC,OAAO,IAAI,QAAQ,CAAC,OAAO,EAAE,CAAC;gBAChD,OAAO;YACV,CAAC;YACD,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACvC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtB,CAAC;QACD,kBAAkB,CAAC,GAAG,EAAE,IAAI,EAAE,QAAQ;YACnC,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC/B,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACb,MAAM,IAAI,KAAK,CAAC,YAAY,GAAG,kCAAkC,CAAC,CAAC;YACtE,CAAC;YACD,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,KAAK,IAAI,CAAC;YACvC,oEAAoE;YACpE,qEAAqE;YACrE,gEAAgE;YAChE,MAAM,IAAI,GAA0B,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,GAAG,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC;YAChH,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACpB,OAAO,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;YAC7D,OAAO,IAAI,CAAC,OAAO,CAAC;QACvB,CAAC;QACD,yBAAyB,CAAC,KAAK,EAAE,QAAQ;YACtC,KAAK,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,CAAC,YAAY,CAAC,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC;YACtD,KAAK,MAAM,QAAQ,IAAI,aAAa,CAAC,KAAK,EAAE,EAAE,CAAC;gBAC5C,QAAQ,CAAC,EAAE,QAAQ,EAAE,EAAE,GAAG,EAAE,KAAK,CAAC,YAAY,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC;YACrE,CAAC;QACJ,CAAC;QACD,SAAS,CAAC,QAAQ;YACf,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC7B,OAAO,UAAU,CAAC,MAAM,CAAC,GAAG,EAAE;gBAC3B,MAAM,GAAG,GAAG,aAAa,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;gBAC5C,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC;oBACZ,aAAa,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;gBAChC,CAAC;YACJ,CAAC,CAAC,CAAC;QACN,CAAC;QACD,UAAU,CAAC,QAAQ;YAChB,cAAc,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC9B,OAAO,UAAU,CAAC,MAAM,CAAC,GAAG,EAAE;gBAC3B,MAAM,GAAG,GAAG,cAAc,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;gBAC7C,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC;oBACZ,cAAc,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;gBACjC,CAAC;YACJ,CAAC,CAAC,CAAC;QACN,CAAC;QACD,SAAS,CAAC,GAAG;YACV,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC;QAClC,CAAC;QACD,sBAAsB,CAAC,GAAG;YACvB,OAAO,kBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACtC,CAAC;QACD,iBAAiB,CAAC,GAAG;YAClB,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,kBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACvD,CAAC;QACD,cAAc,CAAC,GAAG,EAAE,QAAQ;YACzB,OAAO,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC;QAChD,CAAC;QACD,eAAe,CAAC,QAAQ;YACrB,QAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC/B,CAAC;QACD,YAAY,CAAC,QAAQ;YAClB,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACpC,OAAO;YACV,CAAC;YACD,IAAI,CAAC;gBACF,KAAK,MAAM,GAAG,IAAI,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;oBAC/C,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;gBACjC,CAAC;YACJ,CAAC;oBAAS,CAAC;gBACR,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;YACjC,CAAC;QACJ,CAAC;QACD,IAAI,iBAAiB;YAClB,OAAO,QAAQ,CAAC,iBAAiB,CAAC;QACrC,CAAC;QACD,WAAW,CAAC,GAAG,EAAE,QAAQ;YACtB,OAAO,QAAQ,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC;QACnD,CAAC;QACD,cAAc,CAAC,GAAG,EAAE,QAAQ,EAAE,OAAO;YAClC,QAAQ,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;QACxD,CAAC;QACD,aAAa;YACV,mEAAmE;YACnE,kEAAkE;YAClE,OAAO,QAAQ,CAAC,aAAa,EAAE,CAAC;QACnC,CAAC;QACD,mBAAmB,CAAC,OAAO;YACxB,gBAAgB,GAAG,OAAO,CAAC;QAC9B,CAAC;QACD,KAAK,CAAC,yBAAyB,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO;YAClD,YAAY,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;YACjE,OAAO,gBAAgB,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QACzC,CAAC;QACD,mBAAmB,CAAC,GAAG,EAAE,IAAI;YAC1B,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9B,CAAC;QACD,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,QAAQ;YACzB,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC;YACrC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;QACtD,CAAC;QACD,wBAAwB,CAAC,GAAG;YACzB,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,kBAAkB,CAAC,CAAC;YAC/C,kBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC/B,CAAC;QACD,SAAS,CAAC,GAAG,EAAE,QAAQ;YACpB,qEAAqE;YACrE,kEAAkE;YAClE,kEAAkE;YAClE,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC7B,IAAI,MAAM,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;gBAC1C,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;YACD,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC;YACxC,IAAI,QAAQ,KAAK,kBAAkB,EAAE,CAAC;gBACnC,kBAAkB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAClC,CAAC;YACD,MAAM,KAAK,GAAG,EAAE,QAAQ,EAAE,EAAE,GAAG,EAA6B,EAAE,QAAQ,EAAiD,CAAC;YACxH,KAAK,MAAM,QAAQ,IAAI,cAAc,CAAC,KAAK,EAAE,EAAE,CAAC;gBAC7C,QAAQ,CAAC,KAAK,CAAC,CAAC;YACnB,CAAC;QACJ,CAAC;QACD,KAAK;YACF,IAAI,CAAC,KAAK,EAAE,CAAC;YACb,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;YACnB,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;YACjB,kBAAkB,CAAC,KAAK,EAAE,CAAC;YAC3B,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC;YACxB,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;YAClB,aAAa,CAAC,MAAM,GAAG,CAAC,CAAC;YACzB,cAAc,CAAC,MAAM,GAAG,CAAC,CAAC;YAC1B,gBAAgB,GAAG,GAAG,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;YAC7C,sEAAsE;YACtE,wEAAwE;YACxE,gDAAgD;YAChD,QAAQ,CAAC,KAAK,EAAE,CAAC;QACpB,CAAC;KACH,CAAC;IACF,OAAO,IAAI,CAAC;AACf,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/core",
3
- "version": "1.0.0-next.115",
3
+ "version": "1.0.0-next.117",
4
4
  "description": "Foundational runtime of the hydranium framework: AST coordination layer (services, integrity, AST extension, serialization, multi-client documents) plus the LSP textual head at the /lsp subpath. Consumed by protocol-head packages (@hydranium/data-server, @hydranium/glsp-server).",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -104,8 +104,8 @@
104
104
  "diff": "^5.2.0"
105
105
  },
106
106
  "devDependencies": {
107
- "@hydranium/langium": "1.0.0-next.115",
108
- "@hydranium/protocol": "1.0.0-next.115",
107
+ "@hydranium/langium": "1.0.0-next.117",
108
+ "@hydranium/protocol": "1.0.0-next.117",
109
109
  "@playwright/test": "^1.40.0",
110
110
  "@types/diff": "^5.2.0",
111
111
  "rimraf": "^5.0.0",
@@ -117,8 +117,8 @@
117
117
  "vscode-languageserver-types": "^3.17.5"
118
118
  },
119
119
  "peerDependencies": {
120
- "@hydranium/langium": "1.0.0-next.115",
121
- "@hydranium/protocol": "1.0.0-next.115",
120
+ "@hydranium/langium": "1.0.0-next.117",
121
+ "@hydranium/protocol": "1.0.0-next.117",
122
122
  "@playwright/test": "^1.40.0",
123
123
  "vscode-jsonrpc": "9.0.1",
124
124
  "vscode-languageserver": "~10.0.1",
@@ -8,6 +8,7 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import {
11
+ type CanonicalUri,
11
12
  type CloseModelArgs,
12
13
  type Tracer,
13
14
  type TransferSavedEvent,
@@ -178,7 +179,18 @@ export type AstDocumentSavedEvent<TAst extends AstNode, TDiagnostic extends AstD
178
179
  >;
179
180
 
180
181
  /** Construction options for {@link AstDocumentManager}. */
181
- export type AstDocumentManagerOptions = LogNameOptions;
182
+ export interface AstDocumentManagerOptions extends LogNameOptions {
183
+ /**
184
+ * Skip a queued save of a URI when a newer save of it is queued behind. The
185
+ * newer save took its text later, so disk ends the same with fewer writes.
186
+ * A skipped save announces nothing and takes the newer save's outcome: it
187
+ * resolves when that one lands and rejects with its error when it fails,
188
+ * since disk then holds neither text. Defaults to `false`: every save
189
+ * writes. The framework binds the manager without options, so turning this
190
+ * on means rebinding `AstDocumentManager` with them.
191
+ */
192
+ readonly coalesceSaves?: boolean;
193
+ }
182
194
 
183
195
  /**
184
196
  * The document slot the model server and the integrity service talk to:
@@ -201,8 +213,29 @@ export interface AstDocumentManager<TAst extends AstNode, TDiagnostic extends As
201
213
 
202
214
  /** Apply `text` as `clientId`'s edit. Resolves to the resulting text-document version. */
203
215
  update(uri: string, text: string, clientId: string): Promise<number>;
216
+ /**
217
+ * Write the document's current text to disk through the URI's disk queue.
218
+ * The text is taken when this is called, so saves of one URI land in the
219
+ * order they were called.
220
+ */
204
221
  save(uri: string, clientId: string): Promise<void>;
205
222
 
223
+ /**
224
+ * Run `task` once every disk task already queued for `uri` has settled, and
225
+ * before any queued after it. Every server-side disk access of a file that
226
+ * must not interleave with its saves goes through here: two writes of one
227
+ * file that bypass it can land in either order, and the older text then
228
+ * stays on disk. Tasks of different URIs run in parallel. A task that has
229
+ * nothing to do still resolves once the tasks before it have settled.
230
+ *
231
+ * A task must not await a build, nor a save, another disk task or an open
232
+ * of a document no client has open, all of the same URI: a build's
233
+ * integrity repair queues its own write here and the build waits for it,
234
+ * and such an open reads the file here, so a task waiting on either queued
235
+ * behind it never resolves, which wedges the URI's queue for good.
236
+ */
237
+ queueDiskTask<T>(uri: string, task: () => Promise<T>): Promise<T>;
238
+
206
239
  onUpdate(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable;
207
240
  onSave(uri: string, listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void | Promise<void>): Disposable;
208
241
  onClientClosed(uri: string, clientId: string, listener: () => void): Disposable;
@@ -251,6 +284,17 @@ export class DefaultAstDocumentManager<
251
284
  > implements AstDocumentManager<TAst, TDiagnostic> {
252
285
  protected lastUpdate?: UpdateInfo;
253
286
 
287
+ /**
288
+ * Per canonical URI, the settling of the last disk task queued for it; the
289
+ * next task chains behind it. An entry leaves once its URI's queue is idle.
290
+ * Kept outside the workspace lock, whose `write` cancels the write before
291
+ * it: a save cancelled that way reports success for text it never wrote.
292
+ */
293
+ protected readonly diskQueues = new Map<CanonicalUri, Promise<void>>();
294
+ /** Per canonical URI, the newest save queued for it, which {@link coalesceSaves} lets older queued saves defer to. */
295
+ protected readonly newestSaves = new Map<CanonicalUri, Promise<void>>();
296
+ protected readonly coalesceSaves: boolean;
297
+
254
298
  protected readonly textDocuments: HydraniumTextDocuments<TextDocument>;
255
299
  protected readonly fileSystemProvider: WritableFileSystemProvider;
256
300
  protected readonly langiumDocs: LangiumDocuments;
@@ -268,12 +312,19 @@ export class DefaultAstDocumentManager<
268
312
  this.documentBuilder = services.workspace.DocumentBuilder;
269
313
  this.uriPolicy = services.workspace.DocumentUriPolicy;
270
314
  this.tracer = services.Tracer.for(options.logName ?? 'AstDocumentManager').trace('instantiated');
315
+ this.coalesceSaves = options.coalesceSaves ?? false;
271
316
  this.textDocuments.onDidOpen(event =>
272
317
  this.open({ clientId: event.clientId, uri: event.document.uri, languageId: event.document.languageId })
273
318
  );
274
319
  this.textDocuments.onDidClose(event => this.close({ clientId: event.clientId, uri: event.document.uri }));
275
320
  this.documentBuilder.onUpdate((changed, deleted) => {
276
321
  this.lastUpdate = { changed, deleted };
322
+ // The only place a deletion is observable: `DocumentBuilder.update`
323
+ // removes a deleted document before it builds anything, so no phase
324
+ // listener ever sees it.
325
+ for (const uri of deleted) {
326
+ this.textDocuments.notifyDocumentDeleted(uri.toString());
327
+ }
277
328
  });
278
329
  // Content transitions for CLOSED documents bypass the text store: the
279
330
  // last-close revert and watched-file changes rebuild from disk into a
@@ -463,6 +514,12 @@ export class DefaultAstDocumentManager<
463
514
  * callers don't need to pass it — the manager owns content and version
464
515
  * sequencing.
465
516
  *
517
+ * The text is taken synchronously, before the save waits in the URI's disk
518
+ * queue; the compare and the write run in the queue. Taken later, a save
519
+ * that waits behind another writes whatever the store holds when its turn
520
+ * comes, edits made after the call included, and announces that text under
521
+ * its own client id.
522
+ *
466
523
  * **The write is skipped when the file already holds that text; the
467
524
  * notification is not.** A save announces that the content is on disk, which
468
525
  * is true either way — so a subscriber clearing a dirty marker or reacting to
@@ -481,15 +538,52 @@ export class DefaultAstDocumentManager<
481
538
  throw new Error(`Document ${uri} hasn't been opened for saving yet`);
482
539
  }
483
540
  const text = document.getText();
484
- if (!(await this.matchesDisk(canonical, text))) {
485
- await this.tracer.with(canonical).time(
541
+ let supersededBy: Promise<void> | undefined;
542
+ const saved: Promise<void> = this.queueDiskTask(canonical, async () => {
543
+ const newest = this.newestSaves.get(canonical);
544
+ if (this.coalesceSaves && newest !== saved) {
545
+ supersededBy = newest;
546
+ return;
547
+ }
548
+ await this.writeSave(canonical, text, clientId);
549
+ })
550
+ .then(() => supersededBy)
551
+ .finally(() => {
552
+ if (this.newestSaves.get(canonical) === saved) {
553
+ this.newestSaves.delete(canonical);
554
+ }
555
+ });
556
+ this.newestSaves.set(canonical, saved);
557
+ return saved;
558
+ }
559
+
560
+ /** Write `text` to `uri` unless the file already holds it, then announce the save. Runs inside the URI's disk queue. */
561
+ protected async writeSave(uri: CanonicalUri, text: string, clientId: string): Promise<void> {
562
+ if (!(await this.matchesDisk(uri, text))) {
563
+ await this.tracer.with(uri).time(
486
564
  `Write file (${text.length} bytes, from ${clientId})`,
487
565
  // Async write so a slow disk does not block the event loop for the duration of the fs call.
488
- () => this.fileSystemProvider.writeFile(UriUtils.toUri(canonical), text),
566
+ () => this.fileSystemProvider.writeFile(UriUtils.toUri(uri), text),
489
567
  'debug'
490
568
  );
491
569
  }
492
- this.textDocuments.notifyDidSaveTextDocument({ textDocument: TextDocumentIdentifier.create(canonical), text }, clientId);
570
+ this.textDocuments.notifyDidSaveTextDocument({ textDocument: TextDocumentIdentifier.create(uri), text }, clientId);
571
+ }
572
+
573
+ queueDiskTask<T>(uri: string, task: () => Promise<T>): Promise<T> {
574
+ const key = this.uriPolicy.canonicalUri(uri);
575
+ const result = (this.diskQueues.get(key) ?? Promise.resolve()).then(task);
576
+ const settled = result.then(
577
+ () => undefined,
578
+ () => undefined
579
+ );
580
+ this.diskQueues.set(key, settled);
581
+ void settled.then(() => {
582
+ if (this.diskQueues.get(key) === settled) {
583
+ this.diskQueues.delete(key);
584
+ }
585
+ });
586
+ return result;
493
587
  }
494
588
 
495
589
  /**
@@ -534,7 +628,14 @@ export class DefaultAstDocumentManager<
534
628
  version = 0,
535
629
  text?: string
536
630
  ): Promise<TextDocumentItem> {
537
- return { uri, languageId, version, text: text ?? (await this.fileSystemProvider.readFile(UriUtils.toUri(uri))) };
631
+ // Read in the disk queue: a read beside a queued save of the file returns
632
+ // the text from before it, and the document then opens on that.
633
+ return {
634
+ uri,
635
+ languageId,
636
+ version,
637
+ text: text ?? (await this.queueDiskTask(uri, () => this.fileSystemProvider.readFile(UriUtils.toUri(uri))))
638
+ };
538
639
  }
539
640
 
540
641
  /**
@@ -0,0 +1,54 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, 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
+ /**
11
+ * Thrown by every call on a client session after it ended, and by an open that
12
+ * was still in flight when its session ended.
13
+ *
14
+ * The message names the client id, so it is addressed to whoever composes the
15
+ * system rather than to an end user.
16
+ */
17
+ export class SessionClosedError extends Error {
18
+ constructor(readonly clientId: string) {
19
+ super(`Client session ${clientId} is closed`);
20
+ this.name = 'SessionClosedError';
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Thrown when a client session writes a document it does not have open.
26
+ *
27
+ * A session writes only what it has open, so this is the answer both to a write
28
+ * that never opened and to one whose open was closed underneath it — by the
29
+ * session itself, or by the document being deleted.
30
+ */
31
+ export class DocumentNotOpenError extends Error {
32
+ constructor(
33
+ readonly uri: string,
34
+ readonly clientId: string
35
+ ) {
36
+ super(`Document ${uri} is not open in client session ${clientId}`);
37
+ this.name = 'DocumentNotOpenError';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Thrown when a client session is started under an id that is already live in
43
+ * the process, or that the framework reserves for itself.
44
+ *
45
+ * Ids are unique process-wide because the id is also the author label on every
46
+ * version and the key a client recognises its own echoes by; two participants
47
+ * sharing one would each take the other's writes for their own.
48
+ */
49
+ export class DuplicateClientIdError extends Error {
50
+ constructor(readonly clientId: string) {
51
+ super(`Client id ${clientId} is already in use`);
52
+ this.name = 'DuplicateClientIdError';
53
+ }
54
+ }
@@ -0,0 +1,211 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, 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 CanonicalUri, FRAMEWORK_CLIENT_IDS } from '@hydranium/protocol';
11
+ import { Emitter, type Event } from 'vscode-languageserver';
12
+ import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
13
+ import { DuplicateClientIdError, SessionClosedError } from './client-session-errors.js';
14
+
15
+ /**
16
+ * What a client session states about one of its opens, kept per
17
+ * `(session, uri)` for as long as that open lasts.
18
+ *
19
+ * Any object type: an adopter declares the fields its own open path reads and
20
+ * passes that type as a session's `TOpenOptions`. Stored per open rather than
21
+ * per document, so two sessions opening one document with different options
22
+ * each keep their own.
23
+ */
24
+ export type OpenOptions = object;
25
+
26
+ /**
27
+ * `'closing'` covers the span in which a session's opens are being closed. The
28
+ * id is still taken then, and the session can open nothing more, so an open
29
+ * issued from a close listener cannot outlive the session that made it.
30
+ */
31
+ export type ClientSessionState = 'live' | 'closing';
32
+
33
+ /** Delivered by {@link ClientSessionRegistry.onDidCloseSession}. */
34
+ export interface ClientSessionClosedEvent {
35
+ readonly clientId: string;
36
+ }
37
+
38
+ /**
39
+ * The ids no client session may be started under: the wire's well-known ids and
40
+ * the integrity author. Each names a framework participant rather than a
41
+ * client, and a session holding one would have its writes read as that
42
+ * participant's.
43
+ */
44
+ export const RESERVED_CLIENT_IDS: readonly string[] = [...FRAMEWORK_CLIENT_IDS, INTEGRITY_CLIENT_ID];
45
+
46
+ /**
47
+ * Which client has which document open, and which client ids are registered
48
+ * sessions — the open facts every head shares, composed by
49
+ * `HydraniumTextDocuments`.
50
+ *
51
+ * Opens are recorded for every client id, registered or not. An id that was
52
+ * never registered is a client that predates sessions and writes through the
53
+ * flat `clientId` calls; its opens are tracked exactly as a session's are, and
54
+ * only the session table ignores it. Requiring registration before an open
55
+ * would refuse every such client.
56
+ *
57
+ * One open per `(client, uri)`, with no reference count: a repeat open changes
58
+ * nothing, and one close ends it.
59
+ *
60
+ * Pure bookkeeping. Closing a document is the store's job, because a close has
61
+ * to reach the text store and its listeners; the store asks this class what to
62
+ * close and records each close here.
63
+ */
64
+ export class ClientSessionRegistry {
65
+ protected readonly sessions = new Map<string, ClientSessionState>();
66
+ protected readonly clientsByUri = new Map<CanonicalUri, Set<string>>();
67
+ /** Each client's opens, with the options it opened them with. */
68
+ protected readonly opensByClient = new Map<string, Map<CanonicalUri, OpenOptions | undefined>>();
69
+ protected readonly reservedIds: ReadonlySet<string> = new Set(RESERVED_CLIENT_IDS);
70
+ protected readonly sessionClosedEmitter = new Emitter<ClientSessionClosedEvent>();
71
+
72
+ /** Fires once a session has been removed from the table, after all its opens closed. */
73
+ get onDidCloseSession(): Event<ClientSessionClosedEvent> {
74
+ return this.sessionClosedEmitter.event;
75
+ }
76
+
77
+ /**
78
+ * Enter `clientId` in the session table.
79
+ *
80
+ * Throws {@link DuplicateClientIdError} when the id is live anywhere in the
81
+ * process: reserved, registered (including by a session that is still
82
+ * closing), or holding opens as a client that is not a session. The last
83
+ * matters because the id is the author label and the echo key, and a
84
+ * session sharing one with another client would take that client's writes
85
+ * for its own.
86
+ */
87
+ register(clientId: string): void {
88
+ if (this.reservedIds.has(clientId) || this.sessions.has(clientId) || this.opensByClient.has(clientId)) {
89
+ throw new DuplicateClientIdError(clientId);
90
+ }
91
+ this.sessions.set(clientId, 'live');
92
+ }
93
+
94
+ /** Whether `clientId` is in the session table, live or closing. */
95
+ isRegistered(clientId: string): boolean {
96
+ return this.sessions.has(clientId);
97
+ }
98
+
99
+ /**
100
+ * Mark `clientId` as closing and return every URI it has open, as a copy the
101
+ * caller can close while this registry changes underneath it.
102
+ */
103
+ beginClose(clientId: string): CanonicalUri[] {
104
+ if (this.sessions.has(clientId)) {
105
+ this.sessions.set(clientId, 'closing');
106
+ }
107
+ return this.opensOf(clientId);
108
+ }
109
+
110
+ /** Remove `clientId` from the session table and announce it. A no-op for an id that is not registered. */
111
+ unregister(clientId: string): void {
112
+ if (!this.sessions.delete(clientId)) {
113
+ return;
114
+ }
115
+ this.sessionClosedEmitter.fire(Object.freeze({ clientId }));
116
+ }
117
+
118
+ /**
119
+ * Record that `clientId` opened `uri`, with no options. Returns `false` when
120
+ * it already had it open, in which case nothing changes.
121
+ *
122
+ * Throws {@link SessionClosedError} for a session that is closing.
123
+ */
124
+ addOpen(uri: CanonicalUri, clientId: string): boolean {
125
+ if (this.sessions.get(clientId) === 'closing') {
126
+ throw new SessionClosedError(clientId);
127
+ }
128
+ let opens = this.opensByClient.get(clientId);
129
+ if (opens?.has(uri)) {
130
+ return false;
131
+ }
132
+ if (!opens) {
133
+ opens = new Map();
134
+ this.opensByClient.set(clientId, opens);
135
+ }
136
+ opens.set(uri, undefined);
137
+ let clients = this.clientsByUri.get(uri);
138
+ if (!clients) {
139
+ clients = new Set();
140
+ this.clientsByUri.set(uri, clients);
141
+ }
142
+ clients.add(clientId);
143
+ return true;
144
+ }
145
+
146
+ /** Record that `clientId` closed `uri`. Returns `false` when it did not have it open. */
147
+ removeOpen(uri: CanonicalUri, clientId: string): boolean {
148
+ const opens = this.opensByClient.get(clientId);
149
+ if (!opens?.delete(uri)) {
150
+ return false;
151
+ }
152
+ if (opens.size === 0) {
153
+ this.opensByClient.delete(clientId);
154
+ }
155
+ const clients = this.clientsByUri.get(uri);
156
+ clients?.delete(clientId);
157
+ if (!clients?.size) {
158
+ this.clientsByUri.delete(uri);
159
+ }
160
+ return true;
161
+ }
162
+
163
+ /** Whether `clientId` has `uri` open. */
164
+ isOpenIn(uri: CanonicalUri, clientId: string): boolean {
165
+ return !!this.clientsByUri.get(uri)?.has(clientId);
166
+ }
167
+
168
+ /** Whether any client has `uri` open. */
169
+ isOpen(uri: CanonicalUri): boolean {
170
+ return this.clientsByUri.has(uri);
171
+ }
172
+
173
+ /** The clients that have `uri` open, in the order they opened it. */
174
+ clientsOf(uri: CanonicalUri): string[] {
175
+ return [...(this.clientsByUri.get(uri) ?? [])];
176
+ }
177
+
178
+ /** The URIs `clientId` has open, in the order it opened them. */
179
+ opensOf(clientId: string): CanonicalUri[] {
180
+ return [...(this.opensByClient.get(clientId)?.keys() ?? [])];
181
+ }
182
+
183
+ /** Every document open in at least one client, with the clients that have it open. */
184
+ openDocuments(): Array<{ readonly uri: CanonicalUri; readonly clients: string[] }> {
185
+ return [...this.clientsByUri].map(([uri, clients]) => ({ uri, clients: [...clients] }));
186
+ }
187
+
188
+ /** The options `clientId` opened `uri` with, or `undefined` when it gave none or does not have it open. */
189
+ openOptions(uri: CanonicalUri, clientId: string): OpenOptions | undefined {
190
+ return this.opensByClient.get(clientId)?.get(uri);
191
+ }
192
+
193
+ /** Replace the options of an existing open. Does nothing when `clientId` does not have `uri` open. */
194
+ setOpenOptions(uri: CanonicalUri, clientId: string, options: OpenOptions | undefined): void {
195
+ const opens = this.opensByClient.get(clientId);
196
+ if (opens?.has(uri)) {
197
+ opens.set(uri, options);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Forget every session and open without announcing anything. Subscriptions
203
+ * stay, so a listener registered before the clear hears the sessions that
204
+ * end after it.
205
+ */
206
+ clear(): void {
207
+ this.sessions.clear();
208
+ this.clientsByUri.clear();
209
+ this.opensByClient.clear();
210
+ }
211
+ }