amalgm 0.1.260 → 0.1.262
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/lib/cli.js +9 -3
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/lib/prepared.js +27 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +15 -7
- package/runtime/scripts/amalgm-mcp/observer/store.js +41 -27
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +78 -26
- package/runtime/scripts/amalgm-mcp/registration/service.js +33 -19
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +34 -13
- package/runtime/scripts/amalgm-mcp/registry/store.js +30 -26
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +29 -14
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +21 -0
package/lib/cli.js
CHANGED
|
@@ -1215,10 +1215,16 @@ function selectEntity(status, selector) {
|
|
|
1215
1215
|
const uuid = selector && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(selector)
|
|
1216
1216
|
? selector.toLowerCase()
|
|
1217
1217
|
: null;
|
|
1218
|
+
// A UUID is an entity's globally unique address. Do not let rows without
|
|
1219
|
+
// an optional display path turn that exact address into a fuzzy selector.
|
|
1220
|
+
if (uuid) {
|
|
1221
|
+
const match = entities.find((entity) => entity.uuid === uuid);
|
|
1222
|
+
if (!match) throw new Error(`No cloud-backed entity matches ${selector}.`);
|
|
1223
|
+
return match;
|
|
1224
|
+
}
|
|
1218
1225
|
const localPath = selector && !uuid ? path.resolve(selector) : null;
|
|
1219
|
-
const cloudPath =
|
|
1220
|
-
const matches = entities.filter((entity) => entity.
|
|
1221
|
-
|| entity.localPath === localPath
|
|
1226
|
+
const cloudPath = String(selector).replace(/^\/+|\/+$/g, '');
|
|
1227
|
+
const matches = entities.filter((entity) => entity.localPath === localPath
|
|
1222
1228
|
|| entity.cloudPath === cloudPath
|
|
1223
1229
|
|| entity.name === selector);
|
|
1224
1230
|
if (matches.length === 0) throw new Error(`No cloud-backed entity matches ${selector}.`);
|
package/package.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Memoized statement preparation for one database handle.
|
|
5
|
+
*
|
|
6
|
+
* better-sqlite3 compiles the SQL text on EVERY `db.prepare()` call — it
|
|
7
|
+
* keeps no statement cache of its own. The stores' row-level verbs run
|
|
8
|
+
* thousands of times per scan (a first census inserts one row per file),
|
|
9
|
+
* so recompiling identical SQL each call was a measured double-digit
|
|
10
|
+
* share of a large registration. Each store builds one preparer over its
|
|
11
|
+
* injected handle and asks it instead; the SQL string is the whole key,
|
|
12
|
+
* so a handful of table-templated queries memoize to a handful of
|
|
13
|
+
* statements, never an unbounded set.
|
|
14
|
+
*/
|
|
15
|
+
function preparer(db) {
|
|
16
|
+
const statements = new Map();
|
|
17
|
+
return (sql) => {
|
|
18
|
+
let statement = statements.get(sql);
|
|
19
|
+
if (!statement) {
|
|
20
|
+
statement = db.prepare(sql);
|
|
21
|
+
statements.set(sql, statement);
|
|
22
|
+
}
|
|
23
|
+
return statement;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { preparer };
|
|
@@ -407,8 +407,10 @@ function createObserver(options) {
|
|
|
407
407
|
|| linkDiff.renames.length || linkDiff.creates.length || linkDiff.deletes.length
|
|
408
408
|
|| fileDiff.renames.length || fileDiff.creates.length || fileDiff.deletes.length;
|
|
409
409
|
if (structural) return false;
|
|
410
|
-
|
|
411
|
-
|
|
410
|
+
store.transaction(() => {
|
|
411
|
+
applyLinkDiff(root, linkDiff); // changes and retags: payload-local facts
|
|
412
|
+
applyDiff(root, fileDiff); // checks only: content truth on existing channels
|
|
413
|
+
});
|
|
412
414
|
return true;
|
|
413
415
|
}
|
|
414
416
|
|
|
@@ -448,11 +450,17 @@ function createObserver(options) {
|
|
|
448
450
|
: { ...diff, deletes: diff.deletes.filter((row) => !underUnsettled(row.relPath)) });
|
|
449
451
|
if (unsettled.length > 0 && state) armRetry(root, state);
|
|
450
452
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
453
|
+
// One walk's conclusions are one durable transition: a census over
|
|
454
|
+
// thousands of channels commits once, not once per row. Events stay
|
|
455
|
+
// hints — a consumer told early about a rolled-back row re-reads
|
|
456
|
+
// store truth and finds the old state, which concludes nothing.
|
|
457
|
+
store.transaction(() => {
|
|
458
|
+
applyDirDiff(root, concluded(diffDirState(store.dirsForRoot(root.rootId), dirs)));
|
|
459
|
+
applyLinkDiff(root, concluded(diffLinkState(store.linksForRoot(root.rootId), links)));
|
|
460
|
+
const rows = store.filesForRoot(root.rootId);
|
|
461
|
+
const mustCheck = checkAll ? new Set(entries.map((entry) => entry.relPath)) : rung;
|
|
462
|
+
applyDiff(root, concluded(diffRootState(rows, entries, mustCheck)));
|
|
463
|
+
});
|
|
456
464
|
}
|
|
457
465
|
|
|
458
466
|
/**
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* is durable, or its loss is explicit.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
const { preparer } = require('../lib/prepared');
|
|
19
|
+
|
|
18
20
|
const SCHEMA = `
|
|
19
21
|
CREATE TABLE IF NOT EXISTS observer_roots (
|
|
20
22
|
root_id TEXT PRIMARY KEY,
|
|
@@ -141,6 +143,8 @@ function createStore(db) {
|
|
|
141
143
|
db.exec(SCHEMA);
|
|
142
144
|
db.pragma(`user_version = ${GENERATION}`);
|
|
143
145
|
|
|
146
|
+
const prepared = preparer(db);
|
|
147
|
+
|
|
144
148
|
/**
|
|
145
149
|
* One directory's immediate child rows — the scoped scan's slice of
|
|
146
150
|
* memory, served by the (root_id, rel_path) unique index. '0' is the
|
|
@@ -150,33 +154,43 @@ function createStore(db) {
|
|
|
150
154
|
*/
|
|
151
155
|
function immediateRows(table, rootId, relDir) {
|
|
152
156
|
if (relDir === '') {
|
|
153
|
-
return
|
|
157
|
+
return prepared(`SELECT * FROM ${table} WHERE root_id = ? AND instr(rel_path, '/') = 0 ORDER BY rel_path`)
|
|
154
158
|
.all(rootId);
|
|
155
159
|
}
|
|
156
160
|
const prefix = `${relDir}/`;
|
|
157
|
-
return
|
|
161
|
+
return prepared(`SELECT * FROM ${table} WHERE root_id = ? AND rel_path >= ? AND rel_path < ? ORDER BY rel_path`)
|
|
158
162
|
.all(rootId, prefix, `${relDir}0`)
|
|
159
163
|
.filter((row) => !row.rel_path.slice(prefix.length).includes('/'));
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
return {
|
|
167
|
+
/**
|
|
168
|
+
* One scan's whole apply phase commits as one durable transition.
|
|
169
|
+
* Row-level verbs each pay a full journal cycle when run bare, so a
|
|
170
|
+
* census over thousands of channels was thousands of commits; batched,
|
|
171
|
+
* it is one. Nesting is safe — SQLite savepoints — and a crash
|
|
172
|
+
* mid-batch leaves the previous truth whole, exactly like applyDirDiff.
|
|
173
|
+
*/
|
|
174
|
+
transaction(fn) {
|
|
175
|
+
return db.transaction(fn)();
|
|
176
|
+
},
|
|
163
177
|
addRoot({ rootId, path, kind, device = null, inode = null }) {
|
|
164
|
-
|
|
178
|
+
prepared('INSERT INTO observer_roots (root_id, path, kind, device, inode) VALUES (?, ?, ?, ?, ?)')
|
|
165
179
|
.run(rootId, path, kind, device, inode);
|
|
166
180
|
},
|
|
167
181
|
rootByPath(path) {
|
|
168
|
-
const row =
|
|
182
|
+
const row = prepared('SELECT * FROM observer_roots WHERE path = ?').get(path);
|
|
169
183
|
return row ? rowToRoot(row) : null;
|
|
170
184
|
},
|
|
171
185
|
listRoots() {
|
|
172
|
-
return
|
|
186
|
+
return prepared('SELECT * FROM observer_roots ORDER BY path').all().map(rowToRoot);
|
|
173
187
|
},
|
|
174
188
|
setRootKind(rootId, kind) {
|
|
175
|
-
|
|
189
|
+
prepared('UPDATE observer_roots SET kind = ? WHERE root_id = ?').run(kind, rootId);
|
|
176
190
|
},
|
|
177
191
|
/** Identity persists; local presence is separate evidence. */
|
|
178
192
|
setRootPresent(rootId, present) {
|
|
179
|
-
|
|
193
|
+
prepared('UPDATE observer_roots SET present = ? WHERE root_id = ?').run(present ? 1 : 0, rootId);
|
|
180
194
|
},
|
|
181
195
|
/**
|
|
182
196
|
* A root's identity is permanent; its address is not — and a family
|
|
@@ -186,7 +200,7 @@ function createStore(db) {
|
|
|
186
200
|
* Device and inode are continuity itself and never churn on a rename.
|
|
187
201
|
*/
|
|
188
202
|
moveRoots(moves) {
|
|
189
|
-
const move =
|
|
203
|
+
const move = prepared('UPDATE observer_roots SET path = ? WHERE root_id = ?');
|
|
190
204
|
db.transaction(() => {
|
|
191
205
|
for (const { rootId, path } of moves) move.run(path, rootId);
|
|
192
206
|
})();
|
|
@@ -195,18 +209,18 @@ function createStore(db) {
|
|
|
195
209
|
* new identity at a known address (see enrollRoot) — the one write
|
|
196
210
|
* where a root's own device:inode may change. */
|
|
197
211
|
setRootIdentity(rootId, device, inode) {
|
|
198
|
-
|
|
212
|
+
prepared('UPDATE observer_roots SET device = ?, inode = ? WHERE root_id = ?')
|
|
199
213
|
.run(device, inode, rootId);
|
|
200
214
|
},
|
|
201
215
|
removeRoot(rootId) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
216
|
+
prepared('DELETE FROM observer_files WHERE root_id = ?').run(rootId);
|
|
217
|
+
prepared('DELETE FROM observer_dirs WHERE root_id = ?').run(rootId);
|
|
218
|
+
prepared('DELETE FROM observer_links WHERE root_id = ?').run(rootId);
|
|
219
|
+
prepared('DELETE FROM observer_roots WHERE root_id = ?').run(rootId);
|
|
206
220
|
},
|
|
207
221
|
|
|
208
222
|
filesForRoot(rootId) {
|
|
209
|
-
return
|
|
223
|
+
return prepared('SELECT * FROM observer_files WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
210
224
|
.map(rowToFile);
|
|
211
225
|
},
|
|
212
226
|
filesInDir(rootId, relDir) {
|
|
@@ -220,11 +234,11 @@ function createStore(db) {
|
|
|
220
234
|
},
|
|
221
235
|
fileByPath(rootId, relPath) {
|
|
222
236
|
return rowToFile(
|
|
223
|
-
|
|
237
|
+
prepared('SELECT * FROM observer_files WHERE root_id = ? AND rel_path = ?').get(rootId, relPath),
|
|
224
238
|
);
|
|
225
239
|
},
|
|
226
240
|
insertFile(file) {
|
|
227
|
-
|
|
241
|
+
prepared(`
|
|
228
242
|
INSERT INTO observer_files (file_id, root_id, rel_path, device, inode, size, mtime_ms, content_hash, is_binary, hashed_at_ms)
|
|
229
243
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
230
244
|
`).run(
|
|
@@ -233,18 +247,18 @@ function createStore(db) {
|
|
|
233
247
|
);
|
|
234
248
|
},
|
|
235
249
|
updateFile(fileId, { relPath, device, inode, size, mtimeMs, contentHash, binary, hashedAtMs }) {
|
|
236
|
-
|
|
250
|
+
prepared(`
|
|
237
251
|
UPDATE observer_files
|
|
238
252
|
SET rel_path = ?, device = ?, inode = ?, size = ?, mtime_ms = ?, content_hash = ?, is_binary = ?, hashed_at_ms = ?
|
|
239
253
|
WHERE file_id = ?
|
|
240
254
|
`).run(relPath, device, inode, size, mtimeMs, contentHash, binary ? 1 : 0, hashedAtMs, fileId);
|
|
241
255
|
},
|
|
242
256
|
removeFile(fileId) {
|
|
243
|
-
|
|
257
|
+
prepared('DELETE FROM observer_files WHERE file_id = ?').run(fileId);
|
|
244
258
|
},
|
|
245
259
|
|
|
246
260
|
dirsForRoot(rootId) {
|
|
247
|
-
return
|
|
261
|
+
return prepared('SELECT * FROM observer_dirs WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
248
262
|
.map(rowToDir);
|
|
249
263
|
},
|
|
250
264
|
/**
|
|
@@ -257,9 +271,9 @@ function createStore(db) {
|
|
|
257
271
|
* the next scan re-detects the same batch.
|
|
258
272
|
*/
|
|
259
273
|
applyDirDiff({ deletes, moves, retags, creates }) {
|
|
260
|
-
const remove =
|
|
261
|
-
const insert =
|
|
262
|
-
const retag =
|
|
274
|
+
const remove = prepared('DELETE FROM observer_dirs WHERE dir_id = ?');
|
|
275
|
+
const insert = prepared('INSERT INTO observer_dirs (dir_id, root_id, rel_path, device, inode) VALUES (?, ?, ?, ?, ?)');
|
|
276
|
+
const retag = prepared('UPDATE observer_dirs SET rel_path = ?, device = ?, inode = ? WHERE dir_id = ?');
|
|
263
277
|
db.transaction(() => {
|
|
264
278
|
for (const dirId of deletes) remove.run(dirId);
|
|
265
279
|
for (const dir of moves) remove.run(dir.dirId);
|
|
@@ -269,7 +283,7 @@ function createStore(db) {
|
|
|
269
283
|
})();
|
|
270
284
|
},
|
|
271
285
|
removeDir(dirId) {
|
|
272
|
-
|
|
286
|
+
prepared('DELETE FROM observer_dirs WHERE dir_id = ?').run(dirId);
|
|
273
287
|
},
|
|
274
288
|
|
|
275
289
|
// Link channels: a pointer's whole payload is its target string, read
|
|
@@ -277,19 +291,19 @@ function createStore(db) {
|
|
|
277
291
|
// a rename only ever targets a row-free slot), so link rows commit
|
|
278
292
|
// per-row like files — every commit is a valid last-known truth.
|
|
279
293
|
linksForRoot(rootId) {
|
|
280
|
-
return
|
|
294
|
+
return prepared('SELECT * FROM observer_links WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
281
295
|
.map(rowToLink);
|
|
282
296
|
},
|
|
283
297
|
insertLink({ linkId, rootId, relPath, device, inode, target }) {
|
|
284
|
-
|
|
298
|
+
prepared('INSERT INTO observer_links (link_id, root_id, rel_path, device, inode, target) VALUES (?, ?, ?, ?, ?, ?)')
|
|
285
299
|
.run(linkId, rootId, relPath, device, inode, target);
|
|
286
300
|
},
|
|
287
301
|
updateLink(linkId, { relPath, device, inode, target }) {
|
|
288
|
-
|
|
302
|
+
prepared('UPDATE observer_links SET rel_path = ?, device = ?, inode = ?, target = ? WHERE link_id = ?')
|
|
289
303
|
.run(relPath, device, inode, target, linkId);
|
|
290
304
|
},
|
|
291
305
|
removeLink(linkId) {
|
|
292
|
-
|
|
306
|
+
prepared('DELETE FROM observer_links WHERE link_id = ?').run(linkId);
|
|
293
307
|
},
|
|
294
308
|
};
|
|
295
309
|
}
|
|
@@ -124,7 +124,15 @@ function parseSnapshot(bytes) {
|
|
|
124
124
|
* no watcher imports: a cloud record may be known on this machine long before
|
|
125
125
|
* `amalgm add <uuid>` chooses and validates local ground for it.
|
|
126
126
|
*/
|
|
127
|
-
function createEntityCloud({
|
|
127
|
+
function createEntityCloud({
|
|
128
|
+
db,
|
|
129
|
+
userId,
|
|
130
|
+
deviceId,
|
|
131
|
+
content = null,
|
|
132
|
+
repoStates = null,
|
|
133
|
+
repoTransportFor = null,
|
|
134
|
+
recordAt = null,
|
|
135
|
+
}) {
|
|
128
136
|
if (!db) throw new Error('createEntityCloud requires an identity database');
|
|
129
137
|
const ownerUserId = typeof userId === 'string' && userId ? userId : null;
|
|
130
138
|
const localDeviceId = typeof deviceId === 'string' && deviceId ? deviceId : null;
|
|
@@ -155,17 +163,62 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
155
163
|
return snapshotFromRecords(records.map(cloudRecord));
|
|
156
164
|
}
|
|
157
165
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Select bytes by the root representation that reconstructs them. A
|
|
168
|
+
* repository's Repocard transport contains its worktree, so child file rows
|
|
169
|
+
* retain their UUID/version facts but never create duplicate cloud cargo.
|
|
170
|
+
* Ordinary workspace files still carry their own content, and a nested
|
|
171
|
+
* repository carries its own transport.
|
|
172
|
+
*/
|
|
173
|
+
function contentHeads(records, { fallbackRecordAt = recordAt } = {}) {
|
|
174
|
+
const normalized = (records || []).map(cloudRecord);
|
|
175
|
+
const supplied = new Map(normalized.map((record) => [record.uuid, record]));
|
|
176
|
+
const lookup = (uuid) => {
|
|
177
|
+
const known = supplied.get(uuid);
|
|
178
|
+
if (known) return known;
|
|
179
|
+
const fallback = typeof fallbackRecordAt === 'function' ? fallbackRecordAt(uuid) : null;
|
|
180
|
+
return fallback ? cloudRecord(fallback) : null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function enclosedByRepository(record) {
|
|
184
|
+
const seen = new Set([record.uuid]);
|
|
185
|
+
let parentUUID = record.parentUUID;
|
|
186
|
+
while (parentUUID !== null) {
|
|
187
|
+
if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
|
|
188
|
+
seen.add(parentUUID);
|
|
189
|
+
const parent = lookup(parentUUID);
|
|
190
|
+
// Missing ancestry retains content rather than risking an ordinary
|
|
191
|
+
// file whose only bytes were silently skipped.
|
|
192
|
+
if (!parent) return false;
|
|
193
|
+
if (parent.type === 'repo.git') return true;
|
|
194
|
+
parentUUID = parent.parentUUID;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const heads = [];
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
for (const record of normalized) {
|
|
202
|
+
const contentHash = contentHead(record);
|
|
203
|
+
if (!contentHash) continue;
|
|
204
|
+
if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepository(record)) continue;
|
|
205
|
+
if (seen.has(contentHash)) continue;
|
|
206
|
+
seen.add(contentHash);
|
|
207
|
+
heads.push({ record, contentHash });
|
|
164
208
|
}
|
|
165
|
-
|
|
166
|
-
|
|
209
|
+
return heads;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function queueContentHeads(records, resourceId, { queue = false, fallbackRecordAt = recordAt } = {}) {
|
|
213
|
+
for (const { contentHash } of contentHeads(records, { fallbackRecordAt })) {
|
|
214
|
+
if (!content || typeof content.has !== 'function') {
|
|
215
|
+
throw new Error('file entity content is unavailable: registration must capture bytes before it records their cloud head');
|
|
216
|
+
}
|
|
217
|
+
if (!content.has({ contentHash })) {
|
|
218
|
+
throw new Error('file entity bytes were not captured before its cloud head committed');
|
|
219
|
+
}
|
|
220
|
+
if (queue) content.ensureUpload({ resourceId, contentHash });
|
|
167
221
|
}
|
|
168
|
-
if (queue) content.ensureUpload({ resourceId, contentHash: head });
|
|
169
222
|
}
|
|
170
223
|
|
|
171
224
|
function replica() {
|
|
@@ -383,7 +436,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
383
436
|
// content drain creates the filesystem upload cursor; doing that here
|
|
384
437
|
// would let a rolled-back identity write upload unreachable bytes.
|
|
385
438
|
const cloud = cloudRecord(record);
|
|
386
|
-
|
|
439
|
+
queueContentHeads([cloud], current.resourceId);
|
|
387
440
|
upsertCatalogRecord({
|
|
388
441
|
resourceId: current.resourceId,
|
|
389
442
|
authorityEpoch: current.authorityEpoch,
|
|
@@ -423,7 +476,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
423
476
|
if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
|
|
424
477
|
throw new Error(`registered entity graph is ${bytes} bytes; the atomic cloud registration limit is ${SNAPSHOT_REPLACE_MAX_BYTES} bytes`);
|
|
425
478
|
}
|
|
426
|
-
|
|
479
|
+
queueContentHeads(snapshot.records, current.resourceId, { queue: true });
|
|
427
480
|
const createdAt = now();
|
|
428
481
|
db.prepare(`
|
|
429
482
|
INSERT INTO entity_cloud_mutations (
|
|
@@ -448,8 +501,9 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
448
501
|
}
|
|
449
502
|
const insert = db.prepare('INSERT INTO entity_cloud_mutations (resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at) VALUES (?, ?, ?, \'pending\', ?, ?)');
|
|
450
503
|
const createdAt = now();
|
|
451
|
-
|
|
452
|
-
|
|
504
|
+
const snapshot = cloudSnapshot(records);
|
|
505
|
+
queueContentHeads(snapshot.records, current.resourceId, { queue: true });
|
|
506
|
+
for (const record of snapshot.records) {
|
|
453
507
|
insert.run(
|
|
454
508
|
current.resourceId,
|
|
455
509
|
'ent_' + crypto.randomBytes(18).toString('base64url'),
|
|
@@ -632,7 +686,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
632
686
|
// upload. A joining device must NOT queue remote bytes as uploads; it
|
|
633
687
|
// downloads them later through a materialization job instead.
|
|
634
688
|
if (bootstrapping) {
|
|
635
|
-
|
|
689
|
+
queueContentHeads(snapshot.records, resourceId, { queue: true });
|
|
636
690
|
}
|
|
637
691
|
replaceCatalog({
|
|
638
692
|
resourceId,
|
|
@@ -706,15 +760,13 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
706
760
|
for (const job of pendingMaterializations().filter((entry) => entry.state === 'waiting_for_content')) {
|
|
707
761
|
if (job.resourceId !== current.resourceId) continue;
|
|
708
762
|
const tree = catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
if (!head) continue;
|
|
713
|
-
const part = content.missingPart({ contentHash: head });
|
|
763
|
+
const active = tree.filter((record) => record.status === 'active');
|
|
764
|
+
for (const { contentHash } of contentHeads(active)) {
|
|
765
|
+
const part = content.missingPart({ contentHash });
|
|
714
766
|
if (!part) continue;
|
|
715
767
|
return {
|
|
716
768
|
resourceId: job.resourceId,
|
|
717
|
-
contentHash
|
|
769
|
+
contentHash,
|
|
718
770
|
kind: part.kind,
|
|
719
771
|
partIndex: part.partIndex,
|
|
720
772
|
sha256: part.sha256,
|
|
@@ -882,10 +934,9 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
882
934
|
const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
|
|
883
935
|
? operation?.snapshot?.records
|
|
884
936
|
: [operation?.record];
|
|
885
|
-
if ((records
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
})) {
|
|
937
|
+
if (contentHeads(records).some(({ contentHash }) => (
|
|
938
|
+
!content || !content.uploaded({ resourceId: current.resourceId, contentHash })
|
|
939
|
+
))) {
|
|
889
940
|
return [];
|
|
890
941
|
}
|
|
891
942
|
claimRow.run(current.headVersion, leaseUntil, started, row.sequence);
|
|
@@ -923,7 +974,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
923
974
|
const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
|
|
924
975
|
? operation?.snapshot?.records
|
|
925
976
|
: [operation?.record];
|
|
926
|
-
|
|
977
|
+
queueContentHeads(records || [], current.resourceId, { queue: true });
|
|
927
978
|
contentQueuedSequences.add(row.sequence);
|
|
928
979
|
}
|
|
929
980
|
return content.claim({ resourceId: current.resourceId, leaseMs });
|
|
@@ -1136,6 +1187,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
1136
1187
|
status,
|
|
1137
1188
|
hasUnsettledLocalMutation,
|
|
1138
1189
|
catalogTree,
|
|
1190
|
+
contentHeads,
|
|
1139
1191
|
replica,
|
|
1140
1192
|
};
|
|
1141
1193
|
}
|
|
@@ -86,6 +86,17 @@ function createRegistrationService({
|
|
|
86
86
|
|
|
87
87
|
const evidenceDb = new Database(path.join(dir, 'observer.db'));
|
|
88
88
|
const identityDb = new Database(path.join(dir, 'identity.db'));
|
|
89
|
+
// WAL, decided here because this root OWNS the handles: SQLite's default
|
|
90
|
+
// journal mode pays a create-fsync-delete journal cycle on every commit,
|
|
91
|
+
// which made a large registration ~65% commit ceremony. WAL appends to
|
|
92
|
+
// one log instead, and synchronous=NORMAL keeps every commit durable
|
|
93
|
+
// against process crash (an OS crash can lose the tail, never corrupt —
|
|
94
|
+
// detection re-derives evidence, and identity commits are re-walked by
|
|
95
|
+
// the registration that was still running).
|
|
96
|
+
for (const handle of [evidenceDb, identityDb]) {
|
|
97
|
+
handle.pragma('journal_mode = WAL');
|
|
98
|
+
handle.pragma('synchronous = NORMAL');
|
|
99
|
+
}
|
|
89
100
|
const entityContent = createEntityContent({ dir });
|
|
90
101
|
// createRegistration owns the schema migration. Give entity-cloud a stable
|
|
91
102
|
// lookup facade now, then connect it to that one migrated state store once
|
|
@@ -106,6 +117,10 @@ function createRegistrationService({
|
|
|
106
117
|
return localState(captured, artifact.contentHash);
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
// Entity-cloud needs only logical ancestry to choose a content plan. It
|
|
121
|
+
// never owns a watcher or address; this facade is available after the
|
|
122
|
+
// registration boundary is constructed below.
|
|
123
|
+
const boundary = { current: null };
|
|
109
124
|
const entityCloud = createEntityCloud({
|
|
110
125
|
db: identityDb,
|
|
111
126
|
userId: cloudUserId,
|
|
@@ -113,11 +128,13 @@ function createRegistrationService({
|
|
|
113
128
|
content: entityContent,
|
|
114
129
|
repoStates: repoStateLookup,
|
|
115
130
|
repoTransportFor: (record) => repoTransportHeads.get(record.payloadVersion) || null,
|
|
131
|
+
recordAt: (uuid) => {
|
|
132
|
+
try { return boundary.current?.registry.syncRecord(uuid) || null; } catch { return null; }
|
|
133
|
+
},
|
|
116
134
|
});
|
|
117
135
|
|
|
118
136
|
// The observer emits before the boundary exists (its constructor
|
|
119
137
|
// scans), so the wire reads through a box that the boundary fills.
|
|
120
|
-
const boundary = { current: null };
|
|
121
138
|
const observer = createObserver({
|
|
122
139
|
db: evidenceDb,
|
|
123
140
|
emit: (event) => boundary.current?.onEvent(event),
|
|
@@ -474,15 +491,8 @@ function createRegistrationService({
|
|
|
474
491
|
return state;
|
|
475
492
|
}
|
|
476
493
|
|
|
477
|
-
for (const
|
|
478
|
-
if (
|
|
479
|
-
&& !entityContent.complete({ contentHash: record.payloadVersion })) {
|
|
480
|
-
return false;
|
|
481
|
-
}
|
|
482
|
-
if (record.type === 'repo.git'
|
|
483
|
-
&& (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) {
|
|
484
|
-
return false;
|
|
485
|
-
}
|
|
494
|
+
for (const { contentHash } of entityCloud.contentHeads(active)) {
|
|
495
|
+
if (!entityContent.complete({ contentHash })) return false;
|
|
486
496
|
}
|
|
487
497
|
if (!['workspace', 'repo.git', 'file.text', 'file.binary', 'link'].includes(root.type)) {
|
|
488
498
|
throw new Error(`cloud root ${root.uuid} is ${root.type}; this materializer supports workspace, repository, file, and link trees only`);
|
|
@@ -579,11 +589,8 @@ function createRegistrationService({
|
|
|
579
589
|
if (!['workspace', 'repo.git', 'file.text', 'file.binary', 'link'].includes(root.type)) {
|
|
580
590
|
throw new Error(`cloud root ${root.uuid} is ${root.type}; this live materializer supports workspace, repository, file, and link trees only`);
|
|
581
591
|
}
|
|
582
|
-
for (const
|
|
583
|
-
if (
|
|
584
|
-
&& !entityContent.complete({ contentHash: record.payloadVersion })) return false;
|
|
585
|
-
if (record.type === 'repo.git'
|
|
586
|
-
&& (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) return false;
|
|
592
|
+
for (const { contentHash } of entityCloud.contentHeads(active)) {
|
|
593
|
+
if (!entityContent.complete({ contentHash })) return false;
|
|
587
594
|
}
|
|
588
595
|
|
|
589
596
|
const parent = fs.realpathSync(job.targetPath);
|
|
@@ -877,7 +884,13 @@ function createRegistrationService({
|
|
|
877
884
|
/** `amalgm register <path>` — register, then begin Watch + Detect. */
|
|
878
885
|
register: async (targetPath) => {
|
|
879
886
|
entityCloud.assertRegistrationReady();
|
|
880
|
-
|
|
887
|
+
// No active cloud replica means no cloud is listening — the journal
|
|
888
|
+
// itself already no-ops there, so a large tree takes the same plain
|
|
889
|
+
// local registration a small one does instead of demanding an atomic
|
|
890
|
+
// replacement that has nowhere to land. Size never changes whether a
|
|
891
|
+
// signed-out registration is allowed.
|
|
892
|
+
const cloudActive = entityCloud.replica()?.state === 'active';
|
|
893
|
+
if (!cloudActive || estimatedRegistrationRecords(targetPath) < ATOMIC_REGISTRATION_THRESHOLD) {
|
|
881
894
|
return boundary.current.add(targetPath);
|
|
882
895
|
}
|
|
883
896
|
const result = boundary.current.registry.withoutJournal(() => boundary.current.add(targetPath));
|
|
@@ -890,9 +903,10 @@ function createRegistrationService({
|
|
|
890
903
|
* materialize. Small selections retain their ordinary per-entity intent. */
|
|
891
904
|
registerMany: async (targetPaths) => {
|
|
892
905
|
entityCloud.assertRegistrationReady();
|
|
893
|
-
const needsAtomicReplacement =
|
|
894
|
-
|
|
895
|
-
|
|
906
|
+
const needsAtomicReplacement = entityCloud.replica()?.state === 'active'
|
|
907
|
+
&& targetPaths.some((targetPath) => (
|
|
908
|
+
estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
|
|
909
|
+
));
|
|
896
910
|
if (!needsAtomicReplacement) return boundary.current.addMany(targetPaths);
|
|
897
911
|
const results = boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
|
|
898
912
|
entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
|
|
@@ -103,6 +103,8 @@
|
|
|
103
103
|
const fs = require('fs');
|
|
104
104
|
const path = require('path');
|
|
105
105
|
|
|
106
|
+
const { preparer } = require('../lib/prepared');
|
|
107
|
+
|
|
106
108
|
const FILE_TYPES = new Set(['file.text', 'file.binary']);
|
|
107
109
|
const LEAF_TYPES = new Set(['file.text', 'file.binary', 'link']);
|
|
108
110
|
// Adoption candidates by ground shape: a dir channel can be an arrived
|
|
@@ -132,6 +134,7 @@ const MEMORY_TABLES = ['registry_meta', 'channel_bindings', 'contested_ground'];
|
|
|
132
134
|
* Retiring a tree drops its whole scope — bindings and markers together.
|
|
133
135
|
*/
|
|
134
136
|
function createEvidenceMemory(db) {
|
|
137
|
+
const prepared = preparer(db);
|
|
135
138
|
const present = db.prepare(
|
|
136
139
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${MEMORY_TABLES.map(() => '?').join(', ')})`,
|
|
137
140
|
).all(...MEMORY_TABLES).map((row) => row.name);
|
|
@@ -145,30 +148,30 @@ function createEvidenceMemory(db) {
|
|
|
145
148
|
}
|
|
146
149
|
return {
|
|
147
150
|
epoch: () => meta.epoch_uuid,
|
|
148
|
-
bindings: (scopeId) =>
|
|
151
|
+
bindings: (scopeId) => prepared('SELECT channel_id, entity_uuid FROM channel_bindings WHERE scope_id = ?')
|
|
149
152
|
.all(scopeId).map((row) => ({ channelId: row.channel_id, uuid: row.entity_uuid })),
|
|
150
|
-
bind: (scopeId, channelId, uuid) =>
|
|
153
|
+
bind: (scopeId, channelId, uuid) => prepared(
|
|
151
154
|
'INSERT INTO channel_bindings (channel_id, scope_id, entity_uuid) VALUES (?, ?, ?)'
|
|
152
155
|
+ ' ON CONFLICT(channel_id) DO UPDATE SET scope_id = excluded.scope_id, entity_uuid = excluded.entity_uuid',
|
|
153
156
|
).run(channelId, scopeId, uuid),
|
|
154
|
-
unbind: (channelId) =>
|
|
157
|
+
unbind: (channelId) => prepared('DELETE FROM channel_bindings WHERE channel_id = ?').run(channelId),
|
|
155
158
|
/** Across scopes — channel ids are globally unique. */
|
|
156
|
-
boundTo: (channelId) =>
|
|
159
|
+
boundTo: (channelId) => prepared('SELECT entity_uuid FROM channel_bindings WHERE channel_id = ?')
|
|
157
160
|
.get(channelId)?.entity_uuid,
|
|
158
|
-
allBindings: () =>
|
|
161
|
+
allBindings: () => prepared('SELECT channel_id, scope_id, entity_uuid FROM channel_bindings').all()
|
|
159
162
|
.map((row) => ({ channelId: row.channel_id, scopeId: row.scope_id, uuid: row.entity_uuid })),
|
|
160
163
|
/** A retired tree's whole evidence namespace, gone as one act. */
|
|
161
164
|
dropScope: (scopeId) => {
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
prepared('DELETE FROM channel_bindings WHERE scope_id = ?').run(scopeId);
|
|
166
|
+
prepared('DELETE FROM contested_ground WHERE scope_id = ?').run(scopeId);
|
|
164
167
|
},
|
|
165
|
-
contests: (scopeId) =>
|
|
168
|
+
contests: (scopeId) => prepared('SELECT rel_path, claimant_uuid, kind FROM contested_ground WHERE scope_id = ?')
|
|
166
169
|
.all(scopeId).map((row) => ({ relPath: row.rel_path, uuid: row.claimant_uuid, kind: row.kind })),
|
|
167
|
-
contest: (scopeId, relPath, uuid, kind) =>
|
|
170
|
+
contest: (scopeId, relPath, uuid, kind) => prepared(
|
|
168
171
|
'INSERT INTO contested_ground (scope_id, rel_path, claimant_uuid, kind) VALUES (?, ?, ?, ?)'
|
|
169
172
|
+ ' ON CONFLICT(scope_id, rel_path) DO UPDATE SET claimant_uuid = excluded.claimant_uuid, kind = excluded.kind',
|
|
170
173
|
).run(scopeId, relPath, uuid, kind),
|
|
171
|
-
clearContest: (scopeId, relPath) =>
|
|
174
|
+
clearContest: (scopeId, relPath) => prepared('DELETE FROM contested_ground WHERE scope_id = ? AND rel_path = ?')
|
|
172
175
|
.run(scopeId, relPath),
|
|
173
176
|
};
|
|
174
177
|
}
|
|
@@ -359,6 +362,27 @@ function createEvidence(options) {
|
|
|
359
362
|
function reconcile() {
|
|
360
363
|
dirty = false; // an event landing mid-reconcile re-dirties honestly
|
|
361
364
|
try {
|
|
365
|
+
// One reconciliation is ONE durable truth transition: a first census
|
|
366
|
+
// over thousands of channels lands as one commit, not one per record.
|
|
367
|
+
// The registry's own transaction defers every bell to the commit and
|
|
368
|
+
// discards them all on rollback, so a failed pass publishes nothing.
|
|
369
|
+
registry.transaction(pass);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
// The transaction rolled every durable write back; the in-memory
|
|
372
|
+
// caches may still hold entries from the rolled-back half. Durable
|
|
373
|
+
// truth first, cache second — so the caches reload from the tables
|
|
374
|
+
// before the retry, never believing a write that no longer exists.
|
|
375
|
+
channelToUuid.clear();
|
|
376
|
+
for (const row of memory.bindings(rootId)) channelToUuid.set(row.channelId, row.uuid);
|
|
377
|
+
contested.clear();
|
|
378
|
+
for (const row of memory.contests(rootId)) contested.set(row.relPath, { uuid: row.uuid, kind: row.kind });
|
|
379
|
+
dirty = true;
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function pass() {
|
|
385
|
+
{
|
|
362
386
|
const rootRow = observer.listRoots().find((row) => row.rootId === rootId);
|
|
363
387
|
if (!rootRow) return; // not enrolled yet — nothing is claimed
|
|
364
388
|
groundPath = rootRow.path; // the address is derived, never the lens's identity
|
|
@@ -771,9 +795,6 @@ function createEvidence(options) {
|
|
|
771
795
|
: null);
|
|
772
796
|
}
|
|
773
797
|
for (const [relPath, hash] of nowDivergent) divergent.set(relPath, hash);
|
|
774
|
-
} catch (error) {
|
|
775
|
-
dirty = true;
|
|
776
|
-
throw error;
|
|
777
798
|
}
|
|
778
799
|
}
|
|
779
800
|
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
* (address), never by name, so two trees may share a basename.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
const { preparer } = require('../lib/prepared');
|
|
30
|
+
|
|
29
31
|
const SCHEMA = `
|
|
30
32
|
CREATE TABLE IF NOT EXISTS registry_meta (
|
|
31
33
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
@@ -246,56 +248,58 @@ function createStore(db) {
|
|
|
246
248
|
db.exec(SCHEMA);
|
|
247
249
|
db.pragma(`user_version = ${GENERATION}`);
|
|
248
250
|
|
|
251
|
+
const prepared = preparer(db);
|
|
252
|
+
|
|
249
253
|
return {
|
|
250
254
|
meta() {
|
|
251
|
-
const row =
|
|
255
|
+
const row = prepared('SELECT * FROM registry_meta WHERE singleton = 1').get();
|
|
252
256
|
return row ? { epochUuid: row.epoch_uuid, createdAtMs: row.created_at_ms } : null;
|
|
253
257
|
},
|
|
254
258
|
initMeta({ epochUuid, createdAtMs }) {
|
|
255
|
-
|
|
259
|
+
prepared('INSERT INTO registry_meta (singleton, epoch_uuid, created_at_ms) VALUES (1, ?, ?)')
|
|
256
260
|
.run(epochUuid, createdAtMs);
|
|
257
261
|
},
|
|
258
262
|
|
|
259
263
|
byUuid(uuid) {
|
|
260
|
-
return rowToEntity(
|
|
264
|
+
return rowToEntity(prepared('SELECT * FROM entities WHERE uuid = ?').get(uuid));
|
|
261
265
|
},
|
|
262
266
|
/** Every registered tree's top record: the active parentless rows. */
|
|
263
267
|
activeRoots() {
|
|
264
|
-
return
|
|
268
|
+
return prepared("SELECT * FROM entities WHERE parent_uuid IS NULL AND status = 'active' ORDER BY uuid")
|
|
265
269
|
.all().map(rowToEntity);
|
|
266
270
|
},
|
|
267
271
|
activeAtSlot(parentUuid, name) {
|
|
268
272
|
return rowToEntity(
|
|
269
|
-
|
|
273
|
+
prepared("SELECT * FROM entities WHERE parent_uuid IS ? AND name = ? AND status = 'active'")
|
|
270
274
|
.get(parentUuid, name),
|
|
271
275
|
);
|
|
272
276
|
},
|
|
273
277
|
childrenOf(parentUuid) {
|
|
274
|
-
return
|
|
278
|
+
return prepared('SELECT * FROM entities WHERE parent_uuid = ? ORDER BY uuid').all(parentUuid)
|
|
275
279
|
.map(rowToEntity);
|
|
276
280
|
},
|
|
277
281
|
activeChildrenOf(parentUuid) {
|
|
278
|
-
return
|
|
282
|
+
return prepared("SELECT * FROM entities WHERE parent_uuid = ? AND status = 'active' ORDER BY uuid")
|
|
279
283
|
.all(parentUuid).map(rowToEntity);
|
|
280
284
|
},
|
|
281
285
|
activeByType(type) {
|
|
282
|
-
return
|
|
286
|
+
return prepared("SELECT * FROM entities WHERE type = ? AND status = 'active' ORDER BY uuid")
|
|
283
287
|
.all(type).map(rowToEntity);
|
|
284
288
|
},
|
|
285
289
|
/** Logical graph rows for the cloud registry. This deliberately excludes
|
|
286
290
|
* observer bindings and every machine-local table. */
|
|
287
291
|
allEntities() {
|
|
288
|
-
return
|
|
292
|
+
return prepared('SELECT * FROM entities ORDER BY uuid').all().map(rowToEntity);
|
|
289
293
|
},
|
|
290
294
|
|
|
291
295
|
insert(entity) {
|
|
292
|
-
|
|
296
|
+
prepared(`
|
|
293
297
|
INSERT INTO entities (uuid, type, parent_uuid, name, status, payload_version)
|
|
294
298
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
295
299
|
`).run(entity.uuid, entity.type, entity.parentUuid, entity.name, entity.status, entity.payloadVersion);
|
|
296
300
|
},
|
|
297
301
|
update(uuid, { type, parentUuid, name, status, payloadVersion }) {
|
|
298
|
-
|
|
302
|
+
prepared(`
|
|
299
303
|
UPDATE entities SET type = ?, parent_uuid = ?, name = ?, status = ?, payload_version = ?
|
|
300
304
|
WHERE uuid = ?
|
|
301
305
|
`).run(type, parentUuid, name, status, payloadVersion, uuid);
|
|
@@ -313,17 +317,17 @@ function createStore(db) {
|
|
|
313
317
|
*/
|
|
314
318
|
rebindIdentities(mappings) {
|
|
315
319
|
const retired = mappings.filter((mapping) => mapping.oldUuid !== mapping.newUuid);
|
|
316
|
-
const retire =
|
|
317
|
-
const insert =
|
|
320
|
+
const retire = prepared("UPDATE entities SET status = 'trashed' WHERE uuid = ?");
|
|
321
|
+
const insert = prepared(`
|
|
318
322
|
INSERT INTO entities (uuid, type, parent_uuid, name, status, payload_version)
|
|
319
323
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
320
324
|
`);
|
|
321
|
-
const reparent =
|
|
322
|
-
const rebindChannels =
|
|
323
|
-
const rebindClaims =
|
|
324
|
-
const rebindRepoState =
|
|
325
|
-
const rebindReference =
|
|
326
|
-
const remove =
|
|
325
|
+
const reparent = prepared('UPDATE entities SET parent_uuid = ? WHERE parent_uuid = ?');
|
|
326
|
+
const rebindChannels = prepared('UPDATE channel_bindings SET entity_uuid = ? WHERE entity_uuid = ?');
|
|
327
|
+
const rebindClaims = prepared('UPDATE contested_ground SET claimant_uuid = ? WHERE claimant_uuid = ?');
|
|
328
|
+
const rebindRepoState = prepared('UPDATE repo_states SET entity_uuid = ? WHERE entity_uuid = ?');
|
|
329
|
+
const rebindReference = prepared("UPDATE entities SET payload_version = ? WHERE type = 'reference' AND payload_version = ?");
|
|
330
|
+
const remove = prepared('DELETE FROM entities WHERE uuid = ?');
|
|
327
331
|
|
|
328
332
|
db.transaction(() => {
|
|
329
333
|
for (const mapping of retired) retire.run(mapping.oldUuid);
|
|
@@ -365,10 +369,10 @@ function createStore(db) {
|
|
|
365
369
|
* for an exchange. One transaction: the in-between never exists.
|
|
366
370
|
*/
|
|
367
371
|
applyMoves(moves) {
|
|
368
|
-
const toTemp =
|
|
372
|
+
const toTemp = prepared('UPDATE entities SET name = ? WHERE uuid = ?');
|
|
369
373
|
// An optional type rides the same transition (adoption lands a
|
|
370
374
|
// workspace as a folder) — a separate write could tear.
|
|
371
|
-
const toFinal =
|
|
375
|
+
const toFinal = prepared('UPDATE entities SET parent_uuid = ?, name = ?, type = COALESCE(?, type) WHERE uuid = ?');
|
|
372
376
|
db.transaction(() => {
|
|
373
377
|
for (const move of moves) toTemp.run(`\0${move.uuid}`, move.uuid);
|
|
374
378
|
for (const move of moves) toFinal.run(move.parentUuid, move.name, move.type ?? null, move.uuid);
|
|
@@ -376,7 +380,7 @@ function createStore(db) {
|
|
|
376
380
|
},
|
|
377
381
|
|
|
378
382
|
cloudReplica() {
|
|
379
|
-
const row =
|
|
383
|
+
const row = prepared('SELECT * FROM entity_cloud_replica WHERE singleton = 1').get();
|
|
380
384
|
if (!row) return null;
|
|
381
385
|
return {
|
|
382
386
|
resourceId: row.resource_id,
|
|
@@ -389,7 +393,7 @@ function createStore(db) {
|
|
|
389
393
|
};
|
|
390
394
|
},
|
|
391
395
|
saveCloudReplica(replica) {
|
|
392
|
-
|
|
396
|
+
prepared(`
|
|
393
397
|
INSERT INTO entity_cloud_replica (
|
|
394
398
|
singleton, resource_id, device_id, authority_epoch, head_version,
|
|
395
399
|
state, snapshot_cutoff, updated_at
|
|
@@ -413,11 +417,11 @@ function createStore(db) {
|
|
|
413
417
|
);
|
|
414
418
|
},
|
|
415
419
|
cloudMutationCutoff() {
|
|
416
|
-
const row =
|
|
420
|
+
const row = prepared('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM entity_cloud_mutations').get();
|
|
417
421
|
return Number(row.sequence);
|
|
418
422
|
},
|
|
419
423
|
enqueueCloudMutation(mutation) {
|
|
420
|
-
|
|
424
|
+
prepared(`
|
|
421
425
|
INSERT INTO entity_cloud_mutations (
|
|
422
426
|
resource_id, mutation_id, operation_json, mutation_state,
|
|
423
427
|
created_at, updated_at
|
|
@@ -431,7 +435,7 @@ function createStore(db) {
|
|
|
431
435
|
);
|
|
432
436
|
},
|
|
433
437
|
listCloudMutations() {
|
|
434
|
-
return
|
|
438
|
+
return prepared('SELECT * FROM entity_cloud_mutations ORDER BY sequence').all();
|
|
435
439
|
},
|
|
436
440
|
};
|
|
437
441
|
}
|
|
@@ -369,20 +369,35 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
369
369
|
snapshotChecksum: sha256(snapshotBytes),
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
372
|
+
const planned = source.entityCloud.contentHeads(records);
|
|
373
|
+
assert.deepEqual(
|
|
374
|
+
planned.map(({ contentHash }) => contentHash),
|
|
375
|
+
[repoCloudRecord.transportVersion],
|
|
376
|
+
'a repository register uploads its one Repocard transport, never duplicate child file bytes',
|
|
377
|
+
);
|
|
378
|
+
const uploaded = [];
|
|
379
|
+
while (true) {
|
|
380
|
+
const part = source.entityCloud.claimContent();
|
|
381
|
+
if (!part) break;
|
|
382
|
+
uploaded.push(part);
|
|
383
|
+
source.entityCloud.acknowledgeContent({
|
|
384
|
+
resourceId: bootstrap.resourceId,
|
|
385
|
+
contentHash: part.contentHash,
|
|
386
|
+
kind: part.kind,
|
|
387
|
+
partIndex: part.partIndex,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
assert.deepEqual(
|
|
391
|
+
[...new Set(uploaded.map((part) => part.contentHash))],
|
|
392
|
+
[repoCloudRecord.transportVersion],
|
|
393
|
+
'the source content pump sends only the repository transport',
|
|
394
|
+
);
|
|
395
|
+
for (const part of [...uploaded.filter((entry) => entry.kind === 'manifest'), ...uploaded.filter((entry) => entry.kind === 'chunk')]) {
|
|
396
|
+
target.receiveCloudContent({
|
|
397
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash, kind: part.kind,
|
|
398
|
+
partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
|
|
399
|
+
sha256: part.sha256, dataBase64: part.dataBase64,
|
|
400
|
+
});
|
|
386
401
|
}
|
|
387
402
|
|
|
388
403
|
const job = await target.add({ uuid: registered.record.uuid, targetPath: targetParent });
|
|
@@ -544,6 +544,27 @@ test('register registers the named ground and every file beneath it', async () =
|
|
|
544
544
|
assert.equal(nested[0].type, 'file.binary', 'and it distinguishes bytes from text');
|
|
545
545
|
});
|
|
546
546
|
|
|
547
|
+
test('a signed-out installation registers a large tree the same way it registers a small one', async () => {
|
|
548
|
+
// Size routes a SIGNED-IN registration to the atomic cloud replacement;
|
|
549
|
+
// it must never decide whether a signed-out one is allowed at all. The
|
|
550
|
+
// journal already no-ops without an active replica, so a large tree takes
|
|
551
|
+
// the same plain local registration a small tree does.
|
|
552
|
+
const service = makeService('add-large-signed-out');
|
|
553
|
+
const ground = makeGround('add-large-signed-out');
|
|
554
|
+
for (let dir = 0; dir < 30; dir++) {
|
|
555
|
+
const nested = path.join(ground, `dir${dir}`);
|
|
556
|
+
fs.mkdirSync(nested);
|
|
557
|
+
for (let file = 0; file < 20; file++) {
|
|
558
|
+
fs.writeFileSync(path.join(nested, `f${file}.md`), `${dir}/${file}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const { record } = await service.register(ground);
|
|
563
|
+
assert.ok(record.uuid, 'the large tree registered without demanding a cloud handshake');
|
|
564
|
+
const registry = service.registration.tree(ground).registry;
|
|
565
|
+
assert.equal(registry.children(record.uuid).length, 30, 'every directory is registered');
|
|
566
|
+
});
|
|
567
|
+
|
|
547
568
|
test('registerMany registers every selected file or folder through the one registration boundary', async () => {
|
|
548
569
|
const service = makeService('add-many');
|
|
549
570
|
const folder = makeGround('add-many-folder');
|