@hydranium/protocol 1.0.0-next.185 → 1.0.0-next.188

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/lib/client/data-connection.js +2 -2
  2. package/lib/client/data-connection.js.map +1 -1
  3. package/lib/client/data-port.d.ts +1 -1
  4. package/lib/client/data-session.d.ts +39 -21
  5. package/lib/client/data-session.d.ts.map +1 -1
  6. package/lib/client/data-session.js +69 -43
  7. package/lib/client/data-session.js.map +1 -1
  8. package/lib/data/data-server-protocol.d.ts +2 -4
  9. package/lib/data/data-server-protocol.d.ts.map +1 -1
  10. package/lib/data/events.d.ts +11 -5
  11. package/lib/data/events.d.ts.map +1 -1
  12. package/lib/data/requests.d.ts +4 -4
  13. package/lib/data/requests.d.ts.map +1 -1
  14. package/lib/errors.d.ts +31 -25
  15. package/lib/errors.d.ts.map +1 -1
  16. package/lib/errors.js +58 -23
  17. package/lib/errors.js.map +1 -1
  18. package/lib/index.d.ts +1 -0
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +1 -0
  21. package/lib/index.js.map +1 -1
  22. package/lib/messages/index.d.ts +2 -2
  23. package/lib/messages/index.d.ts.map +1 -1
  24. package/lib/messages/index.js +3 -2
  25. package/lib/messages/index.js.map +1 -1
  26. package/lib/model-service/base-version.d.ts +64 -0
  27. package/lib/model-service/base-version.d.ts.map +1 -0
  28. package/lib/model-service/{based-on.js → base-version.js} +21 -15
  29. package/lib/model-service/base-version.js.map +1 -0
  30. package/lib/model-service/index.d.ts +1 -1
  31. package/lib/model-service/index.d.ts.map +1 -1
  32. package/lib/model-service/index.js +2 -2
  33. package/lib/model-service/index.js.map +1 -1
  34. package/lib/patch-merge.d.ts +11 -9
  35. package/lib/patch-merge.d.ts.map +1 -1
  36. package/lib/patch-merge.js +2 -2
  37. package/lib/patch-merge.js.map +1 -1
  38. package/lib/reconcile-write.d.ts +65 -0
  39. package/lib/reconcile-write.d.ts.map +1 -0
  40. package/lib/reconcile-write.js +67 -0
  41. package/lib/reconcile-write.js.map +1 -0
  42. package/lib/rpc/create-rpc-proxy.d.ts +3 -0
  43. package/lib/rpc/create-rpc-proxy.d.ts.map +1 -1
  44. package/lib/rpc/create-rpc-proxy.js +6 -1
  45. package/lib/rpc/create-rpc-proxy.js.map +1 -1
  46. package/lib/transfer-document.d.ts +59 -61
  47. package/lib/transfer-document.d.ts.map +1 -1
  48. package/lib/transfer-document.js +15 -20
  49. package/lib/transfer-document.js.map +1 -1
  50. package/lib/util.d.ts +1 -1
  51. package/lib/util.js +1 -1
  52. package/package.json +1 -1
  53. package/src/client/data-connection.ts +2 -2
  54. package/src/client/data-port.ts +1 -1
  55. package/src/client/data-session.ts +89 -51
  56. package/src/data/data-server-protocol.ts +2 -4
  57. package/src/data/events.ts +11 -5
  58. package/src/data/requests.ts +4 -4
  59. package/src/errors.ts +71 -31
  60. package/src/index.ts +1 -0
  61. package/src/messages/index.ts +2 -2
  62. package/src/model-service/base-version.ts +72 -0
  63. package/src/model-service/index.ts +2 -2
  64. package/src/patch-merge.ts +11 -9
  65. package/src/reconcile-write.ts +124 -0
  66. package/src/rpc/create-rpc-proxy.ts +6 -1
  67. package/src/transfer-document.ts +70 -65
  68. package/src/util.ts +1 -1
  69. package/lib/model-service/based-on.d.ts +0 -57
  70. package/lib/model-service/based-on.d.ts.map +0 -1
  71. package/lib/model-service/based-on.js.map +0 -1
  72. package/src/model-service/based-on.ts +0 -63
