@hydranium/data-server 1.0.0-next.10 → 1.0.0-next.104

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.
@@ -24,11 +24,11 @@ export function makeDataServerHarness(options) {
24
24
  const pair = makeDuplexConnectionPair();
25
25
  const server = options.server(pair.left);
26
26
  // The capture half is the shared client double, not a local copy: the same
27
- // recording semantics (all three channels, an override replacing rather than
27
+ // recording semantics (every channel, an override replacing rather than
28
28
  // supplementing) then hold for a client-side suite that stands the double up
29
29
  // without a server, so an assertion learnt against one reads the same in the
30
30
  // other.
31
- const { client: localClient, updates: events, saves, projectsChanges } = makeCapturingDataClient(options.client);
31
+ const { client: localClient, updates: events, saves, deletions, builds, projectsChanges } = makeCapturingDataClient(options.client);
32
32
  const proxy = createRpcProxy(pair.right, {
33
33
  methodNamespace: options.methodNamespace ?? DATA_SERVER_WIRE_PREFIX,
34
34
  localTarget: localClient,
@@ -40,6 +40,8 @@ export function makeDataServerHarness(options) {
40
40
  pair,
41
41
  events,
42
42
  saves,
43
+ deletions,
44
+ builds,
43
45
  projectsChanges,
44
46
  dispose: () => pair.dispose()
45
47
  };
@@ -1 +1 @@
1
- {"version":3,"file":"data-server-harness.js","sourceRoot":"","sources":["../../src/testing/data-server-harness.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,OAAO,EAAE,cAAc,EAA+D,MAAM,qBAAqB,CAAC;AAClH,OAAO,EACJ,4BAA4B,EAC5B,uBAAuB,EAMzB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAgB,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAA6B,MAAM,kCAAkC,CAAC;AA2EvG;;;;;;;;;GASG;AACH,MAAM,UAAU,qBAAqB,CAMlC,OAAgF;IAEhF,MAAM,IAAI,GAAG,wBAAwB,EAAE,CAAC;IACxC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEzC,2EAA2E;IAC3E,6EAA6E;IAC7E,6EAA6E;IAC7E,6EAA6E;IAC7E,SAAS;IACT,MAAM,EACH,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,MAAM,EACf,KAAK,EACL,eAAe,EACjB,GAAG,uBAAuB,CAAmC,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9E,MAAM,KAAK,GAAG,cAAc,CACzB,IAAI,CAAC,KAAK,EACV;QACG,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,uBAAuB;QACnE,WAAW,EAAE,WAAW;QACxB,YAAY,EAAE,4BAA4B;KAC5C,CACH,CAAC;IAEF,OAAO;QACJ,MAAM;QACN,KAAK;QACL,IAAI;QACJ,MAAM;QACN,KAAK;QACL,eAAe;QACf,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE;KAC/B,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"data-server-harness.js","sourceRoot":"","sources":["../../src/testing/data-server-harness.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,OAAO,EAAE,cAAc,EAA+D,MAAM,qBAAqB,CAAC;AAClH,OAAO,EACJ,4BAA4B,EAC5B,uBAAuB,EAQzB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAgB,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAA6B,MAAM,kCAAkC,CAAC;AA+EvG;;;;;;;;;GASG;AACH,MAAM,UAAU,qBAAqB,CAMlC,OAAgF;IAEhF,MAAM,IAAI,GAAG,wBAAwB,EAAE,CAAC;IACxC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEzC,2EAA2E;IAC3E,wEAAwE;IACxE,6EAA6E;IAC7E,6EAA6E;IAC7E,SAAS;IACT,MAAM,EACH,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,MAAM,EACf,KAAK,EACL,SAAS,EACT,MAAM,EACN,eAAe,EACjB,GAAG,uBAAuB,CAAmC,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9E,MAAM,KAAK,GAAG,cAAc,CACzB,IAAI,CAAC,KAAK,EACV;QACG,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,uBAAuB;QACnE,WAAW,EAAE,WAAW;QACxB,YAAY,EAAE,4BAA4B;KAC5C,CACH,CAAC;IAEF,OAAO;QACJ,MAAM;QACN,KAAK;QACL,IAAI;QACJ,MAAM;QACN,KAAK;QACL,SAAS;QACT,MAAM;QACN,eAAe;QACf,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE;KAC/B,CAAC;AACL,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/data-server",
3
- "version": "1.0.0-next.10",
3
+ "version": "1.0.0-next.104",
4
4
  "description": "Typed-RPC protocol head for the hydranium framework. Peer of @hydranium/core/lsp and @hydranium/glsp-server; built on @hydranium/core.",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -38,6 +38,14 @@
38
38
  "types": "./lib/node/index.d.ts",
39
39
  "default": "./lib/node/index.js"
40
40
  },
41
+ "./messages": {
42
+ "types": "./lib/messages/index.d.ts",
43
+ "default": "./lib/messages/index.js"
44
+ },
45
+ "./lib/messages": {
46
+ "types": "./lib/messages/index.d.ts",
47
+ "default": "./lib/messages/index.js"
48
+ },
41
49
  "./testing": {
42
50
  "types": "./lib/testing/index.d.ts",
43
51
  "default": "./lib/testing/index.js"
@@ -67,19 +75,19 @@
67
75
  "watch": "tsc -b -w --preserveWatchOutput"
68
76
  },
69
77
  "devDependencies": {
70
- "@hydranium/core": "1.0.0-next.10",
71
- "@hydranium/langium": "1.0.0-next.10",
72
- "@hydranium/protocol": "1.0.0-next.10",
78
+ "@hydranium/core": "1.0.0-next.104",
79
+ "@hydranium/langium": "1.0.0-next.104",
80
+ "@hydranium/protocol": "1.0.0-next.104",
73
81
  "rimraf": "^5.0.0",
74
82
  "typescript": "^5.8.0",
75
83
  "vscode-jsonrpc": "9.0.1",
76
84
  "vscode-languageserver-textdocument": "^1.0.12"
77
85
  },
78
86
  "peerDependencies": {
79
- "@hydranium/core": "1.0.0-next.10",
80
- "@hydranium/langium": "1.0.0-next.10",
81
- "@hydranium/protocol": "1.0.0-next.10",
82
- "vscode-jsonrpc": "^8.0.0 || ^9.0.0",
87
+ "@hydranium/core": "1.0.0-next.104",
88
+ "@hydranium/langium": "1.0.0-next.104",
89
+ "@hydranium/protocol": "1.0.0-next.104",
90
+ "vscode-jsonrpc": "9.0.1",
83
91
  "vscode-languageserver-textdocument": "^1.0.12"
84
92
  },
85
93
  "engines": {
@@ -89,5 +97,6 @@
89
97
  "access": "public"
90
98
  },
91
99
  "//browser": "Selects the diagnostics default per platform, the same mechanism @eclipse-glsp/server uses to swap its node build for its browser one. The Node module imports @hydranium/core/node; a browser bundler resolves the twin instead and therefore never follows that import, which is what keeps this package's `.` entry bundleable. Node ignores this field, so a Node host keeps the real implementation with no configuration. `check:neutral` is the proof it still applies — it bundles `.` for the browser and would fail on node:fs the moment the mapping stops matching.",
100
+ "//peerDependencies": "`vscode-jsonrpc` is EXACT rather than a range, because it is one link of an atomic chain with no independently movable link: `vscode-languageserver-protocol` depends on it at exactly 9.0.1, so any other value an adopter supplies is a SECOND physical copy rather than an upgrade. This wire stack breaks on copy identity rather than on structure — `ParameterStructures.auto` is a singleton compared by `===` in connection.js, so a request type built by one copy and sent over a connection owned by the other throws `Unknown parameter structure auto`. A caret or union range was rejected because npm then resolves a `vscode-jsonrpc@8.2.0` tree SILENTLY, with no diagnostic at all, and the failure surfaces only later at server init. Exact does not fail the install either — npm downgrades an unsatisfiable peer to a warning — but it is a NAMED ERESOLVE warning printing the required version beside the found one, and a hard error for anyone installing with `--strict-peer-deps`. It does NOT prevent a NESTED copy — `@eclipse-glsp/*` carries its own exact `vscode-jsonrpc@8.2.0` dependency and root `overrides` do not ship — so a consumer of the GLSP head must pin the chain in its own manifest, and docs/adopting/requirements.md carries the block to paste.",
92
101
  "//prepack": "The publish guard, and it deliberately is NOT a `prepare`: npm runs a workspace `prepare` BEFORE the root `postinstall` that applies patches/vscode-jsonrpc+9.0.1.patch, so building there fails on a cold clone and npm rolls the entire install back. `prepack` runs only when a tarball is made (`npm pack`, `npm publish`) and never on install, so it cannot break the install it has no business touching. It FAILS rather than rebuilds, because the rebuild is exactly the part that ordering defeats. What it defends against: `files` lists `lib`, `lib` is gitignored, and a `files` entry matching nothing is skipped SILENTLY — so `npm publish` from an unbuilt tree emits a tarball of `src` and nothing else, with no error."
93
102
  }
@@ -9,12 +9,15 @@
9
9
 
10
10
  import {
11
11
  createRpcProxy,
12
+ defineMessage,
12
13
  DisposableCollection,
13
14
  isDocumentSource,
14
15
  isElementSource,
15
16
  isSyntheticSource,
17
+ messageError,
16
18
  ReferenceSource,
17
19
  type CloseModelArgs,
20
+ type HydraniumResponseError,
18
21
  type Disposable,
19
22
  type ElementSource,
20
23
  type FindNextNameArgs,
@@ -28,8 +31,9 @@ import {
28
31
  type ReferenceTarget,
29
32
  type Tracer,
30
33
  type TransferDiagnostic,
31
- type TransferDocument,
32
- type TransferElement
34
+ TransferDocument,
35
+ type TransferElement,
36
+ asSnapshotVersion
33
37
  } from '@hydranium/protocol';
34
38
  import {
35
39
  DATA_SERVER_DIAGNOSTICS_METHODS,
@@ -52,11 +56,53 @@ import {
52
56
  } from '@hydranium/protocol/data';
53
57
  import { REVERT_ON_CLOSE_CLIENT_ID, UNKNOWN_CLIENT_ID } from '@hydranium/core';
54
58
  import { defaultDataServerDiagnostics } from './default-diagnostics.js';
59
+
60
+ /**
61
+ * Raised when a profiling command arrives with no capture running.
62
+ *
63
+ * A `ResponseError` rather than a plain `Error` so the identity survives to the
64
+ * response boundary, which is where the message is rendered. An unwrapped throw
65
+ * reaches the client as a generic internal error instead: no code for a caller
66
+ * to switch on, and nothing for the boundary to render from.
67
+ */
68
+ export const NO_ACTIVE_PROFILE = defineMessage(
69
+ 'hydranium/data-server/no-active-profile',
70
+ 'No profiling capture is active; call startProfiling first.'
71
+ );
72
+
73
+ /**
74
+ * The JSON-RPC code, unrelated to the catalogue code above: this one is numeric,
75
+ * survives reconstruction and is what a caller switches on.
76
+ */
77
+ export const NO_ACTIVE_PROFILE_CODE = 1002;
78
+
79
+ export const noActiveProfileError = (): HydraniumResponseError => messageError(NO_ACTIVE_PROFILE_CODE, NO_ACTIVE_PROFILE);
80
+
81
+ /**
82
+ * Raised when a reference question's wait for the build exceeds
83
+ * {@link DataServerOptions.referenceSettleTimeoutMs}.
84
+ *
85
+ * A `ResponseError` because a class name does not cross RPC: without the code a
86
+ * wire client cannot tell a wedged build from a reference that resolves to
87
+ * nothing, which is the distinction the wait exists to keep.
88
+ */
89
+ export const REFERENCE_SETTLE_TIMEOUT = defineMessage(
90
+ 'hydranium/data-server/reference-settle-timeout',
91
+ 'Timed out after {elapsedMs}ms waiting for the build to settle before answering a reference query.'
92
+ );
93
+
94
+ /** See {@link NO_ACTIVE_PROFILE_CODE} for why this is separate from the catalogue code. */
95
+ export const REFERENCE_SETTLE_TIMEOUT_CODE = 1003;
96
+
97
+ export const referenceSettleTimeoutError = (elapsedMs: number): HydraniumResponseError =>
98
+ messageError(REFERENCE_SETTLE_TIMEOUT_CODE, REFERENCE_SETTLE_TIMEOUT, { elapsedMs });
99
+
55
100
  import type { DataServerDiagnosticsProvider, DataServerProfileCapture } from './diagnostics-provider.js';
56
101
  import type {
57
102
  ClientTextDocumentChangeEvent,
58
103
  HydraniumLanguageServices,
59
104
  LogNameOptions,
105
+ AstDiagnostic,
60
106
  ModelService,
61
107
  ProjectChangeEvent,
62
108
  ServerSharedServices,
@@ -201,6 +247,22 @@ export interface DataServerOptions extends LogNameOptions {
201
247
  */
202
248
  readonly fingerprintStrategy?: FingerprintStrategy;
203
249
 
250
+ /**
251
+ * How long a reference question waits for the build to reach `Linked` before
252
+ * failing with {@link REFERENCE_SETTLE_TIMEOUT_CODE}. Bounded because the
253
+ * wait resolves off a build-phase notification, so a build cancelled below
254
+ * `Linked` with nothing after it leaves the request unanswered for the life
255
+ * of the server.
256
+ *
257
+ * Expiry rejects rather than reading the index anyway: the unsettled index
258
+ * is what the wait keeps the caller away from, so falling through to it on a
259
+ * slow build reinstates the fault — and on a name proposal persists it.
260
+ *
261
+ * Default {@link DataServer.DEFAULT_OPTIONS}, sized for a cold workspace
262
+ * still reaching `Linked` for the first time.
263
+ */
264
+ readonly referenceSettleTimeoutMs?: number;
265
+
204
266
  /**
205
267
  * Wire-method namespace for both inbound request handlers and the
206
268
  * outbound notification client proxy. Defaults to
@@ -258,15 +320,20 @@ export interface DataServerOptions extends LogNameOptions {
258
320
  * When supplied, every inbound data-server RPC is timed into this collector
259
321
  * (via the `createRpcProxy` binding) and exposed through
260
322
  * {@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).
323
+ * LSP connection can pass the SAME collector through `lspLatencyOptions` to
324
+ * that connection, so one report covers both heads. Absent by default (no
325
+ * timing overhead).
264
326
  */
265
327
  readonly latency?: LatencyCollector;
266
328
  }
267
329
 
268
- /** Fully-resolved variant — every field set, used internally after merging defaults. */
269
- interface ResolvedDataServerOptions {
330
+ /**
331
+ * Fully-resolved variant of {@link DataServerOptions} — every field set, after
332
+ * merging defaults. Returned by the `protected`
333
+ * {@link DataServer.resolveOptions} and held on {@link DataServer.options}, so
334
+ * an adopter changing how defaults resolve has to name it.
335
+ */
336
+ export interface ResolvedDataServerOptions {
270
337
  readonly subscriptionPhase: DocumentState;
271
338
  readonly fingerprintStrategy: FingerprintStrategy;
272
339
  readonly methodNamespace: string;
@@ -274,6 +341,7 @@ interface ResolvedDataServerOptions {
274
341
  readonly excludedMethods: readonly string[];
275
342
  /** Always resolved — to the caller's, or to the platform default. */
276
343
  readonly diagnostics: DataServerDiagnosticsProvider;
344
+ readonly referenceSettleTimeoutMs: number;
277
345
  }
278
346
 
279
347
  /**
@@ -301,9 +369,11 @@ interface ResolvedDataServerOptions {
301
369
  * any names supplied via {@link DataServerOptions.additionalMethods}) and
302
370
  * builds the {@link DataClientProtocol} notification proxy on the same
303
371
  * 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`.
372
+ * `localTarget`) under the configured namespace, and subscribes two listeners
373
+ * at the configured phase: a `DocumentBuilder.onDocumentPhase` one dispatching
374
+ * per-document `clientProxy.onDocumentUpdated` for watched URIs, and a
375
+ * `DocumentBuilder.onBuildPhase` one dispatching a single
376
+ * `clientProxy.onDocumentsBuilt` naming the batch's unwatched documents.
307
377
  *
308
378
  * The data-server requires the full {@link ServerSharedServices}
309
379
  * shape — `HydraniumTextDocuments` for client-attributed updates,
@@ -331,8 +401,9 @@ export class DataServer<
331
401
  * Framework defaults, exposed so an adopter can spread them when
332
402
  * extending rather than restate a value the framework may change.
333
403
  */
334
- static readonly DEFAULT_OPTIONS: Required<Pick<DataServerOptions, 'subscriptionPhase'>> = {
335
- subscriptionPhase: DocumentState.Validated
404
+ static readonly DEFAULT_OPTIONS: Required<Pick<DataServerOptions, 'subscriptionPhase' | 'referenceSettleTimeoutMs'>> = {
405
+ subscriptionPhase: DocumentState.Validated,
406
+ referenceSettleTimeoutMs: 60_000
336
407
  };
337
408
 
338
409
  protected readonly options: ResolvedDataServerOptions;
@@ -356,10 +427,12 @@ export class DataServer<
356
427
  /**
357
428
  * Snapshot of the most recent `DocumentBuilder.onUpdate` event. Drives
358
429
  * `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.
430
+ * a URI in the `changed` list emits `'changed'`, otherwise `'rebuilt'`
431
+ * (cascade rebuild from a dependent URI's change). The same mechanism
432
+ * `AstDocumentManager.onUpdate` uses on the LSP side, so reason fidelity
433
+ * stays consistent between heads. The `deleted` half is read by
434
+ * {@link dispatchDeleteEvents} rather than by the reason discrimination,
435
+ * a deletion travelling on its own channel.
363
436
  */
364
437
  protected lastBuildUpdate?: { changed: readonly URI[]; deleted: readonly URI[] };
365
438
  /**
@@ -420,7 +493,7 @@ export class DataServer<
420
493
  /** Per-method RPC latency collector, when the head opted in via {@link DataServerOptions.latency}. */
421
494
  protected readonly latency?: LatencyCollector;
422
495
  /** Encoder pulled from DI. Adopters rebind `services.model.TransferEncoder` to a typed-overlay subclass. */
423
- protected readonly encoder: TransferEncoder<unknown, TDiagnostic>;
496
+ protected readonly encoder: TransferEncoder<TDiagnostic>;
424
497
  /**
425
498
  * In-process workspace facade — the lifecycle delegate `get` / `update` / `save` go through.
426
499
  * The facade's AstDocument diagnostic shape is intentionally typed `unknown` here: adopters
@@ -429,7 +502,7 @@ export class DataServer<
429
502
  * `astDocumentToTransferDocument` accepts both shapes (wire-shape or LSP-shape) and projects
430
503
  * to wire shape on the return — see `TransferEncoder.astDocumentToTransferDocument`.
431
504
  */
432
- protected readonly modelService: ModelService<AstNode, unknown, TTransfer>;
505
+ protected readonly modelService: ModelService<AstNode, AstDiagnostic, TTransfer>;
433
506
 
434
507
  constructor(
435
508
  protected readonly connection: MessageConnection,
@@ -448,8 +521,8 @@ export class DataServer<
448
521
  'Did you compose `createServerSharedModule(ctx)` into your shared module?'
449
522
  );
450
523
  }
451
- this.encoder = encoder as TransferEncoder<unknown, TDiagnostic>;
452
- this.modelService = modelService as ModelService<AstNode, unknown, TTransfer>;
524
+ this.encoder = encoder as TransferEncoder<TDiagnostic>;
525
+ this.modelService = modelService as ModelService<AstNode, AstDiagnostic, TTransfer>;
453
526
  const excluded = new Set<string>(this.options.excludedMethods);
454
527
  const registeredMethods = [
455
528
  ...DATA_SERVER_PROTOCOL_METHODS,
@@ -465,11 +538,16 @@ export class DataServer<
465
538
  methodNamespace: this.options.methodNamespace,
466
539
  localTarget: this,
467
540
  localMethods: registeredMethods as readonly (keyof this & string)[],
468
- latency: this.latency
541
+ latency: this.latency,
542
+ // Bound at the chokepoint rather than per handler, so an adopter's
543
+ // `additionalMethods` are rendered on the same terms as the
544
+ // framework's own rejections.
545
+ renderErrorMessage: error => this.services.MessageRenderer.renderError(error)
469
546
  });
470
547
  this.disposables.push(
471
548
  this.services.workspace.DocumentBuilder.onUpdate((changed, deleted) => {
472
549
  this.lastBuildUpdate = { changed, deleted };
550
+ this.dispatchDeleteEvents(deleted);
473
551
  })
474
552
  );
475
553
  this.subscribeToDocumentBuilder();
@@ -539,19 +617,46 @@ export class DataServer<
539
617
  holders = new Set<string>();
540
618
  this.openedDocuments.set(args.uri, holders);
541
619
  }
620
+ const heldBefore = holders.has(args.clientId);
542
621
  holders.add(args.clientId);
543
- const document = await this.getModelDocument({ uri: args.uri });
622
+ let document: TransferDocument<TTransfer, TDiagnostic>;
623
+ try {
624
+ document = await this.getModelDocument({ uri: args.uri });
625
+ } catch (error: unknown) {
626
+ // The snapshot is the last step, and failing it fails the RPC — so the
627
+ // caller sees no open and has no reason to close. Release what this
628
+ // call took rather than leaving a hold nothing on either side is
629
+ // tracking. A repeat open by a client that already held the URI keeps
630
+ // it: the hold is one per `(uri, clientId)`, so releasing here would
631
+ // revoke an earlier open that is still legitimately in use.
632
+ if (!heldBefore) {
633
+ try {
634
+ // Untracked only once the close has actually LANDED. Dropping the
635
+ // record first gives a failed close the same effect as a
636
+ // successful one: the hold survives on the document store while
637
+ // the connection-close drain — the only thing left that would
638
+ // release it — has no record of it.
639
+ await this.modelService.close({ uri: args.uri, clientId: args.clientId });
640
+ this.forgetOpenDocument(args.uri, args.clientId);
641
+ } catch {
642
+ // Swallowed so the caller receives the reason the OPEN failed
643
+ // rather than a secondary one about the cleanup. The record stays
644
+ // behind deliberately, so teardown retries the release.
645
+ }
646
+ }
647
+ throw error;
648
+ }
544
649
  // Project the authoritative client-facing version onto the open snapshot.
545
650
  // `getModelDocument` encodes the freshly-built AST snapshot, whose version
546
651
  // is the `LangiumDocument`'s own `textDocument.version`. That lags the
547
652
  // multi-client synced version whenever the open seeded the synced document
548
653
  // with a caller-supplied version id — and the synced version is what
549
654
  // `ModelService.update`'s optimistic-concurrency gate compares
550
- // `baseVersion` against, so reporting the snapshot's would make the
655
+ // `basedOn` against, so reporting the snapshot's would make the
551
656
  // caller's first tagged write self-conflict. A genuine concurrent edit
552
657
  // still trips the gate and is reconciled by the caller's replay rather
553
658
  // than predicted here.
554
- return { ...document, version: this.services.workspace.TextDocuments.version(args.uri) };
659
+ return { ...document, version: asSnapshotVersion(this.services.workspace.TextDocuments.version(args.uri)) };
555
660
  }
556
661
 
557
662
  async closeModelDocument(args: CloseModelArgs): Promise<void> {
@@ -734,21 +839,59 @@ export class DataServer<
734
839
  // (see resolveReferenceServices) and delegate.
735
840
  // ============================================================
736
841
 
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;
842
+ /**
843
+ * Settle the build before a reference question is answered from the index.
844
+ *
845
+ * A query issued right after a model update otherwise races the rebuild and
846
+ * reads an index missing the symbols it asks about. The cost is not confined
847
+ * to this call: a scope built inside that window is cached, and the build's
848
+ * remaining documents link against it.
849
+ *
850
+ * Waits the source document specifically, but ONLY when one is loaded at
851
+ * that URI: a synthetic source can address a URI with no document (a
852
+ * directory URI, typically), where a per-URI `waitUntil` throws "No document
853
+ * found". Falls back to a global settle, matching the id-based
854
+ * `ElementSource` path.
855
+ *
856
+ * Bounded by {@link DataServerOptions.referenceSettleTimeoutMs}.
857
+ */
858
+ protected async awaitReferencesLinked(source: ReferenceSource): Promise<void> {
859
+ const uri = isDocumentSource(source) || isSyntheticSource(source) ? UriUtils.toUri(source.uri) : undefined;
746
860
  const waitUri = uri && this.services.workspace.LangiumDocuments.hasDocument(uri) ? uri : undefined;
747
- await this.services.workspace.DocumentBuilder.waitUntil(DocumentState.Linked, waitUri);
861
+ const stopwatch = this.services.Clock.stopwatch();
862
+ let timer: Disposable | undefined;
863
+ const timeout = new Promise<void>((resolve, reject) => {
864
+ timer = this.services.Clock.setTimer(() => {
865
+ const elapsedMs = Math.round(stopwatch.elapsedMs);
866
+ // A per-URI wait resolves off a document-phase notification, so a
867
+ // missed one strands a wait on a document that HAS reached the
868
+ // phase. Re-read before failing; the workspace-wide wait has no
869
+ // equivalent reading and can only reject.
870
+ const document = waitUri ? this.services.workspace.LangiumDocuments.getDocument(waitUri) : undefined;
871
+ if (waitUri && document && document.state >= DocumentState.Linked) {
872
+ this.tracer
873
+ .withUri(waitUri.toString())
874
+ .warn(`Missed the 'Linked' notification after ${elapsedMs}ms; the document already reached it`);
875
+ resolve();
876
+ return;
877
+ }
878
+ reject(referenceSettleTimeoutError(elapsedMs));
879
+ }, this.options.referenceSettleTimeoutMs);
880
+ });
881
+ try {
882
+ await Promise.race([this.services.workspace.DocumentBuilder.waitUntil(DocumentState.Linked, waitUri), timeout]);
883
+ } finally {
884
+ timer?.dispose();
885
+ }
886
+ }
887
+
888
+ async findReferenceCandidates(ctx: ReferenceContext): Promise<ReferenceCandidate[]> {
889
+ await this.awaitReferencesLinked(ctx.source);
748
890
  return this.resolveReferenceServices(ctx.source).CandidateProvider.find(ctx);
749
891
  }
750
892
 
751
893
  async resolveReference(ref: ReferenceRequest): Promise<ReferenceTarget<TTransfer> | undefined> {
894
+ await this.awaitReferencesLinked(ref.source);
752
895
  const resolved = this.resolveReferenceServices(ref.source).CandidateProvider.resolveCandidate(ref);
753
896
  if (!resolved) {
754
897
  return undefined;
@@ -764,7 +907,14 @@ export class DataServer<
764
907
  // synthetic source — and the create-element flow driving this method is
765
908
  // the one that produces directory URIs, on which a bare
766
909
  // `getServices(uri)` throws while `findReferenceCandidates` succeeds.
767
- const nameProvider = this.resolveReferenceServices(ReferenceSource.synthetic(args.uri, args.type)).NameProvider;
910
+ const source = ReferenceSource.synthetic(args.uri, args.type, args.language);
911
+ // Mid-rebuild the names of documents not yet re-indexed are absent from
912
+ // the taken set, and the caller PERSISTS what it is handed. Nothing
913
+ // reserves the answer either, so a name is free of the collisions the
914
+ // index knows about and of nothing else — two callers racing get the same
915
+ // proposal, which only uniqueness at the write can catch.
916
+ await this.awaitReferencesLinked(source);
917
+ const nameProvider = this.resolveReferenceServices(source).NameProvider;
768
918
  const tier = args.tier ?? 'project';
769
919
  if (tier === 'public') {
770
920
  return nameProvider.findNextProjectQualifiedName(args.type, args.proposal);
@@ -811,6 +961,9 @@ export class DataServer<
811
961
  /**
812
962
  * Pick the language that owns a reference source, in order:
813
963
  *
964
+ * 0. A {@link isSyntheticSource} naming its own `language` routes there.
965
+ * First because every step below infers, and an inference must not
966
+ * override a caller that has said which grammar it means.
814
967
  * 1. A URI-bearing source ({@link isDocumentSource}/{@link isSyntheticSource})
815
968
  * whose URI resolves to a registered language routes by URI.
816
969
  * 2. A single-language workspace always routes to that one language — so
@@ -829,6 +982,15 @@ export class DataServer<
829
982
  */
830
983
  protected resolveReferenceLanguage(source: ReferenceSource): HydraniumLanguageServices | undefined {
831
984
  const registry = this.services.ServiceRegistry;
985
+ if (isSyntheticSource(source) && source.language !== undefined) {
986
+ const declared = registry.getServicesById(source.language);
987
+ if (declared) {
988
+ return declared;
989
+ }
990
+ // Falling through on an UNREGISTERED id rather than failing: the id
991
+ // crosses the wire, so a client built against a head with one more
992
+ // grammar would otherwise lose queries the steps below can answer.
993
+ }
832
994
  // `getServicesFor` (non-throwing, one ladder walk) gates the URI lookup:
833
995
  // an extensionless / unregistered URI falls through to the steps below
834
996
  // rather than throwing "no services for the extension ''".
@@ -945,14 +1107,9 @@ export class DataServer<
945
1107
  // decides policy at its own layer; `root` is optional on the envelope
946
1108
  // so the compiler forces that decision. Adopters preferring to throw
947
1109
  // override `envelope`.
948
- return {
949
- uri: uri.toString(),
950
- version: 0,
951
- root: undefined,
952
- diagnostics: [] as TDiagnostic[]
953
- };
1110
+ return TransferDocument.absent<TTransfer, TDiagnostic>(uri.toString());
954
1111
  }
955
- return this.encoder.toTransferDocument<AstNode>(document) as unknown as TransferDocument<TTransfer, TDiagnostic>;
1112
+ return this.encoder.toTransferDocument(document) as unknown as TransferDocument<TTransfer, TDiagnostic>;
956
1113
  }
957
1114
 
958
1115
  /**
@@ -967,6 +1124,57 @@ export class DataServer<
967
1124
  this.dispatchPhaseEvent(document, cancelToken)
968
1125
  )
969
1126
  );
1127
+ // The BUILD-phase hook, not the per-document one: this notification is one
1128
+ // message per build rather than one per document, and Langium hands the
1129
+ // whole batch over here. It also does not fire for a cancelled build,
1130
+ // which is what the per-document dispatch has to check by hand.
1131
+ this.disposables.push(
1132
+ this.services.workspace.DocumentBuilder.onBuildPhase(this.options.subscriptionPhase, built => this.dispatchBuiltEvent(built))
1133
+ );
1134
+ }
1135
+
1136
+ /**
1137
+ * Report the documents that reached {@link DataServerOptions.subscriptionPhase}
1138
+ * and that NOBODY on this
1139
+ * connection is watching — the ones a client was not told about through
1140
+ * {@link dispatchPhaseEvent}, which is gated per URI.
1141
+ *
1142
+ * **The gap this closes is the one no other source can observe.** A document
1143
+ * the client watches, it hears about already. A file changed on disk, the
1144
+ * host's own filesystem watcher reports — and on a browser host, where the
1145
+ * workspace lives behind this head, an external change cannot happen at all.
1146
+ * What is left, and what nothing outside the server can see, is a document
1147
+ * rebuilt because something it DEPENDS ON changed: its file never changed, so
1148
+ * a filesystem watcher is silent by construction, and it has no subscriber, so
1149
+ * the update channel is silent by design. A consumer displaying data derived
1150
+ * from that document — a tree label, a decorator — otherwise goes stale with
1151
+ * no signal from any source, and repairs itself the moment someone opens the
1152
+ * file to investigate, which is what makes it expensive to diagnose later.
1153
+ *
1154
+ * Payload-free on purpose: URIs only, so a client re-reads what it displays
1155
+ * rather than being handed transfer documents it did not ask for. That is the
1156
+ * property the subscription map protects, and it is preserved here by
1157
+ * carrying no document rather than by gating the message.
1158
+ *
1159
+ * Quiet in the common case. Editing a document that an editor has open leaves
1160
+ * that URI watched and therefore out of this set, so a build with no
1161
+ * dependents produces nothing at all, and workspace initialisation produces
1162
+ * nothing because it does not build to this phase. The ceiling is a
1163
+ * whole-workspace rebuild at the subscription phase: one message, URIs only.
1164
+ *
1165
+ * The URI is canonicalised for the same reason the subscription map is keyed
1166
+ * that way, and is untested for the same reason as its twin in
1167
+ * {@link dispatchDeleteEvents}: the builder reports URIs out of its own
1168
+ * store, so a non-canonical one cannot be produced without a fixture
1169
+ * asserting a shape the real system never emits.
1170
+ */
1171
+ protected dispatchBuiltEvent(built: readonly LangiumDocument[]): void {
1172
+ const uris = built.map(document => this.canonicalKey(document.uri.toString())).filter(uri => !this.subscriptions.has(uri));
1173
+ if (uris.length === 0) {
1174
+ return;
1175
+ }
1176
+ this.tracer.debug(`Emit onDocumentsBuilt: ${uris.length} unwatched document(s)`);
1177
+ this.clientProxy.onDocumentsBuilt({ uris });
970
1178
  }
971
1179
 
972
1180
  /**
@@ -1004,6 +1212,56 @@ export class DataServer<
1004
1212
  );
1005
1213
  }
1006
1214
 
1215
+ /**
1216
+ * Fan out a deletion for each removed URI, to EVERY client on the connection
1217
+ * rather than only to the ones watching that URI.
1218
+ *
1219
+ * **Deliberately ungated, where {@link dispatchPhaseEvent} and
1220
+ * {@link dispatchSaveEvent} are gated.** Those two carry a built document and
1221
+ * fire on every build, so the subscription map is what keeps bandwidth
1222
+ * proportional to what a client asked for. A deletion is neither: it is one
1223
+ * URI, it is rare, and it reports the workspace's STRUCTURE rather than a
1224
+ * document's content. Gating it forces any consumer that displays the
1225
+ * workspace — a model tree, a file decorator — to learn about disappearances
1226
+ * from a filesystem watcher instead, which a client hosted in a browser has
1227
+ * no way to run: its workspace lives behind the head. A client that does not
1228
+ * care filters on the URI, which costs it a comparison.
1229
+ *
1230
+ * The precedent is the last-close revert broadcast (see
1231
+ * {@link pendingRevertBroadcasts}), where the same judgement was already made
1232
+ * in the other direction: a transition that matters enough is delivered
1233
+ * without a subscription.
1234
+ *
1235
+ * Runs from the `DocumentBuilder.onUpdate` listener rather than from a phase
1236
+ * listener, which is the only place a deletion is observable: `update`
1237
+ * removes the document before deriving the rebuild set, so it is never built.
1238
+ * That ordering also puts this notification ahead of the `'rebuilt'` events
1239
+ * for the dependents whose references the deletion just broke.
1240
+ *
1241
+ * The `deleted` list arrives already expanded to concrete document URIs —
1242
+ * `deleteDocuments` resolves a directory URI to the documents beneath it — so
1243
+ * no caller has to handle a directory here.
1244
+ *
1245
+ * Per-URI derived state is dropped for the same reason it is dropped
1246
+ * anywhere: it describes a document that no longer exists.
1247
+ * {@link lastEmittedFingerprint} in particular can outlive its subscriptions
1248
+ * through the last-close revert path — left behind, it is adopted as the
1249
+ * baseline by the next {@link watchModelDocument} and suppresses the first
1250
+ * emit after the file returns with its previous content.
1251
+ *
1252
+ * The subscription itself SURVIVES: a recreated file resumes delivering to
1253
+ * the same watchers with no re-subscription, and a client that answers the
1254
+ * deletion by closing releases the watch through `closeModelDocument` anyway.
1255
+ */
1256
+ protected dispatchDeleteEvents(deleted: readonly URI[]): void {
1257
+ for (const removed of deleted) {
1258
+ const uri = this.canonicalKey(removed.toString());
1259
+ this.lastEmittedFingerprint.delete(uri);
1260
+ this.pendingRevertBroadcasts.delete(uri);
1261
+ this.clientProxy.onDocumentDeleted({ uri });
1262
+ }
1263
+ }
1264
+
1007
1265
  /** Fan out a save event for the document's URI, gated by the subscription map. */
1008
1266
  protected dispatchSaveEvent(event: ClientTextDocumentChangeEvent<TextDocument>): void {
1009
1267
  // The save event arrives under the CLIENT URI the text store keys by (e.g. a
@@ -1122,26 +1380,19 @@ export class DataServer<
1122
1380
  /**
1123
1381
  * Discriminate the reason for a phase-event-driven update notification.
1124
1382
  * Uses the most recent `DocumentBuilder.onUpdate` snapshot:
1125
- * - URI in the `deleted` list → `'deleted'`
1126
1383
  * - URI in the `changed` list → `'changed'` (the URI was passed to
1127
1384
  * `documentBuilder.update(changed, deleted)`, which spans `didChange`
1128
1385
  * text-document events and programmatic `update([uri], [])` calls).
1129
1386
  * - Otherwise → `'rebuilt'` (cascade re-derivation: this URI was rebuilt
1130
1387
  * because something it depends on changed; its own text wasn't flagged).
1131
1388
  *
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.
1389
+ * `'saved'` is NOT emitted here — saves take the dedicated
1390
+ * `DataClientProtocol.onDocumentSaved` channel, and adopters wanting a
1391
+ * unified stream synthesise it in their bridge layer. A deletion is not a
1392
+ * reason at all; see {@link dispatchDeleteEvents}.
1135
1393
  */
1136
1394
  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';
1395
+ return this.lastBuildUpdate?.changed.some(changed => UriUtils.equals(changed, uri)) ? 'changed' : 'rebuilt';
1145
1396
  }
1146
1397
 
1147
1398
  /**
@@ -1243,7 +1494,7 @@ export class DataServer<
1243
1494
 
1244
1495
  async stopProfiling(args: StopProfilingArgs): Promise<string> {
1245
1496
  if (!this.activeProfile) {
1246
- throw new Error('No profiling capture is active; call startProfiling first.');
1497
+ throw noActiveProfileError();
1247
1498
  }
1248
1499
  const capture = this.activeProfile;
1249
1500
  this.activeProfile = undefined;
@@ -1276,7 +1527,8 @@ export class DataServer<
1276
1527
  methodNamespace: partial.methodNamespace ?? DATA_SERVER_WIRE_PREFIX,
1277
1528
  additionalMethods,
1278
1529
  excludedMethods: partial.excludedMethods ?? [],
1279
- diagnostics: partial.diagnostics ?? defaultDataServerDiagnostics()
1530
+ diagnostics: partial.diagnostics ?? defaultDataServerDiagnostics(),
1531
+ referenceSettleTimeoutMs: partial.referenceSettleTimeoutMs ?? DataServer.DEFAULT_OPTIONS.referenceSettleTimeoutMs
1280
1532
  };
1281
1533
  }
1282
1534
  }