@soulcraft/brainy 5.2.1 → 5.3.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.
@@ -0,0 +1,352 @@
1
+ /**
2
+ * VersionManager - Entity-Level Versioning Engine (v5.3.0)
3
+ *
4
+ * Provides entity-level version control with:
5
+ * - save() - Create entity version
6
+ * - restore() - Restore entity to specific version
7
+ * - list() - List all versions of an entity
8
+ * - compare() - Deep diff between versions
9
+ * - prune() - Remove old versions (retention policies)
10
+ *
11
+ * Architecture:
12
+ * - Hybrid storage: COW commits for full snapshots + version index for fast queries
13
+ * - Content-addressable: SHA-256 hashing for deduplication
14
+ * - Space-efficient: Only stores changed data
15
+ * - Branch-aware: Versions tied to current branch
16
+ *
17
+ * NO MOCKS - Production implementation
18
+ */
19
+ import { VersionStorage } from './VersionStorage.js';
20
+ import { VersionIndex } from './VersionIndex.js';
21
+ import { compareEntityVersions } from './VersionDiff.js';
22
+ /**
23
+ * VersionManager - Core versioning engine
24
+ */
25
+ export class VersionManager {
26
+ constructor(brain) {
27
+ this.initialized = false;
28
+ this.brain = brain;
29
+ this.versionStorage = new VersionStorage(brain);
30
+ this.versionIndex = new VersionIndex(brain);
31
+ }
32
+ /**
33
+ * Initialize versioning system (lazy)
34
+ */
35
+ async initialize() {
36
+ if (this.initialized)
37
+ return;
38
+ await this.versionStorage.initialize();
39
+ await this.versionIndex.initialize();
40
+ this.initialized = true;
41
+ }
42
+ /**
43
+ * Save a version of an entity
44
+ *
45
+ * Creates a version snapshot of the current entity state.
46
+ * If createCommit is true, also creates a commit containing this version.
47
+ *
48
+ * @param entityId Entity ID to version
49
+ * @param options Save options
50
+ * @returns Created version metadata
51
+ */
52
+ async save(entityId, options = {}) {
53
+ await this.initialize();
54
+ // Get current entity state
55
+ const entity = await this.brain.getNounMetadata(entityId);
56
+ if (!entity) {
57
+ throw new Error(`Entity ${entityId} not found`);
58
+ }
59
+ // Get current branch
60
+ const currentBranch = this.brain.currentBranch;
61
+ // Get next version number
62
+ const existingVersions = await this.versionIndex.getVersions({
63
+ entityId,
64
+ branch: currentBranch
65
+ });
66
+ const nextVersion = existingVersions.length + 1;
67
+ // Calculate content hash
68
+ const contentHash = this.versionStorage.hashEntity(entity);
69
+ // Check for duplicate (same content as last version)
70
+ if (existingVersions.length > 0) {
71
+ const lastVersion = existingVersions[existingVersions.length - 1];
72
+ if (lastVersion.contentHash === contentHash) {
73
+ // Content unchanged - return last version instead of creating duplicate
74
+ return lastVersion;
75
+ }
76
+ }
77
+ // Create commit if requested
78
+ let commitHash;
79
+ if (options.createCommit) {
80
+ const commitMessage = options.commitMessage || `Version ${nextVersion} of entity ${entityId}`;
81
+ // Use brain's commit method (note: single options object)
82
+ await this.brain.commit({
83
+ message: commitMessage,
84
+ author: options.author,
85
+ metadata: {
86
+ versionedEntity: entityId,
87
+ version: nextVersion,
88
+ ...options.metadata
89
+ }
90
+ });
91
+ // Get the commit hash that was just created
92
+ const refManager = this.brain.refManager;
93
+ const ref = await refManager.getRef(currentBranch);
94
+ commitHash = ref;
95
+ }
96
+ else {
97
+ // Use current HEAD commit
98
+ const refManager = this.brain.refManager;
99
+ const ref = await refManager.getRef(currentBranch);
100
+ if (!ref) {
101
+ throw new Error(`No commit exists on branch ${currentBranch}. Create a commit first or use createCommit: true`);
102
+ }
103
+ commitHash = ref;
104
+ }
105
+ // Create version metadata
106
+ const version = {
107
+ version: nextVersion,
108
+ entityId,
109
+ branch: currentBranch,
110
+ commitHash,
111
+ timestamp: Date.now(),
112
+ contentHash,
113
+ tag: options.tag,
114
+ description: options.description,
115
+ author: options.author,
116
+ metadata: options.metadata
117
+ };
118
+ // Store version
119
+ await this.versionStorage.saveVersion(version, entity);
120
+ await this.versionIndex.addVersion(version);
121
+ return version;
122
+ }
123
+ /**
124
+ * Get all versions of an entity
125
+ *
126
+ * @param entityId Entity ID
127
+ * @param query Optional query filters
128
+ * @returns List of versions (newest first)
129
+ */
130
+ async list(entityId, query = {}) {
131
+ await this.initialize();
132
+ const currentBranch = this.brain.currentBranch;
133
+ return this.versionIndex.getVersions({
134
+ entityId,
135
+ branch: query.branch || currentBranch,
136
+ limit: query.limit,
137
+ offset: query.offset,
138
+ tag: query.tag,
139
+ startDate: query.startDate,
140
+ endDate: query.endDate
141
+ });
142
+ }
143
+ /**
144
+ * Get a specific version of an entity
145
+ *
146
+ * @param entityId Entity ID
147
+ * @param version Version number (1-indexed)
148
+ * @returns Version metadata
149
+ */
150
+ async getVersion(entityId, version) {
151
+ await this.initialize();
152
+ const currentBranch = this.brain.currentBranch;
153
+ return this.versionIndex.getVersion(entityId, version, currentBranch);
154
+ }
155
+ /**
156
+ * Get version by tag
157
+ *
158
+ * @param entityId Entity ID
159
+ * @param tag Version tag
160
+ * @returns Version metadata
161
+ */
162
+ async getVersionByTag(entityId, tag) {
163
+ await this.initialize();
164
+ const currentBranch = this.brain.currentBranch;
165
+ return this.versionIndex.getVersionByTag(entityId, tag, currentBranch);
166
+ }
167
+ /**
168
+ * Restore entity to a specific version
169
+ *
170
+ * Overwrites current entity state with the specified version.
171
+ * Optionally creates a snapshot before restoring for undo capability.
172
+ *
173
+ * @param entityId Entity ID
174
+ * @param version Version number or tag
175
+ * @param options Restore options
176
+ * @returns Restored version metadata
177
+ */
178
+ async restore(entityId, version, options = {}) {
179
+ await this.initialize();
180
+ // Create snapshot before restoring (for undo)
181
+ if (options.createSnapshot) {
182
+ await this.save(entityId, {
183
+ tag: options.snapshotTag || 'before-restore',
184
+ description: `Snapshot before restoring to version ${version}`,
185
+ metadata: { restoringTo: version }
186
+ });
187
+ }
188
+ // Get target version
189
+ let targetVersion;
190
+ if (typeof version === 'number') {
191
+ targetVersion = await this.getVersion(entityId, version);
192
+ }
193
+ else {
194
+ targetVersion = await this.getVersionByTag(entityId, version);
195
+ }
196
+ if (!targetVersion) {
197
+ throw new Error(`Version ${version} not found for entity ${entityId}`);
198
+ }
199
+ // Load versioned entity data
200
+ const versionedEntity = await this.versionStorage.loadVersion(targetVersion);
201
+ if (!versionedEntity) {
202
+ throw new Error(`Version data not found for entity ${entityId} version ${version}`);
203
+ }
204
+ // Restore entity in storage
205
+ await this.brain.saveNounMetadata(entityId, versionedEntity);
206
+ return targetVersion;
207
+ }
208
+ /**
209
+ * Compare two versions of an entity
210
+ *
211
+ * @param entityId Entity ID
212
+ * @param fromVersion Version number or tag (older)
213
+ * @param toVersion Version number or tag (newer)
214
+ * @returns Diff between versions
215
+ */
216
+ async compare(entityId, fromVersion, toVersion) {
217
+ await this.initialize();
218
+ // Get versions
219
+ const fromVer = typeof fromVersion === 'number'
220
+ ? await this.getVersion(entityId, fromVersion)
221
+ : await this.getVersionByTag(entityId, fromVersion);
222
+ const toVer = typeof toVersion === 'number'
223
+ ? await this.getVersion(entityId, toVersion)
224
+ : await this.getVersionByTag(entityId, toVersion);
225
+ if (!fromVer) {
226
+ throw new Error(`Version ${fromVersion} not found for entity ${entityId}`);
227
+ }
228
+ if (!toVer) {
229
+ throw new Error(`Version ${toVersion} not found for entity ${entityId}`);
230
+ }
231
+ // Load entity data
232
+ const fromEntity = await this.versionStorage.loadVersion(fromVer);
233
+ const toEntity = await this.versionStorage.loadVersion(toVer);
234
+ if (!fromEntity || !toEntity) {
235
+ throw new Error('Failed to load version data for comparison');
236
+ }
237
+ // Compare versions
238
+ return compareEntityVersions(fromEntity, toEntity, {
239
+ fromVersion: fromVer.version,
240
+ toVersion: toVer.version,
241
+ entityId
242
+ });
243
+ }
244
+ /**
245
+ * Prune old versions based on retention policy
246
+ *
247
+ * @param entityId Entity ID (or '*' for all entities)
248
+ * @param options Prune options
249
+ * @returns Number of versions deleted
250
+ */
251
+ async prune(entityId, options) {
252
+ await this.initialize();
253
+ if (!options.keepRecent && !options.keepAfter) {
254
+ throw new Error('Must specify either keepRecent or keepAfter in prune options');
255
+ }
256
+ const currentBranch = this.brain.currentBranch;
257
+ // Get all versions
258
+ const versions = await this.versionIndex.getVersions({
259
+ entityId,
260
+ branch: currentBranch
261
+ });
262
+ // Determine which versions to keep
263
+ const toKeep = new Set();
264
+ const toDelete = [];
265
+ // Keep recent versions
266
+ if (options.keepRecent) {
267
+ const recentVersions = versions.slice(0, options.keepRecent);
268
+ recentVersions.forEach((v) => toKeep.add(v.version));
269
+ }
270
+ // Keep versions after timestamp
271
+ if (options.keepAfter) {
272
+ versions
273
+ .filter((v) => v.timestamp >= options.keepAfter)
274
+ .forEach((v) => toKeep.add(v.version));
275
+ }
276
+ // Keep tagged versions
277
+ if (options.keepTagged !== false) {
278
+ versions
279
+ .filter((v) => v.tag !== undefined)
280
+ .forEach((v) => toKeep.add(v.version));
281
+ }
282
+ // Build delete list
283
+ for (const version of versions) {
284
+ if (!toKeep.has(version.version)) {
285
+ toDelete.push(version);
286
+ }
287
+ }
288
+ // Dry run - just return counts
289
+ if (options.dryRun) {
290
+ return {
291
+ deleted: toDelete.length,
292
+ kept: toKeep.size
293
+ };
294
+ }
295
+ // Delete versions
296
+ for (const version of toDelete) {
297
+ await this.versionStorage.deleteVersion(version);
298
+ await this.versionIndex.removeVersion(entityId, version.version, currentBranch);
299
+ }
300
+ return {
301
+ deleted: toDelete.length,
302
+ kept: toKeep.size
303
+ };
304
+ }
305
+ /**
306
+ * Get version count for an entity
307
+ *
308
+ * @param entityId Entity ID
309
+ * @returns Number of versions
310
+ */
311
+ async getVersionCount(entityId) {
312
+ await this.initialize();
313
+ const currentBranch = this.brain.currentBranch;
314
+ return this.versionIndex.getVersionCount(entityId, currentBranch);
315
+ }
316
+ /**
317
+ * Check if entity has versions
318
+ *
319
+ * @param entityId Entity ID
320
+ * @returns True if entity has versions
321
+ */
322
+ async hasVersions(entityId) {
323
+ const count = await this.getVersionCount(entityId);
324
+ return count > 0;
325
+ }
326
+ /**
327
+ * Get latest version of an entity
328
+ *
329
+ * @param entityId Entity ID
330
+ * @returns Latest version metadata or null
331
+ */
332
+ async getLatest(entityId) {
333
+ await this.initialize();
334
+ const versions = await this.list(entityId, { limit: 1 });
335
+ return versions[0] || null;
336
+ }
337
+ /**
338
+ * Clear all versions for an entity
339
+ *
340
+ * @param entityId Entity ID
341
+ * @returns Number of versions deleted
342
+ */
343
+ async clear(entityId) {
344
+ await this.initialize();
345
+ const result = await this.prune(entityId, {
346
+ keepRecent: 0,
347
+ keepTagged: false
348
+ });
349
+ return result.deleted;
350
+ }
351
+ }
352
+ //# sourceMappingURL=VersionManager.js.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * VersionStorage - Hybrid Storage for Entity Versions (v5.3.0)
3
+ *
4
+ * Implements content-addressable storage for entity versions:
5
+ * - SHA-256 content hashing for deduplication
6
+ * - Stores versions in .brainy/versions/ directory
7
+ * - Integrates with COW commit system
8
+ * - Space-efficient: Only stores unique content
9
+ *
10
+ * Storage structure:
11
+ * .brainy/versions/
12
+ * ├── entities/
13
+ * │ └── {entityId}/
14
+ * │ └── {contentHash}.json # Entity version data
15
+ * └── index/
16
+ * └── {entityId}.json # Version index (managed by VersionIndex)
17
+ *
18
+ * NO MOCKS - Production implementation
19
+ */
20
+ import type { NounMetadata } from '../coreTypes.js';
21
+ import type { EntityVersion } from './VersionManager.js';
22
+ /**
23
+ * VersionStorage - Content-addressable version storage
24
+ */
25
+ export declare class VersionStorage {
26
+ private brain;
27
+ private initialized;
28
+ constructor(brain: any);
29
+ /**
30
+ * Initialize version storage directories
31
+ */
32
+ initialize(): Promise<void>;
33
+ /**
34
+ * Calculate SHA-256 hash of entity content
35
+ *
36
+ * Used for content-addressable storage and deduplication
37
+ *
38
+ * @param entity Entity to hash
39
+ * @returns SHA-256 hash (hex string)
40
+ */
41
+ hashEntity(entity: NounMetadata): string;
42
+ /**
43
+ * Convert entity to stable JSON (sorted keys for consistent hashing)
44
+ */
45
+ private toStableJson;
46
+ /**
47
+ * Save entity version to content-addressable storage
48
+ *
49
+ * @param version Version metadata
50
+ * @param entity Entity data
51
+ */
52
+ saveVersion(version: EntityVersion, entity: NounMetadata): Promise<void>;
53
+ /**
54
+ * Load entity version from storage
55
+ *
56
+ * @param version Version metadata
57
+ * @returns Entity data or null if not found
58
+ */
59
+ loadVersion(version: EntityVersion): Promise<NounMetadata | null>;
60
+ /**
61
+ * Delete entity version from storage
62
+ *
63
+ * @param version Version to delete
64
+ */
65
+ deleteVersion(version: EntityVersion): Promise<void>;
66
+ /**
67
+ * Get version storage path
68
+ *
69
+ * @param entityId Entity ID
70
+ * @param contentHash Content hash
71
+ * @returns Storage path
72
+ */
73
+ private getVersionPath;
74
+ /**
75
+ * Check if content exists in storage
76
+ *
77
+ * @param path Storage path
78
+ * @returns True if exists
79
+ */
80
+ private contentExists;
81
+ /**
82
+ * Write version data to storage
83
+ *
84
+ * @param path Storage path
85
+ * @param entity Entity data
86
+ */
87
+ private writeVersionData;
88
+ /**
89
+ * Read version data from storage
90
+ *
91
+ * @param path Storage path
92
+ * @returns Entity data
93
+ */
94
+ private readVersionData;
95
+ /**
96
+ * Delete version data from storage
97
+ *
98
+ * @param path Storage path
99
+ */
100
+ private deleteVersionData;
101
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * VersionStorage - Hybrid Storage for Entity Versions (v5.3.0)
3
+ *
4
+ * Implements content-addressable storage for entity versions:
5
+ * - SHA-256 content hashing for deduplication
6
+ * - Stores versions in .brainy/versions/ directory
7
+ * - Integrates with COW commit system
8
+ * - Space-efficient: Only stores unique content
9
+ *
10
+ * Storage structure:
11
+ * .brainy/versions/
12
+ * ├── entities/
13
+ * │ └── {entityId}/
14
+ * │ └── {contentHash}.json # Entity version data
15
+ * └── index/
16
+ * └── {entityId}.json # Version index (managed by VersionIndex)
17
+ *
18
+ * NO MOCKS - Production implementation
19
+ */
20
+ import { createHash } from 'crypto';
21
+ /**
22
+ * VersionStorage - Content-addressable version storage
23
+ */
24
+ export class VersionStorage {
25
+ constructor(brain) {
26
+ this.initialized = false;
27
+ this.brain = brain;
28
+ }
29
+ /**
30
+ * Initialize version storage directories
31
+ */
32
+ async initialize() {
33
+ if (this.initialized)
34
+ return;
35
+ // Version storage uses the same storage adapter as the main database
36
+ // Directories are created automatically by the storage adapter
37
+ this.initialized = true;
38
+ }
39
+ /**
40
+ * Calculate SHA-256 hash of entity content
41
+ *
42
+ * Used for content-addressable storage and deduplication
43
+ *
44
+ * @param entity Entity to hash
45
+ * @returns SHA-256 hash (hex string)
46
+ */
47
+ hashEntity(entity) {
48
+ // Create stable JSON representation (sorted keys)
49
+ const stableJson = this.toStableJson(entity);
50
+ return createHash('sha256').update(stableJson).digest('hex');
51
+ }
52
+ /**
53
+ * Convert entity to stable JSON (sorted keys for consistent hashing)
54
+ */
55
+ toStableJson(obj) {
56
+ if (obj === null)
57
+ return 'null';
58
+ if (obj === undefined)
59
+ return 'undefined';
60
+ if (typeof obj !== 'object')
61
+ return JSON.stringify(obj);
62
+ if (Array.isArray(obj)) {
63
+ const items = obj.map((item) => this.toStableJson(item));
64
+ return `[${items.join(',')}]`;
65
+ }
66
+ // Sort object keys for stable hashing
67
+ const sortedKeys = Object.keys(obj).sort();
68
+ const pairs = sortedKeys.map((key) => {
69
+ const value = this.toStableJson(obj[key]);
70
+ return `"${key}":${value}`;
71
+ });
72
+ return `{${pairs.join(',')}}`;
73
+ }
74
+ /**
75
+ * Save entity version to content-addressable storage
76
+ *
77
+ * @param version Version metadata
78
+ * @param entity Entity data
79
+ */
80
+ async saveVersion(version, entity) {
81
+ await this.initialize();
82
+ // Content-addressable path: .brainy/versions/entities/{entityId}/{contentHash}.json
83
+ const versionPath = this.getVersionPath(version.entityId, version.contentHash);
84
+ // Check if content already exists (deduplication)
85
+ const exists = await this.contentExists(versionPath);
86
+ if (exists) {
87
+ // Content already stored - no need to write again
88
+ return;
89
+ }
90
+ // Store entity data
91
+ await this.writeVersionData(versionPath, entity);
92
+ }
93
+ /**
94
+ * Load entity version from storage
95
+ *
96
+ * @param version Version metadata
97
+ * @returns Entity data or null if not found
98
+ */
99
+ async loadVersion(version) {
100
+ await this.initialize();
101
+ const versionPath = this.getVersionPath(version.entityId, version.contentHash);
102
+ try {
103
+ return await this.readVersionData(versionPath);
104
+ }
105
+ catch (error) {
106
+ console.error(`Failed to load version ${version.version}:`, error);
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Delete entity version from storage
112
+ *
113
+ * @param version Version to delete
114
+ */
115
+ async deleteVersion(version) {
116
+ await this.initialize();
117
+ const versionPath = this.getVersionPath(version.entityId, version.contentHash);
118
+ await this.deleteVersionData(versionPath);
119
+ }
120
+ /**
121
+ * Get version storage path
122
+ *
123
+ * @param entityId Entity ID
124
+ * @param contentHash Content hash
125
+ * @returns Storage path
126
+ */
127
+ getVersionPath(entityId, contentHash) {
128
+ return `versions/entities/${entityId}/${contentHash}.json`;
129
+ }
130
+ /**
131
+ * Check if content exists in storage
132
+ *
133
+ * @param path Storage path
134
+ * @returns True if exists
135
+ */
136
+ async contentExists(path) {
137
+ try {
138
+ // Use storage adapter's exists check if available
139
+ const adapter = this.brain.storageAdapter;
140
+ if (adapter && typeof adapter.exists === 'function') {
141
+ return await adapter.exists(path);
142
+ }
143
+ // Fallback: Try to read and catch error
144
+ await this.readVersionData(path);
145
+ return true;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ /**
152
+ * Write version data to storage
153
+ *
154
+ * @param path Storage path
155
+ * @param entity Entity data
156
+ */
157
+ async writeVersionData(path, entity) {
158
+ const adapter = this.brain.storageAdapter;
159
+ if (!adapter) {
160
+ throw new Error('Storage adapter not available');
161
+ }
162
+ // Serialize entity data
163
+ const data = JSON.stringify(entity, null, 2);
164
+ // Write to storage using adapter
165
+ if (typeof adapter.writeFile === 'function') {
166
+ await adapter.writeFile(path, data);
167
+ }
168
+ else if (typeof adapter.set === 'function') {
169
+ await adapter.set(path, data);
170
+ }
171
+ else {
172
+ throw new Error('Storage adapter does not support write operations');
173
+ }
174
+ }
175
+ /**
176
+ * Read version data from storage
177
+ *
178
+ * @param path Storage path
179
+ * @returns Entity data
180
+ */
181
+ async readVersionData(path) {
182
+ const adapter = this.brain.storageAdapter;
183
+ if (!adapter) {
184
+ throw new Error('Storage adapter not available');
185
+ }
186
+ // Read from storage using adapter
187
+ let data;
188
+ if (typeof adapter.readFile === 'function') {
189
+ data = await adapter.readFile(path);
190
+ }
191
+ else if (typeof adapter.get === 'function') {
192
+ data = await adapter.get(path);
193
+ }
194
+ else {
195
+ throw new Error('Storage adapter does not support read operations');
196
+ }
197
+ // Parse entity data
198
+ return JSON.parse(data);
199
+ }
200
+ /**
201
+ * Delete version data from storage
202
+ *
203
+ * @param path Storage path
204
+ */
205
+ async deleteVersionData(path) {
206
+ const adapter = this.brain.storageAdapter;
207
+ if (!adapter) {
208
+ throw new Error('Storage adapter not available');
209
+ }
210
+ // Delete from storage using adapter
211
+ if (typeof adapter.deleteFile === 'function') {
212
+ await adapter.deleteFile(path);
213
+ }
214
+ else if (typeof adapter.delete === 'function') {
215
+ await adapter.delete(path);
216
+ }
217
+ else {
218
+ throw new Error('Storage adapter does not support delete operations');
219
+ }
220
+ }
221
+ }
222
+ //# sourceMappingURL=VersionStorage.js.map