@@ -1 +1 @@
1
- {"version":3,"file":"transfer-document.js","sourceRoot":"","sources":["../src/transfer-document.ts"],"names":[],"mappings":";AAAA;;;;;;;kFAOkF;;;AAIlF,uDAAmF;AAmEnF,IAAiB,gBAAgB,CAuDhC;AAvDD,WAAiB,gBAAgB;IAC9B;;;OAGG;IACH,SAAgB,QAAQ,CACrB,QAAkD;QAElD,OAAO,QAAQ,CAAC,IAAI,KAAK,SAAS,CAAC;IACtC,CAAC;IAJe,iBAAA,QAAQ,WAIvB,CAAA;IAED;;;;;OAKG;IACH,SAAgB,YAAY,CACzB,QAAkD;QAElD,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,KAAK,CAAC,kBAAkB,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC;QACrD,CAAC;QACD,OAAO,QAAQ,CAAC;IACnB,CAAC;IAPe,iBAAA,YAAY,eAO3B,CAAA;IAED;;;;;;;OAOG;IACH,SAAgB,MAAM,CACnB,GAAW,EACX,WAAW,GAAkB,EAAE;QAE/B,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,IAAA,4BAAiB,EAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC;IAC/E,CAAC;IALe,iBAAA,MAAM,SAKrB,CAAA;IAED;;;;;OAKG;IACH,SAAgB,MAAM,CACnB,GAAW,EACX,OAAe,EACf,IAAe,EACf,WAAW,GAAkB,EAAE;QAE/B,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,IAAA,4BAAiB,EAAC,OAAO,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;IAC1E,CAAC;IAPe,iBAAA,MAAM,SAOrB,CAAA;AACJ,CAAC,EAvDgB,gBAAgB,aAAhB,gBAAgB,GAAhB,gBAAgB,QAuDhC"}
1
+ {"version":3,"file":"transfer-document.js","sourceRoot":"","sources":["../src/transfer-document.ts"],"names":[],"mappings":";AAAA;;;;;;;kFAOkF;;;AAIlF,+DAAmG;AA4EnG,IAAiB,gBAAgB,CAmDhC;AAnDD,WAAiB,gBAAgB;IAC9B;;;OAGG;IACH,SAAgB,QAAQ,CACrB,QAAkD;QAElD,OAAO,QAAQ,CAAC,KAAK,KAAK,SAAS,CAAC;IACvC,CAAC;IAJe,iBAAA,QAAQ,WAIvB,CAAA;IAED;;;;;OAKG;IACH,SAAgB,YAAY,CACzB,QAAkD;QAElD,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,KAAK,CAAC,kBAAkB,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC;QACrD,CAAC;QACD,OAAO,QAAQ,CAAC;IACnB,CAAC;IAPe,iBAAA,YAAY,eAO3B,CAAA;IAED,4DAA4D;IAC5D,SAAgB,MAAM,CACnB,GAAW;QAEX,OAAO,EAAE,GAAG,EAAE,CAAC;IAClB,CAAC;IAJe,iBAAA,MAAM,SAIrB,CAAA;IAED;;;OAGG;IACH,SAAgB,MAAM,CACnB,GAAW,EACX,OAAe,EACf,IAAe,EACf,IAAY,EACZ,WAA2B,EAC3B,IAAgB;QAEhB,OAAO;YACJ,GAAG;YACH,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAA,6BAAc,EAAC,OAAO,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE;YAC9G,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;SACzC,CAAC;IACL,CAAC;IAbe,iBAAA,MAAM,SAarB,CAAA;AACJ,CAAC,EAnDgB,gBAAgB,aAAhB,gBAAgB,GAAhB,gBAAgB,QAmDhC"}
package/lib/util.d.ts CHANGED
@@ -146,7 +146,7 @@ export declare function unreachable(input: never): never;
146
146
  export declare function getAt<T = unknown>(obj: unknown, path: string[]): T | undefined;
