@fluidframework/odsp-driver 2.114.0 → 2.116.0-416006

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 (79) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/api-extractor/api-extractor-lint-legacyAlpha.cjs.json +5 -0
  3. package/api-extractor/api-extractor-lint-legacyAlpha.esm.json +5 -0
  4. package/api-extractor/api-extractor.legacy.json +5 -1
  5. package/api-report/odsp-driver.legacy.alpha.api.md +232 -0
  6. package/dist/getUrlAndHeadersWithAuth.d.ts +4 -0
  7. package/dist/getUrlAndHeadersWithAuth.d.ts.map +1 -1
  8. package/dist/getUrlAndHeadersWithAuth.js +4 -0
  9. package/dist/getUrlAndHeadersWithAuth.js.map +1 -1
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +7 -3
  13. package/dist/index.js.map +1 -1
  14. package/dist/legacy.d.ts +1 -1
  15. package/dist/legacyAlpha.d.ts +49 -0
  16. package/dist/odspVersionManager/odspFileVersionFetcher.d.ts +40 -1
  17. package/dist/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -1
  18. package/dist/odspVersionManager/odspFileVersionFetcher.js +52 -14
  19. package/dist/odspVersionManager/odspFileVersionFetcher.js.map +1 -1
  20. package/dist/odspVersionManager/odspVersionManager.d.ts +23 -45
  21. package/dist/odspVersionManager/odspVersionManager.d.ts.map +1 -1
  22. package/dist/odspVersionManager/odspVersionManager.js +68 -33
  23. package/dist/odspVersionManager/odspVersionManager.js.map +1 -1
  24. package/dist/packageVersion.d.ts +1 -1
  25. package/dist/packageVersion.d.ts.map +1 -1
  26. package/dist/packageVersion.js +1 -1
  27. package/dist/packageVersion.js.map +1 -1
  28. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +5 -0
  29. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -1
  30. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js +6 -3
  31. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -1
  32. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +25 -33
  33. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -1
  34. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +62 -38
  35. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -1
  36. package/dist/public.d.ts +1 -1
  37. package/internal.d.ts +1 -1
  38. package/legacy/alpha.d.ts +11 -0
  39. package/legacy.d.ts +1 -1
  40. package/lib/getUrlAndHeadersWithAuth.d.ts +4 -0
  41. package/lib/getUrlAndHeadersWithAuth.d.ts.map +1 -1
  42. package/lib/getUrlAndHeadersWithAuth.js +4 -0
  43. package/lib/getUrlAndHeadersWithAuth.js.map +1 -1
  44. package/lib/index.d.ts +3 -2
  45. package/lib/index.d.ts.map +1 -1
  46. package/lib/index.js +5 -3
  47. package/lib/index.js.map +1 -1
  48. package/lib/legacy.d.ts +1 -1
  49. package/lib/legacyAlpha.d.ts +49 -0
  50. package/lib/odspVersionManager/odspFileVersionFetcher.d.ts +40 -1
  51. package/lib/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -1
  52. package/lib/odspVersionManager/odspFileVersionFetcher.js +53 -15
  53. package/lib/odspVersionManager/odspFileVersionFetcher.js.map +1 -1
  54. package/lib/odspVersionManager/odspVersionManager.d.ts +23 -45
  55. package/lib/odspVersionManager/odspVersionManager.d.ts.map +1 -1
  56. package/lib/odspVersionManager/odspVersionManager.js +68 -33
  57. package/lib/odspVersionManager/odspVersionManager.js.map +1 -1
  58. package/lib/packageVersion.d.ts +1 -1
  59. package/lib/packageVersion.d.ts.map +1 -1
  60. package/lib/packageVersion.js +1 -1
  61. package/lib/packageVersion.js.map +1 -1
  62. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +5 -0
  63. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -1
  64. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js +6 -3
  65. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -1
  66. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +25 -33
  67. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -1
  68. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +62 -38
  69. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -1
  70. package/lib/public.d.ts +1 -1
  71. package/package.json +26 -14
  72. package/src/getUrlAndHeadersWithAuth.ts +4 -0
  73. package/src/index.ts +8 -3
  74. package/src/odspVersionManager/DEV.md +376 -54
  75. package/src/odspVersionManager/odspFileVersionFetcher.ts +111 -16
  76. package/src/odspVersionManager/odspVersionManager.ts +106 -68
  77. package/src/packageVersion.ts +1 -1
  78. package/src/pointInTimeDriver/odspPointInTimeDocumentService.ts +8 -4
  79. package/src/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.ts +123 -48
