@fluidframework/odsp-driver 2.114.0 → 2.115.0

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 (77) 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.js +1 -1
  26. package/dist/packageVersion.js.map +1 -1
  27. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +5 -0
  28. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -1
  29. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js +6 -3
  30. package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -1
  31. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +25 -33
  32. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -1
  33. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +62 -38
  34. package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -1
  35. package/dist/public.d.ts +1 -1
  36. package/internal.d.ts +1 -1
  37. package/legacy/alpha.d.ts +11 -0
  38. package/legacy.d.ts +1 -1
  39. package/lib/getUrlAndHeadersWithAuth.d.ts +4 -0
  40. package/lib/getUrlAndHeadersWithAuth.d.ts.map +1 -1
  41. package/lib/getUrlAndHeadersWithAuth.js +4 -0
  42. package/lib/getUrlAndHeadersWithAuth.js.map +1 -1
  43. package/lib/index.d.ts +3 -2
  44. package/lib/index.d.ts.map +1 -1
  45. package/lib/index.js +5 -3
  46. package/lib/index.js.map +1 -1
  47. package/lib/legacy.d.ts +1 -1
  48. package/lib/legacyAlpha.d.ts +49 -0
  49. package/lib/odspVersionManager/odspFileVersionFetcher.d.ts +40 -1
  50. package/lib/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -1
  51. package/lib/odspVersionManager/odspFileVersionFetcher.js +53 -15
  52. package/lib/odspVersionManager/odspFileVersionFetcher.js.map +1 -1
  53. package/lib/odspVersionManager/odspVersionManager.d.ts +23 -45
  54. package/lib/odspVersionManager/odspVersionManager.d.ts.map +1 -1
  55. package/lib/odspVersionManager/odspVersionManager.js +68 -33
  56. package/lib/odspVersionManager/odspVersionManager.js.map +1 -1
  57. package/lib/packageVersion.d.ts +1 -1
  58. package/lib/packageVersion.js +1 -1
  59. package/lib/packageVersion.js.map +1 -1
  60. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +5 -0
  61. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -1
  62. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js +6 -3
  63. package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -1
  64. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +25 -33
  65. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -1
  66. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +62 -38
  67. package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -1
  68. package/lib/public.d.ts +1 -1
  69. package/package.json +26 -14
  70. package/src/getUrlAndHeadersWithAuth.ts +4 -0
  71. package/src/index.ts +8 -3
  72. package/src/odspVersionManager/DEV.md +376 -54
  73. package/src/odspVersionManager/odspFileVersionFetcher.ts +111 -16
  74. package/src/odspVersionManager/odspVersionManager.ts +106 -68
  75. package/src/packageVersion.ts +1 -1
  76. package/src/pointInTimeDriver/odspPointInTimeDocumentService.ts +8 -4
  77. package/src/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.ts +123 -48
@@ -9,8 +9,9 @@ appears as a `// @q <id>` tag on the matching test.
9
9
 
