@hydranium/data-server 1.0.0-next.10

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/lib/data-server.d.ts +614 -0
  4. package/lib/data-server.d.ts.map +1 -0
  5. package/lib/data-server.js +1003 -0
  6. package/lib/data-server.js.map +1 -0
  7. package/lib/default-diagnostics.browser.d.ts +18 -0
  8. package/lib/default-diagnostics.browser.d.ts.map +1 -0
  9. package/lib/default-diagnostics.browser.js +36 -0
  10. package/lib/default-diagnostics.browser.js.map +1 -0
  11. package/lib/default-diagnostics.d.ts +36 -0
  12. package/lib/default-diagnostics.d.ts.map +1 -0
  13. package/lib/default-diagnostics.js +38 -0
  14. package/lib/default-diagnostics.js.map +1 -0
  15. package/lib/diagnostics-provider.d.ts +65 -0
  16. package/lib/diagnostics-provider.d.ts.map +1 -0
  17. package/lib/diagnostics-provider.js +10 -0
  18. package/lib/diagnostics-provider.js.map +1 -0
  19. package/lib/index.d.ts +11 -0
  20. package/lib/index.d.ts.map +1 -0
  21. package/lib/index.js +15 -0
  22. package/lib/index.js.map +1 -0
  23. package/lib/node/index.d.ts +10 -0
  24. package/lib/node/index.d.ts.map +1 -0
  25. package/lib/node/index.js +16 -0
  26. package/lib/node/index.js.map +1 -0
  27. package/lib/node/node-diagnostics-provider.d.ts +18 -0
  28. package/lib/node/node-diagnostics-provider.d.ts.map +1 -0
  29. package/lib/node/node-diagnostics-provider.js +68 -0
  30. package/lib/node/node-diagnostics-provider.js.map +1 -0
  31. package/lib/testing/data-server-harness.d.ts +85 -0
  32. package/lib/testing/data-server-harness.d.ts.map +1 -0
  33. package/lib/testing/data-server-harness.js +47 -0
  34. package/lib/testing/data-server-harness.js.map +1 -0
  35. package/lib/testing/index.d.ts +11 -0
  36. package/lib/testing/index.d.ts.map +1 -0
  37. package/lib/testing/index.js +10 -0
  38. package/lib/testing/index.js.map +1 -0
  39. package/package.json +93 -0
  40. package/src/data-server.ts +1282 -0
  41. package/src/default-diagnostics.browser.ts +41 -0
  42. package/src/default-diagnostics.ts +40 -0
  43. package/src/diagnostics-provider.ts +70 -0
  44. package/src/index.ts +15 -0
  45. package/src/node/index.ts +17 -0
  46. package/src/node/node-diagnostics-provider.ts +82 -0
  47. package/src/testing/data-server-harness.ts +147 -0
  48. package/src/testing/index.ts +19 -0
