@opengeni/sdk 0.48.0 → 0.57.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/README.md +147 -1
- package/dist/artifact-client.d.ts +20 -2
- package/dist/artifacts.d.ts +4 -0
- package/dist/artifacts.js +11 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/{chunk-V5F253OG.js → chunk-GU6JF75T.js} +6 -3
- package/dist/chunk-GU6JF75T.js.map +1 -0
- package/dist/chunk-HJ3HHUT5.js +222 -0
- package/dist/chunk-HJ3HHUT5.js.map +1 -0
- package/dist/{chunk-KIHGPM7H.js → chunk-JDLDDRNE.js} +20 -14
- package/dist/chunk-JDLDDRNE.js.map +1 -0
- package/dist/chunk-JRPFWI6T.js +128 -0
- package/dist/chunk-JRPFWI6T.js.map +1 -0
- package/dist/{chunk-MBGBLVUV.js → chunk-PKQ377ED.js} +1725 -555
- package/dist/chunk-PKQ377ED.js.map +1 -0
- package/dist/chunk-ST5DDKJP.js +987 -0
- package/dist/chunk-ST5DDKJP.js.map +1 -0
- package/dist/chunk-VWE2QIVO.js +1162 -0
- package/dist/chunk-VWE2QIVO.js.map +1 -0
- package/dist/chunk-VYCTL62C.js +230 -0
- package/dist/chunk-VYCTL62C.js.map +1 -0
- package/dist/{chunk-DXDL7EEW.js → chunk-YKZME56C.js} +197 -113
- package/dist/chunk-YKZME56C.js.map +1 -0
- package/dist/client.d.ts +240 -16
- package/dist/codex-realtime-controller.d.ts +9 -6
- package/dist/codex-realtime-controller.js +2 -2
- package/dist/codex-realtime-lifecycle.d.ts +1 -1
- package/dist/codex-realtime-v3.d.ts +3 -3
- package/dist/codex-realtime.d.ts +1 -1
- package/dist/company-profile.d.ts +100 -0
- package/dist/core.d.ts +5 -5
- package/dist/core.js +10 -5
- package/dist/desktop.d.ts +9 -1
- package/dist/editable-artifact-resources.d.ts +96 -0
- package/dist/editable-artifacts/browser-session.d.ts +35 -0
- package/dist/editable-artifacts/controller.d.ts +60 -0
- package/dist/editable-artifacts/errors.d.ts +23 -0
- package/dist/editable-artifacts/http-live-transport.d.ts +46 -0
- package/dist/editable-artifacts/index.d.ts +19 -0
- package/dist/editable-artifacts/pool.d.ts +15 -0
- package/dist/editable-artifacts/session.d.ts +96 -0
- package/dist/editable-artifacts/storage.d.ts +72 -0
- package/dist/editable-artifacts/types.d.ts +416 -0
- package/dist/editable-artifacts/worker/browser-client.d.ts +80 -0
- package/dist/editable-artifacts/worker/browser-entry.d.ts +6 -0
- package/dist/editable-artifacts/worker/kernel-adapter.d.ts +45 -0
- package/dist/editable-artifacts/worker/rpc-protocol.d.ts +92 -0
- package/dist/editable-artifacts/worker/runtime.d.ts +66 -0
- package/dist/editable-artifacts/worker/wire-codec.d.ts +52 -0
- package/dist/editable-artifacts-worker.d.ts +8 -0
- package/dist/editable-artifacts-worker.js +1708 -0
- package/dist/editable-artifacts-worker.js.map +1 -0
- package/dist/editable-artifacts.d.ts +6 -0
- package/dist/editable-artifacts.js +5954 -0
- package/dist/editable-artifacts.js.map +1 -0
- package/dist/gateway-realtime-transport.d.ts +2 -1
- package/dist/gateway-realtime-transport.js +5 -3
- package/dist/index.d.ts +45 -35
- package/dist/index.js +134 -64
- package/dist/index.js.map +1 -1
- package/dist/interaction-revision-stream.d.ts +11 -0
- package/dist/interaction.d.ts +1484 -0
- package/dist/interaction.js +81 -0
- package/dist/interaction.js.map +1 -0
- package/dist/memory-slack-client.d.ts +13 -0
- package/dist/memory-slack-delivery.d.ts +99 -0
- package/dist/memory-slack.d.ts +4 -0
- package/dist/memory-slack.js +44 -0
- package/dist/memory-slack.js.map +1 -0
- package/dist/proxy.d.ts +3 -3
- package/dist/realtime.d.ts +14 -14
- package/dist/realtime.js +11 -7
- package/dist/realtime.js.map +1 -1
- package/dist/retained-artifacts.d.ts +12 -0
- package/dist/stream.d.ts +1 -1
- package/dist/terminal.d.ts +1 -1
- package/dist/types.d.ts +1347 -34
- package/dist/workspace-control-stream.d.ts +2 -2
- package/dist/workspace-live-stream.d.ts +14 -0
- package/dist/workspace-state.d.ts +1 -1
- package/package.json +29 -3
- package/src/artifact-client.ts +129 -0
- package/src/artifacts.ts +17 -0
- package/src/client.ts +3022 -910
- package/src/codex-realtime-controller.ts +25 -7
- package/src/codex-realtime-lifecycle.ts +1 -0
- package/src/company-profile.ts +92 -0
- package/src/desktop.ts +11 -1
- package/src/editable-artifact-resources.ts +119 -0
- package/src/editable-artifacts/browser-session.ts +176 -0
- package/src/editable-artifacts/controller.ts +2632 -0
- package/src/editable-artifacts/errors.ts +52 -0
- package/src/editable-artifacts/http-live-transport.ts +1449 -0
- package/src/editable-artifacts/index.ts +175 -0
- package/src/editable-artifacts/pool.ts +81 -0
- package/src/editable-artifacts/session.ts +778 -0
- package/src/editable-artifacts/storage.ts +1718 -0
- package/src/editable-artifacts/types.ts +499 -0
- package/src/editable-artifacts/worker/browser-client.ts +1167 -0
- package/src/editable-artifacts/worker/browser-entry.ts +57 -0
- package/src/editable-artifacts/worker/kernel-adapter.ts +1039 -0
- package/src/editable-artifacts/worker/rpc-protocol.ts +622 -0
- package/src/editable-artifacts/worker/runtime.ts +1204 -0
- package/src/editable-artifacts/worker/wire-codec.ts +852 -0
- package/src/editable-artifacts-worker.ts +8 -0
- package/src/editable-artifacts.ts +6 -0
- package/src/errors.ts +16 -2
- package/src/gateway-realtime-transport.ts +252 -109
- package/src/index.ts +215 -1
- package/src/interaction-revision-stream.ts +117 -0
- package/src/interaction.ts +2874 -0
- package/src/memory-slack-client.ts +71 -0
- package/src/memory-slack-delivery.ts +128 -0
- package/src/memory-slack.ts +19 -0
- package/src/realtime.ts +14 -4
- package/src/retained-artifacts.ts +326 -0
- package/src/types.ts +1636 -52
- package/src/workspace-live-stream.ts +137 -0
- package/dist/chunk-DXDL7EEW.js.map +0 -1
- package/dist/chunk-KIHGPM7H.js.map +0 -1
- package/dist/chunk-MBGBLVUV.js.map +0 -1
- package/dist/chunk-V5F253OG.js.map +0 -1
|
@@ -0,0 +1,2632 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeEditableArtifactMutationIntent,
|
|
3
|
+
EDITABLE_ARTIFACT_COMMAND_MAX_BYTES,
|
|
4
|
+
EDITABLE_ARTIFACT_INTENT_MAX_BYTES,
|
|
5
|
+
EDITABLE_ARTIFACT_PRODUCT_MAX_SNAPSHOT_BYTES,
|
|
6
|
+
hashEditableArtifactMutationIntentBytes,
|
|
7
|
+
} from "@opengeni/contracts/editable-artifacts";
|
|
8
|
+
import {
|
|
9
|
+
decodeCommittedTransactionSummary,
|
|
10
|
+
MAX_COMMITTED_TRANSACTION_BYTES,
|
|
11
|
+
} from "@opengeni/contracts/editable-artifact-committed-transaction";
|
|
12
|
+
import { editableArtifactCodecFor } from "@opengeni/contracts/editable-artifact-codec-registry";
|
|
13
|
+
import { decodeEditableArtifactSerializedCommit } from "@opengeni/contracts/editable-artifact-serialized-commit";
|
|
14
|
+
import { EditableArtifactSyncError } from "./errors";
|
|
15
|
+
import { EditableArtifactStorageConflictError } from "./storage";
|
|
16
|
+
import type { EditableArtifactStoragePort, EditableArtifactStorageScope } from "./storage";
|
|
17
|
+
import type {
|
|
18
|
+
EditableArtifactBootstrap,
|
|
19
|
+
EditableArtifactBlockedPending,
|
|
20
|
+
EditableArtifactCausalFrontier,
|
|
21
|
+
EditableArtifactCommittedTransaction,
|
|
22
|
+
EditableArtifactId,
|
|
23
|
+
EditableArtifactLiveConnection,
|
|
24
|
+
EditableArtifactLiveLimits,
|
|
25
|
+
EditableArtifactLiveMessage,
|
|
26
|
+
EditableArtifactModality,
|
|
27
|
+
EditableArtifactPendingTransaction,
|
|
28
|
+
EditableArtifactSerializedCommittedTransaction,
|
|
29
|
+
EditableArtifactSerializedModality,
|
|
30
|
+
EditableArtifactSerializedSnapshot,
|
|
31
|
+
EditableArtifactSpreadsheetCommittedTransaction,
|
|
32
|
+
EditableArtifactSpreadsheetPendingTransaction,
|
|
33
|
+
EditableArtifactSpreadsheetSnapshot,
|
|
34
|
+
EditableArtifactStoredReplica,
|
|
35
|
+
EditableArtifactSubmitReceipt,
|
|
36
|
+
EditableArtifactSyncListener,
|
|
37
|
+
EditableArtifactSyncScheduler,
|
|
38
|
+
EditableArtifactSyncState,
|
|
39
|
+
EditableArtifactSyncTicket,
|
|
40
|
+
EditableArtifactSyncTransport,
|
|
41
|
+
EditableArtifactSyncView,
|
|
42
|
+
EditableArtifactWorkerKernel,
|
|
43
|
+
} from "./types";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_PROTOCOL_VERSION = 1;
|
|
46
|
+
const DEFAULT_REPLAY_PAGE_SIZE = 256;
|
|
47
|
+
const DEFAULT_MAX_QUEUE_MESSAGES = 512;
|
|
48
|
+
const DEFAULT_MAX_QUEUE_BYTES = 8 * 1024 * 1024;
|
|
49
|
+
const DEFAULT_MAX_STORED_TAIL = 8_192;
|
|
50
|
+
const DEFAULT_MAX_SNAPSHOT_BYTES = EDITABLE_ARTIFACT_PRODUCT_MAX_SNAPSHOT_BYTES;
|
|
51
|
+
const DEFAULT_MAX_COMMAND_BYTES = EDITABLE_ARTIFACT_COMMAND_MAX_BYTES;
|
|
52
|
+
const DEFAULT_MAX_OPERATION_BYTES = 8 * 1024 * 1024;
|
|
53
|
+
const DEFAULT_MAX_REPLAY_PAGE_BYTES = 32 * 1024 * 1024;
|
|
54
|
+
const DEFAULT_MAX_PENDING_TRANSACTIONS = 256;
|
|
55
|
+
const DEFAULT_MAX_PENDING_BYTES = 32 * 1024 * 1024;
|
|
56
|
+
const DEFAULT_MIN_TICKET_TTL_MS = 5_000;
|
|
57
|
+
const MAX_IDENTIFIER_BYTES = 256;
|
|
58
|
+
const MAX_KERNEL_VERSION_BYTES = 512;
|
|
59
|
+
const MAX_TICKET_BYTES = 4_096;
|
|
60
|
+
const MAX_FRONTIER_ACTORS = 1_024;
|
|
61
|
+
|
|
62
|
+
export type CreateEditableArtifactSyncControllerOptions = {
|
|
63
|
+
artifactId: EditableArtifactId;
|
|
64
|
+
modality: EditableArtifactModality;
|
|
65
|
+
/** Authenticated cache authority; every field participates in IndexedDB partitioning. */
|
|
66
|
+
storageAuthority: EditableArtifactCacheAuthority;
|
|
67
|
+
transport: EditableArtifactSyncTransport;
|
|
68
|
+
storage: EditableArtifactStoragePort;
|
|
69
|
+
kernel: EditableArtifactWorkerKernel;
|
|
70
|
+
kernelVersion: string;
|
|
71
|
+
modelSchemaVersion: number;
|
|
72
|
+
commandVersion: number;
|
|
73
|
+
protocolVersion?: number;
|
|
74
|
+
replayPageSize?: number;
|
|
75
|
+
maxQueuedMessages?: number;
|
|
76
|
+
maxQueuedBytes?: number;
|
|
77
|
+
maxStoredTailTransactions?: number;
|
|
78
|
+
maxSnapshotBytes?: number;
|
|
79
|
+
maxCommandBytes?: number;
|
|
80
|
+
maxCommittedTransactionBytes?: number;
|
|
81
|
+
maxReplayPageBytes?: number;
|
|
82
|
+
maxPendingTransactions?: number;
|
|
83
|
+
maxPendingBytes?: number;
|
|
84
|
+
minTicketTtlMs?: number;
|
|
85
|
+
reconnectDelayMs?: number;
|
|
86
|
+
maxReconnectDelayMs?: number;
|
|
87
|
+
maxReconnectAttempts?: number;
|
|
88
|
+
scheduler?: EditableArtifactSyncScheduler;
|
|
89
|
+
clientTransactionIdFactory?: () => string;
|
|
90
|
+
/** Tests/embedded runtimes may inject; defaults to a fresh per-controller UUID. */
|
|
91
|
+
writerReplicaIdFactory?: () => string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type EditableArtifactCacheAuthority = {
|
|
95
|
+
deploymentOrigin: string;
|
|
96
|
+
accountId: string;
|
|
97
|
+
workspaceId: string;
|
|
98
|
+
principalId: string;
|
|
99
|
+
/** Host-auth revision/session epoch; rotate on logout, grant change, or account switch. */
|
|
100
|
+
authorizationEpoch: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type EditableArtifactQueueCommandsInput = {
|
|
104
|
+
commandBytes: Uint8Array;
|
|
105
|
+
selectiveUndoTargets?: readonly string[];
|
|
106
|
+
/** Optional deterministic ID for host-driven retries before WAL persistence. */
|
|
107
|
+
clientTransactionId?: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type EditableArtifactSyncController = {
|
|
111
|
+
readonly artifactId: EditableArtifactId;
|
|
112
|
+
readonly modality: EditableArtifactModality;
|
|
113
|
+
start: () => void;
|
|
114
|
+
whenLive: () => Promise<void>;
|
|
115
|
+
close: () => Promise<void>;
|
|
116
|
+
getView: () => EditableArtifactSyncView;
|
|
117
|
+
subscribe: (listener: EditableArtifactSyncListener) => () => void;
|
|
118
|
+
/** Kernel authors/hashes the immutable envelope; SDK WAL persists before delivery. */
|
|
119
|
+
queueCommands: (
|
|
120
|
+
input: EditableArtifactQueueCommandsInput,
|
|
121
|
+
) => Promise<EditableArtifactPendingTransaction>;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type QueuedLiveMessage = {
|
|
125
|
+
message: EditableArtifactLiveMessage;
|
|
126
|
+
bytes: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type LiveWaiter = {
|
|
130
|
+
resolve: () => void;
|
|
131
|
+
reject: (error: Error) => void;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type ControllerReplicaCommon = {
|
|
135
|
+
cursor: number;
|
|
136
|
+
stateHash: string;
|
|
137
|
+
updatedAt: number;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
type ControllerReplica = ControllerReplicaCommon &
|
|
141
|
+
(
|
|
142
|
+
| {
|
|
143
|
+
modality: "spreadsheet";
|
|
144
|
+
snapshot: EditableArtifactSpreadsheetSnapshot;
|
|
145
|
+
causalFrontier: EditableArtifactCausalFrontier;
|
|
146
|
+
}
|
|
147
|
+
| {
|
|
148
|
+
modality: EditableArtifactSerializedModality;
|
|
149
|
+
snapshot: EditableArtifactSerializedSnapshot;
|
|
150
|
+
nativeRevision: number;
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
export function createEditableArtifactSyncController(
|
|
155
|
+
options: CreateEditableArtifactSyncControllerOptions,
|
|
156
|
+
): EditableArtifactSyncController {
|
|
157
|
+
return new EditableArtifactSyncControllerImpl(options);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
class EditableArtifactSyncControllerImpl implements EditableArtifactSyncController {
|
|
161
|
+
readonly artifactId: EditableArtifactId;
|
|
162
|
+
readonly modality: EditableArtifactModality;
|
|
163
|
+
|
|
164
|
+
private readonly transport: EditableArtifactSyncTransport;
|
|
165
|
+
private readonly storage: EditableArtifactStoragePort;
|
|
166
|
+
private readonly storageScope: EditableArtifactStorageScope;
|
|
167
|
+
private readonly kernel: EditableArtifactWorkerKernel;
|
|
168
|
+
private readonly writerReplicaId: string;
|
|
169
|
+
private readonly kernelVersion: string;
|
|
170
|
+
private readonly modelSchemaVersion: number;
|
|
171
|
+
private readonly commandVersion: number;
|
|
172
|
+
private readonly protocolVersion: number;
|
|
173
|
+
private readonly replayPageSize: number;
|
|
174
|
+
private readonly maxQueuedMessages: number;
|
|
175
|
+
private readonly maxQueuedBytes: number;
|
|
176
|
+
private readonly maxStoredTailTransactions: number;
|
|
177
|
+
private readonly maxSnapshotBytes: number;
|
|
178
|
+
private readonly maxCommandBytes: number;
|
|
179
|
+
private readonly maxCommittedTransactionBytes: number;
|
|
180
|
+
private readonly maxReplayPageBytes: number;
|
|
181
|
+
private readonly maxPendingTransactions: number;
|
|
182
|
+
private readonly maxPendingBytes: number;
|
|
183
|
+
private readonly minTicketTtlMs: number;
|
|
184
|
+
private readonly reconnectDelayMs: number;
|
|
185
|
+
private readonly maxReconnectDelayMs: number;
|
|
186
|
+
private readonly maxReconnectAttempts: number;
|
|
187
|
+
private readonly scheduler: EditableArtifactSyncScheduler;
|
|
188
|
+
private readonly clientTransactionIdFactory: () => string;
|
|
189
|
+
|
|
190
|
+
private readonly abortController = new AbortController();
|
|
191
|
+
private readonly listeners = new Set<EditableArtifactSyncListener>();
|
|
192
|
+
private readonly liveWaiters = new Set<LiveWaiter>();
|
|
193
|
+
private readonly pending = new Map<string, EditableArtifactPendingTransaction>();
|
|
194
|
+
private blockedPending: readonly EditableArtifactBlockedPending[] = [];
|
|
195
|
+
private readonly submitBlockedPending = new Map<string, EditableArtifactBlockedPending>();
|
|
196
|
+
|
|
197
|
+
private state: EditableArtifactSyncState = "idle";
|
|
198
|
+
private cursor = 0;
|
|
199
|
+
private headSequence = 0;
|
|
200
|
+
private reconnectAttempt = 0;
|
|
201
|
+
private lastError: Error | null = null;
|
|
202
|
+
private writable = false;
|
|
203
|
+
private replica: ControllerReplica | null = null;
|
|
204
|
+
private retainedTailTransactions = 0;
|
|
205
|
+
private retainedStorageHead: { cursor: number; stateHash: string } | null = null;
|
|
206
|
+
private initialized = false;
|
|
207
|
+
private initializeTask: Promise<void> | null = null;
|
|
208
|
+
private runTask: Promise<void> | null = null;
|
|
209
|
+
private activeTicket: EditableArtifactSyncTicket | null = null;
|
|
210
|
+
private activeConnection: EditableArtifactLiveConnection | null = null;
|
|
211
|
+
private activeLimits: EditableArtifactLiveLimits | null = null;
|
|
212
|
+
private activeEpoch = 0;
|
|
213
|
+
private connectionReady = false;
|
|
214
|
+
private requireSnapshot = false;
|
|
215
|
+
private queuedBytes = 0;
|
|
216
|
+
private readonly liveQueue: QueuedLiveMessage[] = [];
|
|
217
|
+
private drainTask: Promise<void> | null = null;
|
|
218
|
+
private processingFailure: Error | null = null;
|
|
219
|
+
private flushTask: Promise<void> | null = null;
|
|
220
|
+
private readonly flushTasks = new Set<Promise<void>>();
|
|
221
|
+
/**
|
|
222
|
+
* Serializes every operation that moves the Worker and controller causal
|
|
223
|
+
* heads. The Worker commits before its RPC response reaches this thread, so
|
|
224
|
+
* authoring must not observe the controller cursor during that response gap.
|
|
225
|
+
*/
|
|
226
|
+
private causalTail: Promise<void> = Promise.resolve();
|
|
227
|
+
private resyncRevision = 0;
|
|
228
|
+
private readonly submittedEpoch = new Map<string, number>();
|
|
229
|
+
/** Receipt-proven mapping from authoritative OGACO/OGAST ids to caller OGATX ids. */
|
|
230
|
+
private readonly acceptedMappings = new Map<
|
|
231
|
+
string,
|
|
232
|
+
Readonly<{ clientTransactionId: string; requestHash: string }>
|
|
233
|
+
>();
|
|
234
|
+
private lastLocalTransactionId: string | null = null;
|
|
235
|
+
private lastLocalReplicaCounter = 0;
|
|
236
|
+
|
|
237
|
+
constructor(options: CreateEditableArtifactSyncControllerOptions) {
|
|
238
|
+
this.artifactId = requireStableId(options.artifactId, "artifactId");
|
|
239
|
+
this.modality = requireModality(options.modality);
|
|
240
|
+
this.transport = options.transport;
|
|
241
|
+
this.storage = options.storage;
|
|
242
|
+
this.storageScope = {
|
|
243
|
+
namespace: editableArtifactCacheNamespace(options.storageAuthority),
|
|
244
|
+
artifactId: this.artifactId,
|
|
245
|
+
modality: this.modality,
|
|
246
|
+
};
|
|
247
|
+
this.kernel = options.kernel;
|
|
248
|
+
this.writerReplicaId = boundedNonEmpty(
|
|
249
|
+
(options.writerReplicaIdFactory ?? defaultWriterReplicaId)(),
|
|
250
|
+
"writerReplicaId",
|
|
251
|
+
MAX_IDENTIFIER_BYTES,
|
|
252
|
+
);
|
|
253
|
+
requireReplicaId(this.writerReplicaId, "writerReplicaId");
|
|
254
|
+
this.kernelVersion = boundedNonEmpty(
|
|
255
|
+
options.kernelVersion,
|
|
256
|
+
"kernelVersion",
|
|
257
|
+
MAX_KERNEL_VERSION_BYTES,
|
|
258
|
+
);
|
|
259
|
+
this.modelSchemaVersion = positiveSafeInteger(options.modelSchemaVersion, "modelSchemaVersion");
|
|
260
|
+
this.commandVersion = positiveSafeInteger(options.commandVersion, "commandVersion");
|
|
261
|
+
this.protocolVersion = positiveSafeInteger(
|
|
262
|
+
options.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
263
|
+
"protocolVersion",
|
|
264
|
+
);
|
|
265
|
+
this.replayPageSize = positiveSafeInteger(
|
|
266
|
+
options.replayPageSize ?? DEFAULT_REPLAY_PAGE_SIZE,
|
|
267
|
+
"replayPageSize",
|
|
268
|
+
);
|
|
269
|
+
this.maxQueuedMessages = positiveSafeInteger(
|
|
270
|
+
options.maxQueuedMessages ?? DEFAULT_MAX_QUEUE_MESSAGES,
|
|
271
|
+
"maxQueuedMessages",
|
|
272
|
+
);
|
|
273
|
+
this.maxQueuedBytes = positiveSafeInteger(
|
|
274
|
+
options.maxQueuedBytes ?? DEFAULT_MAX_QUEUE_BYTES,
|
|
275
|
+
"maxQueuedBytes",
|
|
276
|
+
);
|
|
277
|
+
this.maxStoredTailTransactions = positiveSafeInteger(
|
|
278
|
+
options.maxStoredTailTransactions ?? DEFAULT_MAX_STORED_TAIL,
|
|
279
|
+
"maxStoredTailTransactions",
|
|
280
|
+
);
|
|
281
|
+
this.maxSnapshotBytes = positiveSafeInteger(
|
|
282
|
+
options.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES,
|
|
283
|
+
"maxSnapshotBytes",
|
|
284
|
+
);
|
|
285
|
+
this.maxCommandBytes = positiveSafeInteger(
|
|
286
|
+
options.maxCommandBytes ?? DEFAULT_MAX_COMMAND_BYTES,
|
|
287
|
+
"maxCommandBytes",
|
|
288
|
+
);
|
|
289
|
+
if (this.maxCommandBytes > EDITABLE_ARTIFACT_COMMAND_MAX_BYTES) {
|
|
290
|
+
throw new RangeError("maxCommandBytes exceeds the editable-artifact contract bound");
|
|
291
|
+
}
|
|
292
|
+
this.maxCommittedTransactionBytes = positiveSafeInteger(
|
|
293
|
+
options.maxCommittedTransactionBytes ?? DEFAULT_MAX_OPERATION_BYTES,
|
|
294
|
+
"maxCommittedTransactionBytes",
|
|
295
|
+
);
|
|
296
|
+
this.maxReplayPageBytes = positiveSafeInteger(
|
|
297
|
+
options.maxReplayPageBytes ?? DEFAULT_MAX_REPLAY_PAGE_BYTES,
|
|
298
|
+
"maxReplayPageBytes",
|
|
299
|
+
);
|
|
300
|
+
this.maxPendingTransactions = positiveSafeInteger(
|
|
301
|
+
options.maxPendingTransactions ?? DEFAULT_MAX_PENDING_TRANSACTIONS,
|
|
302
|
+
"maxPendingTransactions",
|
|
303
|
+
);
|
|
304
|
+
this.maxPendingBytes = positiveSafeInteger(
|
|
305
|
+
options.maxPendingBytes ?? DEFAULT_MAX_PENDING_BYTES,
|
|
306
|
+
"maxPendingBytes",
|
|
307
|
+
);
|
|
308
|
+
this.minTicketTtlMs = nonNegativeSafeInteger(
|
|
309
|
+
options.minTicketTtlMs ?? DEFAULT_MIN_TICKET_TTL_MS,
|
|
310
|
+
"minTicketTtlMs",
|
|
311
|
+
);
|
|
312
|
+
this.reconnectDelayMs = nonNegativeSafeInteger(
|
|
313
|
+
options.reconnectDelayMs ?? 250,
|
|
314
|
+
"reconnectDelayMs",
|
|
315
|
+
);
|
|
316
|
+
this.maxReconnectDelayMs = nonNegativeSafeInteger(
|
|
317
|
+
options.maxReconnectDelayMs ?? 5_000,
|
|
318
|
+
"maxReconnectDelayMs",
|
|
319
|
+
);
|
|
320
|
+
this.maxReconnectAttempts =
|
|
321
|
+
options.maxReconnectAttempts === undefined
|
|
322
|
+
? Number.POSITIVE_INFINITY
|
|
323
|
+
: nonNegativeSafeInteger(options.maxReconnectAttempts, "maxReconnectAttempts");
|
|
324
|
+
this.scheduler = options.scheduler ?? defaultScheduler;
|
|
325
|
+
this.clientTransactionIdFactory =
|
|
326
|
+
options.clientTransactionIdFactory ?? (() => crypto.randomUUID());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
start(): void {
|
|
330
|
+
if (this.runTask || this.state === "closed") return;
|
|
331
|
+
this.runTask = this.run().catch((error: unknown) => {
|
|
332
|
+
if (this.abortController.signal.aborted) return;
|
|
333
|
+
const failure = asError(error);
|
|
334
|
+
if (failure instanceof EditableArtifactSyncError && failure.code === "unsupported_protocol") {
|
|
335
|
+
this.lastError = failure;
|
|
336
|
+
this.setState("unsupported");
|
|
337
|
+
this.rejectLiveWaiters(failure);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this.fail(failure);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async whenLive(): Promise<void> {
|
|
345
|
+
if (this.state === "live") return;
|
|
346
|
+
if (isTerminal(this.state)) throw this.lastError ?? new Error(`artifact sync is ${this.state}`);
|
|
347
|
+
this.start();
|
|
348
|
+
await new Promise<void>((resolve, reject) => {
|
|
349
|
+
this.liveWaiters.add({ resolve, reject });
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async close(): Promise<void> {
|
|
354
|
+
if (this.state === "closed") return;
|
|
355
|
+
this.abortController.abort();
|
|
356
|
+
this.activeEpoch += 1;
|
|
357
|
+
this.connectionReady = false;
|
|
358
|
+
this.activeConnection?.close("client_closed");
|
|
359
|
+
this.activeConnection = null;
|
|
360
|
+
this.clearLiveQueue();
|
|
361
|
+
if (this.runTask) await this.runTask.catch(() => {});
|
|
362
|
+
await Promise.allSettled([...this.flushTasks]);
|
|
363
|
+
await this.causalTail.catch(() => {});
|
|
364
|
+
this.setState("closed");
|
|
365
|
+
this.rejectLiveWaiters(new Error("artifact sync controller closed"));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
getView(): EditableArtifactSyncView {
|
|
369
|
+
return {
|
|
370
|
+
artifactId: this.artifactId,
|
|
371
|
+
modality: this.modality,
|
|
372
|
+
state: this.state,
|
|
373
|
+
cursor: this.cursor,
|
|
374
|
+
headSequence: this.headSequence,
|
|
375
|
+
writable: this.writable,
|
|
376
|
+
pendingTransactions: this.pending.size,
|
|
377
|
+
blockedPending: this.allBlockedPending(),
|
|
378
|
+
queuedMessages: this.liveQueue.length,
|
|
379
|
+
reconnectAttempt: this.reconnectAttempt,
|
|
380
|
+
lastError: this.lastError,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
subscribe(listener: EditableArtifactSyncListener): () => void {
|
|
385
|
+
this.listeners.add(listener);
|
|
386
|
+
listener(this.getView());
|
|
387
|
+
return () => this.listeners.delete(listener);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
queueCommands(
|
|
391
|
+
input: EditableArtifactQueueCommandsInput,
|
|
392
|
+
): Promise<EditableArtifactPendingTransaction> {
|
|
393
|
+
if (!(input.commandBytes instanceof Uint8Array)) {
|
|
394
|
+
return Promise.reject(new TypeError("commandBytes must be a Uint8Array"));
|
|
395
|
+
}
|
|
396
|
+
const captured: EditableArtifactQueueCommandsInput = {
|
|
397
|
+
...input,
|
|
398
|
+
commandBytes: input.commandBytes.slice(),
|
|
399
|
+
...(input.selectiveUndoTargets === undefined
|
|
400
|
+
? {}
|
|
401
|
+
: { selectiveUndoTargets: [...input.selectiveUndoTargets] }),
|
|
402
|
+
};
|
|
403
|
+
return this.runCausalOperation(() => this.queueCommandsExclusive(captured));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private runCausalOperation<Result>(operation: () => Promise<Result>): Promise<Result> {
|
|
407
|
+
const result = this.causalTail.then(operation);
|
|
408
|
+
this.causalTail = result.then(
|
|
409
|
+
() => undefined,
|
|
410
|
+
() => undefined,
|
|
411
|
+
);
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async queueCommandsExclusive(
|
|
416
|
+
input: EditableArtifactQueueCommandsInput,
|
|
417
|
+
): Promise<EditableArtifactPendingTransaction> {
|
|
418
|
+
if (this.abortController.signal.aborted) {
|
|
419
|
+
throw new Error("artifact sync controller is closed");
|
|
420
|
+
}
|
|
421
|
+
if (!(input.commandBytes instanceof Uint8Array) || input.commandBytes.byteLength === 0) {
|
|
422
|
+
throw new TypeError("commandBytes must be a non-empty Uint8Array");
|
|
423
|
+
}
|
|
424
|
+
const effectiveCommandBytes = Math.min(
|
|
425
|
+
this.maxCommandBytes,
|
|
426
|
+
this.activeLimits?.maxCommandBytes ?? this.maxCommandBytes,
|
|
427
|
+
);
|
|
428
|
+
if (input.commandBytes.byteLength > effectiveCommandBytes) {
|
|
429
|
+
throw new RangeError("commandBytes exceeds the configured bound");
|
|
430
|
+
}
|
|
431
|
+
await this.initialize();
|
|
432
|
+
if (this.abortController.signal.aborted) {
|
|
433
|
+
throw new Error("artifact sync controller is closed");
|
|
434
|
+
}
|
|
435
|
+
const clientTransactionId = requireClientTransactionId(
|
|
436
|
+
input.clientTransactionId ?? this.clientTransactionIdFactory(),
|
|
437
|
+
"clientTransactionId",
|
|
438
|
+
);
|
|
439
|
+
const selectiveUndoTargets = normalizeIdentifiers(
|
|
440
|
+
input.selectiveUndoTargets ?? [],
|
|
441
|
+
"selectiveUndoTargets",
|
|
442
|
+
);
|
|
443
|
+
if (this.modality !== "spreadsheet" && selectiveUndoTargets.length > 0) {
|
|
444
|
+
throw new TypeError("selective undo is only supported for spreadsheet artifacts");
|
|
445
|
+
}
|
|
446
|
+
const existing = this.pending.get(clientTransactionId);
|
|
447
|
+
if (existing) {
|
|
448
|
+
if (
|
|
449
|
+
existing.modality !== this.modality ||
|
|
450
|
+
!bytesEqual(existing.commandBytes, input.commandBytes) ||
|
|
451
|
+
(existing.modality === "spreadsheet" &&
|
|
452
|
+
!identifiersEqual(existing.selectiveUndoTargets, selectiveUndoTargets))
|
|
453
|
+
) {
|
|
454
|
+
throw new TypeError(
|
|
455
|
+
`clientTransactionId ${clientTransactionId} is already bound to other commands`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (this.replica) {
|
|
459
|
+
try {
|
|
460
|
+
await this.replacePendingOverlay();
|
|
461
|
+
} catch (error) {
|
|
462
|
+
this.requestSnapshotResync(
|
|
463
|
+
`artifact Worker could not restore pending projection: ${asError(error).message}`,
|
|
464
|
+
"resync_required",
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
this.rotateReplicaConnectionIfNeeded();
|
|
469
|
+
if (this.state === "live") this.schedulePendingFlush();
|
|
470
|
+
return clonePending(existing);
|
|
471
|
+
}
|
|
472
|
+
if (this.modality !== "spreadsheet" && this.allBlockedPending().length > 0) {
|
|
473
|
+
throw new EditableArtifactSyncError(
|
|
474
|
+
"pending_conflict",
|
|
475
|
+
"resolve the stale serialized edit before authoring another command",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (this.pending.size >= this.maxPendingTransactions) {
|
|
479
|
+
throw new RangeError("pending transaction count exceeds the configured bound");
|
|
480
|
+
}
|
|
481
|
+
if (this.pendingBytes() + input.commandBytes.byteLength > this.maxPendingBytes) {
|
|
482
|
+
throw new RangeError("pending transaction bytes exceed the configured bound");
|
|
483
|
+
}
|
|
484
|
+
const foreignPending = this.orderedPending().find(
|
|
485
|
+
(transaction) => transaction.replicaId !== this.writerReplicaId,
|
|
486
|
+
);
|
|
487
|
+
if (foreignPending) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
"pending edits from a prior writer must settle before authoring dependent commands",
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const previous = this.orderedPending()
|
|
493
|
+
.filter((transaction) => transaction.replicaId === this.writerReplicaId)
|
|
494
|
+
.at(-1);
|
|
495
|
+
const confirmedCounter =
|
|
496
|
+
this.modality === "spreadsheet"
|
|
497
|
+
? (this.currentFrontier().find((entry) => entry.replicaId === this.writerReplicaId)
|
|
498
|
+
?.counter ?? 0)
|
|
499
|
+
: this.lastLocalReplicaCounter;
|
|
500
|
+
if (previous && previous.replicaCounter < confirmedCounter) {
|
|
501
|
+
throw new Error("pending writer counter is behind its confirmed causal frontier");
|
|
502
|
+
}
|
|
503
|
+
const replicaCounter = positiveSafeInteger(
|
|
504
|
+
(previous?.replicaCounter ?? confirmedCounter) + 1,
|
|
505
|
+
"replicaCounter",
|
|
506
|
+
);
|
|
507
|
+
const previousLocalTransactionId = previous?.clientTransactionId ?? this.lastLocalTransactionId;
|
|
508
|
+
if (replicaCounter > 1 && previousLocalTransactionId === null) {
|
|
509
|
+
throw new Error("writer replica epoch was reused without its causal predecessor");
|
|
510
|
+
}
|
|
511
|
+
const createdAt = this.scheduler.now();
|
|
512
|
+
const observedHeadSequence =
|
|
513
|
+
this.modality === "spreadsheet" ? this.cursor : this.cursor + this.pending.size;
|
|
514
|
+
const authored = await this.kernel.authorPending({
|
|
515
|
+
modality: this.modality,
|
|
516
|
+
protocolVersion: this.protocolVersion,
|
|
517
|
+
kernelVersion: this.kernelVersion,
|
|
518
|
+
modelSchemaVersion: this.modelSchemaVersion,
|
|
519
|
+
commandVersion: this.commandVersion,
|
|
520
|
+
artifactId: this.artifactId,
|
|
521
|
+
clientTransactionId,
|
|
522
|
+
replicaId: this.writerReplicaId,
|
|
523
|
+
replicaCounter,
|
|
524
|
+
previousLocalTransactionId,
|
|
525
|
+
observedHeadSequence,
|
|
526
|
+
...(this.modality === "spreadsheet"
|
|
527
|
+
? {
|
|
528
|
+
causalBase: cloneFrontier(this.currentFrontier()),
|
|
529
|
+
selectiveUndoTargets,
|
|
530
|
+
}
|
|
531
|
+
: {}),
|
|
532
|
+
commandBytes: input.commandBytes.slice(),
|
|
533
|
+
createdAt,
|
|
534
|
+
});
|
|
535
|
+
validatePendingTransaction(authored, this.artifactId, this.modality, this.maxCommandBytes);
|
|
536
|
+
if (
|
|
537
|
+
this.activeLimits !== null &&
|
|
538
|
+
authored.intentBytes.byteLength > this.activeLimits.maxIntentBytes
|
|
539
|
+
) {
|
|
540
|
+
throw new RangeError("intentBytes exceeds the negotiated live bound");
|
|
541
|
+
}
|
|
542
|
+
if (
|
|
543
|
+
this.pendingBytes() + authored.commandBytes.byteLength + authored.intentBytes.byteLength >
|
|
544
|
+
this.maxPendingBytes
|
|
545
|
+
) {
|
|
546
|
+
throw new RangeError("pending transaction bytes exceed the configured bound");
|
|
547
|
+
}
|
|
548
|
+
verifyAuthoredPending(authored, {
|
|
549
|
+
modality: this.modality,
|
|
550
|
+
clientTransactionId,
|
|
551
|
+
replicaId: this.writerReplicaId,
|
|
552
|
+
replicaCounter,
|
|
553
|
+
previousLocalTransactionId,
|
|
554
|
+
protocolVersion: this.protocolVersion,
|
|
555
|
+
modelSchemaVersion: this.modelSchemaVersion,
|
|
556
|
+
commandVersion: this.commandVersion,
|
|
557
|
+
observedHeadSequence,
|
|
558
|
+
...(this.modality === "spreadsheet"
|
|
559
|
+
? {
|
|
560
|
+
causalBase: this.currentFrontier(),
|
|
561
|
+
selectiveUndoTargets,
|
|
562
|
+
}
|
|
563
|
+
: {}),
|
|
564
|
+
commandBytes: input.commandBytes,
|
|
565
|
+
createdAt,
|
|
566
|
+
});
|
|
567
|
+
await this.storage.putPending(this.storageScope, clonePending(authored));
|
|
568
|
+
this.pending.set(authored.clientTransactionId, clonePending(authored));
|
|
569
|
+
if (this.replica) {
|
|
570
|
+
try {
|
|
571
|
+
await this.replacePendingOverlay();
|
|
572
|
+
} catch (error) {
|
|
573
|
+
// The WAL write is the acceptance boundary. Never report this command
|
|
574
|
+
// as rejected after its immutable intent is durable; rebuild the
|
|
575
|
+
// speculative projection from authority + WAL on the next connection.
|
|
576
|
+
this.requestSnapshotResync(
|
|
577
|
+
`artifact Worker could not project durable pending intent: ${asError(error).message}`,
|
|
578
|
+
"resync_required",
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
this.emit();
|
|
583
|
+
this.rotateReplicaConnectionIfNeeded();
|
|
584
|
+
if (this.state === "live") this.schedulePendingFlush();
|
|
585
|
+
return clonePending(authored);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private async initialize(): Promise<void> {
|
|
589
|
+
if (this.initialized) return;
|
|
590
|
+
if (this.initializeTask) return await this.initializeTask;
|
|
591
|
+
this.initializeTask = this.initializeOnce();
|
|
592
|
+
try {
|
|
593
|
+
await this.initializeTask;
|
|
594
|
+
this.initialized = true;
|
|
595
|
+
} finally {
|
|
596
|
+
this.initializeTask = null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private async initializeOnce(): Promise<void> {
|
|
601
|
+
const pending = await this.storage.listPending(this.storageScope);
|
|
602
|
+
for (const transaction of pending) {
|
|
603
|
+
validatePendingTransaction(transaction, this.artifactId, this.modality, this.maxCommandBytes);
|
|
604
|
+
if (
|
|
605
|
+
transaction.protocolVersion !== this.protocolVersion ||
|
|
606
|
+
transaction.modelSchemaVersion !== this.modelSchemaVersion ||
|
|
607
|
+
transaction.commandVersion !== this.commandVersion
|
|
608
|
+
) {
|
|
609
|
+
throw new EditableArtifactSyncError(
|
|
610
|
+
"unsupported_protocol",
|
|
611
|
+
`retained pending transaction ${transaction.clientTransactionId} uses incompatible protocol/schema/command versions`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
const existing = this.pending.get(transaction.clientTransactionId);
|
|
615
|
+
if (existing && existing.requestHash !== transaction.requestHash) {
|
|
616
|
+
throw storageError(`conflicting retained transaction ${transaction.clientTransactionId}`);
|
|
617
|
+
}
|
|
618
|
+
this.pending.set(transaction.clientTransactionId, clonePending(transaction));
|
|
619
|
+
if (transaction.replicaId === this.writerReplicaId) {
|
|
620
|
+
this.lastLocalReplicaCounter = Math.max(
|
|
621
|
+
this.lastLocalReplicaCounter,
|
|
622
|
+
transaction.replicaCounter,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (
|
|
627
|
+
this.pending.size > this.maxPendingTransactions ||
|
|
628
|
+
this.pendingBytes() > this.maxPendingBytes
|
|
629
|
+
) {
|
|
630
|
+
throw storageError("retained pending transaction WAL exceeds configured bounds");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let stored: EditableArtifactStoredReplica | null;
|
|
634
|
+
try {
|
|
635
|
+
stored = await this.storage.loadReplica(this.storageScope);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
// Confirmed replica state is a reconstructible cache. A malformed or
|
|
638
|
+
// over-bound local record must not permanently brick this artifact; the
|
|
639
|
+
// pending WAL remains separate and is deliberately preserved.
|
|
640
|
+
if (!(error instanceof TypeError) && !(error instanceof RangeError)) {
|
|
641
|
+
throw storageError("failed to load retained artifact replica", error);
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
await this.storage.clearReplica(this.storageScope);
|
|
645
|
+
} catch (clearError) {
|
|
646
|
+
throw storageError("failed to clear invalid retained artifact replica", clearError);
|
|
647
|
+
}
|
|
648
|
+
await this.kernel.reset();
|
|
649
|
+
this.replica = null;
|
|
650
|
+
this.retainedTailTransactions = 0;
|
|
651
|
+
this.cursor = 0;
|
|
652
|
+
this.headSequence = 0;
|
|
653
|
+
this.retainedStorageHead = null;
|
|
654
|
+
this.requireSnapshot = true;
|
|
655
|
+
this.emit();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (!stored) {
|
|
659
|
+
this.requireSnapshot = true;
|
|
660
|
+
this.emit();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
this.retainedStorageHead = { cursor: stored.cursor, stateHash: stored.stateHash };
|
|
664
|
+
try {
|
|
665
|
+
await this.restoreReplica(stored);
|
|
666
|
+
await this.replacePendingOverlay();
|
|
667
|
+
} catch {
|
|
668
|
+
await this.kernel.reset();
|
|
669
|
+
this.replica = null;
|
|
670
|
+
this.retainedTailTransactions = 0;
|
|
671
|
+
this.cursor = 0;
|
|
672
|
+
this.headSequence = 0;
|
|
673
|
+
this.requireSnapshot = true;
|
|
674
|
+
}
|
|
675
|
+
this.emit();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private async restoreReplica(stored: EditableArtifactStoredReplica): Promise<void> {
|
|
679
|
+
validateSnapshot(
|
|
680
|
+
stored.snapshot,
|
|
681
|
+
this.artifactId,
|
|
682
|
+
this.modality,
|
|
683
|
+
this.protocolVersion,
|
|
684
|
+
this.kernelVersion,
|
|
685
|
+
this.modelSchemaVersion,
|
|
686
|
+
this.maxSnapshotBytes,
|
|
687
|
+
);
|
|
688
|
+
if (stored.artifactId !== this.artifactId || stored.modality !== this.modality) {
|
|
689
|
+
throw new TypeError("stored artifact authority mismatch");
|
|
690
|
+
}
|
|
691
|
+
await this.kernel.reset();
|
|
692
|
+
const loaded = await this.kernel.loadSnapshot(stored.snapshot);
|
|
693
|
+
requireStateHash(loaded.stateHash, stored.snapshot.stateHash, "retained snapshot");
|
|
694
|
+
requireDigest(loaded.digest, stored.snapshot.digest, "retained snapshot");
|
|
695
|
+
if (stored.tail.length > this.maxStoredTailTransactions) {
|
|
696
|
+
throw new RangeError("retained operation tail exceeds the configured bound");
|
|
697
|
+
}
|
|
698
|
+
let cursor = stored.snapshot.sequence;
|
|
699
|
+
let stateHash = stored.snapshot.stateHash;
|
|
700
|
+
let causalFrontier =
|
|
701
|
+
stored.snapshot.modality === "spreadsheet" ? stored.snapshot.causalFrontier : null;
|
|
702
|
+
let nativeRevision =
|
|
703
|
+
stored.snapshot.modality === "spreadsheet" ? null : stored.snapshot.nativeRevision;
|
|
704
|
+
for (const transaction of stored.tail) {
|
|
705
|
+
validateCommittedTransaction(
|
|
706
|
+
transaction,
|
|
707
|
+
this.artifactId,
|
|
708
|
+
this.modality,
|
|
709
|
+
this.protocolVersion,
|
|
710
|
+
this.maxCommittedTransactionBytes,
|
|
711
|
+
);
|
|
712
|
+
if (transaction.startSequence !== cursor + 1) {
|
|
713
|
+
throw new TypeError("retained transaction tail is not contiguous");
|
|
714
|
+
}
|
|
715
|
+
requireStateHash(transaction.priorStateHash, stateHash, "retained transaction prior state");
|
|
716
|
+
const applied = await this.kernel.applyRecovered(transaction);
|
|
717
|
+
requireStateHash(applied.stateHash, transaction.stateHash, "retained transaction");
|
|
718
|
+
cursor = transaction.endSequence;
|
|
719
|
+
stateHash = transaction.stateHash;
|
|
720
|
+
if (transaction.modality === "spreadsheet") {
|
|
721
|
+
causalFrontier = transaction.causalFrontier;
|
|
722
|
+
} else {
|
|
723
|
+
if (nativeRevision !== transaction.priorNativeRevision) {
|
|
724
|
+
throw new TypeError("retained transaction native revision is not contiguous");
|
|
725
|
+
}
|
|
726
|
+
nativeRevision = transaction.nativeRevision;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (stored.cursor !== cursor || stored.stateHash !== stateHash) {
|
|
730
|
+
throw new TypeError("retained artifact cursor does not match its snapshot and tail");
|
|
731
|
+
}
|
|
732
|
+
this.replica =
|
|
733
|
+
stored.snapshot.modality === "spreadsheet"
|
|
734
|
+
? {
|
|
735
|
+
modality: "spreadsheet",
|
|
736
|
+
snapshot: cloneSnapshot(stored.snapshot),
|
|
737
|
+
cursor,
|
|
738
|
+
stateHash,
|
|
739
|
+
updatedAt: stored.updatedAt,
|
|
740
|
+
causalFrontier: cloneFrontier(causalFrontier ?? []),
|
|
741
|
+
}
|
|
742
|
+
: {
|
|
743
|
+
modality: stored.snapshot.modality,
|
|
744
|
+
snapshot: cloneSnapshot(stored.snapshot),
|
|
745
|
+
cursor,
|
|
746
|
+
stateHash,
|
|
747
|
+
updatedAt: stored.updatedAt,
|
|
748
|
+
nativeRevision: requireNativeRevision(nativeRevision, "retained native revision"),
|
|
749
|
+
};
|
|
750
|
+
this.retainedTailTransactions = stored.tail.length;
|
|
751
|
+
this.cursor = cursor;
|
|
752
|
+
this.headSequence = cursor;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private async run(): Promise<void> {
|
|
756
|
+
await this.initialize();
|
|
757
|
+
let connectedBefore = false;
|
|
758
|
+
let delayMs = this.reconnectDelayMs;
|
|
759
|
+
while (!this.abortController.signal.aborted) {
|
|
760
|
+
this.processingFailure = null;
|
|
761
|
+
this.connectionReady = false;
|
|
762
|
+
this.setState(
|
|
763
|
+
this.requireSnapshot ? "resyncing" : connectedBefore ? "reconnecting" : "connecting",
|
|
764
|
+
);
|
|
765
|
+
try {
|
|
766
|
+
await this.runConnection();
|
|
767
|
+
connectedBefore = true;
|
|
768
|
+
this.reconnectAttempt = 0;
|
|
769
|
+
delayMs = this.reconnectDelayMs;
|
|
770
|
+
} catch (error) {
|
|
771
|
+
if (this.abortController.signal.aborted) break;
|
|
772
|
+
if (this.state === "live") {
|
|
773
|
+
// A connection that reached the verified live barrier was healthy;
|
|
774
|
+
// only consecutive failures before that barrier consume the budget.
|
|
775
|
+
this.reconnectAttempt = 0;
|
|
776
|
+
delayMs = this.reconnectDelayMs;
|
|
777
|
+
}
|
|
778
|
+
const failure = asError(error);
|
|
779
|
+
this.lastError = failure;
|
|
780
|
+
if (failure instanceof EditableArtifactSyncError && failure.requiresSnapshot) {
|
|
781
|
+
this.requireSnapshot = true;
|
|
782
|
+
}
|
|
783
|
+
if (
|
|
784
|
+
failure instanceof EditableArtifactSyncError &&
|
|
785
|
+
failure.code === "unsupported_protocol"
|
|
786
|
+
) {
|
|
787
|
+
this.setState("unsupported");
|
|
788
|
+
this.rejectLiveWaiters(failure);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (!isRetryableFailure(failure)) {
|
|
792
|
+
this.fail(failure);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
connectedBefore = true;
|
|
796
|
+
this.reconnectAttempt += 1;
|
|
797
|
+
this.emit();
|
|
798
|
+
if (this.reconnectAttempt > this.maxReconnectAttempts) {
|
|
799
|
+
this.fail(
|
|
800
|
+
new EditableArtifactSyncError(
|
|
801
|
+
"reconnect_exhausted",
|
|
802
|
+
`artifact sync gave up after ${this.maxReconnectAttempts} reconnect attempts`,
|
|
803
|
+
{ cause: failure },
|
|
804
|
+
),
|
|
805
|
+
);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
} finally {
|
|
809
|
+
// Every minted ticket owns exactly one live connection. Bootstrap,
|
|
810
|
+
// replay, validation, and queue failures can all exit before the
|
|
811
|
+
// transport's `closed` promise settles, so retire the socket here as
|
|
812
|
+
// well as on the normal close path.
|
|
813
|
+
this.activeConnection?.close("client_reconnect");
|
|
814
|
+
this.activeTicket = null;
|
|
815
|
+
this.activeConnection = null;
|
|
816
|
+
this.activeLimits = null;
|
|
817
|
+
this.connectionReady = false;
|
|
818
|
+
}
|
|
819
|
+
if (this.abortController.signal.aborted) break;
|
|
820
|
+
this.setState(this.requireSnapshot ? "resyncing" : "reconnecting");
|
|
821
|
+
await this.scheduler.sleep(delayMs, this.abortController.signal).catch(() => {});
|
|
822
|
+
delayMs = Math.min(Math.max(delayMs * 2, this.reconnectDelayMs), this.maxReconnectDelayMs);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private async runConnection(): Promise<void> {
|
|
827
|
+
const signal = this.abortController.signal;
|
|
828
|
+
// Fence callbacks from the retired socket before ticket minting. A closed
|
|
829
|
+
// adapter may still deliver a final queued callback while the next HTTP
|
|
830
|
+
// ticket request is in flight.
|
|
831
|
+
const epoch = ++this.activeEpoch;
|
|
832
|
+
// A submit owned by the retired epoch may still settle, but must neither
|
|
833
|
+
// block nor mutate the successor connection.
|
|
834
|
+
this.flushTask = null;
|
|
835
|
+
this.clearLiveQueue();
|
|
836
|
+
const connectionReplicaId = this.desiredConnectionReplicaId();
|
|
837
|
+
const ticket = await this.transport.mintTicket({
|
|
838
|
+
artifactId: this.artifactId,
|
|
839
|
+
replicaId: connectionReplicaId,
|
|
840
|
+
signal,
|
|
841
|
+
});
|
|
842
|
+
this.validateTicket(ticket, connectionReplicaId);
|
|
843
|
+
this.activeTicket = ticket;
|
|
844
|
+
const resyncRevisionAtBootstrap = this.resyncRevision;
|
|
845
|
+
|
|
846
|
+
// The transport must subscribe before the durable head is read. Messages
|
|
847
|
+
// arriving during bootstrap are retained in the bounded queue below.
|
|
848
|
+
const connection = await this.transport.openLive({
|
|
849
|
+
ticket,
|
|
850
|
+
after: this.cursor,
|
|
851
|
+
stateHash: this.replica?.stateHash ?? null,
|
|
852
|
+
resume:
|
|
853
|
+
this.modality === "spreadsheet"
|
|
854
|
+
? { modality: "spreadsheet", causalFrontier: cloneFrontier(this.currentFrontier()) }
|
|
855
|
+
: {
|
|
856
|
+
modality: this.modality,
|
|
857
|
+
nativeRevision: this.replica === null ? null : this.currentNativeRevision(),
|
|
858
|
+
},
|
|
859
|
+
requireSnapshot: this.requireSnapshot,
|
|
860
|
+
signal,
|
|
861
|
+
onMessage: (message) => this.enqueueLive(epoch, message),
|
|
862
|
+
});
|
|
863
|
+
this.activeConnection = connection;
|
|
864
|
+
validateLiveLimits(connection.limits);
|
|
865
|
+
this.activeLimits = connection.limits;
|
|
866
|
+
boundedNonEmpty(connection.streamEpoch, "connection.streamEpoch", MAX_IDENTIFIER_BYTES);
|
|
867
|
+
if (this.processingFailure) {
|
|
868
|
+
connection.close("protocol_error");
|
|
869
|
+
throw this.processingFailure;
|
|
870
|
+
}
|
|
871
|
+
if (this.resyncRevision !== resyncRevisionAtBootstrap) {
|
|
872
|
+
connection.close("resync_required");
|
|
873
|
+
throw new EditableArtifactSyncError(
|
|
874
|
+
"queue_overflow",
|
|
875
|
+
"live input was discarded before bootstrap; restarting from a verified snapshot",
|
|
876
|
+
{ retryable: true, requiresSnapshot: true },
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
this.setState(this.requireSnapshot ? "resyncing" : "syncing");
|
|
880
|
+
const bootstrap = await connection.readBootstrap({
|
|
881
|
+
localCursor: this.replica ? this.cursor : null,
|
|
882
|
+
localStateHash: this.replica?.stateHash ?? null,
|
|
883
|
+
resume:
|
|
884
|
+
this.modality === "spreadsheet"
|
|
885
|
+
? {
|
|
886
|
+
modality: "spreadsheet",
|
|
887
|
+
localCausalFrontier: cloneFrontier(this.currentFrontier()),
|
|
888
|
+
}
|
|
889
|
+
: {
|
|
890
|
+
modality: this.modality,
|
|
891
|
+
localNativeRevision: this.replica === null ? null : this.currentNativeRevision(),
|
|
892
|
+
},
|
|
893
|
+
requireSnapshot: this.requireSnapshot,
|
|
894
|
+
signal,
|
|
895
|
+
});
|
|
896
|
+
await this.applyBootstrap(bootstrap, connection, signal);
|
|
897
|
+
if (this.resyncRevision !== resyncRevisionAtBootstrap) {
|
|
898
|
+
this.requireSnapshot = true;
|
|
899
|
+
throw new EditableArtifactSyncError(
|
|
900
|
+
"queue_overflow",
|
|
901
|
+
"live input was discarded during bootstrap; restarting from a verified snapshot",
|
|
902
|
+
{ retryable: true, requiresSnapshot: true },
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
if (this.processingFailure) throw this.processingFailure;
|
|
906
|
+
|
|
907
|
+
if (this.cursor < this.headSequence) {
|
|
908
|
+
this.enqueueLive(epoch, {
|
|
909
|
+
type: "head",
|
|
910
|
+
artifactId: this.artifactId,
|
|
911
|
+
headSequence: this.headSequence,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
this.connectionReady = true;
|
|
916
|
+
this.scheduleDrain();
|
|
917
|
+
await this.awaitDrain();
|
|
918
|
+
if (this.processingFailure) throw this.processingFailure;
|
|
919
|
+
if (this.requireSnapshot) {
|
|
920
|
+
throw new EditableArtifactSyncError(
|
|
921
|
+
"queue_overflow",
|
|
922
|
+
"live queue requires a verified snapshot resync",
|
|
923
|
+
{ retryable: true, requiresSnapshot: true },
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
this.lastError = null;
|
|
928
|
+
this.reconnectAttempt = 0;
|
|
929
|
+
this.setState("live");
|
|
930
|
+
this.resolveLiveWaiters();
|
|
931
|
+
this.rotateReplicaConnectionIfNeeded();
|
|
932
|
+
this.schedulePendingFlush();
|
|
933
|
+
|
|
934
|
+
const closed = await waitForConnectionClose(connection, signal);
|
|
935
|
+
this.connectionReady = false;
|
|
936
|
+
await this.awaitDrain();
|
|
937
|
+
if (this.processingFailure) throw this.processingFailure;
|
|
938
|
+
if (signal.aborted) return;
|
|
939
|
+
if (closed.reason === "permission_changed") {
|
|
940
|
+
this.writable = false;
|
|
941
|
+
this.emit();
|
|
942
|
+
}
|
|
943
|
+
throw transientError(
|
|
944
|
+
closed.error ?? new TypeError(`artifact live connection closed: ${closed.reason}`),
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private async applyBootstrap(
|
|
949
|
+
bootstrap: EditableArtifactBootstrap,
|
|
950
|
+
connection: EditableArtifactLiveConnection,
|
|
951
|
+
signal: AbortSignal,
|
|
952
|
+
): Promise<void> {
|
|
953
|
+
if (bootstrap.artifactId !== this.artifactId) {
|
|
954
|
+
throw invalidBootstrap("bootstrap artifact does not match the controller");
|
|
955
|
+
}
|
|
956
|
+
if (bootstrap.modality !== this.modality) {
|
|
957
|
+
throw invalidBootstrap("bootstrap modality does not match the controller");
|
|
958
|
+
}
|
|
959
|
+
this.requireProtocol(bootstrap.protocolVersion);
|
|
960
|
+
if (
|
|
961
|
+
bootstrap.kernelVersion !== this.kernelVersion ||
|
|
962
|
+
bootstrap.modelSchemaVersion !== this.modelSchemaVersion
|
|
963
|
+
) {
|
|
964
|
+
throw new EditableArtifactSyncError(
|
|
965
|
+
"unsupported_protocol",
|
|
966
|
+
`artifact kernel/schema ${bootstrap.kernelVersion}/${bootstrap.modelSchemaVersion} is incompatible with ${this.kernelVersion}/${this.modelSchemaVersion}`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
requireSha256(bootstrap.headStateHash, "bootstrap.headStateHash");
|
|
970
|
+
if (bootstrap.modality === "spreadsheet") {
|
|
971
|
+
validateFrontier(bootstrap.headCausalFrontier, "bootstrap.headCausalFrontier");
|
|
972
|
+
} else {
|
|
973
|
+
requireNativeRevision(bootstrap.headNativeRevision, "bootstrap.headNativeRevision");
|
|
974
|
+
}
|
|
975
|
+
this.writable = bootstrap.writable;
|
|
976
|
+
safeSequence(bootstrap.headSequence, "bootstrap headSequence");
|
|
977
|
+
safeSequence(bootstrap.minimumReplaySequence, "bootstrap minimumReplaySequence");
|
|
978
|
+
if (bootstrap.minimumReplaySequence > bootstrap.headSequence + 1) {
|
|
979
|
+
throw invalidBootstrap("minimum replay sequence exceeds the durable head");
|
|
980
|
+
}
|
|
981
|
+
// Bootstrap is the authoritative barrier for this new stream epoch and
|
|
982
|
+
// may legitimately move backward during recovery.
|
|
983
|
+
this.headSequence = bootstrap.headSequence;
|
|
984
|
+
|
|
985
|
+
const mustReplace =
|
|
986
|
+
this.requireSnapshot ||
|
|
987
|
+
bootstrap.resyncRequired ||
|
|
988
|
+
!this.replica ||
|
|
989
|
+
this.cursor > bootstrap.headSequence ||
|
|
990
|
+
this.cursor + 1 < bootstrap.minimumReplaySequence;
|
|
991
|
+
const newerSnapshot = bootstrap.snapshot !== null && bootstrap.snapshot.sequence > this.cursor;
|
|
992
|
+
if (mustReplace || newerSnapshot) {
|
|
993
|
+
if (!bootstrap.snapshot) {
|
|
994
|
+
throw invalidBootstrap("server required resync without a snapshot", true);
|
|
995
|
+
}
|
|
996
|
+
await this.replaceSnapshot(bootstrap.snapshot);
|
|
997
|
+
}
|
|
998
|
+
if (!this.replica) throw invalidBootstrap("bootstrap did not establish a snapshot", true);
|
|
999
|
+
if (this.cursor > bootstrap.headSequence) {
|
|
1000
|
+
throw invalidBootstrap("local cursor is ahead of the durable artifact head", true);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
this.requireSnapshot = false;
|
|
1004
|
+
await this.reconcileTo(bootstrap.headSequence, connection, signal, false);
|
|
1005
|
+
await this.replacePendingOverlay();
|
|
1006
|
+
requireStateHash(this.replica.stateHash, bootstrap.headStateHash, "bootstrap head barrier");
|
|
1007
|
+
if (bootstrap.modality === "spreadsheet") {
|
|
1008
|
+
requireFrontier(
|
|
1009
|
+
this.currentFrontier(),
|
|
1010
|
+
bootstrap.headCausalFrontier,
|
|
1011
|
+
"bootstrap head barrier",
|
|
1012
|
+
);
|
|
1013
|
+
} else {
|
|
1014
|
+
requireNativeRevisionEqual(
|
|
1015
|
+
this.currentNativeRevision(),
|
|
1016
|
+
bootstrap.headNativeRevision,
|
|
1017
|
+
"bootstrap head barrier",
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
await connection.acknowledge({
|
|
1021
|
+
sequence: this.cursor,
|
|
1022
|
+
stateHash: this.replica.stateHash,
|
|
1023
|
+
signal,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private replaceSnapshot(snapshot: EditableArtifactStoredReplica["snapshot"]): Promise<void> {
|
|
1028
|
+
return this.runCausalOperation(() => this.replaceSnapshotExclusive(snapshot));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private async replaceSnapshotExclusive(
|
|
1032
|
+
snapshot: EditableArtifactStoredReplica["snapshot"],
|
|
1033
|
+
): Promise<void> {
|
|
1034
|
+
validateSnapshot(
|
|
1035
|
+
snapshot,
|
|
1036
|
+
this.artifactId,
|
|
1037
|
+
this.modality,
|
|
1038
|
+
this.protocolVersion,
|
|
1039
|
+
this.kernelVersion,
|
|
1040
|
+
this.modelSchemaVersion,
|
|
1041
|
+
this.maxSnapshotBytes,
|
|
1042
|
+
);
|
|
1043
|
+
await this.kernel.reset();
|
|
1044
|
+
const loaded = await this.kernel.loadSnapshot(snapshot);
|
|
1045
|
+
requireStateHash(loaded.stateHash, snapshot.stateHash, "snapshot");
|
|
1046
|
+
requireDigest(loaded.digest, snapshot.digest, "snapshot");
|
|
1047
|
+
const storedReplica: EditableArtifactStoredReplica = {
|
|
1048
|
+
artifactId: this.artifactId,
|
|
1049
|
+
modality: this.modality,
|
|
1050
|
+
snapshot: cloneSnapshot(snapshot),
|
|
1051
|
+
tail: [],
|
|
1052
|
+
cursor: snapshot.sequence,
|
|
1053
|
+
stateHash: snapshot.stateHash,
|
|
1054
|
+
updatedAt: this.scheduler.now(),
|
|
1055
|
+
};
|
|
1056
|
+
// Full replacement is one atomic storage generation swap and may move
|
|
1057
|
+
// backward during authoritative recovery. The pending WAL is separate.
|
|
1058
|
+
try {
|
|
1059
|
+
await this.storage.saveReplica(this.storageScope, storedReplica, this.retainedStorageHead);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
if (error instanceof EditableArtifactStorageConflictError) {
|
|
1062
|
+
try {
|
|
1063
|
+
await this.reloadRetainedProjection();
|
|
1064
|
+
if (this.replica) return;
|
|
1065
|
+
} catch (reloadError) {
|
|
1066
|
+
throw invalidBootstrap(
|
|
1067
|
+
`could not adopt a concurrently retained artifact head: ${asError(reloadError).message}`,
|
|
1068
|
+
true,
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
throw invalidBootstrap("concurrently retained artifact head disappeared", true);
|
|
1072
|
+
}
|
|
1073
|
+
throw storageError("failed to retain verified artifact snapshot", error);
|
|
1074
|
+
}
|
|
1075
|
+
this.retainedStorageHead = {
|
|
1076
|
+
cursor: storedReplica.cursor,
|
|
1077
|
+
stateHash: storedReplica.stateHash,
|
|
1078
|
+
};
|
|
1079
|
+
this.replica =
|
|
1080
|
+
storedReplica.snapshot.modality === "spreadsheet"
|
|
1081
|
+
? {
|
|
1082
|
+
modality: "spreadsheet",
|
|
1083
|
+
snapshot: storedReplica.snapshot,
|
|
1084
|
+
cursor: storedReplica.cursor,
|
|
1085
|
+
stateHash: storedReplica.stateHash,
|
|
1086
|
+
updatedAt: storedReplica.updatedAt,
|
|
1087
|
+
causalFrontier: cloneFrontier(storedReplica.snapshot.causalFrontier),
|
|
1088
|
+
}
|
|
1089
|
+
: {
|
|
1090
|
+
modality: storedReplica.snapshot.modality,
|
|
1091
|
+
snapshot: storedReplica.snapshot,
|
|
1092
|
+
cursor: storedReplica.cursor,
|
|
1093
|
+
stateHash: storedReplica.stateHash,
|
|
1094
|
+
updatedAt: storedReplica.updatedAt,
|
|
1095
|
+
nativeRevision: storedReplica.snapshot.nativeRevision,
|
|
1096
|
+
};
|
|
1097
|
+
this.retainedTailTransactions = 0;
|
|
1098
|
+
this.cursor = snapshot.sequence;
|
|
1099
|
+
this.headSequence = Math.max(this.headSequence, snapshot.sequence);
|
|
1100
|
+
this.emit();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
private async reconcileTo(
|
|
1104
|
+
target: number,
|
|
1105
|
+
connection: EditableArtifactLiveConnection,
|
|
1106
|
+
signal: AbortSignal,
|
|
1107
|
+
consumeHeadWatermark = true,
|
|
1108
|
+
): Promise<void> {
|
|
1109
|
+
let replayThrough = safeSequence(target, "replay target");
|
|
1110
|
+
while (this.cursor < replayThrough) {
|
|
1111
|
+
const before = this.cursor;
|
|
1112
|
+
const page = await connection.replay({
|
|
1113
|
+
after: this.cursor,
|
|
1114
|
+
through: replayThrough,
|
|
1115
|
+
limit: this.replayPageSize,
|
|
1116
|
+
signal,
|
|
1117
|
+
});
|
|
1118
|
+
if (!page || typeof page !== "object") {
|
|
1119
|
+
throw invalidSequence("replay page must be an object");
|
|
1120
|
+
}
|
|
1121
|
+
if (page.artifactId !== this.artifactId) {
|
|
1122
|
+
throw invalidSequence("replay page belongs to another artifact");
|
|
1123
|
+
}
|
|
1124
|
+
if (!Array.isArray(page.transactions)) {
|
|
1125
|
+
throw invalidSequence("replay page transactions must be an array");
|
|
1126
|
+
}
|
|
1127
|
+
if (page.transactions.length > this.replayPageSize) {
|
|
1128
|
+
throw invalidSequence("replay page exceeds the negotiated transaction count");
|
|
1129
|
+
}
|
|
1130
|
+
safeSequence(page.headSequence, "replay headSequence");
|
|
1131
|
+
if (consumeHeadWatermark) replayThrough = Math.max(replayThrough, page.headSequence);
|
|
1132
|
+
this.headSequence = Math.max(this.headSequence, page.headSequence);
|
|
1133
|
+
let pageBytes = 0;
|
|
1134
|
+
for (const transaction of page.transactions) {
|
|
1135
|
+
validateCommittedTransaction(
|
|
1136
|
+
transaction,
|
|
1137
|
+
this.artifactId,
|
|
1138
|
+
this.modality,
|
|
1139
|
+
this.protocolVersion,
|
|
1140
|
+
this.maxCommittedTransactionBytes,
|
|
1141
|
+
);
|
|
1142
|
+
pageBytes += estimateCommittedTransactionBytes(transaction);
|
|
1143
|
+
if (!Number.isSafeInteger(pageBytes) || pageBytes > this.maxReplayPageBytes) {
|
|
1144
|
+
throw invalidSequence("replay page exceeds the negotiated byte bound");
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
for (const transaction of page.transactions) {
|
|
1148
|
+
if (transaction.endSequence <= this.cursor) continue;
|
|
1149
|
+
if (transaction.endSequence > replayThrough) {
|
|
1150
|
+
throw invalidSequence("replay returned a transaction beyond the requested target");
|
|
1151
|
+
}
|
|
1152
|
+
await this.applyCommitted(transaction);
|
|
1153
|
+
}
|
|
1154
|
+
if (this.cursor === before) {
|
|
1155
|
+
throw invalidSequence(
|
|
1156
|
+
`durable replay did not provide sequence ${this.cursor + 1} through ${target}`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private enqueueLive(epoch: number, message: EditableArtifactLiveMessage): void {
|
|
1163
|
+
if (epoch !== this.activeEpoch || this.abortController.signal.aborted) return;
|
|
1164
|
+
let bytes: number;
|
|
1165
|
+
try {
|
|
1166
|
+
validateLiveMessage(
|
|
1167
|
+
message,
|
|
1168
|
+
this.artifactId,
|
|
1169
|
+
this.modality,
|
|
1170
|
+
this.protocolVersion,
|
|
1171
|
+
this.maxCommittedTransactionBytes,
|
|
1172
|
+
);
|
|
1173
|
+
bytes = estimateLiveMessageBytes(message);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
this.processingFailure =
|
|
1176
|
+
error instanceof EditableArtifactSyncError
|
|
1177
|
+
? error
|
|
1178
|
+
: invalidSequence(`invalid live message: ${asError(error).message}`);
|
|
1179
|
+
this.activeConnection?.close("protocol_error");
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (
|
|
1183
|
+
this.liveQueue.length + 1 > this.maxQueuedMessages ||
|
|
1184
|
+
this.queuedBytes + bytes > this.maxQueuedBytes
|
|
1185
|
+
) {
|
|
1186
|
+
this.requestSnapshotResync("bounded live queue overflow");
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
this.liveQueue.push({ message, bytes });
|
|
1190
|
+
this.queuedBytes += bytes;
|
|
1191
|
+
this.emit();
|
|
1192
|
+
if (this.connectionReady) this.scheduleDrain();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
private scheduleDrain(): void {
|
|
1196
|
+
if (!this.connectionReady || this.drainTask || this.liveQueue.length === 0) return;
|
|
1197
|
+
this.drainTask = this.drainLiveQueue()
|
|
1198
|
+
.catch((error: unknown) => {
|
|
1199
|
+
const failure = asError(error);
|
|
1200
|
+
this.processingFailure = failure;
|
|
1201
|
+
if (failure instanceof EditableArtifactSyncError && failure.requiresSnapshot) {
|
|
1202
|
+
this.requireSnapshot = true;
|
|
1203
|
+
}
|
|
1204
|
+
this.activeConnection?.close("client_reconcile_failed");
|
|
1205
|
+
})
|
|
1206
|
+
.finally(() => {
|
|
1207
|
+
this.drainTask = null;
|
|
1208
|
+
if (this.connectionReady && this.liveQueue.length > 0 && !this.processingFailure) {
|
|
1209
|
+
this.scheduleDrain();
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
private async drainLiveQueue(): Promise<void> {
|
|
1215
|
+
const connection = this.activeConnection;
|
|
1216
|
+
if (!connection) return;
|
|
1217
|
+
while (this.connectionReady && this.liveQueue.length > 0) {
|
|
1218
|
+
const queued = this.liveQueue.shift();
|
|
1219
|
+
if (!queued) break;
|
|
1220
|
+
this.queuedBytes -= queued.bytes;
|
|
1221
|
+
const message = queued.message;
|
|
1222
|
+
if (message.type === "resync_required") {
|
|
1223
|
+
this.requestSnapshotResync(message.reason, "resync_required");
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
if (message.type === "authorization") {
|
|
1227
|
+
this.writable = message.writable;
|
|
1228
|
+
if (message.writable) {
|
|
1229
|
+
if (
|
|
1230
|
+
this.lastError instanceof EditableArtifactSyncError &&
|
|
1231
|
+
this.lastError.code === "permission_changed"
|
|
1232
|
+
) {
|
|
1233
|
+
this.lastError = null;
|
|
1234
|
+
}
|
|
1235
|
+
this.schedulePendingFlush();
|
|
1236
|
+
}
|
|
1237
|
+
this.emit();
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (message.type === "head") {
|
|
1241
|
+
this.headSequence = Math.max(this.headSequence, message.headSequence);
|
|
1242
|
+
await this.reconcileTo(message.headSequence, connection, this.abortController.signal);
|
|
1243
|
+
} else {
|
|
1244
|
+
const transaction = message.transaction;
|
|
1245
|
+
this.headSequence = Math.max(this.headSequence, transaction.endSequence);
|
|
1246
|
+
if (transaction.startSequence > this.cursor + 1) {
|
|
1247
|
+
await this.reconcileTo(
|
|
1248
|
+
transaction.startSequence - 1,
|
|
1249
|
+
connection,
|
|
1250
|
+
this.abortController.signal,
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
await this.applyCommitted(transaction);
|
|
1254
|
+
}
|
|
1255
|
+
this.emit();
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
private async awaitDrain(): Promise<void> {
|
|
1260
|
+
while (this.drainTask) await this.drainTask;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
private applyCommitted(transaction: EditableArtifactCommittedTransaction): Promise<void> {
|
|
1264
|
+
return this.runCausalOperation(() => this.applyCommittedExclusive(transaction));
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
private async applyCommittedExclusive(
|
|
1268
|
+
transaction: EditableArtifactCommittedTransaction,
|
|
1269
|
+
): Promise<void> {
|
|
1270
|
+
const embeddedPending = validateCommittedTransaction(
|
|
1271
|
+
transaction,
|
|
1272
|
+
this.artifactId,
|
|
1273
|
+
this.modality,
|
|
1274
|
+
this.protocolVersion,
|
|
1275
|
+
this.maxCommittedTransactionBytes,
|
|
1276
|
+
);
|
|
1277
|
+
if (embeddedPending) {
|
|
1278
|
+
const pending = this.pending.get(embeddedPending.clientTransactionId);
|
|
1279
|
+
if (
|
|
1280
|
+
pending?.requestHash === embeddedPending.requestHash &&
|
|
1281
|
+
bytesEqual(pending.intentBytes, embeddedPending.intentBytes)
|
|
1282
|
+
) {
|
|
1283
|
+
this.recordAcceptedMappingIdentity(transaction.transactionId, embeddedPending);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (transaction.endSequence <= this.cursor) {
|
|
1287
|
+
await this.settlePending(transaction, false);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
if (transaction.startSequence !== this.cursor + 1) {
|
|
1291
|
+
throw invalidSequence(
|
|
1292
|
+
`expected committed sequence ${this.cursor + 1}, received ${transaction.startSequence}`,
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
if (!this.replica)
|
|
1296
|
+
throw invalidBootstrap("cannot apply a transaction without a snapshot", true);
|
|
1297
|
+
requireStateHash(transaction.priorStateHash, this.replica.stateHash, "transaction prior state");
|
|
1298
|
+
const accepted = this.acceptedMappings.get(transaction.transactionId);
|
|
1299
|
+
const orderedPending = this.orderedPending();
|
|
1300
|
+
// OGACO carries the canonical mutation and resulting frontier, but not the
|
|
1301
|
+
// caller's client transaction id. The server deliberately sends those
|
|
1302
|
+
// committed bytes before mutationAccepted. Suppress an exact request-hash
|
|
1303
|
+
// match only from speculative replay so the same CRDT dot is not applied
|
|
1304
|
+
// twice; retain the WAL until the later authority mapping settles identity.
|
|
1305
|
+
const provisionalProjectionMatches =
|
|
1306
|
+
transaction.modality === "spreadsheet" && accepted === undefined
|
|
1307
|
+
? orderedPending.filter((pending) => pending.requestHash === transaction.requestHash)
|
|
1308
|
+
: [];
|
|
1309
|
+
if (provisionalProjectionMatches.length > 1) {
|
|
1310
|
+
throw invalidSequence("multiple pending transactions share one committed request hash");
|
|
1311
|
+
}
|
|
1312
|
+
const provisionalProjectionMatch = provisionalProjectionMatches[0];
|
|
1313
|
+
const remainingPending = orderedPending.filter(
|
|
1314
|
+
(pending) =>
|
|
1315
|
+
(accepted === undefined ||
|
|
1316
|
+
pending.clientTransactionId !== accepted.clientTransactionId ||
|
|
1317
|
+
pending.requestHash !== accepted.requestHash) &&
|
|
1318
|
+
(provisionalProjectionMatch === undefined ||
|
|
1319
|
+
pending.clientTransactionId !== provisionalProjectionMatch.clientTransactionId ||
|
|
1320
|
+
pending.requestHash !== provisionalProjectionMatch.requestHash),
|
|
1321
|
+
);
|
|
1322
|
+
const updatedAt = this.scheduler.now();
|
|
1323
|
+
let applied: { stateHash: string };
|
|
1324
|
+
try {
|
|
1325
|
+
if (this.connectionReady) {
|
|
1326
|
+
const reconciled = await this.kernel.reconcileCommitted(transaction, remainingPending);
|
|
1327
|
+
this.setBlockedPending(reconciled.blockedPending);
|
|
1328
|
+
applied = reconciled;
|
|
1329
|
+
} else {
|
|
1330
|
+
applied = await this.kernel.applyRecovered(transaction);
|
|
1331
|
+
}
|
|
1332
|
+
requireStateHash(applied.stateHash, transaction.stateHash, "committed transaction");
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
await this.reloadRetainedProjection();
|
|
1335
|
+
throw error;
|
|
1336
|
+
}
|
|
1337
|
+
try {
|
|
1338
|
+
await this.storage.appendCommitted(this.storageScope, {
|
|
1339
|
+
artifactId: this.artifactId,
|
|
1340
|
+
expectedCursor: this.cursor,
|
|
1341
|
+
expectedStateHash: this.replica.stateHash,
|
|
1342
|
+
transaction: cloneCommitted(transaction),
|
|
1343
|
+
updatedAt,
|
|
1344
|
+
});
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
try {
|
|
1347
|
+
await this.reloadRetainedProjection();
|
|
1348
|
+
} catch (reloadError) {
|
|
1349
|
+
throw storageError("failed to reload retained artifact transaction state", reloadError);
|
|
1350
|
+
}
|
|
1351
|
+
if (error instanceof EditableArtifactStorageConflictError) {
|
|
1352
|
+
// Another tab may have already advanced or compacted the same verified
|
|
1353
|
+
// log. Its atomic retained head is authoritative for this browser
|
|
1354
|
+
// cache; adopt it instead of failing an otherwise healthy live stream.
|
|
1355
|
+
if (this.replica && this.cursor >= transaction.endSequence) {
|
|
1356
|
+
await this.settlePending(transaction, false);
|
|
1357
|
+
const connection = this.activeConnection;
|
|
1358
|
+
if (connection) {
|
|
1359
|
+
await connection.acknowledge({
|
|
1360
|
+
sequence: this.cursor,
|
|
1361
|
+
stateHash: this.replica.stateHash,
|
|
1362
|
+
signal: this.abortController.signal,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
this.emit();
|
|
1366
|
+
this.rotateReplicaConnectionIfNeeded();
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
throw invalidSequence(
|
|
1370
|
+
"retained artifact head changed without covering the live transaction",
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
throw storageError("failed to retain committed artifact transaction", error);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
this.replica.cursor = transaction.endSequence;
|
|
1377
|
+
this.replica.stateHash = transaction.stateHash;
|
|
1378
|
+
this.replica.updatedAt = updatedAt;
|
|
1379
|
+
if (transaction.modality === "spreadsheet") {
|
|
1380
|
+
if (this.replica.modality !== "spreadsheet") {
|
|
1381
|
+
throw invalidSequence("spreadsheet transaction reached a serialized replica");
|
|
1382
|
+
}
|
|
1383
|
+
this.replica.causalFrontier = cloneFrontier(transaction.causalFrontier);
|
|
1384
|
+
} else {
|
|
1385
|
+
if (this.replica.modality !== transaction.modality) {
|
|
1386
|
+
throw invalidSequence("serialized transaction modality does not match the replica");
|
|
1387
|
+
}
|
|
1388
|
+
this.replica.nativeRevision = transaction.nativeRevision;
|
|
1389
|
+
}
|
|
1390
|
+
this.retainedTailTransactions += 1;
|
|
1391
|
+
this.cursor = transaction.endSequence;
|
|
1392
|
+
this.retainedStorageHead = {
|
|
1393
|
+
cursor: transaction.endSequence,
|
|
1394
|
+
stateHash: transaction.stateHash,
|
|
1395
|
+
};
|
|
1396
|
+
this.headSequence = Math.max(this.headSequence, transaction.endSequence);
|
|
1397
|
+
await this.settlePending(transaction, this.connectionReady);
|
|
1398
|
+
const connection = this.activeConnection;
|
|
1399
|
+
if (connection) {
|
|
1400
|
+
await connection.acknowledge({
|
|
1401
|
+
sequence: transaction.endSequence,
|
|
1402
|
+
stateHash: transaction.stateHash,
|
|
1403
|
+
signal: this.abortController.signal,
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
if (this.retainedTailTransactions > this.maxStoredTailTransactions) {
|
|
1407
|
+
this.requestSnapshotResync("retained operation tail exceeded its bound", "resync_required");
|
|
1408
|
+
}
|
|
1409
|
+
this.emit();
|
|
1410
|
+
this.rotateReplicaConnectionIfNeeded();
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
private async reloadRetainedProjection(): Promise<void> {
|
|
1414
|
+
const stored = await this.storage.loadReplica(this.storageScope);
|
|
1415
|
+
if (!stored) {
|
|
1416
|
+
await this.kernel.reset();
|
|
1417
|
+
this.replica = null;
|
|
1418
|
+
this.retainedTailTransactions = 0;
|
|
1419
|
+
this.cursor = 0;
|
|
1420
|
+
this.headSequence = 0;
|
|
1421
|
+
this.retainedStorageHead = null;
|
|
1422
|
+
this.requireSnapshot = true;
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
this.retainedStorageHead = { cursor: stored.cursor, stateHash: stored.stateHash };
|
|
1426
|
+
await this.restoreReplica(stored);
|
|
1427
|
+
await this.replacePendingOverlay();
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
private async settlePending(
|
|
1431
|
+
transaction: EditableArtifactCommittedTransaction,
|
|
1432
|
+
overlayAlreadyReconciled: boolean,
|
|
1433
|
+
): Promise<void> {
|
|
1434
|
+
const accepted = this.acceptedMappings.get(transaction.transactionId);
|
|
1435
|
+
if (!accepted || accepted.requestHash !== transaction.requestHash) return;
|
|
1436
|
+
const pending = this.pending.get(accepted.clientTransactionId);
|
|
1437
|
+
if (!pending || pending.requestHash !== accepted.requestHash) return;
|
|
1438
|
+
await this.storage.deletePending(this.storageScope, accepted.clientTransactionId);
|
|
1439
|
+
this.pending.delete(accepted.clientTransactionId);
|
|
1440
|
+
this.acceptedMappings.delete(transaction.transactionId);
|
|
1441
|
+
if (pending.replicaId === this.writerReplicaId) {
|
|
1442
|
+
this.lastLocalTransactionId = pending.clientTransactionId;
|
|
1443
|
+
this.lastLocalReplicaCounter = Math.max(this.lastLocalReplicaCounter, pending.replicaCounter);
|
|
1444
|
+
}
|
|
1445
|
+
this.submittedEpoch.delete(accepted.clientTransactionId);
|
|
1446
|
+
this.submitBlockedPending.delete(accepted.clientTransactionId);
|
|
1447
|
+
if (!overlayAlreadyReconciled) await this.replacePendingOverlay();
|
|
1448
|
+
this.emit();
|
|
1449
|
+
this.rotateReplicaConnectionIfNeeded();
|
|
1450
|
+
if (this.state === "live") this.schedulePendingFlush();
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
private schedulePendingFlush(): void {
|
|
1454
|
+
if (
|
|
1455
|
+
this.flushTask ||
|
|
1456
|
+
this.state !== "live" ||
|
|
1457
|
+
!this.writable ||
|
|
1458
|
+
!this.activeTicket ||
|
|
1459
|
+
!this.activeConnection ||
|
|
1460
|
+
this.pending.size === 0
|
|
1461
|
+
) {
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const epoch = this.activeEpoch;
|
|
1465
|
+
const connection = this.activeConnection;
|
|
1466
|
+
let task!: Promise<void>;
|
|
1467
|
+
task = this.flushPending()
|
|
1468
|
+
.catch((error: unknown) => {
|
|
1469
|
+
if (epoch !== this.activeEpoch || connection !== this.activeConnection) return;
|
|
1470
|
+
const failure = asError(error);
|
|
1471
|
+
this.lastError = failure;
|
|
1472
|
+
this.processingFailure = failure;
|
|
1473
|
+
connection.close(
|
|
1474
|
+
isRetryableFailure(failure) ? "pending_outcome_unknown" : "pending_rejected",
|
|
1475
|
+
);
|
|
1476
|
+
})
|
|
1477
|
+
.finally(() => {
|
|
1478
|
+
this.flushTasks.delete(task);
|
|
1479
|
+
if (this.flushTask !== task) return;
|
|
1480
|
+
this.flushTask = null;
|
|
1481
|
+
if (this.state === "live" && !this.lastError && this.hasSubmittablePending()) {
|
|
1482
|
+
this.schedulePendingFlush();
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
this.flushTask = task;
|
|
1486
|
+
this.flushTasks.add(task);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
private async flushPending(): Promise<void> {
|
|
1490
|
+
const ticket = this.activeTicket;
|
|
1491
|
+
const connection = this.activeConnection;
|
|
1492
|
+
if (!ticket || !connection) return;
|
|
1493
|
+
const epoch = this.activeEpoch;
|
|
1494
|
+
const batch = this.orderedPending();
|
|
1495
|
+
for (const transaction of batch) {
|
|
1496
|
+
if (this.state !== "live" || ticket !== this.activeTicket || epoch !== this.activeEpoch)
|
|
1497
|
+
return;
|
|
1498
|
+
if (transaction.replicaId !== ticket.replicaId) continue;
|
|
1499
|
+
if (
|
|
1500
|
+
transaction.previousLocalTransactionId &&
|
|
1501
|
+
this.pending.has(transaction.previousLocalTransactionId)
|
|
1502
|
+
) {
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
if (this.pendingIsBlocked(transaction.clientTransactionId)) continue;
|
|
1506
|
+
if (this.submittedEpoch.get(transaction.clientTransactionId) === epoch) continue;
|
|
1507
|
+
this.submittedEpoch.set(transaction.clientTransactionId, epoch);
|
|
1508
|
+
let receipt: EditableArtifactSubmitReceipt;
|
|
1509
|
+
try {
|
|
1510
|
+
receipt = await connection.submit({
|
|
1511
|
+
transaction: clonePending(transaction),
|
|
1512
|
+
signal: this.abortController.signal,
|
|
1513
|
+
});
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
if (
|
|
1516
|
+
ticket !== this.activeTicket ||
|
|
1517
|
+
connection !== this.activeConnection ||
|
|
1518
|
+
epoch !== this.activeEpoch
|
|
1519
|
+
) {
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const failure = asError(error);
|
|
1523
|
+
const code = errorCode(failure);
|
|
1524
|
+
if (code === "permission_changed") {
|
|
1525
|
+
this.submittedEpoch.delete(transaction.clientTransactionId);
|
|
1526
|
+
this.writable = false;
|
|
1527
|
+
this.lastError = new EditableArtifactSyncError("permission_changed", failure.message);
|
|
1528
|
+
this.emit();
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (isRetryableFailure(failure)) throw failure;
|
|
1532
|
+
if (code === "closed" || code === "stale_epoch") throw transientError(failure);
|
|
1533
|
+
this.submittedEpoch.delete(transaction.clientTransactionId);
|
|
1534
|
+
const blockedCode = boundedNonEmpty(
|
|
1535
|
+
code ?? "submit_rejected",
|
|
1536
|
+
"submit rejection code",
|
|
1537
|
+
MAX_IDENTIFIER_BYTES,
|
|
1538
|
+
);
|
|
1539
|
+
this.submitBlockedPending.set(transaction.clientTransactionId, {
|
|
1540
|
+
clientTransactionId: transaction.clientTransactionId,
|
|
1541
|
+
code: blockedCode,
|
|
1542
|
+
});
|
|
1543
|
+
this.emit();
|
|
1544
|
+
this.rotateReplicaConnectionIfNeeded();
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
if (
|
|
1548
|
+
ticket !== this.activeTicket ||
|
|
1549
|
+
connection !== this.activeConnection ||
|
|
1550
|
+
epoch !== this.activeEpoch
|
|
1551
|
+
)
|
|
1552
|
+
return;
|
|
1553
|
+
if (
|
|
1554
|
+
!receipt ||
|
|
1555
|
+
typeof receipt !== "object" ||
|
|
1556
|
+
!receipt.committed ||
|
|
1557
|
+
typeof receipt.committed !== "object" ||
|
|
1558
|
+
receipt.artifactId !== this.artifactId ||
|
|
1559
|
+
receipt.clientTransactionId !== transaction.clientTransactionId ||
|
|
1560
|
+
receipt.requestHash !== transaction.requestHash ||
|
|
1561
|
+
receipt.committed.artifactId !== this.artifactId ||
|
|
1562
|
+
receipt.committed.transactionId !== receipt.transactionId ||
|
|
1563
|
+
receipt.committed.requestHash !== transaction.requestHash
|
|
1564
|
+
) {
|
|
1565
|
+
throw invalidSequence("submit receipt identity does not match the pending transaction");
|
|
1566
|
+
}
|
|
1567
|
+
validateCommittedTransaction(
|
|
1568
|
+
receipt.committed,
|
|
1569
|
+
this.artifactId,
|
|
1570
|
+
this.modality,
|
|
1571
|
+
this.protocolVersion,
|
|
1572
|
+
this.maxCommittedTransactionBytes,
|
|
1573
|
+
);
|
|
1574
|
+
this.recordAcceptedMapping(receipt);
|
|
1575
|
+
this.enqueueLive(epoch, {
|
|
1576
|
+
type: "transaction.committed",
|
|
1577
|
+
transaction: receipt.committed,
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
private recordAcceptedMapping(receipt: EditableArtifactSubmitReceipt): void {
|
|
1583
|
+
this.recordAcceptedMappingIdentity(receipt.transactionId, {
|
|
1584
|
+
clientTransactionId: receipt.clientTransactionId,
|
|
1585
|
+
requestHash: receipt.requestHash,
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
private recordAcceptedMappingIdentity(
|
|
1590
|
+
transactionId: string,
|
|
1591
|
+
next: Readonly<{ clientTransactionId: string; requestHash: string }>,
|
|
1592
|
+
): void {
|
|
1593
|
+
const existing = this.acceptedMappings.get(transactionId);
|
|
1594
|
+
if (
|
|
1595
|
+
existing &&
|
|
1596
|
+
(existing.clientTransactionId !== next.clientTransactionId ||
|
|
1597
|
+
existing.requestHash !== next.requestHash)
|
|
1598
|
+
) {
|
|
1599
|
+
throw invalidSequence("authority reused a committed transaction id for another request");
|
|
1600
|
+
}
|
|
1601
|
+
this.acceptedMappings.set(transactionId, Object.freeze({ ...next }));
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
private requestSnapshotResync(
|
|
1605
|
+
reason: string,
|
|
1606
|
+
code: "queue_overflow" | "resync_required" = "queue_overflow",
|
|
1607
|
+
): void {
|
|
1608
|
+
this.resyncRevision += 1;
|
|
1609
|
+
this.requireSnapshot = true;
|
|
1610
|
+
this.lastError = new EditableArtifactSyncError(code, reason, {
|
|
1611
|
+
retryable: true,
|
|
1612
|
+
requiresSnapshot: true,
|
|
1613
|
+
});
|
|
1614
|
+
this.clearLiveQueue();
|
|
1615
|
+
this.activeConnection?.close("resync_required");
|
|
1616
|
+
this.emit();
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private clearLiveQueue(): void {
|
|
1620
|
+
this.liveQueue.length = 0;
|
|
1621
|
+
this.queuedBytes = 0;
|
|
1622
|
+
this.emit();
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
private validateTicket(ticket: EditableArtifactSyncTicket, expectedReplicaId: string): void {
|
|
1626
|
+
if (ticket.artifactId !== this.artifactId) {
|
|
1627
|
+
throw new EditableArtifactSyncError(
|
|
1628
|
+
"unsupported_protocol",
|
|
1629
|
+
"sync ticket belongs to another artifact",
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
if (ticket.modality !== this.modality) {
|
|
1633
|
+
throw new EditableArtifactSyncError(
|
|
1634
|
+
"unsupported_protocol",
|
|
1635
|
+
"sync ticket modality does not match the controller",
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
if (ticket.replicaId !== expectedReplicaId) {
|
|
1639
|
+
throw new EditableArtifactSyncError(
|
|
1640
|
+
"unsupported_protocol",
|
|
1641
|
+
"sync ticket is not bound to the requested writer replica",
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
this.requireProtocol(ticket.protocolVersion);
|
|
1645
|
+
boundedNonEmpty(ticket.token, "ticket.token", MAX_TICKET_BYTES);
|
|
1646
|
+
const expiresAt = Date.parse(ticket.expiresAt);
|
|
1647
|
+
if (!Number.isFinite(expiresAt)) {
|
|
1648
|
+
throw invalidBootstrap("sync ticket has an invalid expiry");
|
|
1649
|
+
}
|
|
1650
|
+
if (expiresAt <= this.scheduler.now() + this.minTicketTtlMs) {
|
|
1651
|
+
throw transientError(new TypeError("sync ticket is expired or too close to expiry"));
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
private orderedPending(): EditableArtifactPendingTransaction[] {
|
|
1656
|
+
return orderPendingTransactions([...this.pending.values()]);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
private hasSubmittablePending(): boolean {
|
|
1660
|
+
const activeReplicaId = this.activeTicket?.replicaId;
|
|
1661
|
+
return this.orderedPending().some(
|
|
1662
|
+
(transaction) =>
|
|
1663
|
+
transaction.replicaId === activeReplicaId &&
|
|
1664
|
+
this.submittedEpoch.get(transaction.clientTransactionId) !== this.activeEpoch &&
|
|
1665
|
+
!this.pendingIsBlocked(transaction.clientTransactionId) &&
|
|
1666
|
+
(!transaction.previousLocalTransactionId ||
|
|
1667
|
+
!this.pending.has(transaction.previousLocalTransactionId)),
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
private pendingBytes(): number {
|
|
1672
|
+
let bytes = 0;
|
|
1673
|
+
for (const transaction of this.pending.values()) {
|
|
1674
|
+
bytes += transaction.commandBytes.byteLength + transaction.intentBytes.byteLength;
|
|
1675
|
+
if (!Number.isSafeInteger(bytes)) return Number.POSITIVE_INFINITY;
|
|
1676
|
+
}
|
|
1677
|
+
return bytes;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
private currentFrontier(): EditableArtifactCausalFrontier {
|
|
1681
|
+
if (this.replica === null) return [];
|
|
1682
|
+
if (this.replica.modality !== "spreadsheet") {
|
|
1683
|
+
throw new TypeError("serialized artifacts do not have a causal frontier");
|
|
1684
|
+
}
|
|
1685
|
+
return this.replica.causalFrontier;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
private currentNativeRevision(): number {
|
|
1689
|
+
if (this.replica === null || this.replica.modality === "spreadsheet") {
|
|
1690
|
+
throw new TypeError("spreadsheet artifacts do not have a native revision");
|
|
1691
|
+
}
|
|
1692
|
+
return this.replica.nativeRevision;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
private desiredConnectionReplicaId(): string {
|
|
1696
|
+
return (
|
|
1697
|
+
this.orderedPending().find(
|
|
1698
|
+
(transaction) =>
|
|
1699
|
+
!this.pendingIsBlocked(transaction.clientTransactionId) &&
|
|
1700
|
+
(!transaction.previousLocalTransactionId ||
|
|
1701
|
+
!this.pending.has(transaction.previousLocalTransactionId)),
|
|
1702
|
+
)?.replicaId ?? this.writerReplicaId
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
private rotateReplicaConnectionIfNeeded(): void {
|
|
1707
|
+
const ticket = this.activeTicket;
|
|
1708
|
+
if (!ticket || ticket.replicaId === this.desiredConnectionReplicaId()) return;
|
|
1709
|
+
this.activeConnection?.close("replica_changed");
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
private async replacePendingOverlay(): Promise<void> {
|
|
1713
|
+
const projection = await this.kernel.replacePending(this.orderedPending());
|
|
1714
|
+
this.setBlockedPending(projection.blockedPending);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
private pendingIsBlocked(clientTransactionId: string): boolean {
|
|
1718
|
+
const blockedIds = new Set([
|
|
1719
|
+
...this.blockedPending.map((blocked) => blocked.clientTransactionId),
|
|
1720
|
+
...this.submitBlockedPending.keys(),
|
|
1721
|
+
]);
|
|
1722
|
+
let current = this.pending.get(clientTransactionId);
|
|
1723
|
+
const visited = new Set<string>();
|
|
1724
|
+
while (current) {
|
|
1725
|
+
if (blockedIds.has(current.clientTransactionId)) return true;
|
|
1726
|
+
const predecessor = current.previousLocalTransactionId;
|
|
1727
|
+
if (!predecessor || visited.has(predecessor)) return false;
|
|
1728
|
+
visited.add(predecessor);
|
|
1729
|
+
current = this.pending.get(predecessor);
|
|
1730
|
+
}
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
private setBlockedPending(values: readonly EditableArtifactBlockedPending[]): void {
|
|
1735
|
+
const seen = new Set<string>();
|
|
1736
|
+
this.blockedPending = values.map((value) => {
|
|
1737
|
+
requireClientTransactionId(value.clientTransactionId, "blocked clientTransactionId");
|
|
1738
|
+
boundedNonEmpty(value.code, "blocked code", MAX_IDENTIFIER_BYTES);
|
|
1739
|
+
if (!this.pending.has(value.clientTransactionId) || seen.has(value.clientTransactionId)) {
|
|
1740
|
+
throw new TypeError("worker returned an invalid blocked pending identity");
|
|
1741
|
+
}
|
|
1742
|
+
seen.add(value.clientTransactionId);
|
|
1743
|
+
return { ...value };
|
|
1744
|
+
});
|
|
1745
|
+
this.emit();
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
private allBlockedPending(): EditableArtifactBlockedPending[] {
|
|
1749
|
+
const byId = new Map<string, EditableArtifactBlockedPending>();
|
|
1750
|
+
for (const blocked of this.blockedPending) {
|
|
1751
|
+
byId.set(blocked.clientTransactionId, { ...blocked });
|
|
1752
|
+
}
|
|
1753
|
+
for (const blocked of this.submitBlockedPending.values()) {
|
|
1754
|
+
byId.set(blocked.clientTransactionId, { ...blocked });
|
|
1755
|
+
}
|
|
1756
|
+
return [...byId.values()].sort((left, right) =>
|
|
1757
|
+
compareCodeUnits(left.clientTransactionId, right.clientTransactionId),
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
private requireProtocol(actual: number): void {
|
|
1762
|
+
if (actual !== this.protocolVersion) {
|
|
1763
|
+
throw new EditableArtifactSyncError(
|
|
1764
|
+
"unsupported_protocol",
|
|
1765
|
+
`artifact protocol ${actual} is incompatible with client ${this.protocolVersion}`,
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
private setState(state: EditableArtifactSyncState): void {
|
|
1771
|
+
if (this.state === state) return;
|
|
1772
|
+
this.state = state;
|
|
1773
|
+
this.emit();
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
private emit(): void {
|
|
1777
|
+
const view = this.getView();
|
|
1778
|
+
for (const listener of this.listeners) {
|
|
1779
|
+
try {
|
|
1780
|
+
listener(view);
|
|
1781
|
+
} catch {
|
|
1782
|
+
// Observers cannot own transport or kernel lifecycle.
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
private resolveLiveWaiters(): void {
|
|
1788
|
+
for (const waiter of this.liveWaiters) waiter.resolve();
|
|
1789
|
+
this.liveWaiters.clear();
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
private rejectLiveWaiters(error: Error): void {
|
|
1793
|
+
for (const waiter of this.liveWaiters) waiter.reject(error);
|
|
1794
|
+
this.liveWaiters.clear();
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
private fail(error: Error): void {
|
|
1798
|
+
this.lastError = error;
|
|
1799
|
+
this.setState("failed");
|
|
1800
|
+
this.rejectLiveWaiters(error);
|
|
1801
|
+
this.activeConnection?.close("client_failed");
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function validatePendingTransaction(
|
|
1806
|
+
transaction: EditableArtifactPendingTransaction,
|
|
1807
|
+
artifactId: EditableArtifactId,
|
|
1808
|
+
modality: EditableArtifactModality,
|
|
1809
|
+
maxCommandBytes: number,
|
|
1810
|
+
): void {
|
|
1811
|
+
if (transaction.artifactId !== artifactId || transaction.modality !== modality) {
|
|
1812
|
+
throw new TypeError("pending artifact authority mismatch");
|
|
1813
|
+
}
|
|
1814
|
+
requireClientTransactionId(transaction.clientTransactionId, "clientTransactionId");
|
|
1815
|
+
requireSha256(transaction.requestHash, "requestHash");
|
|
1816
|
+
requireReplicaId(transaction.replicaId, "replicaId");
|
|
1817
|
+
positiveU16(transaction.protocolVersion, "protocolVersion");
|
|
1818
|
+
positiveU16(transaction.modelSchemaVersion, "modelSchemaVersion");
|
|
1819
|
+
positiveU16(transaction.commandVersion, "commandVersion");
|
|
1820
|
+
positiveSafeInteger(transaction.replicaCounter, "replicaCounter");
|
|
1821
|
+
if (transaction.previousLocalTransactionId !== null) {
|
|
1822
|
+
requireClientTransactionId(
|
|
1823
|
+
transaction.previousLocalTransactionId,
|
|
1824
|
+
"previousLocalTransactionId",
|
|
1825
|
+
);
|
|
1826
|
+
if (transaction.previousLocalTransactionId === transaction.clientTransactionId) {
|
|
1827
|
+
throw new TypeError("a pending transaction cannot depend on itself");
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
if (!(transaction.commandBytes instanceof Uint8Array)) {
|
|
1831
|
+
throw new TypeError("commandBytes must be a Uint8Array");
|
|
1832
|
+
}
|
|
1833
|
+
if (
|
|
1834
|
+
!(transaction.intentBytes instanceof Uint8Array) ||
|
|
1835
|
+
transaction.intentBytes.byteLength === 0
|
|
1836
|
+
) {
|
|
1837
|
+
throw new TypeError("intentBytes must be a non-empty Uint8Array");
|
|
1838
|
+
}
|
|
1839
|
+
if (transaction.intentBytes.byteLength > EDITABLE_ARTIFACT_INTENT_MAX_BYTES) {
|
|
1840
|
+
throw new RangeError("intentBytes exceeds the configured bound");
|
|
1841
|
+
}
|
|
1842
|
+
safeSequence(transaction.observedHeadSequence, "observedHeadSequence");
|
|
1843
|
+
if (transaction.commandBytes.byteLength === 0) throw new TypeError("commandBytes is empty");
|
|
1844
|
+
if (transaction.commandBytes.byteLength > maxCommandBytes) {
|
|
1845
|
+
throw new RangeError("commandBytes exceeds the configured bound");
|
|
1846
|
+
}
|
|
1847
|
+
nonNegativeSafeInteger(transaction.createdAt, "createdAt");
|
|
1848
|
+
const intent = decodeEditableArtifactMutationIntent(transaction.intentBytes);
|
|
1849
|
+
if (
|
|
1850
|
+
intent.artifactId !== transaction.artifactId ||
|
|
1851
|
+
intent.clientTransactionId !== transaction.clientTransactionId ||
|
|
1852
|
+
intent.replicaId !== transaction.replicaId ||
|
|
1853
|
+
intent.replicaCounter !== transaction.replicaCounter ||
|
|
1854
|
+
intent.previousLocalTransactionId !== transaction.previousLocalTransactionId ||
|
|
1855
|
+
intent.protocolVersion !== transaction.protocolVersion ||
|
|
1856
|
+
intent.modelSchemaVersion !== transaction.modelSchemaVersion ||
|
|
1857
|
+
intent.commandProtocolVersion !== transaction.commandVersion ||
|
|
1858
|
+
intent.observedHeadSequence !== transaction.observedHeadSequence ||
|
|
1859
|
+
!bytesEqual(intent.commandBytes, transaction.commandBytes)
|
|
1860
|
+
) {
|
|
1861
|
+
throw new TypeError("pending fields do not exactly match their canonical OGATX envelope");
|
|
1862
|
+
}
|
|
1863
|
+
editableArtifactCodecFor({
|
|
1864
|
+
durableModality: transaction.modality,
|
|
1865
|
+
modelSchemaVersion: transaction.modelSchemaVersion,
|
|
1866
|
+
commandProtocolVersion: transaction.commandVersion,
|
|
1867
|
+
}).command.assertCanonical(transaction.commandBytes);
|
|
1868
|
+
if (transaction.modality === "spreadsheet") {
|
|
1869
|
+
validateFrontier(transaction.causalBase, "causalBase");
|
|
1870
|
+
normalizeIdentifiers(transaction.selectiveUndoTargets, "selectiveUndoTargets");
|
|
1871
|
+
if (
|
|
1872
|
+
!identifiersEqual(intent.selectiveUndoOperationIds, transaction.selectiveUndoTargets) ||
|
|
1873
|
+
!frontiersEqual(intent.causalBase, transaction.causalBase)
|
|
1874
|
+
) {
|
|
1875
|
+
throw new TypeError("spreadsheet pending causality does not match its OGATX envelope");
|
|
1876
|
+
}
|
|
1877
|
+
} else {
|
|
1878
|
+
requireNativeRevision(transaction.observedNativeRevision, "observedNativeRevision");
|
|
1879
|
+
if (intent.causalBase.length !== 0 || intent.selectiveUndoOperationIds.length !== 0) {
|
|
1880
|
+
throw new TypeError("serialized pending transactions cannot carry CRDT causality");
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
if (
|
|
1884
|
+
hashEditableArtifactMutationIntentBytes(transaction.intentBytes) !== transaction.requestHash
|
|
1885
|
+
) {
|
|
1886
|
+
throw new TypeError("pending requestHash does not match its canonical OGATX envelope");
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function validateLiveLimits(limits: EditableArtifactLiveLimits): void {
|
|
1891
|
+
if (!limits || typeof limits !== "object" || Array.isArray(limits)) {
|
|
1892
|
+
throw new TypeError("live connection limits must be an object");
|
|
1893
|
+
}
|
|
1894
|
+
positiveSafeInteger(limits.maxClientFrameBytes, "limits.maxClientFrameBytes");
|
|
1895
|
+
nonNegativeSafeInteger(limits.maxCommandBytes, "limits.maxCommandBytes");
|
|
1896
|
+
nonNegativeSafeInteger(limits.maxIntentBytes, "limits.maxIntentBytes");
|
|
1897
|
+
nonNegativeSafeInteger(
|
|
1898
|
+
limits.maxCommittedTransactionBytes,
|
|
1899
|
+
"limits.maxCommittedTransactionBytes",
|
|
1900
|
+
);
|
|
1901
|
+
positiveSafeInteger(limits.maxSnapshotBytes, "limits.maxSnapshotBytes");
|
|
1902
|
+
positiveSafeInteger(limits.maxInFlightTransactions, "limits.maxInFlightTransactions");
|
|
1903
|
+
positiveSafeInteger(limits.maxInFlightBytes, "limits.maxInFlightBytes");
|
|
1904
|
+
if (
|
|
1905
|
+
limits.maxCommandBytes > EDITABLE_ARTIFACT_COMMAND_MAX_BYTES ||
|
|
1906
|
+
limits.maxIntentBytes > EDITABLE_ARTIFACT_INTENT_MAX_BYTES ||
|
|
1907
|
+
limits.maxCommittedTransactionBytes > MAX_COMMITTED_TRANSACTION_BYTES
|
|
1908
|
+
) {
|
|
1909
|
+
throw new TypeError("live connection advertised incompatible protocol limits");
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
type ExpectedAuthoredPending = Readonly<{
|
|
1914
|
+
modality: EditableArtifactModality;
|
|
1915
|
+
clientTransactionId: string;
|
|
1916
|
+
protocolVersion: number;
|
|
1917
|
+
modelSchemaVersion: number;
|
|
1918
|
+
commandVersion: number;
|
|
1919
|
+
replicaId: string;
|
|
1920
|
+
replicaCounter: number;
|
|
1921
|
+
previousLocalTransactionId: string | null;
|
|
1922
|
+
observedHeadSequence: number;
|
|
1923
|
+
commandBytes: Uint8Array;
|
|
1924
|
+
createdAt: number;
|
|
1925
|
+
causalBase?: EditableArtifactCausalFrontier;
|
|
1926
|
+
selectiveUndoTargets?: readonly string[];
|
|
1927
|
+
}>;
|
|
1928
|
+
|
|
1929
|
+
function verifyAuthoredPending(
|
|
1930
|
+
actual: EditableArtifactPendingTransaction,
|
|
1931
|
+
expected: ExpectedAuthoredPending,
|
|
1932
|
+
): void {
|
|
1933
|
+
if (
|
|
1934
|
+
actual.modality !== expected.modality ||
|
|
1935
|
+
actual.clientTransactionId !== expected.clientTransactionId ||
|
|
1936
|
+
actual.protocolVersion !== expected.protocolVersion ||
|
|
1937
|
+
actual.modelSchemaVersion !== expected.modelSchemaVersion ||
|
|
1938
|
+
actual.commandVersion !== expected.commandVersion ||
|
|
1939
|
+
actual.replicaId !== expected.replicaId ||
|
|
1940
|
+
actual.replicaCounter !== expected.replicaCounter ||
|
|
1941
|
+
actual.previousLocalTransactionId !== expected.previousLocalTransactionId ||
|
|
1942
|
+
actual.observedHeadSequence !== expected.observedHeadSequence ||
|
|
1943
|
+
actual.createdAt !== expected.createdAt ||
|
|
1944
|
+
!bytesEqual(actual.commandBytes, expected.commandBytes)
|
|
1945
|
+
) {
|
|
1946
|
+
throw new TypeError("worker authored a pending envelope that differs from its immutable input");
|
|
1947
|
+
}
|
|
1948
|
+
if (actual.modality === "spreadsheet") {
|
|
1949
|
+
if (
|
|
1950
|
+
expected.causalBase === undefined ||
|
|
1951
|
+
expected.selectiveUndoTargets === undefined ||
|
|
1952
|
+
!identifiersEqual(actual.selectiveUndoTargets, expected.selectiveUndoTargets)
|
|
1953
|
+
) {
|
|
1954
|
+
throw new TypeError("worker authored invalid spreadsheet pending metadata");
|
|
1955
|
+
}
|
|
1956
|
+
requireFrontier(actual.causalBase, expected.causalBase, "authored pending");
|
|
1957
|
+
} else if (expected.causalBase !== undefined || expected.selectiveUndoTargets !== undefined) {
|
|
1958
|
+
throw new TypeError("worker authored serialized pending with spreadsheet metadata");
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
|
|
1963
|
+
if (left.byteLength !== right.byteLength) return false;
|
|
1964
|
+
for (let index = 0; index < left.byteLength; index += 1) {
|
|
1965
|
+
if (left[index] !== right[index]) return false;
|
|
1966
|
+
}
|
|
1967
|
+
return true;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function validateCommittedTransaction(
|
|
1971
|
+
transaction: EditableArtifactCommittedTransaction,
|
|
1972
|
+
artifactId: EditableArtifactId,
|
|
1973
|
+
modality: EditableArtifactModality,
|
|
1974
|
+
protocolVersion: number,
|
|
1975
|
+
maxCommittedTransactionBytes: number,
|
|
1976
|
+
): Readonly<{
|
|
1977
|
+
clientTransactionId: string;
|
|
1978
|
+
requestHash: string;
|
|
1979
|
+
intentBytes: Uint8Array;
|
|
1980
|
+
}> | null {
|
|
1981
|
+
if (transaction.artifactId !== artifactId || transaction.modality !== modality) {
|
|
1982
|
+
throw invalidSequence("transaction artifact authority mismatch");
|
|
1983
|
+
}
|
|
1984
|
+
requireStableId(transaction.transactionId, "transactionId");
|
|
1985
|
+
requireSha256(transaction.requestHash, "requestHash");
|
|
1986
|
+
requireSha256(transaction.priorStateHash, "priorStateHash");
|
|
1987
|
+
requireSha256(transaction.stateHash, "stateHash");
|
|
1988
|
+
safeSequence(transaction.startSequence, "transaction startSequence");
|
|
1989
|
+
safeSequence(transaction.endSequence, "transaction endSequence");
|
|
1990
|
+
if (transaction.startSequence < 1 || transaction.endSequence < transaction.startSequence) {
|
|
1991
|
+
throw invalidSequence("transaction sequence interval is invalid");
|
|
1992
|
+
}
|
|
1993
|
+
if (!(transaction.committedTransactionBytes instanceof Uint8Array)) {
|
|
1994
|
+
throw invalidSequence("transaction committedTransactionBytes must be a Uint8Array");
|
|
1995
|
+
}
|
|
1996
|
+
if (transaction.committedTransactionBytes.byteLength === 0) {
|
|
1997
|
+
throw invalidSequence("transaction committedTransactionBytes is empty");
|
|
1998
|
+
}
|
|
1999
|
+
if (transaction.committedTransactionBytes.byteLength > maxCommittedTransactionBytes) {
|
|
2000
|
+
throw invalidSequence("transaction committedTransactionBytes exceeds the configured bound");
|
|
2001
|
+
}
|
|
2002
|
+
if (transaction.modality === "spreadsheet") {
|
|
2003
|
+
if (transaction.protocolVersion !== protocolVersion) {
|
|
2004
|
+
throw new EditableArtifactSyncError(
|
|
2005
|
+
"unsupported_protocol",
|
|
2006
|
+
`transaction protocol ${transaction.protocolVersion} is incompatible with client ${protocolVersion}`,
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
validateFrontier(transaction.causalFrontier, "causalFrontier");
|
|
2010
|
+
let summary: ReturnType<typeof decodeCommittedTransactionSummary>;
|
|
2011
|
+
try {
|
|
2012
|
+
summary = decodeCommittedTransactionSummary(transaction.committedTransactionBytes);
|
|
2013
|
+
} catch (error) {
|
|
2014
|
+
throw invalidSequence(`invalid OGACO envelope: ${asError(error).message}`);
|
|
2015
|
+
}
|
|
2016
|
+
if (
|
|
2017
|
+
summary.transactionId !== transaction.transactionId ||
|
|
2018
|
+
summary.operationProtocolVersion !== transaction.protocolVersion ||
|
|
2019
|
+
summary.priorStateHash !== transaction.priorStateHash ||
|
|
2020
|
+
summary.stateHash !== transaction.stateHash ||
|
|
2021
|
+
!frontiersEqual(summary.resultingCausalFrontier, transaction.causalFrontier)
|
|
2022
|
+
) {
|
|
2023
|
+
throw invalidSequence("committed routing fields do not exactly match the OGACO envelope");
|
|
2024
|
+
}
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
requireNativeRevision(transaction.priorNativeRevision, "transaction.priorNativeRevision");
|
|
2028
|
+
requireNativeRevision(transaction.nativeRevision, "transaction.nativeRevision");
|
|
2029
|
+
let summary: ReturnType<typeof decodeEditableArtifactSerializedCommit>;
|
|
2030
|
+
try {
|
|
2031
|
+
summary = decodeEditableArtifactSerializedCommit(
|
|
2032
|
+
transaction.committedTransactionBytes,
|
|
2033
|
+
transaction.modality,
|
|
2034
|
+
);
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
throw invalidSequence(`invalid OGAST envelope: ${asError(error).message}`);
|
|
2037
|
+
}
|
|
2038
|
+
if (
|
|
2039
|
+
summary.modality !== transaction.modality ||
|
|
2040
|
+
summary.transactionId !== transaction.transactionId ||
|
|
2041
|
+
summary.parentHeadSequence !== transaction.startSequence - 1 ||
|
|
2042
|
+
summary.resultHeadSequence !== transaction.endSequence ||
|
|
2043
|
+
summary.priorNativeRevision !== transaction.priorNativeRevision ||
|
|
2044
|
+
summary.nativeReceipt.revision !== transaction.nativeRevision ||
|
|
2045
|
+
summary.commitProtocolVersion !== transaction.commitProtocolVersion ||
|
|
2046
|
+
summary.priorStateHash !== transaction.priorStateHash ||
|
|
2047
|
+
summary.stateHash !== transaction.stateHash ||
|
|
2048
|
+
summary.requestHash !== transaction.requestHash ||
|
|
2049
|
+
summary.intent.artifactId !== transaction.artifactId
|
|
2050
|
+
) {
|
|
2051
|
+
throw invalidSequence("committed routing fields do not exactly match the OGAST envelope");
|
|
2052
|
+
}
|
|
2053
|
+
return Object.freeze({
|
|
2054
|
+
clientTransactionId: summary.intent.clientTransactionId,
|
|
2055
|
+
requestHash: summary.requestHash,
|
|
2056
|
+
intentBytes: summary.intentBytes,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function validateSnapshot(
|
|
2061
|
+
snapshot: EditableArtifactStoredReplica["snapshot"],
|
|
2062
|
+
artifactId: EditableArtifactId,
|
|
2063
|
+
modality: EditableArtifactModality,
|
|
2064
|
+
protocolVersion: number,
|
|
2065
|
+
kernelVersion: string,
|
|
2066
|
+
modelSchemaVersion: number,
|
|
2067
|
+
maxSnapshotBytes: number,
|
|
2068
|
+
): void {
|
|
2069
|
+
if (snapshot.artifactId !== artifactId || snapshot.modality !== modality) {
|
|
2070
|
+
throw invalidBootstrap("snapshot artifact authority mismatch");
|
|
2071
|
+
}
|
|
2072
|
+
safeSequence(snapshot.sequence, "snapshot sequence");
|
|
2073
|
+
requireSha256(snapshot.stateHash, "snapshot.stateHash");
|
|
2074
|
+
requireSha256(snapshot.digest, "snapshot.digest");
|
|
2075
|
+
boundedNonEmpty(snapshot.kernelVersion, "snapshot.kernelVersion", MAX_KERNEL_VERSION_BYTES);
|
|
2076
|
+
positiveSafeInteger(snapshot.modelSchemaVersion, "snapshot.modelSchemaVersion");
|
|
2077
|
+
if (snapshot.modality === "spreadsheet") {
|
|
2078
|
+
validateFrontier(snapshot.causalFrontier, "snapshot.causalFrontier");
|
|
2079
|
+
if (snapshot.protocolVersion !== protocolVersion) {
|
|
2080
|
+
throw new EditableArtifactSyncError(
|
|
2081
|
+
"unsupported_protocol",
|
|
2082
|
+
`snapshot protocol ${snapshot.protocolVersion} is incompatible with client ${protocolVersion}`,
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
} else {
|
|
2086
|
+
requireNativeRevision(snapshot.nativeRevision, "snapshot.nativeRevision");
|
|
2087
|
+
}
|
|
2088
|
+
if (
|
|
2089
|
+
snapshot.kernelVersion !== kernelVersion ||
|
|
2090
|
+
snapshot.modelSchemaVersion !== modelSchemaVersion
|
|
2091
|
+
) {
|
|
2092
|
+
throw new EditableArtifactSyncError(
|
|
2093
|
+
"unsupported_protocol",
|
|
2094
|
+
`snapshot kernel/schema ${snapshot.kernelVersion}/${snapshot.modelSchemaVersion} is incompatible with ${kernelVersion}/${modelSchemaVersion}`,
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
if (!(snapshot.bytes instanceof Uint8Array)) {
|
|
2098
|
+
throw invalidBootstrap("snapshot bytes must be a Uint8Array");
|
|
2099
|
+
}
|
|
2100
|
+
if (snapshot.bytes.byteLength === 0) throw invalidBootstrap("snapshot bytes is empty");
|
|
2101
|
+
if (snapshot.bytes.byteLength > maxSnapshotBytes) {
|
|
2102
|
+
throw invalidBootstrap("snapshot bytes exceeds the configured bound");
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function validateFrontier(frontier: EditableArtifactCausalFrontier, label: string): void {
|
|
2107
|
+
if (!Array.isArray(frontier)) throw new TypeError(`${label} must be an array`);
|
|
2108
|
+
if (frontier.length > MAX_FRONTIER_ACTORS) {
|
|
2109
|
+
throw new RangeError(`${label} exceeds ${MAX_FRONTIER_ACTORS} actors`);
|
|
2110
|
+
}
|
|
2111
|
+
let previous: string | null = null;
|
|
2112
|
+
for (const [index, entry] of frontier.entries()) {
|
|
2113
|
+
if (
|
|
2114
|
+
!entry ||
|
|
2115
|
+
typeof entry !== "object" ||
|
|
2116
|
+
Array.isArray(entry) ||
|
|
2117
|
+
Object.getPrototypeOf(entry) !== Object.prototype
|
|
2118
|
+
) {
|
|
2119
|
+
throw new TypeError(`${label}[${index}] must be a plain object`);
|
|
2120
|
+
}
|
|
2121
|
+
requireReplicaId(entry.replicaId, `${label}[${index}].replicaId`);
|
|
2122
|
+
positiveSafeInteger(entry.counter, `${label}[${index}].counter`);
|
|
2123
|
+
if (previous !== null && compareCodeUnits(previous, entry.replicaId) >= 0) {
|
|
2124
|
+
throw new TypeError(`${label} must be strictly sorted and duplicate-free`);
|
|
2125
|
+
}
|
|
2126
|
+
previous = entry.replicaId;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function validateLiveMessage(
|
|
2131
|
+
message: EditableArtifactLiveMessage,
|
|
2132
|
+
artifactId: EditableArtifactId,
|
|
2133
|
+
modality: EditableArtifactModality,
|
|
2134
|
+
protocolVersion: number,
|
|
2135
|
+
maxCommittedTransactionBytes: number,
|
|
2136
|
+
): void {
|
|
2137
|
+
if (!message || typeof message !== "object") {
|
|
2138
|
+
throw invalidSequence("live message must be an object");
|
|
2139
|
+
}
|
|
2140
|
+
if (message.type === "transaction.committed") {
|
|
2141
|
+
validateCommittedTransaction(
|
|
2142
|
+
message.transaction,
|
|
2143
|
+
artifactId,
|
|
2144
|
+
modality,
|
|
2145
|
+
protocolVersion,
|
|
2146
|
+
maxCommittedTransactionBytes,
|
|
2147
|
+
);
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
if (message.type === "head") {
|
|
2151
|
+
if (message.artifactId !== artifactId) {
|
|
2152
|
+
throw invalidSequence("live head belongs to another artifact");
|
|
2153
|
+
}
|
|
2154
|
+
safeSequence(message.headSequence, "live headSequence");
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
if (message.type === "authorization") {
|
|
2158
|
+
if (message.artifactId !== artifactId) {
|
|
2159
|
+
throw invalidSequence("live authorization belongs to another artifact");
|
|
2160
|
+
}
|
|
2161
|
+
if (typeof message.writable !== "boolean") {
|
|
2162
|
+
throw invalidSequence("live authorization writable must be boolean");
|
|
2163
|
+
}
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
if (message.type === "resync_required") {
|
|
2167
|
+
if (message.artifactId !== artifactId) {
|
|
2168
|
+
throw invalidSequence("live resync belongs to another artifact");
|
|
2169
|
+
}
|
|
2170
|
+
boundedNonEmpty(message.reason, "live resync reason", 8 * 1024);
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
throw invalidSequence("live message type is unsupported");
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
function estimateLiveMessageBytes(message: EditableArtifactLiveMessage): number {
|
|
2177
|
+
if (message.type === "transaction.committed") {
|
|
2178
|
+
return estimateCommittedTransactionBytes(message.transaction);
|
|
2179
|
+
}
|
|
2180
|
+
if (message.type === "resync_required") {
|
|
2181
|
+
return new TextEncoder().encode(message.reason).byteLength + 128;
|
|
2182
|
+
}
|
|
2183
|
+
return 96;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function estimateCommittedTransactionBytes(
|
|
2187
|
+
transaction: EditableArtifactCommittedTransaction,
|
|
2188
|
+
): number {
|
|
2189
|
+
return (
|
|
2190
|
+
transaction.committedTransactionBytes.byteLength +
|
|
2191
|
+
512 +
|
|
2192
|
+
(transaction.modality === "spreadsheet"
|
|
2193
|
+
? transaction.causalFrontier.length * (MAX_IDENTIFIER_BYTES + 16)
|
|
2194
|
+
: 32)
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function requireStateHash(actual: string, expected: string, source: string): void {
|
|
2199
|
+
if (actual !== expected) {
|
|
2200
|
+
throw new EditableArtifactSyncError(
|
|
2201
|
+
"kernel_diverged",
|
|
2202
|
+
`${source} produced state hash ${actual}; expected ${expected}`,
|
|
2203
|
+
{ retryable: true, requiresSnapshot: true },
|
|
2204
|
+
);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function requireDigest(actual: string, expected: string, source: string): void {
|
|
2209
|
+
requireSha256(actual, `${source} computed digest`);
|
|
2210
|
+
if (actual !== expected) {
|
|
2211
|
+
throw new EditableArtifactSyncError(
|
|
2212
|
+
"kernel_diverged",
|
|
2213
|
+
`${source} bytes produced digest ${actual}; expected ${expected}`,
|
|
2214
|
+
{ retryable: true, requiresSnapshot: true },
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function requireFrontier(
|
|
2220
|
+
actual: EditableArtifactCausalFrontier,
|
|
2221
|
+
expected: EditableArtifactCausalFrontier,
|
|
2222
|
+
source: string,
|
|
2223
|
+
): void {
|
|
2224
|
+
if (
|
|
2225
|
+
actual.length !== expected.length ||
|
|
2226
|
+
actual.some(
|
|
2227
|
+
(entry, index) =>
|
|
2228
|
+
entry.replicaId !== expected[index]?.replicaId ||
|
|
2229
|
+
entry.counter !== expected[index]?.counter,
|
|
2230
|
+
)
|
|
2231
|
+
) {
|
|
2232
|
+
throw new EditableArtifactSyncError(
|
|
2233
|
+
"kernel_diverged",
|
|
2234
|
+
`${source} causal frontier does not match the authoritative barrier`,
|
|
2235
|
+
{ retryable: true, requiresSnapshot: true },
|
|
2236
|
+
);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function orderPendingTransactions(
|
|
2241
|
+
transactions: EditableArtifactPendingTransaction[],
|
|
2242
|
+
): EditableArtifactPendingTransaction[] {
|
|
2243
|
+
const byId = new Map<string, EditableArtifactPendingTransaction>();
|
|
2244
|
+
const replicaCounters = new Set<string>();
|
|
2245
|
+
for (const transaction of transactions) {
|
|
2246
|
+
if (byId.has(transaction.clientTransactionId)) {
|
|
2247
|
+
throw new TypeError(`duplicate pending transaction ${transaction.clientTransactionId}`);
|
|
2248
|
+
}
|
|
2249
|
+
const counterKey = `${transaction.replicaId}\0${transaction.replicaCounter}`;
|
|
2250
|
+
if (replicaCounters.has(counterKey)) {
|
|
2251
|
+
throw new TypeError(
|
|
2252
|
+
`replica ${transaction.replicaId} reused counter ${transaction.replicaCounter}`,
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
replicaCounters.add(counterKey);
|
|
2256
|
+
byId.set(transaction.clientTransactionId, transaction);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
const children = new Map<string, string[]>();
|
|
2260
|
+
const indegree = new Map<string, number>();
|
|
2261
|
+
for (const transaction of transactions) {
|
|
2262
|
+
let count = 0;
|
|
2263
|
+
const dependency = transaction.previousLocalTransactionId;
|
|
2264
|
+
if (dependency && byId.has(dependency)) {
|
|
2265
|
+
const list = children.get(dependency) ?? [];
|
|
2266
|
+
list.push(transaction.clientTransactionId);
|
|
2267
|
+
children.set(dependency, list);
|
|
2268
|
+
count += 1;
|
|
2269
|
+
}
|
|
2270
|
+
indegree.set(transaction.clientTransactionId, count);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
const ready = transactions
|
|
2274
|
+
.filter((transaction) => indegree.get(transaction.clientTransactionId) === 0)
|
|
2275
|
+
.sort(comparePendingStable);
|
|
2276
|
+
const ordered: EditableArtifactPendingTransaction[] = [];
|
|
2277
|
+
while (ready.length > 0) {
|
|
2278
|
+
const transaction = ready.shift();
|
|
2279
|
+
if (!transaction) break;
|
|
2280
|
+
ordered.push(transaction);
|
|
2281
|
+
for (const childId of children.get(transaction.clientTransactionId) ?? []) {
|
|
2282
|
+
const next = (indegree.get(childId) ?? 0) - 1;
|
|
2283
|
+
indegree.set(childId, next);
|
|
2284
|
+
if (next === 0) {
|
|
2285
|
+
const child = byId.get(childId);
|
|
2286
|
+
if (child) insertSorted(ready, child, comparePendingStable);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
if (ordered.length !== transactions.length) {
|
|
2291
|
+
throw new TypeError("pending transaction dependencies contain a cycle");
|
|
2292
|
+
}
|
|
2293
|
+
return ordered;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function comparePendingStable(
|
|
2297
|
+
left: EditableArtifactPendingTransaction,
|
|
2298
|
+
right: EditableArtifactPendingTransaction,
|
|
2299
|
+
): number {
|
|
2300
|
+
const replica = compareCodeUnits(left.replicaId, right.replicaId);
|
|
2301
|
+
if (replica !== 0) return replica;
|
|
2302
|
+
if (left.replicaCounter !== right.replicaCounter) {
|
|
2303
|
+
return left.replicaCounter < right.replicaCounter ? -1 : 1;
|
|
2304
|
+
}
|
|
2305
|
+
if (left.createdAt !== right.createdAt) return left.createdAt < right.createdAt ? -1 : 1;
|
|
2306
|
+
return compareCodeUnits(left.clientTransactionId, right.clientTransactionId);
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
2310
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
function insertSorted<T>(values: T[], value: T, compare: (left: T, right: T) => number): void {
|
|
2314
|
+
let low = 0;
|
|
2315
|
+
let high = values.length;
|
|
2316
|
+
while (low < high) {
|
|
2317
|
+
const middle = (low + high) >>> 1;
|
|
2318
|
+
if (compare(values[middle] as T, value) <= 0) low = middle + 1;
|
|
2319
|
+
else high = middle;
|
|
2320
|
+
}
|
|
2321
|
+
values.splice(low, 0, value);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
function cloneSnapshot(
|
|
2325
|
+
snapshot: EditableArtifactSpreadsheetSnapshot,
|
|
2326
|
+
): EditableArtifactSpreadsheetSnapshot;
|
|
2327
|
+
function cloneSnapshot(
|
|
2328
|
+
snapshot: EditableArtifactSerializedSnapshot,
|
|
2329
|
+
): EditableArtifactSerializedSnapshot;
|
|
2330
|
+
function cloneSnapshot(
|
|
2331
|
+
snapshot: EditableArtifactStoredReplica["snapshot"],
|
|
2332
|
+
): EditableArtifactStoredReplica["snapshot"];
|
|
2333
|
+
function cloneSnapshot(
|
|
2334
|
+
snapshot: EditableArtifactStoredReplica["snapshot"],
|
|
2335
|
+
): EditableArtifactStoredReplica["snapshot"] {
|
|
2336
|
+
return snapshot.modality === "spreadsheet"
|
|
2337
|
+
? {
|
|
2338
|
+
...snapshot,
|
|
2339
|
+
causalFrontier: cloneFrontier(snapshot.causalFrontier),
|
|
2340
|
+
bytes: snapshot.bytes.slice(),
|
|
2341
|
+
}
|
|
2342
|
+
: { ...snapshot, bytes: snapshot.bytes.slice() };
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function cloneCommitted(
|
|
2346
|
+
transaction: EditableArtifactSpreadsheetCommittedTransaction,
|
|
2347
|
+
): EditableArtifactSpreadsheetCommittedTransaction;
|
|
2348
|
+
function cloneCommitted(
|
|
2349
|
+
transaction: EditableArtifactSerializedCommittedTransaction,
|
|
2350
|
+
): EditableArtifactSerializedCommittedTransaction;
|
|
2351
|
+
function cloneCommitted(
|
|
2352
|
+
transaction: EditableArtifactCommittedTransaction,
|
|
2353
|
+
): EditableArtifactCommittedTransaction;
|
|
2354
|
+
function cloneCommitted(
|
|
2355
|
+
transaction: EditableArtifactCommittedTransaction,
|
|
2356
|
+
): EditableArtifactCommittedTransaction {
|
|
2357
|
+
return transaction.modality === "spreadsheet"
|
|
2358
|
+
? {
|
|
2359
|
+
...transaction,
|
|
2360
|
+
causalFrontier: cloneFrontier(transaction.causalFrontier),
|
|
2361
|
+
committedTransactionBytes: transaction.committedTransactionBytes.slice(),
|
|
2362
|
+
}
|
|
2363
|
+
: {
|
|
2364
|
+
...transaction,
|
|
2365
|
+
committedTransactionBytes: transaction.committedTransactionBytes.slice(),
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
function clonePending(
|
|
2370
|
+
transaction: EditableArtifactSpreadsheetPendingTransaction,
|
|
2371
|
+
): EditableArtifactSpreadsheetPendingTransaction;
|
|
2372
|
+
function clonePending(
|
|
2373
|
+
transaction: Exclude<EditableArtifactPendingTransaction, { modality: "spreadsheet" }>,
|
|
2374
|
+
): Exclude<EditableArtifactPendingTransaction, { modality: "spreadsheet" }>;
|
|
2375
|
+
function clonePending(
|
|
2376
|
+
transaction: EditableArtifactPendingTransaction,
|
|
2377
|
+
): EditableArtifactPendingTransaction;
|
|
2378
|
+
function clonePending(
|
|
2379
|
+
transaction: EditableArtifactPendingTransaction,
|
|
2380
|
+
): EditableArtifactPendingTransaction {
|
|
2381
|
+
return transaction.modality === "spreadsheet"
|
|
2382
|
+
? {
|
|
2383
|
+
...transaction,
|
|
2384
|
+
causalBase: cloneFrontier(transaction.causalBase),
|
|
2385
|
+
selectiveUndoTargets: [...transaction.selectiveUndoTargets],
|
|
2386
|
+
commandBytes: transaction.commandBytes.slice(),
|
|
2387
|
+
intentBytes: transaction.intentBytes.slice(),
|
|
2388
|
+
}
|
|
2389
|
+
: {
|
|
2390
|
+
...transaction,
|
|
2391
|
+
commandBytes: transaction.commandBytes.slice(),
|
|
2392
|
+
intentBytes: transaction.intentBytes.slice(),
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
function safeSequence(value: number, label: string): number {
|
|
2397
|
+
return nonNegativeSafeInteger(value, label);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function requireModality(value: unknown): EditableArtifactModality {
|
|
2401
|
+
if (value !== "document" && value !== "spreadsheet" && value !== "presentation") {
|
|
2402
|
+
throw new TypeError("artifact modality is invalid");
|
|
2403
|
+
}
|
|
2404
|
+
return value;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function requireNativeRevision(value: number | null, label: string): number {
|
|
2408
|
+
if (value === null || !Number.isSafeInteger(value) || value < 0) {
|
|
2409
|
+
throw new TypeError(`${label} must be a non-negative safe integer`);
|
|
2410
|
+
}
|
|
2411
|
+
return value;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function requireNativeRevisionEqual(actual: number, expected: number, source: string): void {
|
|
2415
|
+
requireNativeRevision(actual, `${source} actual native revision`);
|
|
2416
|
+
requireNativeRevision(expected, `${source} expected native revision`);
|
|
2417
|
+
if (actual !== expected) {
|
|
2418
|
+
throw new EditableArtifactSyncError(
|
|
2419
|
+
"kernel_diverged",
|
|
2420
|
+
`${source} native revision ${actual} does not match ${expected}`,
|
|
2421
|
+
{ retryable: true, requiresSnapshot: true },
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function positiveSafeInteger(value: number, label: string): number {
|
|
2427
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${label} must be positive`);
|
|
2428
|
+
return value;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function positiveU16(value: number, label: string): number {
|
|
2432
|
+
const validated = positiveSafeInteger(value, label);
|
|
2433
|
+
if (validated > 0xffff) throw new RangeError(`${label} must fit an unsigned 16-bit integer`);
|
|
2434
|
+
return validated;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function nonNegativeSafeInteger(value: number, label: string): number {
|
|
2438
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
2439
|
+
throw new RangeError(`${label} must be a non-negative safe integer`);
|
|
2440
|
+
}
|
|
2441
|
+
return value;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function requireNonEmpty(value: string, label: string): string {
|
|
2445
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
2446
|
+
throw new TypeError(`${label} must be a non-empty string`);
|
|
2447
|
+
}
|
|
2448
|
+
return value;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
function boundedNonEmpty(value: string, label: string, maxBytes: number): string {
|
|
2452
|
+
requireNonEmpty(value, label);
|
|
2453
|
+
if (new TextEncoder().encode(value).byteLength > maxBytes) {
|
|
2454
|
+
throw new RangeError(`${label} exceeds ${maxBytes} UTF-8 bytes`);
|
|
2455
|
+
}
|
|
2456
|
+
return value;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
function requireSha256(value: string, label: string): string {
|
|
2460
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(value)) {
|
|
2461
|
+
throw new TypeError(`${label} must be a canonical sha256: digest`);
|
|
2462
|
+
}
|
|
2463
|
+
return value;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
function requireStableId(value: string, label: string): string {
|
|
2467
|
+
if (!/^(?!0{32}$)[a-f0-9]{32}$/u.test(value)) {
|
|
2468
|
+
throw new TypeError(`${label} must be 32 lowercase nonzero hex characters`);
|
|
2469
|
+
}
|
|
2470
|
+
return value;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
function requireClientTransactionId(value: string, label: string): string {
|
|
2474
|
+
if (!/^[A-Za-z0-9._:-]{1,200}$/u.test(value)) {
|
|
2475
|
+
throw new TypeError(`${label} must be a canonical portable transaction id`);
|
|
2476
|
+
}
|
|
2477
|
+
return value;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function requireReplicaId(value: string, label: string): string {
|
|
2481
|
+
if (!/^(?!0{16}$)[a-f0-9]{16}$/.test(value)) {
|
|
2482
|
+
throw new TypeError(`${label} must be 16 lowercase nonzero hex characters`);
|
|
2483
|
+
}
|
|
2484
|
+
return value;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
function defaultWriterReplicaId(): string {
|
|
2488
|
+
for (;;) {
|
|
2489
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
2490
|
+
const value = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2491
|
+
if (value !== "0000000000000000") return value;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
export function editableArtifactCacheNamespace(authority: EditableArtifactCacheAuthority): string {
|
|
2496
|
+
const origin = new URL(authority.deploymentOrigin).origin;
|
|
2497
|
+
if (!origin.startsWith("https://") && !origin.startsWith("http://")) {
|
|
2498
|
+
throw new TypeError("storageAuthority.deploymentOrigin must be HTTP(S)");
|
|
2499
|
+
}
|
|
2500
|
+
return JSON.stringify([
|
|
2501
|
+
origin,
|
|
2502
|
+
boundedNonEmpty(authority.accountId, "storageAuthority.accountId", MAX_IDENTIFIER_BYTES),
|
|
2503
|
+
boundedNonEmpty(authority.workspaceId, "storageAuthority.workspaceId", MAX_IDENTIFIER_BYTES),
|
|
2504
|
+
boundedNonEmpty(authority.principalId, "storageAuthority.principalId", MAX_IDENTIFIER_BYTES),
|
|
2505
|
+
boundedNonEmpty(
|
|
2506
|
+
authority.authorizationEpoch,
|
|
2507
|
+
"storageAuthority.authorizationEpoch",
|
|
2508
|
+
MAX_IDENTIFIER_BYTES,
|
|
2509
|
+
),
|
|
2510
|
+
]);
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function cloneFrontier(frontier: EditableArtifactCausalFrontier): EditableArtifactCausalFrontier {
|
|
2514
|
+
return frontier.map((entry) => ({ ...entry }));
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
function frontiersEqual(
|
|
2518
|
+
left: readonly Readonly<{ replicaId: string; counter: number }>[],
|
|
2519
|
+
right: readonly Readonly<{ replicaId: string; counter: number }>[],
|
|
2520
|
+
): boolean {
|
|
2521
|
+
return (
|
|
2522
|
+
left.length === right.length &&
|
|
2523
|
+
left.every(
|
|
2524
|
+
(entry, index) =>
|
|
2525
|
+
entry.replicaId === right[index]?.replicaId && entry.counter === right[index]?.counter,
|
|
2526
|
+
)
|
|
2527
|
+
);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function normalizeIdentifiers(values: readonly string[], label: string): readonly string[] {
|
|
2531
|
+
if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`);
|
|
2532
|
+
if (values.length > 1_024) throw new RangeError(`${label} exceeds 1024 entries`);
|
|
2533
|
+
const normalized = values.map((value, index) =>
|
|
2534
|
+
boundedNonEmpty(value, `${label}[${index}]`, MAX_IDENTIFIER_BYTES),
|
|
2535
|
+
);
|
|
2536
|
+
normalized.sort(compareCodeUnits);
|
|
2537
|
+
for (let index = 1; index < normalized.length; index += 1) {
|
|
2538
|
+
if (normalized[index - 1] === normalized[index]) {
|
|
2539
|
+
throw new TypeError(`${label} contains a duplicate identifier`);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
return normalized;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
function identifiersEqual(left: readonly string[], right: readonly string[]): boolean {
|
|
2546
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function invalidBootstrap(message: string, retryable = false): EditableArtifactSyncError {
|
|
2550
|
+
return new EditableArtifactSyncError("invalid_bootstrap", message, {
|
|
2551
|
+
retryable,
|
|
2552
|
+
requiresSnapshot: retryable,
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
function invalidSequence(message: string): EditableArtifactSyncError {
|
|
2557
|
+
return new EditableArtifactSyncError("invalid_sequence", message, {
|
|
2558
|
+
retryable: true,
|
|
2559
|
+
requiresSnapshot: true,
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
function transientError(cause: unknown): EditableArtifactSyncError {
|
|
2564
|
+
return new EditableArtifactSyncError("invalid_bootstrap", "artifact transport interrupted", {
|
|
2565
|
+
retryable: true,
|
|
2566
|
+
cause,
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function storageError(message: string, cause?: unknown): EditableArtifactSyncError {
|
|
2571
|
+
return new EditableArtifactSyncError("storage_failed", message, { cause });
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function asError(error: unknown): Error {
|
|
2575
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function errorCode(error: Error): string | null {
|
|
2579
|
+
const code = (error as Error & { code?: unknown }).code;
|
|
2580
|
+
return typeof code === "string" && code.length > 0 ? code : null;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function isRetryableFailure(error: Error): boolean {
|
|
2584
|
+
if (error instanceof EditableArtifactSyncError) return error.retryable;
|
|
2585
|
+
return (error as Error & { retryable?: unknown }).retryable === true;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
function isTerminal(state: EditableArtifactSyncState): boolean {
|
|
2589
|
+
return state === "unsupported" || state === "failed" || state === "closed";
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function waitForConnectionClose(
|
|
2593
|
+
connection: EditableArtifactLiveConnection,
|
|
2594
|
+
signal: AbortSignal,
|
|
2595
|
+
): Promise<Awaited<EditableArtifactLiveConnection["closed"]>> {
|
|
2596
|
+
if (signal.aborted) return Promise.resolve({ reason: "closed" });
|
|
2597
|
+
return new Promise((resolve, reject) => {
|
|
2598
|
+
let settled = false;
|
|
2599
|
+
const finish = (value: Awaited<EditableArtifactLiveConnection["closed"]>): void => {
|
|
2600
|
+
if (settled) return;
|
|
2601
|
+
settled = true;
|
|
2602
|
+
signal.removeEventListener("abort", onAbort);
|
|
2603
|
+
resolve(value);
|
|
2604
|
+
};
|
|
2605
|
+
const fail = (error: unknown): void => {
|
|
2606
|
+
if (settled) return;
|
|
2607
|
+
settled = true;
|
|
2608
|
+
signal.removeEventListener("abort", onAbort);
|
|
2609
|
+
reject(error);
|
|
2610
|
+
};
|
|
2611
|
+
const onAbort = (): void => finish({ reason: "closed" });
|
|
2612
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2613
|
+
void connection.closed.then(finish, fail);
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
const defaultScheduler: EditableArtifactSyncScheduler = {
|
|
2618
|
+
now: () => Date.now(),
|
|
2619
|
+
sleep: async (delayMs, signal) => {
|
|
2620
|
+
if (signal.aborted || delayMs <= 0) return;
|
|
2621
|
+
await new Promise<void>((resolve) => {
|
|
2622
|
+
const timer = setTimeout(done, delayMs);
|
|
2623
|
+
const onAbort = () => done();
|
|
2624
|
+
function done() {
|
|
2625
|
+
clearTimeout(timer);
|
|
2626
|
+
signal.removeEventListener("abort", onAbort);
|
|
2627
|
+
resolve();
|
|
2628
|
+
}
|
|
2629
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2630
|
+
});
|
|
2631
|
+
},
|
|
2632
|
+
};
|