amalgm 0.1.261 → 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/service.js +22 -4
- 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/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
|
}
|
|
@@ -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
|
|
@@ -873,7 +884,13 @@ function createRegistrationService({
|
|
|
873
884
|
/** `amalgm register <path>` — register, then begin Watch + Detect. */
|
|
874
885
|
register: async (targetPath) => {
|
|
875
886
|
entityCloud.assertRegistrationReady();
|
|
876
|
-
|
|
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) {
|
|
877
894
|
return boundary.current.add(targetPath);
|
|
878
895
|
}
|
|
879
896
|
const result = boundary.current.registry.withoutJournal(() => boundary.current.add(targetPath));
|
|
@@ -886,9 +903,10 @@ function createRegistrationService({
|
|
|
886
903
|
* materialize. Small selections retain their ordinary per-entity intent. */
|
|
887
904
|
registerMany: async (targetPaths) => {
|
|
888
905
|
entityCloud.assertRegistrationReady();
|
|
889
|
-
const needsAtomicReplacement =
|
|
890
|
-
|
|
891
|
-
|
|
906
|
+
const needsAtomicReplacement = entityCloud.replica()?.state === 'active'
|
|
907
|
+
&& targetPaths.some((targetPath) => (
|
|
908
|
+
estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
|
|
909
|
+
));
|
|
892
910
|
if (!needsAtomicReplacement) return boundary.current.addMany(targetPaths);
|
|
893
911
|
const results = boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
|
|
894
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
|
}
|
|
@@ -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');
|