@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.
- package/PURPOSE.md +8 -0
- package/dist/registry-graph.d.ts +61 -0
- package/dist/registry-graph.js +156 -0
- package/dist/registry-graph.js.map +1 -0
- package/dist/user-ground-host.d.ts +13 -0
- package/dist/user-ground-host.js +389 -200
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-client.js +1 -1
- package/package.json +2 -2
- package/dist/paged-mutation-submit.d.ts +0 -28
- package/dist/paged-mutation-submit.js +0 -61
- package/dist/paged-mutation-submit.js.map +0 -1
package/dist/user-ground-host.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
64
|
-
|
|
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.
|
|
826
|
-
//
|
|
827
|
-
|
|
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
|
|
2046
|
-
const
|
|
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 =
|
|
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
|
-
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
|
|
2057
|
-
const
|
|
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 =
|
|
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
|
-
|
|
2071
|
-
|
|
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
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
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:
|
|
2109
|
-
replacementRootIds:
|
|
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
|
-
|
|
2119
|
-
|
|
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
|
|
2126
|
-
//
|
|
2127
|
-
// re-
|
|
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
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
["
|
|
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
|
-
/**
|
|
2379
|
-
*
|
|
2380
|
-
*
|
|
2381
|
-
*
|
|
2382
|
-
*
|
|
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
|
|
2397
|
-
mutation_id TEXT
|
|
2398
|
-
|
|
2399
|
-
|
|
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
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
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
|
|
2415
|
-
*
|
|
2416
|
-
*
|
|
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
|
|
2423
|
-
* the open loudly with the journal intact; nothing
|
|
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
|
-
|
|
2437
|
-
|
|
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
|
-
|
|
2509
|
-
|
|
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
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
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 —
|
|
3109
|
-
//
|
|
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)
|
|
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
|
|
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
|
|
3318
|
-
VALUES (?, ?, ?)
|
|
3319
|
-
`)
|
|
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
|
|
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
|
-
|
|
4985
|
-
|
|
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
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
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:
|
|
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
|
-
|
|
5775
|
+
graph,
|
|
5587
5776
|
},
|
|
5588
5777
|
};
|
|
5589
5778
|
}
|