@fluidframework/container-loader 2.93.0 → 2.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/api-report/container-loader.legacy.alpha.api.md +13 -1
  3. package/dist/captureReferencedContents.d.ts +154 -0
  4. package/dist/captureReferencedContents.d.ts.map +1 -0
  5. package/dist/captureReferencedContents.js +349 -0
  6. package/dist/captureReferencedContents.js.map +1 -0
  7. package/dist/connectionManager.d.ts +2 -1
  8. package/dist/connectionManager.d.ts.map +1 -1
  9. package/dist/connectionManager.js +39 -8
  10. package/dist/connectionManager.js.map +1 -1
  11. package/dist/connectionStateHandler.d.ts.map +1 -1
  12. package/dist/connectionStateHandler.js +3 -1
  13. package/dist/connectionStateHandler.js.map +1 -1
  14. package/dist/container.d.ts.map +1 -1
  15. package/dist/container.js +13 -4
  16. package/dist/container.js.map +1 -1
  17. package/dist/containerStorageAdapter.d.ts +20 -2
  18. package/dist/containerStorageAdapter.d.ts.map +1 -1
  19. package/dist/containerStorageAdapter.js +2 -2
  20. package/dist/containerStorageAdapter.js.map +1 -1
  21. package/dist/createAndLoadContainerUtils.d.ts +95 -0
  22. package/dist/createAndLoadContainerUtils.d.ts.map +1 -1
  23. package/dist/createAndLoadContainerUtils.js +137 -11
  24. package/dist/createAndLoadContainerUtils.js.map +1 -1
  25. package/dist/frozenServices.d.ts +113 -30
  26. package/dist/frozenServices.d.ts.map +1 -1
  27. package/dist/frozenServices.js +236 -58
  28. package/dist/frozenServices.js.map +1 -1
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/legacyAlpha.d.ts +2 -0
  34. package/dist/loader.d.ts +1 -1
  35. package/dist/loader.d.ts.map +1 -1
  36. package/dist/loader.js +1 -1
  37. package/dist/loader.js.map +1 -1
  38. package/dist/loaderLayerCompatState.d.ts +1 -1
  39. package/dist/packageVersion.d.ts +1 -1
  40. package/dist/packageVersion.d.ts.map +1 -1
  41. package/dist/packageVersion.js +1 -1
  42. package/dist/packageVersion.js.map +1 -1
  43. package/dist/pendingLocalStateStore.d.ts.map +1 -1
  44. package/dist/pendingLocalStateStore.js +9 -3
  45. package/dist/pendingLocalStateStore.js.map +1 -1
  46. package/dist/retriableDocumentStorageService.d.ts +2 -1
  47. package/dist/retriableDocumentStorageService.d.ts.map +1 -1
  48. package/dist/retriableDocumentStorageService.js +3 -2
  49. package/dist/retriableDocumentStorageService.js.map +1 -1
  50. package/dist/serializedStateManager.d.ts +16 -1
  51. package/dist/serializedStateManager.d.ts.map +1 -1
  52. package/dist/serializedStateManager.js +11 -1
  53. package/dist/serializedStateManager.js.map +1 -1
  54. package/lib/captureReferencedContents.d.ts +154 -0
  55. package/lib/captureReferencedContents.d.ts.map +1 -0
  56. package/lib/captureReferencedContents.js +338 -0
  57. package/lib/captureReferencedContents.js.map +1 -0
  58. package/lib/connectionManager.d.ts +2 -1
  59. package/lib/connectionManager.d.ts.map +1 -1
  60. package/lib/connectionManager.js +40 -9
  61. package/lib/connectionManager.js.map +1 -1
  62. package/lib/connectionStateHandler.d.ts.map +1 -1
  63. package/lib/connectionStateHandler.js +3 -1
  64. package/lib/connectionStateHandler.js.map +1 -1
  65. package/lib/container.d.ts.map +1 -1
  66. package/lib/container.js +14 -5
  67. package/lib/container.js.map +1 -1
  68. package/lib/containerStorageAdapter.d.ts +20 -2
  69. package/lib/containerStorageAdapter.d.ts.map +1 -1
  70. package/lib/containerStorageAdapter.js +2 -2
  71. package/lib/containerStorageAdapter.js.map +1 -1
  72. package/lib/createAndLoadContainerUtils.d.ts +95 -0
  73. package/lib/createAndLoadContainerUtils.d.ts.map +1 -1
  74. package/lib/createAndLoadContainerUtils.js +128 -3
  75. package/lib/createAndLoadContainerUtils.js.map +1 -1
  76. package/lib/frozenServices.d.ts +113 -30
  77. package/lib/frozenServices.d.ts.map +1 -1
  78. package/lib/frozenServices.js +233 -57
  79. package/lib/frozenServices.js.map +1 -1
  80. package/lib/index.d.ts +2 -1
  81. package/lib/index.d.ts.map +1 -1
  82. package/lib/index.js +2 -1
  83. package/lib/index.js.map +1 -1
  84. package/lib/legacyAlpha.d.ts +2 -0
  85. package/lib/loader.d.ts +1 -1
  86. package/lib/loader.d.ts.map +1 -1
  87. package/lib/loader.js +2 -2
  88. package/lib/loader.js.map +1 -1
  89. package/lib/loaderLayerCompatState.d.ts +1 -1
  90. package/lib/packageVersion.d.ts +1 -1
  91. package/lib/packageVersion.d.ts.map +1 -1
  92. package/lib/packageVersion.js +1 -1
  93. package/lib/packageVersion.js.map +1 -1
  94. package/lib/pendingLocalStateStore.d.ts.map +1 -1
  95. package/lib/pendingLocalStateStore.js +9 -3
  96. package/lib/pendingLocalStateStore.js.map +1 -1
  97. package/lib/retriableDocumentStorageService.d.ts +2 -1
  98. package/lib/retriableDocumentStorageService.d.ts.map +1 -1
  99. package/lib/retriableDocumentStorageService.js +3 -2
  100. package/lib/retriableDocumentStorageService.js.map +1 -1
  101. package/lib/serializedStateManager.d.ts +16 -1
  102. package/lib/serializedStateManager.d.ts.map +1 -1
  103. package/lib/serializedStateManager.js +11 -1
  104. package/lib/serializedStateManager.js.map +1 -1
  105. package/package.json +13 -13
  106. package/src/captureReferencedContents.ts +446 -0
  107. package/src/connectionManager.ts +47 -8
  108. package/src/connectionStateHandler.ts +14 -9
  109. package/src/container.ts +18 -4
  110. package/src/containerStorageAdapter.ts +22 -2
  111. package/src/createAndLoadContainerUtils.ts +229 -2
  112. package/src/frozenServices.ts +285 -64
  113. package/src/index.ts +7 -0
  114. package/src/loader.ts +4 -2
  115. package/src/packageVersion.ts +1 -1
  116. package/src/pendingLocalStateStore.ts +8 -1
  117. package/src/retriableDocumentStorageService.ts +11 -4
  118. package/src/serializedStateManager.ts +28 -1
