amalgm 0.1.245 → 0.1.246

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