@fluidframework/odsp-driver 2.113.1 → 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.
- package/CHANGELOG.md +8 -0
- package/api-extractor/api-extractor-lint-legacyAlpha.cjs.json +5 -0
- package/api-extractor/api-extractor-lint-legacyAlpha.esm.json +5 -0
- package/api-extractor/api-extractor.legacy.json +5 -1
- package/api-report/odsp-driver.legacy.alpha.api.md +232 -0
- package/dist/getUrlAndHeadersWithAuth.d.ts +4 -0
- package/dist/getUrlAndHeadersWithAuth.d.ts.map +1 -1
- package/dist/getUrlAndHeadersWithAuth.js +4 -0
- package/dist/getUrlAndHeadersWithAuth.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/legacy.d.ts +1 -1
- package/dist/legacyAlpha.d.ts +49 -0
- package/dist/odspVersionManager/odspFileVersionFetcher.d.ts +40 -1
- package/dist/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -1
- package/dist/odspVersionManager/odspFileVersionFetcher.js +52 -14
- package/dist/odspVersionManager/odspFileVersionFetcher.js.map +1 -1
- package/dist/odspVersionManager/odspVersionManager.d.ts +23 -45
- package/dist/odspVersionManager/odspVersionManager.d.ts.map +1 -1
- package/dist/odspVersionManager/odspVersionManager.js +68 -33
- package/dist/odspVersionManager/odspVersionManager.js.map +1 -1
- package/dist/packageVersion.d.ts +1 -1
- package/dist/packageVersion.js +1 -1
- package/dist/packageVersion.js.map +1 -1
- package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +5 -0
- package/dist/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -1
- package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js +6 -3
- package/dist/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -1
- package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +25 -33
- package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -1
- package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +62 -38
- package/dist/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -1
- package/dist/public.d.ts +1 -1
- package/internal.d.ts +1 -1
- package/legacy/alpha.d.ts +11 -0
- package/legacy.d.ts +1 -1
- package/lib/getUrlAndHeadersWithAuth.d.ts +4 -0
- package/lib/getUrlAndHeadersWithAuth.d.ts.map +1 -1
- package/lib/getUrlAndHeadersWithAuth.js +4 -0
- package/lib/getUrlAndHeadersWithAuth.js.map +1 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -3
- package/lib/index.js.map +1 -1
- package/lib/legacy.d.ts +1 -1
- package/lib/legacyAlpha.d.ts +49 -0
- package/lib/odspVersionManager/odspFileVersionFetcher.d.ts +40 -1
- package/lib/odspVersionManager/odspFileVersionFetcher.d.ts.map +1 -1
- package/lib/odspVersionManager/odspFileVersionFetcher.js +53 -15
- package/lib/odspVersionManager/odspFileVersionFetcher.js.map +1 -1
- package/lib/odspVersionManager/odspVersionManager.d.ts +23 -45
- package/lib/odspVersionManager/odspVersionManager.d.ts.map +1 -1
- package/lib/odspVersionManager/odspVersionManager.js +68 -33
- package/lib/odspVersionManager/odspVersionManager.js.map +1 -1
- package/lib/packageVersion.d.ts +1 -1
- package/lib/packageVersion.js +1 -1
- package/lib/packageVersion.js.map +1 -1
- package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts +5 -0
- package/lib/pointInTimeDriver/odspPointInTimeDocumentService.d.ts.map +1 -1
- package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js +6 -3
- package/lib/pointInTimeDriver/odspPointInTimeDocumentService.js.map +1 -1
- package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts +25 -33
- package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.d.ts.map +1 -1
- package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js +62 -38
- package/lib/pointInTimeDriver/odspPointInTimeDocumentServiceFactory.js.map +1 -1
- package/lib/public.d.ts +1 -1
- package/package.json +29 -17
- package/src/getUrlAndHeadersWithAuth.ts +4 -0
- package/src/index.ts +8 -3
- package/src/odspVersionManager/DEV.md +376 -54
- package/src/odspVersionManager/odspFileVersionFetcher.ts +111 -16
- package/src/odspVersionManager/odspVersionManager.ts +106 -68
- package/src/packageVersion.ts +1 -1
- package/src/pointInTimeDriver/odspPointInTimeDocumentService.ts +8 -4
- 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
|
|
13
|
-
|
|
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/` (`
|
|
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
|
|
101
|
-
|
|
102
|
-
at or before the target
|
|
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
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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`.
|
|
249
|
-
|
|
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
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
`
|
|
279
|
-
`OdspDocumentServiceFactoryCore` and adds
|
|
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)
|
|
282
|
-
|
|
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.
|
|
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
|
|
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.)
|