@holmes-lab/holmes-kit 0.19.6 → 0.20.0
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/CHANGELOG.md +96 -0
- package/README.md +8 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +86 -12
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +653 -424
- package/dist/holmes/mcp/handlers.js +267 -3250
- package/dist/holmes/mcp/server.js +2 -1
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +120 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +3 -1
- package/dist/holmes/project/root.js +4 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +132 -4
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +207 -0
- package/dist/holmes/spec/entity-integration.js +747 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1051 -0
- package/dist/holmes/spec/entity-transaction.d.ts +89 -0
- package/dist/holmes/spec/entity-transaction.js +701 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.LEGACY_RENUMBER_JOURNAL_SCHEMA = exports.entityContentVersion = exports.EntityStoreError = void 0;
|
|
37
|
+
exports.entityStoreBinding = entityStoreBinding;
|
|
38
|
+
exports.validateEntityAdoptionPlan = validateEntityAdoptionPlan;
|
|
39
|
+
exports.entityAdoptionTarget = entityAdoptionTarget;
|
|
40
|
+
exports.assertNoPendingEntityPublication = assertNoPendingEntityPublication;
|
|
41
|
+
exports.inspectEntityStore = inspectEntityStore;
|
|
42
|
+
exports.planEntityAdoption = planEntityAdoption;
|
|
43
|
+
exports.assertEntityRepositoryReadable = assertEntityRepositoryReadable;
|
|
44
|
+
exports.readEntityRepositorySnapshot = readEntityRepositorySnapshot;
|
|
45
|
+
exports.readEntityRenumberSnapshot = readEntityRenumberSnapshot;
|
|
46
|
+
exports.withEntityRenumberSnapshot = withEntityRenumberSnapshot;
|
|
47
|
+
exports.writeEntityRepository = writeEntityRepository;
|
|
48
|
+
exports.planEntityRecovery = planEntityRecovery;
|
|
49
|
+
exports.validateEntityRecoveryPlan = validateEntityRecoveryPlan;
|
|
50
|
+
exports.entityRecoveryTarget = entityRecoveryTarget;
|
|
51
|
+
exports.recoverEntityStore = recoverEntityStore;
|
|
52
|
+
exports.adoptEntityStore = adoptEntityStore;
|
|
53
|
+
// @implements A-SPEC-629, A-SPEC-628, A-SPEC-627, A-SPEC-627.1
|
|
54
|
+
// @implements A-SPEC-640
|
|
55
|
+
// @implements A-SPEC-639
|
|
56
|
+
// @implements A-SPEC-638
|
|
57
|
+
// @implements A-SPEC-634
|
|
58
|
+
const fs = __importStar(require("node:fs"));
|
|
59
|
+
const path = __importStar(require("node:path"));
|
|
60
|
+
const node_crypto_1 = require("node:crypto");
|
|
61
|
+
const spec_parser_1 = require("./spec-parser");
|
|
62
|
+
const spec_types_1 = require("./spec-types");
|
|
63
|
+
const root_1 = require("../project/root");
|
|
64
|
+
const workspace_identity_1 = require("../project/workspace-identity");
|
|
65
|
+
const entity_transaction_1 = require("./entity-transaction");
|
|
66
|
+
var entity_transaction_2 = require("./entity-transaction");
|
|
67
|
+
Object.defineProperty(exports, "EntityStoreError", { enumerable: true, get: function () { return entity_transaction_2.EntityStoreError; } });
|
|
68
|
+
Object.defineProperty(exports, "entityContentVersion", { enumerable: true, get: function () { return entity_transaction_2.entityContentVersion; } });
|
|
69
|
+
function entityStoreBinding(storeRoot) {
|
|
70
|
+
const store = path.resolve(storeRoot);
|
|
71
|
+
let ancestor = store;
|
|
72
|
+
while (!(0, entity_transaction_1.entityStat)(ancestor) && path.dirname(ancestor) !== ancestor)
|
|
73
|
+
ancestor = path.dirname(ancestor);
|
|
74
|
+
const resolved = (0, root_1.resolveProjectRoot)(ancestor, { includeGit: false });
|
|
75
|
+
if (resolved.marker !== '.ax')
|
|
76
|
+
throw new entity_transaction_1.EntityStoreError('missing-workspace', 'The configured store must belong to a Holmes workspace.');
|
|
77
|
+
const root = fs.realpathSync(resolved.root);
|
|
78
|
+
// The workspace may be reached through a platform alias (/var on macOS).
|
|
79
|
+
// Normalize that prefix only; retain components inside it for symlink checks.
|
|
80
|
+
let spelling = ancestor;
|
|
81
|
+
while (fs.realpathSync(spelling) !== root && path.dirname(spelling) !== spelling)
|
|
82
|
+
spelling = path.dirname(spelling);
|
|
83
|
+
if (fs.realpathSync(spelling) !== root)
|
|
84
|
+
throw new entity_transaction_1.EntityStoreError('foreign-store', 'The configured store does not descend from the workspace.');
|
|
85
|
+
const relative = path.relative(spelling, store);
|
|
86
|
+
if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative))
|
|
87
|
+
throw new entity_transaction_1.EntityStoreError('foreign-store', 'The configured store must be a directory within the workspace.');
|
|
88
|
+
(0, entity_transaction_1.entityDirectory)(root, '.ax');
|
|
89
|
+
(0, entity_transaction_1.entityDirectory)(root, relative);
|
|
90
|
+
const canonical = physicalStoreLocator(root, relative);
|
|
91
|
+
const registrations = (0, entity_transaction_1.entityDirectory)(root, '.ax/entities/stores');
|
|
92
|
+
let registeredLocator;
|
|
93
|
+
if ((0, entity_transaction_1.entityStat)(registrations))
|
|
94
|
+
for (const name of fs.readdirSync(registrations).filter(name => name.endsWith('.json'))) {
|
|
95
|
+
const record = object(recordJson(path.join(registrations, name)), ['schema', 'workspaceId', 'storeId', 'storeLocator', 'adoptionDigest']);
|
|
96
|
+
if (record.schema !== 'holmes-entity-store/1' || !validUuid(record.workspaceId) || !validUuid(record.storeId) || name !== record.storeId + '.json' || !locator(record.storeLocator) || typeof record.adoptionDigest !== 'string' || !DIGEST.test(record.adoptionDigest))
|
|
97
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid entity store registration.');
|
|
98
|
+
if (physicalStoreLocator(root, record.storeLocator) !== canonical)
|
|
99
|
+
continue;
|
|
100
|
+
if (registeredLocator !== undefined)
|
|
101
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Multiple registrations identify the same physical store.');
|
|
102
|
+
registeredLocator = record.storeLocator;
|
|
103
|
+
}
|
|
104
|
+
// Keep an existing registration's persisted spelling; new stores use actual directory entries.
|
|
105
|
+
return { root, store: path.join(root, canonical), locator: registeredLocator ?? canonical };
|
|
106
|
+
}
|
|
107
|
+
function physicalStoreLocator(root, relative) {
|
|
108
|
+
(0, entity_transaction_1.entityDirectory)(root, relative);
|
|
109
|
+
const parts = relative.split(/[\\/]/), resolved = [];
|
|
110
|
+
let directory = root;
|
|
111
|
+
for (let index = 0; index < parts.length; index++) {
|
|
112
|
+
const part = parts[index];
|
|
113
|
+
let names, wanted;
|
|
114
|
+
try {
|
|
115
|
+
names = fs.readdirSync(directory);
|
|
116
|
+
wanted = fs.lstatSync(path.join(directory, part), { bigint: true });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error.code === 'ENOENT')
|
|
120
|
+
return [...resolved, ...parts.slice(index)].join('/');
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
let name = part;
|
|
124
|
+
if (!names.includes(part)) {
|
|
125
|
+
const matches = names.filter(candidate => {
|
|
126
|
+
const actual = fs.lstatSync(path.join(directory, candidate), { bigint: true });
|
|
127
|
+
return actual.isDirectory() && !actual.isSymbolicLink() && actual.dev === wanted.dev && actual.ino === wanted.ino;
|
|
128
|
+
});
|
|
129
|
+
if (matches.length !== 1)
|
|
130
|
+
throw new entity_transaction_1.EntityStoreError('unsafe-path', 'Cannot uniquely bind the configured store to its physical directory entry.');
|
|
131
|
+
name = matches[0];
|
|
132
|
+
}
|
|
133
|
+
resolved.push(name);
|
|
134
|
+
directory = path.join(directory, name);
|
|
135
|
+
}
|
|
136
|
+
return resolved.join('/');
|
|
137
|
+
}
|
|
138
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
139
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
140
|
+
function object(value, keys) {
|
|
141
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(k => !Object.prototype.hasOwnProperty.call(value, k)))
|
|
142
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Unsupported entity record schema or fields.');
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
function validUuid(v) { return typeof v === 'string' && UUID.test(v); }
|
|
146
|
+
function locator(v) {
|
|
147
|
+
return typeof v === 'string' && v.length > 0 && !/[\\:\x00]/.test(v) && v.split('/').every(p => p.length > 0 && p !== '.' && p !== '..');
|
|
148
|
+
}
|
|
149
|
+
function recordJson(file) {
|
|
150
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(file);
|
|
151
|
+
if (!bytes)
|
|
152
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'An entity record disappeared.');
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Entity state contains malformed JSON.');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function validateEntityAdoptionPlan(value) {
|
|
161
|
+
const p = object(value, ['schema', 'operationId', 'workspaceId', 'storeId', 'storeLocator', 'entries']);
|
|
162
|
+
if (p.schema !== 'holmes-entity-adoption/1' || !validUuid(p.operationId) || !validUuid(p.workspaceId) || !validUuid(p.storeId) || !locator(p.storeLocator) || !Array.isArray(p.entries))
|
|
163
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid adoption plan identifiers or schema.');
|
|
164
|
+
const entities = new Set(), ids = new Set(), paths = new Set();
|
|
165
|
+
const entries = p.entries.map(value => {
|
|
166
|
+
const e = object(value, ['entityId', 'displayId', 'documentLocator', 'version']);
|
|
167
|
+
if (!validUuid(e.entityId) || typeof e.displayId !== 'string' || !e.displayId || !locator(e.documentLocator) || !e.documentLocator.endsWith('.md') || typeof e.version !== 'string' || !DIGEST.test(e.version))
|
|
168
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid adoption inventory entry.');
|
|
169
|
+
if (entities.has(e.entityId) || ids.has(e.displayId) || paths.has(e.documentLocator))
|
|
170
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Duplicate adoption identity or document.');
|
|
171
|
+
entities.add(e.entityId);
|
|
172
|
+
ids.add(e.displayId);
|
|
173
|
+
paths.add(e.documentLocator);
|
|
174
|
+
return { entityId: e.entityId, displayId: e.displayId, documentLocator: e.documentLocator, version: e.version };
|
|
175
|
+
});
|
|
176
|
+
return { schema: 'holmes-entity-adoption/1', operationId: p.operationId, workspaceId: p.workspaceId, storeId: p.storeId, storeLocator: p.storeLocator, entries };
|
|
177
|
+
}
|
|
178
|
+
function entityAdoptionTarget(plan) { return 'entity-store:adopt:' + (0, entity_transaction_1.entityContentVersion)(JSON.stringify(plan)); }
|
|
179
|
+
function registration(bound) {
|
|
180
|
+
const dir = (0, entity_transaction_1.entityDirectory)(bound.root, path.join('.ax', 'entities', 'stores'));
|
|
181
|
+
if (!(0, entity_transaction_1.entityStat)(dir))
|
|
182
|
+
return undefined;
|
|
183
|
+
let found;
|
|
184
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
185
|
+
if (!name.endsWith('.json'))
|
|
186
|
+
continue;
|
|
187
|
+
const r = object(recordJson(path.join(dir, name)), ['schema', 'workspaceId', 'storeId', 'storeLocator', 'adoptionDigest']);
|
|
188
|
+
if (r.schema !== 'holmes-entity-store/1' || !validUuid(r.workspaceId) || !validUuid(r.storeId) || name !== r.storeId + '.json' || !locator(r.storeLocator) || typeof r.adoptionDigest !== 'string' || !DIGEST.test(r.adoptionDigest))
|
|
189
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid entity store registration.');
|
|
190
|
+
if (r.storeLocator === bound.locator) {
|
|
191
|
+
if (found)
|
|
192
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Multiple registrations name the configured store.');
|
|
193
|
+
found = r;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return found;
|
|
197
|
+
}
|
|
198
|
+
/** @implements A-SPEC-638 — a registered store whose subtree strictly contains or is contained by the bound locator. */
|
|
199
|
+
function overlappingRegistration(bound) {
|
|
200
|
+
const dir = (0, entity_transaction_1.entityDirectory)(bound.root, path.join('.ax', 'entities', 'stores'));
|
|
201
|
+
if (!(0, entity_transaction_1.entityStat)(dir))
|
|
202
|
+
return undefined;
|
|
203
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
204
|
+
if (!name.endsWith('.json'))
|
|
205
|
+
continue;
|
|
206
|
+
const r = object(recordJson(path.join(dir, name)), ['schema', 'workspaceId', 'storeId', 'storeLocator', 'adoptionDigest']);
|
|
207
|
+
if (r.schema !== 'holmes-entity-store/1' || !validUuid(r.workspaceId) || !validUuid(r.storeId) || name !== r.storeId + '.json' || !locator(r.storeLocator) || typeof r.adoptionDigest !== 'string' || !DIGEST.test(r.adoptionDigest))
|
|
208
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid entity store registration.');
|
|
209
|
+
const registered = r.storeLocator;
|
|
210
|
+
if (registered !== bound.locator && (registered.startsWith(bound.locator + '/') || bound.locator.startsWith(registered + '/')))
|
|
211
|
+
return r;
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
function assertNoOverlap(bound) {
|
|
216
|
+
const overlap = overlappingRegistration(bound);
|
|
217
|
+
if (overlap)
|
|
218
|
+
throw new entity_transaction_1.EntityStoreError('store-overlap', 'This store overlaps the registered store ' + overlap.storeLocator + '.');
|
|
219
|
+
}
|
|
220
|
+
/** @implements A-SPEC-638 — the raw legacy renumber's rollback journal: the original bytes of every file it touches. */
|
|
221
|
+
exports.LEGACY_RENUMBER_JOURNAL_SCHEMA = 'holmes-legacy-renumber-journal/1';
|
|
222
|
+
function legacyRenumberJournal(raw, operationId) {
|
|
223
|
+
const j = object(raw, ['schema', 'operationId', 'workspaceId', 'storeLocator', 'oldBase', 'newBase', 'changes']);
|
|
224
|
+
if (j.schema !== exports.LEGACY_RENUMBER_JOURNAL_SCHEMA || j.operationId !== operationId || !validUuid(j.workspaceId) || !locator(j.storeLocator) || typeof j.oldBase !== 'string' || typeof j.newBase !== 'string' || !Array.isArray(j.changes))
|
|
225
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid legacy renumber journal.');
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
const changes = j.changes.map(value => {
|
|
228
|
+
const c = object(value, ['locator', 'before']);
|
|
229
|
+
if (!locator(c.locator) || !(c.before === null || (typeof c.before === 'string' && Buffer.from(c.before, 'base64').toString('base64') === c.before)) || seen.has(c.locator))
|
|
230
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid legacy renumber journal change.');
|
|
231
|
+
seen.add(c.locator);
|
|
232
|
+
return { locator: c.locator, before: c.before };
|
|
233
|
+
});
|
|
234
|
+
return { schema: exports.LEGACY_RENUMBER_JOURNAL_SCHEMA, operationId, workspaceId: j.workspaceId, storeLocator: j.storeLocator, oldBase: j.oldBase, newBase: j.newBase, changes };
|
|
235
|
+
}
|
|
236
|
+
/** Inspection may use committed identity before local replica registration; pending writes still block it. */
|
|
237
|
+
function assertNoPendingEntityPublication(storeRoot) {
|
|
238
|
+
assertNoPending(entityStoreBinding(storeRoot));
|
|
239
|
+
}
|
|
240
|
+
function assertNoPending(bound) {
|
|
241
|
+
const dir = (0, entity_transaction_1.entityDirectory)(bound.root, path.join('.ax', 'state', 'entity-transactions'));
|
|
242
|
+
if (!(0, entity_transaction_1.entityStat)(dir))
|
|
243
|
+
return;
|
|
244
|
+
for (const name of fs.readdirSync(dir)) {
|
|
245
|
+
const op = (0, entity_transaction_1.entityDirectory)(dir, name);
|
|
246
|
+
// @implements A-SPEC-634 — a pending directory without a journal is an operation that died
|
|
247
|
+
// between taking its locks and publishing; name it, do not fail on the missing record.
|
|
248
|
+
if (!(0, entity_transaction_1.readEntityBytes)(path.join(op, 'journal.json')))
|
|
249
|
+
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Operation ' + name + ' was interrupted before its journal existed; inspect entity_store recovery-plan and recover it.');
|
|
250
|
+
const raw = recordJson(path.join(op, 'journal.json'));
|
|
251
|
+
if (raw?.schema === 'holmes-entity-integration-journal/1') {
|
|
252
|
+
const journal = object(raw, ['schema', 'plan', 'storeOwner', 'sourceOwner']);
|
|
253
|
+
const plan = journal.plan;
|
|
254
|
+
if (!plan || plan.operationId !== name || !validUuid(plan.operationId) || !locator(plan.storeLocator))
|
|
255
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid integration transaction binding.');
|
|
256
|
+
if (plan.storeLocator === bound.locator)
|
|
257
|
+
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Integration publication is pending; inspect and recover that operation.');
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (['holmes-entity-renumber-journal/1', 'holmes-entity-renumber-journal/2'].includes(raw?.schema ?? '')) {
|
|
261
|
+
const journal = object(raw, raw.schema === 'holmes-entity-renumber-journal/2' ? ['schema', 'plan', 'sourceOwner'] : ['schema', 'plan']);
|
|
262
|
+
const plan = journal.plan;
|
|
263
|
+
if (!plan || plan.operationId !== name || !validUuid(plan.operationId) || !locator(plan.snapshot?.storeLocator))
|
|
264
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid renumber transaction binding.');
|
|
265
|
+
if (plan.snapshot.storeLocator === bound.locator)
|
|
266
|
+
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Renumber publication is pending; use entity_renumber recovery.');
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (raw?.schema === exports.LEGACY_RENUMBER_JOURNAL_SCHEMA) {
|
|
270
|
+
// @implements A-SPEC-638 — an interrupted raw legacy renumber blocks its store until rolled back;
|
|
271
|
+
// while its holder is still alive the journal is a publication in progress, not an interruption.
|
|
272
|
+
const journal = legacyRenumberJournal(raw, name);
|
|
273
|
+
if (journal.storeLocator === bound.locator && !(0, entity_transaction_1.entityLockHolderAlive)(bound.root, bound.locator))
|
|
274
|
+
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Legacy renumber was interrupted; inspect entity_store recovery-plan and recover it.');
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (raw?.schema === 'holmes-entity-write-journal/1') {
|
|
278
|
+
const journal = object(raw, ['schema', 'operationId', 'workspaceId', 'storeId', 'storeLocator', 'changes']);
|
|
279
|
+
if (journal.operationId !== name || !validUuid(journal.operationId) || !validUuid(journal.workspaceId) || !validUuid(journal.storeId) || !locator(journal.storeLocator) || !Array.isArray(journal.changes))
|
|
280
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid document transaction journal.');
|
|
281
|
+
if (journal.storeLocator === bound.locator)
|
|
282
|
+
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Document publication was interrupted; inspect its journal before recovery.');
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const journal = object(raw, ['schema', 'plan']);
|
|
286
|
+
if (journal.schema !== 'holmes-entity-adoption-journal/1')
|
|
287
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Unknown entity transaction journal.');
|
|
288
|
+
const plan = validateEntityAdoptionPlan(journal.plan);
|
|
289
|
+
if (plan.operationId !== name)
|
|
290
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Transaction directory does not match its operation.');
|
|
291
|
+
if (plan.storeLocator === bound.locator)
|
|
292
|
+
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Entity adoption was interrupted; inspect its journal before recovery.');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function assertUnactivated(bound) {
|
|
296
|
+
assertNoPending(bound);
|
|
297
|
+
if (registration(bound))
|
|
298
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-present', 'This store is already activated.');
|
|
299
|
+
assertNoOverlap(bound);
|
|
300
|
+
}
|
|
301
|
+
function inspectEntityStore(storeRoot) {
|
|
302
|
+
const bound = entityStoreBinding(storeRoot);
|
|
303
|
+
assertNoPending(bound);
|
|
304
|
+
const active = registration(bound);
|
|
305
|
+
if (active) {
|
|
306
|
+
validateMappedInventory(bound, active);
|
|
307
|
+
return { ok: true, state: 'active', storeLocator: bound.locator, storeId: active.storeId };
|
|
308
|
+
}
|
|
309
|
+
// @implements A-SPEC-638 — a path inside (or above) a registered store is never a legacy store.
|
|
310
|
+
assertNoOverlap(bound);
|
|
311
|
+
return { ok: true, state: 'legacy', storeLocator: bound.locator };
|
|
312
|
+
}
|
|
313
|
+
function planEntityAdoption(storeRoot) {
|
|
314
|
+
const bound = entityStoreBinding(storeRoot);
|
|
315
|
+
const identity = (0, workspace_identity_1.workspaceIdentity)(bound.root);
|
|
316
|
+
if (identity.state !== 'registered')
|
|
317
|
+
throw new workspace_identity_1.WorkspaceIdentityError('unregistered', 'Register workspace identity before planning entity adoption.');
|
|
318
|
+
assertUnactivated(bound);
|
|
319
|
+
return { schema: 'holmes-entity-adoption/1', operationId: (0, node_crypto_1.randomUUID)(), workspaceId: identity.workspaceId, storeId: (0, node_crypto_1.randomUUID)(), storeLocator: bound.locator, entries: inventory(bound) };
|
|
320
|
+
}
|
|
321
|
+
function inventory(bound, excluded = new Set()) {
|
|
322
|
+
const entries = [], ids = new Set();
|
|
323
|
+
function walk(dir) {
|
|
324
|
+
if (!(0, entity_transaction_1.entityStat)(dir))
|
|
325
|
+
return;
|
|
326
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
327
|
+
const file = path.join(dir, name), s = fs.lstatSync(file);
|
|
328
|
+
if (s.isSymbolicLink())
|
|
329
|
+
throw new entity_transaction_1.EntityStoreError('unsafe-path', 'Adoption inventory cannot follow symlinks.');
|
|
330
|
+
if (s.isDirectory()) {
|
|
331
|
+
walk(file);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (!name.endsWith('.md'))
|
|
335
|
+
continue;
|
|
336
|
+
if (excluded.has(path.relative(bound.store, file).split(path.sep).join('/')))
|
|
337
|
+
continue;
|
|
338
|
+
if (!s.isFile())
|
|
339
|
+
throw new entity_transaction_1.EntityStoreError('unsafe-path', 'Markdown inventory must contain regular files.');
|
|
340
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
341
|
+
let bytes;
|
|
342
|
+
try {
|
|
343
|
+
const opened = fs.fstatSync(fd);
|
|
344
|
+
if (!opened.isFile() || opened.dev !== s.dev || opened.ino !== s.ino)
|
|
345
|
+
throw new entity_transaction_1.EntityStoreError('inventory-changed', 'Inventory changed while opening; retry planning.');
|
|
346
|
+
bytes = fs.readFileSync(fd);
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
fs.closeSync(fd);
|
|
350
|
+
}
|
|
351
|
+
let displayId;
|
|
352
|
+
try {
|
|
353
|
+
displayId = (0, spec_parser_1.parseSpec)(bytes.toString('utf8')).id;
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
throw new entity_transaction_1.EntityStoreError('invalid-inventory', 'A Markdown document could not be parsed: ' + path.relative(bound.store, file));
|
|
357
|
+
}
|
|
358
|
+
if (!displayId || ids.has(displayId))
|
|
359
|
+
throw new entity_transaction_1.EntityStoreError('invalid-inventory', 'Missing or duplicate display ID in adoption inventory.');
|
|
360
|
+
ids.add(displayId);
|
|
361
|
+
entries.push({ entityId: (0, node_crypto_1.randomUUID)(), displayId, documentLocator: path.relative(bound.store, file).split(path.sep).join('/'), version: (0, entity_transaction_1.entityContentVersion)(bytes) });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
walk(bound.store);
|
|
365
|
+
return entries;
|
|
366
|
+
}
|
|
367
|
+
function allEntityRecords(bound) {
|
|
368
|
+
const dir = (0, entity_transaction_1.entityDirectory)(bound.root, path.join('.ax', 'entities', 'records'));
|
|
369
|
+
const records = [];
|
|
370
|
+
if ((0, entity_transaction_1.entityStat)(dir))
|
|
371
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
372
|
+
if (!name.endsWith('.json'))
|
|
373
|
+
continue;
|
|
374
|
+
const r = object(recordJson(path.join(dir, name)), ['schema', 'workspaceId', 'storeId', 'entityId', 'displayId', 'documentLocator', 'origin']);
|
|
375
|
+
const origin = object(r.origin, ['kind', 'operationId']);
|
|
376
|
+
if (r.schema !== 'holmes-entity/1' || !validUuid(r.workspaceId) || !validUuid(r.storeId) || !validUuid(r.entityId) || name !== r.entityId + '.json' || typeof r.displayId !== 'string' || !r.displayId || !locator(r.documentLocator) || !['adopted', 'created'].includes(String(origin.kind)) || !validUuid(origin.operationId))
|
|
377
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid entity mapping.');
|
|
378
|
+
records.push(r);
|
|
379
|
+
}
|
|
380
|
+
return records;
|
|
381
|
+
}
|
|
382
|
+
function validateMappedInventory(bound, active) {
|
|
383
|
+
const identity = (0, workspace_identity_1.workspaceIdentity)(bound.root);
|
|
384
|
+
if (identity.state !== 'registered' || identity.workspaceId !== active.workspaceId)
|
|
385
|
+
throw new entity_transaction_1.EntityStoreError('workspace-mismatch', 'Entity registration does not match the registered workspace.');
|
|
386
|
+
const records = allEntityRecords(bound).filter(record => record.storeId === active.storeId);
|
|
387
|
+
const docs = inventory(bound);
|
|
388
|
+
if (records.length !== docs.length || records.some(r => r.workspaceId !== active.workspaceId) || new Set(records.map(r => r.displayId)).size !== records.length || new Set(records.map(r => r.documentLocator)).size !== records.length || docs.some(d => !records.some(r => r.displayId === d.displayId && r.documentLocator === d.documentLocator)))
|
|
389
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Canonical entity mappings and physical documents disagree.');
|
|
390
|
+
return records;
|
|
391
|
+
}
|
|
392
|
+
/** Read barrier for the real repository. Legacy stores without an entity subsystem remain untouched. */
|
|
393
|
+
function assertEntityRepositoryReadable(storeRoot) {
|
|
394
|
+
let ancestor = path.resolve(storeRoot);
|
|
395
|
+
while (!(0, entity_transaction_1.entityStat)(ancestor) && path.dirname(ancestor) !== ancestor)
|
|
396
|
+
ancestor = path.dirname(ancestor);
|
|
397
|
+
const workspace = (0, root_1.resolveProjectRoot)(ancestor, { includeGit: false });
|
|
398
|
+
if (workspace.marker !== '.ax')
|
|
399
|
+
return;
|
|
400
|
+
if (!(0, entity_transaction_1.entityStat)(path.join(workspace.root, '.ax', 'entities')) && !(0, entity_transaction_1.entityStat)(path.join(workspace.root, '.ax', 'state', 'entity-transactions')))
|
|
401
|
+
return;
|
|
402
|
+
const bound = entityStoreBinding(storeRoot);
|
|
403
|
+
assertNoPending(bound);
|
|
404
|
+
const active = registration(bound);
|
|
405
|
+
if (active)
|
|
406
|
+
validateMappedInventory(bound, active);
|
|
407
|
+
}
|
|
408
|
+
function bindingWithEntityState(storeRoot, requireEntityState = true) {
|
|
409
|
+
let ancestor = path.resolve(storeRoot);
|
|
410
|
+
while (!(0, entity_transaction_1.entityStat)(ancestor) && path.dirname(ancestor) !== ancestor)
|
|
411
|
+
ancestor = path.dirname(ancestor);
|
|
412
|
+
const workspace = (0, root_1.resolveProjectRoot)(ancestor, { includeGit: false });
|
|
413
|
+
if (workspace.marker !== '.ax')
|
|
414
|
+
return undefined;
|
|
415
|
+
if (requireEntityState && !(0, entity_transaction_1.entityStat)(path.join(workspace.root, '.ax', 'entities')) && !(0, entity_transaction_1.entityStat)(path.join(workspace.root, '.ax', 'state', 'entity-transactions')))
|
|
416
|
+
return undefined;
|
|
417
|
+
// A workspace-root store cannot be adopted (it has no relative locator).
|
|
418
|
+
// Preserve its legacy path without silently changing its supported layout.
|
|
419
|
+
if (!requireEntityState && path.resolve(storeRoot) === path.resolve(workspace.root))
|
|
420
|
+
return undefined;
|
|
421
|
+
return entityStoreBinding(storeRoot);
|
|
422
|
+
}
|
|
423
|
+
function readEntityRepositorySnapshot(storeRoot) {
|
|
424
|
+
const bound = bindingWithEntityState(storeRoot);
|
|
425
|
+
if (!bound)
|
|
426
|
+
return undefined;
|
|
427
|
+
assertNoPending(bound);
|
|
428
|
+
if (!registration(bound))
|
|
429
|
+
return undefined;
|
|
430
|
+
return (0, entity_transaction_1.withEntityStoreLock)(bound.root, bound.locator, () => {
|
|
431
|
+
assertNoPending(bound);
|
|
432
|
+
const active = registration(bound);
|
|
433
|
+
if (!active)
|
|
434
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'Store registration disappeared.');
|
|
435
|
+
return validateMappedInventory(bound, active).map(r => {
|
|
436
|
+
const dir = (0, entity_transaction_1.entityDirectory)(bound.store, path.dirname(r.documentLocator));
|
|
437
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(path.join(dir, path.basename(r.documentLocator)));
|
|
438
|
+
if (!bytes)
|
|
439
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'A mapped document disappeared.');
|
|
440
|
+
const spec = (0, spec_parser_1.parseSpec)(bytes.toString('utf8'));
|
|
441
|
+
if (spec.id !== r.displayId)
|
|
442
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'A mapped document changed identity.');
|
|
443
|
+
return { spec, version: (0, entity_transaction_1.entityContentVersion)(bytes) };
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** A raw, UUID-bound snapshot for renumber planning; no identity allocation or canonical writes. */
|
|
448
|
+
function readEntityRenumberSnapshot(storeRoot) {
|
|
449
|
+
const bound = entityStoreBinding(storeRoot);
|
|
450
|
+
assertNoPending(bound);
|
|
451
|
+
if (!registration(bound))
|
|
452
|
+
throw new entity_transaction_1.EntityStoreError('adoption-required', 'Explicitly adopt this store before entity renumbering.');
|
|
453
|
+
return (0, entity_transaction_1.withEntityStoreLock)(bound.root, bound.locator, () => renumberSnapshot(bound));
|
|
454
|
+
}
|
|
455
|
+
/** Supply a validated snapshot while retaining the same store ownership through publication. */
|
|
456
|
+
function withEntityRenumberSnapshot(storeRoot, act) {
|
|
457
|
+
const bound = entityStoreBinding(storeRoot);
|
|
458
|
+
assertNoPending(bound);
|
|
459
|
+
return (0, entity_transaction_1.withEntityStoreLockAsync)(bound.root, bound.locator, () => {
|
|
460
|
+
const snapshot = renumberSnapshot(bound);
|
|
461
|
+
return act(snapshot, () => {
|
|
462
|
+
if (JSON.stringify(renumberSnapshot(bound)) !== JSON.stringify(snapshot))
|
|
463
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'The document or identity inventory changed during asynchronous planning.');
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
function renumberSnapshot(bound) {
|
|
468
|
+
assertNoPending(bound);
|
|
469
|
+
const active = registration(bound);
|
|
470
|
+
if (!active)
|
|
471
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'Store registration disappeared.');
|
|
472
|
+
const records = validateMappedInventory(bound, active);
|
|
473
|
+
const registrationBytes = (0, entity_transaction_1.readEntityBytes)(path.join(bound.root, '.ax/entities/stores', active.storeId + '.json'));
|
|
474
|
+
if (!registrationBytes)
|
|
475
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'Store registration disappeared.');
|
|
476
|
+
const entries = records.map(record => {
|
|
477
|
+
const document = path.join(bound.store, record.documentLocator);
|
|
478
|
+
const mapping = path.join(bound.root, '.ax/entities/records', record.entityId + '.json');
|
|
479
|
+
const text = (0, entity_transaction_1.readEntityBytes)(document), mappingBytes = (0, entity_transaction_1.readEntityBytes)(mapping);
|
|
480
|
+
if (!text || !mappingBytes || (0, spec_parser_1.parseSpec)(text.toString('utf8')).id !== record.displayId)
|
|
481
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'Document or mapping changed during renumber planning.');
|
|
482
|
+
return { entityId: record.entityId, documentLocator: record.documentLocator, text: text.toString('utf8'), mapping: mappingBytes.toString('utf8'), mappingVersion: (0, entity_transaction_1.entityContentVersion)(mappingBytes) };
|
|
483
|
+
});
|
|
484
|
+
const referenceNamespaces = allEntityRecords(bound).map(record => ({ entityId: record.entityId, displayId: record.displayId, storeId: record.storeId, workspaceId: record.workspaceId }));
|
|
485
|
+
return { workspaceId: active.workspaceId, storeId: active.storeId, storeLocator: bound.locator, registrationVersion: (0, entity_transaction_1.entityContentVersion)(registrationBytes), entries, referenceNamespaces };
|
|
486
|
+
}
|
|
487
|
+
async function writeEntityRepository(storeRoot, spec, targetPath, expectedVersion, legacyWrite) {
|
|
488
|
+
const bound = bindingWithEntityState(storeRoot, false);
|
|
489
|
+
if (!bound)
|
|
490
|
+
return legacyWrite();
|
|
491
|
+
targetPath = path.resolve(bound.store, path.relative(path.resolve(storeRoot), path.resolve(targetPath)));
|
|
492
|
+
return (0, entity_transaction_1.withEntityStoreLockAsync)(bound.root, bound.locator, () => {
|
|
493
|
+
assertNoPending(bound);
|
|
494
|
+
const active = registration(bound);
|
|
495
|
+
if (!active)
|
|
496
|
+
return legacyWrite();
|
|
497
|
+
const records = validateMappedInventory(bound, active), previous = records.find(r => r.displayId === spec.id);
|
|
498
|
+
const oldFile = previous ? path.join(bound.store, previous.documentLocator) : undefined;
|
|
499
|
+
const before = oldFile ? (0, entity_transaction_1.readEntityBytes)(oldFile) : undefined;
|
|
500
|
+
if (previous && !before)
|
|
501
|
+
throw new entity_transaction_1.EntityStoreError('external-change', 'A validated document disappeared before writing.');
|
|
502
|
+
const currentVersion = before ? (0, entity_transaction_1.entityContentVersion)(before) : undefined;
|
|
503
|
+
if (expectedVersion !== undefined && currentVersion !== expectedVersion)
|
|
504
|
+
throw new entity_transaction_1.EntityStoreError('version-conflict', `spec ${spec.id} changed since it was read (expected ${expectedVersion}, found ${currentVersion ?? 'nothing'})`);
|
|
505
|
+
const relative = path.relative(bound.store, path.resolve(targetPath)).split(path.sep).join('/');
|
|
506
|
+
if (!locator(relative))
|
|
507
|
+
throw new entity_transaction_1.EntityStoreError('unsafe-path', 'Document destination must stay inside the configured store.');
|
|
508
|
+
(0, entity_transaction_1.entityDirectory)(bound.store, path.dirname(relative));
|
|
509
|
+
if (oldFile !== targetPath && (0, entity_transaction_1.entityStat)(targetPath))
|
|
510
|
+
throw new entity_transaction_1.EntityStoreError('target-occupied', 'Document destination is occupied by another file.');
|
|
511
|
+
const previousMapFile = previous ? path.join(bound.root, '.ax/entities/records', previous.entityId + '.json') : undefined;
|
|
512
|
+
const mappingBefore = previousMapFile ? (0, entity_transaction_1.readEntityBytes)(previousMapFile) : undefined;
|
|
513
|
+
if (previous) {
|
|
514
|
+
let current;
|
|
515
|
+
try {
|
|
516
|
+
current = mappingBefore && JSON.parse(mappingBefore.toString('utf8'));
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
throw new entity_transaction_1.EntityStoreError('external-change', 'A validated entity mapping changed before writing.');
|
|
520
|
+
}
|
|
521
|
+
if (JSON.stringify(current) !== JSON.stringify(previous))
|
|
522
|
+
throw new entity_transaction_1.EntityStoreError('external-change', 'A validated entity mapping changed before writing.');
|
|
523
|
+
}
|
|
524
|
+
const operationId = (0, node_crypto_1.randomUUID)(), text = (0, spec_parser_1.serializeSpec)(spec);
|
|
525
|
+
const mapping = previous ? { ...previous, documentLocator: relative } : { schema: 'holmes-entity/1', workspaceId: active.workspaceId, storeId: active.storeId, entityId: (0, node_crypto_1.randomUUID)(), displayId: spec.id, documentLocator: relative, origin: { kind: 'created', operationId } };
|
|
526
|
+
const mapFile = path.join(bound.root, '.ax/entities/records', mapping.entityId + '.json');
|
|
527
|
+
if (!previous && (0, entity_transaction_1.entityStat)(mapFile))
|
|
528
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-present', 'New entity identifier is occupied.');
|
|
529
|
+
// Journal the inputs that were validated, never a fresh read that could silently
|
|
530
|
+
// adopt an intervening external edit. New destinations must remain absent.
|
|
531
|
+
const change = (file, input, after) => ({ locator: path.relative(bound.root, file).split(path.sep).join('/'), before: input?.toString('base64') ?? null, after: after?.toString('base64') ?? null });
|
|
532
|
+
const changes = [change(targetPath, oldFile === targetPath ? before : undefined, Buffer.from(text)), change(mapFile, mappingBefore, Buffer.from(JSON.stringify(mapping) + '\n'))];
|
|
533
|
+
if (oldFile && oldFile !== targetPath)
|
|
534
|
+
changes.push(change(oldFile, before, null));
|
|
535
|
+
for (const c of changes)
|
|
536
|
+
(0, entity_transaction_1.assertEntityByteInput)(bound.root, c);
|
|
537
|
+
const journal = path.join('.ax/state/entity-transactions', operationId, 'journal.json');
|
|
538
|
+
const journalText = JSON.stringify({ schema: 'holmes-entity-write-journal/1', operationId, workspaceId: active.workspaceId, storeId: active.storeId, storeLocator: bound.locator, changes }) + '\n';
|
|
539
|
+
(0, entity_transaction_1.publishEntityRecord)(bound.root, journal, journalText);
|
|
540
|
+
for (const c of changes)
|
|
541
|
+
(0, entity_transaction_1.applyEntityByteChange)(bound.root, c);
|
|
542
|
+
// The old location is removed only after the new document and mapping were published.
|
|
543
|
+
(0, entity_transaction_1.retireEntityOperation)(bound.root, operationId, bound.locator, (0, entity_transaction_1.entityContentVersion)(journalText));
|
|
544
|
+
return { version: (0, entity_transaction_1.entityContentVersion)(text) };
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function adoptionChanges(plan) {
|
|
548
|
+
const changes = plan.entries.map(e => ({ locator: '.ax/entities/records/' + e.entityId + '.json', before: null, after: Buffer.from(JSON.stringify({ schema: 'holmes-entity/1', workspaceId: plan.workspaceId, storeId: plan.storeId, entityId: e.entityId, displayId: e.displayId, documentLocator: e.documentLocator, origin: { kind: 'adopted', operationId: plan.operationId } }) + '\n').toString('base64') }));
|
|
549
|
+
changes.push({ locator: '.ax/entities/stores/' + plan.storeId + '.json', before: null, after: Buffer.from(JSON.stringify({ schema: 'holmes-entity-store/1', workspaceId: plan.workspaceId, storeId: plan.storeId, storeLocator: plan.storeLocator, adoptionDigest: (0, entity_transaction_1.entityContentVersion)(JSON.stringify(plan)) }) + '\n').toString('base64') });
|
|
550
|
+
return changes;
|
|
551
|
+
}
|
|
552
|
+
/** Inspection is not authorization to execute a journal or remove its lock. */
|
|
553
|
+
function planEntityRecovery(storeRoot) {
|
|
554
|
+
const bound = entityStoreBinding(storeRoot), identity = (0, workspace_identity_1.workspaceIdentity)(bound.root);
|
|
555
|
+
if (identity.state !== 'registered')
|
|
556
|
+
throw new workspace_identity_1.WorkspaceIdentityError('unregistered', 'Register workspace identity before inspecting recovery.');
|
|
557
|
+
const transactionDir = (0, entity_transaction_1.entityDirectory)(bound.root, '.ax/state/entity-transactions');
|
|
558
|
+
const lockOwner = (0, entity_transaction_1.entityLockOwner)(bound.root, bound.locator);
|
|
559
|
+
const locations = ((0, entity_transaction_1.entityStat)(transactionDir) ? fs.readdirSync(transactionDir).sort() : []).map(name => ({ name, retired: false, dir: (0, entity_transaction_1.entityDirectory)(transactionDir, name) }));
|
|
560
|
+
const completedDir = (0, entity_transaction_1.entityDirectory)(bound.root, '.ax/state/entity-completed');
|
|
561
|
+
if (lockOwner && (0, entity_transaction_1.entityStat)(completedDir))
|
|
562
|
+
for (const name of fs.readdirSync(completedDir).sort()) {
|
|
563
|
+
if (!validUuid(name))
|
|
564
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid completed operation directory.');
|
|
565
|
+
const dir = (0, entity_transaction_1.entityDirectory)(completedDir, name), receiptFile = path.join(dir, 'completion.json');
|
|
566
|
+
if (!(0, entity_transaction_1.entityStat)(receiptFile))
|
|
567
|
+
continue; // Older receipts cannot establish ownership.
|
|
568
|
+
const receipt = object(recordJson(receiptFile), ['schema', 'operationId', 'storeLocator', 'lockOwner', 'journalVersion']);
|
|
569
|
+
if (receipt.schema !== 'holmes-entity-completion/1' || receipt.operationId !== name || !locator(receipt.storeLocator))
|
|
570
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid completed operation receipt.');
|
|
571
|
+
if (receipt.storeLocator !== bound.locator || JSON.stringify(receipt.lockOwner) !== JSON.stringify(lockOwner))
|
|
572
|
+
continue;
|
|
573
|
+
const journal = (0, entity_transaction_1.readEntityBytes)(path.join(dir, 'journal.json'));
|
|
574
|
+
if (!journal || receipt.journalVersion !== (0, entity_transaction_1.entityContentVersion)(journal))
|
|
575
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Completed journal no longer matches its receipt.');
|
|
576
|
+
locations.push({ name, retired: true, dir });
|
|
577
|
+
}
|
|
578
|
+
const operations = [];
|
|
579
|
+
let aborted = false;
|
|
580
|
+
for (const { name, retired, dir } of locations) {
|
|
581
|
+
if (!validUuid(name))
|
|
582
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid transaction directory.');
|
|
583
|
+
const journalFile = path.join(dir, 'journal.json');
|
|
584
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(journalFile);
|
|
585
|
+
if (!bytes) {
|
|
586
|
+
if (retired)
|
|
587
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'A completed operation has no journal.');
|
|
588
|
+
// @implements A-SPEC-634 — aborted before its journal: the directory holds at most the staged
|
|
589
|
+
// temporary of the journal that never got published. Reported with its leftovers and both
|
|
590
|
+
// dead owners so the exact object the operator approves names everything recovery removes.
|
|
591
|
+
const active = registration(bound);
|
|
592
|
+
if (!active)
|
|
593
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'An aborted operation exists for a store without an active registration.');
|
|
594
|
+
operations.push({ operationId: name, kind: 'aborted', storeId: active.storeId, journalVersion: null, state: 'empty', targets: [], inventoryConflicts: [], leftovers: fs.readdirSync(dir).sort() });
|
|
595
|
+
aborted = true;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
let raw;
|
|
599
|
+
try {
|
|
600
|
+
raw = JSON.parse(bytes.toString('utf8'));
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Malformed recovery journal.');
|
|
604
|
+
}
|
|
605
|
+
let changes, storeId, kind;
|
|
606
|
+
const inventoryConflicts = [];
|
|
607
|
+
if (raw?.schema === 'holmes-entity-integration-journal/1') {
|
|
608
|
+
// @implements A-SPEC-639 — an integration journal belongs to entity_integrate; name that tool instead of failing on its shape.
|
|
609
|
+
const journal = object(raw, ['schema', 'plan', 'storeOwner', 'sourceOwner']);
|
|
610
|
+
const plan = journal.plan;
|
|
611
|
+
if (!plan || plan.operationId !== name || !validUuid(plan.operationId) || !locator(plan.storeLocator))
|
|
612
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid integration recovery binding.');
|
|
613
|
+
if (plan.storeLocator !== bound.locator)
|
|
614
|
+
continue;
|
|
615
|
+
throw new entity_transaction_1.EntityStoreError('integration-recovery-required', 'Use entity_integrate recovery-plan/recover/abandon for this pending integration operation.');
|
|
616
|
+
}
|
|
617
|
+
if (['holmes-entity-renumber-journal/1', 'holmes-entity-renumber-journal/2'].includes(raw?.schema ?? '')) {
|
|
618
|
+
const journal = object(raw, raw.schema === 'holmes-entity-renumber-journal/2' ? ['schema', 'plan', 'sourceOwner'] : ['schema', 'plan']);
|
|
619
|
+
const plan = journal.plan;
|
|
620
|
+
if (!plan || plan.operationId !== name || !validUuid(plan.operationId) || !validUuid(plan.snapshot?.workspaceId) || !validUuid(plan.snapshot?.storeId) || !locator(plan.snapshot?.storeLocator))
|
|
621
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid renumber recovery binding.');
|
|
622
|
+
if (plan.snapshot.storeLocator !== bound.locator)
|
|
623
|
+
continue;
|
|
624
|
+
throw new entity_transaction_1.EntityStoreError('renumber-recovery-required', 'Use entity_renumber recovery-plan/recover for this pending renumber operation.');
|
|
625
|
+
}
|
|
626
|
+
if (raw?.schema === exports.LEGACY_RENUMBER_JOURNAL_SCHEMA) {
|
|
627
|
+
// @implements A-SPEC-638 — a legacy renumber journal is a rollback program: every target that
|
|
628
|
+
// differs from its journaled original is `applied` and will be restored.
|
|
629
|
+
const journal = legacyRenumberJournal(raw, name);
|
|
630
|
+
if (journal.storeLocator !== bound.locator)
|
|
631
|
+
continue;
|
|
632
|
+
if (journal.workspaceId !== identity.workspaceId)
|
|
633
|
+
throw new entity_transaction_1.EntityStoreError('workspace-mismatch', 'Recovery journal belongs to another workspace.');
|
|
634
|
+
const version = (v) => v === null ? null : (0, entity_transaction_1.entityContentVersion)(Buffer.from(v, 'base64'));
|
|
635
|
+
const legacyTargets = journal.changes.map(c => {
|
|
636
|
+
const current = (0, entity_transaction_1.observeEntityTarget)(bound.root, c.locator).bytes?.toString('base64') ?? null; // @implements A-SPEC-640
|
|
637
|
+
return { locator: c.locator, beforeVersion: version(c.before), afterVersion: null, currentVersion: version(current), state: (current === c.before ? 'unchanged' : 'applied') };
|
|
638
|
+
});
|
|
639
|
+
const applied = legacyTargets.filter(t => t.state === 'applied').length;
|
|
640
|
+
const legacyState = applied === 0 ? 'unapplied' : applied === legacyTargets.length ? 'applied' : 'partial';
|
|
641
|
+
if (!(0, entity_transaction_1.readEntityBytes)(journalFile)?.equals(bytes))
|
|
642
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'Journal changed during inspection; inspect again.');
|
|
643
|
+
operations.push({ operationId: name, kind: 'legacy-renumber', storeId: null, journalVersion: (0, entity_transaction_1.entityContentVersion)(bytes), state: legacyState, targets: legacyTargets, inventoryConflicts: [], ...(retired ? { retired: true } : {}) });
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (raw?.schema === 'holmes-entity-adoption-journal/1') {
|
|
647
|
+
const journal = object(raw, ['schema', 'plan']), plan = validateEntityAdoptionPlan(journal.plan);
|
|
648
|
+
if (plan.operationId !== name)
|
|
649
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Journal operation does not match its directory.');
|
|
650
|
+
if (plan.storeLocator !== bound.locator)
|
|
651
|
+
continue;
|
|
652
|
+
if (plan.workspaceId !== identity.workspaceId)
|
|
653
|
+
throw new entity_transaction_1.EntityStoreError('workspace-mismatch', 'Recovery journal belongs to another workspace.');
|
|
654
|
+
kind = 'adoption';
|
|
655
|
+
storeId = plan.storeId;
|
|
656
|
+
changes = adoptionChanges(plan);
|
|
657
|
+
const actual = inventory(bound);
|
|
658
|
+
for (const e of plan.entries)
|
|
659
|
+
if (!actual.some(d => d.documentLocator === e.documentLocator && d.displayId === e.displayId && d.version === e.version))
|
|
660
|
+
inventoryConflicts.push(e.documentLocator);
|
|
661
|
+
for (const d of actual)
|
|
662
|
+
if (!plan.entries.some(e => e.documentLocator === d.documentLocator))
|
|
663
|
+
inventoryConflicts.push(d.documentLocator);
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
const journal = object(raw, ['schema', 'operationId', 'workspaceId', 'storeId', 'storeLocator', 'changes']);
|
|
667
|
+
if (journal.schema !== 'holmes-entity-write-journal/1' || journal.operationId !== name || !validUuid(journal.storeId) || !validUuid(journal.workspaceId) || !locator(journal.storeLocator) || !Array.isArray(journal.changes))
|
|
668
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid write recovery journal.');
|
|
669
|
+
if (journal.storeLocator !== bound.locator)
|
|
670
|
+
continue;
|
|
671
|
+
if (journal.workspaceId !== identity.workspaceId)
|
|
672
|
+
throw new entity_transaction_1.EntityStoreError('workspace-mismatch', 'Recovery journal belongs to another workspace.');
|
|
673
|
+
const active = registration(bound);
|
|
674
|
+
if (!active || active.storeId !== journal.storeId || active.workspaceId !== identity.workspaceId)
|
|
675
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Write recovery journal does not match the active store.');
|
|
676
|
+
kind = 'write';
|
|
677
|
+
storeId = journal.storeId;
|
|
678
|
+
const seen = new Set();
|
|
679
|
+
changes = journal.changes.map(value => {
|
|
680
|
+
const c = object(value, ['locator', 'before', 'after']);
|
|
681
|
+
const validBytes = (v) => v === null || (typeof v === 'string' && Buffer.from(v, 'base64').toString('base64') === v);
|
|
682
|
+
if (!locator(c.locator) || !validBytes(c.before) || !validBytes(c.after) || seen.has(c.locator))
|
|
683
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid recovery change.');
|
|
684
|
+
const document = c.locator.startsWith(bound.locator + '/') && c.locator.endsWith('.md');
|
|
685
|
+
const mapping = c.locator.startsWith('.ax/entities/records/') && validUuid(c.locator.slice('.ax/entities/records/'.length, -5)) && c.locator.endsWith('.json');
|
|
686
|
+
if (!document && !mapping)
|
|
687
|
+
throw new entity_transaction_1.EntityStoreError('unsafe-path', 'A write journal target escapes the configured document/mapping scope.');
|
|
688
|
+
seen.add(c.locator);
|
|
689
|
+
return { locator: c.locator, before: c.before, after: c.after };
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
const targets = changes.map(c => {
|
|
693
|
+
const current = (0, entity_transaction_1.observeEntityTarget)(bound.root, c.locator).bytes?.toString('base64') ?? null; // @implements A-SPEC-640
|
|
694
|
+
const version = (v) => v === null ? null : (0, entity_transaction_1.entityContentVersion)(Buffer.from(v, 'base64'));
|
|
695
|
+
const state = current === c.before && c.before === c.after ? 'unchanged' : current === c.after ? 'applied' : current === c.before ? 'unapplied' : 'conflict';
|
|
696
|
+
return { locator: c.locator, beforeVersion: version(c.before), afterVersion: version(c.after), currentVersion: version(current), state };
|
|
697
|
+
});
|
|
698
|
+
const changed = targets.filter(t => t.state !== 'unchanged');
|
|
699
|
+
const state = inventoryConflicts.length || changed.some(t => t.state === 'conflict') ? 'conflict' : changed.every(t => t.state === 'applied') ? 'applied' : changed.every(t => t.state === 'unapplied') ? 'unapplied' : 'partial';
|
|
700
|
+
if (!(0, entity_transaction_1.readEntityBytes)(journalFile)?.equals(bytes))
|
|
701
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'Journal changed during inspection; inspect again.');
|
|
702
|
+
operations.push({ operationId: name, kind: kind, storeId: storeId, journalVersion: (0, entity_transaction_1.entityContentVersion)(bytes), state, targets, inventoryConflicts, ...(retired ? { retired: true } : {}) });
|
|
703
|
+
}
|
|
704
|
+
const sourceOwner = (0, entity_transaction_1.entityLockOwner)(bound.root, entity_transaction_1.SOURCE_PUBLICATION_LOCATOR);
|
|
705
|
+
// The shared source-publication owner is reported only beside an aborted operation (or a lock
|
|
706
|
+
// with nothing pending), where releasing it is part of what the operator approves.
|
|
707
|
+
const showSource = sourceOwner && (aborted || (lockOwner && operations.length === 0));
|
|
708
|
+
return { schema: 'holmes-entity-recovery/1', workspaceId: identity.workspaceId, storeLocator: bound.locator, operations, ...(lockOwner ? { lockOwner } : {}), ...(showSource ? { sourceOwner } : {}) };
|
|
709
|
+
}
|
|
710
|
+
function approvedRecoveryJournal(file, version) {
|
|
711
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(file);
|
|
712
|
+
if (!bytes || (0, entity_transaction_1.entityContentVersion)(bytes) !== version)
|
|
713
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'Recovery journal changed after approval; inspect and authorize the new plan.');
|
|
714
|
+
return bytes;
|
|
715
|
+
}
|
|
716
|
+
function recoverDraftDocument(bound, operationId, journalVersion, policy, onComplete) {
|
|
717
|
+
const opDir = (0, entity_transaction_1.entityDirectory)(bound.root, '.ax/state/entity-transactions/' + operationId), journalFile = path.join(opDir, 'journal.json');
|
|
718
|
+
const journalBytes = approvedRecoveryJournal(journalFile, journalVersion);
|
|
719
|
+
const journal = object(JSON.parse(journalBytes.toString('utf8')), ['schema', 'operationId', 'workspaceId', 'storeId', 'storeLocator', 'changes']);
|
|
720
|
+
const active = registration(bound);
|
|
721
|
+
if (!active || journal.schema !== 'holmes-entity-write-journal/1' || journal.operationId !== operationId || journal.storeId !== active.storeId || journal.workspaceId !== active.workspaceId || journal.storeLocator !== bound.locator || !Array.isArray(journal.changes))
|
|
722
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Write journal does not belong to this active store.');
|
|
723
|
+
const changes = journal.changes; // The inspected plan validated byte encodings and containment.
|
|
724
|
+
if (changes.length < 2 || changes.length > 3)
|
|
725
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Unsupported document transaction shape.');
|
|
726
|
+
const [document, mapping, deletion] = changes;
|
|
727
|
+
function decodeRecord(bytes) {
|
|
728
|
+
if (bytes === null)
|
|
729
|
+
return null;
|
|
730
|
+
let value;
|
|
731
|
+
try {
|
|
732
|
+
value = JSON.parse(Buffer.from(bytes, 'base64').toString('utf8'));
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Malformed mapping payload.');
|
|
736
|
+
}
|
|
737
|
+
const r = object(value, ['schema', 'workspaceId', 'storeId', 'entityId', 'displayId', 'documentLocator', 'origin']);
|
|
738
|
+
const origin = object(r.origin, ['kind', 'operationId']);
|
|
739
|
+
if (r.schema !== 'holmes-entity/1' || !validUuid(r.workspaceId) || !validUuid(r.storeId) || !validUuid(r.entityId) || typeof r.displayId !== 'string' || !r.displayId || !locator(r.documentLocator) || !r.documentLocator.endsWith('.md') || !['adopted', 'created'].includes(String(origin.kind)) || !validUuid(origin.operationId))
|
|
740
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid mapping payload.');
|
|
741
|
+
return r;
|
|
742
|
+
}
|
|
743
|
+
const before = decodeRecord(mapping.before), after = decodeRecord(mapping.after);
|
|
744
|
+
if (!after || after.storeId !== active.storeId || after.workspaceId !== active.workspaceId || mapping.locator !== '.ax/entities/records/' + after.entityId + '.json' || document.locator !== bound.locator + '/' + after.documentLocator || document.after === null)
|
|
745
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Document and mapping destinations disagree.');
|
|
746
|
+
if (before ? JSON.stringify({ ...before, documentLocator: after.documentLocator }) !== JSON.stringify(after) : after.origin.kind !== 'created' || after.origin.operationId !== operationId)
|
|
747
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Recovery cannot change entity identity or origin.');
|
|
748
|
+
const relocated = before && before.documentLocator !== after.documentLocator;
|
|
749
|
+
if (relocated ? !deletion || deletion.locator !== bound.locator + '/' + before.documentLocator || deletion.before === null || deletion.after !== null || document.before !== null : !!deletion || (before ? document.before === null : document.before !== null))
|
|
750
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Invalid create/update/relocation source relationship.');
|
|
751
|
+
const specs = [document.before, document.after, deletion?.before].filter((s) => typeof s === 'string').map(bytes => (0, spec_parser_1.parseSpec)(Buffer.from(bytes, 'base64').toString('utf8')));
|
|
752
|
+
if (specs.some(s => s.id !== after.displayId || !(0, spec_types_1.specTypeDef)(s.type)?.idRegex.test(s.id)))
|
|
753
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Document identity does not match its entity record.');
|
|
754
|
+
const needsPolicy = specs.some(s => s.status !== 'draft' || s.frontmatter.approved_digest !== undefined || s.frontmatter.parent_digests !== undefined);
|
|
755
|
+
if (needsPolicy && !policy)
|
|
756
|
+
throw new entity_transaction_1.EntityStoreError('spec-approval-required', 'Sealed or non-draft recovery requires the original spec transition authority and act-time validation; config-write approval is insufficient.');
|
|
757
|
+
const recordsDir = (0, entity_transaction_1.entityDirectory)(bound.root, '.ax/entities/records');
|
|
758
|
+
const records = ((0, entity_transaction_1.entityStat)(recordsDir) ? fs.readdirSync(recordsDir).filter(n => n.endsWith('.json')).map(n => {
|
|
759
|
+
const r = decodeRecord((0, entity_transaction_1.readEntityBytes)(path.join(recordsDir, n)).toString('base64'));
|
|
760
|
+
if (n !== r.entityId + '.json')
|
|
761
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Mapping filename and identity disagree.');
|
|
762
|
+
return r;
|
|
763
|
+
}) : []).filter(r => r.storeId === active.storeId && r.entityId !== after.entityId).concat(after);
|
|
764
|
+
const excluded = new Set([document.locator, ...(deletion ? [deletion.locator] : [])].map(p => p.slice(bound.locator.length + 1)));
|
|
765
|
+
const policyInputs = inventory(bound, excluded);
|
|
766
|
+
const docs = policyInputs.map(d => ({ displayId: d.displayId, documentLocator: d.documentLocator })).concat({ displayId: after.displayId, documentLocator: after.documentLocator });
|
|
767
|
+
if (records.length !== docs.length || records.some(r => r.workspaceId !== active.workspaceId) || new Set(records.map(r => r.displayId)).size !== records.length || new Set(records.map(r => r.documentLocator)).size !== records.length || docs.some(d => !records.some(r => r.displayId === d.displayId && r.documentLocator === d.documentLocator)))
|
|
768
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'The proposed recovered inventory is inconsistent.');
|
|
769
|
+
let recheckPolicy;
|
|
770
|
+
if (needsPolicy) {
|
|
771
|
+
const currentSpecs = policyInputs.map(input => {
|
|
772
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(path.join(bound.store, input.documentLocator));
|
|
773
|
+
if (!bytes || (0, entity_transaction_1.entityContentVersion)(bytes) !== input.version)
|
|
774
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'Policy inventory changed during recovery.');
|
|
775
|
+
return (0, spec_parser_1.parseSpec)(bytes.toString('utf8'));
|
|
776
|
+
});
|
|
777
|
+
const prior = deletion?.before ?? document.before;
|
|
778
|
+
const beforeSpec = prior === null ? null : (0, spec_parser_1.parseSpec)(Buffer.from(prior, 'base64').toString('utf8'));
|
|
779
|
+
const afterSpec = (0, spec_parser_1.parseSpec)(Buffer.from(document.after, 'base64').toString('utf8'));
|
|
780
|
+
recheckPolicy = policy(beforeSpec, afterSpec, currentSpecs.concat(beforeSpec ?? afterSpec));
|
|
781
|
+
}
|
|
782
|
+
const journalStat = fs.lstatSync(journalFile), cleanup = [];
|
|
783
|
+
for (const name of fs.readdirSync(opDir)) {
|
|
784
|
+
if (name === 'journal.json')
|
|
785
|
+
continue;
|
|
786
|
+
const file = path.join(opDir, name), s = fs.lstatSync(file);
|
|
787
|
+
if (name === 'completion.json' && (0, entity_transaction_1.readEntityBytes)(file)?.toString('utf8') === (0, entity_transaction_1.entityCompletionRecord)(bound.root, operationId, bound.locator, journalBytes))
|
|
788
|
+
continue;
|
|
789
|
+
if (/^\.entity-[0-9a-f-]+\.tmp$/.test(name) && s.isFile() && !s.isSymbolicLink() && s.dev === journalStat.dev && s.ino === journalStat.ino)
|
|
790
|
+
cleanup.push(file);
|
|
791
|
+
else
|
|
792
|
+
throw new entity_transaction_1.EntityStoreError('cleanup-conflict', 'Unowned transaction files are preserved.');
|
|
793
|
+
}
|
|
794
|
+
approvedRecoveryJournal(journalFile, journalVersion);
|
|
795
|
+
if (needsPolicy) {
|
|
796
|
+
const latest = inventory(bound, excluded);
|
|
797
|
+
if (latest.length !== policyInputs.length || latest.some((entry, i) => entry.documentLocator !== policyInputs[i].documentLocator || entry.version !== policyInputs[i].version))
|
|
798
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'Policy inventory changed before publication.');
|
|
799
|
+
recheckPolicy();
|
|
800
|
+
}
|
|
801
|
+
for (const change of changes) {
|
|
802
|
+
const dir = (0, entity_transaction_1.entityDirectory)(bound.root, path.dirname(change.locator));
|
|
803
|
+
if (((0, entity_transaction_1.readEntityBytes)(path.join(dir, path.basename(change.locator)))?.toString('base64') ?? null) !== change.after)
|
|
804
|
+
(0, entity_transaction_1.applyEntityByteChange)(bound.root, change);
|
|
805
|
+
}
|
|
806
|
+
validateMappedInventory(bound, active);
|
|
807
|
+
for (const file of cleanup)
|
|
808
|
+
fs.unlinkSync(file);
|
|
809
|
+
approvedRecoveryJournal(journalFile, journalVersion);
|
|
810
|
+
onComplete?.();
|
|
811
|
+
(0, entity_transaction_1.retireEntityOperation)(bound.root, operationId, bound.locator, journalVersion);
|
|
812
|
+
return inspectEntityStore(bound.store);
|
|
813
|
+
}
|
|
814
|
+
function validateEntityRecoveryPlan(value) {
|
|
815
|
+
const hasOwner = !!value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'lockOwner');
|
|
816
|
+
const hasSource = !!value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'sourceOwner');
|
|
817
|
+
const p = object(value, ['schema', 'workspaceId', 'storeLocator', 'operations', ...(hasOwner ? ['lockOwner'] : []), ...(hasSource ? ['sourceOwner'] : [])]);
|
|
818
|
+
for (const key of [...(hasOwner ? ['lockOwner'] : []), ...(hasSource ? ['sourceOwner'] : [])]) {
|
|
819
|
+
const owner = object(p[key], ['token', 'version']);
|
|
820
|
+
if (!validUuid(owner.token) || typeof owner.version !== 'string' || !DIGEST.test(owner.version))
|
|
821
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid recovery lock owner.');
|
|
822
|
+
}
|
|
823
|
+
if (p.schema !== 'holmes-entity-recovery/1' || !validUuid(p.workspaceId) || !locator(p.storeLocator) || !Array.isArray(p.operations))
|
|
824
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid recovery plan.');
|
|
825
|
+
for (const value of p.operations) {
|
|
826
|
+
// @implements A-SPEC-634 — an aborted operation: no journal, no targets, only its leftovers.
|
|
827
|
+
if (!!value && typeof value === 'object' && value.kind === 'aborted') {
|
|
828
|
+
const op = object(value, ['operationId', 'kind', 'storeId', 'journalVersion', 'state', 'targets', 'inventoryConflicts', 'leftovers']);
|
|
829
|
+
if (!validUuid(op.operationId) || !validUuid(op.storeId) || op.journalVersion !== null || op.state !== 'empty' || !Array.isArray(op.targets) || op.targets.length !== 0 || !Array.isArray(op.inventoryConflicts) || op.inventoryConflicts.length !== 0
|
|
830
|
+
|| !Array.isArray(op.leftovers) || op.leftovers.some((n) => typeof n !== 'string' || !n || n.includes('/') || n.includes('\\') || n === '.' || n === '..'))
|
|
831
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid aborted recovery operation.');
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const retired = !!value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'retired');
|
|
835
|
+
const op = object(value, ['operationId', 'kind', 'storeId', 'journalVersion', 'state', 'targets', 'inventoryConflicts', ...(retired ? ['retired'] : [])]);
|
|
836
|
+
if (retired && op.retired !== true)
|
|
837
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid completed recovery operation.');
|
|
838
|
+
if (op.kind === 'legacy-renumber') {
|
|
839
|
+
// @implements A-SPEC-638 — a rollback program: no store id, no after state, targets unchanged or applied.
|
|
840
|
+
if (!validUuid(op.operationId) || op.storeId !== null || typeof op.journalVersion !== 'string' || !DIGEST.test(op.journalVersion) || !['unapplied', 'partial', 'applied'].includes(String(op.state)) || !Array.isArray(op.targets) || !Array.isArray(op.inventoryConflicts) || op.inventoryConflicts.length !== 0)
|
|
841
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid legacy renumber recovery operation.');
|
|
842
|
+
for (const value of op.targets) {
|
|
843
|
+
const t = object(value, ['locator', 'beforeVersion', 'afterVersion', 'currentVersion', 'state']);
|
|
844
|
+
if (!locator(t.locator) || t.afterVersion !== null || !['unchanged', 'applied'].includes(String(t.state)) || ['beforeVersion', 'currentVersion'].some(k => t[k] !== null && (typeof t[k] !== 'string' || !DIGEST.test(t[k]))))
|
|
845
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid legacy renumber recovery target.');
|
|
846
|
+
}
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (!validUuid(op.operationId) || !validUuid(op.storeId) || !['adoption', 'write'].includes(String(op.kind)) || typeof op.journalVersion !== 'string' || !DIGEST.test(op.journalVersion) || !['unapplied', 'partial', 'applied', 'conflict'].includes(String(op.state)) || !Array.isArray(op.targets) || !Array.isArray(op.inventoryConflicts) || !op.inventoryConflicts.every(locator))
|
|
850
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid recovery operation.');
|
|
851
|
+
for (const value of op.targets) {
|
|
852
|
+
const t = object(value, ['locator', 'beforeVersion', 'afterVersion', 'currentVersion', 'state']);
|
|
853
|
+
if (!locator(t.locator) || !['unchanged', 'applied', 'unapplied', 'conflict'].includes(String(t.state)) || ['beforeVersion', 'afterVersion', 'currentVersion'].some(k => t[k] !== null && (typeof t[k] !== 'string' || !DIGEST.test(t[k]))))
|
|
854
|
+
throw new entity_transaction_1.EntityStoreError('invalid-plan', 'Invalid recovery target.');
|
|
855
|
+
if (op.kind === 'adoption' && !/^\.ax\/entities\/(records|stores)\/[0-9a-f-]+\.json$/.test(t.locator))
|
|
856
|
+
throw new entity_transaction_1.EntityStoreError('unsafe-path', 'Adoption recovery cannot target documents or other workspace files.');
|
|
857
|
+
if (op.kind === 'write' && !(t.locator.startsWith(p.storeLocator + '/') && t.locator.endsWith('.md')) && !/^\.ax\/entities\/records\/[0-9a-f-]+\.json$/.test(t.locator))
|
|
858
|
+
throw new entity_transaction_1.EntityStoreError('unsafe-path', 'Document recovery targets must stay within the configured store and mappings.');
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return p;
|
|
862
|
+
}
|
|
863
|
+
function entityRecoveryTarget(plan) { return 'entity-store:recover:' + (0, entity_transaction_1.entityContentVersion)(JSON.stringify(plan)); }
|
|
864
|
+
function recoverEntityStore(storeRoot, plan, policy, onComplete) {
|
|
865
|
+
const bound = entityStoreBinding(storeRoot), identity = (0, workspace_identity_1.workspaceIdentity)(bound.root);
|
|
866
|
+
if (identity.state !== 'registered' || identity.workspaceId !== plan.workspaceId || bound.locator !== plan.storeLocator)
|
|
867
|
+
throw new entity_transaction_1.EntityStoreError('workspace-mismatch', 'Recovery plan does not belong to this store.');
|
|
868
|
+
// @implements A-SPEC-634 — zero operations is recoverable only as a lock-only release: a holder
|
|
869
|
+
// that died after taking the store lock and before creating its pending directory.
|
|
870
|
+
const lockOnly = plan.operations.length === 0 && !!plan.lockOwner;
|
|
871
|
+
if (plan.operations.length !== 1 && !lockOnly)
|
|
872
|
+
throw new entity_transaction_1.EntityStoreError('unsupported-recovery', 'Recover one inspected operation at a time.');
|
|
873
|
+
const owner = (0, entity_transaction_1.entityLockOwner)(bound.root, bound.locator);
|
|
874
|
+
if (owner && JSON.stringify(owner) !== JSON.stringify(plan.lockOwner))
|
|
875
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'The current lock belongs to a different operation; inspect and authorize its owner.');
|
|
876
|
+
if (lockOnly && !owner)
|
|
877
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'The lock this plan releases is no longer held.');
|
|
878
|
+
const runOwned = owner ? entity_transaction_1.withEntityRecoveryLock : entity_transaction_1.withEntityStoreLock;
|
|
879
|
+
return runOwned(bound.root, bound.locator, () => {
|
|
880
|
+
const current = planEntityRecovery(storeRoot), op = plan.operations[0];
|
|
881
|
+
if (owner && JSON.stringify(current.lockOwner) !== JSON.stringify(plan.lockOwner))
|
|
882
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'Lock ownership changed while claiming recovery.');
|
|
883
|
+
if (lockOnly || op.kind === 'aborted') {
|
|
884
|
+
// @implements A-SPEC-634 — the store generation is already claimed (a live owner refused
|
|
885
|
+
// store-busy above); claim the dead source-publication generation the plan names, remove only
|
|
886
|
+
// the operation's own staged temporaries and its empty directory, and let both recovery
|
|
887
|
+
// locks retire their generations on the way out. Anything unexpected is preserved.
|
|
888
|
+
if (JSON.stringify(current) !== JSON.stringify(plan))
|
|
889
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'Recovery inputs changed; inspect and authorize the new plan.');
|
|
890
|
+
const release = () => {
|
|
891
|
+
if (op) {
|
|
892
|
+
const opDir = (0, entity_transaction_1.entityDirectory)(bound.root, '.ax/state/entity-transactions/' + op.operationId);
|
|
893
|
+
if ((0, entity_transaction_1.entityStat)(opDir)) {
|
|
894
|
+
const names = fs.readdirSync(opDir);
|
|
895
|
+
for (const name of names) {
|
|
896
|
+
const s = fs.lstatSync(path.join(opDir, name));
|
|
897
|
+
if (!/^\.entity-[0-9a-f-]+\.tmp$/.test(name) || !s.isFile() || s.isSymbolicLink())
|
|
898
|
+
throw new entity_transaction_1.EntityStoreError('cleanup-conflict', 'Unowned transaction files are preserved.');
|
|
899
|
+
}
|
|
900
|
+
for (const name of names)
|
|
901
|
+
fs.unlinkSync(path.join(opDir, name));
|
|
902
|
+
fs.rmdirSync(opDir);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
onComplete?.();
|
|
906
|
+
};
|
|
907
|
+
if (plan.sourceOwner) {
|
|
908
|
+
if (JSON.stringify((0, entity_transaction_1.entityLockOwner)(bound.root, entity_transaction_1.SOURCE_PUBLICATION_LOCATOR)) !== JSON.stringify(plan.sourceOwner))
|
|
909
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'Source publication ownership changed; inspect and authorize the new plan.');
|
|
910
|
+
(0, entity_transaction_1.withEntityRecoveryLock)(bound.root, entity_transaction_1.SOURCE_PUBLICATION_LOCATOR, release);
|
|
911
|
+
}
|
|
912
|
+
else
|
|
913
|
+
release();
|
|
914
|
+
return inspectEntityStore(storeRoot);
|
|
915
|
+
}
|
|
916
|
+
if (!current.operations.length) {
|
|
917
|
+
const active = registration(bound);
|
|
918
|
+
if (!active || active.storeId !== op.storeId || !op.targets.length || op.targets.some(t => {
|
|
919
|
+
const dir = (0, entity_transaction_1.entityDirectory)(bound.root, path.dirname(t.locator));
|
|
920
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(path.join(dir, path.basename(t.locator)));
|
|
921
|
+
return (bytes ? (0, entity_transaction_1.entityContentVersion)(bytes) : null) !== t.afterVersion;
|
|
922
|
+
}))
|
|
923
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'The completed operation no longer matches this recovery plan.');
|
|
924
|
+
onComplete?.();
|
|
925
|
+
return inspectEntityStore(storeRoot);
|
|
926
|
+
}
|
|
927
|
+
if (JSON.stringify(current) !== JSON.stringify(plan))
|
|
928
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'Recovery inputs changed; inspect and authorize the new plan.');
|
|
929
|
+
if (op.state === 'conflict')
|
|
930
|
+
throw new entity_transaction_1.EntityStoreError('external-change', 'External changes must be preserved; recovery cannot overwrite a third state.');
|
|
931
|
+
// @implements A-SPEC-640 — after every refusal point: a lone aside goes back to its absent target before any replay.
|
|
932
|
+
(0, entity_transaction_1.settleEntityTransients)(bound.root, op.targets.map(t => ({ locator: t.locator, before: null, after: null })));
|
|
933
|
+
if (op.retired) {
|
|
934
|
+
if (op.kind === 'legacy-renumber') {
|
|
935
|
+
onComplete?.();
|
|
936
|
+
return inspectEntityStore(storeRoot);
|
|
937
|
+
}
|
|
938
|
+
const active = registration(bound);
|
|
939
|
+
if (op.state !== 'applied' || !active || active.storeId !== op.storeId)
|
|
940
|
+
throw new entity_transaction_1.EntityStoreError('plan-changed', 'Completed receipt does not describe the current active store.');
|
|
941
|
+
onComplete?.();
|
|
942
|
+
return inspectEntityStore(storeRoot);
|
|
943
|
+
}
|
|
944
|
+
if (op.kind === 'legacy-renumber') {
|
|
945
|
+
// @implements A-SPEC-638 — roll the raw legacy renumber back to its journaled originals:
|
|
946
|
+
// files it created are removed first, then every rewritten or moved file gets its original bytes.
|
|
947
|
+
const opDir = (0, entity_transaction_1.entityDirectory)(bound.root, '.ax/state/entity-transactions/' + op.operationId), journalFile = path.join(opDir, 'journal.json');
|
|
948
|
+
const journalBytes = approvedRecoveryJournal(journalFile, op.journalVersion);
|
|
949
|
+
const journal = legacyRenumberJournal(JSON.parse(journalBytes.toString('utf8')), op.operationId);
|
|
950
|
+
const currentOf = (target) => { const dir = (0, entity_transaction_1.entityDirectory)(bound.root, path.dirname(target)); const file = path.join(dir, path.basename(target)); const bytes = (0, entity_transaction_1.readEntityBytes)(file); return { file, bytes, base64: bytes?.toString('base64') ?? null }; };
|
|
951
|
+
const restore = (change) => {
|
|
952
|
+
const current = currentOf(change.locator);
|
|
953
|
+
if (current.base64 === change.before)
|
|
954
|
+
return;
|
|
955
|
+
const beforeMode = current.bytes ? (((0, entity_transaction_1.entityStat)(current.file).mode & 0o100) ? '100755' : '100644') : null;
|
|
956
|
+
(0, entity_transaction_1.applyEntityByteChange)(bound.root, { locator: change.locator, before: current.base64, after: change.before, beforeMode, afterMode: change.before === null ? null : '100644' });
|
|
957
|
+
};
|
|
958
|
+
for (const change of journal.changes)
|
|
959
|
+
if (change.before === null)
|
|
960
|
+
restore(change);
|
|
961
|
+
for (const change of journal.changes)
|
|
962
|
+
if (change.before !== null)
|
|
963
|
+
restore(change);
|
|
964
|
+
for (const change of journal.changes)
|
|
965
|
+
if (currentOf(change.locator).base64 !== change.before)
|
|
966
|
+
throw new entity_transaction_1.EntityStoreError('external-change', 'Legacy renumber rollback could not restore ' + change.locator + '.');
|
|
967
|
+
approvedRecoveryJournal(journalFile, op.journalVersion);
|
|
968
|
+
onComplete?.();
|
|
969
|
+
(0, entity_transaction_1.retireEntityOperation)(bound.root, op.operationId, bound.locator, op.journalVersion);
|
|
970
|
+
return inspectEntityStore(storeRoot);
|
|
971
|
+
}
|
|
972
|
+
if (op.kind === 'write')
|
|
973
|
+
return recoverDraftDocument(bound, op.operationId, op.journalVersion, policy, onComplete);
|
|
974
|
+
const opDir = (0, entity_transaction_1.entityDirectory)(bound.root, '.ax/state/entity-transactions/' + op.operationId), journalFile = path.join(opDir, 'journal.json');
|
|
975
|
+
const journalBytes = approvedRecoveryJournal(journalFile, op.journalVersion);
|
|
976
|
+
const journal = object(JSON.parse(journalBytes.toString('utf8')), ['schema', 'plan']);
|
|
977
|
+
if (journal.schema !== 'holmes-entity-adoption-journal/1')
|
|
978
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Expected an adoption journal.');
|
|
979
|
+
const adoption = validateEntityAdoptionPlan(journal.plan);
|
|
980
|
+
const active = registration(bound);
|
|
981
|
+
if (active && (active.storeId !== adoption.storeId || active.adoptionDigest !== (0, entity_transaction_1.entityContentVersion)(JSON.stringify(adoption))))
|
|
982
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-present', 'Another adoption owns this store.');
|
|
983
|
+
const journalStat = fs.lstatSync(journalFile), cleanup = [journalFile];
|
|
984
|
+
for (const name of fs.readdirSync(opDir)) {
|
|
985
|
+
if (name === 'journal.json')
|
|
986
|
+
continue;
|
|
987
|
+
if (name === 'completion.json') {
|
|
988
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(path.join(opDir, name));
|
|
989
|
+
if (bytes?.toString('utf8') !== (0, entity_transaction_1.entityCompletionRecord)(bound.root, op.operationId, bound.locator, journalBytes))
|
|
990
|
+
throw new entity_transaction_1.EntityStoreError('retirement-conflict', 'Completion receipt does not match the owned journal.');
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const file = path.join(opDir, name), s = fs.lstatSync(file);
|
|
994
|
+
if (/^\.entity-[0-9a-f-]+\.tmp$/.test(name) && s.isFile() && !s.isSymbolicLink() && s.dev === journalStat.dev && s.ino === journalStat.ino)
|
|
995
|
+
cleanup.push(file);
|
|
996
|
+
else
|
|
997
|
+
throw new entity_transaction_1.EntityStoreError('cleanup-conflict', 'Unowned transaction files are preserved.');
|
|
998
|
+
}
|
|
999
|
+
approvedRecoveryJournal(journalFile, op.journalVersion);
|
|
1000
|
+
for (const change of adoptionChanges(adoption)) {
|
|
1001
|
+
const dir = (0, entity_transaction_1.entityDirectory)(bound.root, path.dirname(change.locator));
|
|
1002
|
+
if (((0, entity_transaction_1.readEntityBytes)(path.join(dir, path.basename(change.locator)))?.toString('base64') ?? null) !== change.after)
|
|
1003
|
+
(0, entity_transaction_1.applyEntityByteChange)(bound.root, change);
|
|
1004
|
+
}
|
|
1005
|
+
const completed = registration(bound);
|
|
1006
|
+
if (!completed)
|
|
1007
|
+
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Recovered adoption did not publish its registration.');
|
|
1008
|
+
validateMappedInventory(bound, completed);
|
|
1009
|
+
for (const file of cleanup.slice(1))
|
|
1010
|
+
fs.unlinkSync(file);
|
|
1011
|
+
approvedRecoveryJournal(journalFile, op.journalVersion);
|
|
1012
|
+
onComplete?.();
|
|
1013
|
+
(0, entity_transaction_1.retireEntityOperation)(bound.root, op.operationId, bound.locator, op.journalVersion);
|
|
1014
|
+
return inspectEntityStore(storeRoot);
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
function adoptEntityStore(storeRoot, plan) {
|
|
1018
|
+
const bound = entityStoreBinding(storeRoot), identity = (0, workspace_identity_1.workspaceIdentity)(bound.root);
|
|
1019
|
+
if (identity.state !== 'registered' || identity.workspaceId !== plan.workspaceId || bound.locator !== plan.storeLocator)
|
|
1020
|
+
throw new entity_transaction_1.EntityStoreError('workspace-mismatch', 'Adoption plan does not belong to this registered store.');
|
|
1021
|
+
return (0, entity_transaction_1.withEntityStoreLock)(bound.root, bound.locator, () => {
|
|
1022
|
+
assertNoPending(bound);
|
|
1023
|
+
const active = registration(bound), digest = (0, entity_transaction_1.entityContentVersion)(JSON.stringify(plan));
|
|
1024
|
+
if (active) {
|
|
1025
|
+
if (active.storeId !== plan.storeId || active.adoptionDigest !== digest)
|
|
1026
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-present', 'A different adoption already activated this store.');
|
|
1027
|
+
return inspectEntityStore(storeRoot);
|
|
1028
|
+
}
|
|
1029
|
+
assertNoOverlap(bound);
|
|
1030
|
+
const actual = inventory(bound);
|
|
1031
|
+
if (actual.length !== plan.entries.length || actual.some(d => !plan.entries.some(e => e.displayId === d.displayId && e.documentLocator === d.documentLocator && e.version === d.version)))
|
|
1032
|
+
throw new entity_transaction_1.EntityStoreError('inventory-changed', 'Physical spec inventory changed after planning; preserve it and create a new plan.');
|
|
1033
|
+
const records = plan.entries.map(e => ({ schema: 'holmes-entity/1', workspaceId: plan.workspaceId, storeId: plan.storeId, entityId: e.entityId, displayId: e.displayId, documentLocator: e.documentLocator, origin: { kind: 'adopted', operationId: plan.operationId } }));
|
|
1034
|
+
(0, entity_transaction_1.entityDirectory)(bound.root, path.join('.ax', 'entities', 'records'));
|
|
1035
|
+
for (const r of records)
|
|
1036
|
+
if ((0, entity_transaction_1.entityStat)(path.join(bound.root, '.ax', 'entities', 'records', r.entityId + '.json')))
|
|
1037
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-present', 'A proposed entity identifier is already occupied.');
|
|
1038
|
+
const marker = path.join('.ax', 'entities', 'stores', plan.storeId + '.json');
|
|
1039
|
+
if ((0, entity_transaction_1.entityStat)(path.join(bound.root, marker)))
|
|
1040
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-present', 'The proposed store identifier is occupied.');
|
|
1041
|
+
const journal = path.join('.ax', 'state', 'entity-transactions', plan.operationId, 'journal.json');
|
|
1042
|
+
const journalText = JSON.stringify({ schema: 'holmes-entity-adoption-journal/1', plan }) + '\n';
|
|
1043
|
+
(0, entity_transaction_1.publishEntityRecord)(bound.root, journal, journalText);
|
|
1044
|
+
for (const r of records)
|
|
1045
|
+
(0, entity_transaction_1.publishEntityRecord)(bound.root, path.join('.ax', 'entities', 'records', r.entityId + '.json'), JSON.stringify(r) + '\n');
|
|
1046
|
+
// Documents are never rewritten by adoption. The activation marker is the last canonical write.
|
|
1047
|
+
(0, entity_transaction_1.publishEntityRecord)(bound.root, marker, JSON.stringify({ schema: 'holmes-entity-store/1', workspaceId: plan.workspaceId, storeId: plan.storeId, storeLocator: plan.storeLocator, adoptionDigest: digest }) + '\n');
|
|
1048
|
+
(0, entity_transaction_1.retireEntityOperation)(bound.root, plan.operationId, bound.locator, (0, entity_transaction_1.entityContentVersion)(journalText));
|
|
1049
|
+
return inspectEntityStore(storeRoot);
|
|
1050
|
+
});
|
|
1051
|
+
}
|