@opengeni/core 0.21.10 → 0.28.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/dist/access/index.d.ts +1 -1
- package/dist/application/new-session-drafts.d.ts +1 -1
- package/dist/application/session-commands.d.ts +2 -2
- package/dist/billing/limits.d.ts +1 -1
- package/dist/canonical-human-identities.d.ts +12 -0
- package/dist/canonical-human-identities.js +23 -0
- package/dist/canonical-human-identities.js.map +1 -0
- package/dist/chunk-6WKPWE5S.js +2380 -0
- package/dist/chunk-6WKPWE5S.js.map +1 -0
- package/dist/chunk-IBOEYG6N.js +34 -0
- package/dist/chunk-IBOEYG6N.js.map +1 -0
- package/dist/chunk-JJU6I5XD.js +2713 -0
- package/dist/chunk-JJU6I5XD.js.map +1 -0
- package/dist/chunk-NYPMQ7NO.js +2270 -0
- package/dist/chunk-NYPMQ7NO.js.map +1 -0
- package/dist/dependencies.d.ts +30 -6
- package/dist/domain/capabilities.d.ts +28 -3
- package/dist/domain/company-profile-durable-learning-adapter.d.ts +39 -0
- package/dist/domain/conversation-integrations.d.ts +225 -0
- package/dist/domain/durable-learning-slack-publication.d.ts +72 -0
- package/dist/domain/editable-artifacts/agent-application.d.ts +192 -0
- package/dist/domain/editable-artifacts/durable-export-postgres.d.ts +4 -0
- package/dist/domain/editable-artifacts/durable-export.d.ts +212 -0
- package/dist/domain/editable-artifacts/errors.d.ts +47 -0
- package/dist/domain/editable-artifacts/hash.d.ts +11 -0
- package/dist/domain/editable-artifacts/in-memory.d.ts +99 -0
- package/dist/domain/editable-artifacts/index.d.ts +14 -0
- package/dist/domain/editable-artifacts/ports.d.ts +341 -0
- package/dist/domain/editable-artifacts/postgres-adapters.d.ts +13 -0
- package/dist/domain/editable-artifacts/service.d.ts +105 -0
- package/dist/domain/editable-artifacts/snapshot-compaction.d.ts +44 -0
- package/dist/domain/editable-artifacts/snapshot-verifier-genesis.d.ts +50 -0
- package/dist/domain/editable-artifacts/snapshot-verifier-materialization.d.ts +21 -0
- package/dist/domain/editable-artifacts/snapshot-verifier.d.ts +118 -0
- package/dist/domain/editable-artifacts/types.d.ts +410 -0
- package/dist/domain/fiken.d.ts +35 -0
- package/dist/domain/memory-slack-delivery.d.ts +19 -0
- package/dist/domain/packs.d.ts +29 -1
- package/dist/domain/personal-connection-delegations.d.ts +9 -1
- package/dist/domain/scheduled-tasks.d.ts +2 -2
- package/dist/domain/session-tool-policy.d.ts +2 -2
- package/dist/domain/sessions.d.ts +24 -8
- package/dist/domain/skill-imports.d.ts +32 -0
- package/dist/domain/slack-publication-secret-safety.d.ts +8 -0
- package/dist/domain/timeline-annotations.d.ts +5 -0
- package/dist/domain/video-generation-capabilities.d.ts +10 -0
- package/dist/domain/video-generation.d.ts +47 -0
- package/dist/editable-artifact-live/application.d.ts +69 -0
- package/dist/editable-artifact-live/errors.d.ts +1 -0
- package/dist/editable-artifact-live/index.d.ts +8 -0
- package/dist/editable-artifact-live/outbox-dispatcher.d.ts +162 -0
- package/dist/editable-artifact-live/ports.d.ts +100 -0
- package/dist/editable-artifact-live/server.d.ts +71 -0
- package/dist/editable-artifact-live/ticket.d.ts +40 -0
- package/dist/editable-artifact-live/types.d.ts +213 -0
- package/dist/editable-artifact-live/wire.d.ts +34 -0
- package/dist/editable-artifact-live.d.ts +2 -0
- package/dist/editable-artifact-live.js +58 -0
- package/dist/editable-artifact-live.js.map +1 -0
- package/dist/editable-artifacts.d.ts +2 -0
- package/dist/editable-artifacts.js +179 -0
- package/dist/editable-artifacts.js.map +1 -0
- package/dist/index.d.ts +37 -26
- package/dist/index.js +4145 -959
- package/dist/index.js.map +1 -1
- package/dist/managed-session.d.ts +6 -2
- package/dist/rigs/index.d.ts +1 -1
- package/dist/rigs/provider-images.d.ts +31 -0
- package/dist/sandbox/fleet.d.ts +8 -5
- package/dist/sandbox/routing.d.ts +1 -0
- package/dist/session-authorization.d.ts +4 -2
- package/dist/transcription.d.ts +5 -0
- package/dist/workflow-wake-contract.d.ts +7 -0
- package/package.json +22 -10
- package/src/access/index.ts +14 -2
- package/src/application/new-session-drafts.ts +2 -0
- package/src/application/session-commands.ts +188 -107
- package/src/canonical-human-identities.ts +27 -0
- package/src/dependencies.ts +34 -2
- package/src/domain/capabilities.ts +729 -408
- package/src/domain/company-profile-durable-learning-adapter.ts +221 -0
- package/src/domain/conversation-integrations.ts +1238 -0
- package/src/domain/durable-learning-slack-publication.ts +202 -0
- package/src/domain/editable-artifacts/agent-application.ts +687 -0
- package/src/domain/editable-artifacts/durable-export-postgres.ts +139 -0
- package/src/domain/editable-artifacts/durable-export.ts +734 -0
- package/src/domain/editable-artifacts/errors.ts +115 -0
- package/src/domain/editable-artifacts/hash.ts +131 -0
- package/src/domain/editable-artifacts/in-memory.ts +1662 -0
- package/src/domain/editable-artifacts/index.ts +14 -0
- package/src/domain/editable-artifacts/ports.ts +462 -0
- package/src/domain/editable-artifacts/postgres-adapters.ts +49 -0
- package/src/domain/editable-artifacts/service.ts +2402 -0
- package/src/domain/editable-artifacts/snapshot-compaction.ts +246 -0
- package/src/domain/editable-artifacts/snapshot-verifier-genesis.ts +235 -0
- package/src/domain/editable-artifacts/snapshot-verifier-materialization.ts +74 -0
- package/src/domain/editable-artifacts/snapshot-verifier.ts +687 -0
- package/src/domain/editable-artifacts/types.ts +835 -0
- package/src/domain/fiken.ts +88 -0
- package/src/domain/insights.ts +163 -50
- package/src/domain/memory-slack-delivery.ts +204 -0
- package/src/domain/memory-slack-publication.ts +5 -2
- package/src/domain/packs.ts +266 -5
- package/src/domain/personal-connection-delegations.ts +97 -6
- package/src/domain/scheduled-tasks.ts +171 -29
- package/src/domain/session-tool-policy.ts +8 -2
- package/src/domain/sessions.ts +395 -148
- package/src/domain/skill-imports.ts +350 -0
- package/src/domain/slack-publication-secret-safety.ts +42 -0
- package/src/domain/timeline-annotations.ts +204 -0
- package/src/domain/video-generation-capabilities.ts +104 -0
- package/src/domain/video-generation.ts +249 -0
- package/src/editable-artifact-live/application.ts +182 -0
- package/src/editable-artifact-live/errors.ts +4 -0
- package/src/editable-artifact-live/index.ts +8 -0
- package/src/editable-artifact-live/outbox-dispatcher.ts +916 -0
- package/src/editable-artifact-live/ports.ts +139 -0
- package/src/editable-artifact-live/server.ts +1707 -0
- package/src/editable-artifact-live/ticket.ts +217 -0
- package/src/editable-artifact-live/types.ts +286 -0
- package/src/editable-artifact-live/wire.ts +95 -0
- package/src/editable-artifact-live.ts +2 -0
- package/src/editable-artifacts.ts +2 -0
- package/src/index.ts +15 -0
- package/src/managed-session.ts +19 -2
- package/src/rigs/index.ts +26 -19
- package/src/rigs/provider-images.ts +110 -0
- package/src/sandbox/fleet.ts +72 -66
- package/src/sandbox/routing.ts +6 -3
- package/src/session-authorization.ts +187 -52
- package/src/transcription.ts +5 -0
- package/src/workflow-wake-contract.ts +8 -0
|
@@ -0,0 +1,1707 @@
|
|
|
1
|
+
import type { EditableArtifactAuthorizationPort } from "../domain/editable-artifacts/ports";
|
|
2
|
+
import type { EditableArtifactService } from "../domain/editable-artifacts/service";
|
|
3
|
+
import {
|
|
4
|
+
EDITABLE_ARTIFACT_COMMAND_MAX_BYTES,
|
|
5
|
+
EDITABLE_ARTIFACT_INTENT_MAX_BYTES,
|
|
6
|
+
EDITABLE_ARTIFACT_KERNEL_VERSION_MAX_BYTES,
|
|
7
|
+
EDITABLE_ARTIFACT_PRODUCT_MAX_SNAPSHOT_BYTES,
|
|
8
|
+
} from "@opengeni/contracts/editable-artifacts";
|
|
9
|
+
import { decodeCommittedTransactionSummary } from "@opengeni/contracts/editable-artifact-committed-transaction";
|
|
10
|
+
import { decodeEditableArtifactSerializedCommit } from "@opengeni/contracts/editable-artifact-serialized-commit";
|
|
11
|
+
import { EDITABLE_ARTIFACT_MAX_COMMITTED_TRANSACTION_BYTES } from "../domain/editable-artifacts/service";
|
|
12
|
+
import {
|
|
13
|
+
assertNonnegativeSafeInteger,
|
|
14
|
+
assertPositiveSafeInteger,
|
|
15
|
+
editableArtifactActorKey,
|
|
16
|
+
editableArtifactCausalFrontier,
|
|
17
|
+
editableArtifactId,
|
|
18
|
+
editableArtifactRequestHash,
|
|
19
|
+
editableArtifactStateHash,
|
|
20
|
+
editableArtifactTransactionId,
|
|
21
|
+
type EditableArtifactActor,
|
|
22
|
+
type EditableArtifactCausalFrontier,
|
|
23
|
+
type EditableArtifactId,
|
|
24
|
+
type EditableArtifactModality,
|
|
25
|
+
type EditableArtifactRequestHash,
|
|
26
|
+
type EditableArtifactScope,
|
|
27
|
+
type EditableArtifactStateHash,
|
|
28
|
+
} from "../domain/editable-artifacts/types";
|
|
29
|
+
import { EditableArtifactLiveError } from "./errors";
|
|
30
|
+
import type {
|
|
31
|
+
EditableArtifactLiveAuthorizationInvalidationPort,
|
|
32
|
+
EditableArtifactLiveClockPort,
|
|
33
|
+
EditableArtifactLiveHintPort,
|
|
34
|
+
EditableArtifactLiveReadPort,
|
|
35
|
+
EditableArtifactLiveSchedulerPort,
|
|
36
|
+
EditableArtifactLiveServerDependencies,
|
|
37
|
+
EditableArtifactLiveSinkPort,
|
|
38
|
+
EditableArtifactLiveTicketStorePort,
|
|
39
|
+
EditableArtifactLiveTokenPort,
|
|
40
|
+
} from "./ports";
|
|
41
|
+
import { EditableArtifactLiveTicketAuthority } from "./ticket";
|
|
42
|
+
import {
|
|
43
|
+
EDITABLE_ARTIFACT_LIVE_PROTOCOL_VERSION,
|
|
44
|
+
type EditableArtifactLiveAppliedClientFrame,
|
|
45
|
+
type EditableArtifactLiveBootstrap,
|
|
46
|
+
type EditableArtifactLiveClientFrame,
|
|
47
|
+
type EditableArtifactLiveClose,
|
|
48
|
+
type EditableArtifactLiveCloseReason,
|
|
49
|
+
type EditableArtifactLiveCommittedTransaction,
|
|
50
|
+
type EditableArtifactLiveMutationClientFrame,
|
|
51
|
+
type EditableArtifactLiveMutationReceipt,
|
|
52
|
+
type EditableArtifactLiveResume,
|
|
53
|
+
type EditableArtifactLiveServerFrame,
|
|
54
|
+
type EditableArtifactLiveSnapshot,
|
|
55
|
+
type EditableArtifactLiveTicket,
|
|
56
|
+
type EditableArtifactLiveTicketRecord,
|
|
57
|
+
} from "./types";
|
|
58
|
+
|
|
59
|
+
const textEncoder = new TextEncoder();
|
|
60
|
+
const strictDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
61
|
+
|
|
62
|
+
// Shared OGATX is bounded at 5 MiB and whole OGACO at 8 MiB. Transport
|
|
63
|
+
// metadata gets a small fixed allowance; valid SDK-authored transactions must
|
|
64
|
+
// never enter a reconnect loop because a lower hidden live limit rejects them.
|
|
65
|
+
const DEFAULT_MAX_CLIENT_FRAME_BYTES = 5 * 1024 * 1024 + 64 * 1024;
|
|
66
|
+
const DEFAULT_MAX_OUTBOUND_FRAME_BYTES = 8 * 1024 * 1024 + 64 * 1024;
|
|
67
|
+
const DEFAULT_MAX_SNAPSHOT_BYTES = EDITABLE_ARTIFACT_PRODUCT_MAX_SNAPSHOT_BYTES;
|
|
68
|
+
const DEFAULT_SNAPSHOT_CHUNK_BYTES = 256 * 1024;
|
|
69
|
+
const DEFAULT_MAX_IN_FLIGHT_TRANSACTIONS = 256;
|
|
70
|
+
const DEFAULT_MAX_IN_FLIGHT_BYTES = 32 * 1024 * 1024;
|
|
71
|
+
const DEFAULT_REPLAY_PAGE_TRANSACTIONS = 256;
|
|
72
|
+
const DEFAULT_REPLAY_PAGE_BYTES = 32 * 1024 * 1024;
|
|
73
|
+
const DEFAULT_MAX_SOCKET_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
74
|
+
const DEFAULT_MAX_INBOUND_QUEUE_FRAMES = 64;
|
|
75
|
+
const DEFAULT_MAX_INBOUND_QUEUE_BYTES = 12 * 1024 * 1024;
|
|
76
|
+
const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
|
|
77
|
+
const DEFAULT_REAUTHORIZE_INTERVAL_MS = 15_000;
|
|
78
|
+
const DEFAULT_ACK_TIMEOUT_MS = 30_000;
|
|
79
|
+
const DEFAULT_SEND_TIMEOUT_MS = 10_000;
|
|
80
|
+
const MAX_IDENTIFIER_BYTES = 512;
|
|
81
|
+
const MAX_CAUSAL_ACTORS = 1_024;
|
|
82
|
+
|
|
83
|
+
export type EditableArtifactLiveServerOptions = Readonly<{
|
|
84
|
+
protocolVersion?: number;
|
|
85
|
+
ticketTtlMs?: number;
|
|
86
|
+
maxClientFrameBytes?: number;
|
|
87
|
+
maxOutboundFrameBytes?: number;
|
|
88
|
+
maxSnapshotBytes?: number;
|
|
89
|
+
snapshotChunkBytes?: number;
|
|
90
|
+
maxInFlightTransactions?: number;
|
|
91
|
+
maxInFlightBytes?: number;
|
|
92
|
+
replayPageTransactions?: number;
|
|
93
|
+
replayPageBytes?: number;
|
|
94
|
+
maxSocketBufferedBytes?: number;
|
|
95
|
+
maxInboundQueueFrames?: number;
|
|
96
|
+
maxInboundQueueBytes?: number;
|
|
97
|
+
reconcileIntervalMs?: number;
|
|
98
|
+
reauthorizeIntervalMs?: number;
|
|
99
|
+
ackTimeoutMs?: number;
|
|
100
|
+
sendTimeoutMs?: number;
|
|
101
|
+
}>;
|
|
102
|
+
|
|
103
|
+
type NormalizedOptions = Required<Omit<EditableArtifactLiveServerOptions, "ticketTtlMs">> &
|
|
104
|
+
Readonly<{ ticketTtlMs?: number }>;
|
|
105
|
+
|
|
106
|
+
export type OpenEditableArtifactLiveInput = Readonly<{
|
|
107
|
+
token: string;
|
|
108
|
+
artifactId: EditableArtifactId;
|
|
109
|
+
protocolVersion: number;
|
|
110
|
+
resume: EditableArtifactLiveResume;
|
|
111
|
+
sink: EditableArtifactLiveSinkPort;
|
|
112
|
+
signal?: AbortSignal;
|
|
113
|
+
}>;
|
|
114
|
+
|
|
115
|
+
export interface EditableArtifactLiveSession {
|
|
116
|
+
readonly artifactId: EditableArtifactId;
|
|
117
|
+
readonly modality: EditableArtifactModality;
|
|
118
|
+
readonly streamEpoch: string;
|
|
119
|
+
readonly closed: Promise<EditableArtifactLiveClose>;
|
|
120
|
+
receive(bytes: Uint8Array): Promise<void>;
|
|
121
|
+
acknowledge(frame: EditableArtifactLiveAppliedClientFrame): Promise<void>;
|
|
122
|
+
submitIntent(
|
|
123
|
+
input: Readonly<{
|
|
124
|
+
protocolVersion: number;
|
|
125
|
+
artifactId: EditableArtifactId;
|
|
126
|
+
streamEpoch: string;
|
|
127
|
+
requestHash: EditableArtifactRequestHash;
|
|
128
|
+
intentBytes: Uint8Array;
|
|
129
|
+
}>,
|
|
130
|
+
): Promise<EditableArtifactLiveMutationReceipt>;
|
|
131
|
+
reconcileNow(): Promise<void>;
|
|
132
|
+
reauthorizeNow(): Promise<void>;
|
|
133
|
+
close(reason?: EditableArtifactLiveCloseReason): Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class EditableArtifactLiveServer {
|
|
137
|
+
private readonly options: NormalizedOptions;
|
|
138
|
+
private readonly ticketAuthority: EditableArtifactLiveTicketAuthority;
|
|
139
|
+
|
|
140
|
+
constructor(
|
|
141
|
+
private readonly dependencies: EditableArtifactLiveServerDependencies,
|
|
142
|
+
options: EditableArtifactLiveServerOptions = {},
|
|
143
|
+
) {
|
|
144
|
+
this.options = normalizeOptions(options);
|
|
145
|
+
this.ticketAuthority = new EditableArtifactLiveTicketAuthority({
|
|
146
|
+
authorization: dependencies.authorization,
|
|
147
|
+
tickets: dependencies.tickets,
|
|
148
|
+
tokens: dependencies.tokens,
|
|
149
|
+
clock: dependencies.clock,
|
|
150
|
+
protocolVersion: this.options.protocolVersion,
|
|
151
|
+
...(this.options.ticketTtlMs === undefined ? {} : { ttlMs: this.options.ticketTtlMs }),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
mintTicket(
|
|
156
|
+
input: Readonly<{
|
|
157
|
+
scope: EditableArtifactScope;
|
|
158
|
+
artifactId: EditableArtifactId;
|
|
159
|
+
modality: EditableArtifactModality;
|
|
160
|
+
actor: EditableArtifactActor;
|
|
161
|
+
allowEdit: boolean;
|
|
162
|
+
}>,
|
|
163
|
+
): Promise<EditableArtifactLiveTicket> {
|
|
164
|
+
return this.ticketAuthority.mint(input);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async openLive(input: OpenEditableArtifactLiveInput): Promise<EditableArtifactLiveSession> {
|
|
168
|
+
const artifactId = editableArtifactId(input.artifactId);
|
|
169
|
+
const ticket = await this.ticketAuthority.consume({
|
|
170
|
+
token: input.token,
|
|
171
|
+
artifactId,
|
|
172
|
+
protocolVersion: input.protocolVersion,
|
|
173
|
+
});
|
|
174
|
+
validateResume(input.resume, ticket.modality);
|
|
175
|
+
const streamEpoch = `live_${this.dependencies.tokens.randomOpaqueToken()}`;
|
|
176
|
+
boundedString(streamEpoch, "stream epoch", MAX_IDENTIFIER_BYTES);
|
|
177
|
+
const session = new EditableArtifactLiveSessionImpl({
|
|
178
|
+
dependencies: this.dependencies,
|
|
179
|
+
options: this.options,
|
|
180
|
+
ticket,
|
|
181
|
+
streamEpoch,
|
|
182
|
+
resume: input.resume,
|
|
183
|
+
sink: input.sink,
|
|
184
|
+
...(input.signal === undefined ? {} : { parentSignal: input.signal }),
|
|
185
|
+
});
|
|
186
|
+
await session.start();
|
|
187
|
+
return session;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type SessionConstructor = Readonly<{
|
|
192
|
+
dependencies: EditableArtifactLiveServerDependencies;
|
|
193
|
+
options: NormalizedOptions;
|
|
194
|
+
ticket: EditableArtifactLiveTicketRecord;
|
|
195
|
+
streamEpoch: string;
|
|
196
|
+
resume: EditableArtifactLiveResume;
|
|
197
|
+
sink: EditableArtifactLiveSinkPort;
|
|
198
|
+
parentSignal?: AbortSignal;
|
|
199
|
+
}>;
|
|
200
|
+
|
|
201
|
+
type InFlight = Readonly<{
|
|
202
|
+
bytes: number;
|
|
203
|
+
stateHash: EditableArtifactStateHash;
|
|
204
|
+
sentAt: number;
|
|
205
|
+
}>;
|
|
206
|
+
|
|
207
|
+
class EditableArtifactLiveSessionImpl implements EditableArtifactLiveSession {
|
|
208
|
+
readonly artifactId: EditableArtifactId;
|
|
209
|
+
readonly modality: EditableArtifactModality;
|
|
210
|
+
readonly streamEpoch: string;
|
|
211
|
+
readonly closed: Promise<EditableArtifactLiveClose>;
|
|
212
|
+
|
|
213
|
+
private readonly dependencies: EditableArtifactLiveServerDependencies;
|
|
214
|
+
private readonly options: NormalizedOptions;
|
|
215
|
+
private readonly ticket: EditableArtifactLiveTicketRecord;
|
|
216
|
+
private readonly resume: EditableArtifactLiveResume;
|
|
217
|
+
private readonly sink: EditableArtifactLiveSinkPort;
|
|
218
|
+
private readonly abort = new AbortController();
|
|
219
|
+
private readonly boundaries = new Map<number, EditableArtifactStateHash>();
|
|
220
|
+
private readonly inFlight = new Map<number, InFlight>();
|
|
221
|
+
private resolveClosed!: (close: EditableArtifactLiveClose) => void;
|
|
222
|
+
private workTail: Promise<void> = Promise.resolve();
|
|
223
|
+
private releaseHints: (() => void) | null = null;
|
|
224
|
+
private releaseInvalidations: (() => void) | null = null;
|
|
225
|
+
private detachParent: (() => void) | null = null;
|
|
226
|
+
private maintenance: Promise<void> | null = null;
|
|
227
|
+
private closedValue: EditableArtifactLiveClose | null = null;
|
|
228
|
+
private bootstrapping = true;
|
|
229
|
+
private reconcileRequested = false;
|
|
230
|
+
private reconcileScheduled = false;
|
|
231
|
+
private reauthorizationRequested = false;
|
|
232
|
+
private reauthorizationScheduled = false;
|
|
233
|
+
private sentSequence = 0;
|
|
234
|
+
private sentStateHash: EditableArtifactStateHash | null = null;
|
|
235
|
+
private ackedSequence = 0;
|
|
236
|
+
private ackedStateHash: EditableArtifactStateHash | null = null;
|
|
237
|
+
private targetHead = 0;
|
|
238
|
+
private inFlightBytes = 0;
|
|
239
|
+
private oldestInFlightAt = 0;
|
|
240
|
+
private writable = false;
|
|
241
|
+
private authorizationRevision = 0;
|
|
242
|
+
private queuedInboundFrames = 0;
|
|
243
|
+
private queuedInboundBytes = 0;
|
|
244
|
+
|
|
245
|
+
constructor(input: SessionConstructor) {
|
|
246
|
+
this.dependencies = input.dependencies;
|
|
247
|
+
this.options = input.options;
|
|
248
|
+
this.ticket = input.ticket;
|
|
249
|
+
this.artifactId = input.ticket.artifactId;
|
|
250
|
+
this.modality = input.ticket.modality;
|
|
251
|
+
this.streamEpoch = input.streamEpoch;
|
|
252
|
+
this.resume = input.resume;
|
|
253
|
+
this.sink = input.sink;
|
|
254
|
+
this.closed = new Promise((resolve) => {
|
|
255
|
+
this.resolveClosed = resolve;
|
|
256
|
+
});
|
|
257
|
+
if (input.parentSignal) {
|
|
258
|
+
const close = () => void this.close("closed");
|
|
259
|
+
if (input.parentSignal.aborted) close();
|
|
260
|
+
else {
|
|
261
|
+
input.parentSignal.addEventListener("abort", close, { once: true });
|
|
262
|
+
this.detachParent = () => input.parentSignal!.removeEventListener("abort", close);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async start(): Promise<void> {
|
|
268
|
+
try {
|
|
269
|
+
this.assertOpen();
|
|
270
|
+
this.authorizationRevision = await this.requireRead();
|
|
271
|
+
this.assertOpen();
|
|
272
|
+
|
|
273
|
+
// The hint subscription resolves only after its broker flush barrier.
|
|
274
|
+
// It is deliberately established before the first authoritative head read.
|
|
275
|
+
const releaseHints = await this.dependencies.hints.subscribe({
|
|
276
|
+
scope: this.ticket.scope,
|
|
277
|
+
artifactId: this.artifactId,
|
|
278
|
+
onHint: () => this.requestReconcile(),
|
|
279
|
+
onReconnect: () => this.requestReconcile(),
|
|
280
|
+
});
|
|
281
|
+
if (this.closedValue) {
|
|
282
|
+
safeRelease(releaseHints);
|
|
283
|
+
this.assertOpen();
|
|
284
|
+
}
|
|
285
|
+
this.releaseHints = releaseHints;
|
|
286
|
+
const releaseInvalidations = await this.dependencies.invalidations.subscribe({
|
|
287
|
+
scope: this.ticket.scope,
|
|
288
|
+
artifactId: this.artifactId,
|
|
289
|
+
actor: this.ticket.actor,
|
|
290
|
+
onInvalidated: () => this.requestReauthorization(),
|
|
291
|
+
});
|
|
292
|
+
if (this.closedValue) {
|
|
293
|
+
safeRelease(releaseInvalidations);
|
|
294
|
+
this.assertOpen();
|
|
295
|
+
}
|
|
296
|
+
this.releaseInvalidations = releaseInvalidations;
|
|
297
|
+
// Close the authorization-subscription race before reading any artifact
|
|
298
|
+
// bytes. Later revocations are caught by the subscription and periodic
|
|
299
|
+
// reauthorization.
|
|
300
|
+
this.authorizationRevision = Math.max(this.authorizationRevision, await this.requireRead());
|
|
301
|
+
this.assertOpen();
|
|
302
|
+
let editDecision = await this.checkPermission("edit");
|
|
303
|
+
if (editDecision.revision > this.authorizationRevision) {
|
|
304
|
+
this.authorizationRevision = editDecision.revision;
|
|
305
|
+
await this.requireRead();
|
|
306
|
+
} else if (editDecision.revision < this.authorizationRevision) {
|
|
307
|
+
editDecision = await this.checkPermission("edit");
|
|
308
|
+
}
|
|
309
|
+
this.writable = editDecision.revision === this.authorizationRevision && editDecision.allowed;
|
|
310
|
+
this.assertOpen();
|
|
311
|
+
const bootstrap = await this.dependencies.read.readBootstrap({
|
|
312
|
+
scope: this.ticket.scope,
|
|
313
|
+
artifactId: this.artifactId,
|
|
314
|
+
resume: this.resume,
|
|
315
|
+
protocolVersion: this.options.protocolVersion,
|
|
316
|
+
});
|
|
317
|
+
this.assertOpen();
|
|
318
|
+
validateBootstrap(bootstrap, this.artifactId, this.modality, this.resume, this.options);
|
|
319
|
+
this.targetHead = bootstrap.headSequence;
|
|
320
|
+
await this.send({
|
|
321
|
+
type: "open",
|
|
322
|
+
protocolVersion: this.options.protocolVersion,
|
|
323
|
+
artifactId: this.artifactId,
|
|
324
|
+
streamEpoch: this.streamEpoch,
|
|
325
|
+
modality: this.modality,
|
|
326
|
+
writable: this.writable,
|
|
327
|
+
headSequence: bootstrap.headSequence,
|
|
328
|
+
minimumReplaySequence: bootstrap.minimumReplaySequence,
|
|
329
|
+
maxClientFrameBytes: this.options.maxClientFrameBytes,
|
|
330
|
+
maxCommandBytes: Math.min(
|
|
331
|
+
EDITABLE_ARTIFACT_COMMAND_MAX_BYTES,
|
|
332
|
+
Math.max(0, this.options.maxClientFrameBytes - 64 * 1024),
|
|
333
|
+
),
|
|
334
|
+
maxIntentBytes: Math.min(
|
|
335
|
+
EDITABLE_ARTIFACT_INTENT_MAX_BYTES,
|
|
336
|
+
Math.max(0, this.options.maxClientFrameBytes - 64 * 1024),
|
|
337
|
+
),
|
|
338
|
+
maxCommittedTransactionBytes: Math.min(
|
|
339
|
+
EDITABLE_ARTIFACT_MAX_COMMITTED_TRANSACTION_BYTES,
|
|
340
|
+
Math.max(0, this.options.maxOutboundFrameBytes - 64 * 1024),
|
|
341
|
+
),
|
|
342
|
+
maxSnapshotBytes: this.options.maxSnapshotBytes,
|
|
343
|
+
maxInFlightTransactions: this.options.maxInFlightTransactions,
|
|
344
|
+
maxInFlightBytes: this.options.maxInFlightBytes,
|
|
345
|
+
});
|
|
346
|
+
await this.applyBootstrap(bootstrap);
|
|
347
|
+
|
|
348
|
+
// Close the final-notification race even when the broker loses the hint:
|
|
349
|
+
// reread durable authority after snapshot/replay, while already subscribed.
|
|
350
|
+
await this.reconcileFromDurableHead();
|
|
351
|
+
await this.sendBarrier();
|
|
352
|
+
this.bootstrapping = false;
|
|
353
|
+
if (this.reconcileRequested) this.requestReconcile();
|
|
354
|
+
this.maintenance = this.runMaintenance().catch((error: unknown) => {
|
|
355
|
+
if (!this.abort.signal.aborted) void this.fail(error);
|
|
356
|
+
});
|
|
357
|
+
} catch (error) {
|
|
358
|
+
await this.fail(error);
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
receive(bytes: Uint8Array): Promise<void> {
|
|
364
|
+
if (this.closedValue)
|
|
365
|
+
return Promise.reject(new EditableArtifactLiveError("closed", "Live session is closed"));
|
|
366
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
367
|
+
return Promise.reject(new TypeError("Live frame must be Uint8Array"));
|
|
368
|
+
}
|
|
369
|
+
if (bytes.byteLength > this.options.maxClientFrameBytes) {
|
|
370
|
+
const error = new EditableArtifactLiveError(
|
|
371
|
+
"oversized_frame",
|
|
372
|
+
"Client live frame exceeds the byte limit",
|
|
373
|
+
);
|
|
374
|
+
void this.fail(error);
|
|
375
|
+
return Promise.reject(error);
|
|
376
|
+
}
|
|
377
|
+
let frame: EditableArtifactLiveClientFrame;
|
|
378
|
+
try {
|
|
379
|
+
frame = decodeClientFrame(
|
|
380
|
+
bytes,
|
|
381
|
+
this.artifactId,
|
|
382
|
+
this.streamEpoch,
|
|
383
|
+
this.options.protocolVersion,
|
|
384
|
+
);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
const failure =
|
|
387
|
+
error instanceof EditableArtifactLiveError
|
|
388
|
+
? error
|
|
389
|
+
: new EditableArtifactLiveError("invalid_frame", "Client live frame is malformed", {
|
|
390
|
+
cause: error,
|
|
391
|
+
});
|
|
392
|
+
void this.fail(failure);
|
|
393
|
+
return Promise.reject(failure);
|
|
394
|
+
}
|
|
395
|
+
return this.enqueueInbound(bytes.byteLength, async () => {
|
|
396
|
+
await this.applyAcknowledgement(frame);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
acknowledge(frame: EditableArtifactLiveAppliedClientFrame): Promise<void> {
|
|
401
|
+
if (this.closedValue) {
|
|
402
|
+
return Promise.reject(new EditableArtifactLiveError("closed", "Live session is closed"));
|
|
403
|
+
}
|
|
404
|
+
let normalized: EditableArtifactLiveAppliedClientFrame;
|
|
405
|
+
try {
|
|
406
|
+
normalized = validateAppliedFrame(
|
|
407
|
+
frame,
|
|
408
|
+
this.artifactId,
|
|
409
|
+
this.streamEpoch,
|
|
410
|
+
this.options.protocolVersion,
|
|
411
|
+
);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
const failure =
|
|
414
|
+
error instanceof EditableArtifactLiveError
|
|
415
|
+
? error
|
|
416
|
+
: new EditableArtifactLiveError("invalid_frame", "Client live ACK is malformed", {
|
|
417
|
+
cause: error,
|
|
418
|
+
});
|
|
419
|
+
void this.fail(failure);
|
|
420
|
+
return Promise.reject(failure);
|
|
421
|
+
}
|
|
422
|
+
return this.enqueueInbound(256, async () => {
|
|
423
|
+
await this.applyAcknowledgement(normalized);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
submitIntent(
|
|
428
|
+
input: Readonly<{
|
|
429
|
+
protocolVersion: number;
|
|
430
|
+
artifactId: EditableArtifactId;
|
|
431
|
+
streamEpoch: string;
|
|
432
|
+
requestHash: EditableArtifactRequestHash;
|
|
433
|
+
intentBytes: Uint8Array;
|
|
434
|
+
}>,
|
|
435
|
+
): Promise<EditableArtifactLiveMutationReceipt> {
|
|
436
|
+
if (this.closedValue) {
|
|
437
|
+
return Promise.reject(new EditableArtifactLiveError("closed", "Live session is closed"));
|
|
438
|
+
}
|
|
439
|
+
if (input.protocolVersion !== this.options.protocolVersion) {
|
|
440
|
+
const error = new EditableArtifactLiveError(
|
|
441
|
+
"protocol_mismatch",
|
|
442
|
+
"Mutation protocol mismatch",
|
|
443
|
+
);
|
|
444
|
+
void this.fail(error);
|
|
445
|
+
return Promise.reject(error);
|
|
446
|
+
}
|
|
447
|
+
let inputArtifactId: EditableArtifactId;
|
|
448
|
+
try {
|
|
449
|
+
inputArtifactId = editableArtifactId(input.artifactId);
|
|
450
|
+
} catch (cause) {
|
|
451
|
+
const error = new EditableArtifactLiveError(
|
|
452
|
+
"invalid_frame",
|
|
453
|
+
"Mutation artifact id is malformed",
|
|
454
|
+
{ cause },
|
|
455
|
+
);
|
|
456
|
+
void this.fail(error);
|
|
457
|
+
return Promise.reject(error);
|
|
458
|
+
}
|
|
459
|
+
if (inputArtifactId !== this.artifactId) {
|
|
460
|
+
const error = new EditableArtifactLiveError("invalid_frame", "Mutation artifact mismatch");
|
|
461
|
+
void this.fail(error);
|
|
462
|
+
return Promise.reject(error);
|
|
463
|
+
}
|
|
464
|
+
if (input.streamEpoch !== this.streamEpoch) {
|
|
465
|
+
const error = new EditableArtifactLiveError(
|
|
466
|
+
"stale_epoch",
|
|
467
|
+
"Mutation belongs to a stale stream epoch",
|
|
468
|
+
);
|
|
469
|
+
void this.fail(error);
|
|
470
|
+
return Promise.reject(error);
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
editableArtifactRequestHash(input.requestHash);
|
|
474
|
+
} catch (cause) {
|
|
475
|
+
const error = new EditableArtifactLiveError(
|
|
476
|
+
"invalid_frame",
|
|
477
|
+
"Mutation request hash is malformed",
|
|
478
|
+
{ cause },
|
|
479
|
+
);
|
|
480
|
+
return Promise.reject(error);
|
|
481
|
+
}
|
|
482
|
+
if (
|
|
483
|
+
!(input.intentBytes instanceof Uint8Array) ||
|
|
484
|
+
input.intentBytes.byteLength === 0 ||
|
|
485
|
+
input.intentBytes.byteLength > this.options.maxClientFrameBytes
|
|
486
|
+
) {
|
|
487
|
+
const error = new EditableArtifactLiveError(
|
|
488
|
+
"oversized_frame",
|
|
489
|
+
"Mutation intent exceeds the byte limit",
|
|
490
|
+
);
|
|
491
|
+
void this.fail(error);
|
|
492
|
+
return Promise.reject(error);
|
|
493
|
+
}
|
|
494
|
+
return this.enqueueInbound(
|
|
495
|
+
input.intentBytes.byteLength,
|
|
496
|
+
() => this.applyMutation(input),
|
|
497
|
+
false,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
reconcileNow(): Promise<void> {
|
|
502
|
+
return this.enqueue(() => this.reconcileFromDurableHead());
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
reauthorizeNow(): Promise<void> {
|
|
506
|
+
return this.enqueue(() => this.reauthorize());
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async close(reason: EditableArtifactLiveCloseReason = "closed"): Promise<void> {
|
|
510
|
+
if (this.closedValue) return;
|
|
511
|
+
const close = closeValue(reason);
|
|
512
|
+
this.closedValue = close;
|
|
513
|
+
this.abort.abort();
|
|
514
|
+
safeRelease(this.releaseHints);
|
|
515
|
+
this.releaseHints = null;
|
|
516
|
+
safeRelease(this.releaseInvalidations);
|
|
517
|
+
this.releaseInvalidations = null;
|
|
518
|
+
safeRelease(this.detachParent);
|
|
519
|
+
this.detachParent = null;
|
|
520
|
+
try {
|
|
521
|
+
this.sink.close(close);
|
|
522
|
+
} catch {
|
|
523
|
+
// Adapter teardown is best effort; the protocol session is already
|
|
524
|
+
// atomically closed and must never leak cleanup failures to callers.
|
|
525
|
+
}
|
|
526
|
+
this.resolveClosed(close);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private assertOpen(): void {
|
|
530
|
+
if (this.closedValue) {
|
|
531
|
+
throw new EditableArtifactLiveError("closed", "Live session is closed");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
|
536
|
+
const result = this.workTail.then(async () => {
|
|
537
|
+
if (this.closedValue) {
|
|
538
|
+
throw new EditableArtifactLiveError("closed", "Live session is closed");
|
|
539
|
+
}
|
|
540
|
+
return await task();
|
|
541
|
+
});
|
|
542
|
+
this.workTail = result.then(
|
|
543
|
+
() => undefined,
|
|
544
|
+
(error: unknown) => this.fail(error),
|
|
545
|
+
);
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private enqueueInbound<T>(bytes: number, task: () => Promise<T>, fatal = true): Promise<T> {
|
|
550
|
+
if (
|
|
551
|
+
this.queuedInboundFrames + 1 > this.options.maxInboundQueueFrames ||
|
|
552
|
+
this.queuedInboundBytes + bytes > this.options.maxInboundQueueBytes
|
|
553
|
+
) {
|
|
554
|
+
const error = new EditableArtifactLiveError(
|
|
555
|
+
"slow_client",
|
|
556
|
+
"Live inbound work queue is full",
|
|
557
|
+
{ requiresSnapshot: true },
|
|
558
|
+
);
|
|
559
|
+
void this.fail(error);
|
|
560
|
+
return Promise.reject(error);
|
|
561
|
+
}
|
|
562
|
+
this.queuedInboundFrames += 1;
|
|
563
|
+
this.queuedInboundBytes += bytes;
|
|
564
|
+
const run = async () => {
|
|
565
|
+
try {
|
|
566
|
+
return await task();
|
|
567
|
+
} finally {
|
|
568
|
+
this.queuedInboundFrames -= 1;
|
|
569
|
+
this.queuedInboundBytes -= bytes;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
if (fatal) return this.enqueue(run);
|
|
573
|
+
const result = this.workTail.then(async () => {
|
|
574
|
+
if (this.closedValue) {
|
|
575
|
+
throw new EditableArtifactLiveError("closed", "Live session is closed");
|
|
576
|
+
}
|
|
577
|
+
return await run();
|
|
578
|
+
});
|
|
579
|
+
// A rejected mutation is request-scoped. Unknown outcomes are retried with
|
|
580
|
+
// the same intent/hash; they do not corrupt or tear down the read stream.
|
|
581
|
+
this.workTail = result.then(
|
|
582
|
+
() => undefined,
|
|
583
|
+
() => undefined,
|
|
584
|
+
);
|
|
585
|
+
return result;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private requestReconcile(): void {
|
|
589
|
+
if (this.closedValue) return;
|
|
590
|
+
this.reconcileRequested = true;
|
|
591
|
+
if (this.bootstrapping || this.reconcileScheduled) return;
|
|
592
|
+
this.reconcileScheduled = true;
|
|
593
|
+
void this.enqueue(async () => {
|
|
594
|
+
try {
|
|
595
|
+
while (this.reconcileRequested && !this.closedValue) {
|
|
596
|
+
this.reconcileRequested = false;
|
|
597
|
+
await this.reconcileFromDurableHead();
|
|
598
|
+
}
|
|
599
|
+
} finally {
|
|
600
|
+
this.reconcileScheduled = false;
|
|
601
|
+
if (this.reconcileRequested && !this.closedValue) this.requestReconcile();
|
|
602
|
+
}
|
|
603
|
+
}).catch(() => undefined);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private requestReauthorization(): void {
|
|
607
|
+
if (this.closedValue) return;
|
|
608
|
+
this.reauthorizationRequested = true;
|
|
609
|
+
if (this.reauthorizationScheduled) return;
|
|
610
|
+
this.reauthorizationScheduled = true;
|
|
611
|
+
void this.enqueue(async () => {
|
|
612
|
+
try {
|
|
613
|
+
while (this.reauthorizationRequested && !this.closedValue) {
|
|
614
|
+
this.reauthorizationRequested = false;
|
|
615
|
+
await this.reauthorize();
|
|
616
|
+
}
|
|
617
|
+
} finally {
|
|
618
|
+
this.reauthorizationScheduled = false;
|
|
619
|
+
if (this.reauthorizationRequested && !this.closedValue) {
|
|
620
|
+
this.requestReauthorization();
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}).catch(() => undefined);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private async applyBootstrap(bootstrap: EditableArtifactLiveBootstrap): Promise<void> {
|
|
627
|
+
if (bootstrap.resumeAccepted) {
|
|
628
|
+
this.sentSequence = bootstrap.resumeSequence;
|
|
629
|
+
this.ackedSequence = bootstrap.resumeSequence;
|
|
630
|
+
this.sentStateHash = bootstrap.resumeStateHash;
|
|
631
|
+
this.ackedStateHash = bootstrap.resumeStateHash;
|
|
632
|
+
this.boundaries.set(bootstrap.resumeSequence, bootstrap.resumeStateHash);
|
|
633
|
+
} else if (bootstrap.snapshot) {
|
|
634
|
+
await this.sendSnapshot(bootstrap.snapshot);
|
|
635
|
+
this.sentSequence = bootstrap.snapshot.sequence;
|
|
636
|
+
this.sentStateHash = bootstrap.snapshot.stateHash;
|
|
637
|
+
this.boundaries.set(bootstrap.snapshot.sequence, bootstrap.snapshot.stateHash);
|
|
638
|
+
// Sequence zero is the immutable genesis boundary, not a committed
|
|
639
|
+
// mutation that needs a replica lease advancement. Seed only its hash so
|
|
640
|
+
// the client's exact snapshot ACK is valid; snapshots above zero remain
|
|
641
|
+
// unacknowledged until their ACK is durably recorded below.
|
|
642
|
+
if (bootstrap.snapshot.sequence === 0) {
|
|
643
|
+
this.ackedStateHash = bootstrap.snapshot.stateHash;
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
await this.requireResync("retention_gap", bootstrap.headSequence);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (this.sentSequence + 1 < bootstrap.minimumReplaySequence) {
|
|
650
|
+
await this.requireResync("retention_gap", bootstrap.headSequence);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
await this.pumpThrough(bootstrap.headSequence);
|
|
654
|
+
if (
|
|
655
|
+
!this.closedValue &&
|
|
656
|
+
this.sentSequence === bootstrap.headSequence &&
|
|
657
|
+
this.sentStateHash !== bootstrap.stateHash
|
|
658
|
+
) {
|
|
659
|
+
await this.requireResync("durable_gap", bootstrap.headSequence);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private async sendSnapshot(snapshot: EditableArtifactLiveSnapshot): Promise<void> {
|
|
664
|
+
if (snapshot.bytes.byteLength > this.options.maxSnapshotBytes) {
|
|
665
|
+
await this.requireResync("oversized_frame", snapshot.sequence);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
// The durable read port owns this immutable snapshot for bootstrap. Copy
|
|
669
|
+
// each bounded wire chunk, never the entire potentially 128 MiB snapshot.
|
|
670
|
+
const snapshotBytes = snapshot.bytes;
|
|
671
|
+
if (snapshot.modality !== this.modality) {
|
|
672
|
+
throw new EditableArtifactLiveError(
|
|
673
|
+
"durable_gap",
|
|
674
|
+
"Snapshot modality differs from its durable ticket",
|
|
675
|
+
{ requiresSnapshot: true },
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
const causalFrontier =
|
|
679
|
+
snapshot.modality === "spreadsheet"
|
|
680
|
+
? validateFrontier(snapshot.causalFrontier, "snapshot causal frontier")
|
|
681
|
+
: null;
|
|
682
|
+
const totalBytes = snapshotBytes.byteLength;
|
|
683
|
+
if (totalBytes === 0)
|
|
684
|
+
throw new EditableArtifactLiveError("durable_gap", "Snapshot is empty", {
|
|
685
|
+
requiresSnapshot: true,
|
|
686
|
+
});
|
|
687
|
+
for (let offset = 0; offset < totalBytes; offset += this.options.snapshotChunkBytes) {
|
|
688
|
+
const bytes = snapshotBytes.slice(
|
|
689
|
+
offset,
|
|
690
|
+
Math.min(offset + this.options.snapshotChunkBytes, totalBytes),
|
|
691
|
+
);
|
|
692
|
+
const common = {
|
|
693
|
+
type: "snapshot",
|
|
694
|
+
protocolVersion: this.options.protocolVersion,
|
|
695
|
+
artifactId: this.artifactId,
|
|
696
|
+
streamEpoch: this.streamEpoch,
|
|
697
|
+
sequence: snapshot.sequence,
|
|
698
|
+
stateHash: snapshot.stateHash,
|
|
699
|
+
digest: snapshot.digest,
|
|
700
|
+
kernelVersion: snapshot.kernelVersion,
|
|
701
|
+
modelSchemaVersion: snapshot.modelSchemaVersion,
|
|
702
|
+
offset,
|
|
703
|
+
totalBytes,
|
|
704
|
+
final: offset + bytes.byteLength === totalBytes,
|
|
705
|
+
bytes,
|
|
706
|
+
} as const;
|
|
707
|
+
await this.send(
|
|
708
|
+
snapshot.modality === "spreadsheet"
|
|
709
|
+
? { ...common, modality: snapshot.modality, causalFrontier: causalFrontier! }
|
|
710
|
+
: {
|
|
711
|
+
...common,
|
|
712
|
+
modality: snapshot.modality,
|
|
713
|
+
nativeRevision: snapshot.nativeRevision,
|
|
714
|
+
},
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private async reconcileFromDurableHead(): Promise<void> {
|
|
720
|
+
if (this.closedValue) return;
|
|
721
|
+
const head = await this.dependencies.read.readHead(this.ticket.scope, this.artifactId);
|
|
722
|
+
validateHead(head, this.modality);
|
|
723
|
+
if (head.headSequence < this.sentSequence) {
|
|
724
|
+
await this.requireResync("durable_gap", head.headSequence);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
this.targetHead = head.headSequence;
|
|
728
|
+
if (this.sentSequence + 1 < head.minimumReplaySequence) {
|
|
729
|
+
await this.requireResync("retention_gap", head.headSequence);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
await this.pumpThrough(head.headSequence);
|
|
733
|
+
if (
|
|
734
|
+
!this.closedValue &&
|
|
735
|
+
this.sentSequence === head.headSequence &&
|
|
736
|
+
this.sentStateHash !== head.stateHash
|
|
737
|
+
) {
|
|
738
|
+
await this.requireResync("durable_gap", head.headSequence);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (!this.closedValue) {
|
|
742
|
+
await this.send({
|
|
743
|
+
type: "watermark",
|
|
744
|
+
protocolVersion: this.options.protocolVersion,
|
|
745
|
+
artifactId: this.artifactId,
|
|
746
|
+
streamEpoch: this.streamEpoch,
|
|
747
|
+
headSequence: head.headSequence,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private async pumpThrough(through: number): Promise<void> {
|
|
753
|
+
assertNonnegativeSafeInteger(through, "target head sequence");
|
|
754
|
+
while (!this.closedValue && this.sentSequence < through) {
|
|
755
|
+
if (this.inFlight.size >= this.options.maxInFlightTransactions) return;
|
|
756
|
+
const page = await this.dependencies.read.readTransactions({
|
|
757
|
+
scope: this.ticket.scope,
|
|
758
|
+
artifactId: this.artifactId,
|
|
759
|
+
after: this.sentSequence,
|
|
760
|
+
through,
|
|
761
|
+
maxCount: this.options.replayPageTransactions,
|
|
762
|
+
maxBytes: this.options.replayPageBytes,
|
|
763
|
+
});
|
|
764
|
+
validatePage(
|
|
765
|
+
page,
|
|
766
|
+
this.sentSequence,
|
|
767
|
+
through,
|
|
768
|
+
this.options.replayPageTransactions,
|
|
769
|
+
this.options.replayPageBytes,
|
|
770
|
+
);
|
|
771
|
+
if (this.sentSequence + 1 < page.minimumReplaySequence) {
|
|
772
|
+
await this.requireResync("retention_gap", page.headSequence);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (page.transactions.length === 0) {
|
|
776
|
+
await this.requireResync("durable_gap", page.headSequence);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
let progressed = false;
|
|
780
|
+
for (const transaction of page.transactions) {
|
|
781
|
+
validateTransaction(
|
|
782
|
+
transaction,
|
|
783
|
+
this.artifactId,
|
|
784
|
+
this.modality,
|
|
785
|
+
Math.min(
|
|
786
|
+
this.options.replayPageBytes,
|
|
787
|
+
Math.max(0, this.options.maxOutboundFrameBytes - 64 * 1024),
|
|
788
|
+
),
|
|
789
|
+
);
|
|
790
|
+
if (transaction.endSequence > through) {
|
|
791
|
+
await this.requireResync("durable_gap", page.headSequence);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (transaction.startSequence !== this.sentSequence + 1) {
|
|
795
|
+
await this.requireResync("durable_gap", page.headSequence);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (this.sentStateHash && transaction.priorStateHash !== this.sentStateHash) {
|
|
799
|
+
await this.requireResync("durable_gap", page.headSequence);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const ownedTransaction = ownTransaction(transaction);
|
|
803
|
+
const frameBytes = estimateFrameBytes({
|
|
804
|
+
type: "transaction",
|
|
805
|
+
protocolVersion: this.options.protocolVersion,
|
|
806
|
+
artifactId: this.artifactId,
|
|
807
|
+
streamEpoch: this.streamEpoch,
|
|
808
|
+
transaction: ownedTransaction,
|
|
809
|
+
});
|
|
810
|
+
if (
|
|
811
|
+
frameBytes > this.options.maxOutboundFrameBytes ||
|
|
812
|
+
frameBytes > this.options.maxInFlightBytes
|
|
813
|
+
) {
|
|
814
|
+
await this.requireResync("oversized_frame", page.headSequence);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (
|
|
818
|
+
this.inFlight.size >= this.options.maxInFlightTransactions ||
|
|
819
|
+
this.inFlightBytes + frameBytes > this.options.maxInFlightBytes
|
|
820
|
+
) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
await this.send({
|
|
824
|
+
type: "transaction",
|
|
825
|
+
protocolVersion: this.options.protocolVersion,
|
|
826
|
+
artifactId: this.artifactId,
|
|
827
|
+
streamEpoch: this.streamEpoch,
|
|
828
|
+
transaction: ownedTransaction,
|
|
829
|
+
});
|
|
830
|
+
this.sentSequence = ownedTransaction.endSequence;
|
|
831
|
+
this.sentStateHash = ownedTransaction.stateHash;
|
|
832
|
+
this.boundaries.set(ownedTransaction.endSequence, ownedTransaction.stateHash);
|
|
833
|
+
const sentAt = this.dependencies.clock.now().getTime();
|
|
834
|
+
this.inFlight.set(ownedTransaction.endSequence, {
|
|
835
|
+
bytes: frameBytes,
|
|
836
|
+
stateHash: ownedTransaction.stateHash,
|
|
837
|
+
sentAt,
|
|
838
|
+
});
|
|
839
|
+
this.inFlightBytes += frameBytes;
|
|
840
|
+
if (this.oldestInFlightAt === 0) this.oldestInFlightAt = sentAt;
|
|
841
|
+
progressed = true;
|
|
842
|
+
if (this.sentSequence >= through) break;
|
|
843
|
+
}
|
|
844
|
+
if (!progressed) return;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private async applyAcknowledgement(frame: EditableArtifactLiveAppliedClientFrame): Promise<void> {
|
|
849
|
+
if (frame.sequence < this.ackedSequence || frame.sequence > this.sentSequence) {
|
|
850
|
+
await this.requireResync("invalid_ack", this.targetHead);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const expected =
|
|
854
|
+
frame.sequence === this.ackedSequence
|
|
855
|
+
? this.ackedStateHash
|
|
856
|
+
: this.boundaries.get(frame.sequence);
|
|
857
|
+
if (!expected || expected !== frame.stateHash) {
|
|
858
|
+
await this.requireResync("invalid_ack", this.targetHead);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (frame.sequence > this.ackedSequence) {
|
|
862
|
+
await this.dependencies.read.acknowledgeReplica({
|
|
863
|
+
scope: this.ticket.scope,
|
|
864
|
+
artifactId: this.artifactId,
|
|
865
|
+
replicaId: this.ticket.actor.replicaId,
|
|
866
|
+
actorKey: editableArtifactActorKey(this.ticket.actor),
|
|
867
|
+
streamEpoch: this.streamEpoch,
|
|
868
|
+
sequence: frame.sequence,
|
|
869
|
+
stateHash: frame.stateHash,
|
|
870
|
+
});
|
|
871
|
+
this.ackedSequence = frame.sequence;
|
|
872
|
+
this.ackedStateHash = frame.stateHash;
|
|
873
|
+
for (const [sequence, inFlight] of this.inFlight) {
|
|
874
|
+
if (sequence <= frame.sequence) {
|
|
875
|
+
this.inFlight.delete(sequence);
|
|
876
|
+
this.inFlightBytes -= inFlight.bytes;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
for (const sequence of this.boundaries.keys()) {
|
|
880
|
+
if (sequence < frame.sequence) this.boundaries.delete(sequence);
|
|
881
|
+
}
|
|
882
|
+
this.oldestInFlightAt =
|
|
883
|
+
this.inFlight.size === 0
|
|
884
|
+
? 0
|
|
885
|
+
: Math.min(...[...this.inFlight.values()].map((value) => value.sentAt));
|
|
886
|
+
}
|
|
887
|
+
await this.send({
|
|
888
|
+
type: "applied",
|
|
889
|
+
protocolVersion: this.options.protocolVersion,
|
|
890
|
+
artifactId: this.artifactId,
|
|
891
|
+
streamEpoch: this.streamEpoch,
|
|
892
|
+
sequence: frame.sequence,
|
|
893
|
+
stateHash: frame.stateHash,
|
|
894
|
+
});
|
|
895
|
+
await this.reconcileFromDurableHead();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private async applyMutation(
|
|
899
|
+
frame: EditableArtifactLiveMutationClientFrame,
|
|
900
|
+
): Promise<EditableArtifactLiveMutationReceipt> {
|
|
901
|
+
// Refresh both permissions at one local revision. An old allowed=true must
|
|
902
|
+
// never re-enable writes after a newer revocation was observed.
|
|
903
|
+
await this.reauthorize();
|
|
904
|
+
if (this.closedValue) {
|
|
905
|
+
throw new EditableArtifactLiveError(
|
|
906
|
+
"permission_changed",
|
|
907
|
+
"Editable artifact read permission changed",
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
if (!this.writable) {
|
|
911
|
+
throw new EditableArtifactLiveError(
|
|
912
|
+
"permission_changed",
|
|
913
|
+
"Editable artifact edit permission denied",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
let result: Awaited<ReturnType<EditableArtifactService["applyTransaction"]>>;
|
|
917
|
+
try {
|
|
918
|
+
result = await this.dependencies.domain.applyTransaction({
|
|
919
|
+
scope: this.ticket.scope,
|
|
920
|
+
artifactId: this.artifactId,
|
|
921
|
+
actor: this.ticket.actor,
|
|
922
|
+
request: {
|
|
923
|
+
intentBytes: frame.intentBytes.slice(),
|
|
924
|
+
requestHash: frame.requestHash,
|
|
925
|
+
},
|
|
926
|
+
});
|
|
927
|
+
} catch (error) {
|
|
928
|
+
// The domain is authoritative for the mutation/revocation race. Refresh
|
|
929
|
+
// presentation permissions before returning the request-scoped failure.
|
|
930
|
+
await this.reauthorize();
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
const transaction = await this.dependencies.read.readCommittedTransaction({
|
|
934
|
+
scope: this.ticket.scope,
|
|
935
|
+
artifactId: this.artifactId,
|
|
936
|
+
transactionId: result.receipt.serverTransactionId,
|
|
937
|
+
});
|
|
938
|
+
if (
|
|
939
|
+
!transaction ||
|
|
940
|
+
result.receipt.modality !== this.modality ||
|
|
941
|
+
transaction.modality !== this.modality ||
|
|
942
|
+
transaction.startSequence !== result.receipt.sequenceStart ||
|
|
943
|
+
transaction.endSequence !== result.receipt.sequenceEnd ||
|
|
944
|
+
transaction.transactionId !== result.receipt.serverTransactionId ||
|
|
945
|
+
result.receipt.requestHash !== frame.requestHash ||
|
|
946
|
+
transaction.requestHash !== result.receipt.requestHash ||
|
|
947
|
+
transaction.stateHash !== result.receipt.stateHash
|
|
948
|
+
) {
|
|
949
|
+
await this.requireResync("durable_gap", this.targetHead);
|
|
950
|
+
throw new EditableArtifactLiveError(
|
|
951
|
+
"durable_gap",
|
|
952
|
+
"Committed mutation is unavailable from durable replay",
|
|
953
|
+
{ requiresSnapshot: true },
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
validateTransaction(
|
|
957
|
+
transaction,
|
|
958
|
+
this.artifactId,
|
|
959
|
+
this.modality,
|
|
960
|
+
Math.min(
|
|
961
|
+
this.options.replayPageBytes,
|
|
962
|
+
Math.max(0, this.options.maxOutboundFrameBytes - 64 * 1024),
|
|
963
|
+
),
|
|
964
|
+
);
|
|
965
|
+
const ownedTransaction = ownTransaction(transaction);
|
|
966
|
+
await this.reconcileFromDurableHead();
|
|
967
|
+
return Object.freeze({
|
|
968
|
+
clientTransactionId: result.receipt.clientTransactionId,
|
|
969
|
+
requestHash: result.receipt.requestHash,
|
|
970
|
+
transaction: ownedTransaction,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private async reauthorize(): Promise<void> {
|
|
975
|
+
let readDecision = await this.checkPermission("read");
|
|
976
|
+
let editDecision = await this.checkPermission("edit");
|
|
977
|
+
const targetRevision = Math.max(
|
|
978
|
+
this.authorizationRevision,
|
|
979
|
+
readDecision.revision,
|
|
980
|
+
editDecision.revision,
|
|
981
|
+
);
|
|
982
|
+
// A policy update can land between the two reads. Retry only the stale
|
|
983
|
+
// decision once; persistent revision skew fails closed below.
|
|
984
|
+
if (readDecision.revision < targetRevision) {
|
|
985
|
+
readDecision = await this.checkPermission("read");
|
|
986
|
+
}
|
|
987
|
+
if (editDecision.revision < targetRevision) {
|
|
988
|
+
editDecision = await this.checkPermission("edit");
|
|
989
|
+
}
|
|
990
|
+
const coherentRevision = Math.max(targetRevision, readDecision.revision, editDecision.revision);
|
|
991
|
+
if (readDecision.revision !== coherentRevision) {
|
|
992
|
+
await this.close("permission_changed");
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (!readDecision.allowed) {
|
|
996
|
+
await this.close("permission_changed");
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const nextWritable = editDecision.revision === coherentRevision && editDecision.allowed;
|
|
1000
|
+
const revisionChanged = coherentRevision !== this.authorizationRevision;
|
|
1001
|
+
this.authorizationRevision = coherentRevision;
|
|
1002
|
+
if (nextWritable !== this.writable || revisionChanged) {
|
|
1003
|
+
this.writable = nextWritable;
|
|
1004
|
+
await this.sendAuthorization();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
private async sendAuthorization(): Promise<void> {
|
|
1009
|
+
await this.send({
|
|
1010
|
+
type: "authorizationChanged",
|
|
1011
|
+
protocolVersion: this.options.protocolVersion,
|
|
1012
|
+
artifactId: this.artifactId,
|
|
1013
|
+
streamEpoch: this.streamEpoch,
|
|
1014
|
+
writable: this.writable,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private async requireRead(): Promise<number> {
|
|
1019
|
+
const decision = await this.checkPermission("read");
|
|
1020
|
+
if (!decision.allowed || decision.revision < this.authorizationRevision) {
|
|
1021
|
+
throw new EditableArtifactLiveError(
|
|
1022
|
+
"permission_changed",
|
|
1023
|
+
"Editable artifact read permission denied",
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
return decision.revision;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private async checkPermission(
|
|
1030
|
+
permission: "read" | "edit",
|
|
1031
|
+
): Promise<Awaited<ReturnType<EditableArtifactAuthorizationPort["authorize"]>>> {
|
|
1032
|
+
const decision = await this.dependencies.authorization.authorize({
|
|
1033
|
+
scope: this.ticket.scope,
|
|
1034
|
+
artifactId: this.artifactId,
|
|
1035
|
+
actor: this.ticket.actor,
|
|
1036
|
+
permission,
|
|
1037
|
+
});
|
|
1038
|
+
if (permission === "edit" && !this.ticket.allowEdit) {
|
|
1039
|
+
return Object.freeze({ ...decision, allowed: false });
|
|
1040
|
+
}
|
|
1041
|
+
return decision;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private async sendBarrier(): Promise<void> {
|
|
1045
|
+
if (!this.sentStateHash)
|
|
1046
|
+
throw new EditableArtifactLiveError("durable_gap", "Bootstrap has no state boundary");
|
|
1047
|
+
await this.send({
|
|
1048
|
+
type: "barrier",
|
|
1049
|
+
protocolVersion: this.options.protocolVersion,
|
|
1050
|
+
artifactId: this.artifactId,
|
|
1051
|
+
streamEpoch: this.streamEpoch,
|
|
1052
|
+
sequence: this.sentSequence,
|
|
1053
|
+
stateHash: this.sentStateHash,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
private async send(frame: EditableArtifactLiveServerFrame): Promise<void> {
|
|
1058
|
+
if (this.closedValue) return;
|
|
1059
|
+
const bytes = estimateFrameBytes(frame);
|
|
1060
|
+
if (bytes > this.options.maxOutboundFrameBytes) {
|
|
1061
|
+
throw new EditableArtifactLiveError(
|
|
1062
|
+
"oversized_frame",
|
|
1063
|
+
"Server live frame exceeds the byte limit",
|
|
1064
|
+
{ requiresSnapshot: true },
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
const buffered = this.sink.bufferedBytes();
|
|
1068
|
+
if (
|
|
1069
|
+
!Number.isSafeInteger(buffered) ||
|
|
1070
|
+
buffered < 0 ||
|
|
1071
|
+
buffered + bytes > this.options.maxSocketBufferedBytes
|
|
1072
|
+
) {
|
|
1073
|
+
throw new EditableArtifactLiveError("slow_client", "Live socket send buffer is full", {
|
|
1074
|
+
retryable: true,
|
|
1075
|
+
requiresSnapshot: true,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
const timeout = new AbortController();
|
|
1079
|
+
try {
|
|
1080
|
+
await Promise.race([
|
|
1081
|
+
this.sink.send(frame),
|
|
1082
|
+
this.dependencies.scheduler.sleep(this.options.sendTimeoutMs, timeout.signal).then(() => {
|
|
1083
|
+
throw new EditableArtifactLiveError("slow_client", "Live socket write timed out", {
|
|
1084
|
+
retryable: true,
|
|
1085
|
+
requiresSnapshot: true,
|
|
1086
|
+
});
|
|
1087
|
+
}),
|
|
1088
|
+
]);
|
|
1089
|
+
} finally {
|
|
1090
|
+
timeout.abort();
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
private async requireResync(
|
|
1095
|
+
reason: "retention_gap" | "durable_gap" | "invalid_ack" | "slow_client" | "oversized_frame",
|
|
1096
|
+
headSequence: number,
|
|
1097
|
+
): Promise<void> {
|
|
1098
|
+
if (this.closedValue) return;
|
|
1099
|
+
try {
|
|
1100
|
+
await this.send({
|
|
1101
|
+
type: "resyncRequired",
|
|
1102
|
+
protocolVersion: this.options.protocolVersion,
|
|
1103
|
+
artifactId: this.artifactId,
|
|
1104
|
+
streamEpoch: this.streamEpoch,
|
|
1105
|
+
reason,
|
|
1106
|
+
headSequence,
|
|
1107
|
+
});
|
|
1108
|
+
} catch {
|
|
1109
|
+
// A blocked transport may be unable to carry the advisory frame. The
|
|
1110
|
+
// typed close still forces a verified snapshot on reconnect.
|
|
1111
|
+
}
|
|
1112
|
+
await this.close(reason);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private async runMaintenance(): Promise<void> {
|
|
1116
|
+
let nextReconcile = this.dependencies.clock.now().getTime() + this.options.reconcileIntervalMs;
|
|
1117
|
+
let nextAuthorization =
|
|
1118
|
+
this.dependencies.clock.now().getTime() + this.options.reauthorizeIntervalMs;
|
|
1119
|
+
const tick = Math.max(
|
|
1120
|
+
10,
|
|
1121
|
+
Math.min(
|
|
1122
|
+
this.options.reconcileIntervalMs,
|
|
1123
|
+
this.options.reauthorizeIntervalMs,
|
|
1124
|
+
this.options.ackTimeoutMs,
|
|
1125
|
+
),
|
|
1126
|
+
);
|
|
1127
|
+
while (!this.abort.signal.aborted) {
|
|
1128
|
+
await this.dependencies.scheduler.sleep(tick, this.abort.signal);
|
|
1129
|
+
if (this.abort.signal.aborted || this.closedValue) return;
|
|
1130
|
+
const now = this.dependencies.clock.now().getTime();
|
|
1131
|
+
if (this.oldestInFlightAt !== 0 && now - this.oldestInFlightAt >= this.options.ackTimeoutMs) {
|
|
1132
|
+
await this.enqueue(() => this.requireResync("slow_client", this.targetHead));
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (now >= nextAuthorization) {
|
|
1136
|
+
await this.reauthorizeNow();
|
|
1137
|
+
nextAuthorization = now + this.options.reauthorizeIntervalMs;
|
|
1138
|
+
}
|
|
1139
|
+
if (this.closedValue) return;
|
|
1140
|
+
if (now >= nextReconcile) {
|
|
1141
|
+
await this.reconcileNow();
|
|
1142
|
+
nextReconcile = now + this.options.reconcileIntervalMs;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private async fail(error: unknown): Promise<void> {
|
|
1148
|
+
if (this.closedValue) return;
|
|
1149
|
+
if (error instanceof EditableArtifactLiveError) {
|
|
1150
|
+
if (
|
|
1151
|
+
error.requiresSnapshot &&
|
|
1152
|
+
["durable_gap", "retention_gap", "invalid_ack", "slow_client", "oversized_frame"].includes(
|
|
1153
|
+
error.code,
|
|
1154
|
+
)
|
|
1155
|
+
) {
|
|
1156
|
+
await this.requireResync(
|
|
1157
|
+
error.code as
|
|
1158
|
+
| "durable_gap"
|
|
1159
|
+
| "retention_gap"
|
|
1160
|
+
| "invalid_ack"
|
|
1161
|
+
| "slow_client"
|
|
1162
|
+
| "oversized_frame",
|
|
1163
|
+
this.targetHead,
|
|
1164
|
+
);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
await this.close(error.code === "invalid_ticket" ? "transport_error" : error.code);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
await this.close("transport_error");
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function decodeClientFrame(
|
|
1175
|
+
bytes: Uint8Array,
|
|
1176
|
+
artifactId: EditableArtifactId,
|
|
1177
|
+
streamEpoch: string,
|
|
1178
|
+
protocolVersion: number,
|
|
1179
|
+
): EditableArtifactLiveClientFrame {
|
|
1180
|
+
let value: unknown;
|
|
1181
|
+
try {
|
|
1182
|
+
value = JSON.parse(strictDecoder.decode(bytes));
|
|
1183
|
+
} catch (cause) {
|
|
1184
|
+
throw new EditableArtifactLiveError(
|
|
1185
|
+
"invalid_frame",
|
|
1186
|
+
"Client live frame is not valid UTF-8 JSON",
|
|
1187
|
+
{ cause },
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
const frame = plainRecord(value, "client frame");
|
|
1191
|
+
const type = requiredString(frame, "type", 32);
|
|
1192
|
+
const actualProtocol = requiredSafeInteger(frame, "protocolVersion");
|
|
1193
|
+
const actualArtifact = editableArtifactId(requiredString(frame, "artifactId", 64));
|
|
1194
|
+
const actualEpoch = requiredString(frame, "streamEpoch", MAX_IDENTIFIER_BYTES);
|
|
1195
|
+
if (actualProtocol !== protocolVersion)
|
|
1196
|
+
throw new EditableArtifactLiveError("protocol_mismatch", "Client frame protocol mismatch");
|
|
1197
|
+
if (actualArtifact !== artifactId)
|
|
1198
|
+
throw new EditableArtifactLiveError("invalid_frame", "Client frame artifact mismatch");
|
|
1199
|
+
if (actualEpoch !== streamEpoch)
|
|
1200
|
+
throw new EditableArtifactLiveError(
|
|
1201
|
+
"stale_epoch",
|
|
1202
|
+
"Client frame belongs to a stale stream epoch",
|
|
1203
|
+
);
|
|
1204
|
+
if (type === "applied") {
|
|
1205
|
+
rejectUnknown(frame, [
|
|
1206
|
+
"type",
|
|
1207
|
+
"protocolVersion",
|
|
1208
|
+
"artifactId",
|
|
1209
|
+
"streamEpoch",
|
|
1210
|
+
"sequence",
|
|
1211
|
+
"stateHash",
|
|
1212
|
+
]);
|
|
1213
|
+
return validateAppliedFrame(
|
|
1214
|
+
Object.freeze({
|
|
1215
|
+
type,
|
|
1216
|
+
protocolVersion,
|
|
1217
|
+
artifactId,
|
|
1218
|
+
streamEpoch,
|
|
1219
|
+
sequence: requiredSafeInteger(frame, "sequence"),
|
|
1220
|
+
stateHash: editableArtifactStateHash(requiredString(frame, "stateHash", 80)),
|
|
1221
|
+
}),
|
|
1222
|
+
artifactId,
|
|
1223
|
+
streamEpoch,
|
|
1224
|
+
protocolVersion,
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
throw new EditableArtifactLiveError("invalid_frame", `Unknown client live frame: ${type}`);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function validateAppliedFrame(
|
|
1231
|
+
frame: EditableArtifactLiveAppliedClientFrame,
|
|
1232
|
+
artifactId: EditableArtifactId,
|
|
1233
|
+
streamEpoch: string,
|
|
1234
|
+
protocolVersion: number,
|
|
1235
|
+
): EditableArtifactLiveAppliedClientFrame {
|
|
1236
|
+
if (frame.type !== "applied") {
|
|
1237
|
+
throw new EditableArtifactLiveError("invalid_frame", "Client frame is not an apply ACK");
|
|
1238
|
+
}
|
|
1239
|
+
if (frame.protocolVersion !== protocolVersion) {
|
|
1240
|
+
throw new EditableArtifactLiveError("protocol_mismatch", "Client frame protocol mismatch");
|
|
1241
|
+
}
|
|
1242
|
+
if (editableArtifactId(frame.artifactId) !== artifactId) {
|
|
1243
|
+
throw new EditableArtifactLiveError("invalid_frame", "Client frame artifact mismatch");
|
|
1244
|
+
}
|
|
1245
|
+
boundedString(frame.streamEpoch, "stream epoch", MAX_IDENTIFIER_BYTES);
|
|
1246
|
+
if (frame.streamEpoch !== streamEpoch) {
|
|
1247
|
+
throw new EditableArtifactLiveError(
|
|
1248
|
+
"stale_epoch",
|
|
1249
|
+
"Client frame belongs to a stale stream epoch",
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
assertNonnegativeSafeInteger(frame.sequence, "applied sequence");
|
|
1253
|
+
return Object.freeze({
|
|
1254
|
+
type: "applied",
|
|
1255
|
+
protocolVersion,
|
|
1256
|
+
artifactId,
|
|
1257
|
+
streamEpoch,
|
|
1258
|
+
sequence: frame.sequence,
|
|
1259
|
+
stateHash: editableArtifactStateHash(frame.stateHash),
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function validateResume(
|
|
1264
|
+
resume: EditableArtifactLiveResume,
|
|
1265
|
+
durableModality: EditableArtifactModality,
|
|
1266
|
+
): void {
|
|
1267
|
+
const resumeModality = resume.modality ?? "spreadsheet";
|
|
1268
|
+
if (resumeModality !== durableModality) {
|
|
1269
|
+
throw new EditableArtifactLiveError(
|
|
1270
|
+
"invalid_frame",
|
|
1271
|
+
"Resume modality differs from the durable artifact",
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
if (resume.localCursor !== null) assertNonnegativeSafeInteger(resume.localCursor, "local cursor");
|
|
1275
|
+
if (resume.localStateHash !== null) editableArtifactStateHash(resume.localStateHash);
|
|
1276
|
+
if (resumeModality === "spreadsheet") {
|
|
1277
|
+
if (!("localCausalFrontier" in resume)) {
|
|
1278
|
+
throw new TypeError("spreadsheet resume requires a causal frontier");
|
|
1279
|
+
}
|
|
1280
|
+
validateFrontier(resume.localCausalFrontier, "local causal frontier");
|
|
1281
|
+
} else if ("localNativeRevision" in resume && resume.localNativeRevision !== null) {
|
|
1282
|
+
assertNonnegativeSafeInteger(resume.localNativeRevision, "local native revision");
|
|
1283
|
+
} else if (!("localNativeRevision" in resume)) {
|
|
1284
|
+
throw new TypeError("serialized resume requires a native revision");
|
|
1285
|
+
}
|
|
1286
|
+
if (typeof resume.requireSnapshot !== "boolean")
|
|
1287
|
+
throw new TypeError("requireSnapshot must be boolean");
|
|
1288
|
+
if ((resume.localCursor === null) !== (resume.localStateHash === null)) {
|
|
1289
|
+
throw new TypeError("resume cursor and state hash must both be null or present");
|
|
1290
|
+
}
|
|
1291
|
+
if (
|
|
1292
|
+
resumeModality !== "spreadsheet" &&
|
|
1293
|
+
"localNativeRevision" in resume &&
|
|
1294
|
+
(resume.localCursor === null) !== (resume.localNativeRevision === null)
|
|
1295
|
+
) {
|
|
1296
|
+
throw new TypeError(
|
|
1297
|
+
"serialized resume cursor and native revision must both be null or present",
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function validateBootstrap(
|
|
1303
|
+
bootstrap: EditableArtifactLiveBootstrap,
|
|
1304
|
+
artifactId: EditableArtifactId,
|
|
1305
|
+
durableModality: EditableArtifactModality,
|
|
1306
|
+
resume: EditableArtifactLiveResume,
|
|
1307
|
+
options: NormalizedOptions,
|
|
1308
|
+
): void {
|
|
1309
|
+
validateHead(bootstrap, durableModality);
|
|
1310
|
+
assertNonnegativeSafeInteger(bootstrap.resumeSequence, "bootstrap resume sequence");
|
|
1311
|
+
editableArtifactStateHash(bootstrap.resumeStateHash);
|
|
1312
|
+
if (typeof bootstrap.resumeAccepted !== "boolean")
|
|
1313
|
+
throw new TypeError("resumeAccepted must be boolean");
|
|
1314
|
+
if (bootstrap.resumeSequence > bootstrap.headSequence)
|
|
1315
|
+
throw new TypeError("bootstrap resume is beyond head");
|
|
1316
|
+
if (
|
|
1317
|
+
bootstrap.resumeAccepted &&
|
|
1318
|
+
(resume.requireSnapshot ||
|
|
1319
|
+
resume.localCursor === null ||
|
|
1320
|
+
resume.localStateHash === null ||
|
|
1321
|
+
bootstrap.resumeSequence !== resume.localCursor ||
|
|
1322
|
+
bootstrap.resumeStateHash !== resume.localStateHash)
|
|
1323
|
+
) {
|
|
1324
|
+
throw new TypeError("bootstrap accepted a different or unverified resume boundary");
|
|
1325
|
+
}
|
|
1326
|
+
if (bootstrap.snapshot) {
|
|
1327
|
+
const snapshot = bootstrap.snapshot;
|
|
1328
|
+
if (snapshot.modality !== durableModality) throw new TypeError("snapshot modality mismatch");
|
|
1329
|
+
if (snapshot.artifactId !== artifactId) throw new TypeError("snapshot artifact mismatch");
|
|
1330
|
+
assertNonnegativeSafeInteger(snapshot.sequence, "snapshot sequence");
|
|
1331
|
+
if (snapshot.sequence > bootstrap.headSequence) throw new TypeError("snapshot is beyond head");
|
|
1332
|
+
editableArtifactStateHash(snapshot.stateHash);
|
|
1333
|
+
if (snapshot.modality === "spreadsheet") {
|
|
1334
|
+
validateFrontier(snapshot.causalFrontier, "snapshot causal frontier");
|
|
1335
|
+
assertPositiveSafeInteger(
|
|
1336
|
+
snapshot.operationProtocolVersion,
|
|
1337
|
+
"snapshot operation protocol version",
|
|
1338
|
+
);
|
|
1339
|
+
} else {
|
|
1340
|
+
assertNonnegativeSafeInteger(snapshot.nativeRevision, "snapshot native revision");
|
|
1341
|
+
}
|
|
1342
|
+
if (!/^sha256:[0-9a-f]{64}$/u.test(snapshot.digest))
|
|
1343
|
+
throw new TypeError("snapshot digest is malformed");
|
|
1344
|
+
assertPositiveSafeInteger(snapshot.modelSchemaVersion, "snapshot model schema version");
|
|
1345
|
+
boundedString(
|
|
1346
|
+
snapshot.kernelVersion,
|
|
1347
|
+
"snapshot kernel version",
|
|
1348
|
+
EDITABLE_ARTIFACT_KERNEL_VERSION_MAX_BYTES,
|
|
1349
|
+
);
|
|
1350
|
+
if (
|
|
1351
|
+
!(snapshot.bytes instanceof Uint8Array) ||
|
|
1352
|
+
snapshot.bytes.byteLength > options.maxSnapshotBytes
|
|
1353
|
+
) {
|
|
1354
|
+
throw new EditableArtifactLiveError("oversized_frame", "Snapshot bytes exceed live limit", {
|
|
1355
|
+
requiresSnapshot: true,
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function validateHead(
|
|
1362
|
+
head:
|
|
1363
|
+
| EditableArtifactLiveBootstrap
|
|
1364
|
+
| Awaited<ReturnType<EditableArtifactLiveReadPort["readHead"]>>,
|
|
1365
|
+
durableModality: EditableArtifactModality,
|
|
1366
|
+
): void {
|
|
1367
|
+
if (head.modality !== durableModality) throw new TypeError("durable live head modality mismatch");
|
|
1368
|
+
assertNonnegativeSafeInteger(head.headSequence, "head sequence");
|
|
1369
|
+
assertNonnegativeSafeInteger(head.minimumReplaySequence, "minimum replay sequence");
|
|
1370
|
+
if (head.minimumReplaySequence > head.headSequence + 1)
|
|
1371
|
+
throw new TypeError("minimum replay sequence is beyond head");
|
|
1372
|
+
editableArtifactStateHash(head.stateHash);
|
|
1373
|
+
if (head.modality === "spreadsheet") {
|
|
1374
|
+
validateFrontier(head.causalFrontier, "head causal frontier");
|
|
1375
|
+
} else {
|
|
1376
|
+
assertNonnegativeSafeInteger(head.nativeRevision, "head native revision");
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function validatePage(
|
|
1381
|
+
page: {
|
|
1382
|
+
transactions: readonly EditableArtifactLiveCommittedTransaction[];
|
|
1383
|
+
headSequence: number;
|
|
1384
|
+
minimumReplaySequence: number;
|
|
1385
|
+
},
|
|
1386
|
+
after: number,
|
|
1387
|
+
through: number,
|
|
1388
|
+
maxCount: number,
|
|
1389
|
+
maxBytes: number,
|
|
1390
|
+
): void {
|
|
1391
|
+
if (!Array.isArray(page.transactions)) throw new TypeError("transaction page is malformed");
|
|
1392
|
+
assertNonnegativeSafeInteger(page.headSequence, "page head sequence");
|
|
1393
|
+
assertNonnegativeSafeInteger(page.minimumReplaySequence, "page minimum replay sequence");
|
|
1394
|
+
if (page.headSequence < through || after > through)
|
|
1395
|
+
throw new TypeError("transaction page does not cover requested target");
|
|
1396
|
+
if (page.transactions.length > maxCount) {
|
|
1397
|
+
throw new EditableArtifactLiveError(
|
|
1398
|
+
"oversized_frame",
|
|
1399
|
+
"Durable transaction page exceeds the count limit",
|
|
1400
|
+
{ requiresSnapshot: true },
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
let bytes = 0;
|
|
1404
|
+
for (const transaction of page.transactions) {
|
|
1405
|
+
bytes += transaction.committedTransactionBytes.byteLength;
|
|
1406
|
+
if (!Number.isSafeInteger(bytes) || bytes > maxBytes) {
|
|
1407
|
+
throw new EditableArtifactLiveError(
|
|
1408
|
+
"oversized_frame",
|
|
1409
|
+
"Durable transaction page exceeds the byte limit",
|
|
1410
|
+
{ requiresSnapshot: true },
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function validateTransaction(
|
|
1417
|
+
transaction: EditableArtifactLiveCommittedTransaction,
|
|
1418
|
+
artifactId: EditableArtifactId,
|
|
1419
|
+
durableModality: EditableArtifactModality,
|
|
1420
|
+
maxBytes: number,
|
|
1421
|
+
): void {
|
|
1422
|
+
if (transaction.artifactId !== artifactId) throw new TypeError("transaction artifact mismatch");
|
|
1423
|
+
if (transaction.modality !== durableModality)
|
|
1424
|
+
throw new TypeError("transaction modality differs from the durable artifact");
|
|
1425
|
+
editableArtifactTransactionId(transaction.transactionId);
|
|
1426
|
+
assertPositiveSafeInteger(transaction.startSequence, "transaction start sequence");
|
|
1427
|
+
assertPositiveSafeInteger(transaction.endSequence, "transaction end sequence");
|
|
1428
|
+
if (transaction.endSequence < transaction.startSequence)
|
|
1429
|
+
throw new TypeError("transaction sequence interval is inverted");
|
|
1430
|
+
editableArtifactRequestHash(transaction.requestHash);
|
|
1431
|
+
editableArtifactStateHash(transaction.priorStateHash);
|
|
1432
|
+
editableArtifactStateHash(transaction.stateHash);
|
|
1433
|
+
if (transaction.modality === "spreadsheet") {
|
|
1434
|
+
assertPositiveSafeInteger(
|
|
1435
|
+
transaction.operationProtocolVersion,
|
|
1436
|
+
"transaction operation protocol version",
|
|
1437
|
+
);
|
|
1438
|
+
validateFrontier(transaction.causalFrontier, "transaction causal frontier");
|
|
1439
|
+
} else {
|
|
1440
|
+
assertPositiveSafeInteger(
|
|
1441
|
+
transaction.commitProtocolVersion,
|
|
1442
|
+
"transaction commit protocol version",
|
|
1443
|
+
);
|
|
1444
|
+
assertNonnegativeSafeInteger(
|
|
1445
|
+
transaction.priorNativeRevision,
|
|
1446
|
+
"transaction prior native revision",
|
|
1447
|
+
);
|
|
1448
|
+
assertNonnegativeSafeInteger(transaction.nativeRevision, "transaction native revision");
|
|
1449
|
+
}
|
|
1450
|
+
if (
|
|
1451
|
+
!(transaction.committedTransactionBytes instanceof Uint8Array) ||
|
|
1452
|
+
transaction.committedTransactionBytes.byteLength === 0 ||
|
|
1453
|
+
transaction.committedTransactionBytes.byteLength > maxBytes
|
|
1454
|
+
) {
|
|
1455
|
+
throw new EditableArtifactLiveError(
|
|
1456
|
+
"oversized_frame",
|
|
1457
|
+
"Canonical transaction bytes exceed live limit",
|
|
1458
|
+
{ requiresSnapshot: true },
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
try {
|
|
1462
|
+
if (transaction.modality === "spreadsheet") {
|
|
1463
|
+
const summary = decodeCommittedTransactionSummary(transaction.committedTransactionBytes);
|
|
1464
|
+
if (
|
|
1465
|
+
summary.transactionId !== transaction.transactionId ||
|
|
1466
|
+
summary.priorStateHash !== transaction.priorStateHash ||
|
|
1467
|
+
summary.stateHash !== transaction.stateHash ||
|
|
1468
|
+
summary.operationProtocolVersion !== transaction.operationProtocolVersion ||
|
|
1469
|
+
!sameFrontier(summary.resultingCausalFrontier, transaction.causalFrontier)
|
|
1470
|
+
) {
|
|
1471
|
+
throw new TypeError("spreadsheet transaction projection mismatch");
|
|
1472
|
+
}
|
|
1473
|
+
} else {
|
|
1474
|
+
const summary = decodeEditableArtifactSerializedCommit(
|
|
1475
|
+
transaction.committedTransactionBytes,
|
|
1476
|
+
transaction.modality,
|
|
1477
|
+
);
|
|
1478
|
+
if (
|
|
1479
|
+
summary.transactionId !== transaction.transactionId ||
|
|
1480
|
+
summary.requestHash !== transaction.requestHash ||
|
|
1481
|
+
summary.parentHeadSequence !== transaction.startSequence - 1 ||
|
|
1482
|
+
summary.resultHeadSequence !== transaction.endSequence ||
|
|
1483
|
+
summary.priorStateHash !== transaction.priorStateHash ||
|
|
1484
|
+
summary.stateHash !== transaction.stateHash ||
|
|
1485
|
+
summary.priorNativeRevision !== transaction.priorNativeRevision ||
|
|
1486
|
+
summary.nativeReceipt.revision !== transaction.nativeRevision ||
|
|
1487
|
+
summary.commitProtocolVersion !== transaction.commitProtocolVersion
|
|
1488
|
+
) {
|
|
1489
|
+
throw new TypeError("serialized transaction projection mismatch");
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
} catch (cause) {
|
|
1493
|
+
throw new EditableArtifactLiveError(
|
|
1494
|
+
"durable_gap",
|
|
1495
|
+
"Canonical transaction bytes disagree with durable live metadata",
|
|
1496
|
+
{ requiresSnapshot: true, cause },
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function estimateFrameBytes(frame: EditableArtifactLiveServerFrame): number {
|
|
1502
|
+
if (frame.type === "snapshot") {
|
|
1503
|
+
const { bytes, ...metadata } = frame;
|
|
1504
|
+
return textEncoder.encode(JSON.stringify(metadata)).byteLength + bytes.byteLength + 128;
|
|
1505
|
+
}
|
|
1506
|
+
if (frame.type === "transaction") {
|
|
1507
|
+
const { committedTransactionBytes, ...transaction } = frame.transaction;
|
|
1508
|
+
return (
|
|
1509
|
+
textEncoder.encode(JSON.stringify({ ...frame, transaction })).byteLength +
|
|
1510
|
+
committedTransactionBytes.byteLength +
|
|
1511
|
+
128
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
return textEncoder.encode(JSON.stringify(frame)).byteLength;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function validateFrontier(
|
|
1518
|
+
frontier: EditableArtifactCausalFrontier,
|
|
1519
|
+
label: string,
|
|
1520
|
+
): EditableArtifactCausalFrontier {
|
|
1521
|
+
if (!Array.isArray(frontier) || frontier.length > MAX_CAUSAL_ACTORS) {
|
|
1522
|
+
throw new EditableArtifactLiveError(
|
|
1523
|
+
"oversized_frame",
|
|
1524
|
+
`${label} exceeds ${MAX_CAUSAL_ACTORS} actors`,
|
|
1525
|
+
{ requiresSnapshot: true },
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
const normalized = editableArtifactCausalFrontier(frontier);
|
|
1529
|
+
if (normalized.length > MAX_CAUSAL_ACTORS) {
|
|
1530
|
+
throw new EditableArtifactLiveError(
|
|
1531
|
+
"oversized_frame",
|
|
1532
|
+
`${label} exceeds ${MAX_CAUSAL_ACTORS} actors`,
|
|
1533
|
+
{ requiresSnapshot: true },
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
return normalized;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function sameFrontier(
|
|
1540
|
+
left: readonly Readonly<{ replicaId: string; counter: number }>[],
|
|
1541
|
+
right: readonly Readonly<{ replicaId: string; counter: number }>[],
|
|
1542
|
+
): boolean {
|
|
1543
|
+
return (
|
|
1544
|
+
left.length === right.length &&
|
|
1545
|
+
left.every(
|
|
1546
|
+
(entry, index) =>
|
|
1547
|
+
entry.replicaId === right[index]?.replicaId && entry.counter === right[index]?.counter,
|
|
1548
|
+
)
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function ownTransaction(
|
|
1553
|
+
transaction: EditableArtifactLiveCommittedTransaction,
|
|
1554
|
+
): EditableArtifactLiveCommittedTransaction {
|
|
1555
|
+
return transaction.modality === "spreadsheet"
|
|
1556
|
+
? Object.freeze({
|
|
1557
|
+
...transaction,
|
|
1558
|
+
causalFrontier: validateFrontier(transaction.causalFrontier, "transaction causal frontier"),
|
|
1559
|
+
committedTransactionBytes: transaction.committedTransactionBytes.slice(),
|
|
1560
|
+
})
|
|
1561
|
+
: Object.freeze({
|
|
1562
|
+
...transaction,
|
|
1563
|
+
committedTransactionBytes: transaction.committedTransactionBytes.slice(),
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function plainRecord(value: unknown, label: string): Record<string, unknown> {
|
|
1568
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1569
|
+
throw new EditableArtifactLiveError("invalid_frame", `${label} must be an object`);
|
|
1570
|
+
}
|
|
1571
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1572
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1573
|
+
throw new EditableArtifactLiveError("invalid_frame", `${label} must be a plain object`);
|
|
1574
|
+
}
|
|
1575
|
+
return value as Record<string, unknown>;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function requiredString(record: Record<string, unknown>, key: string, maxBytes: number): string {
|
|
1579
|
+
const value = record[key];
|
|
1580
|
+
if (typeof value !== "string")
|
|
1581
|
+
throw new EditableArtifactLiveError("invalid_frame", `${key} must be a string`);
|
|
1582
|
+
boundedString(value, key, maxBytes);
|
|
1583
|
+
return value;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function requiredSafeInteger(record: Record<string, unknown>, key: string): number {
|
|
1587
|
+
const value = record[key];
|
|
1588
|
+
if (!Number.isSafeInteger(value))
|
|
1589
|
+
throw new EditableArtifactLiveError("invalid_frame", `${key} must be a safe integer`);
|
|
1590
|
+
return value as number;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function rejectUnknown(record: Record<string, unknown>, allowed: readonly string[]): void {
|
|
1594
|
+
const allowedSet = new Set(allowed);
|
|
1595
|
+
const unknown = Object.keys(record).find((key) => !allowedSet.has(key));
|
|
1596
|
+
if (unknown)
|
|
1597
|
+
throw new EditableArtifactLiveError(
|
|
1598
|
+
"invalid_frame",
|
|
1599
|
+
`Unknown client frame property: ${unknown}`,
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function boundedString(value: string, label: string, maxBytes: number): void {
|
|
1604
|
+
const bytes = textEncoder.encode(value).byteLength;
|
|
1605
|
+
if (bytes < 1 || bytes > maxBytes || value.trim() !== value)
|
|
1606
|
+
throw new TypeError(`${label} is malformed`);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function normalizeOptions(options: EditableArtifactLiveServerOptions): NormalizedOptions {
|
|
1610
|
+
const normalized = {
|
|
1611
|
+
protocolVersion: options.protocolVersion ?? EDITABLE_ARTIFACT_LIVE_PROTOCOL_VERSION,
|
|
1612
|
+
maxClientFrameBytes: options.maxClientFrameBytes ?? DEFAULT_MAX_CLIENT_FRAME_BYTES,
|
|
1613
|
+
maxOutboundFrameBytes: options.maxOutboundFrameBytes ?? DEFAULT_MAX_OUTBOUND_FRAME_BYTES,
|
|
1614
|
+
maxSnapshotBytes: options.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES,
|
|
1615
|
+
snapshotChunkBytes: options.snapshotChunkBytes ?? DEFAULT_SNAPSHOT_CHUNK_BYTES,
|
|
1616
|
+
maxInFlightTransactions: options.maxInFlightTransactions ?? DEFAULT_MAX_IN_FLIGHT_TRANSACTIONS,
|
|
1617
|
+
maxInFlightBytes: options.maxInFlightBytes ?? DEFAULT_MAX_IN_FLIGHT_BYTES,
|
|
1618
|
+
replayPageTransactions: options.replayPageTransactions ?? DEFAULT_REPLAY_PAGE_TRANSACTIONS,
|
|
1619
|
+
replayPageBytes: options.replayPageBytes ?? DEFAULT_REPLAY_PAGE_BYTES,
|
|
1620
|
+
maxSocketBufferedBytes: options.maxSocketBufferedBytes ?? DEFAULT_MAX_SOCKET_BUFFERED_BYTES,
|
|
1621
|
+
maxInboundQueueFrames: options.maxInboundQueueFrames ?? DEFAULT_MAX_INBOUND_QUEUE_FRAMES,
|
|
1622
|
+
maxInboundQueueBytes: options.maxInboundQueueBytes ?? DEFAULT_MAX_INBOUND_QUEUE_BYTES,
|
|
1623
|
+
reconcileIntervalMs: options.reconcileIntervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS,
|
|
1624
|
+
reauthorizeIntervalMs: options.reauthorizeIntervalMs ?? DEFAULT_REAUTHORIZE_INTERVAL_MS,
|
|
1625
|
+
ackTimeoutMs: options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS,
|
|
1626
|
+
sendTimeoutMs: options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
|
|
1627
|
+
...(options.ticketTtlMs === undefined ? {} : { ticketTtlMs: options.ticketTtlMs }),
|
|
1628
|
+
};
|
|
1629
|
+
for (const [label, value] of Object.entries(normalized)) {
|
|
1630
|
+
if (label === "ticketTtlMs") continue;
|
|
1631
|
+
assertPositiveSafeInteger(value, label);
|
|
1632
|
+
}
|
|
1633
|
+
if (normalized.maxSnapshotBytes > EDITABLE_ARTIFACT_PRODUCT_MAX_SNAPSHOT_BYTES) {
|
|
1634
|
+
throw new TypeError("maxSnapshotBytes exceeds the browser-compatible product limit");
|
|
1635
|
+
}
|
|
1636
|
+
if (normalized.snapshotChunkBytes + 1024 > normalized.maxOutboundFrameBytes) {
|
|
1637
|
+
throw new TypeError("snapshot chunk exceeds maximum outbound frame");
|
|
1638
|
+
}
|
|
1639
|
+
if (normalized.replayPageBytes < normalized.maxOutboundFrameBytes) {
|
|
1640
|
+
throw new TypeError("replay page bytes must cover one maximum outbound frame");
|
|
1641
|
+
}
|
|
1642
|
+
return Object.freeze(normalized);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function closeValue(reason: EditableArtifactLiveCloseReason): EditableArtifactLiveClose {
|
|
1646
|
+
return Object.freeze({
|
|
1647
|
+
reason,
|
|
1648
|
+
retryable: ["ticket_expired", "permission_changed", "slow_client", "transport_error"].includes(
|
|
1649
|
+
reason,
|
|
1650
|
+
),
|
|
1651
|
+
requiresSnapshot: [
|
|
1652
|
+
"invalid_ack",
|
|
1653
|
+
"retention_gap",
|
|
1654
|
+
"durable_gap",
|
|
1655
|
+
"slow_client",
|
|
1656
|
+
"oversized_frame",
|
|
1657
|
+
].includes(reason),
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function safeRelease(release: (() => void) | null): void {
|
|
1662
|
+
try {
|
|
1663
|
+
release?.();
|
|
1664
|
+
} catch {
|
|
1665
|
+
// Subscription/listener teardown is best effort after the session has
|
|
1666
|
+
// already transitioned to its terminal state.
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
export class SystemEditableArtifactLiveClock implements EditableArtifactLiveClockPort {
|
|
1671
|
+
now(): Date {
|
|
1672
|
+
return new Date();
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
export class SystemEditableArtifactLiveScheduler implements EditableArtifactLiveSchedulerPort {
|
|
1677
|
+
sleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
1678
|
+
return new Promise((resolve, reject) => {
|
|
1679
|
+
if (signal.aborted) {
|
|
1680
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
1684
|
+
signal.addEventListener(
|
|
1685
|
+
"abort",
|
|
1686
|
+
() => {
|
|
1687
|
+
clearTimeout(timer);
|
|
1688
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
1689
|
+
},
|
|
1690
|
+
{ once: true },
|
|
1691
|
+
);
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Re-export port names from this module's type surface for structural server factories.
|
|
1697
|
+
export type {
|
|
1698
|
+
EditableArtifactAuthorizationPort,
|
|
1699
|
+
EditableArtifactService,
|
|
1700
|
+
EditableArtifactLiveAuthorizationInvalidationPort,
|
|
1701
|
+
EditableArtifactLiveHintPort,
|
|
1702
|
+
EditableArtifactLiveReadPort,
|
|
1703
|
+
EditableArtifactLiveSchedulerPort,
|
|
1704
|
+
EditableArtifactLiveSinkPort,
|
|
1705
|
+
EditableArtifactLiveTicketStorePort,
|
|
1706
|
+
EditableArtifactLiveTokenPort,
|
|
1707
|
+
};
|