147
147
  /**
148
148
  * A stable, non-cryptographic digest of `text`, equal on every host for equal
149
- * text: the `TransferDocument.textHash` a data server stamps, so a client can
149
+ * text: the `TextState.hash` a data server stamps, so a client can
150
150
  * hash text of its own to compare. An array is read as its elements joined, so
151
151
  * a large text is hashed in parts without concatenating it. It tells whether a
152
152
  * text changed, not who could forge one.
package/lib/util.js CHANGED
@@ -211,7 +211,7 @@ function getAt(obj, path) {
211
211
  }
212
212
  /**
213
213
  * A stable, non-cryptographic digest of `text`, equal on every host for equal
214
- * text: the `TransferDocument.textHash` a data server stamps, so a client can
214
+ * text: the `TextState.hash` a data server stamps, so a client can
215
215
  * hash text of its own to compare. An array is read as its elements joined, so
216
216
  * a large text is hashed in parts without concatenating it. It tells whether a
217
217
  * text changed, not who could forge one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/protocol",
3
- "version": "1.0.0-next.185",
3
+ "version": "1.0.0-next.188",
4
4
  "description": "Generic, language-agnostic types, constants, and pure utilities for the hydranium framework.",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -172,7 +172,7 @@ export class DataConnection<
172
172
  connected: () => this.connected(),
173
173
  reportError: (error, reported) => this.reportError(error, reported),
174
174
  restoreDirty: event => {
175
- if (this.dirtyStates.get(event.uri) !== event.dirty) {
175
+ if (this.dirtyStates.get(event.uri) !== (event.text?.dirty ?? false)) {
176
176
  this.deliverDirty(event);
177
177
  }
178
178
  },
@@ -234,7 +234,7 @@ export class DataConnection<
234
234
 
235
235
  /** Record `event` in {@link dirtyStates} and hand it to the client. */
236
236
  protected deliverDirty(event: TransferDocumentDirtyChangedEvent): void {
237
- this.dirtyStates.set(event.uri, event.dirty);
237
+ this.dirtyStates.set(event.uri, event.text?.dirty ?? false);
238
238
  const client = this.client as unknown as Partial<Pick<DataClientProtocol<TTransfer>, 'onDocumentDirtyChanged'>>;
239
239
  client.onDocumentDirtyChanged?.(event);
240
240
  }
@@ -27,7 +27,7 @@ import type { RpcConnectionLifecycle } from './rpc-connection';
27
27
  * `DataServerProtocol` surface, so wrapping it would re-derive the framework's
28
28
  * pass-throughs in a second place and lose the `as const satisfies keyof`
29
29
  * method allowlists, which cannot drift. Everything a form or a tree actually
30
- * does — the wire contract, the open/watch/update/close sequence, `basedOn`
30
+ * does — the wire contract, the open/watch/update/close sequence, `baseVersion`
31
31
  * conflict handling, echo filtering by `sourceClientId` — is host-invariant and
32
32
  * lives above this interface. What varies between hosts is exactly the
33
33
  * members below.
@@ -11,12 +11,12 @@ import { type Disposable, Emitter, type Event } from 'vscode-jsonrpc';
11
11
  import { type Clock, SystemClock } from '../clock';
12
12
  import type { DataServerProtocol, DiagnosticOf, TransferDocumentDirtyChangedEvent, TransferDocumentUpdatedEvent } from '../data';
13
13
  import { isConflictError, SessionClosedError } from '../errors';
14
- import { type BasedOn, isSnapshotVersion, type SnapshotVersion } from '../model-service/based-on';
14
+ import { type BaseVersion, isModelVersion, type ModelVersion, UNRECORDED_VERSION } from '../model-service/base-version';
15
15
  import { type ResolvedMessage, defineMessage, describeError, resolve } from '../messages/primitives';
16
16
  import type { OpenModelArgs } from '../model-server';
17
17
  import type { MaybePromise } from '../util';
18
18
  import type { RpcProxy } from '../rpc';
19
- import type { TransferDocument } from '../transfer-document';
19
+ import type { TextState, TransferDocument } from '../transfer-document';
20
20
  import type { TransferElement } from '../transfer-element';
21
21
 
