amalgm 0.1.245 → 0.1.246
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The entity-cloud boundary's local half.
|
|
5
|
+
*
|
|
6
|
+
* This module deliberately stores only logical graph facts in the cloud
|
|
7
|
+
* snapshot/outbox. The observer owns paths, inodes, watcher state, and
|
|
8
|
+
* evidence; identity owns UUID graph facts and the one durable delivery
|
|
9
|
+
* intent that accompanies each local graph mutation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
|
|
14
|
+
const ENTITY_CLOUD_CONTRACT = 'amalgm-entity-registry@1';
|
|
15
|
+
const ENTITY_CLOUD_SCHEMA_VERSION = 1;
|
|
16
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
|
+
const TYPES = new Set([
|
|
18
|
+
'workspace', 'folder', 'repo.git', 'file.text', 'file.binary', 'link', 'reference',
|
|
19
|
+
]);
|
|
20
|
+
const ROOT_TYPES = new Set(['workspace', 'repo.git', 'file.text', 'file.binary', 'link']);
|
|
21
|
+
const CONTENT_HEAD = /^[0-9a-f]{64}$/;
|
|
22
|
+
|
|
23
|
+
function privateEntityResourceId(userId) {
|
|
24
|
+
return `pvr_${crypto
|
|
25
|
+
.createHash('sha256')
|
|
26
|
+
.update(`private-user:${userId}:${ENTITY_CLOUD_CONTRACT}`)
|
|
27
|
+
.digest('base64url')
|
|
28
|
+
.slice(0, 32)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function now() {
|
|
32
|
+
return new Date().toISOString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stableJson(value) {
|
|
36
|
+
return JSON.stringify(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sameRecord(left, right) {
|
|
40
|
+
return stableJson(left) === stableJson(right);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertRecord(value) {
|
|
44
|
+
if (!value || typeof value !== 'object' || typeof value.uuid !== 'string' || !UUID.test(value.uuid)
|
|
45
|
+
|| typeof value.type !== 'string' || !TYPES.has(value.type)
|
|
46
|
+
|| (value.parentUUID !== null && (typeof value.parentUUID !== 'string' || !UUID.test(value.parentUUID)))
|
|
47
|
+
|| typeof value.name !== 'string' || !value.name || value.name.includes('/') || value.name.includes('\0')
|
|
48
|
+
|| !['active', 'trashed', 'deleted'].includes(value.status)
|
|
49
|
+
|| typeof value.version !== 'string' || !/^[0-9a-f]{64}$/.test(value.version)
|
|
50
|
+
|| (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')) {
|
|
51
|
+
throw new Error('cloud entity snapshot has an invalid record');
|
|
52
|
+
}
|
|
53
|
+
if (value.parentUUID === null && !ROOT_TYPES.has(value.type)) {
|
|
54
|
+
throw new Error(`cloud entity ${value.uuid} is not a valid tree root`);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
uuid: value.uuid.toLowerCase(),
|
|
58
|
+
type: value.type,
|
|
59
|
+
parentUUID: value.parentUUID === null ? null : value.parentUUID.toLowerCase(),
|
|
60
|
+
name: value.name,
|
|
61
|
+
status: value.status,
|
|
62
|
+
version: value.version,
|
|
63
|
+
payloadVersion: value.payloadVersion,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function snapshotFromRecords(records) {
|
|
68
|
+
const normalized = records.map(assertRecord).sort((left, right) => left.uuid.localeCompare(right.uuid));
|
|
69
|
+
if (new Set(normalized.map((record) => record.uuid)).size !== normalized.length) {
|
|
70
|
+
throw new Error('cloud entity snapshot has duplicate UUIDs');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
|
|
74
|
+
kind: 'amalgm-entity-registry',
|
|
75
|
+
records: normalized,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseSnapshot(bytes) {
|
|
80
|
+
let snapshot;
|
|
81
|
+
try {
|
|
82
|
+
snapshot = JSON.parse(bytes.toString('utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error('cloud entity snapshot is not JSON');
|
|
85
|
+
}
|
|
86
|
+
if (snapshot?.schemaVersion !== ENTITY_CLOUD_SCHEMA_VERSION
|
|
87
|
+
|| snapshot?.kind !== 'amalgm-entity-registry'
|
|
88
|
+
|| !Array.isArray(snapshot.records)) {
|
|
89
|
+
throw new Error('cloud entity snapshot has an unsupported shape');
|
|
90
|
+
}
|
|
91
|
+
return snapshotFromRecords(snapshot.records);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A service-local adapter over the identity database. It intentionally has
|
|
96
|
+
* no watcher imports: a cloud record may be known on this machine long before
|
|
97
|
+
* `amalgm add <uuid>` chooses and validates local ground for it.
|
|
98
|
+
*/
|
|
99
|
+
function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
100
|
+
if (!db) throw new Error('createEntityCloud requires an identity database');
|
|
101
|
+
const ownerUserId = typeof userId === 'string' && userId ? userId : null;
|
|
102
|
+
const localDeviceId = typeof deviceId === 'string' && deviceId ? deviceId : null;
|
|
103
|
+
|
|
104
|
+
function contentHead(record) {
|
|
105
|
+
return ['file.text', 'file.binary'].includes(record?.type)
|
|
106
|
+
&& typeof record?.payloadVersion === 'string'
|
|
107
|
+
&& CONTENT_HEAD.test(record.payloadVersion);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function queueContent(record, resourceId, { queue = false } = {}) {
|
|
111
|
+
if (!contentHead(record)) return;
|
|
112
|
+
if (!content || typeof content.has !== 'function') {
|
|
113
|
+
throw new Error('file entity content is unavailable: registration must capture bytes before it records their cloud head');
|
|
114
|
+
}
|
|
115
|
+
if (!content.has({ contentHash: record.payloadVersion })) {
|
|
116
|
+
throw new Error('file entity bytes were not captured before its cloud head committed');
|
|
117
|
+
}
|
|
118
|
+
if (queue) content.ensureUpload({ resourceId, contentHash: record.payloadVersion });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function replica() {
|
|
122
|
+
const row = db.prepare('SELECT * FROM entity_cloud_replica WHERE singleton = 1').get();
|
|
123
|
+
if (!row) return null;
|
|
124
|
+
return {
|
|
125
|
+
resourceId: row.resource_id,
|
|
126
|
+
deviceId: row.device_id,
|
|
127
|
+
authorityEpoch: Number(row.authority_epoch),
|
|
128
|
+
headVersion: Number(row.head_version),
|
|
129
|
+
state: row.state,
|
|
130
|
+
snapshotCutoff: row.snapshot_cutoff == null ? null : Number(row.snapshot_cutoff),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function identity() {
|
|
135
|
+
if (!ownerUserId || !localDeviceId) return { enabled: false, reason: 'runtime is not enrolled to a user' };
|
|
136
|
+
return {
|
|
137
|
+
enabled: true,
|
|
138
|
+
resourceId: privateEntityResourceId(ownerUserId),
|
|
139
|
+
contract: ENTITY_CLOUD_CONTRACT,
|
|
140
|
+
schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
|
|
141
|
+
deviceId: localDeviceId,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function saveReplica(value) {
|
|
146
|
+
db.prepare(`
|
|
147
|
+
INSERT INTO entity_cloud_replica (
|
|
148
|
+
singleton, resource_id, device_id, authority_epoch, head_version,
|
|
149
|
+
state, snapshot_cutoff, updated_at
|
|
150
|
+
) VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
|
151
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
152
|
+
resource_id = excluded.resource_id,
|
|
153
|
+
device_id = excluded.device_id,
|
|
154
|
+
authority_epoch = excluded.authority_epoch,
|
|
155
|
+
head_version = excluded.head_version,
|
|
156
|
+
state = excluded.state,
|
|
157
|
+
snapshot_cutoff = excluded.snapshot_cutoff,
|
|
158
|
+
updated_at = excluded.updated_at
|
|
159
|
+
`).run(
|
|
160
|
+
value.resourceId,
|
|
161
|
+
value.deviceId,
|
|
162
|
+
value.authorityEpoch,
|
|
163
|
+
value.headVersion,
|
|
164
|
+
value.state,
|
|
165
|
+
value.snapshotCutoff ?? null,
|
|
166
|
+
now(),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Invoked by registry/index.js INSIDE its identity transaction. */
|
|
171
|
+
function journal({ record }) {
|
|
172
|
+
const current = replica();
|
|
173
|
+
// Bootstrap roots are included in the first immutable snapshot. No
|
|
174
|
+
// unscoped row is written before the cloud replica exists.
|
|
175
|
+
if (!current || current.state !== 'active') return;
|
|
176
|
+
// The SQLite mutation is the durable delivery intent. Its post-commit
|
|
177
|
+
// content drain creates the filesystem upload cursor; doing that here
|
|
178
|
+
// would let a rolled-back identity write upload unreachable bytes.
|
|
179
|
+
queueContent(record, current.resourceId);
|
|
180
|
+
const mutationId = `ent_${crypto.randomBytes(18).toString('base64url')}`;
|
|
181
|
+
const operation = { kind: 'entity.upsert', record: assertRecord(record) };
|
|
182
|
+
const createdAt = now();
|
|
183
|
+
db.prepare(`
|
|
184
|
+
INSERT INTO entity_cloud_mutations (
|
|
185
|
+
resource_id, mutation_id, operation_json, mutation_state,
|
|
186
|
+
created_at, updated_at
|
|
187
|
+
) VALUES (?, ?, ?, 'pending', ?, ?)
|
|
188
|
+
`).run(current.resourceId, mutationId, stableJson(operation), createdAt, createdAt);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function assertRegistrationReady() {
|
|
192
|
+
if (!ownerUserId) return;
|
|
193
|
+
const current = replica();
|
|
194
|
+
if (!current || current.state !== 'active') {
|
|
195
|
+
const error = new Error('entity cloud registry is not ready yet; wait for the signed-in runtime to finish its first cloud handshake');
|
|
196
|
+
error.code = 'entity_cloud_not_ready';
|
|
197
|
+
error.statusCode = 409;
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function bootstrap(registry) {
|
|
203
|
+
const currentIdentity = identity();
|
|
204
|
+
if (!currentIdentity.enabled) return currentIdentity;
|
|
205
|
+
const existing = replica();
|
|
206
|
+
if (existing?.state === 'active') {
|
|
207
|
+
throw new Error('entity cloud registry already exists locally; cloud lookup must not create it again');
|
|
208
|
+
}
|
|
209
|
+
const snapshot = snapshotFromRecords(registry.syncRecords());
|
|
210
|
+
const cutoff = db.prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM entity_cloud_mutations').get().sequence;
|
|
211
|
+
saveReplica({
|
|
212
|
+
resourceId: currentIdentity.resourceId,
|
|
213
|
+
deviceId: currentIdentity.deviceId,
|
|
214
|
+
authorityEpoch: 1,
|
|
215
|
+
headVersion: 0,
|
|
216
|
+
state: 'bootstrapping',
|
|
217
|
+
snapshotCutoff: Number(cutoff),
|
|
218
|
+
});
|
|
219
|
+
const bytes = Buffer.from(stableJson(snapshot), 'utf8');
|
|
220
|
+
return {
|
|
221
|
+
...currentIdentity,
|
|
222
|
+
snapshotBase64: bytes.toString('base64'),
|
|
223
|
+
snapshotBytes: bytes.length,
|
|
224
|
+
snapshotChecksum: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function install({ registry, resourceId, authorityEpoch, headVersion, snapshotBase64, snapshotChecksum }) {
|
|
229
|
+
const currentIdentity = identity();
|
|
230
|
+
if (!currentIdentity.enabled || resourceId !== currentIdentity.resourceId) {
|
|
231
|
+
throw new Error('cloud entity registry identity does not belong to this runtime user');
|
|
232
|
+
}
|
|
233
|
+
const bytes = Buffer.from(String(snapshotBase64 || ''), 'base64');
|
|
234
|
+
const checksum = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
235
|
+
if (checksum !== snapshotChecksum) throw new Error('cloud entity snapshot checksum mismatch');
|
|
236
|
+
const snapshot = parseSnapshot(bytes);
|
|
237
|
+
const local = snapshotFromRecords(registry.syncRecords());
|
|
238
|
+
const bootstrapping = replica()?.state === 'bootstrapping';
|
|
239
|
+
if (bootstrapping && !sameRecord(local, snapshot)) {
|
|
240
|
+
throw new Error('first cloud entity snapshot does not match the locally bootstrapped identity graph');
|
|
241
|
+
}
|
|
242
|
+
if (!bootstrapping && !sameRecord(local, snapshot)) {
|
|
243
|
+
// Import is intentionally delayed until the materializer can bind every
|
|
244
|
+
// extra root deliberately. Failing loudly is safer than silently
|
|
245
|
+
// treating a remote entity as watched local ground.
|
|
246
|
+
throw new Error('cloud entity graph differs from this local registry; materialization import is not enabled for this runtime yet');
|
|
247
|
+
}
|
|
248
|
+
if (!Number.isInteger(Number(authorityEpoch)) || Number(authorityEpoch) < 1
|
|
249
|
+
|| !Number.isInteger(Number(headVersion)) || Number(headVersion) < 0) {
|
|
250
|
+
throw new Error('cloud entity registry authority is invalid');
|
|
251
|
+
}
|
|
252
|
+
// Initial snapshot records have no tail mutation to trigger a content
|
|
253
|
+
// upload. Queue every actual file head before calling this replica
|
|
254
|
+
// active; from that moment its graph may be advertised to the cloud.
|
|
255
|
+
for (const record of snapshot.records) queueContent(record, resourceId, { queue: true });
|
|
256
|
+
saveReplica({
|
|
257
|
+
resourceId,
|
|
258
|
+
deviceId: currentIdentity.deviceId,
|
|
259
|
+
authorityEpoch: Number(authorityEpoch),
|
|
260
|
+
headVersion: Number(headVersion),
|
|
261
|
+
state: 'active',
|
|
262
|
+
snapshotCutoff: null,
|
|
263
|
+
});
|
|
264
|
+
return { resourceId, records: snapshot.records.length, headVersion: Number(headVersion) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function claim({ limit = 16, leaseMs = 30_000 } = {}) {
|
|
268
|
+
const current = replica();
|
|
269
|
+
if (!current || current.state !== 'active') return [];
|
|
270
|
+
const boundedLimit = Math.max(1, Math.min(Number(limit) || 16, 100));
|
|
271
|
+
const leaseUntil = new Date(Date.now() + Math.max(1_000, Number(leaseMs) || 30_000)).toISOString();
|
|
272
|
+
const started = now();
|
|
273
|
+
return db.transaction(() => {
|
|
274
|
+
// One mutation per resource at a time keeps each baseVersion equal to
|
|
275
|
+
// the official head advanced by the previous acknowledgement.
|
|
276
|
+
const rows = db.prepare(`
|
|
277
|
+
SELECT * FROM entity_cloud_mutations
|
|
278
|
+
WHERE resource_id = ?
|
|
279
|
+
AND (mutation_state = 'pending'
|
|
280
|
+
OR (mutation_state = 'syncing' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)))
|
|
281
|
+
ORDER BY sequence ASC
|
|
282
|
+
LIMIT 1
|
|
283
|
+
`).all(current.resourceId, started).slice(0, boundedLimit);
|
|
284
|
+
const claimRow = db.prepare(`
|
|
285
|
+
UPDATE entity_cloud_mutations
|
|
286
|
+
SET mutation_state = 'syncing', base_version = ?, attempt_count = attempt_count + 1,
|
|
287
|
+
next_attempt_at = ?, updated_at = ?
|
|
288
|
+
WHERE sequence = ?
|
|
289
|
+
`);
|
|
290
|
+
return rows.flatMap((row) => {
|
|
291
|
+
const operation = JSON.parse(row.operation_json);
|
|
292
|
+
// The logical mutation is intentionally held behind the immutable
|
|
293
|
+
// content manifest. A cloud graph never advances to a file head
|
|
294
|
+
// whose bytes have not reached private R2 storage.
|
|
295
|
+
if (contentHead(operation?.record)
|
|
296
|
+
&& (!content || !content.uploaded({ resourceId: current.resourceId, contentHash: operation.record.payloadVersion }))) {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
claimRow.run(current.headVersion, leaseUntil, started, row.sequence);
|
|
300
|
+
return [{
|
|
301
|
+
resourceId: current.resourceId,
|
|
302
|
+
mutationId: row.mutation_id,
|
|
303
|
+
deviceId: current.deviceId,
|
|
304
|
+
authorityEpoch: current.authorityEpoch,
|
|
305
|
+
baseVersion: current.headVersion,
|
|
306
|
+
contract: ENTITY_CLOUD_CONTRACT,
|
|
307
|
+
schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
|
|
308
|
+
operationKind: 'entity.upsert',
|
|
309
|
+
operation,
|
|
310
|
+
}];
|
|
311
|
+
});
|
|
312
|
+
})();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function claimContent({ leaseMs = 30_000 } = {}) {
|
|
316
|
+
const current = replica();
|
|
317
|
+
if (!current || current.state !== 'active' || !content) return null;
|
|
318
|
+
// Later registrations journal their record transactionally in SQLite.
|
|
319
|
+
// Derive its content-upload cursor only after that commit exists, so a
|
|
320
|
+
// failed transaction cannot publish a private object no entity names.
|
|
321
|
+
const rows = db.prepare(`
|
|
322
|
+
SELECT operation_json FROM entity_cloud_mutations
|
|
323
|
+
WHERE resource_id = ? AND mutation_state IN ('pending', 'syncing')
|
|
324
|
+
ORDER BY sequence ASC
|
|
325
|
+
`).all(current.resourceId);
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
const record = JSON.parse(row.operation_json)?.record;
|
|
328
|
+
queueContent(record, current.resourceId, { queue: true });
|
|
329
|
+
}
|
|
330
|
+
return content.claim({ resourceId: current.resourceId, leaseMs });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function acknowledgeContent({ resourceId, contentHash, kind, partIndex }) {
|
|
334
|
+
const current = replica();
|
|
335
|
+
if (!current || current.resourceId !== resourceId || !content) {
|
|
336
|
+
throw new Error(`unknown entity content resource ${resourceId}`);
|
|
337
|
+
}
|
|
338
|
+
return content.acknowledge({ resourceId, contentHash, kind, partIndex });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function failContent({ resourceId, contentHash, message }) {
|
|
342
|
+
const current = replica();
|
|
343
|
+
if (!current || current.resourceId !== resourceId || !content) {
|
|
344
|
+
throw new Error(`unknown entity content resource ${resourceId}`);
|
|
345
|
+
}
|
|
346
|
+
return content.fail({ resourceId, contentHash, message });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function acknowledge({ resourceId, mutationId, version, committedAt }) {
|
|
350
|
+
const current = replica();
|
|
351
|
+
if (!current || current.resourceId !== resourceId) throw new Error(`unknown entity cloud resource ${resourceId}`);
|
|
352
|
+
if (!Number.isInteger(Number(version)) || Number(version) < 1) throw new Error('entity cloud acknowledgement version is invalid');
|
|
353
|
+
return db.transaction(() => {
|
|
354
|
+
const row = db.prepare('SELECT * FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_id = ?')
|
|
355
|
+
.get(resourceId, mutationId);
|
|
356
|
+
if (!row) throw new Error(`unknown entity cloud mutation ${mutationId}`);
|
|
357
|
+
if (row.mutation_state === 'synced') return { duplicate: true, version: Number(row.official_version) };
|
|
358
|
+
if (!['pending', 'syncing'].includes(row.mutation_state)) throw new Error(`entity cloud mutation ${mutationId} cannot sync from ${row.mutation_state}`);
|
|
359
|
+
db.prepare(`
|
|
360
|
+
UPDATE entity_cloud_mutations
|
|
361
|
+
SET mutation_state = 'synced', official_version = ?, cloud_committed_at = ?,
|
|
362
|
+
next_attempt_at = NULL, last_error = NULL, updated_at = ?
|
|
363
|
+
WHERE sequence = ?
|
|
364
|
+
`).run(Number(version), String(committedAt || now()), now(), row.sequence);
|
|
365
|
+
if (Number(version) > current.headVersion) {
|
|
366
|
+
saveReplica({ ...current, headVersion: Number(version) });
|
|
367
|
+
}
|
|
368
|
+
return { duplicate: false, version: Number(version) };
|
|
369
|
+
})();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function fail({ resourceId, mutationId, message, retryable = true }) {
|
|
373
|
+
const current = replica();
|
|
374
|
+
if (!current || current.resourceId !== resourceId) throw new Error(`unknown entity cloud resource ${resourceId}`);
|
|
375
|
+
const row = db.prepare('SELECT sequence, mutation_state FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_id = ?')
|
|
376
|
+
.get(resourceId, mutationId);
|
|
377
|
+
if (!row) throw new Error(`unknown entity cloud mutation ${mutationId}`);
|
|
378
|
+
if (row.mutation_state === 'synced') return { duplicate: true };
|
|
379
|
+
db.prepare(`
|
|
380
|
+
UPDATE entity_cloud_mutations
|
|
381
|
+
SET mutation_state = ?, next_attempt_at = ?, last_error = ?, updated_at = ?
|
|
382
|
+
WHERE sequence = ?
|
|
383
|
+
`).run(
|
|
384
|
+
retryable ? 'syncing' : 'rejected',
|
|
385
|
+
retryable ? new Date(Date.now() + 1_000).toISOString() : null,
|
|
386
|
+
String(message || 'entity cloud mutation failed').slice(0, 4000),
|
|
387
|
+
now(),
|
|
388
|
+
row.sequence,
|
|
389
|
+
);
|
|
390
|
+
return { duplicate: false };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function ingest({ registry, envelope, version, committedAt }) {
|
|
394
|
+
const current = replica();
|
|
395
|
+
if (!current || current.state !== 'active') throw new Error('entity cloud registry is not active');
|
|
396
|
+
if (envelope?.resourceId !== current.resourceId
|
|
397
|
+
|| envelope?.contract !== ENTITY_CLOUD_CONTRACT
|
|
398
|
+
|| Number(envelope?.schemaVersion) !== ENTITY_CLOUD_SCHEMA_VERSION
|
|
399
|
+
|| envelope?.operationKind !== 'entity.upsert'
|
|
400
|
+
|| envelope?.operation?.kind !== 'entity.upsert') {
|
|
401
|
+
throw new Error('cloud entity mutation does not belong to this registry');
|
|
402
|
+
}
|
|
403
|
+
const record = assertRecord(envelope.operation.record);
|
|
404
|
+
const localMutation = db.prepare('SELECT mutation_state FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_id = ?')
|
|
405
|
+
.get(current.resourceId, envelope.mutationId);
|
|
406
|
+
if (localMutation) {
|
|
407
|
+
return acknowledge({
|
|
408
|
+
resourceId: current.resourceId,
|
|
409
|
+
mutationId: envelope.mutationId,
|
|
410
|
+
version,
|
|
411
|
+
committedAt,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
// The snapshot install is the first place an unbound remote root may
|
|
415
|
+
// enter this machine. Until `add` has the materializer/catalog rail,
|
|
416
|
+
// accepting a later unknown root here would violate the registry's
|
|
417
|
+
// watched-root loss law. A duplicate record is safe and advances the
|
|
418
|
+
// local cloud cursor; a distinct record is deliberately surfaced.
|
|
419
|
+
let local;
|
|
420
|
+
try {
|
|
421
|
+
local = registry.syncRecord(record.uuid);
|
|
422
|
+
} catch {
|
|
423
|
+
throw new Error('remote entity mutation needs cloud-to-local materialization before this device can apply it');
|
|
424
|
+
}
|
|
425
|
+
if (!sameRecord(local, record)) {
|
|
426
|
+
throw new Error('remote entity mutation conflicts with this device; materialize or reconcile the cloud entity first');
|
|
427
|
+
}
|
|
428
|
+
if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
|
|
429
|
+
return { duplicate: true, version: Number(version) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
identity,
|
|
434
|
+
journal,
|
|
435
|
+
assertRegistrationReady,
|
|
436
|
+
bootstrap,
|
|
437
|
+
install,
|
|
438
|
+
claim,
|
|
439
|
+
acknowledge,
|
|
440
|
+
fail,
|
|
441
|
+
ingest,
|
|
442
|
+
claimContent,
|
|
443
|
+
acknowledgeContent,
|
|
444
|
+
failContent,
|
|
445
|
+
replica,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
module.exports = {
|
|
450
|
+
ENTITY_CLOUD_CONTRACT,
|
|
451
|
+
ENTITY_CLOUD_SCHEMA_VERSION,
|
|
452
|
+
createEntityCloud,
|
|
453
|
+
parseSnapshot,
|
|
454
|
+
privateEntityResourceId,
|
|
455
|
+
snapshotFromRecords,
|
|
456
|
+
};
|