Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3d57157
feat(container-loader): add readOnly option to loadFrozenContainerFro…
anthony-murphy Apr 30, 2026
2991cf6
add changeset and regenerate api-report
anthony-murphy Apr 30, 2026
8f2774f
fix biome formatting and unresolved @link references
anthony-murphy Apr 30, 2026
e771980
fix(container-loader): address frozen-container review findings
anthony-murphy Apr 30, 2026
fb6a0fd
fix(container-loader): hand out writable surface on forced-write init…
anthony-murphy Apr 30, 2026
f4a202e
test(container-loader): cover dispose/close while frozen upgrade-conn…
anthony-murphy May 1, 2026
a08917f
fix(test): drop redundant <void> type argument on timeoutPromise calls
anthony-murphy May 1, 2026
3e767b8
fix(test): replace disconnected-event wait with bounded sleep in disp…
anthony-murphy May 1, 2026
7c31d95
polish(container-loader): batch deferred polish from frozen-container…
anthony-murphy May 1, 2026
ff37f19
fix(container-loader): short-circuit sendMessages for FrozenDeltaStre…
anthony-murphy May 1, 2026
411863f
test(container-loader): restore dispose-rejecter coverage; align docs…
anthony-murphy May 1, 2026
d1fd645
fix(test): use Error|undefined type to satisfy TS2358 on rejection check
anthony-murphy May 1, 2026
7866ef5
fix(test): isolate strictEqual narrowing on rejection check
anthony-murphy May 1, 2026
cdcf203
fix(test): rename catch parameters to satisfy unicorn/catch-error-name
anthony-murphy May 1, 2026
849c0ed
fix(container-loader): mint per-instance clientId for FrozenDeltaStream
anthony-murphy May 2, 2026
ce7f4ed
polish(container-loader): narrow sendMessages short-circuit; pin per-…
anthony-murphy May 2, 2026
e93eb26
Merge branch 'main' into users/anthonm/frozen-container-readonly-option
anthony-murphy May 4, 2026
ac44bd1
refactor(container-loader): split FrozenDeltaStream into read-only/wr…
anthony-murphy May 4, 2026
ebb5b29
test(container-loader): add forceReadonly × writable-frozen pinning t…
anthony-murphy May 5, 2026
8f15eef
refactor(container-loader): preserve read-only clientId; localize per…
anthony-murphy May 5, 2026
86c509a
Merge branch 'main' into users/anthonm/frozen-container-readonly-option
anthony-murphy May 5, 2026
2035078
Merge branch 'main' into users/anthonm/frozen-container-readonly-option
anthony-murphy May 5, 2026
4ddd5c1
Merge branch 'main' into users/anthonm/frozen-container-readonly-option
anthony-murphy May 6, 2026
a3360ee
test(container-loader): replace fixed timeouts with deterministic wai…
anthony-murphy May 6, 2026
102c584
fix(test): collapse Promise.race new Promise to one line for biome
anthony-murphy May 6, 2026
87fe785
chore: remove changeset
anthony-murphy May 8, 2026
dfcb644
fix(container-loader): throw on read-only FrozenDocumentService.conne…
anthony-murphy May 8, 2026
78c9ef4
docs(container-loader): wrap long JSDoc bullets to a sane width
anthony-murphy May 8, 2026
731aaea
docs: rename NoDeltaStream → FrozenDeltaStream in comments and test n…
anthony-murphy May 8, 2026
e8dd9b9
docs(container-loader): lead WritableFrozenDeltaStream JSDoc with "ap…
anthony-murphy May 8, 2026
7422e30
refactor(container-loader): rename readOnly → allowLocalChanges (posi…
anthony-murphy May 8, 2026
7312def
test(container-loader): drop vacuous disconnect/reconnect integration…
anthony-murphy May 8, 2026
858990e
Revert "refactor(container-loader): rename readOnly → allowLocalChang…
anthony-murphy May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/frozen-container-readonly-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@fluidframework/container-loader": minor
"__section": feature
---
Add readOnly option to loadFrozenContainerFromPendingState

Check failure on line 5 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'readOnly'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'readOnly'?", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 5, "column": 5}}}, "severity": "ERROR"}

`loadFrozenContainerFromPendingState` and `createFrozenDocumentServiceFactory` now accept an optional `readOnly` parameter (default `true`, preserving existing behavior).

When `readOnly: false`, the frozen container loads as writable so the runtime accepts DDS submissions. The first runtime submit triggers an internal read→write upgrade attempt that cannot succeed (no upstream, no quorum join op), so the container settles into `Disconnected`. DDS local apply continues, and submitted ops accumulate in the runtime's pending-state manager — this is the state needed to accrue and capture additional pending state without publishing it.

Check failure on line 9 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.Dashes] Remove the spaces around ' — '. Raw Output: {"message": "[Microsoft.Dashes] Remove the spaces around ' — '.", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 9, "column": 371}}}, "severity": "ERROR"}