@@ -8,8 +8,12 @@
8
8
  * - GET /_api/v2.1/.../versions -- enumerate the file's versions.
9
9
  * - GET /_api/v2.1/.../versions/{label}/opStream/snapshots/trees/latest?blobs=2 -- fetch a version's
10
10
  * snapshot and read its sequence number, parsed with the driver's snapshot parser.
11
+ * - GET /_api/v2.1/.../[versions/{label}/]opStream/snapshots/trees/latest?blobs=0 -- read a version's
12
+ * or the live document's ODSP epoch (`x-fluid-epoch`) to compare their lineage.
11
13
  */
12
14
 
15
+ import { NonRetryableError } from "@fluidframework/driver-utils/internal";
16
+ import { OdspErrorTypes } from "@fluidframework/odsp-driver-definitions/internal";
13
17
  import type {
14
18
  IOdspUrlParts,
15
19
  InstrumentedStorageTokenFetcher,
@@ -22,9 +26,50 @@ import type { EpochTracker } from "../epochTracker.js";
22
26
  import { getHeadersWithAuth } from "../getUrlAndHeadersWithAuth.js";
23
27
  import { convertOdspSnapshotToSnapshotTreeAndBlobs } from "../odspSnapshotParser.js";
24
28
  import { getApiRoot } from "../odspUrlHelper.js";
25
- import { getWithRetryForTokenRefresh } from "../odspUtils.js";
29
+ import { fetchArray, getWithRetryForTokenRefresh } from "../odspUtils.js";
30
+ import { pkgVersion as driverVersion } from "../packageVersion.js";
26
31
 
27
- import type { OdspFileVersionRef, IOdspFileVersionFetcher } from "./odspVersionManager.js";
32
+ /**
33
+ * A single ODSP file version, as listed by the file's version history.
34
+ */
35
+ export interface OdspFileVersionRef {
36
+ /**
37
+ * The version's label (e.g. `"42.0"`), used to address the version when fetching it.
38
+ */
39
+ readonly versionId: string;
40
+ /**
41
+ * Last-modified timestamp of this version, ISO-8601.
42
+ */
43
+ readonly lastModifiedDateTime: string;
44
+ }
45
+
46
+ /**
47
+ * Provides a file's versions and resolves each version's Fluid sequence number. Injected into
48
+ * the version manager so the selection logic does not depend on how versions are fetched.
49
+ */
50
+ export interface IOdspFileVersionFetcher {
51
+ /**
52
+ * Enumerate the file's versions, newest-first.
53
+ */
54
+ listFileVersions(): Promise<OdspFileVersionRef[]>;
55
+ /**
56
+ * Resolve a single version's Fluid sequence number. Throws on failure rather than returning a
57
+ * wrong value.
58
+ */
59
+ resolveSequenceNumber(versionId: string): Promise<number>;
60
+ /**
61
+ * Read the live document's current ODSP epoch (`x-fluid-epoch`), or `undefined`. Epoch identifies
62
+ * the file's binary lineage and changes on a version restore or download-then-reupload; compared
63
+ * with {@link IOdspFileVersionFetcher.getRecoverableVersionEpoch} to confirm a base is on the live
64
+ * document's lineage.
65
+ */
66
+ getLiveDocumentEpoch(): Promise<string | undefined>;
67
+ /**
68
+ * Read the ODSP epoch of a specific file version, or `undefined`. See
69
+ * {@link IOdspFileVersionFetcher.getLiveDocumentEpoch}.
70
+ */
71
+ getRecoverableVersionEpoch(versionId: string): Promise<string | undefined>;
72
+ }
28
73
 
29
74
  /**
30
75
  * Raw shape of a OneDrive/SharePoint driveItem version (an entry in the `/versions` response).
@@ -95,10 +140,8 @@ export function createOdspFileVersionFetcher(
95
140
 
96
141
  const resolveSequenceNumber = async (versionId: string): Promise<number> =>
97
142
  getWithRetryForTokenRefresh(async (options) => {
98
- // A file version's sequence number lives inside that version's snapshot, so fetch the snapshot
99
- // from the version-scoped endpoint. `blobs=2` inlines blob contents so the `.protocol/attributes`
100
- // blob (which carries the sequence number) is included; `deltas=1` is intentionally omitted, as
101
- // it would bundle the op stream and its op-level sequence numbers.
143
+ // The sequence number lives in the version snapshot's `.protocol/attributes` blob, so fetch the
144
+ // version-scoped snapshot with `blobs=2` to inline it. No op stream needed.
102
145
  const url = `${getApiRoot(new URL(siteUrl))}/drives/${driveId}/items/${itemId}/versions/${encodeURIComponent(
103
146
  versionId,
104
147
  )}/opStream/snapshots/trees/latest?blobs=2`;
@@ -108,10 +151,8 @@ export function createOdspFileVersionFetcher(
108
151
  "FileVersionSnapshot",
109
152
  );
110
153
  const headers = getHeadersWithAuth(token);
111
- // The server can return the snapshot in one of two equivalent framings: verbose JSON, or
112
- // "ms-fluid" ODSP's compact binary encoding of the same snapshot. Advertise both, and pin the
113
- // binary format version (as the driver's own snapshot fetch does) so the server cannot hand back
114
- // a binary version this code's parser does not understand.
154
+ // The snapshot comes back as JSON or "ms-fluid" (ODSP's compact binary form). Accept both and
155
+ // pin the binary version (as the driver's snapshot fetch does) so the parser can read it.
115
156
  headers.accept = `application/json, application/ms-fluid; v=${currentReadVersion}`;
116
157
  const response = await epochTracker.fetch(url, { method, headers }, "treesLatest");
117
158
  const contentType = response.headers.get("content-type") ?? "";
@@ -121,18 +162,72 @@ export function createOdspFileVersionFetcher(
121
162
  const snapshotJson = (await response.content.json()) as IOdspSnapshot;
122
163
  sequenceNumber =
123
164
  convertOdspSnapshotToSnapshotTreeAndBlobs(snapshotJson).sequenceNumber;
124
- } else {
165
+ } else if (contentType.includes("application/ms-fluid")) {
125
166
  // ms-fluid framing: the compact binary form; read it with the driver's compact-snapshot parser.
126
167
  const bytes = new Uint8Array(await response.content.arrayBuffer());
127
168
  sequenceNumber = parseCompactSnapshotResponse(bytes, logger).sequenceNumber;
169
+ } else {
170
+ // Neither framing (e.g. an HTML error page). Throw the driver's typed bad-response error
171
+ // (like fetchSnapshot.ts): canRetry=false stops the loader re-driving, while the
172
+ // incorrectServerResponse errorType still earns one wire-retry from getWithRetryForTokenRefresh.
173
+ throw new NonRetryableError(
174
+ `ODSP file version ${versionId} snapshot returned an unexpected content-type`,
175
+ OdspErrorTypes.incorrectServerResponse,
176
+ { driverVersion, contentType, accept: headers.accept },
177
+ );
128
178
  }
129
- // A version's snapshot must carry a sequence number; a missing one is surfaced as an error
130
- // naming the version, rather than returning a wrong value.
131
- if (sequenceNumber === undefined) {
132
- throw new Error(`ODSP file version ${versionId} snapshot is missing a sequenceNumber`);
179
+ // The sequence number must be a non-negative integer; a missing or malformed one throws the same
180
+ // typed error as above rather than feeding a wrong value into base selection.
181
+ if (
182
+ !(
183
+ typeof sequenceNumber === "number" &&
184
+ Number.isInteger(sequenceNumber) &&
185
+ sequenceNumber >= 0
186
+ )
187
+ ) {
188
+ throw new NonRetryableError(
189
+ `ODSP file version ${versionId} snapshot has a missing or invalid sequenceNumber (${String(sequenceNumber)})`,
190
+ OdspErrorTypes.incorrectServerResponse,
191
+ { driverVersion, contentType, accept: headers.accept },
192
+ );
133
193
  }
134
194
  return sequenceNumber;
135
195
  });
136
196
 
137
- return { listFileVersions, resolveSequenceNumber };
197
+ const itemRoot = `${getApiRoot(new URL(siteUrl))}/drives/${driveId}/items/${itemId}`;
198
+
199
+ // Reads the `x-fluid-epoch` header from `url`. Deliberately uses the raw fetch helper instead of
200
+ // `epochTracker.fetch`: the whole point is to COMPARE the base version's epoch against the live
201
+ // document's epoch, but the shared EpochTracker pins to the first epoch it sees and throws on the
202
+ // second (divergent) read - so it could never yield two epochs to compare. `fetchArray` also lets
203
+ // the body (JSON or ms-fluid binary) be consumed and discarded; only the header is needed.
204
+ const readEpoch = async (url: string, scenarioName: string): Promise<string | undefined> =>
205
+ getWithRetryForTokenRefresh(async (options) => {
206
+ const method = "GET";
207
+ const token = await getAuthHeader(
208
+ { ...options, request: { url, method } },
209
+ scenarioName,
210
+ );
211
+ const headers = getHeadersWithAuth(token);
212
+ const response = await fetchArray(url, { method, headers });
213
+ return response.headers.get("x-fluid-epoch") ?? undefined;
214
+ });
215
+
216
+ const getLiveDocumentEpoch = async (): Promise<string | undefined> =>
217
+ // The (unversioned) live snapshot endpoint is a current-file read, so its epoch is the live
218
+ // document's epoch. `blobs=0` keeps the response to the tree metadata.
219
+ readEpoch(`${itemRoot}/opStream/snapshots/trees/latest?blobs=0`, "LiveEpoch");
220
+
221
+ const getRecoverableVersionEpoch = async (versionId: string): Promise<string | undefined> =>
222
+ readEpoch(
223
+ `${itemRoot}/versions/${encodeURIComponent(versionId)}/opStream/snapshots/trees/latest?blobs=0`,
224
+ "FileVersionEpoch",
225
+ );
226
+
227
+ return {
228
+ listFileVersions,
229
+ resolveSequenceNumber,
230
+ getLiveDocumentEpoch,
231
+ getRecoverableVersionEpoch,
232
+ };
138
233
  }
@@ -11,24 +11,23 @@
11
11
  * how versions are enumerated and resolved (real ODSP, a test double, or an alternative backend).
12
12
  */
13
13
 
14
+ import { PromiseCache } from "@fluidframework/core-utils/internal";
15
+ import { NonRetryableError } from "@fluidframework/driver-utils/internal";
16
+ import { OdspErrorTypes } from "@fluidframework/odsp-driver-definitions/internal";
17
+
18
+ import { pkgVersion as driverVersion } from "../packageVersion.js";
19
+
14
20
  import {
15
21
  createOdspFileVersionFetcher,
16
22
  type OdspFileVersionFetcherProps,
23
+ type OdspFileVersionRef,
24
+ type IOdspFileVersionFetcher,
17
25
  } from "./odspFileVersionFetcher.js";
18
26
 
19
- /**
20
- * A single ODSP file version, as listed by the file's version history.
21
- */
22
- export interface OdspFileVersionRef {
23
- /**
24
- * The version's label (e.g. `"42.0"`), used to address the version when fetching it.
25
- */
26
- readonly versionId: string;
27
- /**
28
- * Last-modified timestamp of this version, ISO-8601.
29
- */
30
- readonly lastModifiedDateTime: string;
31
- }
27
+ // Re-exported so consumers (and this module's own index) can keep importing these fetcher-owned
28
+ // types from the version manager. The definitions live in odspFileVersionFetcher.ts so that file
29
+ // does not depend on this one, avoiding a circular dependency between the two modules.
30
+ export type { OdspFileVersionRef, IOdspFileVersionFetcher } from "./odspFileVersionFetcher.js";
32
31
 
33
32
  /**
34
33
  * An ODSP file version together with its resolved Fluid sequence number.
@@ -44,9 +43,11 @@ export interface ResolvedVersion extends OdspFileVersionRef {
44
43
  * Result of resolving the base version for a target sequence number.
45
44
  *
46
45
  * @remarks
47
- * There is intentionally no `targetIsLive` case: when the target is at/after the newest recoverable
48
- * version, the greatest version with `seq <= target` IS that newest version, so it is a normal
49
- * `found`. A consumer may separately choose to load the live file when the target is near the head.
46
+ * The tip (newest) version is excluded from base selection, so when the target is at or after the head
47
+ * the base is the newest *sealed* version with `seq <= target` (a normal `found`); if the file's only
48
+ * version is the tip, the result is `noBaseVersion`. The wired consumer surfaces `noBaseVersion` as a
49
+ * `UsageError`; loading the live file for a near-head target is a possible future consumer choice, not
50
+ * current behavior.
50
51
  */
51
52
  export type BaseForSeq =
52
53
  | {
@@ -55,28 +56,15 @@ export type BaseForSeq =
55
56
  readonly base: ResolvedVersion;
56
57
  }
57
58
  | {
58
- /** No recoverable version has `sequenceNumber <= target` (target predates retained history). */
59
+ /**
60
+ * No sealed version has `sequenceNumber <= target` — the target predates retained history, or
61
+ * the only version is the excluded tip.
62
+ */
59
63
  readonly kind: "noBaseVersion";
60
64
  /** The oldest sequence number that was resolved while searching, if any. */
61
65
  readonly oldestResolvedSeq?: number;
62
66
  };
63
67
 
64
- /**
65
- * Provides a file's versions and resolves each version's Fluid sequence number. Injected into
66
- * the version manager so the selection logic does not depend on how versions are fetched.
67
- */
68
- export interface IOdspFileVersionFetcher {
69
- /**
70
- * Enumerate the file's versions, newest-first.
71
- */
72
- listFileVersions(): Promise<OdspFileVersionRef[]>;
73
- /**
74
- * Resolve a single version's Fluid sequence number. Throws on failure rather than returning a
75
- * wrong value.
76
- */
77
- resolveSequenceNumber(versionId: string): Promise<number>;
78
- }
79
-
80
68
  /**
81
69
  * Selects the file version to use as the base for loading or replaying to a target sequence number.
82
70
  */
@@ -84,37 +72,44 @@ export interface IOdspVersionManager {
84
72
  /**
85
73
  * Given a target sequence number, return the closest version at or before it (`found`), or
86
74
  * `noBaseVersion` if the target predates the oldest retained version.
75
+ *
76
+ * @remarks
77
+ * A `found` base is guaranteed to share the live document's ODSP epoch (lineage): before returning
78
+ * it, the chosen base's epoch is compared with the live document's, and a mismatch throws a non-retryable error
79
+ * rather than returning a base that cannot be replayed. Op availability is enforced separately and
80
+ * lazily as the loader reads the bridging ops.
87
81
  */
88
82
  findBaseForSeq(target: number): Promise<BaseForSeq>;
89
83
  }
90
84
 
91
85
  /**
92
- * Default {@link IOdspVersionManager}. Caches the version list and resolved sequence numbers. The
86
+ * Default {@link IOdspVersionManager}. Caches resolved sequence numbers (which never change); the version
87
+ * list is re-enumerated on each query rather than cached, since new versions are cut over time. The
93
88
  * resolution strategy (eager, newest-to-oldest, stopping at the first usable base) is hidden behind
94
89
  * {@link findBaseForSeq} and can change without affecting callers.
95
90
  */
91
+ // Exported only so the same-package tests can construct it with a fake IOdspFileVersionFetcher.
92
+ // Deliberately kept out of the folder barrel and the package public index, so it is not public API.
96
93
  export class OdspVersionManager implements IOdspVersionManager {
97
- private versionsCache: Promise<OdspFileVersionRef[]> | undefined;
98
- private readonly seqByVersion = new Map<string, Promise<number>>();
94
+ // Sealed versions' sequence numbers, memoized so each is resolved at most once per manager instance
95
+ // (a sealed version's number is fixed once the version exists).
96
+ private readonly seqCache = new PromiseCache<string, number>();
97
+ // Sealed versions' ODSP epochs, memoized like their sequence numbers. The live document's epoch is NOT
98
+ // cached — it can change (restore/reupload), so validateLineageEpoch always reads it fresh.
99
+ private readonly epochCache = new PromiseCache<string, string | undefined>();
99
100
 
100
101
  public constructor(private readonly fetcher: IOdspFileVersionFetcher) {}
101
102
 
102
- public refresh(): void {
103
- this.versionsCache = undefined;
104
- this.seqByVersion.clear();
105
- }
106
-
107
103
  public async findBaseForSeq(target: number): Promise<BaseForSeq> {
108
- // Recoverable base candidates = every version except the tip (index 0 ≈ the live document).
109
- const versions = await this.getVersions();
104
+ // Re-enumerate the list each call (it changes as new versions are cut).
105
+ const versions = await this.fetcher.listFileVersions();
106
+
107
+ // Start past the tip (index 0): the newest version's sequence number can still advance until a newer
108
+ // version is cut, so it is treated as the live head rather than a stable base. Scan the remaining
109
+ // (sealed) versions newest-first and return the first with sequence number <= target — the closest
110
+ // base — or noBaseVersion, reporting the oldest sequence number seen.
110
111
  const candidates = versions.slice(1);
111
112
 
112
- // Versions are listed newest-first, and version order is expected to track sequence number, so
113
- // the first candidate whose seq is at or before the target is taken as the closest base. Because
114
- // any base at or before the target replays forward to the same state, this early stop is an
115
- // optimization, not a correctness requirement: if version order and sequence order ever diverge,
116
- // a base that is valid but not strictly the closest may be chosen.
117
- // Scanning newest-first also yields the newest of versions sharing a sequence number (dedup).
118
113
  let oldestResolvedSeq: number | undefined;
119
114
  for (const version of candidates) {
120
115
  const sequenceNumber = await this.resolveSeq(version.versionId);
@@ -123,40 +118,83 @@ export class OdspVersionManager implements IOdspVersionManager {
123
118
  ? sequenceNumber
124
119
  : Math.min(oldestResolvedSeq, sequenceNumber);
125
120
  if (sequenceNumber <= target) {
126
- return { kind: "found", base: { ...version, sequenceNumber } };
121
+ const base = { ...version, sequenceNumber };
122
+ // Confirm the chosen base shares the live document's lineage before handing it back
123
+ await this.validateLineageEpoch(base);
124
+ return { kind: "found", base };
127
125
  }
128
126
  }
129
127
  return { kind: "noBaseVersion", oldestResolvedSeq };
130
128
  }
131
129
 
130
+ private async validateLineageEpoch(base: ResolvedVersion): Promise<void> {
131
+ // The live document's epoch can change (a restore or download-and-reupload bumps it), so it is
132
+ // always read fresh. A numbered version's snapshot is immutable, so its epoch never changes and
133
+ // is cached per versionId (see resolveVersionEpoch).
134
+ const [liveEpoch, baseEpoch] = await Promise.all([
135
+ this.fetcher.getLiveDocumentEpoch(),
136
+ this.resolveVersionEpoch(base.versionId),
137
+ ]);
138
+ if (liveEpoch === undefined || baseEpoch === undefined) {
139
+ throw new NonRetryableError(
140
+ `Cannot verify that ODSP file version ${base.versionId} shares the live document's ` +
141
+ `lineage: the storage response is missing an epoch (base epoch: ${baseEpoch ?? "unknown"}, ` +
142
+ `live epoch: ${liveEpoch ?? "unknown"}).`,
143
+ OdspErrorTypes.incorrectServerResponse,
144
+ {
145
+ driverVersion,
146
+ serverEpoch: liveEpoch,
147
+ clientEpoch: baseEpoch,
148
+ },
149
+ );
150
+ }
151
+ if (liveEpoch !== baseEpoch) {
152
+ throw new NonRetryableError(
153
+ `ODSP file version ${base.versionId} is on epoch "${baseEpoch}" but the live document is ` +
154
+ `on epoch "${liveEpoch}". A binary file change (e.g. a version restore or ` +
155
+ `download-and-reupload) renumbered the op stream, so ops cannot be replayed from this ` +
156
+ `base onto the live document.`,
157
+ OdspErrorTypes.fileOverwrittenInStorage,
158
+ {
159
+ driverVersion,
160
+ serverEpoch: liveEpoch,
161
+ clientEpoch: baseEpoch,
162
+ },
163
+ );
164
+ }
165
+ }
166
+
132
167
  public async listVersions(): Promise<ResolvedVersion[]> {
133
- const versions = await this.getVersions();
134
- // Resolution order does not matter here, so resolve concurrently; the newest-first array order is
168
+ const versions = await this.fetcher.listFileVersions();
169
+ // Resolution order does not matter, so resolve concurrently; the newest-first array order is
135
170
  // preserved by Promise.all regardless of completion order.
136
171
  return Promise.all(
137
- versions.map(async (version) => ({
172
+ versions.map(async (version, index) => ({
138
173
  ...version,
139
- sequenceNumber: await this.resolveSeq(version.versionId),
174
+ // Resolve the tip (index 0) fresh each call, since its sequence number can still change;
175
+ // sealed versions come from the cache.
176
+ sequenceNumber:
177
+ index === 0
178
+ ? await this.fetcher.resolveSequenceNumber(version.versionId)
179
+ : await this.resolveSeq(version.versionId),
140
180
  })),
141
181
  );
142
182
  }
143
183
 
144
- private async getVersions(): Promise<OdspFileVersionRef[]> {
145
- // Cache the pending promise, not the awaited value, so concurrent callers share one fetch and a
146
- // refresh() that runs while the fetch is in flight is not overwritten when the fetch settles.
147
- this.versionsCache ??= this.fetcher.listFileVersions();
148
- return this.versionsCache;
184
+ private async resolveSeq(versionId: string): Promise<number> {
185
+ // Cached indefinitely (a sealed version's number is fixed); concurrent calls coalesce and a failed
186
+ // resolution is evicted so a later call retries.
187
+ return this.seqCache.addOrGet(versionId, async () =>
188
+ this.fetcher.resolveSequenceNumber(versionId),
189
+ );
149
190
  }
150
191
 
151
- private async resolveSeq(versionId: string): Promise<number> {
152
- // Cache the pending promise (a version's sequence number never changes) so concurrent callers
153
- // coalesce and a refresh() is not clobbered by a fetch that was already in flight.
154
- let pending = this.seqByVersion.get(versionId);
155
- if (pending === undefined) {
156
- pending = this.fetcher.resolveSequenceNumber(versionId);
157
- this.seqByVersion.set(versionId, pending);
158
- }
159
- return pending;
192
+ private async resolveVersionEpoch(versionId: string): Promise<string | undefined> {
193
+ // Cached like resolveSeq (a sealed version's epoch is fixed). The live document's epoch is read
194
+ // fresh instead (see validateLineageEpoch).
195
+ return this.epochCache.addOrGet(versionId, async () =>
196
+ this.fetcher.getRecoverableVersionEpoch(versionId),
197
+ );
160
198
  }
161
199
  }
162
200
 
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  export const pkgName = "@fluidframework/odsp-driver";
9
- export const pkgVersion = "2.114.0";
9
+ export const pkgVersion = "2.116.0-416006";
@@ -30,6 +30,11 @@ import type {
30
30
  * still catches up from the snapshot's sequence number through delta storage, which is exactly the
31
31
  * bounded replay we want. As a result no live delta-stream connection is ever established.
32
32
  *
33
+ * Op availability is enforced by the delta storage stack itself: it validates that fetched batches
34
+ * are contiguous from the requested start, keeps requesting until the bounded range is fully
35
+ * delivered, and fails the fetch if the ops never materialize. So a stream that completes has
36
+ * necessarily served the whole bridge, and no additional checks are needed here.
37
+ *
33
38
  * @internal
34
39
  */
35
40
  export class OdspPointInTimeDocumentService
@@ -67,15 +72,14 @@ export class OdspPointInTimeDocumentService
67
72
  // The exclusive upper bound needed to include the target op itself.
68
73
  const boundedTo = this.targetSequenceNumber + 1;
69
74
  return {
70
- fetchMessages: (from, to, abortSignal, cachedOnly, fetchReason) => {
71
- return liveDeltaStorage.fetchMessages(
75
+ fetchMessages: (from, to, abortSignal, cachedOnly, fetchReason) =>
76
+ liveDeltaStorage.fetchMessages(
72
77
  from,
73
78
  to === undefined ? boundedTo : Math.min(to, boundedTo),
74
79
  abortSignal,
75
80
  cachedOnly,
76
81
  fetchReason,
77
- );
78
- },
82
+ ),
79
83
  };
80
84
  }
81
85