@fluidframework/odsp-driver 2.111.0 → 2.113.0-411909

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 (66) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/odspVersionManager/index.d.ts +6 -0
  7. package/dist/odspVersionManager/index.d.ts.map +1 -0
  8. package/dist/odspVersionManager/index.js +10 -0
  9. package/dist/odspVersionManager/index.js.map +1 -0
  10. package/dist/odspVersionManager/odspFileVersionFetcher.d.ts +22 -0
  11. package/dist/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -0
  12. package/dist/odspVersionManager/odspFileVersionFetcher.js +81 -0
  13. package/dist/odspVersionManager/odspFileVersionFetcher.js.map +1 -0
  14. package/dist/odspVersionManager/odspVersionManager.d.ts +98 -0
  15. package/dist/odspVersionManager/odspVersionManager.d.ts.map +1 -0
  16. package/dist/odspVersionManager/odspVersionManager.js +87 -0
  17. package/dist/odspVersionManager/odspVersionManager.js.map +1 -0
  18. package/dist/packageVersion.d.ts +1 -1
  19. package/dist/packageVersion.d.ts.map +1 -1
  20. package/dist/packageVersion.js +1 -1
  21. package/dist/packageVersion.js.map +1 -1
  22. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +37 -0
  23. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -0
  24. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js +70 -0
  25. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -0
  26. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +49 -0
  27. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -0
  28. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +108 -0
  29. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -0
  30. package/lib/index.d.ts +1 -0
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +2 -0
  33. package/lib/index.js.map +1 -1
  34. package/lib/odspVersionManager/index.d.ts +6 -0
  35. package/lib/odspVersionManager/index.d.ts.map +1 -0
  36. package/lib/odspVersionManager/index.js +6 -0
  37. package/lib/odspVersionManager/index.js.map +1 -0
  38. package/lib/odspVersionManager/odspFileVersionFetcher.d.ts +22 -0
  39. package/lib/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -0
  40. package/lib/odspVersionManager/odspFileVersionFetcher.js +77 -0
  41. package/lib/odspVersionManager/odspFileVersionFetcher.js.map +1 -0
  42. package/lib/odspVersionManager/odspVersionManager.d.ts +98 -0
  43. package/lib/odspVersionManager/odspVersionManager.d.ts.map +1 -0
  44. package/lib/odspVersionManager/odspVersionManager.js +82 -0
  45. package/lib/odspVersionManager/odspVersionManager.js.map +1 -0
  46. package/lib/packageVersion.d.ts +1 -1
  47. package/lib/packageVersion.d.ts.map +1 -1
  48. package/lib/packageVersion.js +1 -1
  49. package/lib/packageVersion.js.map +1 -1
  50. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +37 -0
  51. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -0
  52. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js +66 -0
  53. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -0
  54. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +49 -0
  55. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -0
  56. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +104 -0
  57. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -0
  58. package/package.json +12 -12
  59. package/src/index.ts +2 -0
  60. package/src/odspVersionManager/DEV.md +349 -0
  61. package/src/odspVersionManager/index.ts +12 -0
  62. package/src/odspVersionManager/odspFileVersionFetcher.ts +138 -0
  63. package/src/odspVersionManager/odspVersionManager.ts +170 -0
  64. package/src/packageVersion.ts +1 -1
  65. package/src/pointInTimeDriver/odspPointInTimeDocumentService.ts +94 -0
  66. package/src/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.ts +185 -0