22
22
  /**
@@ -29,6 +29,12 @@ export const DATA_SESSION_RESTORE_FAILED = defineMessage(
29
29
  'Could not restore {uri} after reconnecting to the data server: {detail}'
30
30
  );
31
31
 
32
+ /** A write or open of `{uri}` that the data server answered without a model. */
33
+ export const DATA_SESSION_ANSWER_WITHOUT_MODEL = defineMessage(
34
+ 'hydranium/protocol/data-session-answer-without-model',
35
+ 'The data server answered a write of {uri} without a model; the session counts that answer as older than every version.'
36
+ );
37
+
32
38
  /**
33
39
  * A session re-opened documents after its connection dropped and cannot put
34
40
  * back what it wrote to them since their last save: another client changed
@@ -110,14 +116,18 @@ export interface DataSessionUnsavedWrite<
110
116
  TServer extends DataServerProtocol<TTransfer, DiagnosticOf<TServer>> = DataServerProtocol<TTransfer>
111
117
  > {
112
118
  /**
113
- * The {@link TransferDocument.textHash} of the text the first of these
114
- * writes was based on. `undefined` when the session could not tell, for a
115
- * write based on `'anything'` or on a version it could not read back, and
116
- * such a write is never sent again: the session cannot tell another
117
- * client's text from the text it wrote over.
119
+ * The `text.hash` of the text the first of these writes was based on.
120
+ * `undefined` when the session could not tell, for a write based on
121
+ * `'any'` or on a version it could not read back, and such a write is
122
+ * never sent again: the session cannot tell another client's text from the
123
+ * text it wrote over.
118
124
  */
119
125
  readonly baseHash: string | undefined;
120
- readonly answer: Pick<DataSessionDocument<TTransfer, TServer>, 'version' | 'textHash'>;
126
+ /**
127
+ * The model version the last of these writes was answered with, which
128
+ * orders its answers, and the `text.hash` of that answer.
129
+ */
130
+ readonly answer: { readonly version: ModelVersion; readonly hash?: string };
121
131
  /**
122
132
  * The call that made the last write, and for an `updateDocuments` call this
123
133
  * URI's index in it. Records whose `updates` is the same object were
@@ -251,18 +261,18 @@ export class DataSession<
251
261
  /** Per URI written since its last save, what {@link restore} needs to write it again. */
252
262
  protected readonly unsavedWrites = new Map<string, DataSessionUnsavedWrite<TTransfer, TServer>>();
253
263
  /**
254
- * Per URI this session has open, the version and text hash of the last
255
- * document one of its own calls was answered with, which spares the read a
256
- * first unsaved write otherwise makes for its base; see {@link baseHashOf}.
264
+ * Per URI this session has open, the text of the last document one of its
265
+ * own calls was answered with, which spares the read a first unsaved write
266
+ * otherwise makes for its base; see {@link baseHashOf}.
257
267
  */
258
- protected readonly lastAnswers = new Map<string, Pick<DataSessionDocument<TTransfer, TServer>, 'version' | 'textHash'>>();
268
+ protected readonly lastAnswers = new Map<string, TextState>();
259
269
  /**
260
- * Per URI this session has open, the version its last save of it was
270
+ * Per URI this session has open, the model version its last save of it was
261
271
  * answered with; see {@link recordWrite}. Only a save sets it: an open
262
272
  * answers at the version of a write still in flight as well, and that
263
273
  * write is not saved.
264
274
  */
265
- protected readonly savedVersions = new Map<string, SnapshotVersion>();
275
+ protected readonly savedVersions = new Map<string, ModelVersion>();
266
276
  /** Per URI, this session's calls still in flight on it, which a close waits for. */
267
277
  protected readonly inFlight = new Map<string, Set<Promise<unknown>>>();
268
278
  /** This session's saves still in flight, which a host's exit waits for; see {@link hasSavesInFlight}. */
@@ -347,8 +357,8 @@ export class DataSession<
347
357
  * view misreads as a concurrent third-party write, losing whatever the user
348
358
  * had typed.
349
359
  *
350
- * Note that the returned snapshot's empty `diagnostics` does not mean
351
- * valid: `open` settles at the integrity landmark, not at validation.
360
+ * Note that the returned snapshot's model usually has no `diagnostics`:
361
+ * `open` settles at the integrity landmark, not at validation.
352
362
  * Validity arrives asynchronously on `onDocumentUpdated`, or synchronously
353
363
  * from `getModelDocument({ includeDiagnostics: true })`.
354
364
  */
@@ -389,7 +399,7 @@ export class DataSession<
389
399
  }
390
400
  this.openUris.add(uri);
391
401
  this.serverUris.set(uri, document.uri);
