@hydranium/core 1.0.0-next.117 → 1.0.0-next.123
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/lib/documents/ast-document-manager.d.ts +8 -44
- package/lib/documents/ast-document-manager.d.ts.map +1 -1
- package/lib/documents/ast-document-manager.js +22 -41
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/client-session-errors.d.ts +4 -33
- package/lib/documents/client-session-errors.d.ts.map +1 -1
- package/lib/documents/client-session-errors.js +4 -46
- package/lib/documents/client-session-errors.js.map +1 -1
- package/lib/documents/client-session-registry.d.ts +40 -20
- package/lib/documents/client-session-registry.d.ts.map +1 -1
- package/lib/documents/client-session-registry.js +33 -20
- package/lib/documents/client-session-registry.js.map +1 -1
- package/lib/documents/file-system-task-queue.d.ts +49 -0
- package/lib/documents/file-system-task-queue.d.ts.map +1 -0
- package/lib/documents/file-system-task-queue.js +34 -0
- package/lib/documents/file-system-task-queue.js.map +1 -0
- package/lib/documents/hydranium-text-documents.d.ts +138 -13
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +283 -40
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/documents/index.d.ts +1 -0
- package/lib/documents/index.d.ts.map +1 -1
- package/lib/documents/index.js +1 -0
- package/lib/documents/index.js.map +1 -1
- package/lib/langium/document-builder/document-builder.d.ts +15 -2
- package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
- package/lib/langium/document-builder/document-builder.js +15 -2
- package/lib/langium/document-builder/document-builder.js.map +1 -1
- package/lib/langium/integrity/integrity-service.d.ts +3 -2
- package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
- package/lib/langium/integrity/integrity-service.js +9 -6
- package/lib/langium/integrity/integrity-service.js.map +1 -1
- package/lib/langium/language-module.d.ts +2 -2
- package/lib/langium/language-module.js +2 -2
- package/lib/langium/language-module.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +203 -24
- package/lib/langium/model-service/client-session.d.ts.map +1 -1
- package/lib/langium/model-service/client-session.js +233 -29
- package/lib/langium/model-service/client-session.js.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +62 -217
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +54 -357
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/langium/module.d.ts +11 -1
- package/lib/langium/module.d.ts.map +1 -1
- package/lib/langium/module.js +5 -1
- package/lib/langium/module.js.map +1 -1
- package/lib/langium/update-rewrite/update-rewrite.d.ts +1 -1
- package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
- package/lib/langium/workspace/in-memory-file-system-provider.js +10 -2
- package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
- package/lib/langium/workspace/write-lock-scope.d.ts +1 -1
- package/lib/langium/workspace/write-lock-scope.js +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts +69 -67
- package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.js +111 -89
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/lsp/shared-module.d.ts +4 -8
- package/lib/lsp/shared-module.d.ts.map +1 -1
- package/lib/lsp/shared-module.js.map +1 -1
- package/lib/lsp/start-language-server.d.ts +3 -4
- package/lib/lsp/start-language-server.d.ts.map +1 -1
- package/lib/lsp/start-language-server.js +3 -4
- package/lib/lsp/start-language-server.js.map +1 -1
- package/lib/testing/make-test-services.d.ts +16 -4
- package/lib/testing/make-test-services.d.ts.map +1 -1
- package/lib/testing/make-test-services.js +6 -2
- package/lib/testing/make-test-services.js.map +1 -1
- package/lib/testing/node/lsp-server-connection.d.ts +1 -1
- package/lib/testing/node/scratch-workspace.js +1 -1
- package/lib/testing/run-update-pipeline.d.ts +4 -4
- package/lib/testing/run-update-pipeline.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts +5 -5
- package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.js +6 -8
- package/lib/testing/stub-ast-document-manager.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.d.ts +10 -5
- package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +16 -9
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/lib/testing/stub-model-service.d.ts +2 -2
- package/lib/testing/stub-model-service.js +2 -2
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +27 -78
- package/src/documents/client-session-errors.ts +11 -43
- package/src/documents/client-session-registry.ts +54 -28
- package/src/documents/file-system-task-queue.ts +68 -0
- package/src/documents/hydranium-text-documents.ts +348 -44
- package/src/documents/index.ts +1 -0
- package/src/langium/document-builder/document-builder.ts +16 -2
- package/src/langium/integrity/integrity-service.ts +9 -6
- package/src/langium/language-module.ts +4 -4
- package/src/langium/model-service/client-session.ts +363 -39
- package/src/langium/model-service/model-service.ts +91 -420
- package/src/langium/module.ts +14 -2
- package/src/langium/update-rewrite/update-rewrite.ts +1 -1
- package/src/langium/workspace/in-memory-file-system-provider.ts +11 -2
- package/src/langium/workspace/write-lock-scope.ts +1 -1
- package/src/lsp/hydranium-document-update-handler.ts +138 -92
- package/src/lsp/shared-module.ts +4 -8
- package/src/lsp/start-language-server.ts +3 -4
- package/src/testing/make-test-services.ts +25 -6
- package/src/testing/node/lsp-server-connection.ts +1 -1
- package/src/testing/node/scratch-workspace.ts +1 -1
- package/src/testing/run-update-pipeline.ts +4 -4
- package/src/testing/stub-ast-document-manager.ts +10 -24
- package/src/testing/stub-hydranium-text-documents.ts +29 -16
- package/src/testing/stub-model-service.ts +2 -2
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// on lsp-server, contradicting the peer architecture.
|
|
21
21
|
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
|
22
22
|
import { NormalizedTextDocuments } from '@hydranium/langium/lsp';
|
|
23
|
-
import {
|
|
23
|
+
import { URI, UriUtils } from '@hydranium/langium';
|
|
24
24
|
import { type ServerSharedServices } from '../langium/module.js';
|
|
25
25
|
import {
|
|
26
26
|
type ApplyWorkspaceEditResult,
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
type DidOpenTextDocumentParams,
|
|
32
32
|
type DidSaveTextDocumentParams,
|
|
33
33
|
type Disposable,
|
|
34
|
-
|
|
34
|
+
Emitter,
|
|
35
35
|
type Event,
|
|
36
36
|
type HandlerResult,
|
|
37
37
|
OptionalVersionedTextDocumentIdentifier,
|
|
@@ -46,11 +46,20 @@ import {
|
|
|
46
46
|
type WillSaveTextDocumentParams
|
|
47
47
|
} from 'vscode-languageserver';
|
|
48
48
|
import { type DocumentUri, TextDocument, type TextDocumentContentChangeEvent } from 'vscode-languageserver-textdocument';
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
type CanonicalUri,
|
|
51
|
+
type LanguageClientUri,
|
|
52
|
+
asLanguageClientUri,
|
|
53
|
+
DisposableCollection,
|
|
54
|
+
type Stopwatch,
|
|
55
|
+
type Tracer
|
|
56
|
+
} from '@hydranium/protocol';
|
|
50
57
|
import { type LogNameOptions } from '../langium/diagnostics/logger.js';
|
|
58
|
+
import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
|
|
59
|
+
import { isConnectionGoneError } from '../util/connection-liveness.js';
|
|
51
60
|
import { LANGUAGE_CLIENT_ID } from './client-ids.js';
|
|
52
61
|
import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
|
|
53
|
-
import { type ClientSessionClosedEvent, ClientSessionRegistry, type
|
|
62
|
+
import { type ClientSessionClosedEvent, ClientSessionRegistry, type SessionEndCause } from './client-session-registry.js';
|
|
54
63
|
import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
|
|
55
64
|
|
|
56
65
|
/**
|
|
@@ -77,6 +86,39 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
|
|
|
77
86
|
* Adopters with a custom text-document type (rare) pass their factory.
|
|
78
87
|
*/
|
|
79
88
|
readonly configuration?: TextDocumentsConfiguration<T>;
|
|
89
|
+
/**
|
|
90
|
+
* How long a document whose last open closed because its client's
|
|
91
|
+
* connection was lost keeps its text before it reverts to disk. An open by
|
|
92
|
+
* a client lost from the document, within this time of its own loss,
|
|
93
|
+
* cancels the revert, so a client that registers again under its id after
|
|
94
|
+
* a dropped connection finds its unsaved edits. Any other open releases the
|
|
95
|
+
* document first and then opens it as a first open does, from the text the
|
|
96
|
+
* opener supplies (an editor's own) or else from the file. Meanwhile the
|
|
97
|
+
* document counts as open for the integrity service, which therefore writes
|
|
98
|
+
* none of its unsaved text to disk. A close the client makes itself, or
|
|
99
|
+
* ending its session, reverts at once whatever this is.
|
|
100
|
+
*
|
|
101
|
+
* Defaults to 10 s. `0` reverts such a document at once as well, released
|
|
102
|
+
* in the close itself rather than on a timer.
|
|
103
|
+
*/
|
|
104
|
+
readonly revertGraceMs?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The default of {@link HydraniumTextDocumentsOptions.revertGraceMs}: long
|
|
109
|
+
* enough for a client whose connection dropped to register again and reopen
|
|
110
|
+
* its documents, which a data client does as soon as it has a connection.
|
|
111
|
+
*/
|
|
112
|
+
const DEFAULT_REVERT_GRACE_MS = 10_000;
|
|
113
|
+
|
|
114
|
+
/** Delivered by {@link HydraniumTextDocuments.onDidSaveInLanguageClient}. */
|
|
115
|
+
export interface LanguageClientSavedEvent {
|
|
116
|
+
readonly uri: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Delivered by {@link HydraniumTextDocuments.onDidCloseLastOpen}. */
|
|
120
|
+
export interface LastOpenClosedEvent {
|
|
121
|
+
readonly uri: CanonicalUri;
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
/**
|
|
@@ -121,6 +163,22 @@ export interface DocumentTrackingRecord {
|
|
|
121
163
|
* record on last close.
|
|
122
164
|
*/
|
|
123
165
|
languageClientUris?: Set<LanguageClientUri>;
|
|
166
|
+
/**
|
|
167
|
+
* The clients whose close of this document was caused by a lost connection
|
|
168
|
+
* and that have not opened it again, each with a stopwatch started at its
|
|
169
|
+
* loss on the store's `Clock`. While the document waits out the revert grace, only an
|
|
170
|
+
* open by one of them within its own
|
|
171
|
+
* {@link HydraniumTextDocumentsOptions.revertGraceMs} of that loss cancels
|
|
172
|
+
* the revert. Every such client counts, not only the last to close: one
|
|
173
|
+
* connection's sessions all end lost together, and any of them may reopen
|
|
174
|
+
* first. Without the time limit, a client that returns late inherits the
|
|
175
|
+
* unsaved text of a holder lost after it. A stopwatch rather than a `now()`
|
|
176
|
+
* reading, because a wall-clock step would otherwise expire a claim early or
|
|
177
|
+
* revive one past its grace, against a grace timer that the step leaves
|
|
178
|
+
* alone. An entry past its grace counts as any other client's and is pruned
|
|
179
|
+
* at the next open of the document.
|
|
180
|
+
*/
|
|
181
|
+
lostClients?: Map<string, Stopwatch>;
|
|
124
182
|
}
|
|
125
183
|
|
|
126
184
|
/**
|
|
@@ -225,6 +283,18 @@ export type LanguageClientChangeOrigin =
|
|
|
225
283
|
*/
|
|
226
284
|
| { readonly kind: 'unreconstructable' };
|
|
227
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Whether `err` reports that the file of `target` does not exist, as a Node
|
|
288
|
+
* file system and the framework's providers do: code `ENOENT`, with `target`'s
|
|
289
|
+
* path. A missing file of another document is a different failure.
|
|
290
|
+
*/
|
|
291
|
+
function isFileNotFound(err: unknown, target: URI): boolean {
|
|
292
|
+
if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
return !('path' in err) || err.path === target.fsPath;
|
|
296
|
+
}
|
|
297
|
+
|
|
228
298
|
/**
|
|
229
299
|
* Cheap, stable, non-cryptographic content hash (cyrb53) for the version
|
|
230
300
|
* sequence's "did the content change across close/reopen?" question. Only
|
|
@@ -262,6 +332,9 @@ function contentHash(text: string): string {
|
|
|
262
332
|
* document's content-revision number), and splicing them lets versions drift
|
|
263
333
|
* silently past based-on gate holders.
|
|
264
334
|
* - Version-author history so each edit is attributable to its originating client.
|
|
335
|
+
* - The revert to disk once no client has a document open, for every head
|
|
336
|
+
* ({@link revertToDisk}), deferred by
|
|
337
|
+
* {@link HydraniumTextDocumentsOptions.revertGraceMs} after a lost connection.
|
|
265
338
|
* - Pending-content staging used by the integrity service to thread corrections
|
|
266
339
|
* through `workspace/applyEdit` cycles for currently-closed documents.
|
|
267
340
|
* - `didOpen` notifications arriving over the LSP connection wait on the
|
|
@@ -352,6 +425,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
352
425
|
|
|
353
426
|
protected readonly tracer: Tracer;
|
|
354
427
|
protected readonly configuration: TextDocumentsConfiguration<T>;
|
|
428
|
+
/** See {@link HydraniumTextDocumentsOptions.revertGraceMs}. */
|
|
429
|
+
protected readonly revertGraceMs: number;
|
|
430
|
+
protected readonly lastOpenClosedEmitter = new Emitter<LastOpenClosedEvent>();
|
|
431
|
+
protected readonly languageClientSavedEmitter = new Emitter<LanguageClientSavedEvent>();
|
|
355
432
|
|
|
356
433
|
constructor(
|
|
357
434
|
protected services: ServerSharedServices,
|
|
@@ -365,6 +442,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
365
442
|
(uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`),
|
|
366
443
|
this
|
|
367
444
|
);
|
|
445
|
+
this.revertGraceMs = options.revertGraceMs ?? DEFAULT_REVERT_GRACE_MS;
|
|
446
|
+
this.onDidCloseLastOpen(event => void this.revertToDisk(event.uri));
|
|
368
447
|
}
|
|
369
448
|
|
|
370
449
|
// Re-exposed configuration factories — for framework-internal callers
|
|
@@ -465,7 +544,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
465
544
|
);
|
|
466
545
|
disposables.push(
|
|
467
546
|
connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => {
|
|
468
|
-
this.
|
|
547
|
+
void this.notifyLanguageClientSave(event);
|
|
469
548
|
})
|
|
470
549
|
);
|
|
471
550
|
return disposables;
|
|
@@ -613,12 +692,25 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
613
692
|
return document.version;
|
|
614
693
|
}
|
|
615
694
|
|
|
616
|
-
|
|
695
|
+
/**
|
|
696
|
+
* Close `clientId`'s open of the document. When it was the last open, the
|
|
697
|
+
* document is released and reverts to disk, at once or, for a `'lost'`
|
|
698
|
+
* close, after {@link HydraniumTextDocumentsOptions.revertGraceMs}.
|
|
699
|
+
*/
|
|
700
|
+
public notifyDidCloseTextDocument(
|
|
701
|
+
event: DidCloseTextDocumentParams,
|
|
702
|
+
clientId = LANGUAGE_CLIENT_ID,
|
|
703
|
+
cause: SessionEndCause = 'closed'
|
|
704
|
+
): void {
|
|
617
705
|
const uri = this.documentKey(event.textDocument.uri);
|
|
618
706
|
if (!this.__sessions.removeOpen(uri, clientId)) {
|
|
619
707
|
return;
|
|
620
708
|
}
|
|
621
|
-
this.__documents.get(uri)
|
|
709
|
+
const record = this.__documents.get(uri);
|
|
710
|
+
record?.clientVersions.delete(clientId);
|
|
711
|
+
if (record && cause === 'lost') {
|
|
712
|
+
(record.lostClients ??= new Map()).set(clientId, this.services.Clock.stopwatch());
|
|
713
|
+
}
|
|
622
714
|
const syncedDocument = this.__syncedDocuments.get(uri);
|
|
623
715
|
if (syncedDocument !== undefined) {
|
|
624
716
|
this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
|
|
@@ -627,32 +719,159 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
627
719
|
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
628
720
|
// Monaco closed the document; drop the URI it held and the shadow
|
|
629
721
|
// baselined under it. (If this was the last URI/client the whole record
|
|
630
|
-
// is deleted
|
|
722
|
+
// is deleted on release, clearing the set anyway.)
|
|
631
723
|
const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
|
|
632
724
|
this.__documents.get(uri)?.languageClientUris?.delete(droppedUri);
|
|
633
725
|
this.__shadow.invalidate(droppedUri);
|
|
634
726
|
this.__pendingPushes.delete(droppedUri);
|
|
635
727
|
}
|
|
636
728
|
if (!this.__sessions.isOpen(uri)) {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
729
|
+
if (cause === 'lost' && this.revertGraceMs > 0) {
|
|
730
|
+
this.deferRelease(uri);
|
|
731
|
+
} else {
|
|
732
|
+
this.releaseDocument(uri);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Keep the document, text and all, for the revert grace, then release it.
|
|
740
|
+
* The document stays in the store meanwhile, so a lost client that opens it
|
|
741
|
+
* again within its own grace attaches to it and finds its unsaved text
|
|
742
|
+
* rather than reading disk; see {@link resolvePendingRevert} for any other
|
|
743
|
+
* open.
|
|
744
|
+
*/
|
|
745
|
+
protected deferRelease(uri: CanonicalUri): void {
|
|
746
|
+
this.log(uri, `No client left; revert deferred for ${this.revertGraceMs} ms (connection lost)`);
|
|
747
|
+
const timer = this.services.Clock.setTimer(() => {
|
|
748
|
+
this.__sessions.cancelRevert(uri);
|
|
749
|
+
if (!this.__sessions.isOpen(uri)) {
|
|
750
|
+
this.releaseDocument(uri);
|
|
751
|
+
}
|
|
752
|
+
}, this.revertGraceMs);
|
|
753
|
+
this.__sessions.deferRevert(uri, timer);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Resolve the pending revert of `uri` for an open by `clientId`. An open by a
|
|
758
|
+
* client lost from the document within its grace cancels the revert, and
|
|
759
|
+
* the client finds its unsaved text. Any other open releases the document
|
|
760
|
+
* first, so it opens as a first open does: cancelling for every open hands
|
|
761
|
+
* a lost client's unsaved text to whoever opens next, a reloaded page or an
|
|
762
|
+
* editor, with nothing marking it unsaved.
|
|
763
|
+
*/
|
|
764
|
+
protected resolvePendingRevert(uri: CanonicalUri, clientId: string): void {
|
|
765
|
+
const record = this.__documents.get(uri);
|
|
766
|
+
if (record) {
|
|
767
|
+
this.pruneLostClients(record);
|
|
768
|
+
}
|
|
769
|
+
const returning = record?.lostClients?.delete(clientId) ?? false;
|
|
770
|
+
if (!this.__sessions.isRevertPending(uri)) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
this.__sessions.cancelRevert(uri);
|
|
774
|
+
if (!returning) {
|
|
775
|
+
this.releaseDocument(uri);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Drop the entries of {@link DocumentTrackingRecord.lostClients} whose grace has run out. */
|
|
780
|
+
protected pruneLostClients(record: DocumentTrackingRecord): void {
|
|
781
|
+
for (const [clientId, sinceLoss] of record.lostClients ?? []) {
|
|
782
|
+
if (sinceLoss.elapsedMs >= this.revertGraceMs) {
|
|
783
|
+
record.lostClients?.delete(clientId);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Drop the document no client has open any more, then announce it on
|
|
790
|
+
* {@link onDidCloseLastOpen}, which is what reverts it to disk.
|
|
791
|
+
*/
|
|
792
|
+
protected releaseDocument(uri: CanonicalUri): void {
|
|
793
|
+
const syncedDocument = this.__syncedDocuments.get(uri);
|
|
794
|
+
if (syncedDocument === undefined) {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
|
|
798
|
+
// Persist where the shared version sequence left off (version +
|
|
799
|
+
// content hash) so the next open CONTINUES the sequence instead of
|
|
800
|
+
// restarting at the reopening client's declared id. Hashed once
|
|
801
|
+
// here at release, not on every change.
|
|
802
|
+
this.__versionSequences.set(uri, {
|
|
803
|
+
version: syncedDocument.version,
|
|
804
|
+
contentHash: contentHash(syncedDocument.getText())
|
|
805
|
+
});
|
|
806
|
+
this.__syncedDocuments.delete(uri);
|
|
807
|
+
// One delete clears every per-URI axis (version history + any staged
|
|
808
|
+
// pending content) so a future open with the same URI starts fresh.
|
|
809
|
+
this.__documents.delete(uri);
|
|
810
|
+
this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Rebuild a released document from disk, so the build stops carrying the
|
|
815
|
+
* unsaved text of its last client.
|
|
816
|
+
*
|
|
817
|
+
* Only a `file:` document reverts. A document of another scheme may have
|
|
818
|
+
* been loaded by the adopter into the workspace index, and a rebuild would
|
|
819
|
+
* drop it and break every reference to it.
|
|
820
|
+
*
|
|
821
|
+
* The file's existence is read in its disk queue, so the rebuild follows
|
|
822
|
+
* any save still queued rather than reverting past it. A document with no
|
|
823
|
+
* file behind it — created and never saved, or deleted meanwhile — is
|
|
824
|
+
* removed from the workspace instead: there is no disk text to revert to,
|
|
825
|
+
* and one whose file goes after that read is removed when its rebuild
|
|
826
|
+
* finds none.
|
|
827
|
+
*
|
|
828
|
+
* Whether to revert at all is decided inside the write lock, as its holder:
|
|
829
|
+
* decided before waiting for the lock, a client that opens or re-creates the
|
|
830
|
+
* document meanwhile has its text rebuilt over from disk, or removed. A
|
|
831
|
+
* document some client has open again, or that waits out a new grace, is
|
|
832
|
+
* left to that client.
|
|
833
|
+
*/
|
|
834
|
+
protected async revertToDisk(uri: CanonicalUri): Promise<void> {
|
|
835
|
+
if (URI.parse(uri).scheme !== 'file') {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const workspace = this.services.workspace;
|
|
839
|
+
const target = UriUtils.toUri(uri);
|
|
840
|
+
const reopened = (): boolean => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
|
|
841
|
+
try {
|
|
842
|
+
// Read before the lock: every build and read waits while the lock is
|
|
843
|
+
// held, and this read waits on the file's save I/O.
|
|
844
|
+
const onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
|
|
845
|
+
await workspace.WorkspaceManager?.ready;
|
|
846
|
+
await workspace.WorkspaceLock.write(async token => {
|
|
847
|
+
if (reopened()) {
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
|
|
851
|
+
try {
|
|
852
|
+
await (onDisk
|
|
853
|
+
? workspace.DocumentBuilder.update([target], [], token)
|
|
854
|
+
: workspace.DocumentBuilder.update([], [target], token));
|
|
855
|
+
} catch (err: unknown) {
|
|
856
|
+
if (!onDisk || !isFileNotFound(err, target)) {
|
|
857
|
+
throw err;
|
|
858
|
+
}
|
|
859
|
+
// The file went after its existence was read.
|
|
860
|
+
this.tracer.with(uri).debug('Revert found no file; removing the document instead');
|
|
861
|
+
workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
|
|
862
|
+
await workspace.DocumentBuilder.update([], [target], token);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
} catch (err: unknown) {
|
|
866
|
+
// A revert that finishes after the LSP peer went away fails its
|
|
867
|
+
// diagnostics publish, and one that runs after its workspace was torn
|
|
868
|
+
// down finds no file: teardown races, not failed reverts.
|
|
869
|
+
if (isConnectionGoneError(err) || isFileNotFound(err, target)) {
|
|
870
|
+
this.tracer.with(uri).debug(`Revert on last close skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
871
|
+
return;
|
|
655
872
|
}
|
|
873
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
874
|
+
this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
|
|
656
875
|
}
|
|
657
876
|
}
|
|
658
877
|
|
|
@@ -675,17 +894,70 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
675
894
|
}
|
|
676
895
|
}
|
|
677
896
|
|
|
897
|
+
/**
|
|
898
|
+
* Handle a save the language client reports: fire
|
|
899
|
+
* {@link onDidSaveInLanguageClient}, then announce it on {@link onDidSave}
|
|
900
|
+
* only once the file read back from disk holds the store's current text.
|
|
901
|
+
*/
|
|
902
|
+
protected async notifyLanguageClientSave(event: DidSaveTextDocumentParams): Promise<void> {
|
|
903
|
+
const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
|
|
904
|
+
if (syncedDocument === undefined) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const uri = syncedDocument.uri;
|
|
908
|
+
this.languageClientSavedEmitter.fire(Object.freeze({ uri }));
|
|
909
|
+
// `onDidSave` tells its listeners that the shared document is on disk: a
|
|
910
|
+
// diagram marks its save done, the data head broadcasts a save and its
|
|
911
|
+
// clients drop their dirty state. The editor wrote its own buffer,
|
|
912
|
+
// which lags the store while another client's edit is still on its way
|
|
913
|
+
// to it, so only the file says whether that holds. Read it rather than
|
|
914
|
+
// trust the editor: `didSave` carries text only for a client that
|
|
915
|
+
// honours `includeText`, and asking for it means rewriting Langium's
|
|
916
|
+
// initialize answer, which advertises `save` as a Boolean. The read goes
|
|
917
|
+
// through the disk queue and waits on nothing but the provider, so it
|
|
918
|
+
// follows a server save queued meanwhile. A file changed again between
|
|
919
|
+
// the editor's write and this read compares unequal and takes the safe
|
|
920
|
+
// side. A save not announced leaves every other client dirty, which is
|
|
921
|
+
// true of the store's text: a diagram keeps its save pending, and data
|
|
922
|
+
// clients keep their unsaved state until the next save of the document.
|
|
923
|
+
let onDisk: string | undefined;
|
|
924
|
+
try {
|
|
925
|
+
onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(uri, () =>
|
|
926
|
+
this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(uri))
|
|
927
|
+
);
|
|
928
|
+
} catch (err: unknown) {
|
|
929
|
+
this.tracer
|
|
930
|
+
.with(uri)
|
|
931
|
+
.debug(`Editor save not announced: reading the file back failed. ${err instanceof Error ? err.message : String(err)}`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
// An editor that saves and then closes releases the document while the
|
|
935
|
+
// read is under way; its save is still a save of the text it held.
|
|
936
|
+
const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
|
|
937
|
+
if (onDisk !== document.getText()) {
|
|
938
|
+
this.tracer.with(uri).debug(`Editor save not announced: the file does not hold the text of version ${document.version}`);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
this.announceSave(document, LANGUAGE_CLIENT_ID);
|
|
942
|
+
}
|
|
943
|
+
|
|
678
944
|
public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
|
|
679
945
|
const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
|
|
680
946
|
if (syncedDocument !== undefined) {
|
|
681
|
-
this.
|
|
682
|
-
this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId }));
|
|
947
|
+
this.announceSave(syncedDocument, clientId);
|
|
683
948
|
}
|
|
684
949
|
}
|
|
685
950
|
|
|
951
|
+
/** Fire {@link onDidSave} for `document`, which need not be synced any more. */
|
|
952
|
+
protected announceSave(document: T, clientId: string): void {
|
|
953
|
+
this.log(document.uri, `Saved synced document: ${document.version} by ${this.formatClientId(clientId)}`);
|
|
954
|
+
this.__onDidSave.fire(Object.freeze({ document, clientId }));
|
|
955
|
+
}
|
|
956
|
+
|
|
686
957
|
public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
|
|
687
958
|
const td = event.textDocument;
|
|
688
959
|
const uri = this.documentKey(td.uri);
|
|
960
|
+
this.resolvePendingRevert(uri, clientId);
|
|
689
961
|
if (this.isOpenInClient(uri, clientId)) {
|
|
690
962
|
// Already open for this client under this canonical identity. If this is a
|
|
691
963
|
// NEW client-facing URI for the same file (a second tab reached via a
|
|
@@ -787,13 +1059,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
787
1059
|
* Unifying the two routes therefore rebaselines a textual client's guard to a
|
|
788
1060
|
* version it never declared.
|
|
789
1061
|
*
|
|
790
|
-
* Returns whether a hold was added — `false` when `uri` is not open,
|
|
791
|
-
* `clientId` already holds it
|
|
1062
|
+
* Returns whether a hold was added — `false` when `uri` is not open,
|
|
1063
|
+
* `clientId` already holds it, or the document was waiting out the revert
|
|
1064
|
+
* grace for other clients and was released instead (see
|
|
1065
|
+
* {@link resolvePendingRevert}); the caller then opens it anew.
|
|
792
1066
|
*/
|
|
793
1067
|
attachClient(uri: DocumentUri, clientId: string): boolean {
|
|
794
1068
|
const key = this.documentKey(uri);
|
|
1069
|
+
if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
this.resolvePendingRevert(key, clientId);
|
|
795
1073
|
const document = this.__syncedDocuments.get(key);
|
|
796
|
-
if (!document
|
|
1074
|
+
if (!document) {
|
|
797
1075
|
return false;
|
|
798
1076
|
}
|
|
799
1077
|
const existingClients = this.__sessions.clientsOf(key);
|
|
@@ -1050,6 +1328,35 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1050
1328
|
return this.__sessions.onDidCloseSession;
|
|
1051
1329
|
}
|
|
1052
1330
|
|
|
1331
|
+
/**
|
|
1332
|
+
* Fires once a document no client has open is released: at its last close,
|
|
1333
|
+
* or, for a lost client's last close, when its revert grace runs out,
|
|
1334
|
+
* another client opens it, or it is deleted. The document then reverts to
|
|
1335
|
+
* disk.
|
|
1336
|
+
*/
|
|
1337
|
+
get onDidCloseLastOpen(): Event<LastOpenClosedEvent> {
|
|
1338
|
+
return this.lastOpenClosedEmitter.event;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Fires for every save the language client reports of a document it has
|
|
1343
|
+
* open: the editor has written the file. {@link onDidSave} follows only
|
|
1344
|
+
* when the file holds the store's text.
|
|
1345
|
+
*/
|
|
1346
|
+
get onDidSaveInLanguageClient(): Event<LanguageClientSavedEvent> {
|
|
1347
|
+
return this.languageClientSavedEmitter.event;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Whether `uri` is waiting out the revert grace: its last open closed with a
|
|
1352
|
+
* lost connection, and it still holds its unsaved text. Such a document is
|
|
1353
|
+
* open for no client, yet not closed either, so a caller that would persist
|
|
1354
|
+
* a closed document's text to disk treats it as open.
|
|
1355
|
+
*/
|
|
1356
|
+
isRevertPending(uri: DocumentUri): boolean {
|
|
1357
|
+
return this.__sessions.isRevertPending(this.documentKey(uri));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1053
1360
|
/**
|
|
1054
1361
|
* Start a client session under `clientId`. Throws `DuplicateClientIdError`
|
|
1055
1362
|
* where {@link ClientSessionRegistry.register} refuses the id.
|
|
@@ -1063,15 +1370,16 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1063
1370
|
/**
|
|
1064
1371
|
* End the client session `clientId`: close every document it has open, then
|
|
1065
1372
|
* free the id. Immediate — each close runs the ordinary close path before
|
|
1066
|
-
* this returns. A no-op for an id that is not a
|
|
1373
|
+
* this returns, with `cause` as its cause. A no-op for an id that is not a
|
|
1374
|
+
* registered session.
|
|
1067
1375
|
*/
|
|
1068
|
-
closeSession(clientId: string): void {
|
|
1376
|
+
closeSession(clientId: string, cause: SessionEndCause = 'closed'): void {
|
|
1069
1377
|
if (!this.__sessions.isRegistered(clientId)) {
|
|
1070
1378
|
return;
|
|
1071
1379
|
}
|
|
1072
1380
|
try {
|
|
1073
1381
|
for (const uri of this.__sessions.beginClose(clientId)) {
|
|
1074
|
-
this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId);
|
|
1382
|
+
this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId, cause);
|
|
1075
1383
|
}
|
|
1076
1384
|
} finally {
|
|
1077
1385
|
this.__sessions.unregister(clientId);
|
|
@@ -1080,16 +1388,6 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1080
1388
|
}
|
|
1081
1389
|
}
|
|
1082
1390
|
|
|
1083
|
-
/** The options `clientId` opened `uri` with, or `undefined` when it gave none or does not have it open. */
|
|
1084
|
-
openOptions(uri: DocumentUri, clientId: string): OpenOptions | undefined {
|
|
1085
|
-
return this.__sessions.openOptions(this.documentKey(uri), clientId);
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
/** Replace the options of an existing open. Does nothing when `clientId` does not have `uri` open. */
|
|
1089
|
-
setOpenOptions(uri: DocumentUri, clientId: string, options: OpenOptions | undefined): void {
|
|
1090
|
-
this.__sessions.setOpenOptions(this.documentKey(uri), clientId, options);
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
1391
|
/**
|
|
1094
1392
|
* The file behind `uri` was deleted: close every open of it except the
|
|
1095
1393
|
* language client's.
|
|
@@ -1122,6 +1420,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1122
1420
|
for (const clientId of this.__sessions.clientsOf(key)) {
|
|
1123
1421
|
this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
|
|
1124
1422
|
}
|
|
1423
|
+
// A document waiting out the grace has no client left to close, and is
|
|
1424
|
+
// released now as its last close would have released it.
|
|
1425
|
+
if (this.__sessions.isRevertPending(key)) {
|
|
1426
|
+
this.__sessions.cancelRevert(key);
|
|
1427
|
+
this.releaseDocument(key);
|
|
1428
|
+
}
|
|
1125
1429
|
super.delete(key);
|
|
1126
1430
|
}
|
|
1127
1431
|
|
package/src/documents/index.ts
CHANGED
|
@@ -44,6 +44,20 @@ export const DEFAULT_LOGGED_PHASES: DocumentState[] = [
|
|
|
44
44
|
DocumentState.Validated
|
|
45
45
|
];
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The build reasons the framework stages through
|
|
49
|
+
* {@link HydraniumDocumentBuilder.markNextReason}: the LSP events the update
|
|
50
|
+
* handler dispatches on, and `didClose` for the revert the text store runs
|
|
51
|
+
* after a last close. Exposed so a subclass layering reasons of its own keeps
|
|
52
|
+
* the names the framework emits.
|
|
53
|
+
*/
|
|
54
|
+
export const HYDRANIUM_BUILD_REASONS = Object.freeze({
|
|
55
|
+
didOpen: 'didOpen',
|
|
56
|
+
didChangeContent: 'didChangeContent',
|
|
57
|
+
didChangeWatchedFiles: 'didChangeWatchedFiles',
|
|
58
|
+
didClose: 'didClose'
|
|
59
|
+
} as const);
|
|
60
|
+
|
|
47
61
|
/**
|
|
48
62
|
* Consecutive re-queued builds that leave a waited-on document at the same state
|
|
49
63
|
* before {@link HydraniumDocumentBuilder.awaitDocumentState} stops re-queuing it.
|
|
@@ -211,8 +225,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
211
225
|
|
|
212
226
|
/**
|
|
213
227
|
* Stage an LSP event name for the next `update()` call. Adopters call before
|
|
214
|
-
* the update fires; `HydraniumDocumentUpdateHandler` does it for the
|
|
215
|
-
*
|
|
228
|
+
* the update fires; `HydraniumDocumentUpdateHandler` does it for the LSP
|
|
229
|
+
* events, and the text store for the revert that follows a last close.
|
|
216
230
|
*
|
|
217
231
|
* The framework stages the value and never reads it back. The consumer is a
|
|
218
232
|
* subclass overriding the build logging, which takes
|
|
@@ -456,7 +456,8 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
|
|
|
456
456
|
* mutation rode the current build, so `ModelService.syncToLanguageClient`
|
|
457
457
|
* (the persistent integrity-settled listener) mirrors it to the client by
|
|
458
458
|
* *content* (shadow diff), and the editor shows it as an unsaved change.
|
|
459
|
-
* - **Held only through the data or GLSP head
|
|
459
|
+
* - **Held only through the data or GLSP head**, or waiting out the revert
|
|
460
|
+
* grace after its last client's connection was lost → the store is the
|
|
460
461
|
* authority, and those heads read the correction from the settled build.
|
|
461
462
|
* In silent mode it is also written to disk when the text it was computed
|
|
462
463
|
* from is what disk holds, since the repair is then the only difference;
|
|
@@ -497,7 +498,9 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
|
|
|
497
498
|
return;
|
|
498
499
|
}
|
|
499
500
|
|
|
500
|
-
|
|
501
|
+
// A document waiting out the revert grace still holds its lost client's
|
|
502
|
+
// unsaved text, so writing it as a closed file would persist that text.
|
|
503
|
+
if (this.textDocuments.isOpenInAnyClient(document.uri) || this.textDocuments.isRevertPending(document.uri)) {
|
|
501
504
|
if (this.syncMode === 'silent' && parsedFrom !== undefined) {
|
|
502
505
|
await this.persistIfDiskMatches(document, parsedFrom);
|
|
503
506
|
}
|
|
@@ -508,9 +511,9 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
|
|
|
508
511
|
if (this.syncMode === 'silent') {
|
|
509
512
|
const repaired = document.getText();
|
|
510
513
|
// Async write so the integrity pass does not block the event loop on a slow filesystem.
|
|
511
|
-
// In the
|
|
514
|
+
// In the file's task queue, behind any save already queued: written
|
|
512
515
|
// beside one, the older repair can land last and stay on disk.
|
|
513
|
-
await this.services.shared.workspace.
|
|
516
|
+
await this.services.shared.workspace.FileSystemTaskQueue.enqueue(document.uri, () =>
|
|
514
517
|
this.fileSystemProvider.writeFile(UriUtils.toUri(document.uri), repaired)
|
|
515
518
|
);
|
|
516
519
|
return;
|
|
@@ -525,7 +528,7 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
|
|
|
525
528
|
* the text the repair was computed from — so the write carries the repair
|
|
526
529
|
* and nothing a holder could still discard.
|
|
527
530
|
*
|
|
528
|
-
* The read, the compare and the write run as one task in the
|
|
531
|
+
* The read, the compare and the write run as one task in the file's task
|
|
529
532
|
* queue, so no save of the file lands between them. The store is compared
|
|
530
533
|
* again once the read returns, and the write is skipped if it no longer
|
|
531
534
|
* holds the repair: an edit does not wait for the queue, and writing the
|
|
@@ -537,7 +540,7 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
|
|
|
537
540
|
protected async persistIfDiskMatches(document: TextDocument, parsedFrom: string): Promise<void> {
|
|
538
541
|
const uri = UriUtils.toUri(document.uri);
|
|
539
542
|
const repaired = document.getText();
|
|
540
|
-
await this.services.shared.workspace.
|
|
543
|
+
await this.services.shared.workspace.FileSystemTaskQueue.enqueue(document.uri, async () => {
|
|
541
544
|
let onDisk: string;
|
|
542
545
|
try {
|
|
543
546
|
onDisk = await this.fileSystemProvider.readFile(uri);
|
|
@@ -158,7 +158,7 @@ export interface ServerAddedServices {
|
|
|
158
158
|
* multi-grammar workspaces route to the right serializer per file. The
|
|
159
159
|
* framework default throws a clear "no Serializer registered" error;
|
|
160
160
|
* adopters bind their own per-language subclass to enable
|
|
161
|
-
* `
|
|
161
|
+
* a client session's `update` / `save`.
|
|
162
162
|
*/
|
|
163
163
|
Serializer: Serializer;
|
|
164
164
|
};
|
|
@@ -211,7 +211,7 @@ export interface ServerAddedServices {
|
|
|
211
211
|
updateRewrite: {
|
|
212
212
|
/**
|
|
213
213
|
* Per-language runner for transfer-model rewrites applied on the
|
|
214
|
-
* structured write path of `
|
|
214
|
+
* structured write path of a client session's `update` / `save`, before
|
|
215
215
|
* serialisation. The RPC-update-stage sibling of the
|
|
216
216
|
* {@link IntegrityService} / {@link AstExtensionService} registries.
|
|
217
217
|
* Framework default is a no-op (zero rewrites registered) — adopters
|
|
@@ -263,12 +263,12 @@ export interface ServerAddedServices {
|
|
|
263
263
|
class UnboundSerializer implements Serializer {
|
|
264
264
|
serializeAst(_model: AstNode): string {
|
|
265
265
|
throw new Error(
|
|
266
|
-
|
|
266
|
+
"No Serializer registered at services.serializer.Serializer — bind a Serializer<TAst, TTransfer> in your per-language module before writing through a client session's update or save."
|
|
267
267
|
);
|
|
268
268
|
}
|
|
269
269
|
serializeTransfer(_model: TransferElement): string {
|
|
270
270
|
throw new Error(
|
|
271
|
-
|
|
271
|
+
"No Serializer registered at services.serializer.Serializer — bind a Serializer<TAst, TTransfer> in your per-language module before writing through a client session's update or save."
|
|
272
272
|
);
|
|
273
273
|
}
|
|
274
274
|
}
|