@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.
- package/CHANGELOG.md +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/odspVersionManager/index.d.ts +6 -0
- package/dist/odspVersionManager/index.d.ts.map +1 -0
- package/dist/odspVersionManager/index.js +10 -0
- package/dist/odspVersionManager/index.js.map +1 -0
- package/dist/odspVersionManager/odspFileVersionFetcher.d.ts +22 -0
- package/dist/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -0
- package/dist/odspVersionManager/odspFileVersionFetcher.js +81 -0
- package/dist/odspVersionManager/odspFileVersionFetcher.js.map +1 -0
- package/dist/odspVersionManager/odspVersionManager.d.ts +98 -0
- package/dist/odspVersionManager/odspVersionManager.d.ts.map +1 -0
- package/dist/odspVersionManager/odspVersionManager.js +87 -0
- package/dist/odspVersionManager/odspVersionManager.js.map +1 -0
- package/dist/packageVersion.d.ts +1 -1
- package/dist/packageVersion.d.ts.map +1 -1
- package/dist/packageVersion.js +1 -1
- package/dist/packageVersion.js.map +1 -1
- package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +37 -0
- package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -0
- package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js +70 -0
- package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -0
- package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +49 -0
- package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -0
- package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +108 -0
- package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/odspVersionManager/index.d.ts +6 -0
- package/lib/odspVersionManager/index.d.ts.map +1 -0
- package/lib/odspVersionManager/index.js +6 -0
- package/lib/odspVersionManager/index.js.map +1 -0
- package/lib/odspVersionManager/odspFileVersionFetcher.d.ts +22 -0
- package/lib/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -0
- package/lib/odspVersionManager/odspFileVersionFetcher.js +77 -0
- package/lib/odspVersionManager/odspFileVersionFetcher.js.map +1 -0
- package/lib/odspVersionManager/odspVersionManager.d.ts +98 -0
- package/lib/odspVersionManager/odspVersionManager.d.ts.map +1 -0
- package/lib/odspVersionManager/odspVersionManager.js +82 -0
- package/lib/odspVersionManager/odspVersionManager.js.map +1 -0
- package/lib/packageVersion.d.ts +1 -1
- package/lib/packageVersion.d.ts.map +1 -1
- package/lib/packageVersion.js +1 -1
- package/lib/packageVersion.js.map +1 -1
- package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +37 -0
- package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -0
- package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js +66 -0
- package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -0
- package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +49 -0
- package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -0
- package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +104 -0
- package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -0
- package/package.json +12 -12
- package/src/index.ts +2 -0
- package/src/odspVersionManager/DEV.md +349 -0
- package/src/odspVersionManager/index.ts +12 -0
- package/src/odspVersionManager/odspFileVersionFetcher.ts +138 -0
- package/src/odspVersionManager/odspVersionManager.ts +170 -0
- package/src/packageVersion.ts +1 -1
- package/src/pointInTimeDriver/odspPointInTimeDocumentService.ts +94 -0
- 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
|
+
}
|