@@ -26,40 +26,58 @@ import {
26
26
  type ISignalMessage,
27
27
  type ITokenClaims,
28
28
  } from "@fluidframework/driver-definitions/internal";
29
+ import { v4 as uuid } from "uuid";
29
30
 
30
31
  import type { IConnectionStateChangeReason } from "./contracts.js";
31
32
 
32
33
  /**
33
- * Creation of a FrozenDocumentServiceFactory which wraps an existing
34
- * DocumentServiceFactory to provide a storage-only document service.
34
+ * Creates an `IDocumentServiceFactory` that produces a "frozen" document service: one whose
35
+ * delta stream never sends or receives ops, and whose storage service only supports
36
+ * `IDocumentStorageService.readBlob`. Used to load a container from pending local state
37
+ * without re-establishing a live connection.
35
38
  *
36
- * @param documentServiceFactory - The underlying DocumentServiceFactory to wrap.
37
- * @returns A FrozenDocumentServiceFactory
39
+ * @param factory - The underlying factory to wrap. Its storage backs blob reads; all other
40
+ * storage operations throw. May be omitted when blob fetches are not required.
41
+ * @param readOnly - When `true` (the default), the document service advertises the
42
+ * `IDocumentServicePolicies.storageOnly` policy, which causes the loader to surface the
43
+ * container as read-only (see `IContainer.readOnlyInfo`).
44
+ *
45
+ * When `false`, the container is loaded as writable so the runtime will accept DDS submissions.
46
+ * The connection itself stays `Connected`: `ConnectionManager.sendMessages` recognizes the
47
+ * `WritableFrozenDeltaStream` as the live connection and short-circuits — the message is dropped
48
+ * at the network layer rather than triggering a read→write reconnect. Local DDS state continues
49
+ * to update via optimistic apply, and submitted ops accumulate in the runtime's pending-state
50
+ * manager, which is exactly the state needed to capture pending local state. Use `false` when
51
+ * callers want to accrue and capture pending state without publishing it.
52
+ * @returns A factory that produces frozen document services.
38
53
  * @legacy @alpha
39
54
  */
40
55
  export function createFrozenDocumentServiceFactory(
41
56
  factory?: IDocumentServiceFactory | Promise<IDocumentServiceFactory>,
57
+ readOnly: boolean = true,
42
58
  ): IDocumentServiceFactory {
43
- // Sync path
44
- return factory instanceof FrozenDocumentServiceFactory
45
- ? factory
46
- : new FrozenDocumentServiceFactory(factory);
59
+ if (factory instanceof FrozenDocumentServiceFactory) {
60
+ // Already wrapped. Reuse if readOnly matches; otherwise unwrap and rewrap so the caller's
61
+ // most recent readOnly intent wins (silently honoring caller intent rather than dropping
62
+ // the new argument).
63
+ return factory.readOnly === readOnly
64
+ ? factory
65
+ : new FrozenDocumentServiceFactory(readOnly, factory.inner);
66
+ }
67
+ return new FrozenDocumentServiceFactory(readOnly, factory);
47
68
  }
