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.
Files changed (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. 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
+ };