@amalgm/shell 0.1.112 → 0.1.113

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.
@@ -3,7 +3,7 @@ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlin
3
3
  import { open, readFile } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
6
- import { CHUNK_BYTES, CONTENT_CONTRACT, INLINE_CARGO_MAX_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, decodeAcceptedEntityRecord, deriveUploadManifest, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, downloadAll as downloadAllArtifacts, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, planCargoWaves, PRESIGN_BATCH_OBJECTS, privateEntityResourceId, repositoryIdentityHash, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
6
+ import { CHUNK_BYTES, CONTENT_CONTRACT, INLINE_CARGO_MAX_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, decodeAcceptedEntityRecord, deriveUploadManifest, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, downloadAll as downloadAllArtifacts, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, pathIsSuspect, planCargoWaves, PRESIGN_BATCH_OBJECTS, privateEntityResourceId, repositoryIdentityHash, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, GRAPH_HEAD_OPERATION_KIND, } from "@amalgm/live";
7
7
  import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir, ensureUserDir } from "./filesystem.js";
9
9
  import { emitFilesStage, } from "./files-observability.js";
@@ -22,9 +22,9 @@ import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBindin
22
22
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, readCachedRepositoryIdentity, } from "./git-repository-host.js";
23
23
  import { gitEvidenceFingerprint, inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
24
24
  import { projectMaterializedGraph } from "./materialized-graph.js";
25
+ import { graphPageIdentities, registryGraphFromSnapshot, } from "./registry-graph.js";
25
26
  import { NodeWatchHost, } from "./watching/index.js";
26
27
  import { GroundCoordinator } from "./ground-coordination.js";
27
- import { submitMutationOperation } from "./paged-mutation-submit.js";
28
28
  import { adoptSnapshotDeliveryBaseline } from "./snapshot-delivery-baseline.js";
29
29
  import { WireClient, WireRequestError } from "./wire-client.js";
30
30
  /** Cargo waves hold up to 16MiB of payload each; two in flight bounds the
@@ -40,6 +40,10 @@ const DIRECT_OBJECT_DOWNLOAD_CONCURRENCY = 128;
40
40
  // on the 1 GiB runtimes used for ordinary Files materialization. Small
41
41
  // objects still use the full request-concurrency window.
42
42
  const DIRECT_OBJECT_DOWNLOAD_MAX_IN_FLIGHT_BYTES = 2 * CHUNK_BYTES;
43
+ /** Graph pages are at most a few megabytes each and a whole account is a
44
+ * handful of them, so a narrow lane keeps the cold read fast without holding
45
+ * the whole graph twice while it assembles. */
46
+ const GRAPH_PAGE_READ_CONCURRENCY = 8;
43
47
  const DETECT_QUIET_MS = 12;
44
48
  const DETECT_MAX_DEFERRAL_MS = 75;
45
49
  const DETECT_RETRY_MS = 250;
@@ -55,13 +59,19 @@ const WALK_CAPTURE_CONCURRENCY = 8;
55
59
  const REGISTER_STALE_CAPTURE_ATTEMPTS = 3;
56
60
  const DOWNLOAD_CONTENT_RETRY_ATTEMPTS = 3;
57
61
  const APPLY_GROUND_WAIT_TIMEOUT_MS = 15_000;
62
+ /** Load a journaled publication for replay. The records whose cargo must
63
+ * re-upload are already inside the publication's own bytes — its pages, or,
64
+ * for a row converted from the pre-page journal, the replacement operation
65
+ * itself — so nothing is stored twice and nothing is re-read from ground. */
58
66
  function loadSnapshotPublication(entry) {
59
- const snapshotRecords = snapshotFromRecords((JSON.parse(entry.snapshotBlob.toString("utf8")).records ?? [])).records;
60
67
  const uploadIds = new Set(entry.uploadUuids);
68
+ const published = uploadIds.size === 0 ? [] : entry.pages.length > 0
69
+ ? entry.pages.flatMap((page) => JSON.parse(page.bytes.toString("utf8")))
70
+ : (JSON.parse(entry.operationBlob.toString("utf8")).records ?? []);
61
71
  return {
62
72
  ...entry,
63
- snapshotRecords,
64
- uploadRecords: snapshotRecords.filter((record) => uploadIds.has(record.uuid)),
73
+ uploadRecords: published.filter((record) => uploadIds.has(record.uuid)),
74
+ graph: null,
65
75
  };
66
76
  }
67
77
  const GROUND_ROW_COLUMNS = [
@@ -558,7 +568,7 @@ export class UserGroundHost {
558
568
  && record.status === "active"
559
569
  && record.payloadVersion === workspaceId);
560
570
  if (hasRegisteredBoundaryInDatabase(database, workspaceId)
561
- && hasRegisteredBoundary(this.cloudState.records)) {
571
+ && hasRegisteredBoundary(this.cloudState.graph.records())) {
562
572
  // Already declared — but Register may only return once cargo closure
563
573
  // holds under the current content contract. The graph head can be
564
574
  // unchanged while a contract cutover moved its cargo namespace, so
@@ -740,7 +750,7 @@ export class UserGroundHost {
740
750
  if (!hasRegisteredBoundaryInDatabase(database, workspaceId)) {
741
751
  throw stageError("entity registration", new Error(`workspace ${workspaceId} and its durable reference were not recorded`));
742
752
  }
743
- if (!hasRegisteredBoundary(this.cloudState.records)) {
753
+ if (!hasRegisteredBoundary(this.cloudState.graph.records())) {
744
754
  throw stageError("cloud graph commit", new Error(`workspace ${workspaceId} or its durable reference is absent from the committed head`));
745
755
  }
746
756
  return {
@@ -822,9 +832,10 @@ export class UserGroundHost {
822
832
  throw new Error("entity Record rail already started");
823
833
  await this.wire.open();
824
834
  // One floor, no per-feature gates: this shell and its gateway release
825
- // together, and the gateway ships first. 12 = bounded cold-Register cargo
826
- // waves; an older gateway cannot verify and seal that upload frame.
827
- if (this.wire.protocolVersion < 12) {
835
+ // together, and the gateway ships first. 13 = immutable registry graph
836
+ // pages plus one graph-head commit; an older gateway cannot publish or
837
+ // resolve that representation.
838
+ if (this.wire.protocolVersion < 13) {
828
839
  throw new Error("gateway speaks an older wire protocol than this shell");
829
840
  }
830
841
  this.adoptCloudDeliveryBaseline(identity, this.cloudState.deliveryWatermark);
@@ -1096,7 +1107,7 @@ export class UserGroundHost {
1096
1107
  }, ["private.resource.ready", "private.resource.missing"]);
1097
1108
  if (frame.type === "private.resource.missing")
1098
1109
  return null;
1099
- const ready = readyFromFrame(frame);
1110
+ const ready = readyFromFrame(frame, await this.materializeRegistry(identity, frame));
1100
1111
  this.cloudState = ready.state;
1101
1112
  return ready.cloud;
1102
1113
  },
@@ -1111,7 +1122,7 @@ export class UserGroundHost {
1111
1122
  snapshot_checksum: input.checksum,
1112
1123
  snapshot_bytes: bytes.length,
1113
1124
  }, ["private.resource.ready"]);
1114
- const ready = readyFromFrame(frame);
1125
+ const ready = readyFromFrame(frame, await this.materializeRegistry(identity, frame));
1115
1126
  const current = ready.cloud;
1116
1127
  this.cloudState = ready.state;
1117
1128
  const created = frame.duplicate === false;
@@ -1121,6 +1132,61 @@ export class UserGroundHost {
1121
1132
  },
1122
1133
  };
1123
1134
  }
1135
+ /** The authority's stored snapshot as this machine's graph. A head names
1136
+ * immutable pages, so every page this machine already cached is a local read
1137
+ * and an unchanged root never crosses the network twice. */
1138
+ async materializeRegistry(identity, frame) {
1139
+ const resourceId = String(frame.resource_id || "");
1140
+ return registryGraphFromSnapshot(Buffer.from(String(frame.snapshot_b64 || ""), "base64"), (refs) => this.readGraphPages(identity, resourceId, refs), sha256Hex);
1141
+ }
1142
+ /** Read graph pages by identity: cached pages come off local disk, the rest
1143
+ * are fetched straight from content storage and cached on the way in. Every
1144
+ * page is re-proven against its declared identity before it is kept. */
1145
+ async readGraphPages(identity, resourceId, refs) {
1146
+ const directory = graphPageCacheDir(this.cacheDir(identity));
1147
+ const pages = new Map();
1148
+ const missing = [];
1149
+ for (const ref of new Map(refs.map((ref) => [ref.sha256, ref])).values()) {
1150
+ let cached = null;
1151
+ try {
1152
+ cached = readFileSync(join(directory, `${ref.sha256}.bin`));
1153
+ }
1154
+ catch {
1155
+ cached = null;
1156
+ }
1157
+ // A cache copy of the wrong size is torn evidence, never a page: fetch
1158
+ // the immutable original instead of assembling from it.
1159
+ if (cached && cached.byteLength === ref.bytes)
1160
+ pages.set(ref.sha256, cached);
1161
+ else
1162
+ missing.push(ref);
1163
+ }
1164
+ if (missing.length === 0)
1165
+ return pages;
1166
+ const started = performance.now();
1167
+ let fetchedBytes = 0;
1168
+ await boundedForEach(missing, GRAPH_PAGE_READ_CONCURRENCY, async (ref) => {
1169
+ const bytes = await retryContent(() => this.direct.fetchObject(resourceId, { kind: "block", sha256: ref.sha256 }), true);
1170
+ if (bytes.byteLength !== ref.bytes || sha256Hex(bytes) !== ref.sha256) {
1171
+ throw new Error(`cloud graph page ${ref.sha256} does not match its bytes`);
1172
+ }
1173
+ atomicWrite(join(directory, `${ref.sha256}.bin`), bytes);
1174
+ fetchedBytes += bytes.byteLength;
1175
+ pages.set(ref.sha256, bytes);
1176
+ });
1177
+ this.stage({
1178
+ primitive: "download",
1179
+ stage: "graph-pages",
1180
+ status: "completed",
1181
+ durationMs: performance.now() - started,
1182
+ measurements: {
1183
+ pages: refs.length,
1184
+ fetchedPages: missing.length,
1185
+ fetchedBytes,
1186
+ },
1187
+ });
1188
+ return pages;
1189
+ }
1124
1190
  localPort(identity) {
1125
1191
  const userRoot = this.userRoot(identity);
1126
1192
  const database = this.databasePath(identity);
@@ -1962,14 +2028,6 @@ export class UserGroundHost {
1962
2028
  if (first.resourceId !== resourceId) {
1963
2029
  throw new Error("local snapshot publication belongs to a different user-ground resource");
1964
2030
  }
1965
- this.cloudState = {
1966
- resourceId: first.resourceId,
1967
- authorityEpoch: first.authorityEpoch,
1968
- headVersion: first.baseVersion,
1969
- checksum: "",
1970
- deliveryWatermark: first.deliveryWatermark,
1971
- records: loadSnapshotPublication(first).snapshotRecords,
1972
- };
1973
2031
  try {
1974
2032
  await this.publishPendingSnapshots(identity);
1975
2033
  }
@@ -2001,6 +2059,12 @@ export class UserGroundHost {
2001
2059
  throw error;
2002
2060
  }
2003
2061
  await this.recordRail?.flush();
2062
+ // A replayed publication leaves no graph behind — it only ever journaled
2063
+ // the roots it rebuilt — so the machine re-reads the authority's head
2064
+ // before deriving the next boundary on top of it.
2065
+ if (!this.cloudState) {
2066
+ await this.cloudPort(identity).lookup(privateEntityResourceId(identity.userId, sha256Hex));
2067
+ }
2004
2068
  const state = this.cloudState;
2005
2069
  if (!state)
2006
2070
  throw new Error("cloud state is unavailable for graph publication");
@@ -2038,24 +2102,24 @@ export class UserGroundHost {
2038
2102
  ...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
2039
2103
  });
2040
2104
  }
2041
- const officialRecords = timed("officialRows", () => recordsAtDeliveryPrefix(state.records, readAcceptedResults(this.databasePath(identity), state.resourceId, state.deliveryWatermark, deliveryWatermark)));
2042
2105
  const localRecords = timed("groundRows", () => recordsRetainingMissingEvidence(readGroundRowsForRoots(this.databasePath(identity), "entities", publicationRootIds)
2043
2106
  .map(portableRecord), readGroundRowsForRoots(this.databasePath(identity), "detection_notebook", publicationRootIds)
2044
2107
  .map(portableRecord)));
2045
- const merged = timed("merge", () => mergeMaterializedRoots(officialRecords, localRecords));
2046
- const traveling = timed("project", () => travelingRecords(merged, sha256Hex));
2108
+ const accepted = timed("officialRows", () => readAcceptedResults(this.databasePath(identity), state.resourceId, state.deliveryWatermark, deliveryWatermark));
2109
+ const rebuilt = timed("merge", () => publicationRoots(state.graph, localRecords, accepted));
2110
+ const projected = timed("project", () => new Map([...rebuilt].map(([rootId, records]) => [rootId, records.length === 0 ? records : travelingRecords(records, sha256Hex)])));
2111
+ const traveling = [...projected.values()].flat();
2047
2112
  const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
2048
2113
  && record.payloadVersion?.startsWith("git:"));
2049
2114
  if (impossibleGitLeaf) {
2050
- const parent = merged.find((record) => record.uuid === impossibleGitLeaf.parentUUID);
2115
+ const parent = traveling.find((record) => record.uuid === impossibleGitLeaf.parentUUID);
2051
2116
  throw new Error(`repository-owned leaf escaped its rail: ${impossibleGitLeaf.name} parent=${parent?.type ?? "missing"}`);
2052
2117
  }
2053
- const snapshot = timed("normalize", () => snapshotFromRecords(traveling));
2054
- // ONE canonical serialization: these exact bytes are the journal blob
2055
- // and the checksum's input; they are never re-derived or re-parsed on
2056
- // the in-process path.
2057
- const snapshotBlob = timed("serialize", () => Buffer.from(stableJson(snapshot), "utf8"));
2058
- const checksum = timed("checksum", () => sha256Hex(snapshotBlob));
2118
+ // ONE canonical serialization per rebuilt root: the page bytes are the
2119
+ // journal's payload, the upload's payload, and the head's identities.
2120
+ // Untouched roots are not serialized, hashed, or journaled at all.
2121
+ const publication = timed("pages", () => state.graph.rebuild(projected, sha256Hex));
2122
+ const checksum = publication.checksum;
2059
2123
  const localIds = new Set(localRecords.map((record) => record.uuid));
2060
2124
  // Graph membership is never proof of sealed cargo: a record in the cloud
2061
2125
  // graph may name content sealed only under a prior content contract,
@@ -2064,11 +2128,11 @@ export class UserGroundHost {
2064
2128
  // sealed-first upload asks that exact question itself and moves only
2065
2129
  // what is unsealed, so Register always re-establishes cargo closure
2066
2130
  // before returning, even when the graph itself is unchanged.
2067
- const cargoRecords = snapshot.records.filter((record) => localIds.has(record.uuid) && artifactForRecord(record) !== null);
2131
+ const cargoRecords = traveling.filter((record) => localIds.has(record.uuid) && artifactForRecord(record) !== null);
2068
2132
  if (checksum === state.checksum && deliveryWatermark === state.deliveryWatermark) {
2069
2133
  this.finishPreparation(trace, preparationStarted, leg, {
2070
- records: snapshot.records.length,
2071
- snapshotBytes: snapshotBlob.byteLength,
2134
+ rebuiltRoots: rebuilt.size,
2135
+ records: traveling.length,
2072
2136
  cargoRecords: cargoRecords.length,
2073
2137
  graphUnchanged: true,
2074
2138
  });
@@ -2085,19 +2149,12 @@ export class UserGroundHost {
2085
2149
  }
2086
2150
  return;
2087
2151
  }
2088
- const replacement = timed("replacement", () => {
2089
- const rootIds = localRecords
2090
- .filter((record) => record.parentUUID === null)
2091
- .map((record) => record.uuid);
2092
- const roots = new Set(rootIds);
2093
- const rootOf = recordRootResolver(snapshot.records);
2094
- return rootReplacementFromRecords(rootIds, snapshot.records.filter((record) => {
2095
- const root = rootOf(record);
2096
- return root !== null && roots.has(root);
2097
- }));
2098
- });
2099
- // The operation's canonical bytes: staged, hashed, and submitted as-is.
2100
- const operationBlob = timed("operation", () => Buffer.from(stableJson(replacement), "utf8"));
2152
+ // The operation's canonical bytes: journaled and submitted as-is.
2153
+ const operationBlob = timed("operation", () => Buffer.from(stableJson(publication.operation), "utf8"));
2154
+ // Only pages the committed head does not already name still have to be
2155
+ // sealed; a rebuilt root that came out byte-identical costs nothing.
2156
+ const committed = graphPageIdentities(state.graph.head);
2157
+ const fresh = publication.pages.filter((page) => !committed.has(page.ref.sha256));
2101
2158
  const entry = {
2102
2159
  mutationId: randomUUID(),
2103
2160
  resourceId: state.resourceId,
@@ -2105,30 +2162,35 @@ export class UserGroundHost {
2105
2162
  baseVersion: state.headVersion,
2106
2163
  snapshotChecksum: checksum,
2107
2164
  deliveryWatermark,
2108
- operationKind: replacement.kind,
2109
- replacementRootIds: replacement.rootIds,
2165
+ operationKind: publication.operation.kind,
2166
+ replacementRootIds: publication.operation.replacedRootIds,
2110
2167
  uploadUuids: cargoRecords.map((record) => record.uuid),
2111
- snapshotBlob,
2112
2168
  operationBlob,
2169
+ pages: fresh.map((page) => ({
2170
+ sha256: page.ref.sha256,
2171
+ bytes: Buffer.from(page.bytes.buffer, page.bytes.byteOffset, page.bytes.byteLength),
2172
+ })),
2113
2173
  status: "pending",
2114
2174
  submitted: false,
2115
2175
  };
2116
2176
  timed("journal", () => supersedeSnapshotJournal(this.databasePath(identity), entry));
2117
2177
  this.finishPreparation(trace, preparationStarted, leg, {
2118
- records: snapshot.records.length,
2119
- snapshotBytes: snapshotBlob.byteLength,
2178
+ rebuiltRoots: rebuilt.size,
2179
+ records: traveling.length,
2180
+ pages: publication.pages.length,
2181
+ freshPages: fresh.length,
2182
+ pageBytes: fresh.reduce((total, page) => total + page.ref.bytes, 0),
2120
2183
  operationBytes: operationBlob.byteLength,
2121
- operationRoots: replacement.rootIds.length,
2122
2184
  cargoRecords: cargoRecords.length,
2123
2185
  graphUnchanged: false,
2124
2186
  });
2125
- // Publish the freshly journaled intent directly: the records are already
2126
- // in memory and the operation bytes already exist — nothing is re-read,
2127
- // re-parsed, or re-serialized between prepare and the wire.
2187
+ // Publish the freshly journaled intent directly: the pages and the
2188
+ // operation bytes already exist — nothing is re-read, re-parsed, or
2189
+ // re-serialized between prepare and the wire.
2128
2190
  await this.publishLoadedSnapshot(identity, {
2129
2191
  ...entry,
2130
- snapshotRecords: snapshot.records,
2131
2192
  uploadRecords: cargoRecords,
2193
+ graph: publication.graph,
2132
2194
  }, trace, manifestHints);
2133
2195
  }
2134
2196
  /** Close the graph-preparation stage with every leg it spent, so a slow
@@ -2181,8 +2243,61 @@ export class UserGroundHost {
2181
2243
  await this.publishLoadedSnapshot(identity, loadSnapshotPublication(pending), trace, manifestHints);
2182
2244
  }
2183
2245
  }
2246
+ /** Seal this publication's graph pages. Pages are immutable, content
2247
+ * addressed, and already in memory, so the authority's seal inventory alone
2248
+ * decides what still has to move and whole pages ride cargo waves. The head
2249
+ * may not commit until every page it newly names is sealed. */
2250
+ async uploadGraphPages(resourceId, pages, trace) {
2251
+ if (pages.length === 0)
2252
+ return;
2253
+ const started = performance.now();
2254
+ const sealed = await this.sealedContentHashes(resourceId, pages.map((page) => page.sha256));
2255
+ const unsealed = pages
2256
+ .filter((page) => !sealed.has(page.sha256))
2257
+ .map((page) => ({ page, bytes: page.bytes.byteLength }));
2258
+ let uploadedBytes = 0;
2259
+ await boundedForEach([...planCargoWaves(unsealed)], CARGO_UPLOAD_CONCURRENCY, async (wave) => {
2260
+ const payload = Buffer.concat(wave.items.map((item) => item.page.bytes));
2261
+ const encoded = await encodeContentWireBytes(payload);
2262
+ const frame = await retryContent(() => this.wire.requestBinary({
2263
+ type: "private.entity-content.put-cargo",
2264
+ resource_id: resourceId,
2265
+ // A page is a single-chunk artifact: its identity, its only chunk,
2266
+ // and its bytes are one and the same sha.
2267
+ artifacts: wave.items.map((item) => ({
2268
+ content_hash: item.page.sha256,
2269
+ bytes: item.bytes,
2270
+ chunks: [{ sha256: item.page.sha256, bytes: item.bytes }],
2271
+ })),
2272
+ bytes: payload.byteLength,
2273
+ wire_bytes: encoded.bytes.byteLength,
2274
+ ...(encoded.encoding ? { content_encoding: encoded.encoding } : {}),
2275
+ }, encoded.bytes, ["private.entity-content.stored-cargo"]));
2276
+ const stored = new Set(Array.isArray(frame.sealed) ? frame.sealed.map(String) : []);
2277
+ for (const item of wave.items) {
2278
+ if (!stored.has(item.page.sha256)) {
2279
+ throw new Error(`content authority did not seal graph page ${item.page.sha256}`);
2280
+ }
2281
+ }
2282
+ uploadedBytes += encoded.bytes.byteLength;
2283
+ });
2284
+ this.stage({
2285
+ primitive: "upload",
2286
+ stage: "graph-pages",
2287
+ status: "completed",
2288
+ ...(trace?.workspaceId ? { workspaceId: trace.workspaceId } : {}),
2289
+ durationMs: performance.now() - started,
2290
+ measurements: {
2291
+ journey: trace?.journey ?? "bootstrap",
2292
+ pages: pages.length,
2293
+ uploadedPages: unsealed.length,
2294
+ uploadedBytes,
2295
+ },
2296
+ });
2297
+ }
2184
2298
  async publishLoadedSnapshot(identity, publication, trace, manifestHints) {
2185
2299
  try {
2300
+ await this.uploadGraphPages(publication.resourceId, publication.pages, trace);
2186
2301
  await this.uploadSnapshotContent(identity, publication.resourceId, publication.uploadRecords, trace, manifestHints);
2187
2302
  }
2188
2303
  catch (error) {
@@ -2218,17 +2333,25 @@ export class UserGroundHost {
2218
2333
  local("markSubmitted", () => markSnapshotJournalSubmitted(this.databasePath(identity), publication.mutationId));
2219
2334
  }
2220
2335
  try {
2221
- frame = await submitMutationOperation(this.wire, {
2222
- resourceId: publication.resourceId,
2223
- authorityEpoch: publication.authorityEpoch,
2224
- mutationId: publication.mutationId,
2225
- deviceId: identity.deviceId,
2226
- baseVersion: publication.baseVersion,
2227
- contract: ENTITY_CLOUD_CONTRACT,
2228
- schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
2229
- operationKind: publication.operationKind,
2230
- deliveryWatermark: publication.deliveryWatermark,
2231
- }, publication.operationBlob, (leg, durationMs) => { legMs[`${leg}Ms`] = durationMs; });
2336
+ // A head commit names pages instead of carrying records, so it is small
2337
+ // by construction and always travels inline in one frame.
2338
+ const submitStarted = performance.now();
2339
+ frame = await this.wire.request({
2340
+ type: "shared.mutation.submit",
2341
+ envelope: {
2342
+ resourceId: publication.resourceId,
2343
+ authorityEpoch: publication.authorityEpoch,
2344
+ mutationId: publication.mutationId,
2345
+ deviceId: identity.deviceId,
2346
+ baseVersion: publication.baseVersion,
2347
+ contract: ENTITY_CLOUD_CONTRACT,
2348
+ schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
2349
+ operationKind: publication.operationKind,
2350
+ deliveryWatermark: publication.deliveryWatermark,
2351
+ operation: JSON.parse(publication.operationBlob.toString("utf8")),
2352
+ },
2353
+ }, ["shared.mutation.ack"]);
2354
+ legMs.submitMs = performance.now() - submitStarted;
2232
2355
  }
2233
2356
  catch (error) {
2234
2357
  if (trace?.journey === "register") {
@@ -2249,13 +2372,16 @@ export class UserGroundHost {
2249
2372
  }
2250
2373
  throw pipelineStageError("cloud graph commit", error);
2251
2374
  }
2252
- this.cloudState = {
2375
+ // A replay carries no graph: clearing the state makes the next reader
2376
+ // fetch the authority's own head instead of a graph missing every root
2377
+ // this publication did not rebuild.
2378
+ this.cloudState = publication.graph === null ? null : {
2253
2379
  resourceId: publication.resourceId,
2254
2380
  authorityEpoch: Number(frame.authority_epoch),
2255
2381
  headVersion: Number(frame.version),
2256
2382
  checksum: publication.snapshotChecksum,
2257
2383
  deliveryWatermark: Number(frame.delivery_watermark),
2258
- records: publication.snapshotRecords,
2384
+ graph: publication.graph,
2259
2385
  };
2260
2386
  local("clearJournal", () => deleteJournalEntry(this.databasePath(identity), publication.mutationId));
2261
2387
  if (trace?.journey === "register") {
@@ -2263,7 +2389,7 @@ export class UserGroundHost {
2263
2389
  // its legs here gives one ledger line the whole story: local journal
2264
2390
  // marks, wire time, gateway assemble/validate, and the database RPC.
2265
2391
  for (const [wire, leg] of [
2266
- ["assemble_ms", "serverAssembleMs"],
2392
+ ["verify_ms", "serverVerifyMs"],
2267
2393
  ["validate_ms", "serverValidateMs"],
2268
2394
  ["database_ms", "serverDatabaseMs"],
2269
2395
  ]) {
@@ -2302,6 +2428,12 @@ function contentCacheFile(cacheDir, contentHash) {
2302
2428
  function manifestCacheFile(cacheDir, contentHash) {
2303
2429
  return join(cacheDir, "manifests", `${contentHash}.json`);
2304
2430
  }
2431
+ /** Graph pages are immutable and content-addressed, so they cache exactly like
2432
+ * artifacts do — in their own directory, because they are the registry's own
2433
+ * bytes rather than any entity's cargo. */
2434
+ function graphPageCacheDir(cacheDir) {
2435
+ return join(cacheDir, "graph-pages");
2436
+ }
2305
2437
  function readCachedManifest(cacheDir, contentHash) {
2306
2438
  try {
2307
2439
  const checked = checkContentManifest(JSON.parse(readFileSync(manifestCacheFile(cacheDir, contentHash), "utf8")), contentHash);
@@ -2375,118 +2507,110 @@ function assertHealthyWatch(evidence, rootId) {
2375
2507
  * file over a dozen times; the file itself is never deleted by the host, so
2376
2508
  * every later open needs only the connection and its pragmas. */
2377
2509
  const migratedDatabases = new Set();
2378
- /** The journal's mutable facts and its immutable payload live in separate
2379
- * tables on purpose: SQLite rewrites a row's entire record — overflow pages
2380
- * included — on any column update, so flipping submitted_at on a row that
2381
- * carried a register-scale snapshot cost a full multi-megabyte rewrite
2382
- * through the WAL at synchronous=FULL (measured ~7.5s of the production
2383
- * cloud-commit stage on the 1-GB machine). The blob row is written exactly
2384
- * once and read only on restart replay; every status transition touches only
2385
- * the small row. */
2510
+ /** A publication row holds only what the authority must be told: the small
2511
+ * meta facts and the head-commit operation naming this graph's pages. The
2512
+ * pages themselves are write-once rows in their own table, because a status
2513
+ * transition rewrites its whole row — overflow pages included — through the
2514
+ * WAL at synchronous=FULL, and page bytes must never ride that rewrite. */
2386
2515
  const SNAPSHOT_PUBLICATIONS_SCHEMA = `
2387
2516
  CREATE TABLE IF NOT EXISTS snapshot_publications (
2388
2517
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
2389
2518
  mutation_id TEXT NOT NULL UNIQUE,
2390
2519
  resource_id TEXT NOT NULL,
2391
2520
  meta_json TEXT NOT NULL,
2521
+ operation_blob BLOB NOT NULL,
2392
2522
  created_at INTEGER NOT NULL,
2393
2523
  status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected')),
2394
2524
  submitted_at INTEGER
2395
2525
  );
2396
- CREATE TABLE IF NOT EXISTS snapshot_publication_blobs (
2397
- mutation_id TEXT PRIMARY KEY,
2398
- snapshot_blob BLOB NOT NULL,
2399
- operation_blob BLOB NOT NULL
2526
+ CREATE TABLE IF NOT EXISTS snapshot_publication_pages (
2527
+ mutation_id TEXT NOT NULL,
2528
+ page_index INTEGER NOT NULL,
2529
+ sha256 TEXT NOT NULL,
2530
+ bytes BLOB NOT NULL,
2531
+ PRIMARY KEY (mutation_id, page_index)
2400
2532
  );`;
2401
- /** Converts an old-shape publication journal (one nested publication_json
2402
- * text per row) into the blob journal, atomically, preserving every row's
2403
- * meaning. A pending row may be a submitted mutation whose outcome exists
2404
- * only at the authority — dropping it would violate the retry law that
2405
- * recovers a lost acknowledgement — so conversion is structural: the nested
2406
- * snapshot text becomes the snapshot blob verbatim, the stored replacement
2407
- * becomes the operation blob, and semantic judgment stays where it always
2408
- * was, with the reader (an unlawful row still blocks registration rather
2409
- * than being silently replaced).
2533
+ function legacyJournalShape(columns) {
2534
+ if (columns.size === 0)
2535
+ return null;
2536
+ if (columns.has("publication_json"))
2537
+ return "nested-text";
2538
+ if (columns.has("snapshot_blob"))
2539
+ return "row-blobs";
2540
+ // The paged journal and the side-table one both keep only small columns
2541
+ // here; only the paged one names the operation on the row itself.
2542
+ return columns.has("operation_blob") ? null : "split-blobs";
2543
+ }
2544
+ /** Converts a pre-paged publication journal into the paged one, atomically,
2545
+ * preserving every row's meaning. A pending row may be a submitted mutation
2546
+ * whose outcome exists only at the authority — dropping it would violate the
2547
+ * retry law that recovers a lost acknowledgement — so conversion is
2548
+ * structural: each row keeps its identity, its meta facts, and the exact
2549
+ * operation bytes it would submit, and semantic judgment stays where it
2550
+ * always was, with the reader (an unlawful row still blocks registration
2551
+ * rather than being silently replaced). A converted row rebuilt whole roots
2552
+ * rather than pages, so it carries no page rows; its replayed upload comes
2553
+ * from the records inside its own operation.
2410
2554
  *
2411
2555
  * The transform runs entirely inside SQLite. A production journal row is a
2412
2556
  * register-scale graph serialized three times over — the exact text whose
2413
2557
  * repeated JavaScript materialization blew the 512 MB heap — so no part of
2414
- * it may ever become a JavaScript value here: json_extract lifts the nested
2415
- * snapshot text straight into its blob, and the meta and operation rows are
2416
- * json_object over subtrees, all in engine memory outside the V8 heap.
2558
+ * it may ever become a JavaScript value here: json_extract lifts nested text
2559
+ * straight into its blob and meta rows are json_object over subtrees, all in
2560
+ * engine memory outside the V8 heap. The snapshot blob is simply not carried:
2561
+ * the authority stores the head now, and a replay re-reads it.
2417
2562
  *
2418
2563
  * Tables older than the three-state journal carry no status or submitted_at;
2419
2564
  * their rows were written before the pre-submit durability mark existed, so
2420
2565
  * the only safe reading is "possibly submitted": keep them pending and
2421
2566
  * marked submitted, which replays the same mutation id — always idempotent —
2422
- * and never supersedes. A row whose publication_json does not parse fails
2423
- * the open loudly with the journal intact; nothing is ever deleted on a
2424
- * conversion failure. */
2425
- function convertLegacySnapshotJournal(database, columns) {
2567
+ * and never supersedes. A row whose stored operation does not resolve fails
2568
+ * the open loudly against the NOT NULL law with the journal intact; nothing
2569
+ * is ever deleted on a conversion failure. */
2570
+ function convertLegacySnapshotJournal(database, shape, columns) {
2571
+ const metaJson = shape === "nested-text"
2572
+ ? `json_object(
2573
+ 'authorityEpoch', json_extract(publication_json, '$.authorityEpoch'),
2574
+ 'baseVersion', json_extract(publication_json, '$.baseVersion'),
2575
+ 'deliveryWatermark', json_extract(publication_json, '$.deliveryWatermark'),
2576
+ 'snapshotChecksum', json_extract(publication_json, '$.snapshotChecksum'),
2577
+ 'operationKind', 'entity.roots.replace',
2578
+ 'replacementRootIds', json(json_extract(publication_json, '$.replacementRootIds')),
2579
+ 'uploadUuids', coalesce((
2580
+ SELECT json_group_array(json_extract(uploaded.value, '$.uuid'))
2581
+ FROM json_each(publication_json, '$.uploadRecords') AS uploaded
2582
+ ), json_array())
2583
+ )`
2584
+ : "meta_json";
2585
+ const operationBlob = {
2586
+ "nested-text": `CAST(json_object(
2587
+ 'kind', 'entity.roots.replace',
2588
+ 'rootIds', json(json_extract(publication_json, '$.replacementRootIds')),
2589
+ 'records', json(json_extract(publication_json, '$.replacementRecords'))
2590
+ ) AS BLOB)`,
2591
+ "row-blobs": "operation_blob",
2592
+ "split-blobs": `(SELECT blobs.operation_blob FROM snapshot_publication_blobs AS blobs
2593
+ WHERE blobs.mutation_id = legacy.mutation_id)`,
2594
+ }[shape];
2426
2595
  database.transaction(() => {
2427
2596
  database.exec("ALTER TABLE snapshot_publications RENAME TO snapshot_publications_legacy");
2428
2597
  database.exec(SNAPSHOT_PUBLICATIONS_SCHEMA);
2429
2598
  database.exec(`
2430
2599
  INSERT INTO snapshot_publications(
2431
- mutation_id, resource_id, meta_json, created_at, status, submitted_at
2600
+ mutation_id, resource_id, meta_json, operation_blob, created_at, status, submitted_at
2432
2601
  )
2433
2602
  SELECT
2434
2603
  mutation_id,
2435
2604
  resource_id,
2436
- json_object(
2437
- 'authorityEpoch', json_extract(publication_json, '$.authorityEpoch'),
2438
- 'baseVersion', json_extract(publication_json, '$.baseVersion'),
2439
- 'deliveryWatermark', json_extract(publication_json, '$.deliveryWatermark'),
2440
- 'snapshotChecksum', json_extract(publication_json, '$.snapshotChecksum'),
2441
- 'operationKind', 'entity.roots.replace',
2442
- 'replacementRootIds', json(json_extract(publication_json, '$.replacementRootIds')),
2443
- 'uploadUuids', coalesce((
2444
- SELECT json_group_array(json_extract(uploaded.value, '$.uuid'))
2445
- FROM json_each(publication_json, '$.uploadRecords') AS uploaded
2446
- ), json_array())
2447
- ),
2605
+ ${metaJson},
2606
+ ${operationBlob},
2448
2607
  created_at,
2449
2608
  ${columns.has("status") ? "CASE status WHEN 'rejected' THEN 'rejected' ELSE 'pending' END" : "'pending'"},
2450
2609
  ${columns.has("submitted_at") ? "submitted_at" : "created_at"}
2451
- FROM snapshot_publications_legacy ORDER BY sequence
2452
- `);
2453
- database.exec(`
2454
- INSERT INTO snapshot_publication_blobs(mutation_id, snapshot_blob, operation_blob)
2455
- SELECT
2456
- mutation_id,
2457
- CAST(json_extract(publication_json, '$.snapshotJson') AS BLOB),
2458
- CAST(json_object(
2459
- 'kind', 'entity.roots.replace',
2460
- 'rootIds', json(json_extract(publication_json, '$.replacementRootIds')),
2461
- 'records', json(json_extract(publication_json, '$.replacementRecords'))
2462
- ) AS BLOB)
2463
- FROM snapshot_publications_legacy ORDER BY sequence
2464
- `);
2465
- database.exec("DROP TABLE snapshot_publications_legacy");
2466
- })();
2467
- }
2468
- /** Converts the single-table blob journal (blobs stored on the publication
2469
- * row itself) into the split journal, atomically and entirely inside SQLite —
2470
- * the same engine-memory law as the nested-text conversion above. Every
2471
- * field already has its final meaning; the blobs simply move to their
2472
- * write-once table so status transitions stop rewriting them. */
2473
- function splitSnapshotJournalBlobs(database) {
2474
- database.transaction(() => {
2475
- database.exec("ALTER TABLE snapshot_publications RENAME TO snapshot_publications_legacy");
2476
- database.exec(SNAPSHOT_PUBLICATIONS_SCHEMA);
2477
- database.exec(`
2478
- INSERT INTO snapshot_publications(
2479
- mutation_id, resource_id, meta_json, created_at, status, submitted_at
2480
- )
2481
- SELECT mutation_id, resource_id, meta_json, created_at, status, submitted_at
2482
- FROM snapshot_publications_legacy ORDER BY sequence
2483
- `);
2484
- database.exec(`
2485
- INSERT INTO snapshot_publication_blobs(mutation_id, snapshot_blob, operation_blob)
2486
- SELECT mutation_id, snapshot_blob, operation_blob
2487
- FROM snapshot_publications_legacy ORDER BY sequence
2610
+ FROM snapshot_publications_legacy AS legacy ORDER BY sequence
2488
2611
  `);
2489
2612
  database.exec("DROP TABLE snapshot_publications_legacy");
2613
+ database.exec("DROP TABLE IF EXISTS snapshot_publication_blobs");
2490
2614
  })();
2491
2615
  }
2492
2616
  /** The single opener and schema-migration entry for a user ground database.
@@ -2505,12 +2629,9 @@ export function initializeDatabase(file) {
2505
2629
  // how this file arrived at this process. One PRAGMA against the in-memory
2506
2630
  // catalog costs nothing next to the connection open itself.
2507
2631
  const publicationColumns = new Set(database.prepare("PRAGMA table_info(snapshot_publications)").all().map(({ name }) => name));
2508
- if (publicationColumns.has("publication_json")) {
2509
- convertLegacySnapshotJournal(database, publicationColumns);
2510
- }
2511
- else if (publicationColumns.has("snapshot_blob")) {
2512
- splitSnapshotJournalBlobs(database);
2513
- }
2632
+ const legacyShape = legacyJournalShape(publicationColumns);
2633
+ if (legacyShape)
2634
+ convertLegacySnapshotJournal(database, legacyShape, publicationColumns);
2514
2635
  if (migratedDatabases.has(file))
2515
2636
  return database;
2516
2637
  database.exec(`
@@ -3086,29 +3207,36 @@ function readSnapshotPublications(file) {
3086
3207
  return [];
3087
3208
  const database = initializeDatabase(file);
3088
3209
  try {
3210
+ const pages = database.prepare(`
3211
+ SELECT mutation_id AS mutationId, sha256, bytes
3212
+ FROM snapshot_publication_pages ORDER BY mutation_id, page_index
3213
+ `).all();
3214
+ const pagesByMutation = new Map();
3215
+ for (const page of pages) {
3216
+ const carried = pagesByMutation.get(page.mutationId) ?? [];
3217
+ carried.push({ sha256: page.sha256, bytes: page.bytes });
3218
+ pagesByMutation.set(page.mutationId, carried);
3219
+ }
3089
3220
  return database.prepare(`
3090
- SELECT publications.mutation_id AS mutationId,
3091
- publications.resource_id AS resourceId,
3092
- publications.meta_json AS metaJson,
3093
- blobs.snapshot_blob AS snapshotBlob,
3094
- blobs.operation_blob AS operationBlob,
3095
- publications.status,
3096
- publications.submitted_at AS submittedAt
3097
- FROM snapshot_publications AS publications
3098
- LEFT JOIN snapshot_publication_blobs AS blobs USING (mutation_id)
3099
- ORDER BY publications.sequence
3221
+ SELECT mutation_id AS mutationId,
3222
+ resource_id AS resourceId,
3223
+ meta_json AS metaJson,
3224
+ operation_blob AS operationBlob,
3225
+ status,
3226
+ submitted_at AS submittedAt
3227
+ FROM snapshot_publications ORDER BY sequence
3100
3228
  `).all().map((row) => {
3101
- // A publication without its payload is damage, never a skippable row —
3102
- // silently dropping it would let a new publication supersede a
3103
- // submitted mutation whose outcome exists only at the authority.
3104
- if (!row.snapshotBlob || !row.operationBlob) {
3105
- throw new Error(`snapshot publication ${row.mutationId} has no journaled payload`);
3106
- }
3107
3229
  const meta = JSON.parse(row.metaJson);
3108
- // No lawful publication carries zero roots — rootReplacementFromRecords
3109
- // refuses them at build time, so a rootless row here is damage.
3230
+ // No lawful publication carries zero roots — a head commit that replaces
3231
+ // nothing would prove nothing — so a rootless row here is damage, never
3232
+ // a skippable row: silently dropping it would let a new publication
3233
+ // supersede a submitted mutation whose outcome exists only at the
3234
+ // authority. `entity.roots.replace` appears only on rows converted from
3235
+ // a pre-paged journal, which no longer produce but must still replay.
3110
3236
  if (!Array.isArray(meta.replacementRootIds) || meta.replacementRootIds.length === 0
3111
- || !Array.isArray(meta.uploadUuids) || meta.operationKind !== "entity.roots.replace") {
3237
+ || !Array.isArray(meta.uploadUuids)
3238
+ || (meta.operationKind !== GRAPH_HEAD_OPERATION_KIND
3239
+ && meta.operationKind !== "entity.roots.replace")) {
3112
3240
  throw new Error(`snapshot publication ${row.mutationId} has no exact root replacement`);
3113
3241
  }
3114
3242
  const deliveryWatermark = Number(meta.deliveryWatermark);
@@ -3122,11 +3250,11 @@ function readSnapshotPublications(file) {
3122
3250
  baseVersion: Number(meta.baseVersion),
3123
3251
  deliveryWatermark,
3124
3252
  snapshotChecksum: String(meta.snapshotChecksum),
3125
- operationKind: meta.operationKind,
3253
+ operationKind: String(meta.operationKind),
3126
3254
  replacementRootIds: meta.replacementRootIds.map(String),
3127
3255
  uploadUuids: meta.uploadUuids.map(String),
3128
- snapshotBlob: row.snapshotBlob,
3129
3256
  operationBlob: row.operationBlob,
3257
+ pages: pagesByMutation.get(row.mutationId) ?? [],
3130
3258
  status: row.status === "rejected" ? "rejected" : "pending",
3131
3259
  submitted: row.submittedAt !== null,
3132
3260
  };
@@ -3294,7 +3422,7 @@ function supersedeSnapshotJournal(file, entry) {
3294
3422
  throw new Error("a submitted publication with an unknown outcome must resolve before supersession");
3295
3423
  }
3296
3424
  database.prepare(`
3297
- DELETE FROM snapshot_publication_blobs WHERE mutation_id IN (
3425
+ DELETE FROM snapshot_publication_pages WHERE mutation_id IN (
3298
3426
  SELECT mutation_id FROM snapshot_publications WHERE resource_id = ?
3299
3427
  )
3300
3428
  `).run(entry.resourceId);
@@ -3302,8 +3430,8 @@ function supersedeSnapshotJournal(file, entry) {
3302
3430
  .run(entry.resourceId);
3303
3431
  database.prepare(`
3304
3432
  INSERT INTO snapshot_publications(
3305
- mutation_id, resource_id, meta_json, created_at
3306
- ) VALUES (?, ?, ?, ?)
3433
+ mutation_id, resource_id, meta_json, operation_blob, created_at
3434
+ ) VALUES (?, ?, ?, ?, ?)
3307
3435
  `).run(entry.mutationId, entry.resourceId, stableJson({
3308
3436
  authorityEpoch: entry.authorityEpoch,
3309
3437
  baseVersion: entry.baseVersion,
@@ -3312,11 +3440,12 @@ function supersedeSnapshotJournal(file, entry) {
3312
3440
  operationKind: entry.operationKind,
3313
3441
  replacementRootIds: entry.replacementRootIds,
3314
3442
  uploadUuids: entry.uploadUuids,
3315
- }), Date.now());
3316
- database.prepare(`
3317
- INSERT INTO snapshot_publication_blobs(mutation_id, snapshot_blob, operation_blob)
3318
- VALUES (?, ?, ?)
3319
- `).run(entry.mutationId, entry.snapshotBlob, entry.operationBlob);
3443
+ }), entry.operationBlob, Date.now());
3444
+ const insertPage = database.prepare(`
3445
+ INSERT INTO snapshot_publication_pages(mutation_id, page_index, sha256, bytes)
3446
+ VALUES (?, ?, ?, ?)
3447
+ `);
3448
+ entry.pages.forEach((page, index) => insertPage.run(entry.mutationId, index, page.sha256, page.bytes));
3320
3449
  })();
3321
3450
  }
3322
3451
  finally {
@@ -3387,7 +3516,7 @@ function deleteJournalEntry(file, mutationId) {
3387
3516
  const database = initializeDatabase(file);
3388
3517
  try {
3389
3518
  database.transaction(() => {
3390
- database.prepare("DELETE FROM snapshot_publication_blobs WHERE mutation_id = ?").run(mutationId);
3519
+ database.prepare("DELETE FROM snapshot_publication_pages WHERE mutation_id = ?").run(mutationId);
3391
3520
  database.prepare("DELETE FROM snapshot_publications WHERE mutation_id = ?").run(mutationId);
3392
3521
  })();
3393
3522
  }
@@ -4981,19 +5110,79 @@ function recordRootResolver(records) {
4981
5110
  };
4982
5111
  return rootOf;
4983
5112
  }
4984
- function mergeMaterializedRoots(cloudRecords, localRecords) {
4985
- const localRoots = new Set(localRecords
5113
+ /** The roots one publication rebuilds, each with its complete new records.
5114
+ *
5115
+ * A walked root is rebuilt from its ground alone: materialized ground is the
5116
+ * whole truth for the roots it covers, so nothing of the committed graph
5117
+ * survives underneath one. Any other root the newly delivered authority prefix
5118
+ * touched is rebuilt from the committed graph with those results folded in, so
5119
+ * the published graph never declares a watermark it has not actually applied.
5120
+ * Every root outside this map keeps its committed pages untouched. */
5121
+ function publicationRoots(graph, localRecords, accepted) {
5122
+ const rebuilt = new Map();
5123
+ const walked = new Map(localRecords
4986
5124
  .filter((record) => record.parentUUID === null)
4987
- .map((record) => record.uuid));
4988
- const localIds = new Set(localRecords.map((record) => record.uuid));
4989
- const rootOf = recordRootResolver(cloudRecords);
4990
- const preserved = cloudRecords.filter((record) => {
4991
- if (localIds.has(record.uuid))
4992
- return false;
4993
- const root = rootOf(record);
4994
- return root === null || !localRoots.has(root);
4995
- });
4996
- return [...preserved, ...localRecords];
5125
+ .map((record) => [record.uuid, []]));
5126
+ if (walked.size > 0) {
5127
+ const rootOf = recordRootResolver(localRecords);
5128
+ for (const record of localRecords) {
5129
+ const rootId = rootOf(record);
5130
+ const group = rootId === null ? undefined : walked.get(rootId);
5131
+ if (!group)
5132
+ throw new Error(`walked record ${record.uuid} descends from no walked root`);
5133
+ group.push(record);
5134
+ }
5135
+ for (const [rootId, records] of walked)
5136
+ rebuilt.set(rootId, records);
5137
+ }
5138
+ const overlays = new Map();
5139
+ for (const result of accepted) {
5140
+ const rootId = graph.rootOf(result.uuid);
5141
+ if (rootId === null || walked.has(rootId))
5142
+ continue;
5143
+ const overlay = overlays.get(rootId);
5144
+ if (overlay)
5145
+ overlay.push(result);
5146
+ else
5147
+ overlays.set(rootId, [result]);
5148
+ }
5149
+ for (const [rootId, results] of overlays) {
5150
+ const byUuid = new Map(graph.recordsFor(rootId).map((record) => [record.uuid, record]));
5151
+ for (const result of results)
5152
+ byUuid.set(result.uuid, result);
5153
+ // Entity channels are independently ordered, so container membership is
5154
+ // derived once from the final prefix rather than by arrival order.
5155
+ rebuilt.set(rootId, recordsRetainingMissingEvidence([...byUuid.values()], []));
5156
+ }
5157
+ // A records-shaped authority base carries no page identities, so it can
5158
+ // prove nothing about a root the commit leaves alone: the first head pages
5159
+ // the whole account at once, and never again.
5160
+ if (graph.head === null) {
5161
+ for (const rootId of graph.rootIds()) {
5162
+ if (!rebuilt.has(rootId))
5163
+ rebuilt.set(rootId, graph.recordsFor(rootId));
5164
+ }
5165
+ }
5166
+ // Re-rooting is a move, not a copy: an enclosing Register walks records the
5167
+ // committed graph still files under the root that used to hold them. Every
5168
+ // such root is rebuilt without them, and a root that keeps nothing is left
5169
+ // with no records at all — the rebuild then drops it from the graph.
5170
+ const vacated = new Map();
5171
+ for (const record of localRecords) {
5172
+ const rootId = graph.rootOf(record.uuid);
5173
+ if (rootId === null || walked.has(rootId))
5174
+ continue;
5175
+ const gone = vacated.get(rootId);
5176
+ if (gone)
5177
+ gone.add(record.uuid);
5178
+ else
5179
+ vacated.set(rootId, new Set([record.uuid]));
5180
+ }
5181
+ for (const [rootId, gone] of vacated) {
5182
+ const held = rebuilt.get(rootId) ?? graph.recordsFor(rootId);
5183
+ rebuilt.set(rootId, held.filter((record) => !gone.has(record.uuid)));
5184
+ }
5185
+ return rebuilt;
4997
5186
  }
4998
5187
  /** Filesystem disappearance is evidence, not cloud lifecycle authority. Keep
4999
5188
  * the last logical records in the outgoing graph while their materialized
@@ -5566,14 +5755,14 @@ async function installCloudWorkspace(input) {
5566
5755
  alreadyMaterialized: false,
5567
5756
  };
5568
5757
  }
5569
- function readyFromFrame(frame) {
5570
- const bytes = Buffer.from(String(frame.snapshot_b64 || ""), "base64");
5758
+ function readyFromFrame(frame, graph) {
5571
5759
  const cloud = {
5572
5760
  resourceId: String(frame.resource_id || ""),
5573
5761
  version: Number(frame.head_version),
5574
5762
  deliveryWatermark: Number(frame.delivery_watermark),
5575
5763
  checksum: String(frame.snapshot_checksum || ""),
5576
- snapshot: parseSnapshot(bytes),
5764
+ snapshot: graph.snapshot(),
5765
+ ...(graph.head ? { head: graph.head } : {}),
5577
5766
  };
5578
5767
  return {
5579
5768
  cloud,
@@ -5583,7 +5772,7 @@ function readyFromFrame(frame) {
5583
5772
  headVersion: cloud.version,
5584
5773
  checksum: cloud.checksum,
5585
5774
  deliveryWatermark: cloud.deliveryWatermark,
5586
- records: cloud.snapshot.records,
5775
+ graph,
5587
5776
  },
5588
5777
  };
5589
5778
  }