10
10
  ID format: `<area>-<topic>-<nn>`. Area is `M` (the version manager — [Part II](#part-ii--the-version-manager))
11
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.
12
+ mnemonic (e.g. `SELECT`, `RESOLVE`). `nn` is a zero-padded counter assigned the lowest number available
13
+ within its `<area>-<topic>` group. When a scenario is removed, the remaining IDs in that group are
14
+ renumbered to close the gap, so the numbering stays compact with no reserved or retired IDs.
14
15
 
15
16
  The contract:
16
17
 
@@ -35,11 +36,45 @@ Part 1 is built in three components:
35
36
  - **Component A — the version manager**: choose which file version to load or replay from. **This
36
37
  folder is Component A**, and this document is mostly about it.
37
38
  - **Component B — the recomposed driver**: load the chosen version and replay ops forward to the exact
38
- target. **Built** in `../pointInTimeDriver/` (`OdspPointInTimeDocumentServiceFactory` /
39
+ target. **Built** in `../pointInTimeDriver/` (`getOdspPointInTimeDocumentServiceFactory` /
39
40
  `OdspPointInTimeDocumentService`) — see [Part V](#part-v--components-b--c-as-built).
40
41
  - **Component C — the loader hookup**: expose Component B through the container loader. **Built** in
41
42
  `@fluidframework/container-loader` (`loadContainerToSequenceNumber`) — see
42
- [Part V](#part-v--components-b--c-as-built).
43
+ [Part V](#part-v--components-b--c-as-built). This is the current prototype-era package
44
+ placement; the planned feature-package boundary is documented in the
45
+ [point-in-time loading guide](../../../../loader/container-loader/src/pointInTime/DEV.md#package-ownership-and-planned-extraction).
46
+
47
+ ### Vocabulary (the overloaded terms)
48
+
49
+ Several words describe "the saved state of a document" at different layers, which is a frequent source of
50
+ confusion. Everything is built from two primitives — **ops** and the **state** they produce — and the rest
51
+ is either a bundle of ops or a saved snapshot of state.
52
+
53
+ - **Op**: a single change, globally ordered by a **sequence number**. The full history *is* the ordered
54
+ ops; the document is those ops replayed.
55
+ - **Summary**: the Fluid **runtime**'s serialized tree of current state. A summarizer client *produces* a
56
+ summary and proposes it via a `summarize` → `summaryAck` op handshake. ("Summary" = the act/content the
57
+ client generates.)
58
+ - **Snapshot**: the stored, fetchable form of a summary (tree + blobs, carrying a `sequenceNumber`). To
59
+ load, you fetch a snapshot and replay ops forward. Note two different snapshot lists exist: the driver's
60
+ *Fluid* snapshot list (`getVersions`) is **not** the ODSP file-version history — a different address
61
+ space.
62
+ - **ODSP file version**: an entry in the file's version history (driveItem `/versions`, labels like
63
+ `"42.0"`). ODSP surfaces stored snapshots as recoverable version rows, with a retention cap and dedup.
64
+ **This is what Component A selects over.**
65
+ - **op stream / `opStream`**: overloaded. As a concept, the ordered op log. As a URL segment, a path prefix
66
+ under which *both* snapshots (`.../opStream/snapshots/...`) and raw ops (`.../opStream?filter=...`) live.
67
+ - **delta storage**: the durable REST op log (`OdspDeltaStorageService`), which fetches raw ops by
68
+ sequence-number range and is retention-limited. Distinct from —
69
+ - **trailing / bundled ops**: a small tail of ops baked *inside* a snapshot (up to `latestSequenceNumber`),
70
+ returned with the snapshot for free — not a separate fetch.
71
+ - **sequence number** = an op's global order index (a snapshot's is the op its tree is current through);
72
+ **latestSequenceNumber** = the last bundled trailing op; **minimumSequenceNumber (MSN)** = the
73
+ collaboration-window floor (seq all connected clients have acked) — **not** a retention/trimming signal.
74
+
75
+ Chain of custody for one saved state: a **summarizer** writes a **summary** → stored as a **snapshot** →
76
+ surfaced by ODSP as a **file version**. Same state, three names because three layers own it.
77
+
43
78
 
44
79
  ## Part I — Foundations
45
80
 
@@ -82,6 +117,17 @@ driver's normal (`application/json` or `application/ms-fluid`) framing. The driv
82
117
  parser reads it, and the sequence number is `trees[0].sequenceNumber`. `blobs=2` inlines blob contents
83
118
  so the parser has everything it needs.
84
119
 
120
+ ### Can a base be replayed across a version restore (a lineage change)?
121
+
122
+ No. Replay is only correct while the base version and the live document share one continuous,
123
+ monotonically-numbered op stream. ODSP's **epoch** identifies that lineage: a version restore (or a
124
+ download-then-reupload) bumps the epoch and renumbers the op stream. A base captured before such a
125
+ boundary is on a different lineage than the live document, so replaying the live ops in `(base, target]`
126
+ on top of it would silently corrupt the result. A chosen base must therefore be proven to share the live
127
+ document's epoch — and to still have its bridging ops retained — before it is used. The lineage proof is
128
+ folded into `findBaseForSeq` (see [Part II](#how-does-it-verify-a-chosen-base-can-be-replayed-to-the-target)),
129
+ backed by a structural epoch guard in [Part V](#part-v--components-b--c-as-built).
130
+
85
131
  ### What is deliberately not built in *this folder*?
86
132
 
87
133
  Components B and C now exist, but elsewhere: the recomposed driver in `../pointInTimeDriver/` and the
@@ -97,45 +143,97 @@ these behaviors are tested with an in-memory fake.
97
143
 
98
144
  ### Which version does `findBaseForSeq` pick for a target sequence number?
99
145
 
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.
146
+ The list is newest-first, and the tip (index 0, the newest version) is excluded: it is the one version
147
+ whose sequence number is not yet static, so it cannot be a stable base. Among the remaining (sealed)
148
+ versions the answer is the closest one at or before the target the greatest sequence number at or before
149
+ the target when version order tracks sequence order, which an early-stop newest-first scan finds.
104
150
 
105
151
  - **Target between two versions?** The closer, older one. `M-SELECT-01`
106
152
  - **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`
153
+ - **Target newer than every sealed version?** The newest sealed version. `M-SELECT-03`
108
154
  - **Target older than every version?** `noBaseVersion`, reporting the oldest sequence number seen.
109
155
  `M-SELECT-04`
110
156
 
111
- ### How does it handle duplicate versions and the tip?
157
+ ### How does it handle the tip, duplicate versions, and empty history?
112
158
 
159
+ - **The tip (index 0)?** Never treated as a base; its sequence number is never even resolved. `M-TIP-01`
160
+ - **Only the tip exists?** `noBaseVersion` — the sole version is excluded as a base. The wired consumer
161
+ (the point-in-time document service factory) surfaces this as a `UsageError`; loading the live document
162
+ for a near-head target is a possible future consumer choice, not current behavior. `M-TIP-02`
113
163
  - **Two versions share a sequence number?** Return the newest label (a metadata-only re-save leaves the
114
164
  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
165
  - **No versions at all?** `noBaseVersion`. `M-EMPTY-01`
119
166
 
120
- ### What work does it avoid?
167
+ ### What work does it avoid when scanning?
168
+
169
+ - **Resolving more versions than needed?** It stops at the first version at or before the target and does
170
+ not resolve older ones. `M-STOP-01`
171
+
172
+ ### What is cached, and what is re-fetched?
121
173
 
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`
174
+ Resolved sequence numbers are memoized; the version list is not. A sealed version's sequence number never
175
+ changes, so once resolved it is reused for the manager's lifetime rather than re-fetched. The version
176
+ list, by contrast, changes as new versions are cut, so it is **re-enumerated on every query**. This
177
+ mirrors how Page History (`host-page-history`) works: its `PageHistoryVersionManager` pulls the ODSP
178
+ version list fresh on each navigation (`refreshVersions()`), caching only the expensive loaded *content*
179
+ not the list. Page History's `#getOdspVersions` likewise dedups and drops the tip (`slice(1)`), the same
180
+ two rules applied here.
181
+
182
+ The manager is short-lived the point-in-time document service factory creates one per load and calls
183
+ `findBaseForSeq` once, then drops it — so the memoization caches never grow large and need no eviction.
184
+
185
+ - **Re-resolving a sequence number across calls?** Each version's number is memoized, so a later query
186
+ reuses it rather than re-fetching. `M-CACHE-01`
187
+ - **A version-list fetch that fails?** It propagates rather than being read as empty; the list is
188
+ re-enumerated on the next call. `M-CACHE-02`
131
189
 
132
190
  ### What happens when a version cannot be resolved?
133
191
 
134
- The failure propagates; it is never swallowed into a wrong base. `M-ERR-01`
192
+ The failure propagates; it is never swallowed into a wrong base. `M-ERR-01` A failed resolution is not
193
+ cached, so a later call re-attempts it rather than replaying the cached rejection. `M-ERR-02`
135
194
 
136
195
  ### What does `listVersions` return?
137
196
 
138
- Every version with its resolved sequence number, newest-first. `M-LIST-01`
197
+ Every version with its resolved sequence number, newest-first. `M-LIST-01` The tip's number is resolved
198
+ fresh on every call (never cached, since it is not yet stable), while sealed versions are served from the
199
+ cache. `M-LIST-02`
200
+
201
+ ### How does it verify a chosen base can be replayed to the target?
202
+
203
+ `findBaseForSeq` both *picks* the closest version and *proves* it can be used: before returning a
204
+ `found` base it checks that base shares the live document's lineage, so a cross-lineage base fails as a
205
+ clear non-retryable error before any document service is built instead of a corrupt or stalled load.
206
+ There is no separate public validation step — the lineage check is folded into selection so a caller
207
+ cannot obtain an unvalidated base. Op availability is _not_ checked here — it is enforced by the
208
+ delta-storage stack as the loader streams the bridging ops (Part V), because the lineage gate is the
209
+ only check that must run before choosing to build the services.
210
+
211
+ **Lineage (epoch).** It reads the live document's epoch and the chosen base version's epoch and compares
212
+ them.
213
+
214
+ - **Base and live share an epoch?** Returns the base. `M-VALIDATE-01`
215
+ - **Base on a different epoch than the live document?** Throws, naming both epochs, and reuses the
216
+ driver's canonical `fileOverwrittenInStorage` epoch-mismatch error (the same `errorType` the shared
217
+ `EpochTracker` raises) rather than a generic `UsageError`, so the loader sees one machine-readable,
218
+ non-retryable error for a cross-lineage base. `M-VALIDATE-02`
219
+ - **Either epoch unknown?** Fails closed — without both epochs the shared-lineage claim cannot be proven.
220
+ `M-VALIDATE-03`
221
+
222
+ A numbered version's snapshot is immutable, so its epoch is read once and memoized per versionId; the
223
+ live document's epoch can change (a restore or download-and-reupload bumps it) and is therefore read
224
+ fresh on every lineage check, never cached. `M-VALIDATE-CACHE-01`
225
+
226
+ **Op availability.** This is _not_ re-checked up front, and Component B adds no check of its own. Op
227
+ retention trims a contiguous _prefix_ from the oldest end of the stream, and op sequence numbers are
228
+ contiguous by construction, so the ordinary delta-storage stack already enforces exactly what a replay
229
+ needs: `validateMessages` (strict) discards any fetched batch that does not begin at the requested
230
+ `from`, and `requestOps`/`ParallelRequests` keep requesting until the whole bounded range has been
231
+ delivered, asserting contiguity as they dispatch. A bounded stream that reaches `done` has therefore
232
+ necessarily served the full bridge; a range that never materializes fails the fetch instead (the delta
233
+ stack polls, then throws its non-retryable "Failed to retrieve ops from storage (Too Many Retries)"
234
+ error). The `OdspPointInTimeDocumentService` delta-storage wrapper (Part V) only bounds every fetch at
235
+ the target. Because the wrapper rides the live document's delta storage, the creation snapshot's ops
236
+ are already merged in for free.
139
237
 
140
238
  ## Part III — The File-Version Fetcher
141
239
 
@@ -157,8 +255,14 @@ field yields an empty list rather than an error. `F-LIST-03`
157
255
  parses the response, and returns `trees[0].sequenceNumber`. `F-RESOLVE-01`
158
256
  - **A snapshot with no sequence number?** It throws, naming the version, rather than returning a wrong
159
257
  value. `F-RESOLVE-02`
258
+ - **A snapshot whose sequence number is present but not a valid non-negative integer?** It throws rather
259
+ than coercing a wrong value into base selection. `F-RESOLVE-06`
160
260
  - **A binary (`application/ms-fluid`) snapshot?** It reads it with the driver's compact-snapshot parser
161
261
  and returns the same sequence number the JSON path would. `F-RESOLVE-03`
262
+ - **An unexpected content-type (e.g. an HTML error page)?** It throws rather than mis-parsing the body as
263
+ a compact snapshot. `F-RESOLVE-04`
264
+ - **A version label with characters that need escaping?** The label is percent-encoded into the snapshot
265
+ URL. `F-RESOLVE-05`
162
266
 
163
267
  ### How does it handle request failures?
164
268
 
@@ -171,6 +275,30 @@ field yields an empty list rather than an error. `F-LIST-03`
171
275
  - **An authentication failure while resolving?** Likewise, it refreshes the token and retries once.
172
276
  `F-ERROR-02`
173
277
 
278
+ ### How does it read a version's or the live document's lineage (epoch)?
279
+
280
+ The ODSP `x-fluid-epoch` header identifies the file's binary lineage. It is read with the raw fetch
281
+ helper — deliberately **not** `epochTracker.fetch`, whose whole job is to pin the first epoch and reject
282
+ a divergent one, which would make comparing two epochs impossible — and the response body is consumed and
283
+ discarded, keeping only the header.
284
+
285
+ - **The live document's epoch?** From the unversioned live snapshot endpoint (`blobs=0`), never a
286
+ versioned URL. `F-EPOCH-01`
287
+ - **A specific version's epoch?** From that version's snapshot endpoint
288
+ (`.../versions/{label}/opStream/snapshots/trees/latest?blobs=0`). `F-EPOCH-02`
289
+ - **The server sends no epoch header?** Returns `undefined` rather than throwing; the caller fails closed
290
+ on an unknown epoch. `F-EPOCH-03`
291
+
292
+ ### How does it verify a base shares the live document's lineage?
293
+
294
+ Versions carry their own ODSP epoch (`x-fluid-epoch`). `getLiveDocumentEpoch` and
295
+ `getRecoverableVersionEpoch(versionId)` read that header from the live and version-scoped snapshot
296
+ endpoints (`...?blobs=0`, metadata only). The version manager compares the two; a mismatch means a
297
+ restore or download-then-reupload renumbered the op stream, so the base is a different lineage.
298
+
299
+ Op availability is deliberately _not_ fetched here — it is enforced by the delta-storage stack against
300
+ the ops the loader reads (see Part V), which also gives the creation snapshot's ops for free.
301
+
174
302
  ## Part IV — Directional
175
303
 
176
304
  Aspirational behaviors, written as questions that cannot yet be answered "yes".
@@ -226,32 +354,60 @@ comparison. It also allows locating a version by time when no sequence number is
226
354
  Component B is built (see [Part V](#part-v--components-b--c-as-built)), but the version it ships makes
227
355
  one simplifying assumption: it loads a single base file version and replays the ops in `(base, target]`
228
356
  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.
357
+ retained and a base whose bridging ops have been trimmed is still **detected**, by the ordinary
358
+ delta-storage stack as the loader reads them: it discards any batch that does not start at the
359
+ requested op and keeps requesting the remainder, so the fetch fails
360
+ (see [Part V](#part-v--components-b--c-as-built)) rather than replaying a truncated range. What is
361
+ still not built is **recovering** from that case: bridging a trimmed range by starting from a newer
362
+ intermediate snapshot is the part that is not built yet the rest of this answer is its design.
363
+
364
+ A snapshot already contains the full accumulated state at its sequence number the tree *is* the
365
+ materialized state at `sequenceNumber`, with every earlier op baked in (nothing is replayed to *reach*
366
+ the snapshot). So to reach a target `T`, Component B loads the closest base snapshot (`seq T`) and
367
+ replays only the ops in `(base, T]` on top of it.
368
+
369
+ Those ops come from two distinct pools:
370
+
371
+ 1. **The snapshot's own bundled ops.** Every stored snapshot carries a frozen tail of the ops *after* its
372
+ base — a `deltas` section the summarizer writes into the snapshot itself (`writeOpsSection` in
373
+ `compactSnapshotWriter.ts`), surfaced by the parser as `ISnapshot.ops` with `latestSequenceNumber` = the
374
+ last such op (`odspSnapshotParser.ts`). This tail is intrinsic to the snapshot object: the version-scoped
375
+ snapshot endpoint returns it whether or not `deltas=1` is asked, and its first op is always `base + 1`
376
+ (`fetchSnapshot.ts` asserts `ops[0].sequenceNumber - 1 === sequenceNumber`). So `(base, latestSequenceNumber]`
377
+ is available for free, no extra fetch.
378
+ 2. **The standalone op log.** Anything beyond `latestSequenceNumber` is fetched from ODSP **delta storage** —
379
+ `OdspDeltaStorageService.get(from, to)`, which issues
380
+ `.../opStream?ump=1&filter=sequenceNumber ge {from} and sequenceNumber le {to-1}` (`odspDeltaStorageService.ts`;
381
+ URL built from `getUrlBase`/`getDeltaStorageUrl` in `odspDriverUrlResolver.ts`). This is the same op-fetch
382
+ path the container's DeltaManager uses.
383
+
384
+ Ops in the standalone op log are retained for a window (time-based, best-effort ~7 days measured from each
385
+ version's date — confirmed with the ODSP storage team, who reduced it from ~30 days earlier this year) and
386
+ can be trimmed. There is no field that advertises the earliest retained op; a gap is
387
+ discovered by asking the op stream for the range and getting a short result (delta storage assumes the
388
+ server returns all ops it has in the requested range). The resolution is not to fetch the trimmed ops from
389
+ somewhere else — it is to **start from a newer snapshot that already absorbed them**. If the ops just after
390
+ the base are gone but another snapshot exists later in `(base, T]`, that snapshot's state already includes
391
+ the trimmed ops, so Component B starts there and replays only the retained tail. Trimmed ops are never
392
+ re-fetched; a later snapshot makes them unnecessary.
243
393
 
244
394
  The target is only unreachable when all of the following hold: the nearest snapshot at or before `T` is
245
395
  old, the ops between it and `T` have been trimmed, and no snapshot falls anywhere in between to bridge
246
396
  the gap. In that case the exact state at `T` cannot be reconstructed, and Component B reports it
247
397
  (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.
398
+ consumer may still choose to fall back to the nearest reachable state at or before `T`.
399
+
400
+ Put precisely, the exactly-recoverable targets are **every snapshot (they are durable, not op-retention-bound),
401
+ plus any target whose replay ops `(base, T]` still fall within the op-retention window**. A target older than
402
+ retention that lands *between* snapshots — no snapshot on it, and its ops trimmed — is not exactly
403
+ reconstructable: ops cannot be un-applied, so overshooting to a later snapshot does not help. This is rare in
404
+ practice because snapshots are written frequently relative to the op-retention window, but it is a real limit,
405
+ not a bug — so the honest behavior is to report the nearest reachable point rather than a wrong state.
250
406
 
251
407
  Note that `minimumSequenceNumber` is not the signal for any of this: it is the collaboration-window floor
252
408
  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.
409
+ retains. Op availability is determined by what the delta stream actually serves for the range, not by a
410
+ version's minimum sequence number.
255
411
 
256
412
  ### Should there be an end-to-end test against a real ODSP file?
257
413
 
@@ -264,25 +420,157 @@ The `/content` download also contains a version's snapshot, but wrapped in a con
264
420
  snapshot parser does not read directly. If the version-scoped snapshot endpoint is ever unavailable,
265
421
  unwrapping `/content` could be a fallback path.
266
422
 
423
+ ### How should near-head targets work when there is no sealed base?
424
+
425
+ The newest file-version row is intentionally excluded because its sequence number can still advance.
426
+ When it is the only row, or when sequence number `0` predates every sealed row, the current factory
427
+ returns `noBaseVersion` and the loader surfaces a `UsageError`. Decide whether the driver should keep
428
+ that strict behavior or use a separately fetched live/creation snapshot as a read-only base when it can
429
+ prove the snapshot is at or before the target. Add real-service coverage for sequence number `0`, an
430
+ only-tip file, a target equal to the current tip, and a target just behind the tip.
431
+
432
+ ### What numeric target range does the ODSP capability accept?
433
+
434
+ `loadContainerToSequenceNumber` rejects negative and fractional values, but the ODSP factory is also an
435
+ exported capability that can be called directly, and the bounded delta wrapper computes
436
+ `targetSequenceNumber + 1`. Define and enforce a non-negative safe-integer contract at the driver
437
+ boundary too, including `Number.MAX_SAFE_INTEGER`, so the exclusive upper bound cannot overflow or lose
438
+ precision.
439
+
440
+ ### What happens when version history changes while a load is being built?
441
+
442
+ The list can add a new head, age out an old row, or lose the selected version between enumeration,
443
+ sequence-number resolution, lineage validation, URL resolution, and the eventual snapshot read. Add
444
+ tests for each churn point. A disappeared base should trigger one bounded re-enumeration/reselection
445
+ when safe, or surface a clear non-retryable availability error; it must not reuse stale list membership
446
+ or fall through to a different version silently. If a long-lived manager is ever reused, reconcile the
447
+ sequence/epoch caches with versions that have aged out.
448
+
449
+ ### How is partial construction cleaned up?
450
+
451
+ `createPointInTimeDocumentService` creates the recoverable service before the live service. If live
452
+ service creation or later composition fails, the already-created recoverable service must be disposed.
453
+ Add fault-injection coverage at base selection, version URL resolution, recoverable-service creation,
454
+ live-service creation, storage connection, and delta-storage connection, and verify cleanup preserves
455
+ the original error.
456
+
457
+ ### Does the complete point-in-time path preserve retries, cancellation, and authentication?
458
+
459
+ Fetcher unit tests cover token refresh for version enumeration and sequence-number resolution, but the
460
+ composed load has no end-to-end regression spanning version discovery, base snapshot fetch, and live-op
461
+ replay. Cover an auth refresh in each phase, an abort during each phase, and a retrying op fetch that is
462
+ canceled by the caller. Cancellation should reach the active ODSP request rather than merely closing the
463
+ loader-side container while background retries continue.
464
+
465
+ ### Is the snapshot-op to live-op handoff explicitly covered?
466
+
467
+ The ordinary ODSP delta stack reads trailing ops bundled in the selected snapshot, then persisted ops
468
+ cache, then network storage. Add a focused integration test where the target crosses each source, with
469
+ the handoffs exactly at `latestSequenceNumber` and an ops-cache batch boundary. Cover duplicate and
470
+ missing boundary ops, a partial dirty cache batch that has not yet been flushed, and a cache gap that
471
+ forces all later reads to storage. Verify the bounded wrapper neither replays one twice nor skips one.
472
+
473
+ ### How are sequenced but not yet persisted ops materialized?
474
+
475
+ `OdspDeltaStorageWithCache` calls `requestFromSocket(from, to)` before consulting persisted cache and
476
+ storage. In a normal connected load, that sends PUSH `get_ops`; its response is emitted on the delta
477
+ connection and can supply ops that have sequenced but have not yet been flushed to the ODSP op-stream
478
+ endpoint. The storage request still runs, while the live connection gives DeltaManager another route to
479
+ make progress.
480
+
481
+ The point-in-time service advertises `storageOnly`, so the connection manager never creates that live
482
+ delta connection. Its live ODSP document service therefore has no `currentDeltaConnection`, making
483
+ `requestFromSocket` a no-op. A target can be resolved by a live version-mark resolver and still be
484
+ temporarily unavailable to the historical loader until the ordering service persists it. For a known
485
+ bounded range, `getSingleOpBatch` retries an empty storage response and fails after roughly 30 seconds.
486
+
487
+ Define the product contract for this window: wait for eventual persistence, expose a retryable
488
+ “sequenced but not persisted” result, coordinate an explicit PUSH `flush_ops`, or give the historical
489
+ service a narrowly scoped way to retrieve PUSH-only ops without becoming a writable/live container.
490
+ Add a real-service test that resolves a mark and immediately loads it, plus delayed-persistence,
491
+ cancellation, and never-persisted variants.
492
+
493
+ ### How does `ParallelRequests` batching interact with the target bound?
494
+
495
+ The point-in-time wrapper converts every DeltaManager request, including one with no `to`, into the
496
+ known bounded range `[from, target + 1)`. `requestOps` divides that range into transport pages using
497
+ `opsBatchSize` and may issue `concurrentOpsBatches` pages concurrently. `ParallelRequests` buffers
498
+ out-of-order responses by their starting sequence number and dispatches only contiguous pages. A
499
+ partial snapshot/cache response continues from its first missing sequence number; an oversized response
500
+ is split; a known final page is retried until complete; and cancellation may leave later buffered pages
501
+ intentionally undispatched. Its separate “learn the end from a short response” mode is not used by
502
+ point-in-time loading because `target + 1` is always known.
503
+
504
+ Point-in-time coverage currently tests only the wrapper's per-call `to` clamp and simple ordered streams.
505
+ Add integration coverage with small page sizes and concurrency greater than one: targets on every page
506
+ boundary, out-of-order completion, partial and oversized pages, a final short page, cancellation with
507
+ requests in flight, and verification that speculative requests and buffered results beyond
508
+ `target + 1` are never delivered.
509
+
510
+ Also cover failure ordering: allow later pages to complete while the first missing page retries, then
511
+ make that earlier page fail. No later page may cross the gap, the stream must surface the terminal error
512
+ once, and late completions after cancellation/failure must be ignored. Deep-history loads need a
513
+ backpressure/memory test because both the out-of-order `results` map and the stream `Queue` can retain
514
+ whole pages when producers outrun the consumer.
515
+
516
+ Finally, validate `opsBatchSize` and `concurrentOpsBatches` at the ODSP boundary. Zero or negative
517
+ concurrency currently reaches a `ParallelRequests.run()` assertion, while very large values can create
518
+ excessive requests and buffered data. Define positive-integer requirements and practical upper bounds,
519
+ with explicit `UsageError` behavior rather than an internal assertion or resource spike.
520
+
521
+ ### Which `OpsCache` batches are visible to a historical load?
522
+
523
+ `OpsCache` groups ops into persisted-cache batches (100 ops by default). Full batches are written
524
+ immediately; partial dirty batches are written only by the timer or document-service disposal, and
525
+ `OpsCache.get()` reads persisted entries rather than another service instance's in-memory dirty batch.
526
+ A newly created point-in-time service can therefore miss recently received ops that are neither in its
527
+ snapshot nor flushed to persisted cache/storage.
528
+
529
+ Cover full and partial cache batches, leading/trailing empty slots, timer and dispose flushes racing a
530
+ historical read, gaps between cache batches, and the `useCacheForOps` transition that permanently stops
531
+ consulting cache after the first miss. The source merge must remain contiguous when the same op is
532
+ available from snapshot, cache, PUSH, or storage, even though the current storage-only point-in-time
533
+ path cannot consume the PUSH source.
534
+
535
+ ### Are concurrent and routed historical loads isolated?
536
+
537
+ Each point-in-time load creates a fresh `NonPersistentCache` and per-load shared `EpochTracker`, but this
538
+ is not covered under concurrency. Run simultaneous loads to different targets, dispose one while the
539
+ other is reading, then perform a normal live load with the same credentials. Also cover a resolved URL
540
+ with `dataStorePath` and `codeHint` so version URL rewriting preserves routing metadata without leaking
541
+ historical cache entries into another load.
542
+
267
543
  ## Part V — Components B & C, as built
268
544
 
269
545
  Component A (this folder) only selects the base. Components B and C — which materialize the document at
270
546
  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.
547
+ code IDs here (those index Component A's suite); Component B's lineage guard is covered by
548
+ `../test/odspPointInTimeDocumentServiceFactory.spec.ts` both the structural shared-`EpochTracker`
549
+ wiring and the up-front recoverable-vs-live epoch comparison (a mismatch fails the load before any
550
+ service is built; a matching epoch proceeds to create both services) — and its bounded `fetchMessages`
551
+ clamp (an unbounded, past-target, or before-target `to`, plus op pass-through) by
552
+ `../test/odspPointInTimeDocumentService.spec.ts`; the rest are conceptual answers in the spirit of
553
+ [Part I](#part-i--foundations). The one still-directional gap is bridging a
554
+ *trimmed* op range via an intermediate snapshot (see [Part IV](#part-iv--directional)); everything below
555
+ is what ships today.
275
556
 
276
557
  ### Component B — how does the recomposed driver materialize the target?
277
558
 
278
- `OdspPointInTimeDocumentServiceFactory` (in `../pointInTimeDriver/`) extends
279
- `OdspDocumentServiceFactoryCore` and adds `createPointInTimeDocumentService(resolvedUrl, targetSequenceNumber)`:
559
+ The factory returned by `getOdspPointInTimeDocumentServiceFactory` (in `../pointInTimeDriver/`)
560
+ extends `OdspDocumentServiceFactoryCore` internally and adds
561
+ `createPointInTimeDocumentService(resolvedUrl, targetSequenceNumber)`:
280
562
 
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.
563
+ 1. Build a version manager (Component A), sharing the single `EpochTracker` described below, and call
564
+ `findBaseForSeq(target)`. It picks the closest version *and* proves that base shares the live
565
+ document's epoch before returning it (see
566
+ [Part II](#how-does-it-verify-a-chosen-base-can-be-replayed-to-the-target)); a cross-lineage base
567
+ throws the non-retryable `fileOverwrittenInStorage` error. A `noBaseVersion` result becomes a
568
+ `UsageError` naming the target and the oldest resolved sequence number.
283
569
  2. Resolve the chosen file version into a version-scoped resolved URL, then create two ordinary ODSP
284
570
  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).
571
+ snapshot) and a **live** one (its delta storage supplies the ops to replay). Both are created via
572
+ `createDocumentServiceCore` with a **single shared** `EpochTracker` (the same one the version
573
+ manager reads through) — this is the structural lineage guard; see the next question.
286
574
  3. Return an `OdspPointInTimeDocumentService` composing the two.
287
575
 
288
576
  It lives in this package rather than a generic wrapping driver (e.g. `@fluidframework/replay-driver`)
@@ -290,6 +578,35 @@ because loading a historical file version is a storage-layer concern: it needs t
290
578
  snapshot fetch, the epoch tracker, and authentication — all internal to this driver — and it consumes
291
579
  the version manager in-package, so the manager itself needs no exported surface.
292
580
 
581
+ ### Component B — what stops replay across a lineage boundary (a restore)?
582
+
583
+ Replay only produces a correct result while the base version and the live document are on the **same
584
+ lineage** — one continuous, monotonically-numbered op stream. ODSP's **epoch** is exactly that lineage
585
+ id: a version restore (or download-then-reupload) bumps the epoch and renumbers the op stream
586
+ (`epochTracker.ts:82-89`). If the base file version predates such a boundary, its snapshot is from the
587
+ old lineage while the live ops in `(base, target]` are from the new one, so replaying them would
588
+ silently corrupt the materialized state (see [Part I / can a base replay across a lineage change?](#can-a-base-be-replayed-across-a-version-restore-a-lineage-change)).
589
+
590
+ The guard has **two layers**. **Up front**, `findBaseForSeq` validates the chosen base's lineage before
591
+ returning it (and thus before `createPointInTimeDocumentService` builds any service): it reads the base
592
+ version's epoch and the live document's epoch and, if they differ, rejects the load with the driver's
593
+ canonical `fileOverwrittenInStorage` epoch-mismatch error — the *same* `errorType` the shared
594
+ `EpochTracker` raises structurally — so both layers surface one consistent, non-retryable error for a
595
+ cross-lineage base. This up-front comparison is
596
+ exercised end-to-end at the factory: `test/odspPointInTimeDocumentServiceFactory.spec.ts` drives a real
597
+ version manager whose recoverable-version epoch differs from the live document's and asserts the load is
598
+ rejected *before* any service is created (with a matching-epoch companion that proceeds to build both).
599
+ **Structurally**, it then
600
+ threads one `EpochTracker` through every read — the version-history reads that pick the base, the
601
+ recoverable base snapshot, and the live op stream — by passing a single shared `ICacheAndTracker` to
602
+ `createDocumentServiceCore` for both services. An `EpochTracker` pins itself to the first epoch it sees
603
+ and throws `fileOverwrittenInStorage` ("Epoch mismatch") on any later divergence
604
+ (`epochTracker.ts:130-132, 496-511`), so even a lineage change that slips past the up-front check is
605
+ caught as reads happen and the load fails loudly instead of returning a wrong document. A fresh
606
+ `NonPersistentCache` backs that shared tracker so this read-only historical load stays isolated from the
607
+ factory's cache — a base version's snapshot can never leak into a normal live load. (The structural
608
+ guard — shared-tracker threading and divergent-epoch rejection — is verified by the same spec.)
609
+
293
610
  ### Component B — which `IDocumentService` method drives the replay?
294
611
 
295
612
  `OdspPointInTimeDocumentService` is read-only and advertises the `storageOnly` document-service policy.
@@ -298,7 +615,11 @@ Its three `IDocumentService` methods:
298
615
  - `connectToStorage` → the recoverable (base-version) service's storage: the base snapshot.
299
616
  - `connectToDeltaStorage` → wraps the **live** service's delta storage and clamps every
300
617
  **`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.**
618
+ op past the target is ever fetched. The clamp is all it does: op availability is already enforced
619
+ beneath it by the delta-storage stack, which discards any batch not starting at the requested `from`
620
+ and keeps requesting until the bounded range is fully delivered — so a stream that completes has
621
+ necessarily served the whole bridge, and one that cannot fails the fetch.
622
+ **`fetchMessages` is the method that drives the bounded replay.**
302
623
  - `connectToDeltaStream` → throws: under `storageOnly` the connection manager synthesizes a frozen,
303
624
  read-only delta stream instead of opening a live socket, so this is never called under normal flow.
304
625
 
@@ -341,7 +662,8 @@ and the `opStream` endpoint is queried for exactly `[from, target]`.
341
662
  1. Validates `loadToSequenceNumber` is a non-negative integer (`UsageError` otherwise).
342
663
  2. Detects the point-in-time capability with `asPointInTimeCapableFactory`, which checks the passed
343
664
  `documentServiceFactory` exposes `createPointInTimeDocumentService`. A plain factory is a
344
- `UsageError` — the caller must pass `OdspPointInTimeDocumentServiceFactory` directly, with no wrapping.
665
+ `UsageError` — the caller must pass the result of `getOdspPointInTimeDocumentServiceFactory`
666
+ directly, with no wrapping.
345
667
  3. Wraps it in a `PointInTimeDocumentServiceFactory` adapter so the container's normal
346
668
  `createDocumentService(resolvedUrl)` routes to `createPointInTimeDocumentService(resolvedUrl, target)`.
347
669
  (`createContainer` throws — the adapter is load-only.)