@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.
- package/README.md +5 -1
- package/lib/data-server.d.ts +171 -22
- package/lib/data-server.d.ts.map +1 -1
- package/lib/data-server.js +253 -44
- package/lib/data-server.js.map +1 -1
- package/lib/messages/index.d.ts +18 -0
- package/lib/messages/index.d.ts.map +1 -0
- package/lib/messages/index.js +18 -0
- package/lib/messages/index.js.map +1 -0
- package/lib/testing/data-server-harness.d.ts +5 -1
- package/lib/testing/data-server-harness.d.ts.map +1 -1
- package/lib/testing/data-server-harness.js +4 -2
- package/lib/testing/data-server-harness.js.map +1 -1
- package/package.json +17 -8
- package/src/data-server.ts +308 -56
- package/src/messages/index.ts +19 -0
- package/src/testing/data-server-harness.ts +11 -1
|
@@ -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 (
|
|
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,
|
|
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.
|
|
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.
|
|
71
|
-
"@hydranium/langium": "1.0.0-next.
|
|
72
|
-
"@hydranium/protocol": "1.0.0-next.
|
|
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.
|
|
80
|
-
"@hydranium/langium": "1.0.0-next.
|
|
81
|
-
"@hydranium/protocol": "1.0.0-next.
|
|
82
|
-
"vscode-jsonrpc": "
|
|
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
|
}
|
package/src/data-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
263
|
-
*
|
|
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
|
-
/**
|
|
269
|
-
|
|
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
|
|
305
|
-
* `DocumentBuilder.onDocumentPhase`
|
|
306
|
-
*
|
|
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'`,
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
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<
|
|
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,
|
|
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<
|
|
452
|
-
this.modelService = modelService as ModelService<AstNode,
|
|
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
|
-
|
|
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
|
-
// `
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1133
|
-
* `DataClientProtocol.onDocumentSaved` channel
|
|
1134
|
-
* unified
|
|
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
|
-
|
|
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
|
|
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
|
}
|