48
69
 
49
70
  export class FrozenDocumentServiceFactory implements IDocumentServiceFactory {
50
71
  constructor(
51
- private readonly documentServiceFactory?:
52
- | IDocumentServiceFactory
53
- | Promise<IDocumentServiceFactory>,
72
+ public readonly readOnly: boolean,
73
+ public readonly inner?: IDocumentServiceFactory | Promise<IDocumentServiceFactory>,
54
74
  ) {}
55
75
 
56
76
  async createDocumentService(resolvedUrl: IResolvedUrl): Promise<IDocumentService> {
57
- let factory = this.documentServiceFactory;
58
- if (isPromiseLike(factory)) {
59
- factory = await this.documentServiceFactory;
60
- }
77
+ const factory = isPromiseLike(this.inner) ? await this.inner : this.inner;
61
78
  return new FrozenDocumentService(
62
79
  resolvedUrl,
80
+ this.readOnly,
63
81
  await factory?.createDocumentService(resolvedUrl),
64
82
  );
65
83
  }
@@ -72,43 +90,147 @@ class FrozenDocumentService
72
90
  extends TypedEventEmitter<IDocumentServiceEvents>
73
91
  implements IDocumentService
74
92
  {
93
+ // Tracks every storage instance handed out by `connectToStorage` so `dispose()` can cascade
94
+ // disposal to each one (rejecting their hanging `createBlob` deferreds). A Set rather than
95
+ // a single field because `IDocumentService.connectToStorage` is a public API that can be
96
+ // called more than once — we cannot assume the Container holds a single instance.
97
+ private readonly storageServices = new Set<FrozenDocumentStorageService>();
98
+
75
99
  constructor(
76
100
  public readonly resolvedUrl: IResolvedUrl,
101
+ private readonly readOnly: boolean,
77
102
  private readonly documentService?: IDocumentService,
78
103
  ) {
79
104
  super();
105
+ // When readOnly, advertise the storageOnly policy. The connectionManager short-circuits
106
+ // on it: it synthesizes a FrozenDeltaStream itself and never calls
107
+ // connectToDeltaStream, and the readOnlyInfo getter forces the container to read-only
108
+ // because the live connection is a FrozenDeltaStream.
109
+ //
110
+ // Audit (2026-05-05): the only consumer of `policies.storageOnly` as a frozen-container
111
+ // signal is `ConnectionManager` (synthesizing a `FrozenDeltaStream` when set). All other
112
+ // matches in the loader/runtime/driver layers are either drivers reading their own
113
+ // policies (e.g. local-driver) or `IReadOnlyInfo.storageOnly`, which is derived from the
114
+ // live connection — not the policy. So the writable-frozen container is intentionally
115
+ // indistinguishable from a normal container at the policies layer; downstream behavior
116
+ // flows through the live `WritableFrozenDeltaStream` instead.
117
+ this.policies = readOnly ? { storageOnly: true } : {};
80
118
  }
81
119
 
82
- public readonly policies: IDocumentServicePolicies = {
83
- storageOnly: true,
84
- };
120
+ public readonly policies: IDocumentServicePolicies;
85
121
  async connectToStorage(): Promise<IDocumentStorageService> {
86
- return new FrozenDocumentStorageService(await this.documentService?.connectToStorage());
122
+ const storage = new FrozenDocumentStorageService(
123
+ this.readOnly,
124
+ await this.documentService?.connectToStorage(),
125
+ );
126
+ this.storageServices.add(storage);
127
+ return storage;
87
128
  }
88
129
  async connectToDeltaStorage(): Promise<IDocumentDeltaStorageService> {
89
130
  return frozenDocumentDeltaStorageService;
90
131
  }
91
- async connectToDeltaStream(client: IClient): Promise<IDocumentDeltaConnection> {
92
- return new FrozenDeltaStream();
132
+ async connectToDeltaStream(_client: IClient): Promise<IDocumentDeltaConnection> {
133
+ if (this.readOnly) {
134
+ // connectionManager short-circuits via policies.storageOnly before reaching here
135
+ // in the read-only path; reaching this branch indicates a non-connectionManager
136
+ // consumer or a regression of the short-circuit. Throw to surface the misuse
137
+ // rather than silently produce a working stream.
138
+ throw new Error(
139
+ "FrozenDocumentService is read-only; connectToDeltaStream should not be called (connectionManager short-circuits via policies.storageOnly)",
140
+ );
141
+ }
142
+ // Writable path: hand out a fresh WritableFrozenDeltaStream regardless of client.mode
143
+ // or whether this is the initial connect or a reconnect. The stream's own mode is
144
+ // "read" (advertising "write" would imply quorum membership we cannot honor), and
145
+ // `ConnectionManager.sendMessages` short-circuits on WritableFrozenDeltaStream so
146
+ // outbound writes never reach a real network. The per-instance clientId minted in
147
+ // FrozenDeltaStreamBase prevents pendingStateManager 0x173 on replay across reconnects.
148
+ return new WritableFrozenDeltaStream();
149
+ }
150
+ dispose(error?: unknown): void {
151
+ // Cascade disposal to each storage instance so any hanging `createBlob` promises (the
152
+ // writable-frozen pending-blob mechanism) reject and the BlobManager can release its
153
+ // references. Without this, hung promises remain held by BlobManager closures for the
154
+ // lifetime of the process.
155
+ for (const storage of this.storageServices) {
156
+ storage.dispose();
157
+ }
158
+ this.storageServices.clear();
159
+ // Forward disposal to the wrapped service. We own its lifetime (it was created for us
160
+ // by the wrapping factory and is never exposed to callers), so the contract from
161
+ // `IDocumentService.dispose` ("called by storage consumer when done with storage")
162
+ // applies here.
163
+ this.documentService?.dispose(error);
93
164
  }
94
- dispose(): void {}
95
165
  }
96
166
 
97
167
  const frozenDocumentStorageServiceHandler = (): never => {
98
168
  throw new Error("Operations are not supported on the FrozenDocumentStorageService.");
99
169
  };
100
- class FrozenDocumentStorageService implements IDocumentStorageService {
101
- constructor(private readonly documentStorageService?: IDocumentStorageService) {}
170
+
171
+ class FrozenDocumentStorageService implements IDocumentStorageService, IDisposable {
172
+ // Single deferred shared by every in-flight `createBlob` call. The writable-frozen
173
+ // `createBlob` returns this promise so the BlobManager keeps the blob in `uploading`
174
+ // state (see comment in the constructor). Rejecting the deferred on disposal fans the
175
+ // rejection out to every awaiter at once — and to any future `createBlob` calls too,
176
+ // since they receive the already-rejected promise.
177
+ private readonly disposalDeferred: {
178
+ readonly promise: Promise<never>;
179
+ readonly reject: (error: Error) => void;
180
+ };
181
+
182
+ private _disposed = false;
183
+ public get disposed(): boolean {
184
+ return this._disposed;
185
+ }
186
+
187
+ constructor(
188
+ readOnly: boolean,
189
+ private readonly documentStorageService?: IDocumentStorageService,
190
+ ) {
191
+ let rejectFn!: (error: Error) => void;
192
+ const promise = new Promise<never>((_, reject) => {
193
+ rejectFn = reject;
194
+ });
195
+ // Attach a no-op catch so node doesn't log an unhandled-rejection warning when
196
+ // dispose runs before any caller has awaited the promise. Callers awaiting the
197
+ // original promise still observe the rejection.
198
+ promise.catch(() => {});
199
+ this.disposalDeferred = { promise, reject: rejectFn };
200
+
201
+ // In the writable-frozen path, `createBlob` returns a never-resolving promise instead
202
+ // of throwing. This keeps the BlobManager's `localBlobCache` entry in the `uploading`
203
+ // state: `getPendingBlobs` downgrades `uploading` blobs to `localOnly` in pending
204
+ // state, so the blob survives `getPendingLocalState`. A subsequent live load runs
205
+ // `sharePendingBlobs`, which re-enters `uploadAndAttach` against the real storage to
206
+ // complete the upload. Throwing here would instead delete the cache entry (in
207
+ // `uploadAndAttach`'s catch handler) and lose the blob — defeating the whole point of
208
+ // accruing pending state.
209
+ this.createBlob = readOnly
210
+ ? frozenDocumentStorageServiceHandler
211
+ : async () => this.disposalDeferred.promise;
212
+ }
102
213
 
103
214
  getSnapshotTree = frozenDocumentStorageServiceHandler;
104
215
  getSnapshot = frozenDocumentStorageServiceHandler;
105
216
  getVersions = frozenDocumentStorageServiceHandler;
106
- createBlob = frozenDocumentStorageServiceHandler;
217
+ createBlob: IDocumentStorageService["createBlob"];
107
218
  readBlob =
108
219
  this.documentStorageService?.readBlob.bind(this.documentStorageService) ??
109
220
  frozenDocumentStorageServiceHandler;
110
221
  uploadSummaryWithContext = frozenDocumentStorageServiceHandler;
111
222
  downloadSummary = frozenDocumentStorageServiceHandler;
223
+
224
+ public dispose(): void {
225
+ if (this._disposed) {
226
+ return;
227
+ }
228
+ this._disposed = true;
229
+ // Don't propagate any caller-supplied error here. `IDocumentService.dispose` already
230
+ // logs the Container's error path; the createBlob deferred's consumer (BlobManager)
231
+ // only needs the "this storage is going away" signal — not a chain of error causes.
232
+ this.disposalDeferred.reject(new Error("FrozenDocumentStorageService is disposed"));
233
+ }
112
234
  }
113
235
 
114
236
  const frozenDocumentDeltaStorageService: IDocumentDeltaStorageService = {
@@ -126,63 +248,87 @@ const clientFrozenDeltaStream: IClient = {
126
248
  user: { id: "storage-only client" }, // we need some "fake" ID here.
127
249
  scopes: [],
128
250
  };
251
+
129
252
  const clientIdFrozenDeltaStream: string = "storage-only client";
130
253
 
254
+ // Cast rationale: ITokenClaims requires tenantId/documentId/user/iat/exp/ver, but a frozen
255
+ // delta stream has no tenant or session to draw real values from — it's a synthetic
256
+ // in-process connection that never reaches a service. Inventing sentinel values would imply
257
+ // quorum membership we cannot honor; only `scopes` actually drives behavior here (DocRead vs
258
+ // DocWrite gates readOnlyInfo). The cast is the honest representation of "this connection
259
+ // has no claims worth populating."
260
+ /* eslint-disable @typescript-eslint/consistent-type-assertions */
261
+ const readOnlyClaims: ITokenClaims = { scopes: [ScopeType.DocRead] } as ITokenClaims;
262
+ const writableClaims: ITokenClaims = {
263
+ scopes: [ScopeType.DocRead, ScopeType.DocWrite],
264
+ } as ITokenClaims;
265
+ /* eslint-enable @typescript-eslint/consistent-type-assertions */
266
+
131
267
  /**
132
- * Implementation of IDocumentDeltaConnection that does not support submitting
133
- * or receiving ops. Used in storage-only mode and in frozen loads.
268
+ * Inert `IDocumentDeltaConnection` for frozen container loads. Has no server upstream:
269
+ * op and signal streams are empty, and `initialClients` contains only its own synthetic
270
+ * read-only client — which lets the connection state handler observe "self" in the audience
271
+ * and transition the container to Connected without waiting for a real join op or signal.
272
+ *
273
+ * Two concrete variants share this base — see their JSDoc for variant-specific details:
274
+ *
275
+ * - {@link FrozenDeltaStream} — read-only.
276
+ * - {@link WritableFrozenDeltaStream} — writable.
277
+ *
278
+ * Both variants nack any incoming `submit`: this connection has no upstream and
279
+ * `ConnectionManager.sendMessages` recognizes `WritableFrozenDeltaStream` and drops messages
280
+ * before they reach `submit`, so under normal flow it should never fire. A nack reaching the
281
+ * connectionManager surfaces the misuse — and may close the container — which is the right
282
+ * defensive signal that something has bypassed the expected flow.
283
+ *
284
+ * `submitSignal` is a silent no-op for both variants. Signals are ephemeral and best-effort —
285
+ * runtime/presence subsystems may submit them at any point in the writable-frozen lifetime, and
286
+ * dropping them is the correct behavior here (we have no upstream). Closing the container or
287
+ * triggering a reconnect on a stray signal would be strictly worse than dropping it.
134
288
  */
135
- export class FrozenDeltaStream
289
+ abstract class FrozenDeltaStreamBase
136
290
  extends TypedEventEmitter<IDocumentDeltaConnectionEvents>
137
291
  implements IDocumentDeltaConnection, IDisposable
138
292
  {
139
- clientId = clientIdFrozenDeltaStream;
140
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
141
- claims = {
142
- scopes: [ScopeType.DocRead],
143
- } as ITokenClaims;
144
- mode: ConnectionMode = "read";
145
- existing: boolean = true;
146
- maxMessageSize: number = 0;
147
- version: string = "";
148
- initialMessages: ISequencedDocumentMessage[] = [];
149
- initialSignals: ISignalMessage[] = [];
150
- initialClients: ISignalClient[] = [
151
- { client: clientFrozenDeltaStream, clientId: clientIdFrozenDeltaStream },
152
- ];
153
- serviceConfiguration: IClientConfiguration = {
293
+ public readonly clientId: string;
294
+ public readonly claims: ITokenClaims;
295
+ public readonly initialClients: ISignalClient[];
296
+ public readonly mode: ConnectionMode = "read";
297
+ public readonly existing: boolean = true;
298
+ public readonly maxMessageSize: number = 0;
299
+ public readonly version: string = "";
300
+ public readonly initialMessages: ISequencedDocumentMessage[] = [];
301
+ public readonly initialSignals: ISignalMessage[] = [];
302
+ public readonly serviceConfiguration: IClientConfiguration = {
154
303
  maxMessageSize: 0,
155
304
  blockSize: 0,
156
305
  };
157
- checkpointSequenceNumber?: number | undefined = undefined;
158
- /**
159
- * Connection which is not connected to socket.
160
- * @param storageOnlyReason - Reason on why the connection to delta stream is not allowed.
161
- * @param readonlyConnectionReason - reason/error if any which lead to using FrozenDeltaStream.
162
- */
163
- constructor(
164
- public readonly storageOnlyReason?: string,
165
- public readonly readonlyConnectionReason?: IConnectionStateChangeReason,
166
- ) {
306
+ public readonly checkpointSequenceNumber?: number | undefined = undefined;
307
+
308
+ constructor(clientId: string, claims: ITokenClaims) {
167
309
  super();
310
+ this.clientId = clientId;
311
+ this.claims = claims;
312
+ // initialClients mirrors clientId so the audience handler observes "self" and
313
+ // transitions the container to Connected without waiting for a real join op or signal.
314
+ this.initialClients = [{ client: clientFrozenDeltaStream, clientId }];
168
315
  }
316
+
169
317
  submit(messages: IDocumentMessage[]): void {
318
+ // Defensive nack: nothing should send on a frozen delta stream. If this fires, an
319
+ // invariant in connectionManager has changed and we want it to surface loudly.
170
320
  this.emit(
171
321
  "nack",
172
322
  this.clientId,
173
- messages.map((operation) => {
174
- return {
175
- operation,
176
- content: { message: "Cannot submit with storage-only connection", code: 403 },
177
- };
178
- }),
323
+ messages.map((operation) => ({
324
+ operation,
325
+ content: { message: "Cannot submit on a frozen delta stream", code: 403 },
326
+ })),
179
327
  );
180
328
  }
181
- submitSignal(message: unknown): void {
182
- this.emit("nack", this.clientId, {
183
- operation: message,
184
- content: { message: "Cannot submit signal with storage-only connection", code: 403 },
185
- });
329
+
330
+ submitSignal(_message: unknown): void {
331
+ // Intentional no-op. See class JSDoc for rationale.
186
332
  }
187
333
 
188
334
  private _disposed = false;
@@ -193,8 +339,83 @@ export class FrozenDeltaStream
193
339
  this._disposed = true;
194
340
  }
195
341
  }
342
+
343
+ /**
344
+ * Read-only variant of {@link FrozenDeltaStreamBase}. Claims show only `DocRead`. Used by
345
+ * storage-only loads (where `connectionManager` synthesizes one directly via
346
+ * `policies.storageOnly`) and by the forbidden / out-of-storage fallback paths.
347
+ * {@link isFrozenDeltaStreamConnection} matches this variant and drives the read-only forcing
348
+ * in `ConnectionManager.readOnlyInfo`. Uses the historical `"storage-only client"` constant
349
+ * `clientId`, preserving existing behavior for any consumer that keys off it.
350
+ *
351
+ * `storageOnlyReason` and `readonlyConnectionReason` are surfaced through `IContainer.readOnlyInfo`
352
+ * for diagnostics on the fallback paths (`isDeltaStreamConnectionForbiddenError`,
353
+ * `outOfStorageError`).
354
+ */
355
+ export class FrozenDeltaStream extends FrozenDeltaStreamBase {
356
+ public readonly storageOnlyReason: string | undefined;
357
+ public readonly readonlyConnectionReason: IConnectionStateChangeReason | undefined;
358
+
359
+ constructor(options?: {
360
+ storageOnlyReason?: string;
361
+ readonlyConnectionReason?: IConnectionStateChangeReason;
362
+ }) {
363
+ // Constant clientId: preserves the pre-PR `"storage-only client"` identity for any
364
+ // consumer that keys off it. The 0x173 replay-assert risk that motivates per-instance
365
+ // clientIds applies only to the writable variant, where the runtime accumulates dirty
366
+ // pending ops across reconnects; the read-only variant does not.
367
+ super(clientIdFrozenDeltaStream, readOnlyClaims);
368
+ this.storageOnlyReason = options?.storageOnlyReason;
369
+ this.readonlyConnectionReason = options?.readonlyConnectionReason;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Variant of {@link FrozenDeltaStreamBase} that appears to support writing but remains
375
+ * "frozen" — no messages are actually sent or received. The stream itself does not enforce
376
+ * the no-send guarantee; that lives in `ConnectionManager.sendMessages`, which recognizes
377
+ * any `WritableFrozenDeltaStream` (via {@link isWritableFrozenDeltaStreamConnection}) and
378
+ * short-circuits before its read-mode upgrade branch. Submitted ops are dropped at the
379
+ * connection-manager layer, so the container stays `Connected` and the runtime accumulates
380
+ * them in `pendingStateManager`.
381
+ *
382
+ * "Appears writable" mechanics: claims include `DocWrite` so the container surfaces as
383
+ * writable; not matched by {@link isFrozenDeltaStreamConnection}, so `readOnlyInfo` reports
384
+ * `readonly: false`. Connection mode stays `"read"` (advertising `"write"` would imply quorum
385
+ * membership we cannot honor).
386
+ *
387
+ * Each instance mints a fresh `frozen-delta-stream/<uuid>` `clientId` to avoid
388
+ * `pendingStateManager` `0x173` (`replayPendingStates called twice for same clientId!`) on
389
+ * reconnect with dirty pending ops. Sibling (not subclass) of `FrozenDeltaStream` so
390
+ * `instanceof` cleanly distinguishes the two for `ConnectionManager`'s short-circuits.
391
+ */
392
+ export class WritableFrozenDeltaStream extends FrozenDeltaStreamBase {
393
+ constructor() {
394
+ super(`frozen-delta-stream/${uuid()}`, writableClaims);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Recognizes the read-only variant of {@link FrozenDeltaStreamBase}. Drives the storage-only
400
+ * forcing in `ConnectionManager.readOnlyInfo`: only the read-only variant should make the
401
+ * container surface as read-only. {@link WritableFrozenDeltaStream} is a sibling class, not
402
+ * a subclass, so `instanceof FrozenDeltaStream` already excludes it.
403
+ */
196
404
  export function isFrozenDeltaStreamConnection(
197
405
  connection: unknown,
198
406
  ): connection is FrozenDeltaStream {
199
407
  return connection instanceof FrozenDeltaStream;
200
408
  }
409
+
410
+ /**
411
+ * Recognizes the writable variant of {@link FrozenDeltaStreamBase}. Drives the
412
+ * `ConnectionManager.sendMessages` short-circuit: writable-frozen submits must be dropped at
413
+ * the network layer instead of triggering a read→write reconnect. Sibling (not subclass) of
414
+ * {@link FrozenDeltaStream}, so `instanceof WritableFrozenDeltaStream` excludes the read-only
415
+ * variant.
416
+ */
417
+ export function isWritableFrozenDeltaStreamConnection(
418
+ connection: unknown,
419
+ ): connection is WritableFrozenDeltaStream {
420
+ return connection instanceof WritableFrozenDeltaStream;
421
+ }
package/src/index.ts CHANGED
@@ -7,11 +7,13 @@ export { ConnectionState } from "./connectionState.js";
7
7
  export { type ContainerAlpha, waitContainerToCatchUp, asLegacyAlpha } from "./container.js";
8
8
  export { createFrozenDocumentServiceFactory } from "./frozenServices.js";
9
9
  export {
10
+ captureFullContainerState,
10
11
  createDetachedContainer,
11
12
  loadExistingContainer,
12
13
  rehydrateDetachedContainer,
13
14
  loadFrozenContainerFromPendingState,
14
15
  loadSummarizerContainerAndMakeSummary,
16
+ type ICaptureFullContainerStateProps,
15
17
  type ICreateAndLoadContainerProps,
16
18
  type ICreateDetachedContainerProps,
17
19
  type ILoadExistingContainerProps,
@@ -55,3 +57,8 @@ export type {
55
57
  QuorumProposalsSnapshot,
56
58
  } from "./protocol/index.js";
57
59
  export { PendingLocalStateStore } from "./pendingLocalStateStore.js";
60
+ export {
61
+ extractBlobAttachReferences,
62
+ wireFormatConstants,
63
+ type IBlobAttachReference,
64
+ } from "./captureReferencedContents.js";
package/src/loader.ts CHANGED
@@ -26,13 +26,15 @@ import type {
26
26
  IUrlResolver,
27
27
  } from "@fluidframework/driver-definitions/internal";
28
28
  import {
29
- type ITelemetryLoggerExt,
30
29
  type MonitoringContext,
31
30
  PerformanceEvent,
32
31
  createChildMonitoringContext,
33
32
  mixinMonitoringContext,
34
33
  sessionStorageConfigProvider,
34
+ toITelemetryLoggerExt,
35
35
  } from "@fluidframework/telemetry-utils/internal";
36
+ // eslint-disable-next-line import-x/no-internal-modules -- Needed to avoid specialized /internal ITelemetryLoggerExt
37
+ import type { ITelemetryLoggerExt } from "@fluidframework/telemetry-utils/legacy";
36
38
  import { v4 as uuid } from "uuid";
37
39
 
38
40
  import { Container } from "./container.js";
@@ -268,7 +270,7 @@ export class Loader implements IHostLoader {
268
270
  scope:
269
271
  options?.provideScopeLoader === false ? { ...scope } : { ...scope, ILoader: this },
270
272
  protocolHandlerBuilder,
271
- subLogger: subMc.logger,
273
+ subLogger: toITelemetryLoggerExt(subMc.logger),
272
274
  };
273
275
  this.mc = createChildMonitoringContext({
274
276
  logger: this.services.subLogger,
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  export const pkgName = "@fluidframework/container-loader";
9
- export const pkgVersion = "2.93.0";
9
+ export const pkgVersion = "2.101.0";
@@ -45,6 +45,7 @@ export class PendingLocalStateStore<TKey> {
45
45
  readonly #pendingStates = new Map<TKey, IPendingContainerState>();
46
46
  readonly #savedOps: Record<number, ISequencedDocumentMessage> = {};
47
47
  readonly #blobs: Record<string, string> = {};
48
+ readonly #attachmentBlobs: Record<string, string> = {};
48
49
  readonly #loadingGroups: Record<string, SerializedSnapshotInfo> = {};
49
50
 
50
51
  /**
@@ -92,7 +93,8 @@ export class PendingLocalStateStore<TKey> {
92
93
  */
93
94
  set(key: TKey, pendingLocalState: string): this {
94
95
  const state = getAttachedContainerStateFromSerializedContainer(pendingLocalState);
95
- const { savedOps, snapshotBlobs, loadedGroupIdSnapshots, url } = state;
96
+ const { savedOps, snapshotBlobs, attachmentBlobContents, loadedGroupIdSnapshots, url } =
97
+ state;
96
98
 
97
99
  // Normalize URL by removing trailing slash for comparison
98
100
  const normalizedUrl = url.replace(/\/$/, "");
@@ -108,6 +110,11 @@ export class PendingLocalStateStore<TKey> {
108
110
  for (const [id, blob] of Object.entries(snapshotBlobs)) {
109
111
  snapshotBlobs[id] = this.#blobs[id] ??= blob;
110
112
  }
113
+ if (attachmentBlobContents !== undefined) {
114
+ for (const [id, blob] of Object.entries(attachmentBlobContents)) {
115
+ attachmentBlobContents[id] = this.#attachmentBlobs[id] ??= blob;
116
+ }
117
+ }
111
118
  if (loadedGroupIdSnapshots !== undefined) {
112
119
  for (const [id, lg] of Object.entries(loadedGroupIdSnapshots)) {
113
120
  if (
@@ -30,6 +30,7 @@ export class RetriableDocumentStorageService implements IDocumentStorageService,
30
30
  constructor(
31
31
  private readonly internalStorageServiceP: Promise<IDocumentStorageService>,
32
32
  private readonly logger: ITelemetryLoggerExt,
33
+ private readonly maxRetries?: number,
33
34
  ) {
34
35
  this.internalStorageServiceP
35
36
  .then((s) => (this.internalStorageService = s))
@@ -167,9 +168,15 @@ export class RetriableDocumentStorageService implements IDocumentStorageService,
167
168
  }
168
169
 
169
170
  private async runWithRetry<T>(api: () => Promise<T>, callName: string): Promise<T> {
170
- return runWithRetry(api, callName, this.logger, {
171
- onRetry: (_delayInMs: number, error: unknown) =>
172
- this.checkStorageDisposed(callName, error),
173
- });
171
+ return runWithRetry(
172
+ api,
173
+ callName,
174
+ this.logger,
175
+ {
176
+ onRetry: (_delayInMs: number, error: unknown) =>
177
+ this.checkStorageDisposed(callName, error),
178
+ },
179
+ this.maxRetries,
180
+ );
174
181
  }
175
182
  }
@@ -33,6 +33,7 @@ import {
33
33
  import {
34
34
  getBlobContentsFromTree,
35
35
  type ContainerStorageAdapter,
36
+ type IBase64BlobContents,
36
37
  type ISerializableBlobContents,
37
38
  } from "./containerStorageAdapter.js";
38
39
  import { SnapshotRefresher } from "./snapshotRefresher.js";
@@ -83,6 +84,21 @@ export interface IPendingContainerState extends SnapshotWithBlobs {
83
84
  * Any group snapshots (aka delay-loaded) we've downloaded from the service for this container
84
85
  */
85
86
  loadedGroupIdSnapshots?: Record<string, SerializedSnapshotInfo>;
87
+ /**
88
+ * Attachment blob contents inlined by storage id, encoded as base64.
89
+ *
90
+ * Carried separately from {@link SnapshotWithBlobs.snapshotBlobs} because
91
+ * attachment blobs may contain arbitrary binary payloads, and the
92
+ * UTF-8 encoding used for `snapshotBlobs` (which holds JSON/text the
93
+ * runtime authors) would corrupt non-UTF-8 byte sequences with
94
+ * replacement characters. Populated by `captureFullContainerState`; the
95
+ * live container's pending-state path leaves this `undefined` because
96
+ * it does not inline attachment blob contents.
97
+ *
98
+ * On load, entries are decoded from base64 and merged into the same
99
+ * blob cache that `snapshotBlobs` populates.
100
+ */
101
+ attachmentBlobContents?: IBase64BlobContents;
86
102
  /**
87
103
  * All ops since base snapshot sequence number up to the latest op
88
104
  * seen when the container was closed. Used to apply stashed (saved pending)
@@ -265,11 +281,22 @@ export class SerializedStateManager implements IDisposable {
265
281
  }
266
282
  return { snapshot, version, attributes };
267
283
  } else {
268
- const { baseSnapshot, snapshotBlobs, savedOps } = pendingLocalState;
284
+ const { baseSnapshot, snapshotBlobs, attachmentBlobContents, savedOps } =
285
+ pendingLocalState;
269
286
  const blobContents = new Map<string, ArrayBuffer>();
287
+ // Structural snapshot blobs (snapshot trees, `.attributes`, `.redirectTable`)
288
+ // are JSON/text the runtime authored, so UTF-8 round-trip is lossless.
270
289
  for (const [id, value] of Object.entries(snapshotBlobs)) {
271
290
  blobContents.set(id, stringToBuffer(value, "utf8"));
272
291
  }
292
+ // Attachment blobs are base64-encoded — see IPendingContainerState
293
+ // docs. Decoded after structural blobs because storage-id collisions
294
+ // between the two namespaces should resolve to the binary form.
295
+ if (attachmentBlobContents !== undefined) {
296
+ for (const [id, value] of Object.entries(attachmentBlobContents)) {
297
+ blobContents.set(id, stringToBuffer(value, "base64"));
298
+ }
299
+ }
273
300
  this.storageAdapter.cacheSnapshotBlobs(blobContents);
274
301
  const attributes = await getDocumentAttributes(this.storageAdapter, baseSnapshot);
275
302