@@ -0,0 +1,349 @@
1
+ # ODSP Version History Load — Design Catechism
2
+
3
+ ## How to read this document
4
+
5
+ This spec is a **catechism**: a hierarchical series of questions and answers that is the prose
6
+ mirror of the test suite. Each topic-level question (`###`) corresponds to a `describe` block; each
7
+ leaf question maps to one test, linked by a stable **code ID** at the end of the answer. The same ID
8
+ appears as a `// @q <id>` tag on the matching test.
9
+
10
+ ID format: `<area>-<topic>-<nn>`. Area is `M` (the version manager — [Part II](#part-ii--the-version-manager))
11
+ or `F` (the file-version fetcher — [Part III](#part-iii--the-file-version-fetcher)). Topic is a short
12
+ mnemonic (e.g. `SELECT`, `RESOLVE`). `nn` is a zero-padded counter. IDs are append-only: existing IDs
13
+ never renumber, so links stay stable across edits.
14
+
15
+ The contract:
16
+
17
+ - **Every leaf Q&A has a test, and every test has a leaf Q&A**, joined by its code ID. A change to
18
+ behavior is a change to both.
19
+ - **This document reflects the code as it is now.** Future/aspirational work lives in
20
+ [Part IV — Directional](#part-iv--directional), written as questions that cannot yet be answered
21
+ "yes".
22
+
23
+ ### Terminology (legend)
24
+
25
+ This code is one piece of a larger capability — letting a document be viewed or recovered at an
26
+ earlier point in time — which spans two repositories:
27
+
28
+ - **Part 1 — point-in-time load** (this repository): given a target sequence number, produce a
29
+ read-only view of the document at that number.
30
+ - **Part 2 — capture & marker** (a separate host repository): record the markers that name the points
31
+ worth returning to. Out of scope for this document.
32
+
33
+ Part 1 is built in three components:
34
+
35
+ - **Component A — the version manager**: choose which file version to load or replay from. **This
36
+ folder is Component A**, and this document is mostly about it.
37
+ - **Component B — the recomposed driver**: load the chosen version and replay ops forward to the exact
38
+ target. **Built** in `../pointInTimeDriver/` (`OdspPointInTimeDocumentServiceFactory` /
39
+ `OdspPointInTimeDocumentService`) — see [Part V](#part-v--components-b--c-as-built).
40
+ - **Component C — the loader hookup**: expose Component B through the container loader. **Built** in
41
+ `@fluidframework/container-loader` (`loadContainerToSequenceNumber`) — see
42
+ [Part V](#part-v--components-b--c-as-built).
43
+
44
+ ## Part I — Foundations
45
+
46
+ These are conceptual answers with no single test; they frame everything below.
47
+
48
+ ### What problem does this code solve?
49
+
50
+ Given a target Fluid **sequence number** (Fluid numbers every change to a document: op 1, 2, 3, …),
51
+ we want to materialize the document as it was at that number. The first step is choosing a **base**:
52
+ the most recent saved version whose state is at or before the target, from which the remaining ops
53
+ can be replayed. This code finds that base.
54
+
55
+ ### What is an ODSP file version, and how is it different from a Fluid snapshot?
56
+
57
+ Two different things are both called a "version":
58
+
59
+ - A **file version** is an entry in the file's version history — a recoverable saved state of the whole
60
+ file, addressed by a label such as `"42.0"`. These are what a user could restore to.
61
+ - A **Fluid snapshot** is an internal checkpoint the runtime writes; the driver's snapshot list
62
+ (`getVersions`) enumerates these, not the file versions.
63
+
64
+ Selecting a base uses the **file version history**, enumerated by the driveItem `/versions` API — not
65
+ the driver's snapshot list.
66
+
67
+ ### Why the closest version at or before the target, rather than any earlier one?
68
+
69
+ Any base at or before the target can be replayed forward to the target and yields the same state, so
70
+ the choice is not about correctness. The **closest** one minimizes how many ops must be replayed, and
71
+ minimizes the chance that the needed ops have been trimmed from retention. Selection therefore aims for
72
+ the greatest version sequence number at or before the target. Because versions are enumerated
73
+ newest-first and version order is expected to track sequence order, an early-stop scan finds it; if that
74
+ ordering is ever violated, a valid but not-strictly-closest base may be chosen (still correct, just less
75
+ optimal) — see [Part IV](#part-iv--directional) for the planned order-tolerant search.
76
+
77
+ ### How is a version's sequence number obtained?
78
+
79
+ By fetching that version's snapshot from the **version-scoped snapshot endpoint**
80
+ (`.../versions/{label}/opStream/snapshots/trees/latest?blobs=2`), which returns the snapshot in the
81
+ driver's normal (`application/json` or `application/ms-fluid`) framing. The driver's existing snapshot
82
+ parser reads it, and the sequence number is `trees[0].sequenceNumber`. `blobs=2` inlines blob contents
83
+ so the parser has everything it needs.
84
+
85
+ ### What is deliberately not built in *this folder*?
86
+
87
+ Components B and C now exist, but elsewhere: the recomposed driver in `../pointInTimeDriver/` and the
88
+ loader hookup in `@fluidframework/container-loader` (see
89
+ [Part V](#part-v--components-b--c-as-built)). This folder (Component A) still owns only base selection.
90
+ Not built anywhere yet: bridging a trimmed op range via an intermediate snapshot, and a test against a
91
+ live ODSP file. See [Part IV](#part-iv--directional).
92
+
93
+ ## Part II — The Version Manager
94
+
95
+ `OdspVersionManager` selects the base version. It depends on an injected `IOdspFileVersionFetcher`, so
96
+ these behaviors are tested with an in-memory fake.
97
+
98
+ ### Which version does `findBaseForSeq` pick for a target sequence number?
99
+
100
+ The list is newest-first, and the tip (index 0, the live document) is not a base candidate. Among the
101
+ remaining versions, the answer is the closest one at or before the target — the greatest sequence number
102
+ at or before the target when version order tracks sequence order, which an early-stop newest-first scan
103
+ finds.
104
+
105
+ - **Target between two versions?** The closer, older one. `M-SELECT-01`
106
+ - **Target equal to a version?** That version, an exact match (zero ops to replay). `M-SELECT-02`
107
+ - **Target newer than every version?** The newest recoverable version. `M-SELECT-03`
108
+ - **Target older than every version?** `noBaseVersion`, reporting the oldest sequence number seen.
109
+ `M-SELECT-04`
110
+
111
+ ### How does it handle duplicate versions and the tip?
112
+
113
+ - **Two versions share a sequence number?** Return the newest label (a metadata-only re-save leaves the
114
+ sequence number unchanged; the newest is closest to the head). `M-DEDUP-01`
115
+ - **The tip (index 0)?** Never treated as a base; its sequence number is never even resolved.
116
+ `M-TIP-01`
117
+ - **Only the tip exists?** `noBaseVersion`. `M-TIP-02`
118
+ - **No versions at all?** `noBaseVersion`. `M-EMPTY-01`
119
+
120
+ ### What work does it avoid?
121
+
122
+ - **Resolving more versions than needed?** It stops at the first version at or before the target and
123
+ does not resolve older ones. `M-STOP-01`
124
+ - **Re-fetching across calls?** The version list and each resolved sequence number are cached.
125
+ `M-CACHE-01`
126
+ - **Stale caches?** `refresh()` drops both the version list and the resolved sequence numbers, so the
127
+ next query re-enumerates and re-resolves. `M-CACHE-02`
128
+ - **A `refresh()` while a fetch is still in flight?** The cache holds the pending fetch rather than its
129
+ eventual value, so a fetch that started before the refresh cannot write its now-stale result back over
130
+ the cleared cache; the next query re-fetches. `M-CACHE-03`
131
+
132
+ ### What happens when a version cannot be resolved?
133
+
134
+ The failure propagates; it is never swallowed into a wrong base. `M-ERR-01`
135
+
136
+ ### What does `listVersions` return?
137
+
138
+ Every version with its resolved sequence number, newest-first. `M-LIST-01`
139
+
140
+ ## Part III — The File-Version Fetcher
141
+
142
+ `createOdspFileVersionFetcher` is the real `IOdspFileVersionFetcher`, talking to ODSP. Its behaviors
143
+ are tested against a stubbed `fetch` that returns canned responses through the real request,
144
+ authentication, and snapshot-parsing code.
145
+
146
+ ### How does it enumerate versions?
147
+
148
+ It calls the driveItem versions URL — built from the same API root as the snapshot call — and maps the
149
+ `value` array of each page to versions (newest-first). `F-LIST-01` A long history is paged, so it follows
150
+ `@odata.nextLink` until it is absent and concatenates every page; a base version beyond the first page is
151
+ therefore still found rather than mistaken for `noBaseVersion`. `F-LIST-02` A response without a `value`
152
+ field yields an empty list rather than an error. `F-LIST-03`
153
+
154
+ ### How does it resolve a version's sequence number?
155
+
156
+ - **A well-formed snapshot?** It calls the version-scoped snapshot URL (`.../versions/{label}/opStream/snapshots/trees/latest?blobs=2`),
157
+ parses the response, and returns `trees[0].sequenceNumber`. `F-RESOLVE-01`
158
+ - **A snapshot with no sequence number?** It throws, naming the version, rather than returning a wrong
159
+ value. `F-RESOLVE-02`
160
+ - **A binary (`application/ms-fluid`) snapshot?** It reads it with the driver's compact-snapshot parser
161
+ and returns the same sequence number the JSON path would. `F-RESOLVE-03`
162
+
163
+ ### How does it handle request failures?
164
+
165
+ - **A non-success response while enumerating?** The failure propagates rather than being read as an
166
+ empty result. `F-ERROR-01`
167
+ - **A non-success response while resolving?** Likewise, it propagates rather than yielding a wrong value.
168
+ `F-ERROR-03`
169
+ - **An authentication failure while enumerating?** The shared token-refresh wrapper refreshes the token
170
+ and retries the request once. `F-ERROR-04`
171
+ - **An authentication failure while resolving?** Likewise, it refreshes the token and retries once.
172
+ `F-ERROR-02`
173
+
174
+ ## Part IV — Directional
175
+
176
+ Aspirational behaviors, written as questions that cannot yet be answered "yes".
177
+
178
+ ### Should sequence-number resolution be lazy or binary-search, rather than eager?
179
+
180
+ Resolving each version costs one snapshot fetch. With up to ~50 versions, an eager newest-to-oldest
181
+ scan can fetch more than necessary. The public contract (`findBaseForSeq`) already hides the strategy,
182
+ so a binary search over versions could replace it without changing callers.
183
+
184
+ The version list is effectively a sorted array: it is newest-first, and a version's sequence number is
185
+ monotonically non-increasing toward older versions (a newer version is a later state). That makes it
186
+ searchable for "the greatest sequence number at or before the target". The search must be "fuzzy" rather
187
+ than textbook, for two reasons: versions can share a sequence number (a metadata-only re-save leaves it
188
+ unchanged), so it is a sorted array with duplicates; and the ordering can have small local inversions.
189
+ The robust shape is therefore binary/interpolation to get close, then a short local walk (older if the
190
+ probe overshot the target, newer while still at or before it) to pin the exact base and absorb ties and
191
+ inversions.
192
+
193
+ Two further refinements reduce fetches. First, a version's sequence number never changes, so once
194
+ resolved it can be cached indefinitely; refreshing only needs to reconcile which versions still exist
195
+ (dropping ones that aged out), not re-resolve sequence numbers. Second, selection does not need the exact
196
+ closest version — any version within a bounded number of ops of the target is "close enough", because the
197
+ recomposed driver replays the remaining ops anyway; a tolerance lets the search stop early.
198
+
199
+ ### Could the version list's `lastModifiedDateTime` seed the search?
200
+
201
+ Each version carries a `lastModifiedDateTime` in the list response, for free — unlike a sequence number,
202
+ which costs a fetch to resolve. If the target is accompanied by a wall-clock time (for example, a time
203
+ recorded when a mark was made), that timestamp does not replace the search — it replaces its **first
204
+ probe**. Instead of starting at the blind midpoint, seed at the newest version whose
205
+ `lastModifiedDateTime` is at or before the target time (a comparison over the already-fetched list, zero
206
+ fetches), then converge:
207
+
208
+ 1. Resolve the seed version's sequence number (the first fetch).
209
+ 2. If it overshot the target (`seq > target`), step toward older versions; if it is at or before the
210
+ target, step toward newer versions while still at or before it — to land on the greatest sequence
211
+ number at or before the target.
212
+ 3. Because time, list order, and sequence number all move together, this correction is usually zero or
213
+ one step. If the seed is far off (large clock drift), fall back to binary search over the residual
214
+ interval, bounding the worst case at ~log N.
215
+
216
+ The timestamp is only a seed, never the answer: time does not map linearly to sequence number (edits are
217
+ bursty) and clocks can skew, so the neighbourhood it points to must still be pinned by resolving sequence
218
+ numbers. Timestamps are ISO-8601 UTC; any caller-supplied time must be normalized to UTC before
219
+ comparison. It also allows locating a version by time when no sequence number is available. This is why
220
+ `lastModifiedDateTime` is carried on a version even though base selection itself does not use it today.
221
+
222
+
223
+
224
+ ### How would Component B bridge a *trimmed* op range between snapshots?
225
+
226
+ Component B is built (see [Part V](#part-v--components-b--c-as-built)), but the version it ships makes
227
+ one simplifying assumption: it loads a single base file version and replays the ops in `(base, target]`
228
+ from the **live** document's delta storage. That assumption holds only while those ops are still
229
+ retained. Bridging a *trimmed* range by starting from a newer intermediate snapshot is the part that is
230
+ not built yet — the rest of this answer is its design.
231
+
232
+ A snapshot already contains the full accumulated state at its sequence number — every op at or below it
233
+ is baked in. So to reach a target `T`, Component B loads the closest base snapshot (`seq ≤ T`) and
234
+ replays only the ops in `(base, T]` on top of it. Those ops come from the op stream (delta storage), and
235
+ may also be bundled with a snapshot (the `deltas=1` query parameter, deliberately omitted here because
236
+ the manager only needs the sequence number, not the ops).
237
+
238
+ Ops in the op stream are retained for a window and can be trimmed. The resolution is not to fetch the
239
+ trimmed ops from somewhere else — it is to **start from a newer snapshot that already absorbed them**. If
240
+ the ops just after the base are gone but another snapshot exists later in `(base, T]`, that snapshot's
241
+ state already includes the trimmed ops, so Component B starts there and replays only the retained tail.
242
+ Trimmed ops are never re-fetched; a later snapshot makes them unnecessary.
243
+
244
+ The target is only unreachable when all of the following hold: the nearest snapshot at or before `T` is
245
+ old, the ops between it and `T` have been trimmed, and no snapshot falls anywhere in between to bridge
246
+ the gap. In that case the exact state at `T` cannot be reconstructed, and Component B reports it
247
+ (for example, a `missing ops` / not-materializable outcome) rather than returning a wrong state — a
248
+ consumer may still choose to fall back to the nearest reachable state at or before `T`. This is rare in
249
+ practice because snapshots are written frequently relative to the op-retention window.
250
+
251
+ Note that `minimumSequenceNumber` is not the signal for any of this: it is the collaboration-window floor
252
+ baked into a snapshot, used when a snapshot is loaded, not an indicator of which ops the op stream still
253
+ retains. Op availability is determined by asking the op stream for the range, not by a version's minimum
254
+ sequence number.
255
+
256
+ ### Should there be an end-to-end test against a real ODSP file?
257
+
258
+ The fetcher is covered by stubbed-`fetch` integration tests, but not against a live file (which needs
259
+ tenant credentials). An end-to-end test would exercise the real endpoints.
260
+
261
+ ### Should the raw driveItem `/content` download be a supported fallback?
262
+
263
+ The `/content` download also contains a version's snapshot, but wrapped in a container framing the
264
+ snapshot parser does not read directly. If the version-scoped snapshot endpoint is ever unavailable,
265
+ unwrapping `/content` could be a fallback path.
266
+
267
+ ## Part V — Components B & C, as built
268
+
269
+ Component A (this folder) only selects the base. Components B and C — which materialize the document at
270
+ the target and expose it through the loader — are now built, in other files. They carry no catechism
271
+ code IDs here because their are no tests at the moment; these are
272
+ conceptual answers in the spirit of [Part I](#part-i--foundations). The one still-directional gap is
273
+ bridging a *trimmed* op range via an intermediate snapshot (see
274
+ [Part IV](#part-iv--directional)); everything below is what ships today.
275
+
276
+ ### Component B — how does the recomposed driver materialize the target?
277
+
278
+ `OdspPointInTimeDocumentServiceFactory` (in `../pointInTimeDriver/`) extends
279
+ `OdspDocumentServiceFactoryCore` and adds `createPointInTimeDocumentService(resolvedUrl, targetSequenceNumber)`:
280
+
281
+ 1. Build a version manager (Component A) and call `findBaseForSeq(target)`. A `noBaseVersion` result
282
+ becomes a `UsageError` naming the target and the oldest resolved sequence number.
283
+ 2. Resolve the chosen file version into a version-scoped resolved URL, then create two ordinary ODSP
284
+ document services: a **recoverable** one bound to that base version (its storage is the base
285
+ snapshot) and a **live** one (its delta storage supplies the ops to replay).
286
+ 3. Return an `OdspPointInTimeDocumentService` composing the two.
287
+
288
+ It lives in this package rather than a generic wrapping driver (e.g. `@fluidframework/replay-driver`)
289
+ because loading a historical file version is a storage-layer concern: it needs the version-scoped
290
+ snapshot fetch, the epoch tracker, and authentication — all internal to this driver — and it consumes
291
+ the version manager in-package, so the manager itself needs no exported surface.
292
+
293
+ ### Component B — which `IDocumentService` method drives the replay?
294
+
295
+ `OdspPointInTimeDocumentService` is read-only and advertises the `storageOnly` document-service policy.
296
+ Its three `IDocumentService` methods:
297
+
298
+ - `connectToStorage` → the recoverable (base-version) service's storage: the base snapshot.
299
+ - `connectToDeltaStorage` → wraps the **live** service's delta storage and clamps every
300
+ **`fetchMessages(from, to, …)`** call to an exclusive upper bound of `targetSequenceNumber + 1`, so no
301
+ op past the target is ever fetched. **`fetchMessages` is the method that drives the bounded replay.**
302
+ - `connectToDeltaStream` → throws: under `storageOnly` the connection manager synthesizes a frozen,
303
+ read-only delta stream instead of opening a live socket, so this is never called under normal flow.
304
+
305
+ The `storageOnly` policy is the key mechanism: it forces the container read-only and reuses the loader's
306
+ existing "frozen" delta stream, and the delta manager then catches up from the base snapshot's sequence
307
+ number through delta storage — the bounded `fetchMessages` replay — up to and including the target op.
308
+
309
+ ### Component B — what request does the bounded `fetchMessages` actually make?
310
+
311
+ The point-in-time service builds no URL of its own: `connectToDeltaStorage` wraps the **live** service's
312
+ delta storage and only clamps the `to` argument (`Math.min(to, targetSequenceNumber + 1)`). Everything
313
+ below is the ordinary ODSP delta path (`OdspDeltaStorageWithCache` → `OdspDeltaStorageService`), just
314
+ range-constrained by that clamp.
315
+
316
+ `OdspDeltaStorageWithCache.fetchMessages` is a **paged stream**, not a single request: via `requestOps`
317
+ it walks the requested `[from, to)` in batches, checking three sources in order — ops bundled with the
318
+ base snapshot, then the cache, then network storage — so the clamp guarantees no page is ever requested
319
+ past the target.
320
+
321
+ The network leg (`OdspDeltaStorageService.get`) is where the request is constructed:
322
+
323
+ - **URL** (`buildUrl`): `${deltaStorageUrl}?ump=1&filter=` + `encodeURIComponent("sequenceNumber ge {from} and sequenceNumber le {to - 1}")`.
324
+ `deltaStorageUrl` is `.../drives/{driveId}/items/{itemId}/opStream`. Because `from` is inclusive and
325
+ `to` exclusive, the filter is `ge {from} and le {to - 1}`; with the clamped `to = target + 1` the
326
+ effective server bound is `sequenceNumber le target` — the target op is included, nothing beyond it.
327
+ - **Method & body**: despite fetching ops it issues a **`POST`** carrying `X-HTTP-Method-Override: GET`,
328
+ encoded as `multipart/form-data` (the `ump=1` "unified multipart" framing). The auth token rides in the
329
+ form body (`Authorization: {authHeader}` / `_post: 1`), not a header.
330
+ - **Plumbing**: the call goes through the `epochTracker` (epoch/consistency checks) and
331
+ `getWithRetryForTokenRefresh` (one token-refresh retry), with a 30s `AbortController` timeout as a
332
+ hang mitigation.
333
+
334
+ So the target bound flows `target + 1` → `Math.min` clamp → stream page `to` → `le {to - 1}` filter,
335
+ and the `opStream` endpoint is queried for exactly `[from, target]`.
336
+
337
+ ### Component C — how is this exposed through the loader?
338
+
339
+ `loadContainerToSequenceNumber` (in `@fluidframework/container-loader`):
340
+
341
+ 1. Validates `loadToSequenceNumber` is a non-negative integer (`UsageError` otherwise).
342
+ 2. Detects the point-in-time capability with `asPointInTimeCapableFactory`, which checks the passed
343
+ `documentServiceFactory` exposes `createPointInTimeDocumentService`. A plain factory is a
344
+ `UsageError` — the caller must pass `OdspPointInTimeDocumentServiceFactory` directly, with no wrapping.
345
+ 3. Wraps it in a `PointInTimeDocumentServiceFactory` adapter so the container's normal
346
+ `createDocumentService(resolvedUrl)` routes to `createPointInTimeDocumentService(resolvedUrl, target)`.
347
+ (`createContainer` throws — the adapter is load-only.)
348
+ 4. Delegates to `loadContainerPaused(...)` with inbound/outbound processing paused, returning a
349
+ disconnected, read-only historical view of the container at the target sequence number.
@@ -0,0 +1,12 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ export {
7
+ createOdspVersionManager,
8
+ type BaseForSeq,
9
+ type IOdspVersionManager,
10
+ type OdspFileVersionRef,
11
+ type ResolvedVersion,
12
+ } from "./odspVersionManager.js";
@@ -0,0 +1,138 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ /*
7
+ * An {@link IOdspFileVersionFetcher} backed by the ODSP REST APIs:
8
+ * - GET /_api/v2.1/.../versions -- enumerate the file's versions.
9
+ * - GET /_api/v2.1/.../versions/{label}/opStream/snapshots/trees/latest?blobs=2 -- fetch a version's
10
+ * snapshot and read its sequence number, parsed with the driver's snapshot parser.
11
+ */
12
+
13
+ import type {
14
+ IOdspUrlParts,
15
+ InstrumentedStorageTokenFetcher,
16
+ } from "@fluidframework/odsp-driver-definitions/internal";
17
+ import type { TelemetryLoggerExt } from "@fluidframework/telemetry-utils/internal";
18
+
19
+ import { currentReadVersion, parseCompactSnapshotResponse } from "../compactSnapshotParser.js";
20
+ import type { IOdspSnapshot } from "../contracts.js";
21
+ import type { EpochTracker } from "../epochTracker.js";
22
+ import { getHeadersWithAuth } from "../getUrlAndHeadersWithAuth.js";
23
+ import { convertOdspSnapshotToSnapshotTreeAndBlobs } from "../odspSnapshotParser.js";
24
+ import { getApiRoot } from "../odspUrlHelper.js";
25
+ import { getWithRetryForTokenRefresh } from "../odspUtils.js";
26
+
27
+ import type { OdspFileVersionRef, IOdspFileVersionFetcher } from "./odspVersionManager.js";
28
+
29
+ /**
30
+ * Raw shape of a OneDrive/SharePoint driveItem version (an entry in the `/versions` response).
31
+ * @see https://learn.microsoft.com/en-us/onedrive/developer/rest-api/resources/driveitemversion
32
+ */
33
+ interface IDriveItemVersion {
34
+ /** The version's label, e.g. "42.0". */
35
+ readonly id: string;
36
+ readonly lastModifiedDateTime: string;
37
+ }
38
+
39
+ /** A single page of the driveItem `/versions` response. */
40
+ interface IDriveItemVersionsPage {
41
+ readonly value?: IDriveItemVersion[];
42
+ /** Absolute URL of the next page, present only while more versions remain. */
43
+ readonly "@odata.nextLink"?: string;
44
+ }
45
+
46
+ /**
47
+ * Inputs needed to make authenticated requests against a specific ODSP file.
48
+ */
49
+ export interface OdspFileVersionFetcherProps {
50
+ readonly urlParts: IOdspUrlParts;
51
+ readonly getAuthHeader: InstrumentedStorageTokenFetcher;
52
+ readonly epochTracker: EpochTracker;
53
+ readonly logger: TelemetryLoggerExt;
54
+ }
55
+
56
+ /**
57
+ * Create an {@link IOdspFileVersionFetcher} that talks to a specific ODSP file.
58
+ */
59
+ export function createOdspFileVersionFetcher(
60
+ props: OdspFileVersionFetcherProps,
61
+ ): IOdspFileVersionFetcher {
62
+ const { urlParts, getAuthHeader, epochTracker, logger } = props;
63
+ const { siteUrl, driveId, itemId } = urlParts;
64
+
65
+ const listFileVersions = async (): Promise<OdspFileVersionRef[]> =>
66
+ getWithRetryForTokenRefresh(async (options) => {
67
+ const method = "GET";
68
+ const versions: OdspFileVersionRef[] = [];
69
+ // The file's version history (distinct from the driver's snapshot list), from the same API
70
+ // root as the snapshot call so consumer (ODC) and enterprise (SPO) hosts are handled alike.
71
+ // A long history is paged, so follow `@odata.nextLink` until it is absent; otherwise a base
72
+ // version beyond the first page would be missed and wrongly reported as "no base version".
73
+ let url = `${getApiRoot(new URL(siteUrl))}/drives/${driveId}/items/${itemId}/versions`;
74
+ do {
75
+ const token = await getAuthHeader(
76
+ { ...options, request: { url, method } },
77
+ "FileVersions",
78
+ );
79
+ const headers = getHeadersWithAuth(token);
80
+ const response = await epochTracker.fetchAndParseAsJSON<{
81
+ value?: IDriveItemVersion[];
82
+ }>(url, { method, headers }, "versions");
83
+ const page = response.content as IDriveItemVersionsPage;
84
+ // The API returns versions newest-first.
85
+ for (const version of page.value ?? []) {
86
+ versions.push({
87
+ versionId: version.id,
88
+ lastModifiedDateTime: version.lastModifiedDateTime,
89
+ });
90
+ }
91
+ url = page["@odata.nextLink"] ?? "";
92
+ } while (url);
93
+ return versions;
94
+ });
95
+
96
+ const resolveSequenceNumber = async (versionId: string): Promise<number> =>
97
+ 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.
102
+ const url = `${getApiRoot(new URL(siteUrl))}/drives/${driveId}/items/${itemId}/versions/${encodeURIComponent(
103
+ versionId,
104
+ )}/opStream/snapshots/trees/latest?blobs=2`;
105
+ const method = "GET";
106
+ const token = await getAuthHeader(
107
+ { ...options, request: { url, method } },
108
+ "FileVersionSnapshot",
109
+ );
110
+ 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.
115
+ headers.accept = `application/json, application/ms-fluid; v=${currentReadVersion}`;
116
+ const response = await epochTracker.fetch(url, { method, headers }, "treesLatest");
117
+ const contentType = response.headers.get("content-type") ?? "";
118
+ let sequenceNumber: number | undefined;
119
+ if (contentType.includes("application/json")) {
120
+ // JSON framing: read it with the driver's JSON snapshot parser.
121
+ const snapshotJson = (await response.content.json()) as IOdspSnapshot;
122
+ sequenceNumber =
123
+ convertOdspSnapshotToSnapshotTreeAndBlobs(snapshotJson).sequenceNumber;
124
+ } else {
125
+ // ms-fluid framing: the compact binary form; read it with the driver's compact-snapshot parser.
126
+ const bytes = new Uint8Array(await response.content.arrayBuffer());
127
+ sequenceNumber = parseCompactSnapshotResponse(bytes, logger).sequenceNumber;
128
+ }
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`);
133
+ }
134
+ return sequenceNumber;
135
+ });
136
+
137
+ return { listFileVersions, resolveSequenceNumber };
138
+ }
@@ -0,0 +1,170 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ /**
7
+ * Selects the ODSP file version whose snapshot sits at or before a target Fluid sequence number —
8
+ * the base to load or replay from when materializing a document at a point in time.
9
+ *
10
+ * The selection logic depends on an injected {@link IOdspFileVersionFetcher}, so it is independent of
11
+ * how versions are enumerated and resolved (real ODSP, a test double, or an alternative backend).
12
+ */
13
+
14
+ import {
15
+ createOdspFileVersionFetcher,
16
+ type OdspFileVersionFetcherProps,
17
+ } from "./odspFileVersionFetcher.js";
18
+
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
+ }
32
+
33
+ /**
34
+ * An ODSP file version together with its resolved Fluid sequence number.
35
+ */
36
+ export interface ResolvedVersion extends OdspFileVersionRef {
37
+ /**
38
+ * The Fluid sequence number the version's snapshot represents.
39
+ */
40
+ readonly sequenceNumber: number;
41
+ }
42
+
43
+ /**
44
+ * Result of resolving the base version for a target sequence number.
45
+ *
46
+ * @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.
50
+ */
51
+ export type BaseForSeq =
52
+ | {
53
+ /** A recoverable version with `sequenceNumber <= target` was found. */
54
+ readonly kind: "found";
55
+ readonly base: ResolvedVersion;
56
+ }
57
+ | {
58
+ /** No recoverable version has `sequenceNumber <= target` (target predates retained history). */
59
+ readonly kind: "noBaseVersion";
60
+ /** The oldest sequence number that was resolved while searching, if any. */
61
+ readonly oldestResolvedSeq?: number;
62
+ };
63
+
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
+ /**
81
+ * Selects the file version to use as the base for loading or replaying to a target sequence number.
82
+ */
83
+ export interface IOdspVersionManager {
84
+ /**
85
+ * Given a target sequence number, return the closest version at or before it (`found`), or
86
+ * `noBaseVersion` if the target predates the oldest retained version.
87
+ */
88
+ findBaseForSeq(target: number): Promise<BaseForSeq>;
89
+ }
90
+
91
+ /**
92
+ * Default {@link IOdspVersionManager}. Caches the version list and resolved sequence numbers. The
93
+ * resolution strategy (eager, newest-to-oldest, stopping at the first usable base) is hidden behind
94
+ * {@link findBaseForSeq} and can change without affecting callers.
95
+ */
96
+ export class OdspVersionManager implements IOdspVersionManager {
97
+ private versionsCache: Promise<OdspFileVersionRef[]> | undefined;
98
+ private readonly seqByVersion = new Map<string, Promise<number>>();
99
+
100
+ public constructor(private readonly fetcher: IOdspFileVersionFetcher) {}
101
+
102
+ public refresh(): void {
103
+ this.versionsCache = undefined;
104
+ this.seqByVersion.clear();
105
+ }
106
+
107
+ 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();
110
+ const candidates = versions.slice(1);
111
+
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
+ let oldestResolvedSeq: number | undefined;
119
+ for (const version of candidates) {
120
+ const sequenceNumber = await this.resolveSeq(version.versionId);
121
+ oldestResolvedSeq =
122
+ oldestResolvedSeq === undefined
123
+ ? sequenceNumber
124
+ : Math.min(oldestResolvedSeq, sequenceNumber);
125
+ if (sequenceNumber <= target) {
126
+ return { kind: "found", base: { ...version, sequenceNumber } };
127
+ }
128
+ }
129
+ return { kind: "noBaseVersion", oldestResolvedSeq };
130
+ }
131
+
132
+ 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
135
+ // preserved by Promise.all regardless of completion order.
136
+ return Promise.all(
137
+ versions.map(async (version) => ({
138
+ ...version,
139
+ sequenceNumber: await this.resolveSeq(version.versionId),
140
+ })),
141
+ );
142
+ }
143
+
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;
149
+ }
150
+
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;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Create an {@link IOdspVersionManager} for a specific ODSP file, wired to the real ODSP REST APIs.
165
+ */
166
+ export function createOdspVersionManager(
167
+ props: OdspFileVersionFetcherProps,
168
+ ): IOdspVersionManager {
169
+ return new OdspVersionManager(createOdspFileVersionFetcher(props));
170
+ }