@amalgm/shell 0.1.85 → 0.1.87
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 +9 -0
- package/dist/entity-apply-host.js +6 -6
- package/dist/entity-apply-host.js.map +1 -1
- package/dist/filesystem.d.ts +4 -0
- package/dist/filesystem.js +19 -3
- package/dist/filesystem.js.map +1 -1
- package/dist/user-ground-host.d.ts +3 -3
- package/dist/user-ground-host.js +73 -61
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-client.js +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
|
|
|
5
5
|
import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
|
|
6
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, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
|
-
import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
|
|
8
|
+
import { atomicCopy, atomicWrite, ensurePrivateDir, ensureUserDir } from "./filesystem.js";
|
|
9
9
|
import { emitFilesStage, } from "./files-observability.js";
|
|
10
10
|
import { EntityApplyHost, } from "./entity-apply-host.js";
|
|
11
11
|
import { EntityRecordSqliteStore } from "./entity-record-store.js";
|
|
@@ -647,8 +647,11 @@ export class UserGroundHost {
|
|
|
647
647
|
if (this.recordRail || this.recordStore)
|
|
648
648
|
throw new Error("entity Record rail already started");
|
|
649
649
|
await this.wire.open();
|
|
650
|
-
|
|
651
|
-
|
|
650
|
+
// One floor, no per-feature gates: this shell and its gateway release
|
|
651
|
+
// together, and the gateway ships first. 7 = one content contract and
|
|
652
|
+
// deterministic manifest keys.
|
|
653
|
+
if (this.wire.protocolVersion < 7) {
|
|
654
|
+
throw new Error("gateway speaks an older wire protocol than this shell");
|
|
652
655
|
}
|
|
653
656
|
const store = new EntityRecordSqliteStore(initializeDatabase(this.databasePath(identity)));
|
|
654
657
|
const wireAuthority = createEntityRecordAuthorityPort({
|
|
@@ -970,16 +973,12 @@ export class UserGroundHost {
|
|
|
970
973
|
}
|
|
971
974
|
/** Send's small-cargo fast path: the record's sealed artifact rides the
|
|
972
975
|
* submit frame itself, so one round trip carries record, manifest, and
|
|
973
|
-
* bytes. Only when the
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
+
* bytes. Only when the local cache still proves the whole artifact within
|
|
977
|
+
* the inline bound; anything else returns null and the block upload path
|
|
978
|
+
* runs unchanged. */
|
|
976
979
|
readInlineCargo(identity, record) {
|
|
977
|
-
// Structure reuses the already-sealed artifact. This is a transfer law,
|
|
978
|
-
// independent of which wire version the authority speaks.
|
|
979
980
|
if (record.change.cargo.length === 0)
|
|
980
981
|
return [];
|
|
981
|
-
if (this.wire.protocolVersion < 5)
|
|
982
|
-
return null;
|
|
983
982
|
const cache = this.cacheDir(identity);
|
|
984
983
|
const inline = [];
|
|
985
984
|
for (const artifact of record.change.cargo) {
|
|
@@ -1064,7 +1063,6 @@ export class UserGroundHost {
|
|
|
1064
1063
|
type: "private.entity-content.put",
|
|
1065
1064
|
resource_id: resourceId,
|
|
1066
1065
|
content_hash: artifact.contentHash,
|
|
1067
|
-
content_contract: manifest.contract,
|
|
1068
1066
|
kind: "chunk",
|
|
1069
1067
|
part_index: index,
|
|
1070
1068
|
part_count: manifest.chunks.length,
|
|
@@ -1094,7 +1092,6 @@ export class UserGroundHost {
|
|
|
1094
1092
|
type: "private.entity-content.inventory",
|
|
1095
1093
|
resource_id: resourceId,
|
|
1096
1094
|
content_hash: artifact.contentHash,
|
|
1097
|
-
content_contract: manifest.contract,
|
|
1098
1095
|
chunks: manifest.chunks,
|
|
1099
1096
|
}, ["private.entity-content.inventory-result"]));
|
|
1100
1097
|
manifestPresent = frame.complete === true;
|
|
@@ -1105,23 +1102,11 @@ export class UserGroundHost {
|
|
|
1105
1102
|
},
|
|
1106
1103
|
putChunk: async (_candidate, manifest, index, bytes) => putChunk(manifest, index, bytes),
|
|
1107
1104
|
putChunkBatch: async (_candidate, manifest, indexes, bytes) => {
|
|
1108
|
-
if (this.wire.protocolVersion < 3) {
|
|
1109
|
-
let offset = 0;
|
|
1110
|
-
for (const index of indexes) {
|
|
1111
|
-
const chunk = manifest.chunks[index];
|
|
1112
|
-
if (!chunk)
|
|
1113
|
-
throw new Error(`content manifest has no chunk ${index}`);
|
|
1114
|
-
await putChunk(manifest, index, bytes.subarray(offset, offset + chunk.bytes));
|
|
1115
|
-
offset += chunk.bytes;
|
|
1116
|
-
}
|
|
1117
|
-
return;
|
|
1118
|
-
}
|
|
1119
1105
|
const encoded = await encodeContentWireBytes(bytes);
|
|
1120
1106
|
await retryContent(() => this.wire.requestBinary({
|
|
1121
1107
|
type: "private.entity-content.put-batch",
|
|
1122
1108
|
resource_id: resourceId,
|
|
1123
1109
|
content_hash: artifact.contentHash,
|
|
1124
|
-
content_contract: manifest.contract,
|
|
1125
1110
|
part_count: manifest.chunks.length,
|
|
1126
1111
|
chunks: indexes.map((index) => ({
|
|
1127
1112
|
part_index: index,
|
|
@@ -1142,7 +1127,6 @@ export class UserGroundHost {
|
|
|
1142
1127
|
type: "private.entity-content.put",
|
|
1143
1128
|
resource_id: resourceId,
|
|
1144
1129
|
content_hash: artifact.contentHash,
|
|
1145
|
-
content_contract: manifest.contract,
|
|
1146
1130
|
kind: "manifest",
|
|
1147
1131
|
part_index: manifest.chunks.length,
|
|
1148
1132
|
part_count: manifest.chunks.length,
|
|
@@ -1213,7 +1197,29 @@ export class UserGroundHost {
|
|
|
1213
1197
|
measurements: { journey: trace?.journey ?? "bootstrap", kind: artifact.kind },
|
|
1214
1198
|
});
|
|
1215
1199
|
const cache = { target: null };
|
|
1200
|
+
// A cold artifact — no local manifest — is fetched whole in one round
|
|
1201
|
+
// trip: get-artifact answers with the manifest and, for artifacts within
|
|
1202
|
+
// the batch caps, every block packed in part order. Chunk reads then
|
|
1203
|
+
// slice this buffer instead of paying a second round trip.
|
|
1204
|
+
const prefetched = { bytes: null };
|
|
1205
|
+
const sliceChunks = (packed, manifest, indexes) => {
|
|
1206
|
+
const offsets = [];
|
|
1207
|
+
let offset = 0;
|
|
1208
|
+
for (const chunk of manifest.chunks) {
|
|
1209
|
+
offsets.push(offset);
|
|
1210
|
+
offset += chunk.bytes;
|
|
1211
|
+
}
|
|
1212
|
+
return Buffer.concat(indexes.map((index) => {
|
|
1213
|
+
const chunk = manifest.chunks[index];
|
|
1214
|
+
const start = offsets[index];
|
|
1215
|
+
if (!chunk || start === undefined)
|
|
1216
|
+
throw new Error(`content manifest has no chunk ${index}`);
|
|
1217
|
+
return packed.subarray(start, start + chunk.bytes);
|
|
1218
|
+
}));
|
|
1219
|
+
};
|
|
1216
1220
|
const getChunk = async (manifest, index) => {
|
|
1221
|
+
if (prefetched.bytes)
|
|
1222
|
+
return sliceChunks(prefetched.bytes, manifest, [index]);
|
|
1217
1223
|
const chunk = manifest.chunks[index];
|
|
1218
1224
|
if (!chunk)
|
|
1219
1225
|
throw new Error(`content manifest has no chunk ${index}`);
|
|
@@ -1222,7 +1228,6 @@ export class UserGroundHost {
|
|
|
1222
1228
|
type: "private.entity-content.get",
|
|
1223
1229
|
resource_id: resourceId,
|
|
1224
1230
|
content_hash: artifact.contentHash,
|
|
1225
|
-
content_contract: manifest.contract,
|
|
1226
1231
|
kind: "chunk",
|
|
1227
1232
|
part_index: index,
|
|
1228
1233
|
sha256: chunk.sha256,
|
|
@@ -1245,18 +1250,23 @@ export class UserGroundHost {
|
|
|
1245
1250
|
}
|
|
1246
1251
|
requests += 1;
|
|
1247
1252
|
const frame = await retryContent(() => this.wire.request({
|
|
1248
|
-
type: "private.entity-content.get",
|
|
1253
|
+
type: "private.entity-content.get-artifact",
|
|
1249
1254
|
resource_id: resourceId,
|
|
1250
1255
|
content_hash: artifact.contentHash,
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
const
|
|
1255
|
-
downloadedBytes += manifestBytes.byteLength;
|
|
1256
|
-
const candidate = JSON.parse(manifestBytes.toString("utf8"));
|
|
1257
|
-
const checked = checkContentManifest(candidate, artifact.contentHash);
|
|
1256
|
+
}, ["private.entity-content.artifact"]), true, retryAttempts);
|
|
1257
|
+
const manifestJson = String(frame.manifest_json ?? "");
|
|
1258
|
+
downloadedBytes += Buffer.byteLength(manifestJson);
|
|
1259
|
+
const checked = checkContentManifest(JSON.parse(manifestJson), artifact.contentHash);
|
|
1258
1260
|
if (!checked.ok)
|
|
1259
1261
|
throw new Error(checked.error);
|
|
1262
|
+
if (frame.complete === true) {
|
|
1263
|
+
const decoded = await decodeContentWireBytes(binaryWireFrameBytes(frame), frame.content_encoding, Number(frame.bytes));
|
|
1264
|
+
if (decoded.byteLength !== checked.value.bytes) {
|
|
1265
|
+
throw new Error("artifact payload does not match its manifest");
|
|
1266
|
+
}
|
|
1267
|
+
prefetched.bytes = decoded;
|
|
1268
|
+
downloadedBytes += Number(frame.wire_bytes ?? frame.bytes);
|
|
1269
|
+
}
|
|
1260
1270
|
cache.target = new ContentCacheDownload(cacheDir, artifact, checked.value);
|
|
1261
1271
|
return checked.value;
|
|
1262
1272
|
},
|
|
@@ -1267,17 +1277,12 @@ export class UserGroundHost {
|
|
|
1267
1277
|
},
|
|
1268
1278
|
getChunk: async (_candidate, manifest, index) => getChunk(manifest, index),
|
|
1269
1279
|
getChunkBatch: async (_candidate, manifest, indexes) => {
|
|
1270
|
-
if (
|
|
1271
|
-
|
|
1272
|
-
for (const index of indexes)
|
|
1273
|
-
chunks.push(await getChunk(manifest, index));
|
|
1274
|
-
return Buffer.concat(chunks);
|
|
1275
|
-
}
|
|
1280
|
+
if (prefetched.bytes)
|
|
1281
|
+
return sliceChunks(prefetched.bytes, manifest, indexes);
|
|
1276
1282
|
const frame = await retryContent(() => this.wire.request({
|
|
1277
1283
|
type: "private.entity-content.get-batch",
|
|
1278
1284
|
resource_id: resourceId,
|
|
1279
1285
|
content_hash: artifact.contentHash,
|
|
1280
|
-
content_contract: manifest.contract,
|
|
1281
1286
|
chunks: indexes.map((index) => ({
|
|
1282
1287
|
part_index: index,
|
|
1283
1288
|
sha256: manifest.chunks[index]?.sha256,
|
|
@@ -1899,12 +1904,18 @@ function assertHealthyWatch(evidence, rootId) {
|
|
|
1899
1904
|
throw new Error(`Watch coverage is not healthy for workspace ${rootId}: ${evidence.health.reason ?? "incomplete handle evidence"}`);
|
|
1900
1905
|
}
|
|
1901
1906
|
}
|
|
1907
|
+
/** Schema migration runs once per file per process. A converge opens this
|
|
1908
|
+
* file over a dozen times; the file itself is never deleted by the host, so
|
|
1909
|
+
* every later open needs only the connection and its pragmas. */
|
|
1910
|
+
const migratedDatabases = new Set();
|
|
1902
1911
|
function initializeDatabase(file) {
|
|
1903
1912
|
ensurePrivateDir(dirname(file));
|
|
1904
1913
|
const database = new Database(file);
|
|
1905
1914
|
database.pragma("busy_timeout = 5000");
|
|
1906
1915
|
database.pragma("journal_mode = WAL");
|
|
1907
1916
|
database.pragma("synchronous = FULL");
|
|
1917
|
+
if (migratedDatabases.has(file))
|
|
1918
|
+
return database;
|
|
1908
1919
|
database.exec(`
|
|
1909
1920
|
CREATE TABLE IF NOT EXISTS ground_identity (
|
|
1910
1921
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
@@ -2025,6 +2036,7 @@ function initializeDatabase(file) {
|
|
|
2025
2036
|
}
|
|
2026
2037
|
database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
|
|
2027
2038
|
database.exec("DROP TABLE IF EXISTS mutation_journal");
|
|
2039
|
+
migratedDatabases.add(file);
|
|
2028
2040
|
return database;
|
|
2029
2041
|
}
|
|
2030
2042
|
const GROUND_ROW_PROJECTION = `
|
|
@@ -2533,17 +2545,29 @@ function portableRecord(row) {
|
|
|
2533
2545
|
transportVersion: derived ? null : row.transportVersion,
|
|
2534
2546
|
};
|
|
2535
2547
|
}
|
|
2536
|
-
|
|
2548
|
+
/** The authenticated user's core ground: the unique active root workspace
|
|
2549
|
+
* named by the canonical email, plus everything reachable from it by
|
|
2550
|
+
* parent_uuid — the exact walk Live's convergence performs. Sibling
|
|
2551
|
+
* workspace trees never leave SQLite; a bound root nested inside the core
|
|
2552
|
+
* tree is still included because the walk follows parents, not root_uuid. */
|
|
2553
|
+
function coreGroundRecords(file, userEmail) {
|
|
2537
2554
|
if (!existsSync(file))
|
|
2538
2555
|
return [];
|
|
2539
2556
|
const database = initializeDatabase(file);
|
|
2540
2557
|
try {
|
|
2541
2558
|
return database.prepare(`
|
|
2559
|
+
WITH RECURSIVE core(uuid) AS (
|
|
2560
|
+
SELECT uuid FROM entities
|
|
2561
|
+
WHERE parent_uuid IS NULL AND type = 'workspace' AND status = 'active'
|
|
2562
|
+
AND lower(name) = lower(?)
|
|
2563
|
+
UNION
|
|
2564
|
+
SELECT entities.uuid FROM entities JOIN core ON entities.parent_uuid = core.uuid
|
|
2565
|
+
)
|
|
2542
2566
|
SELECT uuid, type, parent_uuid AS parentUUID, name, status, version,
|
|
2543
2567
|
payload_version AS payloadVersion,
|
|
2544
2568
|
transport_version AS transportVersion
|
|
2545
|
-
FROM
|
|
2546
|
-
`).all().map((record) => (record.type === "workspace" || record.type === "folder"
|
|
2569
|
+
FROM entities WHERE uuid IN (SELECT uuid FROM core) ORDER BY uuid
|
|
2570
|
+
`).all(userEmail).map((record) => (record.type === "workspace" || record.type === "folder"
|
|
2547
2571
|
? { ...record, payloadVersion: null, transportVersion: null }
|
|
2548
2572
|
: record));
|
|
2549
2573
|
}
|
|
@@ -2551,9 +2575,6 @@ function readPortableRecords(file, table) {
|
|
|
2551
2575
|
database.close();
|
|
2552
2576
|
}
|
|
2553
2577
|
}
|
|
2554
|
-
function portableRecords(file) {
|
|
2555
|
-
return readPortableRecords(file, "entities");
|
|
2556
|
-
}
|
|
2557
2578
|
function capturedGroundRow(resourceId, rootUUID, row) {
|
|
2558
2579
|
return {
|
|
2559
2580
|
resourceId,
|
|
@@ -2758,7 +2779,7 @@ function localValue(identity, userRoot, database) {
|
|
|
2758
2779
|
...identity,
|
|
2759
2780
|
localRoot: userRoot,
|
|
2760
2781
|
sqlitePath: database,
|
|
2761
|
-
records:
|
|
2782
|
+
records: coreGroundRecords(database, identity.userEmail),
|
|
2762
2783
|
};
|
|
2763
2784
|
}
|
|
2764
2785
|
function createDeclaredHome(input) {
|
|
@@ -4051,14 +4072,14 @@ async function materializeRecords(input) {
|
|
|
4051
4072
|
if (record.type === "repo.git")
|
|
4052
4073
|
await materializeRepository(record, destination);
|
|
4053
4074
|
else
|
|
4054
|
-
|
|
4075
|
+
ensureUserDir(destination);
|
|
4055
4076
|
}
|
|
4056
4077
|
else if (record.type === "folder") {
|
|
4057
4078
|
if (pathExists(destination)) {
|
|
4058
4079
|
assertRepositoryBoundaryPath(record, relativePath, destination);
|
|
4059
4080
|
}
|
|
4060
4081
|
else {
|
|
4061
|
-
|
|
4082
|
+
ensureUserDir(destination);
|
|
4062
4083
|
}
|
|
4063
4084
|
}
|
|
4064
4085
|
else if (record.type === "repo.git") {
|
|
@@ -4073,7 +4094,7 @@ async function materializeRecords(input) {
|
|
|
4073
4094
|
}
|
|
4074
4095
|
if (pathExists(destination))
|
|
4075
4096
|
throw new Error(`cloud materialization conflicts with ${relativePath}`);
|
|
4076
|
-
|
|
4097
|
+
ensureUserDir(dirname(destination));
|
|
4077
4098
|
const binding = join(bindingDir, record.payloadVersion.toLowerCase());
|
|
4078
4099
|
symlinkSync(relative(dirname(destination), binding), destination);
|
|
4079
4100
|
}
|
|
@@ -4088,7 +4109,7 @@ async function materializeRecords(input) {
|
|
|
4088
4109
|
const local = fileContent.get(artifact.contentHash);
|
|
4089
4110
|
if (!local)
|
|
4090
4111
|
throw new Error(`cloud leaf ${record.uuid} content was not downloaded`);
|
|
4091
|
-
|
|
4112
|
+
ensureUserDir(dirname(destination));
|
|
4092
4113
|
if (record.type === "link")
|
|
4093
4114
|
symlinkSync(readFileSync(local.file, "utf8"), destination);
|
|
4094
4115
|
// Mode is not portable state: a fresh user file gets 0644, the same
|
|
@@ -4436,15 +4457,6 @@ function binaryWireFrameBytes(frame) {
|
|
|
4436
4457
|
}
|
|
4437
4458
|
return bytes;
|
|
4438
4459
|
}
|
|
4439
|
-
/** Small control artifacts do not pass through Live's block verifier, so
|
|
4440
|
-
* their wire checksum is proven here once. */
|
|
4441
|
-
function verifiedFrameBytes(frame) {
|
|
4442
|
-
const bytes = binaryFrameBytes(frame);
|
|
4443
|
-
if (sha256Hex(bytes) !== String(frame.sha256 || "")) {
|
|
4444
|
-
throw new Error("wire content bytes do not match their checksum");
|
|
4445
|
-
}
|
|
4446
|
-
return bytes;
|
|
4447
|
-
}
|
|
4448
4460
|
/** Immutable content reads and writes are idempotent. Retry transient wire or
|
|
4449
4461
|
* authority failures; reads additionally tolerate the brief not-found window
|
|
4450
4462
|
* between an immutable object acknowledgement and globally visible R2 state. */
|