392
- this.lastAnswers.set(uri, { version: document.version, textHash: document.textHash });
402
+ this.recordText(uri, document);
393
403
  // Keyed as the server keys its notifications, which may not be how the
394
404
  // caller spelled the URI.
395
405
  this.host.forgetDirty?.(document.uri);
@@ -444,7 +454,7 @@ export class DataSession<
444
454
  return this.track([args.uri], async () => {
445
455
  const server = await this.connected();
446
456
  const update = { ...args };
447
- const baseHash = await this.baseHashOf(server, args.uri, args.basedOn);
457
+ const baseHash = await this.baseHashOf(server, args.uri, args.baseVersion);
448
458
  const document = await server.updateModelDocument({ ...args, clientId: this.clientId });
449
459
  this.recordWrite(args.uri, document, baseHash, { update });
450
460
  return document;
@@ -464,7 +474,7 @@ export class DataSession<
464
474
  // records together.
465
475
  const updates = { ...args, updates: args.updates.map(update => ({ ...update })) };
466
476
  const server = await this.connected();
467
- const baseHashes = await Promise.all(args.updates.map(update => this.baseHashOf(server, update.uri, update.basedOn)));
477
+ const baseHashes = await Promise.all(args.updates.map(update => this.baseHashOf(server, update.uri, update.baseVersion)));
468
478
  const documents = await server.updateModelDocuments({ ...args, clientId: this.clientId });
469
479
  args.updates.forEach((update, index) => this.recordWrite(update.uri, documents[index], baseHashes[index], { updates, index }));
470
480
  return documents;
@@ -483,11 +493,12 @@ export class DataSession<
483
493
  if (this.unsavedWrites.get(args.uri) === unsaved) {
484
494
  this.unsavedWrites.delete(args.uri);
485
495
  }
486
- this.lastAnswers.set(args.uri, { version: document.version, textHash: document.textHash });
496
+ const answer = this.answerOf(args.uri, document);
497
+ this.recordText(args.uri, document);
487
498
  // The higher of two saves' answers, which may arrive out of order.
488
499
  const saved = this.savedVersions.get(args.uri);
489
- if (saved === undefined || document.version > saved) {
490
- this.savedVersions.set(args.uri, document.version);
500
+ if (saved === undefined || answer.version > saved) {
501
+ this.savedVersions.set(args.uri, answer.version);
491
502
  }
492
503
  return document;
493
504
  });
@@ -620,7 +631,7 @@ export class DataSession<
620
631
  * can still be reported lost that way.
621
632
  */
622
633
  protected forgetSavedWrite(event: TransferDocumentDirtyChangedEvent): void {
623
- if (event.dirty) {
634
+ if (event.text?.dirty) {
624
635
  return;
625
636
  }
626
637
  // The flip names the server's key, which is not always the caller's
@@ -675,7 +686,7 @@ export class DataSession<
675
686
  * what it wrote since their last save where the re-open lost it, and tell
676
687
  * the host which of them lost it for good.
677
688
  *
678
- * Decided per document by the re-opened {@link TransferDocument.textHash},
689
+ * Decided per document by the re-opened document's `text.hash`,
679
690
  * in {@link restoreOutcome}. Versions cannot decide a write: a revert moves
680
691
  * the version on and a restarted server numbers afresh, so the version the
681
692
  * last write was answered with never matches where a write is needed, and
@@ -717,7 +728,7 @@ export class DataSession<
717
728
  await server.watchModelDocument({ uri, clientId: this.clientId });
718
729
  if (this.openUris.has(uri)) {
719
730
  this.serverUris.set(uri, document.uri);
720
- this.lastAnswers.set(uri, { version: document.version, textHash: document.textHash });
731
+ this.recordText(uri, document);
721
732
  }
722
733
  reopened.set(uri, document);
723
734
  } catch (error: unknown) {
@@ -742,12 +753,12 @@ export class DataSession<
742
753
  if (!this.openUris.has(uri)) {
743
754
  continue;
744
755
  }
745
- // A failed read leaves the client's dirty state as it was; the
746
- // document is restored all the same.
756
+ // A failed read, or one without text, leaves the client's dirty
757
+ // state as it was; the document is restored all the same.
747
758
  const current = await server.getModelDocument({ uri }).catch(() => undefined);
748
- if (current?.dirty !== undefined) {
759
+ if (current?.text !== undefined) {
749
760
  try {
750
- this.host.restoreDirty?.({ uri: current.uri, dirty: current.dirty });
761
+ this.host.restoreDirty?.({ uri: current.uri, text: current.text });
751
762
  } catch {
752
763
  // The client's listener failed, not the restore: the server
753
764
  // has the document open and watched, so it stays restored.
@@ -765,20 +776,21 @@ export class DataSession<
765
776
  * `write` (`'kept'`), holds the text the write started from, so the write
766
777
  * can be sent again (`'resend'`), or holds something else (`'lost'`).
767
778
  *
768
- * A document without a text hash comes from a server that sends none, and
769
- * counts as kept only at the version the write was answered with.
779
+ * A document without `text` comes from a server that sends none, and
780
+ * counts as kept only at the version the write was answered with. One
781
+ * without a model has no version to send the write again on.
770
782
  */
771
783
  protected restoreOutcome(
772
784
  write: DataSessionUnsavedWrite<TTransfer, TServer>,
773
785
  document: DataSessionDocument<TTransfer, TServer>
774
786
  ): 'kept' | 'resend' | 'lost' {
775
- if (document.textHash === undefined) {
776
- return document.version === write.answer.version ? 'kept' : 'lost';
787
+ if (document.text === undefined) {
788
+ return document.model?.version === write.answer.version ? 'kept' : 'lost';
777
789
  }
778
- if (document.textHash === write.answer.textHash) {
790
+ if (document.text.hash === write.answer.hash) {
779
791
  return 'kept';
780
792
  }
781
- return write.baseHash !== undefined && document.textHash === write.baseHash ? 'resend' : 'lost';
793
+ return document.model && write.baseHash !== undefined && document.text.hash === write.baseHash ? 'resend' : 'lost';
782
794
  }
783
795
 
784
796
  /**
@@ -808,7 +820,7 @@ export class DataSession<
808
820
  // on from the re-opened version, not from the one the record holds.
809
821
  for (const { uri, write, document, outcome } of outcomes) {
810
822
  if (outcome === 'kept' && document) {
811
- this.unsavedWrites.set(uri, { ...write, answer: { version: document.version, textHash: document.textHash } });
823
+ this.unsavedWrites.set(uri, { ...write, answer: this.answerOf(uri, document) });
812
824
  }
813
825
  }
814
826
  // A document that could not be re-opened is forgotten, and was reported
@@ -823,7 +835,7 @@ export class DataSession<
823
835
  // In the order of the call's updates, which is the order of its answers.
824
836
  const resend = outcomes
825
837
  .flatMap(({ uri, write, document, outcome }) =>
826
- outcome === 'resend' && document ? [{ uri, write, basedOn: document.version }] : []
838
+ outcome === 'resend' && document?.model ? [{ uri, write, baseVersion: document.model.version }] : []
827
839
  )
828
840
  .sort((left, right) => indexOf(left.write) - indexOf(right.write));
829
841
  if (resend.length === 0) {
@@ -833,15 +845,15 @@ export class DataSession<
833
845
  try {
834
846
  const { call } = resend[0].write;
835
847
  if ('update' in call) {
836
- documents = [await server.updateModelDocument({ ...call.update, basedOn: resend[0].basedOn, clientId: this.clientId })];
848
+ documents = [await server.updateModelDocument({ ...call.update, baseVersion: resend[0].baseVersion, clientId: this.clientId })];
837
849
  } else {
838
- const basedOn = new Map(resend.map(member => [indexOf(member.write), member.basedOn]));
850
+ const baseVersion = new Map(resend.map(member => [indexOf(member.write), member.baseVersion]));
839
851
  documents = await server.updateModelDocuments({
840
852
  ...call.updates,
841
853
  clientId: this.clientId,
842
854
  updates: call.updates.updates.flatMap((update, index) => {
843
- const version = basedOn.get(index);
844
- return version === undefined ? [] : [{ ...update, basedOn: version }];
855
+ const version = baseVersion.get(index);
856
+ return version === undefined ? [] : [{ ...update, baseVersion: version }];
845
857
  })
846
858
  });
847
859
  }
@@ -849,13 +861,13 @@ export class DataSession<
849
861
  if (isConflictError(error)) {
850
862
  return lose();
851
863
  }
852
- for (const { uri, write, basedOn } of resend) {
864
+ for (const { uri, write, baseVersion } of resend) {
853
865
  this.host.reportError?.(error, resolve(DATA_SESSION_RESTORE_FAILED, { uri, detail: describeError(error) }));
854
866
  // Kept for the next restore, and numbered from the re-opened
855
867
  // version, as the kept records are: a record numbered by a server
856
868
  // that has since restarted would have later answers ignored.
857
869
  if (current(uri, write)) {
858
- this.unsavedWrites.set(uri, { ...write, answer: { ...write.answer, version: basedOn } });
870
+ this.unsavedWrites.set(uri, { ...write, answer: { ...write.answer, version: baseVersion } });
859
871
  }
860
872
  }
861
873
  return [];
@@ -872,7 +884,7 @@ export class DataSession<
872
884
  }
873
885
 
874
886
  /**
875
- * The text hash of the version `basedOn` names for `uri`, for a write that
887
+ * The text hash of the version `baseVersion` names for `uri`, for a write that
876
888
  * starts the URI's record in {@link unsavedWrites}; `undefined` for any
877
889
  * other write, whose record keeps the base it has.
878
890
  *
@@ -881,16 +893,16 @@ export class DataSession<
881
893
  * changes only with its version, so the text at a version is the text the
882
894
  * write, once it passes the gate, was applied to.
883
895
  */
884
- protected async baseHashOf(server: RpcProxy<TServer>, uri: string, basedOn: BasedOn): Promise<string | undefined> {
885
- if (this.unsavedWrites.has(uri) || !isSnapshotVersion(basedOn)) {
896
+ protected async baseHashOf(server: RpcProxy<TServer>, uri: string, baseVersion: BaseVersion): Promise<string | undefined> {
897
+ if (this.unsavedWrites.has(uri) || !isModelVersion(baseVersion)) {
886
898
  return undefined;
887
899
  }
888
900
  const known = this.lastAnswers.get(uri);
889
- if (known?.version === basedOn) {
890
- return known.textHash;
901
+ if (known?.version === baseVersion) {
902
+ return known.hash;
891
903
  }
892
904
  const read = await server.getModelDocument({ uri }).catch(() => undefined);
893
- return read?.version === basedOn ? read.textHash : undefined;
905
+ return read?.text?.version === baseVersion ? read.text.hash : undefined;
894
906
  }
895
907
 
896
908
  /**
@@ -910,7 +922,7 @@ export class DataSession<
910
922
  baseHash: string | undefined,
911
923
  call: DataSessionUnsavedWrite<TTransfer, TServer>['call']
912
924
  ): void {
913
- const answer = { version: document.version, textHash: document.textHash };
925
+ const answer = this.answerOf(uri, document);
914
926
  const kept = this.unsavedWrites.get(uri);
915
927
  if (kept && answer.version < kept.answer.version) {
916
928
  return;
@@ -920,7 +932,33 @@ export class DataSession<
920
932
  return;
921
933
  }
922
934
  this.unsavedWrites.set(uri, { baseHash: kept ? kept.baseHash : baseHash, answer, call });
923
- this.lastAnswers.set(uri, answer);
935
+ this.recordText(uri, document);
936
+ }
937
+
938
+ /**
939
+ * What {@link DataSessionUnsavedWrite.answer} keeps of `document`. Every write
940
+ * and open answers with a model; one without is reported and counted older
941
+ * than every version, so it never replaces a real answer.
942
+ */
943
+ protected answerOf(
944
+ uri: string,
945
+ document: DataSessionDocument<TTransfer, TServer>
946
+ ): DataSessionUnsavedWrite<TTransfer, TServer>['answer'] {
947
+ if (!document.model) {
948
+ const reported = resolve(DATA_SESSION_ANSWER_WITHOUT_MODEL, { uri });
949
+ this.host.reportError?.(new Error(reported.text), reported);
950
+ return { version: UNRECORDED_VERSION, hash: document.text?.hash };
951
+ }
952
+ return { version: document.model.version, hash: document.text?.hash };
953
+ }
954
+
955
+ /** Keep `document`'s text in {@link lastAnswers}, or forget it for a server that sends none. */
956
+ protected recordText(uri: string, document: DataSessionDocument<TTransfer, TServer>): void {
957
+ if (document.text) {
958
+ this.lastAnswers.set(uri, document.text);
959
+ } else {
960
+ this.lastAnswers.delete(uri);
961
+ }
924
962
  }
925
963
 
926
964
  /** Run `call`, counted as in flight on each of `uris` until it settles. */
@@ -100,10 +100,8 @@ export interface DocumentServerProtocol<TTransfer extends TransferElement, TDiag
100
100
  * as every document request here does. The document is read from disk
101
101
  * unless a client has it open already, so the request carries no
102
102
  * `languageId`, `version` or `text` seed. Returns the document at
103
- * the server's configured target phase — the same shape
104
- * {@link getModelDocument} returns, except that `version` is taken from the
105
- * text-document store so the caller's first `basedOn` write cannot
106
- * self-conflict. A repeat open changes nothing.
103
+ * the server's configured target phase, as {@link getModelDocument} does.
104
+ * A repeat open changes nothing.
107
105
  *
108
106
  * Pair with {@link watchModelDocument} to receive subsequent build-phase
109
107
  * events (open returns a one-shot snapshot; later validation diagnostics
@@ -10,7 +10,7 @@
10
10
  import type { TransferDiagnostic } from '../transfer-diagnostic';
11
11
  import type { TransferElement } from '../transfer-element';
12
12
  import type { Project } from '../project';
13
- import type { TransferDocument } from '../transfer-document';
13
+ import type { TextState, TransferDocument } from '../transfer-document';
14
14
 
15
15
  /**
16
16
  * Why an update event fired. Subscribers filter on reason for behaviour
@@ -106,14 +106,20 @@ export type TransferDocumentSavedListener<
106
106
  /**
107
107
  * Delivered on the data-server when a watched document's text starts or stops
108
108
  * differing from its file, as the server last knew the file. The current
109
- * answer rides on every transfer document the server sends as `dirty`; this
110
- * carries only its changes, so a client that follows it needs no rebuild to
111
- * learn of a save.
109
+ * answer rides on every transfer document the server sends as `text.dirty`;
110
+ * this carries only its changes, so a client that follows it needs no rebuild
111
+ * to learn of a save.
112
112
  */
113
113
  export interface TransferDocumentDirtyChangedEvent {
114
114
  /** Canonical URI, keyed as the subscription is. */
115
115
  readonly uri: string;
116
- readonly dirty: boolean;
116
+ /**
117
+ * The text the answer was decided on. It is sent before that text is
118
+ * built, so a `text.version` ahead of the model a client holds means an
119
+ * update at this version or a later one follows. Absent when the document
120
+ * no longer exists, or when the build that follows its release failed.
121
+ */
122
+ readonly text?: TextState;
117
123
  }
118
124
 
119
125
  /** Callback shape for `DataClientProtocol.onDocumentDirtyChanged`. */
@@ -7,7 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import type { BasedOn } from '../model-service/based-on';
10
+ import type { BaseVersion } from '../model-service/base-version';
11
11
 
12
12
  /** Get the current state of a single document. The server returns the latest built version. */
13
13
  export interface GetModelDocumentArgs {
@@ -53,16 +53,16 @@ export interface TransferUpdateDocumentArgs<TTransfer> {
53
53
  /** The whole structured model root, or its serialised textual form. */
54
54
  model: TTransfer | string;
55
55
  /**
56
- * What this write was authored against. A `SnapshotVersion` is compared
56
+ * What this write was authored against. A `ModelVersion` is compared
57
57
  * against the server's current text-document version for `uri` and throws
58
- * `ConflictError` on mismatch; `'anything'` writes unconditionally.
58
+ * `ConflictError` on mismatch; `'any'` writes unconditionally.
59
59
  *
60
60
  * **Required so that an ungated write is a decision rather than an
61
61
  * omission.** An optional gate is indistinguishable from a forgotten one at
62
62
  * the call site, and a file of twenty writes hides the one that lost the
63
63
  * field. Nothing else here can see that, since the defect is an absence.
64
64
  */
65
- basedOn: BasedOn;
65
+ baseVersion: BaseVersion;
66
66
  }
67
67
 
68
68
  /** Update a document the session `clientId` has open, then persist it to disk. */