Check failure on line 9 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'runtime's'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'runtime's'?", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 9, "column": 340}}}, "severity": "ERROR"}

Check warning on line 9 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.Acronyms] 'DDS' has no definition. Raw Output: {"message": "[Microsoft.Acronyms] 'DDS' has no definition.", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 9, "column": 277}}}, "severity": "INFO"}

Check warning on line 9 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.Acronyms] 'DDS' has no definition. Raw Output: {"message": "[Microsoft.Acronyms] 'DDS' has no definition.", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 9, "column": 87}}}, "severity": "INFO"}

Use `readOnly: false` when the caller wants to load a frozen container, apply additional local changes, and capture the resulting pending state via `getPendingLocalState()`.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface ContainerAlpha extends IContainer {
export function createDetachedContainer(createDetachedContainerProps: ICreateDetachedContainerProps): Promise<IContainer>;

// @alpha @legacy
export function createFrozenDocumentServiceFactory(factory?: IDocumentServiceFactory | Promise<IDocumentServiceFactory>): IDocumentServiceFactory;
export function createFrozenDocumentServiceFactory(factory?: IDocumentServiceFactory | Promise<IDocumentServiceFactory>, readOnly?: boolean): IDocumentServiceFactory;

// @beta @legacy (undocumented)
export interface IBaseProtocolHandler {
Expand Down Expand Up @@ -106,6 +106,7 @@ export interface ILoadExistingContainerProps extends ICreateAndLoadContainerProp
// @alpha @legacy
export interface ILoadFrozenContainerFromPendingStateProps extends ILoadExistingContainerProps {
readonly pendingLocalState: string;
readonly readOnly?: boolean;
}

// @alpha @legacy
Expand Down
11 changes: 5 additions & 6 deletions packages/loader/container-loader/src/connectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,9 +582,9 @@ export class ConnectionManager implements IConnectionManager {
LogLevel.verbose,
);
if (isDeltaStreamConnectionForbiddenError(origError)) {
connection = new FrozenDeltaStream(origError.storageOnlyReason, {
text: origError.message,
error: origError,
connection = new FrozenDeltaStream({
storageOnlyReason: origError.storageOnlyReason,
readonlyConnectionReason: { text: origError.message, error: origError },
});
requestedMode = "read";
break;
Expand All @@ -594,9 +594,8 @@ export class ConnectionManager implements IConnectionManager {
) {
// If we get out of storage error from calling joinsession, then use the NoDeltaStream object so
Comment thread
markfields marked this conversation as resolved.
Outdated
// that user can at least load the container.
connection = new FrozenDeltaStream(undefined, {
text: origError.message,
error: origError,
connection = new FrozenDeltaStream({
readonlyConnectionReason: { text: origError.message, error: origError },
});
requestedMode = "read";
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,21 @@ export interface ILoadFrozenContainerFromPendingStateProps
* Pending local state to be applied to the container.
*/
readonly pendingLocalState: string;

/**
* Controls whether the frozen container is surfaced as read-only.
*
* Defaults to `true`. When `true`, the container reports `readOnlyInfo.readonly === true`
* with `storageOnly === true`, matching the historical behavior of frozen loads.
*
* When `false`, the container loads as writable so the runtime will accept DDS submissions.
* The first submission triggers an internal read→write upgrade attempt that cannot succeed
* (no upstream, no quorum join op), so the container settles into a `Disconnected` state.
* Local DDS state continues to update via optimistic apply, and submitted ops accumulate in
* the runtime's pending-state manager. Use this when callers want to accrue and capture
* pending state without publishing it.
*/
readonly readOnly?: boolean;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term "readonly" typically makes me think of a state where you can't write but you can receive remote changes.

I'd call the parameter "allowLocalChanges" (defaulting to false).

Copy link
Copy Markdown
Member

@markfields markfields May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or if you want negative polarity (I just read the remarks), then disallowLocalChanges...?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7422e300f5. Renamed to allowLocalChanges with default false (positive opt-in — caller passes allowLocalChanges: true to opt into the writable-frozen behavior; preserves the previous default behavior of "no local writes" when no arg is passed). Updated the props interface, factory function, internal class fields, JSDoc, comments, tests, and the API report.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with positive allowLocalChanges (default false) over negative disallowLocalChanges (default true) — the double-negative in disallowLocalChanges: false is harder to parse than allowLocalChanges: true. The earlier "negative polarity" rationale (alignment with IContainer.readOnlyInfo.readonly) was specific to the readOnly name; once the name itself was wrong, that justification dropped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On reflection, going back to readOnly?: boolean (default true) in 858990ee5b. Rationale: readOnly is the established state name across the container, runtime, and datastore layers — IContainer.readOnlyInfo.readonly, the readonly signals on the runtime and datastore, and version-history / storage-only mode all use the same word for the same concept. It determines whether ops are allowed; matching that convention here keeps the surface aligned rather than introducing a parallel name for the same idea.

The allowLocalChanges framing is more literal about what the flag toggles in this specific path, but the cost is splitting readers between two conventions for "this thing controls whether ops can be submitted." For consistency with the rest of the stack I'd rather keep readOnly.

}

/**
Expand All @@ -241,7 +256,10 @@ export async function loadFrozenContainerFromPendingState(
): Promise<IContainer> {
return loadExistingContainer({
...props,
documentServiceFactory: createFrozenDocumentServiceFactory(props.documentServiceFactory),
documentServiceFactory: createFrozenDocumentServiceFactory(
props.documentServiceFactory,
props.readOnly,
),
});
}

Expand Down
216 changes: 161 additions & 55 deletions packages/loader/container-loader/src/frozenServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,36 +30,53 @@ import {
import type { IConnectionStateChangeReason } from "./contracts.js";

/**
* Creation of a FrozenDocumentServiceFactory which wraps an existing
* DocumentServiceFactory to provide a storage-only document service.
* Creates an `IDocumentServiceFactory` that produces a "frozen" document service: one whose
* delta stream never sends or receives ops, and whose storage service only supports
* `IDocumentStorageService.readBlob`. Used to load a container from pending local state
* without re-establishing a live connection.
*
* @param documentServiceFactory - The underlying DocumentServiceFactory to wrap.
* @returns A FrozenDocumentServiceFactory
* @param factory - The underlying factory to wrap. Its storage backs blob reads; all other
* storage operations throw. May be omitted when blob fetches are not required.
* @param readOnly - When `true` (the default), the document service advertises the
* `IDocumentServicePolicies.storageOnly` policy, which causes the loader to surface the
* container as read-only (see `IContainer.readOnlyInfo`).
*
* When `false`, the container is loaded as writable so the runtime will accept DDS submissions.
* The first such submission triggers the connectionManager's read→write upgrade attempt. Since
* there is no real upstream and we will not fabricate a quorum join op, that upgrade hangs and
* the container settles into a `Disconnected` state. Local DDS state continues to update via
* optimistic apply, and submitted ops accumulate in the runtime's pending-state manager — which
* is exactly the state needed to capture pending local state. Use `false` when callers want to
* accrue and capture pending state without publishing it.
* @returns A factory that produces frozen document services.
* @legacy @alpha
*/
export function createFrozenDocumentServiceFactory(
factory?: IDocumentServiceFactory | Promise<IDocumentServiceFactory>,
readOnly: boolean = true,
): IDocumentServiceFactory {
// Sync path
return factory instanceof FrozenDocumentServiceFactory
? factory
: new FrozenDocumentServiceFactory(factory);
if (factory instanceof FrozenDocumentServiceFactory) {
// Already wrapped. Reuse if readOnly matches; otherwise unwrap and rewrap so the caller's
// most recent readOnly intent wins (silently honoring caller intent rather than dropping
// the new argument).
return factory.readOnly === readOnly
? factory
: new FrozenDocumentServiceFactory(readOnly, factory.inner);
}
return new FrozenDocumentServiceFactory(readOnly, factory);
}

export class FrozenDocumentServiceFactory implements IDocumentServiceFactory {
constructor(
private readonly documentServiceFactory?:
| IDocumentServiceFactory
| Promise<IDocumentServiceFactory>,
public readonly readOnly: boolean,
public readonly inner?: IDocumentServiceFactory | Promise<IDocumentServiceFactory>,
) {}

async createDocumentService(resolvedUrl: IResolvedUrl): Promise<IDocumentService> {
let factory = this.documentServiceFactory;
if (isPromiseLike(factory)) {
factory = await this.documentServiceFactory;
}
const factory = isPromiseLike(this.inner) ? await this.inner : this.inner;
return new FrozenDocumentService(
resolvedUrl,
this.readOnly,
await factory?.createDocumentService(resolvedUrl),
);
}
Expand All @@ -72,26 +89,80 @@ class FrozenDocumentService
extends TypedEventEmitter<IDocumentServiceEvents>
implements IDocumentService
{
private disposed = false;
private handedOutInitialConnection = false;
private readonly pendingConnectRejecters = new Set<(reason: Error) => void>();

constructor(
public readonly resolvedUrl: IResolvedUrl,
private readonly readOnly: boolean,
private readonly documentService?: IDocumentService,
) {
super();
// When readOnly, advertise the storageOnly policy. The connectionManager short-circuits
// on it: it synthesizes a FrozenDeltaStream itself and never calls
// connectToDeltaStream, and the readOnlyInfo getter forces the container to read-only
// because the live connection is a FrozenDeltaStream.
this.policies = readOnly ? { storageOnly: true } : {};
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep Review: Writable-frozen loads with pendingAttachmentBlobs in pending state will close or hang the container via BlobManager.sharePendingBlobs.

New correctness gap on a path the prior reviews didn't trace. Walking it end-to-end:

  1. this.policies = readOnly ? { storageOnly: true } : {} (this line) — the writable-frozen variant has empty policies, so Container reports connected && readOnlyInfo.readonly !== true.
  2. ContainerRuntime.loadRuntime (containerRuntime.ts:1299) always calls runtime.sharePendingBlobs() after load. The implementation (containerRuntime.ts:5331-5356) proceeds when connected and not readonly, and routes errors to closeFn via .catch(this.closeFn).
  3. For pending state containing pendingAttachmentBlobs, BlobManager.sharePendingBlobs() either:
    • calls storage.createBlob() — which the frozen storage wrapper at frozenServices.ts:151 is wired to throw on. The error propagates to closeFn and closes the container, or
    • sends a blob-attach op via this.submit (containerRuntime.ts:2014-2023). The op then hits ConnectionManager.sendMessages whose new isWritableFrozenDeltaStreamConnection short-circuit (connectionManager.ts:1111-1112) drops it. BlobManager waits indefinitely for a processed BlobAttach (blobManager.ts:666-703), so the attach promise never resolves and the load hangs.

Both behaviors violate the PR's stated invariant that writable-frozen loads "do not publish anything" and that storage operations remain unsupported.

The new writable-frozen suite (loadFrozenContainerFromPendingState.spec.ts:328-593) covers DDS writes, signal drops, pending-op round-trip, rewrap, and reconnect, but no test constructs pending state with pendingAttachmentBlobs — so this path is uncovered.

Suggested options:

  1. Suppress sharePendingBlobs for frozen document services even in writable mode — e.g. via an additional policy flag distinct from storageOnly (so connectToDeltaStream still runs but sharePendingBlobs does not).
  2. Short-circuit BlobManager.sharePendingBlobs when the storage's createBlob is the throw-handler.
  3. If supporting pending state containing pendingAttachmentBlobs is out of scope, fail fast at load time with a diagnosable error rather than relying on the silent drop / closeFn path.

Whichever you pick, please add a regression test in the writable-frozen suite that loads from pending state containing pendingAttachmentBlobs with readOnly: false and asserts the load resolves without closing the container and without hanging.

Open design questions:

  • For writable-frozen with pendingAttachmentBlobs, what is the intended behavior — suppress sharePendingBlobs, fail fast, or out-of-scope?
  • If sharePendingBlobs is intentionally allowed to run, how should the dropped blob-attach ops in the writable-frozen sendMessages path resolve their attach promises (or surface a diagnosable failure) rather than hanging indefinitely?

}

public readonly policies: IDocumentServicePolicies = {
storageOnly: true,
};
public readonly policies: IDocumentServicePolicies;
async connectToStorage(): Promise<IDocumentStorageService> {
return new FrozenDocumentStorageService(await this.documentService?.connectToStorage());
}
async connectToDeltaStorage(): Promise<IDocumentDeltaStorageService> {
return frozenDocumentDeltaStorageService;
}
async connectToDeltaStream(client: IClient): Promise<IDocumentDeltaConnection> {
return new FrozenDeltaStream();
if (this.readOnly) {
// connectionManager short-circuits via policies.storageOnly before reaching here in
// the read-only path; this is a defensive fallback.
Comment thread
markfields marked this conversation as resolved.
Outdated
return new FrozenDeltaStream();
}
// Distinguish the initial connect from the runtime-driven read→write upgrade. Both can
// arrive with `client.mode === "write"`: the upgrade case follows the first runtime
// submit (sendMessages sees connectionMode === "read" and triggers reconnectOnError on
// "write"); the initial-connect case happens when Container.connectToDeltaStream forces
// mode = "write" because allowReconnect is false or the client is non-interactive.
// Mode alone can't tell them apart, so track whether we've already handed out a stream.
if (!this.handedOutInitialConnection) {
// First connect: hand out the writable-surface FrozenDeltaStream regardless of
// `client.mode`. DocWrite scope + not matched by isFrozenDeltaStreamConnection, so
// readOnlyInfo reports `readonly: false` and the runtime will accept DDS submissions.
this.handedOutInitialConnection = true;
return new FrozenDeltaStream({ readOnly: false });
}
if (client.mode !== "write") {
Comment thread
anthony-murphy marked this conversation as resolved.
Outdated
Comment thread
anthony-murphy marked this conversation as resolved.
Outdated
// Subsequent connect in read mode (e.g. reconnect after a forced disconnect). Hand
// out another writable stream so the container can re-establish.
return new FrozenDeltaStream({ readOnly: false });
Comment thread
anthony-murphy marked this conversation as resolved.
Outdated
}
// Subsequent connect in write mode: this is the read→write upgrade attempt. We can't
// honor it — there's no upstream and we won't fabricate a quorum join op. Hang the
// promise. The container settles into Disconnected (Connected → reconnecting → never
// resolves), DDS local apply continues to work, and submitted ops accumulate in the
// runtime's pendingStateManager (the outbox sees shouldSend() return false and skips
// actual send). That's the right representation for "load to accrue and capture pending
// state without publishing".
return new Promise<IDocumentDeltaConnection>((_, reject) => {
if (this.disposed) {
reject(new Error("FrozenDocumentService disposed"));
return;
}
this.pendingConnectRejecters.add(reject);
});
}
dispose(): void {
this.disposed = true;
// Unblock any hung connect attempts so connectCore can exit cleanly. Without this,
// container.dispose() leaves the connectionManager's connect loop awaiting a promise
// that never resolves until garbage collection cleans up the closure.
const rejecters = [...this.pendingConnectRejecters];
this.pendingConnectRejecters.clear();
for (const reject of rejecters) {
reject(new Error("FrozenDocumentService disposed"));
}
}
dispose(): void {}
}

const frozenDocumentStorageServiceHandler = (): never => {
Expand Down Expand Up @@ -129,60 +200,89 @@ const clientFrozenDeltaStream: IClient = {
const clientIdFrozenDeltaStream: string = "storage-only client";

/**
* Implementation of IDocumentDeltaConnection that does not support submitting
* or receiving ops. Used in storage-only mode and in frozen loads.
* Inert `IDocumentDeltaConnection` for frozen container loads. Has no server upstream:
* op and signal streams are empty, and `initialClients` contains only its own synthetic
* read-only client — which lets the connection state handler observe "self" in the audience
* and transition the container to Connected without waiting for a real join op or signal.
*
Comment thread
markfields marked this conversation as resolved.
* Two variants, selected via `options.readOnly` (default `true`):
*
* - **Read-only (default)** — claims show only `DocRead`. Used by storage-only loads (where connectionManager synthesizes one directly via `policies.storageOnly`) and by the forbidden / out-of-storage fallback paths. {@link isFrozenDeltaStreamConnection} matches this variant and drives the read-only forcing in `ConnectionManager.readOnlyInfo`.
* - **Writable (`{ readOnly: false }`)** — claims include `DocWrite` so the container surfaces as writable; not matched by `isFrozenDeltaStreamConnection`, so `readOnlyInfo` reports `readonly: false`. Connection mode stays `"read"`: advertising `"write"` would imply quorum membership, which we cannot honor. The connectionManager's read→write upgrade attempt that follows the first runtime submit is intercepted in `FrozenDocumentService.connectToDeltaStream` and hung indefinitely; the container then settles into Disconnected.
*
* Both variants nack any incoming `submit`: this connection has no upstream and
* `ConnectionManager.sendMessages` short-circuits read-mode ops to reconnect rather than calling
* `submit`, so under normal flow it should never fire. A nack reaching the connectionManager
* surfaces the misuse — and may close the container — which is the right defensive signal that
* something has bypassed the expected flow.
*
* `submitSignal` is a silent no-op for both variants. Signals are ephemeral and best-effort —
* runtime/presence subsystems may submit them at any point in the writable-frozen lifetime, and
* dropping them is the correct behavior here (we have no upstream). Closing the container or
* triggering a reconnect on a stray signal would be strictly worse than dropping it.
*/
export class FrozenDeltaStream
extends TypedEventEmitter<IDocumentDeltaConnectionEvents>
implements IDocumentDeltaConnection, IDisposable
{
clientId = clientIdFrozenDeltaStream;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
claims = {
scopes: [ScopeType.DocRead],
} as ITokenClaims;
mode: ConnectionMode = "read";
existing: boolean = true;
maxMessageSize: number = 0;
version: string = "";
initialMessages: ISequencedDocumentMessage[] = [];
initialSignals: ISignalMessage[] = [];
initialClients: ISignalClient[] = [
public readonly clientId: string = clientIdFrozenDeltaStream;
public readonly claims: ITokenClaims;
public readonly mode: ConnectionMode = "read";
public readonly existing: boolean = true;
public readonly maxMessageSize: number = 0;
public readonly version: string = "";
public readonly initialMessages: ISequencedDocumentMessage[] = [];
public readonly initialSignals: ISignalMessage[] = [];
public readonly initialClients: ISignalClient[] = [
{ client: clientFrozenDeltaStream, clientId: clientIdFrozenDeltaStream },
];
serviceConfiguration: IClientConfiguration = {
public readonly serviceConfiguration: IClientConfiguration = {
maxMessageSize: 0,
blockSize: 0,
};
checkpointSequenceNumber?: number | undefined = undefined;
public readonly checkpointSequenceNumber?: number | undefined = undefined;

public readonly readOnly: boolean;
public readonly storageOnlyReason: string | undefined;
public readonly readonlyConnectionReason: IConnectionStateChangeReason | undefined;

/**
* Connection which is not connected to socket.
* @param storageOnlyReason - Reason on why the connection to delta stream is not allowed.
* @param readonlyConnectionReason - reason/error if any which lead to using FrozenDeltaStream.
* @param options - Configuration:
*
* - `readOnly`: when `true` (the default), claims include only `DocRead` and {@link isFrozenDeltaStreamConnection} matches this instance (forcing the container read-only). When `false`, claims include `DocWrite` and the container surfaces as writable.
* - `storageOnlyReason`: surfaced via `IContainer.readOnlyInfo.storageOnlyReason` for the read-only variant.
* - `readonlyConnectionReason`: error/reason that led to using this stream as a fallback (e.g. forbidden delta stream connection); surfaced via the same readOnlyInfo path.
*/
constructor(
public readonly storageOnlyReason?: string,
public readonly readonlyConnectionReason?: IConnectionStateChangeReason,
) {
constructor(options?: {
readOnly?: boolean;
storageOnlyReason?: string;
readonlyConnectionReason?: IConnectionStateChangeReason;
}) {
super();
this.readOnly = options?.readOnly ?? true;
this.storageOnlyReason = options?.storageOnlyReason;
this.readonlyConnectionReason = options?.readonlyConnectionReason;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
this.claims = {
scopes: this.readOnly ? [ScopeType.DocRead] : [ScopeType.DocRead, ScopeType.DocWrite],
} as ITokenClaims;
}

submit(messages: IDocumentMessage[]): void {
// Defensive nack: nothing should send on a frozen delta stream. If this fires, an
// invariant in connectionManager has changed and we want it to surface loudly.
this.emit(
"nack",
this.clientId,
messages.map((operation) => {
return {
operation,
content: { message: "Cannot submit with storage-only connection", code: 403 },
};
}),
messages.map((operation) => ({
operation,
content: { message: "Cannot submit on a frozen delta stream", code: 403 },
})),
);
}
submitSignal(message: unknown): void {
this.emit("nack", this.clientId, {
operation: message,
content: { message: "Cannot submit signal with storage-only connection", code: 403 },
});

submitSignal(_message: unknown): void {
// Intentional no-op. See class JSDoc for rationale.
}

private _disposed = false;
Expand All @@ -193,8 +293,14 @@ export class FrozenDeltaStream
this._disposed = true;
}
}

/**
* Recognizes the read-only variant of {@link FrozenDeltaStream}. Drives the storage-only forcing
* in `ConnectionManager.readOnlyInfo`: only the read-only variant should make the container
* surface as read-only.
*/
export function isFrozenDeltaStreamConnection(
connection: unknown,
): connection is FrozenDeltaStream {
return connection instanceof FrozenDeltaStream;
return connection instanceof FrozenDeltaStream && connection.readOnly;
}
Loading
Loading