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,666 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The entity registry: the canonical owner of the six-field universal
|
|
5
|
+
* records (docs/entity-model.md). Adapters observe and PROPOSE; only this
|
|
6
|
+
* module validates, versions, and commits — and only its lifecycle verbs
|
|
7
|
+
* ever write `status`. The evidence path has no status parameter at all,
|
|
8
|
+
* so detection cannot write lifecycle by construction.
|
|
9
|
+
*
|
|
10
|
+
* The rules it enforces are exactly the ratified axioms, nothing more:
|
|
11
|
+
* - ONE identity database holds every registered tree: each tree is one
|
|
12
|
+
* active parentless record — a workspace, a repo.git, or, a tree of
|
|
13
|
+
* one, a single registered file or explicitly registered link
|
|
14
|
+
* (`amalgm register <path>` names an address, and a pointer at that
|
|
15
|
+
* address is an entity of its own; docs/realtime-rails.md). A tree
|
|
16
|
+
* boundary is never an identity boundary: an entity moves between
|
|
17
|
+
* trees as a parent update in one transaction, and a whole tree
|
|
18
|
+
* joining another (ADOPTION) is the same lawful move performed on a
|
|
19
|
+
* parentless record — the UUID and every descendant ride along;
|
|
20
|
+
* - one active owner per (parentUUID, name) slot (the schema convicts
|
|
21
|
+
* too); parentless records are exempt — a top-level tree is placed by
|
|
22
|
+
* its machine binding, never by name;
|
|
23
|
+
* - only containers — workspace, folder, repo.git — hold active children;
|
|
24
|
+
* - the parent graph is acyclic;
|
|
25
|
+
* - valid type transitions preserve the UUID; a folder with stored-active
|
|
26
|
+
* children never becomes a file (that flip would canonicalize subtree
|
|
27
|
+
* destruction);
|
|
28
|
+
* - lifecycle is explicit: trash (active → trashed), restore
|
|
29
|
+
* (trashed → active, refused while another active entity owns the slot),
|
|
30
|
+
* and empty-trash (trashed → deleted, owner-only via the injected
|
|
31
|
+
* `authorizeDelete`; unwired means unavailable, never fail-open);
|
|
32
|
+
* - lifecycle inherits DOWN the chain as derived effective status — one
|
|
33
|
+
* flip on a folder, no descendant record rewritten.
|
|
34
|
+
*
|
|
35
|
+
* The canonical version is DERIVED, never stored: a column could drift
|
|
36
|
+
* from its inputs, a pure function cannot. Container payloads (workspace,
|
|
37
|
+
* folder) are their sorted active direct membership; other types attest
|
|
38
|
+
* their payload version through the evidence path.
|
|
39
|
+
*
|
|
40
|
+
* First initialization is distinguished from loss: an empty database on a
|
|
41
|
+
* fresh machine enrolls; a database missing tables where the sentinel says
|
|
42
|
+
* a registry existed fails loudly and never silently mints replacement
|
|
43
|
+
* UUIDs. A DURABLE REGISTRY REQUIRES A SENTINEL — only a real in-memory
|
|
44
|
+
* database may declare `ephemeral: true` instead. Losing both the database
|
|
45
|
+
* and the sentinel stays honestly unsolvable until remote identity storage
|
|
46
|
+
* exists.
|
|
47
|
+
*
|
|
48
|
+
* Registry state is truth; notifications are doorbells. `entity.committed`
|
|
49
|
+
* payloads are an optimization, never authoritative mutations — every
|
|
50
|
+
* consumer rereads and compares registry versions, and startup attachment
|
|
51
|
+
* always compares (a restart loses the pending flag). A failed ring leaves
|
|
52
|
+
* one pending bell, cleared only by a successful `registry.changed`;
|
|
53
|
+
* `flush()` always requests comparison regardless of the flag.
|
|
54
|
+
*
|
|
55
|
+
* NOTHING RINGS UNTIL ITS TRUTH COMMITS: an event born inside an open
|
|
56
|
+
* transaction waits for the outermost commit and a rollback silences it,
|
|
57
|
+
* so a consumer can never hear a record the database never held. The
|
|
58
|
+
* registry owns the identity database's one transition primitive
|
|
59
|
+
* (`transaction`) for exactly this reason — the ringer must own the
|
|
60
|
+
* commit boundary its bells wait on.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const crypto = require('crypto');
|
|
64
|
+
const fs = require('fs');
|
|
65
|
+
|
|
66
|
+
const { createStore, inspect, isMigratableGenerationShape } = require('./store');
|
|
67
|
+
|
|
68
|
+
// `link` is a filesystem symlink as ratified: payload = the literal
|
|
69
|
+
// target text, resolution derived, death honest. `reference` is the
|
|
70
|
+
// OTHER link kind, never conflated: Amalgm-created by a deliberate act,
|
|
71
|
+
// its payload is the TARGET ENTITY'S UUID — identity, not an address —
|
|
72
|
+
// and its local rendering as an ordinary symlink is derived state the
|
|
73
|
+
// registration boundary maintains.
|
|
74
|
+
// The alphabet is PHYSICAL kinds only. Constructs — agents, apps,
|
|
75
|
+
// automations, tools, skills — are organized filesystem ground with a
|
|
76
|
+
// known structure: physically folders and files, with their construct
|
|
77
|
+
// meaning layered ABOVE the physical type, never encoded as one.
|
|
78
|
+
// (The former `automation`/`agent` placeholder types contradicted
|
|
79
|
+
// themselves: declared as folder-shaped ground yet barred from holding
|
|
80
|
+
// children. Removed by ratification 2026-08-02; promoting a construct
|
|
81
|
+
// to a physical type would take proof that it truly travels or applies
|
|
82
|
+
// differently — a deliberate future decision, never a default.)
|
|
83
|
+
const TYPES = new Set([
|
|
84
|
+
'workspace', 'folder', 'repo.git', 'file.text', 'file.binary',
|
|
85
|
+
'link', 'reference',
|
|
86
|
+
]);
|
|
87
|
+
const CONTAINERS = new Set(['workspace', 'folder', 'repo.git']);
|
|
88
|
+
const ROOT_TYPES = new Set(['workspace', 'repo.git', 'file.text', 'file.binary', 'link']);
|
|
89
|
+
// Types whose payload IS their membership: derived from the records
|
|
90
|
+
// beneath them, so no observation may attest one.
|
|
91
|
+
const DERIVED_PAYLOAD = new Set(['workspace', 'folder']);
|
|
92
|
+
|
|
93
|
+
// The one entity alphabet, published for the layers that map onto it
|
|
94
|
+
// (adapters/ serves every member) — read from here, never restated,
|
|
95
|
+
// so the alphabet cannot drift from the registry that enforces it.
|
|
96
|
+
const ENTITY_TYPES = Object.freeze([...TYPES].sort());
|
|
97
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What an attestation may say — asked of both the type a record wears
|
|
101
|
+
* now and the type its new bytes claim, because an attestation may
|
|
102
|
+
* carry a record across leaf kinds (a pointer replaced by bytes) and
|
|
103
|
+
* both ends must be ground an observation can speak for.
|
|
104
|
+
*/
|
|
105
|
+
function attestable(type) {
|
|
106
|
+
if (DERIVED_PAYLOAD.has(type)) {
|
|
107
|
+
throw new Error(`${type} payload is derived membership, never attested`);
|
|
108
|
+
}
|
|
109
|
+
if (type === 'reference') {
|
|
110
|
+
throw new Error('a reference\'s payload is the target\'s identity, set by the deliberate act that created it — never an attestation; the rendered symlink is derived state, not evidence');
|
|
111
|
+
}
|
|
112
|
+
if (!TYPES.has(type)) throw new Error(`unknown entity type ${JSON.stringify(type)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The ratified formula: version = hash(type, parentUUID, name, status, payloadVersion). */
|
|
116
|
+
function canonicalVersion({ type, parentUUID = null, name, status, payloadVersion = null }) {
|
|
117
|
+
return crypto.createHash('sha256')
|
|
118
|
+
.update(JSON.stringify([type, parentUUID, name, status, payloadVersion]))
|
|
119
|
+
.digest('hex');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createRegistry(options) {
|
|
123
|
+
const {
|
|
124
|
+
db,
|
|
125
|
+
emit,
|
|
126
|
+
sentinelPath = null,
|
|
127
|
+
ephemeral = false,
|
|
128
|
+
authorizeDelete = null,
|
|
129
|
+
journal = null,
|
|
130
|
+
} = options;
|
|
131
|
+
if (!db) throw new Error('createRegistry requires { db }');
|
|
132
|
+
if (typeof emit !== 'function') throw new Error('createRegistry requires { emit }');
|
|
133
|
+
if (journal !== null && typeof journal !== 'function') {
|
|
134
|
+
throw new Error('createRegistry journal must be a function when supplied');
|
|
135
|
+
}
|
|
136
|
+
if (ephemeral) {
|
|
137
|
+
if (sentinelPath) throw new Error('ephemeral and sentinelPath are contradictory: pick one');
|
|
138
|
+
if (!db.memory) throw new Error(`ephemeral is for a real in-memory database; this one lives at ${db.name}`);
|
|
139
|
+
} else if (!sentinelPath) {
|
|
140
|
+
throw new Error('a durable registry requires a sentinel: pass sentinelPath, or ephemeral: true for a real in-memory database');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- Sentinel: first init vs loss, decided before any schema exists ----
|
|
144
|
+
// The registry tables — records, evidence memory, and repo state — are
|
|
145
|
+
// born together, so
|
|
146
|
+
// any missing one in an established database is loss, never first init:
|
|
147
|
+
// silently recreating a table would be forgetting that resolves identity.
|
|
148
|
+
const inspection = inspect(db);
|
|
149
|
+
const { fresh, missing } = inspection;
|
|
150
|
+
if (!fresh && missing.length > 0 && !isMigratableGenerationShape(db, inspection)) {
|
|
151
|
+
throw new Error(`registry corrupt: ${missing.join(', ')} gone from a database where a registry lived — refusing to touch it`);
|
|
152
|
+
}
|
|
153
|
+
const marker = sentinelPath && fs.existsSync(sentinelPath)
|
|
154
|
+
? fs.readFileSync(sentinelPath, 'utf8').trim()
|
|
155
|
+
: null;
|
|
156
|
+
if (fresh && marker !== null) {
|
|
157
|
+
throw new Error(`registry lost: sentinel ${sentinelPath} says epoch ${marker} lived here, but this database holds no registry — refusing to mint replacement identities`);
|
|
158
|
+
}
|
|
159
|
+
const store = createStore(db);
|
|
160
|
+
if (fresh) store.initMeta({ epochUuid: crypto.randomUUID(), createdAtMs: Date.now() });
|
|
161
|
+
const meta = store.meta();
|
|
162
|
+
if (!meta) {
|
|
163
|
+
throw new Error('registry corrupt: registry_meta exists but holds no epoch row — refusing to re-initialize over it');
|
|
164
|
+
}
|
|
165
|
+
if (sentinelPath) {
|
|
166
|
+
if (marker === null) {
|
|
167
|
+
fs.writeFileSync(sentinelPath, `${meta.epochUuid}\n`);
|
|
168
|
+
if (!fresh) console.warn(`[Registry] sentinel marker was missing at ${sentinelPath}; rewrote epoch ${meta.epochUuid}`);
|
|
169
|
+
} else if (marker !== meta.epochUuid) {
|
|
170
|
+
throw new Error(`registry mismatch: sentinel epoch ${marker} is not this database's epoch ${meta.epochUuid} — this is not the registry that lived here`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- Internals ----
|
|
175
|
+
|
|
176
|
+
// Registry state is truth; notifications are DOORBELLS. `entity.committed`
|
|
177
|
+
// payloads are an optimization, never authoritative mutations — every
|
|
178
|
+
// consumer rereads and compares registry versions. A failed ring leaves
|
|
179
|
+
// the bell pending: multiple misses collapse into the one comparison the
|
|
180
|
+
// consumer owes anyway, so no event content is ever stored for replay.
|
|
181
|
+
// The flag is in-memory only — a restart loses it — which is why startup
|
|
182
|
+
// attachment must always compare, pending bell or not.
|
|
183
|
+
let bellPending = false;
|
|
184
|
+
const deferred = []; // bells born inside an open transaction: they ring at commit, or a rollback silences them
|
|
185
|
+
|
|
186
|
+
function ring(event) {
|
|
187
|
+
try {
|
|
188
|
+
emit(event);
|
|
189
|
+
return true;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.warn('[Registry] ring failed:', error?.message || error);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function publish(event) {
|
|
197
|
+
// Nothing rings until its truth commits: inside a transaction the
|
|
198
|
+
// bell waits — transaction() flushes it after the outermost commit,
|
|
199
|
+
// and a rollback discards exactly what it rolled back.
|
|
200
|
+
if (db.inTransaction) {
|
|
201
|
+
deferred.push(event);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (bellPending) {
|
|
205
|
+
if (!ring({ type: 'registry.changed' })) return; // still down; truth waits in the registry
|
|
206
|
+
bellPending = false;
|
|
207
|
+
}
|
|
208
|
+
if (!ring(event)) bellPending = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function transaction(fn) {
|
|
212
|
+
const outermost = !db.inTransaction;
|
|
213
|
+
const mark = deferred.length;
|
|
214
|
+
try {
|
|
215
|
+
const result = db.transaction(fn)();
|
|
216
|
+
if (outermost) {
|
|
217
|
+
while (deferred.length > 0) publish(deferred.shift());
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
deferred.length = mark;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function mustGet(uuid) {
|
|
227
|
+
const row = store.byUuid(uuid);
|
|
228
|
+
if (!row) throw new Error(`no entity ${uuid}`);
|
|
229
|
+
return row;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function validName(name) {
|
|
233
|
+
if (typeof name !== 'string' || name === '' || name === '.' || name === '..'
|
|
234
|
+
|| name.includes('/') || name.includes('\0')) {
|
|
235
|
+
throw new Error(`invalid entity name ${JSON.stringify(name)}: a name is one non-empty path segment`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A UUID may arrive only through a trusted identity binding (for example
|
|
240
|
+
* a cloud user-home bootstrap). The registry still owns the actual insert:
|
|
241
|
+
* accepting a caller-supplied UUID is not accepting a caller-supplied
|
|
242
|
+
* record, parent, status, or version. */
|
|
243
|
+
function mintOrValidateUuid(uuid) {
|
|
244
|
+
if (uuid === undefined || uuid === null) return crypto.randomUUID();
|
|
245
|
+
if (typeof uuid !== 'string' || !UUID.test(uuid)) {
|
|
246
|
+
throw new Error(`entity identity must be a UUID, got ${JSON.stringify(uuid)}`);
|
|
247
|
+
}
|
|
248
|
+
if (store.byUuid(uuid)) throw new Error(`entity identity ${uuid} is already registered`);
|
|
249
|
+
return uuid.toLowerCase();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Ancestor uuids from the given parent up to the root. */
|
|
253
|
+
function ancestorsOf(parentUuid) {
|
|
254
|
+
const chain = [];
|
|
255
|
+
const seen = new Set();
|
|
256
|
+
let cursor = parentUuid;
|
|
257
|
+
while (cursor !== null) {
|
|
258
|
+
if (seen.has(cursor)) throw new Error(`registry corrupt: parent cycle at ${cursor}`);
|
|
259
|
+
seen.add(cursor);
|
|
260
|
+
chain.push(cursor);
|
|
261
|
+
cursor = mustGet(cursor).parentUuid;
|
|
262
|
+
}
|
|
263
|
+
return chain;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function effectiveStatusOf(row) {
|
|
267
|
+
const statuses = [row.status, ...ancestorsOf(row.parentUuid).map((uuid) => mustGet(uuid).status)];
|
|
268
|
+
if (statuses.includes('deleted')) return 'deleted';
|
|
269
|
+
if (statuses.includes('trashed')) return 'trashed';
|
|
270
|
+
return row.status;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Container payload: the sorted active direct membership (childUUID, name). */
|
|
274
|
+
function membershipHash(uuid) {
|
|
275
|
+
const pairs = store.activeChildrenOf(uuid).map((child) => [child.uuid, child.name]);
|
|
276
|
+
return crypto.createHash('sha256').update(JSON.stringify(pairs)).digest('hex');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function payloadFor(row) {
|
|
280
|
+
return DERIVED_PAYLOAD.has(row.type) ? membershipHash(row.uuid) : row.payloadVersion;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function record(row) {
|
|
284
|
+
return {
|
|
285
|
+
uuid: row.uuid,
|
|
286
|
+
type: row.type,
|
|
287
|
+
parentUUID: row.parentUuid,
|
|
288
|
+
name: row.name,
|
|
289
|
+
version: canonicalVersion({
|
|
290
|
+
type: row.type, parentUUID: row.parentUuid, name: row.name,
|
|
291
|
+
status: row.status, payloadVersion: payloadFor(row),
|
|
292
|
+
}),
|
|
293
|
+
status: row.status,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The cloud form contains identity and the content/head token, never an
|
|
298
|
+
* address, watcher, inode, or SQLite bytes. `payloadVersion` is the
|
|
299
|
+
* unambiguous current head for leaf types; containers derive theirs from
|
|
300
|
+
* membership and retain null here; a repo.git retains its immutable
|
|
301
|
+
* Card/Checkpoint head. */
|
|
302
|
+
function syncRecord(row) {
|
|
303
|
+
return {
|
|
304
|
+
...record(row),
|
|
305
|
+
payloadVersion: row.payloadVersion,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function journalRecord(next, previous) {
|
|
310
|
+
// This call intentionally happens inside the identity transaction and
|
|
311
|
+
// before notification. A failed journal write rolls the entity mutation
|
|
312
|
+
// back; a later process can never observe local truth without its cloud
|
|
313
|
+
// delivery intent.
|
|
314
|
+
if (journal) journal({ record: syncRecord(next), previous: previous ? syncRecord(previous) : null });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function normalizeCloudRecord(value) {
|
|
318
|
+
if (!value || typeof value !== 'object'
|
|
319
|
+
|| typeof value.uuid !== 'string' || !UUID.test(value.uuid)
|
|
320
|
+
|| typeof value.type !== 'string' || !TYPES.has(value.type)
|
|
321
|
+
|| (value.parentUUID !== null && (typeof value.parentUUID !== 'string' || !UUID.test(value.parentUUID)))
|
|
322
|
+
|| typeof value.name !== 'string'
|
|
323
|
+
|| !['active', 'trashed', 'deleted'].includes(value.status)
|
|
324
|
+
|| (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')
|
|
325
|
+
|| typeof value.version !== 'string' || !/^[0-9a-f]{64}$/i.test(value.version)) {
|
|
326
|
+
throw new Error('cloud entity operation has an invalid record');
|
|
327
|
+
}
|
|
328
|
+
validName(value.name);
|
|
329
|
+
if (value.parentUUID === null && !ROOT_TYPES.has(value.type)) {
|
|
330
|
+
throw new Error(`cloud entity ${value.uuid} is not a valid tree root`);
|
|
331
|
+
}
|
|
332
|
+
if (value.parentUUID !== null && value.type === 'workspace') {
|
|
333
|
+
throw new Error('cloud entity workspace is the root type only');
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
uuid: value.uuid.toLowerCase(),
|
|
337
|
+
type: value.type,
|
|
338
|
+
parentUuid: value.parentUUID === null ? null : value.parentUUID.toLowerCase(),
|
|
339
|
+
name: value.name,
|
|
340
|
+
status: value.status,
|
|
341
|
+
payloadVersion: value.payloadVersion,
|
|
342
|
+
expectedVersion: value.version.toLowerCase(),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function sameLogicalRecord(left, right) {
|
|
347
|
+
return left.uuid === right.uuid
|
|
348
|
+
&& left.type === right.type
|
|
349
|
+
&& left.parentUuid === right.parentUuid
|
|
350
|
+
&& left.name === right.name
|
|
351
|
+
&& left.status === right.status
|
|
352
|
+
&& left.payloadVersion === right.payloadVersion;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Every write funnels through here: previous captured, change applied, one event. */
|
|
356
|
+
function commit(row, changes) {
|
|
357
|
+
if (!db.inTransaction) return transaction(() => commit(row, changes));
|
|
358
|
+
const previous = record(row);
|
|
359
|
+
store.update(row.uuid, { ...row, ...changes });
|
|
360
|
+
const nextRow = mustGet(row.uuid);
|
|
361
|
+
const next = record(nextRow);
|
|
362
|
+
journalRecord(nextRow, row);
|
|
363
|
+
publish({ type: 'entity.committed', record: next, previous });
|
|
364
|
+
return next;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function requireActiveContainerParent(parentUuid) {
|
|
368
|
+
const parent = mustGet(parentUuid);
|
|
369
|
+
if (!CONTAINERS.has(parent.type)) {
|
|
370
|
+
throw new Error(`only containers hold active children: ${parentUuid} is ${parent.type}, not workspace/folder/repo.git`);
|
|
371
|
+
}
|
|
372
|
+
if (effectiveStatusOf(parent) !== 'active') {
|
|
373
|
+
throw new Error(`parent ${parentUuid} is not effectively active — restore it before putting entities under it`);
|
|
374
|
+
}
|
|
375
|
+
return parent;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function requireFreeSlot(parentUuid, name, forUuid = null) {
|
|
379
|
+
if (parentUuid === null) return; // top level: address places a tree, never its name
|
|
380
|
+
const occupant = store.activeAtSlot(parentUuid, name);
|
|
381
|
+
if (occupant && occupant.uuid !== forUuid) {
|
|
382
|
+
throw new Error(`slot taken: active entity ${occupant.uuid} already owns (${parentUuid}, ${name}) — move or trash it first`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* One batch of moves, one transition. The slot law and acyclicity are
|
|
388
|
+
* STATE invariants: they constrain the graph after the batch, never the
|
|
389
|
+
* order inside it — so movers may hold each other's slots (directories
|
|
390
|
+
* exchange addresses on disk, and detection reports the endpoints). A
|
|
391
|
+
* single move is a batch of one. A PARENTLESS mover is ADOPTION: the
|
|
392
|
+
* whole tree joins another under one parent update — a tree boundary
|
|
393
|
+
* is never an identity boundary, so this is the same lawful move, not
|
|
394
|
+
* a special ceremony.
|
|
395
|
+
*/
|
|
396
|
+
function moveMany(moves) {
|
|
397
|
+
if (!db.inTransaction) return transaction(() => moveMany(moves));
|
|
398
|
+
const rows = new Map(); // uuid -> current row
|
|
399
|
+
const post = new Map(); // uuid -> its move
|
|
400
|
+
for (const move of moves) {
|
|
401
|
+
const { uuid, name } = move;
|
|
402
|
+
if (post.has(uuid)) throw new Error(`one batch moves ${uuid} once, not twice`);
|
|
403
|
+
const row = mustGet(uuid);
|
|
404
|
+
if (row.status !== 'active') {
|
|
405
|
+
throw new Error(`only an active entity moves: ${uuid} is ${row.status}`);
|
|
406
|
+
}
|
|
407
|
+
validName(name);
|
|
408
|
+
rows.set(uuid, row);
|
|
409
|
+
post.set(uuid, move);
|
|
410
|
+
}
|
|
411
|
+
const postParentOf = (uuid) => post.get(uuid)?.parentUUID ?? mustGet(uuid).parentUuid;
|
|
412
|
+
const targets = new Set();
|
|
413
|
+
for (const { uuid, parentUUID, name } of moves) {
|
|
414
|
+
const parent = mustGet(parentUUID);
|
|
415
|
+
if (!CONTAINERS.has(parent.type)) {
|
|
416
|
+
throw new Error(`only containers hold active children: ${parentUUID} is ${parent.type}, not workspace/folder/repo.git`);
|
|
417
|
+
}
|
|
418
|
+
const slot = `${parentUUID}\0${name}`;
|
|
419
|
+
if (targets.has(slot)) throw new Error(`slot taken: two movers claim (${parentUUID}, ${name}) in one batch`);
|
|
420
|
+
targets.add(slot);
|
|
421
|
+
const occupant = store.activeAtSlot(parentUUID, name);
|
|
422
|
+
if (occupant && occupant.uuid !== uuid && !post.has(occupant.uuid)) {
|
|
423
|
+
throw new Error(`slot taken: active entity ${occupant.uuid} already owns (${parentUUID ?? 'root'}, ${name}) — move or trash it first`);
|
|
424
|
+
}
|
|
425
|
+
// The target parent's whole post-state chain: acyclic and effectively
|
|
426
|
+
// active, walked once.
|
|
427
|
+
const seen = new Set();
|
|
428
|
+
let cursor = parentUUID;
|
|
429
|
+
while (cursor !== null) {
|
|
430
|
+
if (cursor === uuid) {
|
|
431
|
+
throw new Error(`the parent graph is acyclic: ${uuid} cannot live under itself`);
|
|
432
|
+
}
|
|
433
|
+
if (seen.has(cursor)) throw new Error(`registry corrupt: parent cycle at ${cursor}`);
|
|
434
|
+
seen.add(cursor);
|
|
435
|
+
if (mustGet(cursor).status !== 'active') {
|
|
436
|
+
throw new Error(`parent ${parentUUID} is not effectively active — restore it before putting entities under it`);
|
|
437
|
+
}
|
|
438
|
+
cursor = postParentOf(cursor);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const previous = moves.map(({ uuid }) => record(rows.get(uuid)));
|
|
442
|
+
// Adoption sheds the root shape in the SAME transition: workspace is
|
|
443
|
+
// the root type only, so a parentless workspace landing under a
|
|
444
|
+
// parent lands as a folder — one write, never a two-step that could
|
|
445
|
+
// tear into a nested workspace.
|
|
446
|
+
store.applyMoves(moves.map(({ uuid, parentUUID, name }) => {
|
|
447
|
+
const row = rows.get(uuid);
|
|
448
|
+
const adoptedWorkspace = row.parentUuid === null && row.type === 'workspace';
|
|
449
|
+
return { uuid, parentUuid: parentUUID, name, ...(adoptedWorkspace ? { type: 'folder' } : {}) };
|
|
450
|
+
}));
|
|
451
|
+
return moves.map(({ uuid }, index) => {
|
|
452
|
+
const nextRow = mustGet(uuid);
|
|
453
|
+
const next = record(nextRow);
|
|
454
|
+
journalRecord(nextRow, rows.get(uuid));
|
|
455
|
+
publish({ type: 'entity.committed', record: next, previous: previous[index] });
|
|
456
|
+
return next;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---- The registry ----
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
epoch: () => meta.epochUuid,
|
|
464
|
+
canonicalVersion,
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The identity database's one transition primitive: everything the
|
|
468
|
+
* callback writes — records and evidence bindings alike, they share
|
|
469
|
+
* this database — lands or none of it does, and every bell those
|
|
470
|
+
* writes rang waits here: bells ring only after the OUTERMOST
|
|
471
|
+
* commit, and a rollback silences exactly what it rolled back
|
|
472
|
+
* (a nested savepoint truncates to its own mark). A tree's birth
|
|
473
|
+
* uses it so a minted record and its witness binding never tear
|
|
474
|
+
* apart — and never announce a record that rolled back.
|
|
475
|
+
*/
|
|
476
|
+
transaction,
|
|
477
|
+
|
|
478
|
+
/** Always requests comparison — it never trusts the in-memory flag,
|
|
479
|
+
* because a restart loses that memory. Returns whether the bell rang. */
|
|
480
|
+
flush() {
|
|
481
|
+
if (ring({ type: 'registry.changed' })) {
|
|
482
|
+
bellPending = false;
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
bellPending = true;
|
|
486
|
+
return false;
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
/** Births one registered tree's top record. The one registry holds
|
|
490
|
+
* every tree, so enrolling another root is ordinary — each is one
|
|
491
|
+
* active parentless record, placed by its machine binding. */
|
|
492
|
+
enrollRoot({ type, name, payloadVersion = null, uuid = undefined }) {
|
|
493
|
+
if (!db.inTransaction) {
|
|
494
|
+
return transaction(() => this.enrollRoot({ type, name, payloadVersion, uuid }));
|
|
495
|
+
}
|
|
496
|
+
if (!ROOT_TYPES.has(type)) {
|
|
497
|
+
throw new Error(`the root is workspace, repo.git, a registered file, or a registered link, never ${JSON.stringify(type)}`);
|
|
498
|
+
}
|
|
499
|
+
validName(name);
|
|
500
|
+
const row = {
|
|
501
|
+
uuid: mintOrValidateUuid(uuid), type, parentUuid: null, name, status: 'active', payloadVersion,
|
|
502
|
+
};
|
|
503
|
+
store.insert(row);
|
|
504
|
+
const next = record(row);
|
|
505
|
+
journalRecord(row, null);
|
|
506
|
+
publish({ type: 'entity.committed', record: next, previous: null });
|
|
507
|
+
return next;
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
create({ type, parentUUID, name, payloadVersion = null, uuid = undefined }) {
|
|
511
|
+
if (!db.inTransaction) {
|
|
512
|
+
return transaction(() => this.create({ type, parentUUID, name, payloadVersion, uuid }));
|
|
513
|
+
}
|
|
514
|
+
if (!TYPES.has(type)) throw new Error(`unknown entity type ${JSON.stringify(type)}`);
|
|
515
|
+
if (type === 'workspace') throw new Error('workspace is the root type only; a nested container is a folder');
|
|
516
|
+
validName(name);
|
|
517
|
+
requireActiveContainerParent(parentUUID);
|
|
518
|
+
requireFreeSlot(parentUUID, name);
|
|
519
|
+
const row = {
|
|
520
|
+
uuid: mintOrValidateUuid(uuid), type, parentUuid: parentUUID, name,
|
|
521
|
+
status: 'active', payloadVersion,
|
|
522
|
+
};
|
|
523
|
+
store.insert(row);
|
|
524
|
+
const next = record(row);
|
|
525
|
+
journalRecord(row, null);
|
|
526
|
+
publish({ type: 'entity.committed', record: next, previous: null });
|
|
527
|
+
return next;
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
move(uuid, { parentUUID, name }) {
|
|
531
|
+
return moveMany([{ uuid, parentUUID, name }])[0];
|
|
532
|
+
},
|
|
533
|
+
moveMany,
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The evidence path, and ONE OBSERVATION IS ONE TRANSITION: the
|
|
537
|
+
* payload a look found and the type that same look implies land in
|
|
538
|
+
* a single commit, or neither does. Two writes could tear — new
|
|
539
|
+
* bytes wearing an old type — and the equal-payload silence on the
|
|
540
|
+
* next pass would never heal it, because the payload it compares
|
|
541
|
+
* already matches. One write, one bell: what a consumer hears is a
|
|
542
|
+
* state disk actually held.
|
|
543
|
+
*
|
|
544
|
+
* Equal attestations are silence.
|
|
545
|
+
*/
|
|
546
|
+
attest(uuid, { payloadVersion, type }) {
|
|
547
|
+
const row = mustGet(uuid);
|
|
548
|
+
if (row.status !== 'active') {
|
|
549
|
+
throw new Error(`only an active entity attests payload: ${uuid} is ${row.status}`);
|
|
550
|
+
}
|
|
551
|
+
attestable(row.type);
|
|
552
|
+
attestable(type);
|
|
553
|
+
if (row.parentUuid === null && !ROOT_TYPES.has(type)) {
|
|
554
|
+
throw new Error(`the root is workspace, repo.git, a registered file, or a registered link, never ${JSON.stringify(type)}`);
|
|
555
|
+
}
|
|
556
|
+
if (row.type === type && row.payloadVersion === payloadVersion) return record(row);
|
|
557
|
+
return commit(row, { type, payloadVersion });
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
changeType(uuid, type, { payloadVersion = null } = {}) {
|
|
561
|
+
const row = mustGet(uuid);
|
|
562
|
+
if (row.status !== 'active') {
|
|
563
|
+
throw new Error(`only an active entity changes type: ${uuid} is ${row.status}`);
|
|
564
|
+
}
|
|
565
|
+
if (!TYPES.has(type)) throw new Error(`unknown entity type ${JSON.stringify(type)}`);
|
|
566
|
+
if (row.parentUuid === null) {
|
|
567
|
+
if (!ROOT_TYPES.has(type)) throw new Error(`the root is workspace, repo.git, a registered file, or a registered link, never ${JSON.stringify(type)}`);
|
|
568
|
+
} else if (type === 'workspace') {
|
|
569
|
+
throw new Error('workspace is the root type only; a nested container is a folder');
|
|
570
|
+
}
|
|
571
|
+
if (!CONTAINERS.has(type)) {
|
|
572
|
+
const children = store.activeChildrenOf(uuid);
|
|
573
|
+
if (children.length > 0) {
|
|
574
|
+
throw new Error(`refusing ${row.type} → ${type} on ${uuid}: ${children.length} stored-active child(ren) would be stranded — only containers hold active children`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (type === row.type) return record(row);
|
|
578
|
+
return commit(row, {
|
|
579
|
+
type, payloadVersion: DERIVED_PAYLOAD.has(type) ? null : payloadVersion,
|
|
580
|
+
});
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
trash(uuid) {
|
|
584
|
+
const row = mustGet(uuid);
|
|
585
|
+
if (row.status !== 'active') throw new Error(`trash is active → trashed: ${uuid} is ${row.status}`);
|
|
586
|
+
if (row.parentUuid === null) {
|
|
587
|
+
throw new Error('a tree\'s top record is never trashed: removing a whole tree is an explicit future operation, never a lifecycle flip');
|
|
588
|
+
}
|
|
589
|
+
return commit(row, { status: 'trashed' });
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
restore(uuid) {
|
|
593
|
+
const row = mustGet(uuid);
|
|
594
|
+
if (row.status === 'deleted') throw new Error(`deleted is permanent: ${uuid} cannot be restored`);
|
|
595
|
+
if (row.status !== 'trashed') throw new Error(`restore is trashed → active: ${uuid} is ${row.status}`);
|
|
596
|
+
requireFreeSlot(row.parentUuid, row.name);
|
|
597
|
+
return commit(row, { status: 'active' });
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
deletePermanently(uuid) {
|
|
601
|
+
if (typeof authorizeDelete !== 'function') {
|
|
602
|
+
throw new Error('permanent deletion is unavailable: no ownership authorization is wired (authorizeDelete)');
|
|
603
|
+
}
|
|
604
|
+
const row = mustGet(uuid);
|
|
605
|
+
if (row.status === 'deleted') throw new Error(`${uuid} is already deleted`);
|
|
606
|
+
if (row.status !== 'trashed') {
|
|
607
|
+
throw new Error(`empty-trash deletes from Trash only: ${uuid} is ${row.status} — trash it first`);
|
|
608
|
+
}
|
|
609
|
+
if (!authorizeDelete(record(row))) {
|
|
610
|
+
throw new Error(`permanent deletion is owner-only: not authorized for ${uuid}`);
|
|
611
|
+
}
|
|
612
|
+
return commit(row, { status: 'deleted' });
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Apply one already-authorized cloud operation without creating a new
|
|
617
|
+
* local outbox row. This is intentionally a graph operation only: no
|
|
618
|
+
* observer root, path, inode, or watcher is created by an inbound UUID.
|
|
619
|
+
*/
|
|
620
|
+
applyCloudRecord(value) {
|
|
621
|
+
const incoming = normalizeCloudRecord(value);
|
|
622
|
+
return transaction(() => {
|
|
623
|
+
const previous = store.byUuid(incoming.uuid);
|
|
624
|
+
if (!previous && incoming.parentUuid !== null && !store.byUuid(incoming.parentUuid)) {
|
|
625
|
+
throw new Error(`cloud entity ${incoming.uuid} names missing parent ${incoming.parentUuid}`);
|
|
626
|
+
}
|
|
627
|
+
if (previous) {
|
|
628
|
+
store.update(incoming.uuid, incoming);
|
|
629
|
+
} else {
|
|
630
|
+
store.insert(incoming);
|
|
631
|
+
}
|
|
632
|
+
const current = mustGet(incoming.uuid);
|
|
633
|
+
const synced = syncRecord(current);
|
|
634
|
+
if (synced.version !== incoming.expectedVersion) {
|
|
635
|
+
throw new Error(`cloud entity ${incoming.uuid} version does not match its logical record`);
|
|
636
|
+
}
|
|
637
|
+
const previousRecord = previous ? record(previous) : null;
|
|
638
|
+
if (!previous || !sameLogicalRecord(previous, current)) {
|
|
639
|
+
publish({ type: 'entity.committed', record: record(current), previous: previousRecord });
|
|
640
|
+
}
|
|
641
|
+
return synced;
|
|
642
|
+
});
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
record: (uuid) => record(mustGet(uuid)),
|
|
646
|
+
syncRecord: (uuid) => syncRecord(mustGet(uuid)),
|
|
647
|
+
/** Full logical graph for cloud snapshots. Machine bindings and evidence
|
|
648
|
+
* are intentionally absent. */
|
|
649
|
+
syncRecords: () => store.allEntities().map(syncRecord),
|
|
650
|
+
/** Every registered tree's top record. */
|
|
651
|
+
roots: () => store.activeRoots().map(record),
|
|
652
|
+
/** Every active managed reference with the identity it targets. The
|
|
653
|
+
* six-field record law holds: the target UUID is the payload, and it
|
|
654
|
+
* travels beside the record here, never as a seventh field. */
|
|
655
|
+
references: () => store.activeByType('reference')
|
|
656
|
+
.map((row) => ({ record: record(row), targetUUID: row.payloadVersion })),
|
|
657
|
+
activeAt: (parentUUID, name) => {
|
|
658
|
+
const row = store.activeAtSlot(parentUUID, name);
|
|
659
|
+
return row ? record(row) : null;
|
|
660
|
+
},
|
|
661
|
+
children: (uuid) => store.childrenOf(uuid).map(record),
|
|
662
|
+
effectiveStatus: (uuid) => effectiveStatusOf(mustGet(uuid)),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
module.exports = { createRegistry, canonicalVersion, ENTITY_TYPES };
|