@@ -0,0 +1,1282 @@
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 {
11
+ createRpcProxy,
12
+ DisposableCollection,
13
+ isDocumentSource,
14
+ isElementSource,
15
+ isSyntheticSource,
16
+ ReferenceSource,
17
+ type CloseModelArgs,
18
+ type Disposable,
19
+ type ElementSource,
20
+ type FindNextNameArgs,
21
+ type LatencyCollector,
22
+ type LatencyReport,
23
+ type OpenModelArgs,
24
+ type Project,
25
+ type ReferenceCandidate,
26
+ type ReferenceContext,
27
+ type ReferenceRequest,
28
+ type ReferenceTarget,
29
+ type Tracer,
30
+ type TransferDiagnostic,
31
+ type TransferDocument,
32
+ type TransferElement
33
+ } from '@hydranium/protocol';
34
+ import {
35
+ DATA_SERVER_DIAGNOSTICS_METHODS,
36
+ DATA_SERVER_PROTOCOL_METHODS,
37
+ DATA_SERVER_WIRE_PREFIX,
38
+ type DataClientProtocol,
39
+ type DataServerDiagnosticsProtocol,
40
+ type DataServerProtocol,
41
+ type DumpServerStateArgs,
42
+ type StartProfilingArgs,
43
+ type StopProfilingArgs,
44
+ type WriteServerHeapSnapshotArgs,
45
+ type GetModelDocumentArgs,
46
+ type GetProjectForUriArgs,
47
+ type TransferSaveDocumentArgs,
48
+ type WatchModelDocumentArgs,
49
+ type TransferDocumentSavedEvent,
50
+ type TransferDocumentUpdatedEvent,
51
+ type TransferUpdateDocumentArgs
52
+ } from '@hydranium/protocol/data';
53
+ import { REVERT_ON_CLOSE_CLIENT_ID, UNKNOWN_CLIENT_ID } from '@hydranium/core';
54
+ import { defaultDataServerDiagnostics } from './default-diagnostics.js';
55
+ import type { DataServerDiagnosticsProvider, DataServerProfileCapture } from './diagnostics-provider.js';
56
+ import type {
57
+ ClientTextDocumentChangeEvent,
58
+ HydraniumLanguageServices,
59
+ LogNameOptions,
60
+ ModelService,
61
+ ProjectChangeEvent,
62
+ ServerSharedServices,
63
+ TransferEncoder
64
+ } from '@hydranium/core';
65
+ import type { TextDocument } from 'vscode-languageserver-textdocument';
66
+ import { type AstNode, DocumentState, type LangiumDocument, UriUtils, type URI } from '@hydranium/langium';
67
+ import type { CancellationToken, MessageConnection } from 'vscode-jsonrpc';
68
+
69
+ /**
70
+ * Domain separator between text and diagnostics inputs of
71
+ * {@link DataServer.computeDocumentFingerprint}'s hash. A single NUL byte is
72
+ * sufficient: the JSON-stringified diagnostics never contain a NUL byte, so
73
+ * the boundary is unambiguous.
74
+ */
75
+ const FINGERPRINT_SEPARATOR = '\0';
76
+
77
+ /**
78
+ * Portable, non-cryptographic 64-bit fingerprint hash (cyrb53), returning a
79
+ * 16-char hex digest.
80
+ *
81
+ * **A cryptographic hash via `node:crypto` is the wrong trade**: the
82
+ * fingerprint only needs a stable signal that `(text, diagnostics)` changed
83
+ * between emissions, and a `node:*` import would cost
84
+ * `@hydranium/data-server` its browser-portability. Parts are fed
85
+ * incrementally, char by char, so multi-MB document text is never
86
+ * concatenated into one string.
87
+ */
88
+ function fingerprintHash(parts: readonly string[]): string {
89
+ let h1 = 0xdeadbeef;
90
+ let h2 = 0x41c6ce57;
91
+ for (const part of parts) {
92
+ for (let i = 0; i < part.length; i++) {
93
+ const ch = part.charCodeAt(i);
94
+ h1 = Math.imul(h1 ^ ch, 2654435761);
95
+ h2 = Math.imul(h2 ^ ch, 1597334677);
96
+ }
97
+ }
98
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
99
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
100
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
101
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
102
+ const high = (h2 >>> 0).toString(16).padStart(8, '0');
103
+ const low = (h1 >>> 0).toString(16).padStart(8, '0');
104
+ return high + low;
105
+ }
106
+
107
+ /**
108
+ * Which observable state {@link DataServer.computeDocumentFingerprint} hashes to
109
+ * de-dup `onDocumentUpdated` emissions.
110
+ *
111
+ * - `'transfer-document'` (default) — the client-visible transfer root
112
+ * (`TransferEncoder.toTransferDocument`) plus diagnostics. This is exactly the
113
+ * payload `envelope` sends, so it changes whenever — and only when — the
114
+ * client-visible model changes, INCLUDING cross-document derived state
115
+ * folded in on a cascade rebuild that leaves the document's own text
116
+ * untouched. Safe for any adopter whose AST extensions fold derived state
117
+ * into the transfer projection (the framework norm).
118
+ * - `'text-diagnostics'` — the cheaper `getText()` + diagnostics hash. An
119
+ * opt-DOWN for adopters that fold no derived state and want to avoid the
120
+ * transfer-encode per phase event.
121
+ */
122
+ export type FingerprintStrategy = 'transfer-document' | 'text-diagnostics';
123
+
124
+ /** Hash a document's raw text + diagnostics — the `'text-diagnostics'` strategy. */
125
+ function textDiagnosticsFingerprint(document: LangiumDocument): string {
126
+ return fingerprintHash([document.textDocument.getText(), FINGERPRINT_SEPARATOR, JSON.stringify(document.diagnostics ?? [])]);
127
+ }
128
+
129
+ /** Hash an encoded transfer document's root + diagnostics — the `'transfer-document'` strategy. */
130
+ function transferDocumentFingerprint(transferDocument: Pick<TransferDocument<TransferElement, unknown>, 'root' | 'diagnostics'>): string {
131
+ // null, not undefined: `JSON.stringify(undefined)` yields undefined rather
132
+ // than a string, putting a non-string into the hash inputs.
133
+ return fingerprintHash([
134
+ JSON.stringify(transferDocument.root ?? null),
135
+ FINGERPRINT_SEPARATOR,
136
+ JSON.stringify(transferDocument.diagnostics)
137
+ ]);
138
+ }
139
+ /**
140
+ * `logAfterMs` threshold passed to {@link Tracer.time} around
141
+ * {@link DataServer.computeDocumentFingerprint}. Below this the timing
142
+ * pair is suppressed so steady-state edits don't flood the log; above it
143
+ * the pathological cases (multi-MB files, massive diagnostic batches)
144
+ * surface naturally via the framework's standard timing pattern.
145
+ */
146
+ const FINGERPRINT_LOG_AFTER_MS = 5;
147
+
148
+ /**
149
+ * Construction-time options for {@link DataServer}. Every field is
150
+ * optional; values not supplied fall back to {@link DataServer.DEFAULT_OPTIONS}.
151
+ */
152
+ export interface DataServerOptions extends LogNameOptions {
153
+ /**
154
+ * Document phase at which the data-server fires subscription events
155
+ * (`DataClientProtocol.onDocumentUpdated`).
156
+ *
157
+ * **Subscription dispatch only** — distinct from the synchronous RPC
158
+ * response phase. The lifecycle reads/writes (`getModelDocument` /
159
+ * `updateModelDocument` / `saveModelDocument`) settle at the
160
+ * integrity-settled landmark (`IntegrityService.SettledState`); a
161
+ * read additionally upgrades to `Validated` per call when
162
+ * {@link GetModelDocumentArgs.includeDiagnostics} is set. This option
163
+ * controls only the async notification phase.
164
+ *
165
+ * Default: `DocumentState.Validated` — validation is the last builder
166
+ * phase and produces the full diagnostic set, so subscription events fired
167
+ * at validation surface the complete diagnostic picture.
168
+ *
169
+ * **Trade-off.** Validation can take seconds on a real workspace. Adopters
170
+ * that publish validation diagnostics through a separate channel —
171
+ * typically LSP `publishDiagnostics` — can fire subscription events earlier
172
+ * (e.g. `IndexedReferences`) since clients observe validation diagnostics
173
+ * asynchronously via that channel regardless of when the subscription
174
+ * event lands.
175
+ */
176
+ readonly subscriptionPhase?: DocumentState;
177
+
178
+ /**
179
+ * Runtime-specific implementation of the four diagnostics methods that need
180
+ * a process to inspect — heap snapshot, profile capture, pod memory, server
181
+ * state.
182
+ *
183
+ * **Defaulted per platform, so most hosts pass nothing.** On Node the default
184
+ * is the real implementation; in a browser bundle `package.json`'s `browser`
185
+ * field selects a twin whose methods reject, because there is no process to
186
+ * inspect. Supply this only to override — a custom implementation, or to say
187
+ * explicitly at the call site which one you mean.
188
+ *
189
+ * It is injected rather than reached for because a static
190
+ * `@hydranium/core/node` import on this package's portable entry pulls
191
+ * `node:fs`, `node:v8` and `node:perf_hooks` into any browser build, over
192
+ * methods a browser cannot call anyway — which is what previously made the
193
+ * head unbundleable.
194
+ */
195
+ readonly diagnostics?: DataServerDiagnosticsProvider;
196
+
197
+ /**
198
+ * Which observable state the `onDocumentUpdated` de-dup fingerprint hashes.
199
+ * Defaults to `'transfer-document'` (the client-visible payload, derived
200
+ * state included). See {@link FingerprintStrategy}.
201
+ */
202
+ readonly fingerprintStrategy?: FingerprintStrategy;
203
+
204
+ /**
205
+ * Wire-method namespace for both inbound request handlers and the
206
+ * outbound notification client proxy. Defaults to
207
+ * {@link DATA_SERVER_WIRE_PREFIX} (`'data-server/'`).
208
+ *
209
+ * Adopters that combine the data-server head with their own protocol
210
+ * head on one connection pass their own namespace, so the full wire
211
+ * surface is partitioned under one prefix instead of two, the way LSP
212
+ * itself partitions `textDocument/*` and `workspace/*`. The client side
213
+ * (the `methodNamespace` option of its client `createRpcProxy`) MUST
214
+ * agree.
215
+ */
216
+ readonly methodNamespace?: string;
217
+
218
+ /**
219
+ * Additional protocol-method names to register as request handlers on
220
+ * the same connection alongside the framework's
221
+ * {@link DATA_SERVER_PROTOCOL_METHODS}. Used by `DataServer` subclasses
222
+ * that implement an adopter-specific protocol — the subclass implements
223
+ * the adopter methods on `this`, the names go here, and the
224
+ * constructor's single `createRpcProxy` call (binding `this` as its
225
+ * `localTarget`) registers framework + adopter handlers under one
226
+ * namespace.
227
+ *
228
+ * Throws at construction time if any name overlaps with a built-in
229
+ * framework method (would cause vscode-jsonrpc duplicate-handler
230
+ * errors at registration). Notification-shaped (`on*`-prefixed) names
231
+ * register as notification handlers; everything else as request
232
+ * handlers — same `on*`-prefix heuristic the framework's
233
+ * `bindRpcMethods` uses for `DataClientProtocol`.
234
+ */
235
+ readonly additionalMethods?: readonly string[];
236
+
237
+ /**
238
+ * Framework method names to NOT register on the wire. Used by adopters
239
+ * that expose renamed domain-vocabulary equivalents of framework
240
+ * methods and want to keep the wire surface free of the unused
241
+ * framework names.
242
+ *
243
+ * Each name is filtered out of the combined `[framework + additional]`
244
+ * set before the handlers are bound. The adopter typically pairs an
245
+ * `excludedMethods` entry with an `additionalMethods` entry naming the
246
+ * renamed equivalent on the same class — the subclass's renamed method
247
+ * usually delegates to the inherited framework implementation via
248
+ * `super.X()` so the behaviour stays identical.
249
+ *
250
+ * Names that are neither in `DATA_SERVER_PROTOCOL_METHODS` nor in
251
+ * `additionalMethods` are simply no-ops — listing an unknown name does
252
+ * not throw. The overlap-detection between `additionalMethods` and
253
+ * built-in framework methods still fires for non-excluded names.
254
+ */
255
+ readonly excludedMethods?: readonly string[];
256
+
257
+ /**
258
+ * When supplied, every inbound data-server RPC is timed into this collector
259
+ * (via the `createRpcProxy` binding) and exposed through
260
+ * {@link DataServerDiagnosticsProtocol.getLatency}. A head that also runs an
261
+ * LSP connection can pass the SAME collector to
262
+ * `instrumentLspConnection(connection, latency)` so one report covers both
263
+ * heads. Absent by default (no timing overhead).
264
+ */
265
+ readonly latency?: LatencyCollector;
266
+ }
267
+
268
+ /** Fully-resolved variant — every field set, used internally after merging defaults. */
269
+ interface ResolvedDataServerOptions {
270
+ readonly subscriptionPhase: DocumentState;
271
+ readonly fingerprintStrategy: FingerprintStrategy;
272
+ readonly methodNamespace: string;
273
+ readonly additionalMethods: readonly string[];
274
+ readonly excludedMethods: readonly string[];
275
+ /** Always resolved — to the caller's, or to the platform default. */
276
+ readonly diagnostics: DataServerDiagnosticsProvider;
277
+ }
278
+
279
+ /**
280
+ * Typed-RPC protocol head for the hydranium framework. The data-server
281
+ * is a PEER of `@hydranium/core/lsp` and `@hydranium/glsp-server` —
282
+ * all three heads coordinate through shared services in
283
+ * `@hydranium/core` (multi-client text documents, self-save
284
+ * registry, document-builder phases, project manager, ModelService
285
+ * facade) and never depend on each other at the package level.
286
+ *
287
+ * **No abstract grammar hooks**. The data-server has no grammar-specific
288
+ * abstract methods: `serialize` / `parseModel` live on
289
+ * {@link ModelService} (the in-process facade), where the `update` /
290
+ * `save` lifecycle owns the transfer-model round-trip. Adopters that want
291
+ * to customise serialisation rebind `services.model.ModelService` with a
292
+ * subclass; adopters that need a typed-overlay encoder rebind
293
+ * `services.model.TransferEncoder` with a subclass exposing the typed
294
+ * `TTransferMap`.
295
+ *
296
+ * **What still subclasses**. The data-server is concrete by default;
297
+ * adopters subclass ONLY when they need to decorate wire returns or
298
+ * notifications. The usual adoption path is DI rebinds alone.
299
+ *
300
+ * Lifecycle: the constructor registers framework request handlers (plus
301
+ * any names supplied via {@link DataServerOptions.additionalMethods}) and
302
+ * builds the {@link DataClientProtocol} notification proxy on the same
303
+ * connection in one {@link createRpcProxy} call (binding `this` as its
304
+ * `localTarget`) under the configured namespace, and subscribes one
305
+ * `DocumentBuilder.onDocumentPhase` listener per configured phase that
306
+ * dispatches subscription events via `clientProxy.onDocumentUpdated`.
307
+ *
308
+ * The data-server requires the full {@link ServerSharedServices}
309
+ * shape — `HydraniumTextDocuments` for client-attributed updates,
310
+ * `WritableFileSystemProvider` for save, `SelfSaveRegistry` for
311
+ * save-echo suppression, `ProjectManager` for project listing,
312
+ * `ModelService` for the lifecycle delegate, `TransferEncoder` for
313
+ * wire envelope construction — all of which the framework's
314
+ * `createServerSharedModule` binds by default.
315
+ *
316
+ * **No per-head shared module.** The data-server head does not surface a
317
+ * `createDataServerSharedModule` factory because it contributes no
318
+ * shared-tier bindings: it reads exclusively from
319
+ * {@link ServerSharedServices}. An empty factory would only mislead
320
+ * adopters into composing it as though it were the canonical adoption
321
+ * path.
322
+ */
323
+ export class DataServer<
324
+ TTransfer extends TransferElement,
325
+ TDiagnostic extends TransferDiagnostic = TransferDiagnostic,
326
+ TProject extends Project = Project
327
+ >
328
+ implements DataServerProtocol<TTransfer, TDiagnostic, TProject>, DataServerDiagnosticsProtocol, Disposable
329
+ {
330
+ /**
331
+ * Framework defaults, exposed so an adopter can spread them when
332
+ * extending rather than restate a value the framework may change.
333
+ */
334
+ static readonly DEFAULT_OPTIONS: Required<Pick<DataServerOptions, 'subscriptionPhase'>> = {
335
+ subscriptionPhase: DocumentState.Validated
336
+ };
337
+
338
+ protected readonly options: ResolvedDataServerOptions;
339
+ /** Subscription bookkeeping: URI → set of clientIds that subscribed for that URI. */
340
+ protected readonly subscriptions = new Map<string, Set<string>>();
341
+ /**
342
+ * Open-document bookkeeping: URI → set of clientIds that opened it over THIS
343
+ * connection.
344
+ *
345
+ * Kept because the document store releases a client's hold only from an
346
+ * explicit close, and a client that dies without closing would otherwise keep
347
+ * the document open forever — resident, and with its last-close revert
348
+ * suppressed. The store has no notion of which connection a clientId reached
349
+ * it over, so the connection-scoped set has to live here; teaching it a client
350
+ * identity is the more robust and much larger alternative.
351
+ */
352
+ protected readonly openedDocuments = new Map<string, Set<string>>();
353
+ /** Typed client proxy — sends `data-server/on*` notifications back over the same wire. */
354
+ protected readonly clientProxy: DataClientProtocol<TTransfer, TDiagnostic, TProject>;
355
+ protected readonly disposables = new DisposableCollection();
356
+ /**
357
+ * Snapshot of the most recent `DocumentBuilder.onUpdate` event. Drives
358
+ * `reason` discrimination on outbound `onDocumentUpdated` notifications:
359
+ * a URI in the `changed` list emits `'changed'`, in `deleted` emits
360
+ * `'deleted'`, otherwise `'rebuilt'` (cascade rebuild from a dependent
361
+ * URI's change). The same mechanism `AstDocumentManager.onUpdate` uses on
362
+ * the LSP side, so reason fidelity stays consistent between heads.
363
+ */
364
+ protected lastBuildUpdate?: { changed: readonly URI[]; deleted: readonly URI[] };
365
+ /**
366
+ * Per-URI hash of the last emitted (text + diagnostics) state,
367
+ * used by {@link dispatchPhaseEvent} to suppress duplicate
368
+ * `onDocumentUpdated` notifications for rebuilds that produce no
369
+ * observable change since the last emit.
370
+ *
371
+ * Two scenarios routinely produce such rebuilds:
372
+ * 1. **Refresh-triggered rebuilds.** When a second client attaches to a
373
+ * document already open in another client,
374
+ * `HydraniumTextDocuments.refreshContent` fires `onDidChangeContent`
375
+ * purely to re-trigger the build pipeline — the text and diagnostics
376
+ * are identical to the prior emit.
377
+ * 2. **Cascade rebuilds with no diagnostic delta.** Langium rebuilds a
378
+ * document when a dependency changes; if the rebuild produces the
379
+ * same diagnostics, there is nothing new to communicate to subscribers.
380
+ *
381
+ * Without this filter, both scenarios reach subscribers as wire-side
382
+ * `'changed'` events. A subscriber that interprets `'changed'` as
383
+ * "another client wrote new content" and resets its in-memory root to
384
+ * the server view then misreads the spurious event as a concurrent
385
+ * third-party write and loses the user's edits.
386
+ *
387
+ * Initialised on {@link watchModelDocument} to the current
388
+ * document fingerprint so the FIRST phase event after a fresh
389
+ * subscription is also de-duplicated against the state subscribers
390
+ * obtained via `getModelDocument` — they do not need a redundant phase
391
+ * notification for the state they just fetched.
392
+ *
393
+ * Cleared in {@link unwatchModelDocument} when the last
394
+ * subscriber for a URI leaves so memory does not accumulate.
395
+ *
396
+ * Stored as a cyrb53 hex digest (16 chars per URI) rather than the raw
397
+ * text + diagnostics serialisation so the memory cost is constant in
398
+ * document size. See {@link computeDocumentFingerprint} for the inputs.
399
+ */
400
+ protected readonly lastEmittedFingerprint = new Map<string, string>();
401
+ /**
402
+ * URIs whose LAST client just closed. The update handler rebuilds such a
403
+ * document from its disk content (discarding unsaved in-session edits),
404
+ * but {@link dispatchPhaseEvent} gates on the subscription map — and the
405
+ * last close typically also removed the last watcher, so consumers that
406
+ * only ever fetch via `getModelDocument` would keep showing the
407
+ * discarded state forever. Marked on the last-close transition (see
408
+ * {@link subscribeToTextDocumentCloses}) and consumed by
409
+ * {@link dispatchPhaseEvent}, which broadcasts the following rebuild's
410
+ * phase event even without a subscription — de-duplicated against
411
+ * {@link lastEmittedFingerprint} where an entry survives, so a close
412
+ * whose disk state equals the last emitted state stays silent.
413
+ */
414
+ protected readonly pendingRevertBroadcasts = new Set<string>();
415
+ protected readonly tracer: Tracer;
416
+ /** The in-flight interactive profile capture, held between {@link startProfiling} and {@link stopProfiling}. */
417
+ protected activeProfile?: DataServerProfileCapture;
418
+ /** Set once {@link dispose} has run, so a capture that starts after teardown is stopped instead of leaked. */
419
+ protected disposed = false;
420
+ /** Per-method RPC latency collector, when the head opted in via {@link DataServerOptions.latency}. */
421
+ protected readonly latency?: LatencyCollector;
422
+ /** Encoder pulled from DI. Adopters rebind `services.model.TransferEncoder` to a typed-overlay subclass. */
423
+ protected readonly encoder: TransferEncoder<unknown, TDiagnostic>;
424
+ /**
425
+ * In-process workspace facade — the lifecycle delegate `get` / `update` / `save` go through.
426
+ * The facade's AstDocument diagnostic shape is intentionally typed `unknown` here: adopters
427
+ * carry LSP-shape diagnostics in their AstDocument (e.g. an adopter's LSP-shape diagnostic type)
428
+ * while the wire shape stays `TDiagnostic extends TransferDiagnostic`. The encoder's
429
+ * `astDocumentToTransferDocument` accepts both shapes (wire-shape or LSP-shape) and projects
430
+ * to wire shape on the return — see `TransferEncoder.astDocumentToTransferDocument`.
431
+ */
432
+ protected readonly modelService: ModelService<AstNode, unknown, TTransfer>;
433
+
434
+ constructor(
435
+ protected readonly connection: MessageConnection,
436
+ protected readonly services: ServerSharedServices<TProject>,
437
+ options: DataServerOptions = {}
438
+ ) {
439
+ this.options = this.resolveOptions(options);
440
+ this.tracer = this.services.Tracer.for(options.logName ?? 'DataServer').trace('instantiated');
441
+ // DI-bound: adopters rebind `services.model.TransferEncoder` /
442
+ // `services.model.ModelService` with their own subclasses. Slot types use the
443
+ // framework upper bounds; the casts below narrow to this instance's generics.
444
+ const { TransferEncoder: encoder, ModelService: modelService } = this.services.model;
445
+ if (!encoder || !modelService) {
446
+ throw new Error(
447
+ 'DataServer requires `services.model.TransferEncoder` and `services.model.ModelService` to be bound. ' +
448
+ 'Did you compose `createServerSharedModule(ctx)` into your shared module?'
449
+ );
450
+ }
451
+ this.encoder = encoder as TransferEncoder<unknown, TDiagnostic>;
452
+ this.modelService = modelService as ModelService<AstNode, unknown, TTransfer>;
453
+ const excluded = new Set<string>(this.options.excludedMethods);
454
+ const registeredMethods = [
455
+ ...DATA_SERVER_PROTOCOL_METHODS,
456
+ ...DATA_SERVER_DIAGNOSTICS_METHODS,
457
+ ...this.options.additionalMethods
458
+ ].filter(name => !excluded.has(name));
459
+ // One call builds the outbound notification proxy AND registers the
460
+ // inbound handlers. A separate `bindRpcMethods` call per slice would
461
+ // force a subclass to re-bind in its own constructor to contribute
462
+ // methods alongside the framework's.
463
+ this.latency = options.latency;
464
+ this.clientProxy = createRpcProxy<DataClientProtocol<TTransfer, TDiagnostic, TProject>, this>(connection, {
465
+ methodNamespace: this.options.methodNamespace,
466
+ localTarget: this,
467
+ localMethods: registeredMethods as readonly (keyof this & string)[],
468
+ latency: this.latency
469
+ });
470
+ this.disposables.push(
471
+ this.services.workspace.DocumentBuilder.onUpdate((changed, deleted) => {
472
+ this.lastBuildUpdate = { changed, deleted };
473
+ })
474
+ );
475
+ this.subscribeToDocumentBuilder();
476
+ this.subscribeToTextDocumentSaves();
477
+ this.subscribeToTextDocumentCloses();
478
+ this.subscribeToProjectManager();
479
+ // Self-register teardown so an adopter that keeps no reference to the
480
+ // server still releases per-connection state, with no lifecycle hook of
481
+ // its own. See `dispose`.
482
+ this.disposables.push(connection.onClose(() => this.dispose()));
483
+ }
484
+
485
+ /**
486
+ * Release everything the constructor wired up — the bound protocol
487
+ * handlers and every listener — and clear the subscription map and the
488
+ * per-URI emission-fingerprint cache, so a long-lived shared services
489
+ * bundle does not retain per-connection memory after the connection
490
+ * closes. Also closes every document still open over this connection (see
491
+ * {@link closeOpenDocuments}), which is the SHARED store's state rather than
492
+ * this server's and so outlives the connection unless released here.
493
+ *
494
+ * Idempotent: subsequent calls are no-ops. Self-fires on
495
+ * `connection.onClose` so adopters who don't hold a reference still
496
+ * get per-connection cleanup; adopters that DO hold a reference may
497
+ * call `dispose()` directly for early teardown.
498
+ */
499
+ dispose(): void {
500
+ this.disposed = true;
501
+ // Release an in-flight interactive capture so the process-wide inspector
502
+ // singleton is not left active after the connection closes.
503
+ if (this.activeProfile) {
504
+ const capture = this.activeProfile;
505
+ this.activeProfile = undefined;
506
+ void capture.stop({}).catch(() => undefined);
507
+ }
508
+ // AFTER `disposables.dispose()`, deliberately: closing a document fires
509
+ // `onDidClose`, and this server's own close listener would otherwise mark a
510
+ // revert broadcast for a connection that is already gone.
511
+ this.disposables.dispose();
512
+ this.closeOpenDocuments();
513
+ this.subscriptions.clear();
514
+ this.lastEmittedFingerprint.clear();
515
+ this.pendingRevertBroadcasts.clear();
516
+ }
517
+
518
+ // ============================================================
519
+ // DataServerProtocol implementation — delegates to ModelService.
520
+ // ============================================================
521
+ //
522
+ // The lifecycle methods (`get` / `update` / `save` / `ready`) forward
523
+ // straight to `ModelService` and encode the returned `AstDocument` on
524
+ // the wire boundary via `encoder.astDocumentToTransferDocument`.
525
+ // Adopters customising lifecycle behaviour (normalisation, supersession,
526
+ // settled-phase choice, etc.) override on the ModelService subclass —
527
+ // no override on DataServer is needed.
528
+
529
+ async openModelDocument(args: OpenModelArgs): Promise<TransferDocument<TTransfer, TDiagnostic>> {
530
+ // Register the editor session (idempotent — `ModelService.open` refreshes
531
+ // an already-open document rather than re-opening), then return the built
532
+ // state at the configured target phase. The one-shot snapshot; subsequent
533
+ // build-phase events arrive via `watchModelDocument`.
534
+ await this.modelService.open(args);
535
+ // Record the hold so `dispose` can release it for a client that never
536
+ // closes. Keyed by the URI as given, because that is what `close` takes.
537
+ let holders = this.openedDocuments.get(args.uri);
538
+ if (!holders) {
539
+ holders = new Set<string>();
540
+ this.openedDocuments.set(args.uri, holders);
541
+ }
542
+ holders.add(args.clientId);
543
+ const document = await this.getModelDocument({ uri: args.uri });
544
+ // Project the authoritative client-facing version onto the open snapshot.
545
+ // `getModelDocument` encodes the freshly-built AST snapshot, whose version
546
+ // is the `LangiumDocument`'s own `textDocument.version`. That lags the
547
+ // multi-client synced version whenever the open seeded the synced document
548
+ // with a caller-supplied version id — and the synced version is what
549
+ // `ModelService.update`'s optimistic-concurrency gate compares
550
+ // `baseVersion` against, so reporting the snapshot's would make the
551
+ // caller's first tagged write self-conflict. A genuine concurrent edit
552
+ // still trips the gate and is reconciled by the caller's replay rather
553
+ // than predicted here.
554
+ return { ...document, version: this.services.workspace.TextDocuments.version(args.uri) };
555
+ }
556
+
557
+ async closeModelDocument(args: CloseModelArgs): Promise<void> {
558
+ // Closing a session also releases its watch for (uri, clientId) — a
559
+ // forgotten unwatch would otherwise leak phase-event dispatch until the
560
+ // connection closes. Idempotent: a close without a prior watch is a no-op.
561
+ await this.unwatchModelDocument({ uri: args.uri, clientId: args.clientId });
562
+ this.forgetOpenDocument(args.uri, args.clientId);
563
+ await this.modelService.close(args);
564
+ }
565
+
566
+ /**
567
+ * Drop the recorded hold for `(uri, clientId)` so {@link dispose} does not
568
+ * close it a second time. Idempotent.
569
+ */
570
+ protected forgetOpenDocument(uri: string, clientId: string): void {
571
+ const holders = this.openedDocuments.get(uri);
572
+ if (!holders) {
573
+ return;
574
+ }
575
+ holders.delete(clientId);
576
+ if (holders.size === 0) {
577
+ this.openedDocuments.delete(uri);
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Close every document still open over this connection, for the clients that
583
+ * opened it here.
584
+ *
585
+ * The document store releases a per-URI hold only from an explicit close, so
586
+ * without this a client that dies mid-session keeps its documents open for the
587
+ * lifetime of the process: `isOpenInAnyClient` stays true, the document stays
588
+ * resident, and the last-close revert never runs. A long-lived multi-client
589
+ * head is the configuration where a dead client is normal rather than
590
+ * exceptional, so the leak accumulates there.
591
+ *
592
+ * Runs from {@link dispose}, which is synchronous, so each close is fired and
593
+ * its failure swallowed — a teardown must not reject, and a URI whose close
594
+ * fails is no worse off than it was before this drain existed.
595
+ */
596
+ protected closeOpenDocuments(): void {
597
+ const held = [...this.openedDocuments];
598
+ this.openedDocuments.clear();
599
+ for (const [uri, clientIds] of held) {
600
+ for (const clientId of clientIds) {
601
+ void Promise.resolve(this.modelService.close({ uri, clientId })).catch(() => undefined);
602
+ }
603
+ }
604
+ }
605
+
606
+ async getModelDocument(args: GetModelDocumentArgs): Promise<TransferDocument<TTransfer, TDiagnostic>> {
607
+ // Smart dispatch: a warm document (already in LangiumDocuments) is
608
+ // returned at its settle phase without a redundant build; a cold URI
609
+ // falls through to a fresh build. update/save already drive builds, so
610
+ // this read path does not need to force one, which would make every
611
+ // polling read pay for a rebuild.
612
+ //
613
+ // Defaults to the integrity-settled landmark (diagnostics may be absent,
614
+ // delivered asynchronously via the subscription channel). A one-shot /
615
+ // unsubscribed caller that needs diagnostics inline passes
616
+ // `includeDiagnostics: true` to settle at `Validated` instead.
617
+ const state = args.includeDiagnostics ? DocumentState.Validated : undefined;
618
+ const astDocument = await this.modelService.ensureDocumentState(args.uri, state);
619
+ return this.encoder.astDocumentToTransferDocument(astDocument as never) as unknown as TransferDocument<TTransfer, TDiagnostic>;
620
+ }
621
+
622
+ async updateModelDocument(args: TransferUpdateDocumentArgs<TTransfer>): Promise<TransferDocument<TTransfer, TDiagnostic>> {
623
+ const astDocument = await this.modelService.update(args);
624
+ return this.encoder.astDocumentToTransferDocument(astDocument as never) as unknown as TransferDocument<TTransfer, TDiagnostic>;
625
+ }
626
+
627
+ async saveModelDocument(args: TransferSaveDocumentArgs<TTransfer>): Promise<TransferDocument<TTransfer, TDiagnostic>> {
628
+ const astDocument = await this.modelService.save(args);
629
+ return this.encoder.astDocumentToTransferDocument(astDocument as never) as unknown as TransferDocument<TTransfer, TDiagnostic>;
630
+ }
631
+
632
+ /**
633
+ * Record a watch for `(uri, clientId)`. Subsequent phase events on `uri`
634
+ * fan out to the wired `clientProxy.onDocumentUpdated`. The
635
+ * bidirectional pattern means the event channel is the client
636
+ * notification surface, not a returned handle — this method only
637
+ * registers the URI in the dispatch table.
638
+ *
639
+ * Also baselines the per-URI emission fingerprint (see
640
+ * {@link lastEmittedFingerprint}) to the document's current state when
641
+ * the first watcher for the URI registers. This guarantees that any
642
+ * phase event firing immediately after the watch with no observable
643
+ * change is suppressed — watchers obtain initial state via
644
+ * `getModelDocument` (or {@link openModelDocument}) and do not need a
645
+ * redundant phase notification for that same state.
646
+ */
647
+ async watchModelDocument(args: WatchModelDocumentArgs): Promise<void> {
648
+ const uri = this.canonicalKey(args.uri);
649
+ let subscribers = this.subscriptions.get(uri);
650
+ if (!subscribers) {
651
+ subscribers = new Set();
652
+ this.subscriptions.set(uri, subscribers);
653
+ }
654
+ subscribers.add(args.clientId);
655
+ if (!this.lastEmittedFingerprint.has(uri)) {
656
+ const document = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
657
+ if (document) {
658
+ this.lastEmittedFingerprint.set(uri, this.computeDocumentFingerprint(document));
659
+ }
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Remove a watch for `(uri, clientId)` previously created by
665
+ * {@link watchModelDocument}. Idempotent — unwatching twice is a no-op.
666
+ * Dispatch for `uri` stops once no watchers remain, at which point the
667
+ * per-URI emission fingerprint is also cleared so the next first-watch
668
+ * re-baselines against the then-current document state rather than a
669
+ * stale snapshot from the previous watch.
670
+ */
671
+ async unwatchModelDocument(args: WatchModelDocumentArgs): Promise<void> {
672
+ const uri = this.canonicalKey(args.uri);
673
+ const subscribers = this.subscriptions.get(uri);
674
+ if (!subscribers) {
675
+ return;
676
+ }
677
+ subscribers.delete(args.clientId);
678
+ if (subscribers.size === 0) {
679
+ this.subscriptions.delete(uri);
680
+ this.lastEmittedFingerprint.delete(uri);
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Canonicalise a URI string for use as a subscription / fingerprint
686
+ * map key, via the shared `DocumentUriPolicy`. Callers may send
687
+ * non-canonical URIs (drive-letter casing, percent-encoding differences,
688
+ * or a symlink path) over the wire; the dispatch side keys by
689
+ * `document.uri.toString()` from `LangiumDocuments`, so writer keys must
690
+ * canonicalise to the same form or events silently fail to deliver.
691
+ * Routing through the seam (rather than a bare `UriUtils.normalize`)
692
+ * means that when an adopter strengthens document identity — e.g.
693
+ * real-path (symlink) resolution — the data-server head's keys track
694
+ * it too, instead of carrying the same path-identity divergence the
695
+ * LSP head resolves.
696
+ *
697
+ * Adopters can still override for head-specific URI policy by subclassing.
698
+ */
699
+ protected canonicalKey(uri: string): string {
700
+ return this.services.workspace.DocumentUriPolicy.canonicalUri(uri);
701
+ }
702
+
703
+ async getProjects(): Promise<readonly TProject[]> {
704
+ // Pass-through: `ProjectManager<TProject>` already produces `TProject`
705
+ // (the shared services are parameterised over the same generic).
706
+ // The framework reads `id` for registry identity and `dependencies`
707
+ // for the visibility closure; every other field rides on the
708
+ // JSON-RPC envelope as adopter-defined wire metadata.
709
+ return this.services.workspace.ProjectManager.getProjects();
710
+ }
711
+
712
+ async getProjectForUri(args: GetProjectForUriArgs): Promise<TProject | undefined> {
713
+ // Pass-through: `ProjectManager.getProject(uri)` already returns the
714
+ // adopter's `TProject` shape. Membership logic belongs to the adopter's
715
+ // `ProjectManager`; the data-server forwards the URI without
716
+ // interpretation.
717
+ return this.services.workspace.ProjectManager.getProject(UriUtils.toUri(args.uri));
718
+ }
719
+
720
+ /**
721
+ * Resolve once the data-server is ready to serve requests. Delegates
722
+ * to {@link ModelService.ready} so adopters that warm-load services
723
+ * (workspace indexing, etc.) override the `ModelService` slot in
724
+ * their shared module rather than this method on a DataServer
725
+ * subclass.
726
+ */
727
+ async waitForReady(): Promise<void> {
728
+ await this.modelService.ready;
729
+ }
730
+
731
+ // ============================================================
732
+ // ReferenceServerProtocol defaults — opt-in (not in DataServerProtocol).
733
+ // Resolve the language's reference services from each request's source
734
+ // (see resolveReferenceServices) and delegate.
735
+ // ============================================================
736
+
737
+ async findReferenceCandidates(ctx: ReferenceContext): Promise<ReferenceCandidate[]> {
738
+ // Candidates are derived from resolved cross-references, so the relevant document(s) must
739
+ // have finished the Linked phase — otherwise a query issued right after a model update races
740
+ // the asynchronous rebuild and returns stale candidates. Wait the source document
741
+ // specifically, but ONLY when a document is actually loaded at that URI: a synthetic source
742
+ // can address a URI with no document (a directory URI, typically), and a per-URI `waitUntil`
743
+ // there throws "No document found". Fall back to a global Linked settle, matching the
744
+ // id-based ElementSource (no URI) path.
745
+ const uri = isDocumentSource(ctx.source) || isSyntheticSource(ctx.source) ? UriUtils.toUri(ctx.source.uri) : undefined;
746
+ const waitUri = uri && this.services.workspace.LangiumDocuments.hasDocument(uri) ? uri : undefined;
747
+ await this.services.workspace.DocumentBuilder.waitUntil(DocumentState.Linked, waitUri);
748
+ return this.resolveReferenceServices(ctx.source).CandidateProvider.find(ctx);
749
+ }
750
+
751
+ async resolveReference(ref: ReferenceRequest): Promise<ReferenceTarget<TTransfer> | undefined> {
752
+ const resolved = this.resolveReferenceServices(ref.source).CandidateProvider.resolveCandidate(ref);
753
+ if (!resolved) {
754
+ return undefined;
755
+ }
756
+ const element = this.encoder.toTransfer(resolved.node) as unknown as TTransfer;
757
+ return { ...resolved.candidate, element };
758
+ }
759
+
760
+ async findNextName(args: FindNextNameArgs): Promise<string> {
761
+ const uri = UriUtils.toUri(args.uri);
762
+ // Route through the same router as every other reference method.
763
+ // `args` carries a URI and an AST type, which is precisely a
764
+ // synthetic source — and the create-element flow driving this method is
765
+ // the one that produces directory URIs, on which a bare
766
+ // `getServices(uri)` throws while `findReferenceCandidates` succeeds.
767
+ const nameProvider = this.resolveReferenceServices(ReferenceSource.synthetic(args.uri, args.type)).NameProvider;
768
+ const tier = args.tier ?? 'project';
769
+ if (tier === 'public') {
770
+ return nameProvider.findNextProjectQualifiedName(args.type, args.proposal);
771
+ }
772
+ if (tier === 'local') {
773
+ // Document-scoped uniqueness: the document root is the container.
774
+ const document = await this.modelService.ensureDocumentState(args.uri);
775
+ return document.root ? nameProvider.findNextName(args.type, args.proposal, document.root as AstNode) : args.proposal;
776
+ }
777
+ const project = this.services.workspace.ProjectManager.getProject(uri);
778
+ if (!project) {
779
+ // Project-tier uniqueness on a URI no project owns: the scope the
780
+ // caller asked about does not exist. Widen to workspace-wide, which is
781
+ // a strict superset — a name unique across every project is unique
782
+ // within any one of them, so this can only add a suffix, never miss a
783
+ // collision. Filtering on an empty project id instead would match no
784
+ // element, and so always answer "no collisions" with the bare
785
+ // proposal. Unreachable for adopters on the default
786
+ // `SingleProjectManager`, which owns every URI.
787
+ return nameProvider.findNextProjectQualifiedName(args.type, args.proposal);
788
+ }
789
+ return nameProvider.findNextDocumentQualifiedName(args.type, args.proposal, project.id);
790
+ }
791
+
792
+ /**
793
+ * Resolve the per-language `references` services for a reference source.
794
+ * Delegates the language choice to {@link resolveReferenceLanguage} and
795
+ * throws when no language owns the source — the reference heads have no
796
+ * meaningful empty answer (an empty candidate list reads to the client as
797
+ * "nothing matches" and hides the misrouting).
798
+ */
799
+ protected resolveReferenceServices(source: ReferenceSource): HydraniumLanguageServices['references'] {
800
+ const language = this.resolveReferenceLanguage(source);
801
+ if (!language) {
802
+ throw new Error(
803
+ 'DataServer cannot resolve the language for a reference source whose URI matches no registered ' +
804
+ 'language in a multi-language workspace. Override `fallbackReferenceLanguage` on your DataServer ' +
805
+ 'subclass to name the language such sources belong to.'
806
+ );
807
+ }
808
+ return language.references;
809
+ }
810
+
811
+ /**
812
+ * Pick the language that owns a reference source, in order:
813
+ *
814
+ * 1. A URI-bearing source ({@link isDocumentSource}/{@link isSyntheticSource})
815
+ * whose URI resolves to a registered language routes by URI.
816
+ * 2. A single-language workspace always routes to that one language — so
817
+ * single-language adopters never reach the steps below, and never pay
818
+ * for them.
819
+ * 3. An {@link isElementSource} source carries no URI, so in a
820
+ * multi-language workspace it is routed via the document that holds the
821
+ * element (see {@link findElementDocumentUri}).
822
+ * 4. A source carrying an AST type is routed by that type when exactly one
823
+ * registered grammar can produce it (see
824
+ * {@link resolveReferenceLanguageByType}, over the registry's own type
825
+ * index). This is what resolves a create-element flow's synthetic source
826
+ * on a bare directory URI without asking the adopter.
827
+ * 5. Anything still unresolved falls to {@link fallbackReferenceLanguage},
828
+ * which is adopter policy.
829
+ */
830
+ protected resolveReferenceLanguage(source: ReferenceSource): HydraniumLanguageServices | undefined {
831
+ const registry = this.services.ServiceRegistry;
832
+ // `getServicesFor` (non-throwing, one ladder walk) gates the URI lookup:
833
+ // an extensionless / unregistered URI falls through to the steps below
834
+ // rather than throwing "no services for the extension ''".
835
+ const uri = isDocumentSource(source) || isSyntheticSource(source) ? UriUtils.toUri(source.uri) : undefined;
836
+ const byUri = uri && registry.getServicesFor(uri);
837
+ if (byUri) {
838
+ return byUri;
839
+ }
840
+ const all = registry.all;
841
+ if (all.length === 1) {
842
+ return all[0] as HydraniumLanguageServices;
843
+ }
844
+ if (isElementSource(source)) {
845
+ const documentUri = this.findElementDocumentUri(source);
846
+ const byDocument = documentUri && registry.getServicesFor(documentUri);
847
+ if (byDocument) {
848
+ return byDocument;
849
+ }
850
+ }
851
+ const byType = this.resolveReferenceLanguageByType(source);
852
+ return byType ?? this.fallbackReferenceLanguage(source);
853
+ }
854
+
855
+ /**
856
+ * Route a reference source by the AST type it carries — a
857
+ * {@link isSyntheticSource}'s `type` (the transient node being created) or an
858
+ * {@link isElementSource}'s optional narrowing `type`.
859
+ *
860
+ * Answers only when EXACTLY ONE registered grammar can produce the type.
861
+ * Several can when the type comes from a grammar both import, and then the
862
+ * type genuinely does not identify a language — that is a fall-through to
863
+ * adopter policy, not a coin toss. Note this asks which grammar can *produce*
864
+ * the type, not which mentions it: a grammar that merely cross-references a
865
+ * type can never hold a node of it.
866
+ */
867
+ protected resolveReferenceLanguageByType(source: ReferenceSource): HydraniumLanguageServices | undefined {
868
+ const type = isSyntheticSource(source) ? source.type : isElementSource(source) ? source.type : undefined;
869
+ // The registry owns the type index — it is the one place that knows when
870
+ // the registered set changed, so unlike a private memo here it cannot go
871
+ // stale on a language registered after the first lookup.
872
+ return type ? this.services.ServiceRegistry.soleServicesByType(type) : undefined;
873
+ }
874
+
875
+ /**
876
+ * Locate the document that holds the element an {@link ElementSource}
877
+ * addresses, via the index's O(1) name lookup — `name` is the qualified
878
+ * name the `NameProvider` wrote into the index, and the optional `type`
879
+ * disambiguates names that repeat across types (honouring grammar
880
+ * subtyping through `AstReflection.isSubtype`).
881
+ *
882
+ * Only reached on the multi-language, name-based path (step 3 of
883
+ * {@link resolveReferenceLanguage}); single-language adopters return at
884
+ * step 2.
885
+ *
886
+ * Abstains when the name matches elements in more than one DOCUMENT — the
887
+ * index spans every language and is filled in build order, so "the
888
+ * first match" would be file-watch order rather than an answer. Step 3
889
+ * then falls through to the type-based step 4, which abstains on ties
890
+ * in the same way, and finally to adopter policy.
891
+ */
892
+ protected findElementDocumentUri(source: ElementSource): URI | undefined {
893
+ const matches = this.services.workspace.IndexManager.getElementsByName(source.name, source.type);
894
+ const [first] = matches;
895
+ if (!first) {
896
+ return undefined;
897
+ }
898
+ // Routing only asks WHICH DOCUMENT, so several descriptions of the same
899
+ // document are not ambiguous — one element is routinely indexed several
900
+ // times, once per visibility tier and once more for a wrapper root
901
+ // beside its semantic root. Only matches that disagree on the document
902
+ // are ambiguous.
903
+ return matches.every(match => match.documentUri.toString() === first.documentUri.toString()) ? first.documentUri : undefined;
904
+ }
905
+
906
+ /**
907
+ * Language to serve reference queries whose source names no language of
908
+ * its own — a synthetic source addressing a URI with no (or an
909
+ * unregistered) extension, or an element id absent from the index.
910
+ *
911
+ * Returns `undefined` by default, which makes
912
+ * {@link resolveReferenceServices} throw. Only reachable in a
913
+ * multi-language workspace, where choosing among the registered languages
914
+ * is adopter policy: override and return the language such sources belong
915
+ * to.
916
+ */
917
+ protected fallbackReferenceLanguage(_source: ReferenceSource): HydraniumLanguageServices | undefined {
918
+ return undefined;
919
+ }
920
+
921
+ // ============================================================
922
+ // Internal plumbing
923
+ // ============================================================
924
+
925
+ /**
926
+ * Build a {@link TransferDocument} envelope from the current document state,
927
+ * delegating root + diagnostic encoding to {@link encoder} (see
928
+ * `TransferEncoder.toTransferDocument` for the walk).
929
+ *
930
+ * The encoder field's generic-map binding is widened to
931
+ * `Record<string, TransferElement>` at the framework-default level, and an
932
+ * adopter supplying a typed-overlay encoder narrows the runtime shape to
933
+ * its wire types. The cast on the return is where that invariant — the
934
+ * adopter's `TTransfer` matches its encoder's overlay — is asserted, at a
935
+ * single boundary point rather than spread across the callers.
936
+ */
937
+ protected envelope(uri: URI): TransferDocument<TTransfer, TDiagnostic> {
938
+ // Resolve through the model service's canonicalizing gateway rather than
939
+ // reaching into `LangiumDocuments` directly, so a divergent (symlink) URI
940
+ // still finds the document the build keys by its real path — and so the
941
+ // data-server never has to remember to canonicalize this lookup itself.
942
+ const document = this.modelService.getDocument(uri.toString());
943
+ if (!document) {
944
+ // No document — a shaped envelope rather than a throw, so the caller
945
+ // decides policy at its own layer; `root` is optional on the envelope
946
+ // so the compiler forces that decision. Adopters preferring to throw
947
+ // override `envelope`.
948
+ return {
949
+ uri: uri.toString(),
950
+ version: 0,
951
+ root: undefined,
952
+ diagnostics: [] as TDiagnostic[]
953
+ };
954
+ }
955
+ return this.encoder.toTransferDocument<AstNode>(document) as unknown as TransferDocument<TTransfer, TDiagnostic>;
956
+ }
957
+
958
+ /**
959
+ * Subscribe one listener at the configured {@link DataServerOptions.subscriptionPhase}.
960
+ * The listener dispatches subscription events for every matching URI. A
961
+ * single listener (rather than one per subscription) keeps the cost flat
962
+ * regardless of subscriber count.
963
+ */
964
+ protected subscribeToDocumentBuilder(): void {
965
+ this.disposables.push(
966
+ this.services.workspace.DocumentBuilder.onDocumentPhase(this.options.subscriptionPhase, (document, cancelToken) =>
967
+ this.dispatchPhaseEvent(document, cancelToken)
968
+ )
969
+ );
970
+ }
971
+
972
+ /**
973
+ * Subscribe to the universal save event on `HydraniumTextDocuments` so a
974
+ * single `onDocumentSaved` wire notification fires for ANY save of a
975
+ * subscribed URI — regardless of whether the save originated from the
976
+ * data-server's RPC `saveModelDocument`, the LSP head's text-editor save,
977
+ * or any other client writing through `notifyDidSaveTextDocument`.
978
+ *
979
+ * Architectural symmetry with {@link subscribeToDocumentBuilder}: every
980
+ * subscribed client sees every state change to documents they care about,
981
+ * regardless of which client triggered it. Firing `onDocumentSaved` only
982
+ * from the data-server's own RPC path is a bug, not an optimisation: an
983
+ * LSP-driven save then lands on disk without notifying the subscribed
984
+ * clients, which never clear their dirty state.
985
+ */
986
+ protected subscribeToTextDocumentSaves(): void {
987
+ this.disposables.push(this.services.workspace.TextDocuments.onDidSave(event => this.dispatchSaveEvent(event)));
988
+ }
989
+
990
+ /**
991
+ * Mark the last-close transition per URI (see
992
+ * {@link pendingRevertBroadcasts}). The listener consults
993
+ * `isOpenInAnyClient` AFTER the store decremented the closing client's
994
+ * hold, so a `false` answer means this close was the last one.
995
+ */
996
+ protected subscribeToTextDocumentCloses(): void {
997
+ const textDocuments = this.services.workspace.TextDocuments;
998
+ this.disposables.push(
999
+ textDocuments.onDidClose(event => {
1000
+ if (!textDocuments.isOpenInAnyClient(event.document.uri)) {
1001
+ this.pendingRevertBroadcasts.add(this.canonicalKey(event.document.uri));
1002
+ }
1003
+ })
1004
+ );
1005
+ }
1006
+
1007
+ /** Fan out a save event for the document's URI, gated by the subscription map. */
1008
+ protected dispatchSaveEvent(event: ClientTextDocumentChangeEvent<TextDocument>): void {
1009
+ // The save event arrives under the CLIENT URI the text store keys by (e.g. a
1010
+ // symlink path S); the subscription map and `dispatchPhaseEvent` key by the
1011
+ // CANONICAL identity R. Canonicalize before both the gate and the envelope so
1012
+ // a save of a symlinked file isn't silently dropped (and the envelope resolves
1013
+ // the R-keyed document rather than missing into an empty one).
1014
+ const uri = this.canonicalKey(event.document.uri);
1015
+ if (!this.subscriptions.has(uri)) {
1016
+ return;
1017
+ }
1018
+ const response = this.envelope(UriUtils.toUri(uri));
1019
+ const wireEvent: TransferDocumentSavedEvent<TTransfer, TDiagnostic> = {
1020
+ document: response,
1021
+ sourceClientId: event.clientId
1022
+ };
1023
+ this.clientProxy.onDocumentSaved(wireEvent);
1024
+ }
1025
+
1026
+ /**
1027
+ * Fan out a phase event for the document's URI by calling
1028
+ * `clientProxy.onDocumentUpdated`. The proxy lowers the call to a
1029
+ * `data-server/onDocumentUpdated` wire notification; the paired client
1030
+ * (bound via the client `createRpcProxy`'s `localTarget`/`localMethods`)
1031
+ * routes it to its handler. Adopters fan a single inbound onDocumentUpdated
1032
+ * out to multiple local subscribers with an `Emitter<T>` — the
1033
+ * framework deliberately does NOT promise multi-listener semantics.
1034
+ *
1035
+ * The dispatch is guarded by two filters:
1036
+ * 1. **Subscription map**: events for URIs no subscriber registered for
1037
+ * are NOT sent over the wire (bandwidth scales with subscribed URIs,
1038
+ * not phase events).
1039
+ * 2. **Emission fingerprint**: events for rebuilds that produce no
1040
+ * observable change since the last emit are suppressed. See
1041
+ * {@link lastEmittedFingerprint} for the rationale.
1042
+ */
1043
+ protected dispatchPhaseEvent(document: LangiumDocument, cancelToken: CancellationToken): void {
1044
+ if (cancelToken.isCancellationRequested) {
1045
+ // Build preempted by a concurrent write lock (or other cancel source) —
1046
+ // the subscription event is stale by the time it would fire. Skip
1047
+ // emission so RPC subscribers don't surface intermediate states.
1048
+ // A pending revert mark is deliberately NOT consumed here: the
1049
+ // follow-up build re-fires this phase and broadcasts then.
1050
+ return;
1051
+ }
1052
+ const uri = document.uri.toString();
1053
+ // Consume the last-close mark even when subscriptions exist — the
1054
+ // regular dispatch below serves those watchers, and the mark's author
1055
+ // attribution is more precise than the post-close `UNKNOWN_CLIENT_ID`
1056
+ // the author lookup would yield.
1057
+ const revertedOnClose = this.pendingRevertBroadcasts.delete(uri);
1058
+ if (!this.subscriptions.has(uri) && !revertedOnClose) {
1059
+ return;
1060
+ }
1061
+ const fingerprint = this.computeDocumentFingerprint(document);
1062
+ if (this.lastEmittedFingerprint.get(uri) === fingerprint) {
1063
+ // A rebuild that produced no observable change since the last emit —
1064
+ // suppressed so RPC subscribers don't see a no-op broadcast. Logged at
1065
+ // debug so a *needed* re-broadcast wrongly suppressed by this dedup
1066
+ // (the failure mode the fingerprint strategy must avoid) is visible.
1067
+ this.tracer.withUri(uri).debug(`Suppress onDocumentUpdated v${document.textDocument.version}: fingerprint unchanged`);
1068
+ return;
1069
+ }
1070
+ this.lastEmittedFingerprint.set(uri, fingerprint);
1071
+ const event: TransferDocumentUpdatedEvent<TTransfer, TDiagnostic> = {
1072
+ document: this.envelope(document.uri),
1073
+ sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : this.resolveSourceClientId(document),
1074
+ reason: this.resolveUpdateReason(document.uri)
1075
+ };
1076
+ this.tracer
1077
+ .withUri(uri)
1078
+ .debug(`Emit onDocumentUpdated v${event.document.version} (reason=${event.reason}, sourceClientId=${event.sourceClientId})`);
1079
+ this.clientProxy.onDocumentUpdated(event);
1080
+ }
1081
+
1082
+ /**
1083
+ * Fingerprint of the document's observable state, used to de-dup
1084
+ * `onDocumentUpdated` emissions. The {@link FingerprintStrategy} option
1085
+ * selects what is hashed (default `'transfer-document'` — see the type), and
1086
+ * {@link additionalFingerprintInputs} folds in any extra adopter signal.
1087
+ *
1088
+ * The default `'transfer-document'` strategy goes through the encoder's
1089
+ * {@link TransferEncoder.toTransferDocument}, which is cached per build —
1090
+ * so within one phase event the fingerprint and the subsequently-emitted
1091
+ * `envelope` share a single encode walk.
1092
+ *
1093
+ * Wrapped in {@link Tracer.time} against {@link FINGERPRINT_LOG_AFTER_MS}.
1094
+ */
1095
+ protected computeDocumentFingerprint(document: LangiumDocument): string {
1096
+ return this.tracer.withUri(document.uri.toString()).time(
1097
+ 'Compute fingerprint',
1098
+ () => {
1099
+ const base =
1100
+ this.options.fingerprintStrategy === 'text-diagnostics'
1101
+ ? textDiagnosticsFingerprint(document)
1102
+ : transferDocumentFingerprint(this.encoder.toTransferDocument(document));
1103
+ const extra = this.additionalFingerprintInputs(document);
1104
+ return extra.length === 0 ? base : fingerprintHash([base, FINGERPRINT_SEPARATOR, ...extra.map(value => JSON.stringify(value))]);
1105
+ },
1106
+ 'debug',
1107
+ { logAfterMs: FINGERPRINT_LOG_AFTER_MS }
1108
+ );
1109
+ }
1110
+
1111
+ /**
1112
+ * Extra inputs folded into {@link computeDocumentFingerprint} alongside the
1113
+ * chosen {@link FingerprintStrategy}. Default: none. Override to contribute a
1114
+ * signal that lives outside the document's text / root / diagnostics — each
1115
+ * entry need only be stable across equivalent emissions and
1116
+ * JSON-serialisable.
1117
+ */
1118
+ protected additionalFingerprintInputs(_document: LangiumDocument): readonly unknown[] {
1119
+ return [];
1120
+ }
1121
+
1122
+ /**
1123
+ * Discriminate the reason for a phase-event-driven update notification.
1124
+ * Uses the most recent `DocumentBuilder.onUpdate` snapshot:
1125
+ * - URI in the `deleted` list → `'deleted'`
1126
+ * - URI in the `changed` list → `'changed'` (the URI was passed to
1127
+ * `documentBuilder.update(changed, deleted)`, which spans `didChange`
1128
+ * text-document events and programmatic `update([uri], [])` calls).
1129
+ * - Otherwise → `'rebuilt'` (cascade re-derivation: this URI was rebuilt
1130
+ * because something it depends on changed; its own text wasn't flagged).
1131
+ *
1132
+ * `'saved'` is NOT emitted from this code path — saves take the dedicated
1133
+ * `DataClientProtocol.onDocumentSaved` channel; adopters that want a
1134
+ * unified update stream synthesise `'saved'` in their bridge layer.
1135
+ */
1136
+ protected resolveUpdateReason(uri: URI): TransferDocumentUpdatedEvent<TTransfer, TDiagnostic>['reason'] {
1137
+ const last = this.lastBuildUpdate;
1138
+ if (last?.deleted.some(deleted => UriUtils.equals(deleted, uri))) {
1139
+ return 'deleted';
1140
+ }
1141
+ if (last?.changed.some(changed => UriUtils.equals(changed, uri))) {
1142
+ return 'changed';
1143
+ }
1144
+ return 'rebuilt';
1145
+ }
1146
+
1147
+ /**
1148
+ * Subscribe to the project tier's change channel and re-fan registry
1149
+ * diffs into per-project wire notifications. The internal
1150
+ * `ProjectChangeEvent` carries arrays of added/updated ids plus a
1151
+ * removed list of `{ id, snapshot }` pairs; each affected project
1152
+ * emits one wire `onProjectsChanged` event so clients react
1153
+ * one-at-a-time without walking arrays. `'removed'` dispatches carry
1154
+ * the pre-removal snapshot bundled in
1155
+ * {@link ProjectChangeEvent.removed} because the registry entry is
1156
+ * already gone by the time the event fires.
1157
+ *
1158
+ * Adopters that warm-load services may want to defer this subscription
1159
+ * until {@link waitForReady} resolves (to avoid replaying the initial
1160
+ * discovery as a burst of `'added'` events). Override
1161
+ * {@link subscribeToProjectManager} on the subclass to gate.
1162
+ */
1163
+ protected subscribeToProjectManager(): void {
1164
+ this.disposables.push(this.services.workspace.ProjectManager.onProjectsChanged(event => this.dispatchProjectChangeEvent(event)));
1165
+ }
1166
+
1167
+ /**
1168
+ * Fan one internal {@link ProjectChangeEvent} out to per-project wire
1169
+ * notifications. Each `removed` entry pairs the id with the pre-removal
1170
+ * snapshot needed for the `'removed'` wire payload.
1171
+ */
1172
+ protected dispatchProjectChangeEvent(event: ProjectChangeEvent<TProject>): void {
1173
+ for (const id of event.added) {
1174
+ const project = this.services.workspace.ProjectManager.getProjectById(id);
1175
+ if (project) {
1176
+ this.clientProxy.onProjectsChanged({ project, reason: 'added' });
1177
+ }
1178
+ }
1179
+ for (const id of event.updated) {
1180
+ const project = this.services.workspace.ProjectManager.getProjectById(id);
1181
+ if (project) {
1182
+ this.clientProxy.onProjectsChanged({ project, reason: 'updated' });
1183
+ }
1184
+ }
1185
+ for (const entry of event.removed) {
1186
+ this.clientProxy.onProjectsChanged({ project: entry.snapshot, reason: 'removed' });
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * Resolve the wire-level `sourceClientId` for `document`'s events — the client
1192
+ * that authored its current version, from the version-author history on
1193
+ * `HydraniumTextDocuments`. The protocol-level counterpart of the internal
1194
+ * `AstDocumentManager.getAuthor`: a framework-internal rebuild has no author,
1195
+ * so this surfaces the {@link UNKNOWN_CLIENT_ID} presentation default. Adopters
1196
+ * that rebuild through non-text-document channels override to derive a
1197
+ * source id of their own.
1198
+ */
1199
+ protected resolveSourceClientId(document: LangiumDocument): string {
1200
+ const author = this.services.workspace.TextDocuments.getAuthor(document.textDocument.uri, document.textDocument.version);
1201
+ return author ?? UNKNOWN_CLIENT_ID;
1202
+ }
1203
+
1204
+ // --- Diagnostics (DataServerDiagnosticsProtocol) ---------------------------
1205
+ // Computed in THIS process: the data-server child holds the model store, so
1206
+ // these snapshots reflect the heap that actually carries the workspace
1207
+ // AST/CST — the process that OOMs. Each is also emitted through the tracer so
1208
+ // it reaches the server's log sink (e.g. a pod's stdout) and not only the
1209
+ // RPC caller.
1210
+
1211
+ async dumpServerState(args: DumpServerStateArgs): Promise<string> {
1212
+ const snapshot = await this.options.diagnostics.dumpServerState(this.services, args);
1213
+ this.tracer.info(snapshot);
1214
+ return snapshot;
1215
+ }
1216
+
1217
+ async writeHeapSnapshot(args: WriteServerHeapSnapshotArgs): Promise<string> {
1218
+ const filePath = await this.options.diagnostics.writeHeapSnapshot(args);
1219
+ this.tracer.info(`Heap snapshot written to ${filePath}`);
1220
+ return filePath;
1221
+ }
1222
+
1223
+ async dumpPodMemory(): Promise<string> {
1224
+ const snapshot = await this.options.diagnostics.dumpPodMemory();
1225
+ this.tracer.info(snapshot);
1226
+ return snapshot;
1227
+ }
1228
+
1229
+ async startProfiling(args: StartProfilingArgs): Promise<void> {
1230
+ // The provider is singleton-guarded (one inspector session per process);
1231
+ // a second start rejects there. Hold the capture so stopProfiling can end it.
1232
+ const capture = await this.options.diagnostics.startProfiling(args);
1233
+ // The connection may have closed (firing dispose) while start() was still
1234
+ // awaiting begin(); dispose saw no activeProfile yet, so stop it here rather
1235
+ // than leave the inspector singleton wedged for the process lifetime.
1236
+ if (this.disposed) {
1237
+ await capture.stop({}).catch(() => undefined);
1238
+ return;
1239
+ }
1240
+ this.activeProfile = capture;
1241
+ this.tracer.info('Profiling started');
1242
+ }
1243
+
1244
+ async stopProfiling(args: StopProfilingArgs): Promise<string> {
1245
+ if (!this.activeProfile) {
1246
+ throw new Error('No profiling capture is active; call startProfiling first.');
1247
+ }
1248
+ const capture = this.activeProfile;
1249
+ this.activeProfile = undefined;
1250
+ // Writing the artefacts, folding the window into `server-summary.json` and
1251
+ // formatting the report all happen inside the capture: each needs the
1252
+ // filesystem, and the report shape they pass between them is Node-side.
1253
+ const formatted = await capture.stop(args);
1254
+ this.tracer.info(formatted);
1255
+ return formatted;
1256
+ }
1257
+
1258
+ async getLatency(): Promise<LatencyReport> {
1259
+ return this.latency?.report() ?? { windowMs: 0, methods: [] };
1260
+ }
1261
+
1262
+ /** Resolve a partial options object into a fully-defaulted form. */
1263
+ protected resolveOptions(partial: DataServerOptions): ResolvedDataServerOptions {
1264
+ const additionalMethods = partial.additionalMethods ?? [];
1265
+ const builtIn = [...DATA_SERVER_PROTOCOL_METHODS, ...DATA_SERVER_DIAGNOSTICS_METHODS] as readonly string[];
1266
+ const overlap = additionalMethods.filter(name => builtIn.includes(name));
1267
+ if (overlap.length > 0) {
1268
+ throw new Error(
1269
+ `DataServer.additionalMethods overlaps with built-in data-server methods: ${overlap.join(', ')}. ` +
1270
+ 'Adopter protocols must not redeclare framework method names; rename the adopter method or remove it from additionalMethods.'
1271
+ );
1272
+ }
1273
+ return {
1274
+ subscriptionPhase: partial.subscriptionPhase ?? DataServer.DEFAULT_OPTIONS.subscriptionPhase,
1275
+ fingerprintStrategy: partial.fingerprintStrategy ?? 'transfer-document',
1276
+ methodNamespace: partial.methodNamespace ?? DATA_SERVER_WIRE_PREFIX,
1277
+ additionalMethods,
1278
+ excludedMethods: partial.excludedMethods ?? [],
1279
+ diagnostics: partial.diagnostics ?? defaultDataServerDiagnostics()
1280
+ };
1281
+ }
1282
+ }