amalgm 0.1.245 → 0.1.246
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The registry's durable memory: ONE identity database per installation.
|
|
5
|
+
* Every registered tree lives here as one active parentless record with
|
|
6
|
+
* its descendants beneath it — identity is never partitioned by tree, so
|
|
7
|
+
* an entity crossing tree boundaries is a parent update in one
|
|
8
|
+
* transaction, never an export between databases. A tree is a SYNC
|
|
9
|
+
* boundary (its own head and rail, future Send work), not an identity
|
|
10
|
+
* boundary. The database handle is injected — this module never opens a
|
|
11
|
+
* connection itself. The schema also creates the evidence layer's two
|
|
12
|
+
* tables (registry/evidence.js), so all four are born together and ONE
|
|
13
|
+
* inspect and one sentinel guard records and evidence alike — but
|
|
14
|
+
* creation is the whole relationship: the registry never reads or writes
|
|
15
|
+
* them.
|
|
16
|
+
*
|
|
17
|
+
* Unlike the observer's store, this is IDENTITY DATA, not a rebuildable
|
|
18
|
+
* cache: nothing on disk carries UUIDs, so a lost registry cannot be
|
|
19
|
+
* reconstructed locally. `inspect` exists so the owner (index.js) can tell
|
|
20
|
+
* first initialization apart from loss before any schema is created.
|
|
21
|
+
*
|
|
22
|
+
* One invariant lives in the schema itself, so the database convicts even
|
|
23
|
+
* if application code fails to: one active owner per (parent_uuid, name)
|
|
24
|
+
* slot. Parentless records are exempt by SQLite's own NULL-distinctness —
|
|
25
|
+
* deliberately: a top-level tree is placed by its machine binding
|
|
26
|
+
* (address), never by name, so two trees may share a basename.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const SCHEMA = `
|
|
30
|
+
CREATE TABLE IF NOT EXISTS registry_meta (
|
|
31
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
32
|
+
epoch_uuid TEXT NOT NULL,
|
|
33
|
+
created_at_ms INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
36
|
+
uuid TEXT PRIMARY KEY,
|
|
37
|
+
type TEXT NOT NULL,
|
|
38
|
+
parent_uuid TEXT REFERENCES entities(uuid),
|
|
39
|
+
name TEXT NOT NULL,
|
|
40
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'deleted')),
|
|
41
|
+
payload_version TEXT
|
|
42
|
+
);
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS entities_slot
|
|
44
|
+
ON entities (parent_uuid, name) WHERE status = 'active';
|
|
45
|
+
CREATE INDEX IF NOT EXISTS entities_parent ON entities (parent_uuid);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS channel_bindings (
|
|
47
|
+
channel_id TEXT PRIMARY KEY,
|
|
48
|
+
scope_id TEXT NOT NULL,
|
|
49
|
+
entity_uuid TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS channel_bindings_scope ON channel_bindings (scope_id);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS contested_ground (
|
|
53
|
+
scope_id TEXT NOT NULL,
|
|
54
|
+
rel_path TEXT NOT NULL,
|
|
55
|
+
claimant_uuid TEXT NOT NULL,
|
|
56
|
+
kind TEXT NOT NULL CHECK (kind IN ('file', 'dir')),
|
|
57
|
+
PRIMARY KEY (scope_id, rel_path)
|
|
58
|
+
);
|
|
59
|
+
CREATE TABLE IF NOT EXISTS repo_states (
|
|
60
|
+
entity_uuid TEXT PRIMARY KEY REFERENCES entities(uuid),
|
|
61
|
+
state_id TEXT NOT NULL,
|
|
62
|
+
card_id TEXT NOT NULL,
|
|
63
|
+
checkpoint_id TEXT NOT NULL,
|
|
64
|
+
state_json TEXT NOT NULL
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS entity_cloud_replica (
|
|
67
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
68
|
+
resource_id TEXT NOT NULL,
|
|
69
|
+
device_id TEXT NOT NULL,
|
|
70
|
+
authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1),
|
|
71
|
+
head_version INTEGER NOT NULL CHECK (head_version >= 0),
|
|
72
|
+
state TEXT NOT NULL CHECK (state IN ('active', 'bootstrapping')),
|
|
73
|
+
snapshot_cutoff INTEGER,
|
|
74
|
+
updated_at TEXT NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE TABLE IF NOT EXISTS entity_cloud_mutations (
|
|
77
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
78
|
+
resource_id TEXT NOT NULL,
|
|
79
|
+
mutation_id TEXT NOT NULL UNIQUE,
|
|
80
|
+
operation_json TEXT NOT NULL,
|
|
81
|
+
mutation_state TEXT NOT NULL CHECK (mutation_state IN ('pending', 'syncing', 'synced', 'rejected')),
|
|
82
|
+
base_version INTEGER,
|
|
83
|
+
official_version INTEGER,
|
|
84
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
85
|
+
next_attempt_at TEXT,
|
|
86
|
+
last_error TEXT,
|
|
87
|
+
cloud_committed_at TEXT,
|
|
88
|
+
created_at TEXT NOT NULL,
|
|
89
|
+
updated_at TEXT NOT NULL
|
|
90
|
+
);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS entity_cloud_mutations_pending
|
|
92
|
+
ON entity_cloud_mutations (resource_id, mutation_state, sequence);
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
const CORE_TABLES = ['registry_meta', 'entities', 'channel_bindings', 'contested_ground'];
|
|
96
|
+
const REPO_STATE_TABLES = [...CORE_TABLES, 'repo_states'];
|
|
97
|
+
const TABLES = [...REPO_STATE_TABLES, 'entity_cloud_replica', 'entity_cloud_mutations'];
|
|
98
|
+
|
|
99
|
+
// The schema generation this code speaks, stamped as PRAGMA user_version at
|
|
100
|
+
// creation — completeness is judged against a declared shape, never
|
|
101
|
+
// guessed. Generation 3 adds the current Card + Checkpoint document for
|
|
102
|
+
// each repo.git entity. Generation 4 adds the entity-cloud replica and its
|
|
103
|
+
// durable outbox to that SAME identity database: a local entity mutation and
|
|
104
|
+
// the cloud delivery intent must commit together. Generations 2 and 3 are
|
|
105
|
+
// additive predecessors: migrations only add tables beside existing identity
|
|
106
|
+
// records, never rewrite UUIDs or evidence bindings. The per-tree era never
|
|
107
|
+
// stamped a version and reads 0 — an older generation, refused distinctly
|
|
108
|
+
// from loss until an explicit migration exists.
|
|
109
|
+
const GENERATION = 4;
|
|
110
|
+
const REPO_STATE_GENERATION = 3;
|
|
111
|
+
const PREVIOUS_GENERATION = 2;
|
|
112
|
+
|
|
113
|
+
function rowToEntity(row) {
|
|
114
|
+
if (!row) return null;
|
|
115
|
+
return {
|
|
116
|
+
uuid: row.uuid,
|
|
117
|
+
type: row.type,
|
|
118
|
+
parentUuid: row.parent_uuid,
|
|
119
|
+
name: row.name,
|
|
120
|
+
status: row.status,
|
|
121
|
+
payloadVersion: row.payload_version,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Which registry tables already exist — asked BEFORE any schema runs. */
|
|
126
|
+
function inspect(db) {
|
|
127
|
+
const present = db.prepare(
|
|
128
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${TABLES.map(() => '?').join(', ')})`,
|
|
129
|
+
).all(...TABLES).map((row) => row.name);
|
|
130
|
+
return { fresh: present.length === 0, missing: TABLES.filter((name) => !present.includes(name)) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isMigratableGenerationShape(db, inspection) {
|
|
134
|
+
const generation = db.pragma('user_version', { simple: true });
|
|
135
|
+
if (generation === PREVIOUS_GENERATION) {
|
|
136
|
+
return inspection.missing.length === 3
|
|
137
|
+
&& ['repo_states', 'entity_cloud_replica', 'entity_cloud_mutations']
|
|
138
|
+
.every((name) => inspection.missing.includes(name))
|
|
139
|
+
&& CORE_TABLES.every((name) => !inspection.missing.includes(name));
|
|
140
|
+
}
|
|
141
|
+
return generation === REPO_STATE_GENERATION
|
|
142
|
+
&& inspection.missing.length === 2
|
|
143
|
+
&& ['entity_cloud_replica', 'entity_cloud_mutations']
|
|
144
|
+
.every((name) => inspection.missing.includes(name))
|
|
145
|
+
&& REPO_STATE_TABLES.every((name) => !inspection.missing.includes(name));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createStore(db) {
|
|
149
|
+
const generation = db.pragma('user_version', { simple: true });
|
|
150
|
+
const inspection = inspect(db);
|
|
151
|
+
if (!inspection.fresh && generation !== GENERATION && !isMigratableGenerationShape(db, inspection)) {
|
|
152
|
+
throw new Error(`identity registry is schema generation ${generation}, this code speaks ${GENERATION} — migration is an explicit act, never a silent table creation`);
|
|
153
|
+
}
|
|
154
|
+
db.exec(SCHEMA);
|
|
155
|
+
db.pragma(`user_version = ${GENERATION}`);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
meta() {
|
|
159
|
+
const row = db.prepare('SELECT * FROM registry_meta WHERE singleton = 1').get();
|
|
160
|
+
return row ? { epochUuid: row.epoch_uuid, createdAtMs: row.created_at_ms } : null;
|
|
161
|
+
},
|
|
162
|
+
initMeta({ epochUuid, createdAtMs }) {
|
|
163
|
+
db.prepare('INSERT INTO registry_meta (singleton, epoch_uuid, created_at_ms) VALUES (1, ?, ?)')
|
|
164
|
+
.run(epochUuid, createdAtMs);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
byUuid(uuid) {
|
|
168
|
+
return rowToEntity(db.prepare('SELECT * FROM entities WHERE uuid = ?').get(uuid));
|
|
169
|
+
},
|
|
170
|
+
/** Every registered tree's top record: the active parentless rows. */
|
|
171
|
+
activeRoots() {
|
|
172
|
+
return db.prepare("SELECT * FROM entities WHERE parent_uuid IS NULL AND status = 'active' ORDER BY uuid")
|
|
173
|
+
.all().map(rowToEntity);
|
|
174
|
+
},
|
|
175
|
+
activeAtSlot(parentUuid, name) {
|
|
176
|
+
return rowToEntity(
|
|
177
|
+
db.prepare("SELECT * FROM entities WHERE parent_uuid IS ? AND name = ? AND status = 'active'")
|
|
178
|
+
.get(parentUuid, name),
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
childrenOf(parentUuid) {
|
|
182
|
+
return db.prepare('SELECT * FROM entities WHERE parent_uuid = ? ORDER BY uuid').all(parentUuid)
|
|
183
|
+
.map(rowToEntity);
|
|
184
|
+
},
|
|
185
|
+
activeChildrenOf(parentUuid) {
|
|
186
|
+
return db.prepare("SELECT * FROM entities WHERE parent_uuid = ? AND status = 'active' ORDER BY uuid")
|
|
187
|
+
.all(parentUuid).map(rowToEntity);
|
|
188
|
+
},
|
|
189
|
+
activeByType(type) {
|
|
190
|
+
return db.prepare("SELECT * FROM entities WHERE type = ? AND status = 'active' ORDER BY uuid")
|
|
191
|
+
.all(type).map(rowToEntity);
|
|
192
|
+
},
|
|
193
|
+
/** Logical graph rows for the cloud registry. This deliberately excludes
|
|
194
|
+
* observer bindings and every machine-local table. */
|
|
195
|
+
allEntities() {
|
|
196
|
+
return db.prepare('SELECT * FROM entities ORDER BY uuid').all().map(rowToEntity);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
insert(entity) {
|
|
200
|
+
db.prepare(`
|
|
201
|
+
INSERT INTO entities (uuid, type, parent_uuid, name, status, payload_version)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
203
|
+
`).run(entity.uuid, entity.type, entity.parentUuid, entity.name, entity.status, entity.payloadVersion);
|
|
204
|
+
},
|
|
205
|
+
update(uuid, { type, parentUuid, name, status, payloadVersion }) {
|
|
206
|
+
db.prepare(`
|
|
207
|
+
UPDATE entities SET type = ?, parent_uuid = ?, name = ?, status = ?, payload_version = ?
|
|
208
|
+
WHERE uuid = ?
|
|
209
|
+
`).run(type, parentUuid, name, status, payloadVersion, uuid);
|
|
210
|
+
},
|
|
211
|
+
/**
|
|
212
|
+
* One address permutation as one write. Movers may hold each other's
|
|
213
|
+
* slots, and the live unique index cannot be threaded one row at a
|
|
214
|
+
* time — so land in two phases through impossible names (a real name
|
|
215
|
+
* never contains NUL), the same hidden-name trick disk itself uses
|
|
216
|
+
* for an exchange. One transaction: the in-between never exists.
|
|
217
|
+
*/
|
|
218
|
+
applyMoves(moves) {
|
|
219
|
+
const toTemp = db.prepare('UPDATE entities SET name = ? WHERE uuid = ?');
|
|
220
|
+
// An optional type rides the same transition (adoption lands a
|
|
221
|
+
// workspace as a folder) — a separate write could tear.
|
|
222
|
+
const toFinal = db.prepare('UPDATE entities SET parent_uuid = ?, name = ?, type = COALESCE(?, type) WHERE uuid = ?');
|
|
223
|
+
db.transaction(() => {
|
|
224
|
+
for (const move of moves) toTemp.run(`\0${move.uuid}`, move.uuid);
|
|
225
|
+
for (const move of moves) toFinal.run(move.parentUuid, move.name, move.type ?? null, move.uuid);
|
|
226
|
+
})();
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
cloudReplica() {
|
|
230
|
+
const row = db.prepare('SELECT * FROM entity_cloud_replica WHERE singleton = 1').get();
|
|
231
|
+
if (!row) return null;
|
|
232
|
+
return {
|
|
233
|
+
resourceId: row.resource_id,
|
|
234
|
+
deviceId: row.device_id,
|
|
235
|
+
authorityEpoch: Number(row.authority_epoch),
|
|
236
|
+
headVersion: Number(row.head_version),
|
|
237
|
+
state: row.state,
|
|
238
|
+
snapshotCutoff: row.snapshot_cutoff == null ? null : Number(row.snapshot_cutoff),
|
|
239
|
+
updatedAt: row.updated_at,
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
saveCloudReplica(replica) {
|
|
243
|
+
db.prepare(`
|
|
244
|
+
INSERT INTO entity_cloud_replica (
|
|
245
|
+
singleton, resource_id, device_id, authority_epoch, head_version,
|
|
246
|
+
state, snapshot_cutoff, updated_at
|
|
247
|
+
) VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
|
248
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
249
|
+
resource_id = excluded.resource_id,
|
|
250
|
+
device_id = excluded.device_id,
|
|
251
|
+
authority_epoch = excluded.authority_epoch,
|
|
252
|
+
head_version = excluded.head_version,
|
|
253
|
+
state = excluded.state,
|
|
254
|
+
snapshot_cutoff = excluded.snapshot_cutoff,
|
|
255
|
+
updated_at = excluded.updated_at
|
|
256
|
+
`).run(
|
|
257
|
+
replica.resourceId,
|
|
258
|
+
replica.deviceId,
|
|
259
|
+
replica.authorityEpoch,
|
|
260
|
+
replica.headVersion,
|
|
261
|
+
replica.state,
|
|
262
|
+
replica.snapshotCutoff ?? null,
|
|
263
|
+
replica.updatedAt,
|
|
264
|
+
);
|
|
265
|
+
},
|
|
266
|
+
cloudMutationCutoff() {
|
|
267
|
+
const row = db.prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM entity_cloud_mutations').get();
|
|
268
|
+
return Number(row.sequence);
|
|
269
|
+
},
|
|
270
|
+
enqueueCloudMutation(mutation) {
|
|
271
|
+
db.prepare(`
|
|
272
|
+
INSERT INTO entity_cloud_mutations (
|
|
273
|
+
resource_id, mutation_id, operation_json, mutation_state,
|
|
274
|
+
created_at, updated_at
|
|
275
|
+
) VALUES (?, ?, ?, 'pending', ?, ?)
|
|
276
|
+
`).run(
|
|
277
|
+
mutation.resourceId,
|
|
278
|
+
mutation.mutationId,
|
|
279
|
+
mutation.operationJson,
|
|
280
|
+
mutation.createdAt,
|
|
281
|
+
mutation.createdAt,
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
listCloudMutations() {
|
|
285
|
+
return db.prepare('SELECT * FROM entity_cloud_mutations ORDER BY sequence').all();
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { createStore, inspect, isMigratableGenerationShape };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Repocard — the code-description layer
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Describe a git repository perfectly, in two documents, so that another
|
|
6
|
+
machine can rebuild exactly what the user sees — and so that a checkout,
|
|
7
|
+
rebase, or merge travels the rails as one card, never as thousands of file
|
|
8
|
+
events. This layer never syncs, never watches, never decides truth for
|
|
9
|
+
anyone else: it captures, it applies, it verifies.
|
|
10
|
+
|
|
11
|
+
## Axioms
|
|
12
|
+
|
|
13
|
+
1. **Two documents, one truth.** The *card* is the committed repository:
|
|
14
|
+
HEAD, every local branch, every tag, and a bundle holding the objects.
|
|
15
|
+
The *checkpoint* is everything uncommitted riding on top: the staged
|
|
16
|
+
patch, the unstaged patch, and untracked file contents. Card + checkpoint
|
|
17
|
+
rebuild the user's exact view.
|
|
18
|
+
2. **Identity is content-derived.** `cardId` hashes HEAD + branches + tags;
|
|
19
|
+
`checkpointId` hashes base commit + patches + untracked hashes. Equal ids
|
|
20
|
+
are silence — the echo-suppression rule of this layer. Bundle bytes never
|
|
21
|
+
enter identity (git's packing is not byte-deterministic); the bundle
|
|
22
|
+
carries its own checksum so corrupt data is rejected on apply.
|
|
23
|
+
3. **Busy is honest.** A repo mid-merge, mid-rebase, mid-cherry-pick holds
|
|
24
|
+
index states no patch can represent. `capture` says `busy` and nothing
|
|
25
|
+
else; the operation's end fires its own doorbell. This is why a rebase is
|
|
26
|
+
one final card instead of a thousand events. Bisect is deliberately NOT
|
|
27
|
+
busy: it is a mode, not an unfinished operation — it can run for hours,
|
|
28
|
+
and every checkout it makes must be captured like any other.
|
|
29
|
+
4. **Apply proves itself.** After materializing, the ids are re-derived from
|
|
30
|
+
the result and must equal the ids that arrived. Only then is the state
|
|
31
|
+
"applied". There is no separate notion of verification — reproduction is
|
|
32
|
+
the proof.
|
|
33
|
+
5. **Machine-local stays local.** Absolute paths, credentials, reflogs,
|
|
34
|
+
hooks, stashes, and ignored files never travel. Remote URLs ride the card
|
|
35
|
+
as seed metadata (a rebuilt repo should know its origin) but stay outside
|
|
36
|
+
`cardId` — machines may legitimately reach the same origin differently.
|
|
37
|
+
|
|
38
|
+
## Shape
|
|
39
|
+
|
|
40
|
+
- `git.js` — the one place this layer runs git. Plain subprocesses, no
|
|
41
|
+
libraries, Buffers in and out.
|
|
42
|
+
- `capture.js` — repo → `{ card, checkpoint, cardId, checkpointId, stateId }`
|
|
43
|
+
or `{ busy }`. Reads git, writes nothing.
|
|
44
|
+
- `apply.js` — `materialize(dir, card, checkpoint)`: blank directory or
|
|
45
|
+
existing replica in, exact repo state out. Same code for a first apply and
|
|
46
|
+
a checkpoint-only refresh — refs, HEAD, and worktree are forced to the
|
|
47
|
+
card's truth, then the checkpoint lands on top.
|
|
48
|
+
- `follow.js` — the follower: raw repo doorbells in, at most one honest
|
|
49
|
+
semantic event out (`card.changed` / `checkpoint.changed` / silence).
|
|
50
|
+
- `index.js` — the api: `capture`, `verify`, `apply` (materialize + the
|
|
51
|
+
axiom-4 proof), and `createFollower`.
|
|
52
|
+
|
|
53
|
+
Fidelity is git-level, not filesystem-level: the executable bit travels,
|
|
54
|
+
arbitrary permissions do not; commit timestamps travel (they are committed
|
|
55
|
+
history), machine timestamps do not; ignored files never travel.
|
|
56
|
+
|
|
57
|
+
## The follower
|
|
58
|
+
|
|
59
|
+
The observer answers "something happened in this repo territory"; the
|
|
60
|
+
follower answers "what fact, if any, should travel". Its axiom: **ids
|
|
61
|
+
identify settled states — and a settled state is the only completion git
|
|
62
|
+
ever announces.** Every change the two documents describe settles as one
|
|
63
|
+
of exactly two facts: the committed history moved (cardId) or the work on
|
|
64
|
+
top of it moved (checkpointId). History-moving operations — commit,
|
|
65
|
+
merge, rebase, checkout — end by moving a ref atomically; worktree
|
|
66
|
+
commands — add, restore, clean, an editor saving — end the way any edit
|
|
67
|
+
does, leaving files that settle into a new checkpointId. Ids never claim
|
|
68
|
+
a *command* finished: an unowned writer that pauses mid-run yields two
|
|
69
|
+
true states, not one. Operation boundaries exist only where someone owns
|
|
70
|
+
the writer — git's busy markers for its own operations, `command()` for
|
|
71
|
+
amalgm's — and no event-driven observer can invent one for a writer
|
|
72
|
+
nobody owns. So the follower reads where both ids are once the ground is
|
|
73
|
+
still. Card + checkpoint is the whole vocabulary; everything else is
|
|
74
|
+
read-safety plumbing for an outside reader git gives no bell, no atomic
|
|
75
|
+
glance, and no record of untracked files:
|
|
76
|
+
|
|
77
|
+
- **The torn-read guard** — a state travels only when two consecutive
|
|
78
|
+
captures agree on its identity: no torn, id-inconsistent snapshot ever
|
|
79
|
+
travels, and ignored churn — which never moves the ids — cannot starve
|
|
80
|
+
detection (`capMs` keeps trying, the pair keeps it honest).
|
|
81
|
+
- **Busy markers** — git saying "I am between two cards; there is no
|
|
82
|
+
coherent state to photograph yet"; their removal rings.
|
|
83
|
+
- **Locks with a liveness check** — a lock held by a living process is git
|
|
84
|
+
mid-write: wait, and report it (`status().blocked`); its deletion rings.
|
|
85
|
+
An unheld lock is litter from a crash and blocks nothing — proven: a
|
|
86
|
+
stale lock stops neither editors nor `git clean` nor `update-ref`, so
|
|
87
|
+
honoring it would be permanent blindness, not safety. The holder check
|
|
88
|
+
is a single shot when a capture attempt meets a lock — never a poll.
|
|
89
|
+
- **`command(fn)` is an optimization, not an authority** — an amalgm-run
|
|
90
|
+
command batches its noise into one capture at its declared end (nesting
|
|
91
|
+
collapses to the outermost end; failure still ends). Correctness never
|
|
92
|
+
depends on it: the ids' movement is the completion signal for the
|
|
93
|
+
commands amalgm never sees.
|
|
94
|
+
|
|
95
|
+
Equal ids are silence, and `known` advances only on successful delivery —
|
|
96
|
+
a failed emit leaves the state undelivered and re-offered. The follower
|
|
97
|
+
retries itself whenever it witnessed the miss firsthand (a disagreeing
|
|
98
|
+
pair, a failed capture or emit); it waits on the doorbell only for endings
|
|
99
|
+
that are filesystem events; and consumer moments (`flush()` — a session
|
|
100
|
+
opening, an app regaining focus) are the pull valve that turns a lost
|
|
101
|
+
doorbell's "delayed" into "delivered" without polling. Named residual: a
|
|
102
|
+
lock-free writer that stalls across both reads looks like stillness — the
|
|
103
|
+
next doorbell or flush corrects the ids but cannot unsend the event.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* materialize(dir, card, checkpoint): blank directory or existing replica
|
|
5
|
+
* in, the exact described repo state out. One code path for both — refs,
|
|
6
|
+
* HEAD, index, and worktree are forced to the card's truth, then the
|
|
7
|
+
* checkpoint lands on top. Nothing here trusts itself: the caller proves
|
|
8
|
+
* the result by re-deriving ids (axiom 4, done in index.js).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
const { runGit, tryGit, tryGitText } = require('./git');
|
|
17
|
+
const { sha256 } = require('./capture');
|
|
18
|
+
|
|
19
|
+
function fetchBundle(dir, bundle) {
|
|
20
|
+
if (sha256(bundle.bytes) !== bundle.sha256) {
|
|
21
|
+
throw new Error('bundle rejected: checksum does not match the card');
|
|
22
|
+
}
|
|
23
|
+
const tmp = path.join(os.tmpdir(), `repocard-apply-${process.pid}-${crypto.randomBytes(6).toString('hex')}.bundle`);
|
|
24
|
+
fs.writeFileSync(tmp, bundle.bytes);
|
|
25
|
+
try {
|
|
26
|
+
// Land the objects under a neutral namespace; real refs are written
|
|
27
|
+
// next from the card's explicit list, then the namespace is pruned.
|
|
28
|
+
// HEAD must be fetched explicitly: detached-HEAD commits are reachable
|
|
29
|
+
// from no branch, and a fetch that skips HEAD silently drops them.
|
|
30
|
+
runGit(dir, ['fetch', '--quiet', tmp,
|
|
31
|
+
'+HEAD:refs/card-import/HEAD',
|
|
32
|
+
'+refs/heads/*:refs/card-import/heads/*',
|
|
33
|
+
'+refs/tags/*:refs/card-import/tags/*']);
|
|
34
|
+
} finally {
|
|
35
|
+
fs.rmSync(tmp, { force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function haveAllObjects(dir, card) {
|
|
40
|
+
const wanted = [
|
|
41
|
+
...card.branches.map((ref) => ref.commit),
|
|
42
|
+
...card.tags.map((ref) => ref.commit),
|
|
43
|
+
...(card.head.commit ? [card.head.commit] : []),
|
|
44
|
+
];
|
|
45
|
+
return wanted.every((sha) => tryGit(dir, ['cat-file', '-e', sha]) !== null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function forceRefs(dir, card) {
|
|
49
|
+
for (const ref of card.branches) runGit(dir, ['update-ref', `refs/heads/${ref.name}`, ref.commit]);
|
|
50
|
+
for (const ref of card.tags) runGit(dir, ['update-ref', `refs/tags/${ref.name}`, ref.commit]);
|
|
51
|
+
|
|
52
|
+
const keep = new Set([
|
|
53
|
+
...card.branches.map((ref) => `refs/heads/${ref.name}`),
|
|
54
|
+
...card.tags.map((ref) => `refs/tags/${ref.name}`),
|
|
55
|
+
]);
|
|
56
|
+
const existing = tryGitText(dir, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/tags', 'refs/card-import']);
|
|
57
|
+
for (const ref of (existing || '').split('\n').filter(Boolean)) {
|
|
58
|
+
if (!keep.has(ref)) runGit(dir, ['update-ref', '-d', ref]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function forceHead(dir, card) {
|
|
63
|
+
if (card.head.commit === null) {
|
|
64
|
+
// Unborn: point HEAD at the named branch, clear index and worktree.
|
|
65
|
+
runGit(dir, ['symbolic-ref', 'HEAD', `refs/heads/${card.head.branch}`]);
|
|
66
|
+
runGit(dir, ['read-tree', '--empty']);
|
|
67
|
+
runGit(dir, ['clean', '-fdq']);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (card.head.branch === null) {
|
|
71
|
+
runGit(dir, ['-c', 'advice.detachedHead=false', 'checkout', '--force', '--quiet', '--detach', card.head.commit]);
|
|
72
|
+
} else {
|
|
73
|
+
runGit(dir, ['symbolic-ref', 'HEAD', `refs/heads/${card.head.branch}`]);
|
|
74
|
+
runGit(dir, ['reset', '--hard', '--quiet']);
|
|
75
|
+
}
|
|
76
|
+
runGit(dir, ['clean', '-fdq']); // stale untracked work is the old checkpoint's, not ours
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function applyCheckpoint(dir, checkpoint) {
|
|
80
|
+
if (checkpoint.stagedPatch.length > 0) {
|
|
81
|
+
runGit(dir, ['apply', '--index', '--binary', '--whitespace=nowarn'], { input: checkpoint.stagedPatch });
|
|
82
|
+
}
|
|
83
|
+
if (checkpoint.unstagedPatch.length > 0) {
|
|
84
|
+
runGit(dir, ['apply', '--binary', '--whitespace=nowarn'], { input: checkpoint.unstagedPatch });
|
|
85
|
+
}
|
|
86
|
+
for (const file of checkpoint.untracked) {
|
|
87
|
+
const abs = path.join(dir, file.path);
|
|
88
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
89
|
+
if (file.mode === '120000') {
|
|
90
|
+
fs.rmSync(abs, { force: true });
|
|
91
|
+
fs.symlinkSync(file.content.toString('utf8'), abs);
|
|
92
|
+
} else {
|
|
93
|
+
fs.writeFileSync(abs, file.content);
|
|
94
|
+
fs.chmodSync(abs, file.mode === '100755' ? 0o755 : 0o644);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function materialize(dir, card, checkpoint) {
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
if (!fs.existsSync(path.join(dir, '.git'))) {
|
|
102
|
+
runGit(dir, ['init', '--quiet']);
|
|
103
|
+
for (const remote of card.remotes) runGit(dir, ['remote', 'add', remote.name, remote.url]);
|
|
104
|
+
}
|
|
105
|
+
if (card.bundle && !haveAllObjects(dir, card)) fetchBundle(dir, card.bundle);
|
|
106
|
+
forceRefs(dir, card);
|
|
107
|
+
forceHead(dir, card);
|
|
108
|
+
applyCheckpoint(dir, checkpoint);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { materialize };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capture, off the event loop.
|
|
5
|
+
*
|
|
6
|
+
* One persistent worker thread runs the synchronous `capture` so the
|
|
7
|
+
* runtime never blocks on git subprocesses. Requests serialize through the
|
|
8
|
+
* single worker on purpose: captures are short, a follower never overlaps
|
|
9
|
+
* its own pairs, and serializing across repos means a churny machine runs
|
|
10
|
+
* one git at a time instead of a subprocess storm. Structured clone strips
|
|
11
|
+
* the Buffer prototype, so patch and file bytes are re-hydrated on receipt.
|
|
12
|
+
*
|
|
13
|
+
* The worker is spawned lazily on first use, unref'd so it never holds the
|
|
14
|
+
* process open, and respawned on the next request if it dies. A death
|
|
15
|
+
* rejects every pending request — the follower treats that like any failed
|
|
16
|
+
* capture: retry, patiently.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { Worker, isMainThread, parentPort } = require('worker_threads');
|
|
20
|
+
|
|
21
|
+
if (!isMainThread) {
|
|
22
|
+
const { capture } = require('./capture');
|
|
23
|
+
parentPort.on('message', ({ id, repoPath, options }) => {
|
|
24
|
+
try {
|
|
25
|
+
parentPort.postMessage({ id, snap: capture(repoPath, options) });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
parentPort.postMessage({ id, error: error?.message || String(error) });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const asBuffer = (view) => (view == null ? view : Buffer.from(view));
|
|
33
|
+
|
|
34
|
+
/** Structured clone turns Buffers into plain Uint8Arrays; undo that. */
|
|
35
|
+
function rehydrate(snap) {
|
|
36
|
+
if (!snap || snap.busy) return snap;
|
|
37
|
+
if (snap.card.bundle) snap.card.bundle.bytes = asBuffer(snap.card.bundle.bytes);
|
|
38
|
+
snap.checkpoint.stagedPatch = asBuffer(snap.checkpoint.stagedPatch);
|
|
39
|
+
snap.checkpoint.unstagedPatch = asBuffer(snap.checkpoint.unstagedPatch);
|
|
40
|
+
for (const file of snap.checkpoint.untracked) file.content = asBuffer(file.content);
|
|
41
|
+
return snap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createCaptureWorker() {
|
|
45
|
+
let worker = null;
|
|
46
|
+
let closed = false;
|
|
47
|
+
let nextId = 1;
|
|
48
|
+
const pending = new Map();
|
|
49
|
+
|
|
50
|
+
function failAll(error) {
|
|
51
|
+
const waiting = [...pending.values()];
|
|
52
|
+
pending.clear();
|
|
53
|
+
for (const entry of waiting) entry.reject(error);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensureWorker() {
|
|
57
|
+
if (worker) return worker;
|
|
58
|
+
worker = new Worker(__filename);
|
|
59
|
+
worker.unref();
|
|
60
|
+
worker.on('message', ({ id, snap, error }) => {
|
|
61
|
+
const entry = pending.get(id);
|
|
62
|
+
if (!entry) return;
|
|
63
|
+
pending.delete(id);
|
|
64
|
+
if (error) entry.reject(new Error(error));
|
|
65
|
+
else entry.resolve(rehydrate(snap));
|
|
66
|
+
});
|
|
67
|
+
worker.on('error', (error) => failAll(error));
|
|
68
|
+
worker.on('exit', (code) => {
|
|
69
|
+
worker = null; // next request respawns
|
|
70
|
+
if (pending.size) failAll(new Error(`capture worker exited (code ${code})`));
|
|
71
|
+
});
|
|
72
|
+
return worker;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
capture(repoPath, options = {}) {
|
|
77
|
+
if (closed) return Promise.reject(new Error('capture worker is closed'));
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const id = nextId++;
|
|
80
|
+
pending.set(id, { resolve, reject });
|
|
81
|
+
ensureWorker().postMessage({ id, repoPath, options });
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
async close() {
|
|
85
|
+
closed = true;
|
|
86
|
+
const dying = worker;
|
|
87
|
+
worker = null;
|
|
88
|
+
failAll(new Error('capture worker is closed'));
|
|
89
|
+
if (dying) await dying.terminate();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { createCaptureWorker };
|