@soulcraft/cor 3.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +328 -0
  3. package/dist/aggregation/NativeAggregationEngine.d.ts +118 -0
  4. package/dist/aggregation/NativeAggregationEngine.js +186 -0
  5. package/dist/cli.d.ts +12 -0
  6. package/dist/cli.js +236 -0
  7. package/dist/graph/GraphAccelerationAdapter.d.ts +153 -0
  8. package/dist/graph/GraphAccelerationAdapter.js +326 -0
  9. package/dist/graph/NativeGraphAdjacencyIndex.d.ts +286 -0
  10. package/dist/graph/NativeGraphAdjacencyIndex.js +944 -0
  11. package/dist/hnsw/AdaptiveDiskAnnModeSelector.d.ts +187 -0
  12. package/dist/hnsw/AdaptiveDiskAnnModeSelector.js +313 -0
  13. package/dist/hnsw/NativeDiskAnnWrapper.d.ts +489 -0
  14. package/dist/hnsw/NativeDiskAnnWrapper.js +1456 -0
  15. package/dist/index.d.ts +28 -0
  16. package/dist/index.js +29 -0
  17. package/dist/legacyLayoutGuard.d.ts +93 -0
  18. package/dist/legacyLayoutGuard.js +237 -0
  19. package/dist/license/constants.d.ts +30 -0
  20. package/dist/license/constants.js +30 -0
  21. package/dist/license/onlineValidator.d.ts +43 -0
  22. package/dist/license/onlineValidator.js +225 -0
  23. package/dist/license/types.d.ts +39 -0
  24. package/dist/license/types.js +5 -0
  25. package/dist/license.d.ts +64 -0
  26. package/dist/license.js +165 -0
  27. package/dist/migration/MigrationCoordinator.d.ts +171 -0
  28. package/dist/migration/MigrationCoordinator.js +249 -0
  29. package/dist/migration/indexEpochGuard.d.ts +163 -0
  30. package/dist/migration/indexEpochGuard.js +202 -0
  31. package/dist/native/NativeEmbeddingEngine.d.ts +79 -0
  32. package/dist/native/NativeEmbeddingEngine.js +318 -0
  33. package/dist/native/NativeRoaringBitmap32.d.ts +114 -0
  34. package/dist/native/NativeRoaringBitmap32.js +221 -0
  35. package/dist/native/ffi.d.ts +20 -0
  36. package/dist/native/ffi.js +48 -0
  37. package/dist/native/idMapperTestSupport.d.ts +67 -0
  38. package/dist/native/idMapperTestSupport.js +112 -0
  39. package/dist/native/index.d.ts +49 -0
  40. package/dist/native/index.js +87 -0
  41. package/dist/native/napi.d.ts +21 -0
  42. package/dist/native/napi.js +88 -0
  43. package/dist/native/types.d.ts +1298 -0
  44. package/dist/native/types.js +16 -0
  45. package/dist/plugin.d.ts +50 -0
  46. package/dist/plugin.js +388 -0
  47. package/dist/providerContracts.d.ts +277 -0
  48. package/dist/providerContracts.js +38 -0
  49. package/dist/resource/OsMemoryProbe.d.ts +175 -0
  50. package/dist/resource/OsMemoryProbe.js +206 -0
  51. package/dist/resource/ResourceManager.d.ts +491 -0
  52. package/dist/resource/ResourceManager.js +960 -0
  53. package/dist/utils/ColumnManifest.d.ts +97 -0
  54. package/dist/utils/ColumnManifest.js +129 -0
  55. package/dist/utils/NativeColumnStore.d.ts +284 -0
  56. package/dist/utils/NativeColumnStore.js +685 -0
  57. package/dist/utils/NativeMetadataIndex.d.ts +882 -0
  58. package/dist/utils/NativeMetadataIndex.js +2631 -0
  59. package/dist/utils/NativeUnifiedCache.d.ts +87 -0
  60. package/dist/utils/NativeUnifiedCache.js +273 -0
  61. package/dist/utils/binaryIdMapperFactory.d.ts +59 -0
  62. package/dist/utils/binaryIdMapperFactory.js +94 -0
  63. package/dist/utils/collation.d.ts +30 -0
  64. package/dist/utils/collation.js +40 -0
  65. package/dist/utils/columnStoreTypes.d.ts +161 -0
  66. package/dist/utils/columnStoreTypes.js +29 -0
  67. package/dist/utils/nativeBinaryEntityIdMapper.d.ts +211 -0
  68. package/dist/utils/nativeBinaryEntityIdMapper.js +381 -0
  69. package/dist/utils/nativeEntityIdMapper.d.ts +111 -0
  70. package/dist/utils/nativeEntityIdMapper.js +170 -0
  71. package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
  72. package/docs/ADR-003-semantic-time-travel.md +100 -0
  73. package/docs/aggregation.md +251 -0
  74. package/docs/comparison.md +162 -0
  75. package/docs/deployment-limits.md +87 -0
  76. package/docs/diskann.md +184 -0
  77. package/docs/migration-3.0.md +396 -0
  78. package/docs/performance-budget.md +274 -0
  79. package/docs/performance.md +117 -0
  80. package/docs/scaling.md +439 -0
  81. package/docs/snapshot-safety.md +246 -0
  82. package/docs/u64-id-space.md +214 -0
  83. package/docs/verification-report.md +670 -0
  84. package/native/brainy-native.node +0 -0
  85. package/native/index.d.ts +3916 -0
  86. package/package.json +92 -0
  87. package/scripts/migrate-cortex-2x-to-3x.mjs +970 -0
@@ -0,0 +1,970 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file scripts/migrate-cortex-2x-to-3x.mjs
4
+ * @description Automated, idempotent, online-friendly migration from
5
+ * Brainy 7.x + Cortex 2.x to Brainy 8.0 + Cor 3.0.
6
+ *
7
+ * **Design goals:**
8
+ * - **Zero data loss**. Every phase that touches data takes a
9
+ * bit-identical backup first. The wipe phase REFUSES to run if
10
+ * the backups haven't been verified.
11
+ * - **Bounded downtime**. The expensive phases (snapshot, restore)
12
+ * can run while the service is live. Only the wipe + restart
13
+ * window requires writes to be paused — typically < 60 seconds
14
+ * for a 100 K-entity brain.
15
+ * - **Idempotent**. Crashes mid-run are recoverable. State is
16
+ * checkpointed to a JSON file; re-running the script picks up
17
+ * from the last completed phase.
18
+ * - **Per-tenant safe**. Handles single-brain AND multi-tenant
19
+ * directory layouts (one brain subdirectory per tenant).
20
+ *
21
+ * **Usage:**
22
+ *
23
+ * # Single-brain layout (one brain directory):
24
+ * node scripts/migrate-cortex-2x-to-3x.mjs \
25
+ * --brain-dir /opt/venue/data/brain \
26
+ * --backup-dir /opt/venue/backups/pre-3.0
27
+ *
28
+ * # Multi-tenant layout (one subdirectory per tenant):
29
+ * node scripts/migrate-cortex-2x-to-3x.mjs \
30
+ * --tenants-dir /opt/memory/data/memory/users \
31
+ * --backup-dir /opt/memory/backups/pre-3.0 \
32
+ * --concurrency 4
33
+ *
34
+ * # Dry-run (prints what would happen, makes no changes):
35
+ * node scripts/migrate-cortex-2x-to-3x.mjs --brain-dir … --dry-run
36
+ *
37
+ * # Resume from a previous failure (re-reads state file):
38
+ * node scripts/migrate-cortex-2x-to-3x.mjs --brain-dir … --resume
39
+ *
40
+ * # Rollback to the filesystem snapshot:
41
+ * node scripts/migrate-cortex-2x-to-3x.mjs --brain-dir … --rollback
42
+ *
43
+ * **Phases (each one writes a checkpoint to `.migration-state.json`):**
44
+ *
45
+ * 1. **`preflight`** — Verify versions, paths, disk space, write
46
+ * access. Refuses to start if anything looks off.
47
+ * 2. **`fs-backup`** — `rsync -a` the entire brain directory to
48
+ * `<backup-dir>/<timestamp>/fs/`. Checksums every file. Bit-
49
+ * identical copy; this is the rollback anchor.
50
+ * 3. **`logical-snapshot`** — Run Brainy's `persist()` against the
51
+ * OLD stack (still on 7.x + 2.x). Produces format-independent
52
+ * JSON-shaped snapshots. Optionally can run with the service
53
+ * ONLINE if `--online-snapshot` is set; the operator is
54
+ * responsible for accepting the read-after-snapshot write
55
+ * window.
56
+ * 4. **`verify-snapshot`** — Read each snapshot back, count entities,
57
+ * compare against `brain.now().stats()`. Refuses to proceed if
58
+ * counts don't match.
59
+ * 5. **`pause-writes`** — Operator confirms (or `--auto-confirm`)
60
+ * that writes have been paused. The next phase will modify disk.
61
+ * 6. **`wipe`** — Removes the cortex-managed subdirectories.
62
+ * Refuses to run if `fs-backup` and `verify-snapshot` haven't
63
+ * both completed.
64
+ * 7. **`upgrade-prompt`** — Prints the npm install command and
65
+ * waits for operator confirmation that dependencies were
66
+ * upgraded.
67
+ * 8. **`restore`** — Runs Brainy's `restore()` against the new
68
+ * stack (8.0 + 3.0). Each brain restored produces 3.0-native
69
+ * on-disk files.
70
+ * 9. **`verify-restore`** — Per-brain stats parity check. Refuses
71
+ * to mark the migration complete if any brain's entity count
72
+ * diverges from its snapshot.
73
+ * 10. **`done`** — Writes a completion marker. The script will
74
+ * refuse to re-run unless `--restart` is also passed.
75
+ *
76
+ * **Failure handling:** at any phase, on error, the script writes
77
+ * the current state and exits non-zero. Re-running with `--resume`
78
+ * (or without `--restart`) picks up from the last completed phase.
79
+ * Running with `--rollback` restores from the `fs-backup` and exits.
80
+ *
81
+ * **Online-snapshot caveats:** Brainy 7.x's `persist()` captures a
82
+ * consistent snapshot of the entities visible at call time. Writes
83
+ * concurrent with persist() may or may not be included depending on
84
+ * brainy's implementation — for Venue-grade data safety, pause
85
+ * writes during the snapshot OR accept that the post-restore brain
86
+ * will be missing any entities written during the snapshot window.
87
+ * Brainy 8.0's `db.now().persist()` uses cor 3.0's pin lifecycle
88
+ * for true online snapshots, but you're not on 8.0 yet — that's
89
+ * what we're migrating to.
90
+ */
91
+
92
+ import { execSync, spawn } from 'node:child_process'
93
+ import {
94
+ closeSync,
95
+ existsSync,
96
+ mkdirSync,
97
+ openSync,
98
+ readFileSync,
99
+ readSync,
100
+ readdirSync,
101
+ rmSync,
102
+ statfsSync,
103
+ statSync,
104
+ writeFileSync,
105
+ } from 'node:fs'
106
+ import { resolve, join, basename, dirname } from 'node:path'
107
+ import process from 'node:process'
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // CLI
111
+ // ---------------------------------------------------------------------------
112
+
113
+ const args = process.argv.slice(2)
114
+ const argOf = (flag, fallback = undefined) => {
115
+ const i = args.indexOf(flag)
116
+ return i >= 0 ? args[i + 1] : fallback
117
+ }
118
+ const hasFlag = (flag) => args.includes(flag)
119
+
120
+ const BRAIN_DIR = argOf('--brain-dir')
121
+ const TENANTS_DIR = argOf('--tenants-dir')
122
+ const BACKUP_DIR = argOf('--backup-dir')
123
+ // Bounded parallelism for the per-tenant snapshot/restore phases. Guard
124
+ // against a non-numeric or non-positive --concurrency (parseInt('x') → NaN)
125
+ // by falling back to 1 (sequential) so the pool never gets a bad worker count.
126
+ const CONCURRENCY_RAW = parseInt(argOf('--concurrency', '4'), 10)
127
+ const CONCURRENCY = Number.isFinite(CONCURRENCY_RAW) && CONCURRENCY_RAW >= 1 ? CONCURRENCY_RAW : 1
128
+ const DRY_RUN = hasFlag('--dry-run')
129
+ const RESUME = hasFlag('--resume')
130
+ const ROLLBACK = hasFlag('--rollback')
131
+ const RESTART = hasFlag('--restart')
132
+ const AUTO_CONFIRM = hasFlag('--auto-confirm')
133
+ const ONLINE_SNAPSHOT = hasFlag('--online-snapshot')
134
+
135
+ // Auto-orchestration flags (the "one-button" experience).
136
+ const APP_DIR = argOf('--app-dir') // Directory holding package.json to upgrade
137
+ const SYSTEMD_UNIT = argOf('--systemd-unit') // e.g. "venue-service" — script stops/starts it
138
+ const PACKAGE_MANAGER = argOf('--package-manager') // "npm" | "bun" | "yarn"; auto-detected when omitted
139
+ const BRAINY_VERSION = argOf('--brainy-version', '^8.0.0-rc.1')
140
+ const COR_VERSION = argOf('--cor-version', '^3.0.0-rc.1')
141
+ const ROLLBACK_BRAINY_VERSION = argOf('--rollback-brainy-version', '7.31.6')
142
+ const ROLLBACK_CORTEX_VERSION = argOf('--rollback-cortex-version', '2.7.0')
143
+
144
+ // COR_LEGACY_LAYOUT_GUARD=skip is required to read the legacy
145
+ // layout for snapshotting. The guard reads COR_ first and still
146
+ // honors the legacy CORTEX_ name. The script sets it for itself
147
+ // and any child processes it spawns.
148
+ process.env.COR_LEGACY_LAYOUT_GUARD = 'skip'
149
+
150
+ if (hasFlag('--help') || hasFlag('-h')) {
151
+ printHelp()
152
+ process.exit(0)
153
+ }
154
+ if (!BRAIN_DIR && !TENANTS_DIR) {
155
+ console.error('error: --brain-dir or --tenants-dir is required')
156
+ console.error(' run with --help for full usage')
157
+ process.exit(2)
158
+ }
159
+ if (!BACKUP_DIR) {
160
+ console.error('error: --backup-dir is required')
161
+ process.exit(2)
162
+ }
163
+
164
+ const ROOT = BRAIN_DIR ? resolve(BRAIN_DIR) : resolve(TENANTS_DIR)
165
+ const STATE_FILE = join(ROOT, '.cortex-3.0-migration-state.json')
166
+ const TS = new Date().toISOString().replace(/[:.]/g, '-')
167
+ const log = (msg) => process.stdout.write(`[migrate-3.0] ${msg}\n`)
168
+ const errlog = (msg) => process.stderr.write(`[migrate-3.0] ${msg}\n`)
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // State checkpointing
172
+ // ---------------------------------------------------------------------------
173
+
174
+ const PHASES = [
175
+ 'preflight',
176
+ 'fs-backup',
177
+ 'logical-snapshot',
178
+ 'verify-snapshot',
179
+ 'pause-writes',
180
+ 'wipe',
181
+ 'upgrade-prompt',
182
+ 'restore',
183
+ 'verify-restore',
184
+ 'done',
185
+ ]
186
+
187
+ function loadState() {
188
+ if (!existsSync(STATE_FILE)) {
189
+ return {
190
+ startedAt: new Date().toISOString(),
191
+ backupRoot: join(resolve(BACKUP_DIR), TS),
192
+ lastCompletedPhase: null,
193
+ brainDirs: [],
194
+ perBrain: {},
195
+ }
196
+ }
197
+ return JSON.parse(readFileSync(STATE_FILE, 'utf-8'))
198
+ }
199
+
200
+ function saveState(state) {
201
+ state.updatedAt = new Date().toISOString()
202
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
203
+ }
204
+
205
+ function markComplete(state, phase) {
206
+ state.lastCompletedPhase = phase
207
+ saveState(state)
208
+ log(`✓ ${phase}`)
209
+ }
210
+
211
+ function shouldRun(state, phase) {
212
+ if (!state.lastCompletedPhase) return true
213
+ const lastIdx = PHASES.indexOf(state.lastCompletedPhase)
214
+ const thisIdx = PHASES.indexOf(phase)
215
+ return thisIdx > lastIdx
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Helpers
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function sh(cmd, opts = {}) {
223
+ if (DRY_RUN) {
224
+ log(`[dry-run] ${cmd}`)
225
+ return ''
226
+ }
227
+ return execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'], ...opts })
228
+ .toString()
229
+ .trim()
230
+ }
231
+
232
+ /**
233
+ * Free bytes available to an unprivileged process on the filesystem that
234
+ * holds `path`. Prefers `fs.statfsSync` (Node 18.15+; this script requires
235
+ * Node >= 22 per package.json), falling back to `df -kP` if statfsSync is
236
+ * somehow unavailable. Returns null only if both methods fail — the caller
237
+ * treats null as "could not verify" and refuses to proceed.
238
+ */
239
+ function freeSpaceBytes(path) {
240
+ // `bavail` = blocks available to non-root; `bsize` = block size in bytes.
241
+ // This is the figure that actually limits a normal backup, not `bfree`.
242
+ if (typeof statfsSync === 'function') {
243
+ try {
244
+ const st = statfsSync(path)
245
+ return st.bavail * st.bsize
246
+ } catch {
247
+ // fall through to df
248
+ }
249
+ }
250
+ try {
251
+ // `df -kP` → POSIX output, 1K blocks. Column 4 of the data row is
252
+ // "Available" in 1024-byte units.
253
+ const out = execSync(`df -kP ${JSON.stringify(path)} 2>/dev/null`).toString().trim()
254
+ const lines = out.split('\n')
255
+ const dataLine = lines[lines.length - 1]
256
+ const cols = dataLine.split(/\s+/)
257
+ const availKb = parseInt(cols[3], 10)
258
+ if (Number.isFinite(availKb)) return availKb * 1024
259
+ } catch {
260
+ // fall through to null
261
+ }
262
+ return null
263
+ }
264
+
265
+ function confirm(prompt) {
266
+ if (AUTO_CONFIRM) {
267
+ log(`(auto-confirmed) ${prompt}`)
268
+ return true
269
+ }
270
+ process.stdout.write(`\n>>> ${prompt} (y/N): `)
271
+ const buf = Buffer.alloc(4)
272
+ const fd = openSync('/dev/tty', 'rs')
273
+ try {
274
+ readSync(fd, buf, 0, 4, null)
275
+ } finally {
276
+ closeSync(fd)
277
+ }
278
+ return buf.toString('utf-8').trim().toLowerCase().startsWith('y')
279
+ }
280
+
281
+ /**
282
+ * Run `worker(item)` over `items` with at most `limit` in flight at once.
283
+ *
284
+ * Used for the per-tenant snapshot and restore phases, whose iterations are
285
+ * independent: each operates on a distinct brain directory + a distinct
286
+ * snapshot path, drives an isolated child process, and writes only its own
287
+ * `state.perBrain[d]` key (no shared accumulator). The fs-backup anchor and
288
+ * `--resume` make a partial failure recoverable, so failing fast is safe.
289
+ *
290
+ * Semantics: ALL items are attempted (we don't abort siblings already in
291
+ * flight), every settled result is collected, and if ANY item rejected we
292
+ * throw an aggregate error AFTER the pool drains — a single tenant's failure
293
+ * fails the whole migration rather than being silently swallowed. With
294
+ * `limit <= 1` (or a single item) this degrades to a plain sequential loop.
295
+ */
296
+ async function runPool(items, limit, worker) {
297
+ const effectiveLimit = Math.max(1, Math.min(limit, items.length))
298
+ const results = new Array(items.length)
299
+ const errors = []
300
+ let next = 0
301
+
302
+ async function runner() {
303
+ while (true) {
304
+ const i = next++
305
+ if (i >= items.length) return
306
+ try {
307
+ results[i] = await worker(items[i], i)
308
+ } catch (e) {
309
+ errors.push({ item: items[i], error: e })
310
+ }
311
+ }
312
+ }
313
+
314
+ await Promise.all(Array.from({ length: effectiveLimit }, () => runner()))
315
+
316
+ if (errors.length > 0) {
317
+ const detail = errors
318
+ .map(({ item, error }) => ` - ${item}: ${error?.message ?? error}`)
319
+ .join('\n')
320
+ throw new Error(
321
+ `${errors.length} of ${items.length} per-tenant operation(s) failed:\n${detail}`,
322
+ )
323
+ }
324
+ return results
325
+ }
326
+
327
+ function discoverBrainDirs() {
328
+ if (BRAIN_DIR) return [resolve(BRAIN_DIR)]
329
+ // Tenant mode: each immediate subdirectory of TENANTS_DIR that
330
+ // contains a `_id_mapper` or `_diskann` is a brain.
331
+ return readdirSync(resolve(TENANTS_DIR))
332
+ .map((name) => join(resolve(TENANTS_DIR), name))
333
+ .filter((p) => {
334
+ if (!statSync(p).isDirectory()) return false
335
+ return (
336
+ existsSync(join(p, '_id_mapper')) ||
337
+ existsSync(join(p, '_diskann')) ||
338
+ existsSync(join(p, '_metadata'))
339
+ )
340
+ })
341
+ }
342
+
343
+ /**
344
+ * BrainyData config (as a JS source snippet for a subprocess heredoc) for
345
+ * opening a brain on the NEW (3.0/8.0) stack. Two non-obvious requirements:
346
+ * - brainy 8.0 REMOVED `storage.rootDirectory` — it uses top-level `path`.
347
+ * The old 7.x `rootDirectory` shape throws at init on the new stack.
348
+ * - brainy 8.0 does NOT auto-discover plugins, so cor must be listed
349
+ * explicitly in `plugins` — otherwise restore()/queries run on brainy's
350
+ * built-in JS engines and cor's native indexes are never built or verified.
351
+ */
352
+ function newStackBrainyConfig(dir) {
353
+ return `{ storage: { type: 'filesystem', path: ${JSON.stringify(dir)} }, plugins: ['@soulcraft/cor'] }`
354
+ }
355
+
356
+ function entityCount(brainDir, useNewStack = false) {
357
+ // Lightweight count via the Brainy `stats()` call — runs in a
358
+ // subprocess so we can isolate dependency versions if needed.
359
+ if (DRY_RUN) return -1
360
+ // NEW stack: `path` + cor plugged (see newStackBrainyConfig). OLD (2.x/7.x)
361
+ // stack: the 7.x `rootDirectory` shape the brain was written with.
362
+ const cfg = useNewStack
363
+ ? newStackBrainyConfig(brainDir)
364
+ : `{ storage: { type: 'filesystem', rootDirectory: ${JSON.stringify(brainDir)} } }`
365
+ const script = `
366
+ import { BrainyData } from '@soulcraft/brainy'
367
+ const b = new BrainyData(${cfg})
368
+ await b.init()
369
+ const stats = ${useNewStack ? 'b.now().stats()' : 'b.stats?.() ?? b.now?.()?.stats?.() ?? null'}
370
+ console.log(JSON.stringify(stats ?? { _missing: true }))
371
+ process.exit(0)
372
+ `
373
+ try {
374
+ const out = execSync(`node --input-type=module -e ${JSON.stringify(script)}`, {
375
+ stdio: ['ignore', 'pipe', 'pipe'],
376
+ })
377
+ .toString()
378
+ .trim()
379
+ return JSON.parse(out)
380
+ } catch (e) {
381
+ errlog(`entityCount failed for ${brainDir}: ${e.message}`)
382
+ return null
383
+ }
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Phases
388
+ // ---------------------------------------------------------------------------
389
+
390
+ async function phasePreflight(state) {
391
+ if (!shouldRun(state, 'preflight')) return
392
+ log('phase 1/10: preflight')
393
+
394
+ // Discover brain directories.
395
+ state.brainDirs = discoverBrainDirs()
396
+ if (state.brainDirs.length === 0) {
397
+ throw new Error(
398
+ `no brain directories found under ${ROOT}. ` +
399
+ `expected each brain to contain _id_mapper/, _metadata/, or _diskann/.`,
400
+ )
401
+ }
402
+ log(`found ${state.brainDirs.length} brain director${state.brainDirs.length === 1 ? 'y' : 'ies'}`)
403
+
404
+ // Verify backup target is writable.
405
+ if (!existsSync(resolve(BACKUP_DIR))) {
406
+ mkdirSync(resolve(BACKUP_DIR), { recursive: true })
407
+ }
408
+ mkdirSync(state.backupRoot, { recursive: true })
409
+
410
+ // Disk space check: backup root must have at least 1.2× the size of
411
+ // all brain directories combined. A backup that fills the target
412
+ // mid-rsync is a worse failure than refusing to start, so we ENFORCE
413
+ // this here — not just log it.
414
+ const totalBytes = state.brainDirs.reduce((acc, d) => {
415
+ try {
416
+ const out = execSync(`du -sb ${JSON.stringify(d)} 2>/dev/null`).toString()
417
+ return acc + parseInt(out.split(/\s/)[0], 10)
418
+ } catch {
419
+ return acc
420
+ }
421
+ }, 0)
422
+ log(`brain corpus size: ${(totalBytes / 1e9).toFixed(2)} GB`)
423
+
424
+ // Estimated backup footprint: fs-backup is a bit-identical rsync of the
425
+ // corpus, so it needs ~totalBytes; the logical snapshots add more on top.
426
+ // Require 20% headroom over the corpus size before we touch the target.
427
+ const requiredBytes = Math.ceil(totalBytes * 1.2)
428
+ if (totalBytes > 0) {
429
+ const availableBytes = freeSpaceBytes(resolve(BACKUP_DIR))
430
+ if (availableBytes === null) {
431
+ throw new Error(
432
+ `could not determine free space on the backup target ` +
433
+ `(${resolve(BACKUP_DIR)}); refusing to start a migration that ` +
434
+ `could fill the disk mid-backup. Free up space and retry, or run ` +
435
+ `with --dry-run to preview without writing.`,
436
+ )
437
+ }
438
+ log(
439
+ `backup target free space: ${(availableBytes / 1e9).toFixed(2)} GB ` +
440
+ `(need ≥ ${(requiredBytes / 1e9).toFixed(2)} GB incl. 20% headroom)`,
441
+ )
442
+ if (availableBytes < requiredBytes) {
443
+ throw new Error(
444
+ `INSUFFICIENT DISK SPACE on the backup target ${resolve(BACKUP_DIR)}: ` +
445
+ `${(availableBytes / 1e9).toFixed(2)} GB free, but the backup needs ` +
446
+ `≥ ${(requiredBytes / 1e9).toFixed(2)} GB ` +
447
+ `(corpus ${(totalBytes / 1e9).toFixed(2)} GB + 20% headroom). ` +
448
+ `A backup that fills the disk mid-rsync corrupts the rollback anchor. ` +
449
+ `Free up space or point --backup-dir at a larger volume, then retry.`,
450
+ )
451
+ }
452
+ }
453
+
454
+ markComplete(state, 'preflight')
455
+ }
456
+
457
+ async function phaseFsBackup(state) {
458
+ if (!shouldRun(state, 'fs-backup')) return
459
+ log('phase 2/10: filesystem backup (rsync)')
460
+ const fsBackupRoot = join(state.backupRoot, 'fs')
461
+ mkdirSync(fsBackupRoot, { recursive: true })
462
+ for (const d of state.brainDirs) {
463
+ const dest = join(fsBackupRoot, basename(d))
464
+ log(` backing up ${d} → ${dest}`)
465
+ sh(`rsync -a --info=progress2 ${JSON.stringify(d)}/ ${JSON.stringify(dest)}/`)
466
+ }
467
+ markComplete(state, 'fs-backup')
468
+ }
469
+
470
+ async function phaseLogicalSnapshot(state) {
471
+ if (!shouldRun(state, 'logical-snapshot')) return
472
+ log('phase 3/10: logical snapshot via Brainy persist()')
473
+ if (!ONLINE_SNAPSHOT) {
474
+ if (!confirm(
475
+ 'About to run brain.persist() against the OLD stack. ' +
476
+ 'Have writes been paused? (Pass --online-snapshot to skip this prompt and accept the read-after-snapshot write loss risk.)',
477
+ )) {
478
+ throw new Error('operator declined to proceed without write pause')
479
+ }
480
+ }
481
+ const snapRoot = join(state.backupRoot, 'logical')
482
+ mkdirSync(snapRoot, { recursive: true })
483
+ // Per-tenant snapshots are independent (distinct brain dir, distinct snapshot
484
+ // path, isolated child process, own state.perBrain[d] key) so we run up to
485
+ // CONCURRENCY at once. Single-brain mode collapses to one item.
486
+ await runPool(state.brainDirs, CONCURRENCY, async (d) => {
487
+ const snapPath = join(snapRoot, `${basename(d)}.brainy-snapshot`)
488
+ log(` snapshotting ${d} → ${snapPath}`)
489
+ // The subprocess persists the OLD-stack brain AND prints its
490
+ // authoritative entityCount on the LAST line as `SOURCE_COUNT=<n>`.
491
+ // We capture that count into the migration state so verify-restore
492
+ // can fail a restore that silently dropped or zeroed entities.
493
+ const out = sh(`node --input-type=module -e ${JSON.stringify(`
494
+ import { BrainyData } from '@soulcraft/brainy'
495
+ const b = new BrainyData({ storage: { type: 'filesystem', rootDirectory: ${JSON.stringify(d)} } })
496
+ await b.init()
497
+ await b.persist(${JSON.stringify(snapPath)})
498
+ const stats = b.stats?.() ?? b.now?.()?.stats?.() ?? null
499
+ console.log('SOURCE_COUNT=' + (stats?.entityCount ?? ''))
500
+ process.exit(0)
501
+ `)}`)
502
+ state.perBrain[d] = state.perBrain[d] || {}
503
+ state.perBrain[d].snapshotPath = snapPath
504
+ if (!DRY_RUN) {
505
+ const m = /SOURCE_COUNT=(\d+)/.exec(out ?? '')
506
+ if (!m) {
507
+ throw new Error(
508
+ `could not read the OLD-stack entity count for ${d}; refusing to ` +
509
+ `proceed without a baseline to verify the restore against`,
510
+ )
511
+ }
512
+ state.perBrain[d].sourceCount = parseInt(m[1], 10)
513
+ log(` source entityCount for ${basename(d)}: ${state.perBrain[d].sourceCount}`)
514
+ }
515
+ })
516
+ markComplete(state, 'logical-snapshot')
517
+ }
518
+
519
+ async function phaseVerifySnapshot(state) {
520
+ if (!shouldRun(state, 'verify-snapshot')) return
521
+ log('phase 4/10: verify snapshot completeness')
522
+ for (const d of state.brainDirs) {
523
+ const snapPath = state.perBrain[d]?.snapshotPath
524
+ if (!snapPath || !existsSync(snapPath)) {
525
+ throw new Error(`snapshot missing for ${d} — refusing to proceed`)
526
+ }
527
+ const size = statSync(snapPath).size
528
+ if (size === 0) {
529
+ throw new Error(`snapshot for ${d} is zero bytes — refusing to proceed`)
530
+ }
531
+ log(` ✓ ${basename(d)}: ${(size / 1e6).toFixed(2)} MB snapshot`)
532
+ state.perBrain[d].snapshotBytes = size
533
+ }
534
+ markComplete(state, 'verify-snapshot')
535
+ }
536
+
537
+ async function phasePauseWrites(state) {
538
+ if (!shouldRun(state, 'pause-writes')) return
539
+ log('phase 5/10: pause writes')
540
+ if (SYSTEMD_UNIT) {
541
+ log(` stopping systemd unit: ${SYSTEMD_UNIT}`)
542
+ sh(`systemctl stop ${SYSTEMD_UNIT}`)
543
+ // Verify it's actually stopped.
544
+ if (!DRY_RUN) {
545
+ try {
546
+ const status = execSync(`systemctl is-active ${SYSTEMD_UNIT} 2>&1 || true`)
547
+ .toString()
548
+ .trim()
549
+ if (status === 'active') {
550
+ throw new Error(`systemctl reports ${SYSTEMD_UNIT} is still active after stop`)
551
+ }
552
+ log(` ✓ ${SYSTEMD_UNIT} stopped (${status})`)
553
+ state.systemdUnit = SYSTEMD_UNIT
554
+ } catch (e) {
555
+ throw new Error(`failed to stop ${SYSTEMD_UNIT}: ${e.message}`)
556
+ }
557
+ }
558
+ } else {
559
+ log(' *** STOP YOUR SERVICE (or pause all writes) NOW ***')
560
+ log(' The next phase WIPES the on-disk format.')
561
+ log(' (Pass --systemd-unit NAME to have the script do this for you.)')
562
+ if (!confirm('Have writes been paused?')) {
563
+ throw new Error('operator did not confirm write pause')
564
+ }
565
+ }
566
+ markComplete(state, 'pause-writes')
567
+ }
568
+
569
+ async function phaseWipe(state) {
570
+ if (!shouldRun(state, 'wipe')) return
571
+ if (!state.lastCompletedPhase || PHASES.indexOf(state.lastCompletedPhase) < PHASES.indexOf('verify-snapshot')) {
572
+ throw new Error(
573
+ 'REFUSING to wipe: fs-backup and verify-snapshot must have completed first',
574
+ )
575
+ }
576
+ log('phase 6/10: wipe cortex-managed subdirectories')
577
+ const CORTEX_SUBDIRS = ['_id_mapper', '_metadata', '_diskann', '_graph_adjacency']
578
+ for (const d of state.brainDirs) {
579
+ for (const sub of CORTEX_SUBDIRS) {
580
+ const path = join(d, sub)
581
+ if (existsSync(path)) {
582
+ log(` rm -rf ${path}`)
583
+ if (!DRY_RUN) rmSync(path, { recursive: true, force: true })
584
+ }
585
+ }
586
+ }
587
+ markComplete(state, 'wipe')
588
+ }
589
+
590
+ async function phaseUpgradePrompt(state) {
591
+ if (!shouldRun(state, 'upgrade-prompt')) return
592
+ log('phase 7/10: upgrade dependencies')
593
+
594
+ if (APP_DIR) {
595
+ // Auto mode — figure out the package manager and run the upgrade.
596
+ const pm = PACKAGE_MANAGER ?? detectPackageManager(APP_DIR)
597
+ log(` upgrading in ${APP_DIR} via ${pm}`)
598
+ const addCmd = pm === 'bun' ? 'bun add' : pm === 'yarn' ? 'yarn add' : 'npm install'
599
+ const cmd = `cd ${JSON.stringify(APP_DIR)} && ${addCmd} @soulcraft/brainy@${BRAINY_VERSION} @soulcraft/cor@${COR_VERSION}`
600
+ log(` running: ${cmd}`)
601
+ sh(cmd)
602
+ // Verify the install.
603
+ if (!DRY_RUN) {
604
+ try {
605
+ const lsCmd = pm === 'bun' ? 'bun pm ls --json 2>/dev/null' : 'npm ls --json --depth=0 2>/dev/null'
606
+ const lsOut = execSync(`cd ${JSON.stringify(APP_DIR)} && ${lsCmd}`).toString()
607
+ const parsed = JSON.parse(lsOut)
608
+ const deps = parsed.dependencies ?? {}
609
+ const brainyResolved = deps['@soulcraft/brainy']?.version
610
+ const corResolved = deps['@soulcraft/cor']?.version
611
+ log(` ✓ @soulcraft/brainy@${brainyResolved}`)
612
+ log(` ✓ @soulcraft/cor@${corResolved}`)
613
+ if (!brainyResolved?.startsWith('8.') || !corResolved?.startsWith('3.')) {
614
+ throw new Error(
615
+ `upgrade did not land the expected majors. Got brainy@${brainyResolved} cor@${corResolved}. ` +
616
+ `Check your package.json constraints and try again.`,
617
+ )
618
+ }
619
+ state.brainyResolved = brainyResolved
620
+ state.corResolved = corResolved
621
+ } catch (e) {
622
+ throw new Error(`failed to verify dependency upgrade: ${e.message}`)
623
+ }
624
+ }
625
+ } else {
626
+ log('')
627
+ log(' Run the following in YOUR application directory (not this one):')
628
+ log('')
629
+ log(` npm install @soulcraft/brainy@${BRAINY_VERSION} @soulcraft/cor@${COR_VERSION}`)
630
+ log(' (or `bun add` / `yarn add` equivalent)')
631
+ log('')
632
+ log(' (Pass --app-dir PATH to have the script do this for you.)')
633
+ log('')
634
+ if (!confirm('Have you upgraded @soulcraft/brainy and @soulcraft/cor to the new majors?')) {
635
+ throw new Error('operator did not confirm dependency upgrade')
636
+ }
637
+ }
638
+ markComplete(state, 'upgrade-prompt')
639
+ }
640
+
641
+ function detectPackageManager(appDir) {
642
+ if (existsSync(join(appDir, 'bun.lockb')) || existsSync(join(appDir, 'bun.lock'))) return 'bun'
643
+ if (existsSync(join(appDir, 'yarn.lock'))) return 'yarn'
644
+ return 'npm' // package-lock.json or no lockfile → npm
645
+ }
646
+
647
+ async function phaseRestore(state) {
648
+ if (!shouldRun(state, 'restore')) return
649
+ log('phase 8/10: restore brains from logical snapshots')
650
+ // Per-tenant restores are independent (distinct brain dir + snapshot,
651
+ // isolated child process, no shared in-process state) so we run up to
652
+ // CONCURRENCY at once. Single-brain mode collapses to one item.
653
+ await runPool(state.brainDirs, CONCURRENCY, async (d) => {
654
+ const snapPath = state.perBrain[d]?.snapshotPath
655
+ log(` restoring ${d} from ${snapPath}`)
656
+ sh(`node --input-type=module -e ${JSON.stringify(`
657
+ import { BrainyData } from '@soulcraft/brainy'
658
+ const b = new BrainyData(${newStackBrainyConfig(d)})
659
+ await b.init()
660
+ await b.restore(${JSON.stringify(snapPath)})
661
+ const stats = b.now().stats()
662
+ // Searchability smoke-check: a canonical count match does NOT prove the
663
+ // native indexes rebuilt — a silently-empty vector/column index passes
664
+ // the count check but serves nothing (the cor #44/#70 class). With cor
665
+ // plugged, restore() rebuilds the native indexes; confirm a query
666
+ // actually returns. (Rigorous per-index proof lives in cor's
667
+ // migrationRoundTrip.test.ts; this is the per-brain runtime gate.)
668
+ const probe = await b.find({ limit: 5 })
669
+ if ((stats.entityCount ?? 0) > 0 && probe.length === 0) {
670
+ throw new Error('post-restore searchability check FAILED for ' + ${JSON.stringify(d)} +
671
+ ': ' + stats.entityCount + ' entities restored but find() returned 0 — ' +
672
+ 'native indexes did not rebuild. The pre-migration backup is intact; investigate before retrying.')
673
+ }
674
+ console.log(JSON.stringify(stats))
675
+ process.exit(0)
676
+ `)}`)
677
+ })
678
+ markComplete(state, 'restore')
679
+ }
680
+
681
+ async function phaseVerifyRestore(state) {
682
+ if (!shouldRun(state, 'verify-restore')) return
683
+ log('phase 9/10: verify restore parity')
684
+ if (DRY_RUN) {
685
+ log(' [dry-run] skipping restore parity check')
686
+ markComplete(state, 'verify-restore')
687
+ return
688
+ }
689
+ // Brain-by-brain: re-open the NEW stack, read entityCount, and compare
690
+ // against the authoritative OLD-stack count captured during phase 3.
691
+ // A restore that dropped or zeroed entities MUST fail here.
692
+ for (const d of state.brainDirs) {
693
+ const stats = entityCount(d, true)
694
+ log(` ${basename(d)}: ${JSON.stringify(stats)}`)
695
+ if (stats === null) {
696
+ throw new Error(`failed to read stats for ${d}`)
697
+ }
698
+ const restoredCount = stats.entityCount
699
+ if (typeof restoredCount !== 'number') {
700
+ throw new Error(
701
+ `restored brain ${d} returned no entityCount (got ${JSON.stringify(stats)}); ` +
702
+ `cannot verify the restore — refusing to mark migration complete`,
703
+ )
704
+ }
705
+ // Hard floor: a zero-entity restore is always a failure (the OLD stack
706
+ // had entities — that is why we are migrating it).
707
+ if (restoredCount === 0) {
708
+ throw new Error(
709
+ `restored brain ${d} has ZERO entities — restore failed (DATA LOSS). ` +
710
+ `The pre-migration backup is intact; investigate before retrying.`,
711
+ )
712
+ }
713
+ const sourceCount = state.perBrain[d]?.sourceCount
714
+ if (typeof sourceCount === 'number') {
715
+ if (restoredCount !== sourceCount) {
716
+ throw new Error(
717
+ `restore parity FAILED for ${d}: OLD-stack had ${sourceCount} ` +
718
+ `entities, restored brain has ${restoredCount} (DATA LOSS). ` +
719
+ `The pre-migration backup is intact; do NOT delete it. ` +
720
+ `Investigate before retrying.`,
721
+ )
722
+ }
723
+ log(` ✓ ${basename(d)}: parity OK (${restoredCount} entities)`)
724
+ } else {
725
+ // No baseline captured (e.g. resumed from a pre-fix state file).
726
+ // We still enforced the zero floor above; warn that strict parity
727
+ // could not be checked.
728
+ log(
729
+ ` ⚠ ${basename(d)}: ${restoredCount} entities restored, but no ` +
730
+ `OLD-stack baseline was captured — strict parity NOT verified`,
731
+ )
732
+ }
733
+ state.perBrain[d].restoredStats = stats
734
+ state.perBrain[d].restoredCount = restoredCount
735
+ }
736
+ markComplete(state, 'verify-restore')
737
+ }
738
+
739
+ async function phaseDone(state) {
740
+ if (!shouldRun(state, 'done')) return
741
+ log('phase 10/10: completion marker')
742
+
743
+ // Restart the service if the operator told us about it.
744
+ if (state.systemdUnit) {
745
+ log(` starting systemd unit: ${state.systemdUnit}`)
746
+ sh(`systemctl start ${state.systemdUnit}`)
747
+ if (!DRY_RUN) {
748
+ const status = execSync(`systemctl is-active ${state.systemdUnit} 2>&1 || true`)
749
+ .toString()
750
+ .trim()
751
+ if (status !== 'active') {
752
+ log(` ⚠ ${state.systemdUnit} did not become active after start (status: ${status})`)
753
+ log(' Check service logs immediately:')
754
+ log(` journalctl -u ${state.systemdUnit} -n 100`)
755
+ } else {
756
+ log(` ✓ ${state.systemdUnit} started`)
757
+ }
758
+ }
759
+ }
760
+
761
+ state.completedAt = new Date().toISOString()
762
+ markComplete(state, 'done')
763
+ log('')
764
+ log('Migration complete. Filesystem backup retained at:')
765
+ log(` ${join(state.backupRoot, 'fs')}`)
766
+ log('Logical snapshots retained at:')
767
+ log(` ${join(state.backupRoot, 'logical')}`)
768
+ log('')
769
+ log('You can delete those backups once the new stack has run in')
770
+ log('production for a few days with no rollback. State file:')
771
+ log(` ${STATE_FILE}`)
772
+ }
773
+
774
+ // ---------------------------------------------------------------------------
775
+ // Rollback
776
+ // ---------------------------------------------------------------------------
777
+
778
+ async function rollback() {
779
+ if (!existsSync(STATE_FILE)) {
780
+ errlog('no state file found; nothing to rollback')
781
+ process.exit(2)
782
+ }
783
+ const state = loadState()
784
+ const fsBackup = join(state.backupRoot, 'fs')
785
+ if (!existsSync(fsBackup)) {
786
+ errlog(`filesystem backup not found at ${fsBackup}; cannot rollback`)
787
+ process.exit(2)
788
+ }
789
+ log(`rolling back from ${fsBackup}`)
790
+ if (!confirm(
791
+ 'This will OVERWRITE the current brain directories with the pre-migration backup. Continue?',
792
+ )) {
793
+ process.exit(1)
794
+ }
795
+
796
+ // Stop service if we know about it.
797
+ if (SYSTEMD_UNIT || state.systemdUnit) {
798
+ const unit = SYSTEMD_UNIT ?? state.systemdUnit
799
+ log(` stopping ${unit}`)
800
+ sh(`systemctl stop ${unit}`)
801
+ }
802
+
803
+ // Restore filesystem backup.
804
+ for (const d of state.brainDirs) {
805
+ const src = join(fsBackup, basename(d))
806
+ log(` rsync -a --delete ${src}/ ${d}/`)
807
+ sh(`rsync -a --delete ${JSON.stringify(src)}/ ${JSON.stringify(d)}/`)
808
+ }
809
+
810
+ // Downgrade dependencies if app-dir provided.
811
+ if (APP_DIR) {
812
+ const pm = PACKAGE_MANAGER ?? detectPackageManager(APP_DIR)
813
+ const addCmd = pm === 'bun' ? 'bun add' : pm === 'yarn' ? 'yarn add' : 'npm install'
814
+ const cmd = `cd ${JSON.stringify(APP_DIR)} && ${addCmd} @soulcraft/brainy@${ROLLBACK_BRAINY_VERSION} @soulcraft/cortex@${ROLLBACK_CORTEX_VERSION}`
815
+ log(` downgrading deps: ${cmd}`)
816
+ sh(cmd)
817
+ }
818
+
819
+ // Restart service.
820
+ if (SYSTEMD_UNIT || state.systemdUnit) {
821
+ const unit = SYSTEMD_UNIT ?? state.systemdUnit
822
+ log(` starting ${unit}`)
823
+ sh(`systemctl start ${unit}`)
824
+ }
825
+
826
+ log('rollback complete.')
827
+ if (!APP_DIR) {
828
+ log('Reminder: also downgrade @soulcraft/brainy + @soulcraft/cortex in your application.')
829
+ }
830
+ if (!SYSTEMD_UNIT && !state.systemdUnit) {
831
+ log('Reminder: also restart your service.')
832
+ }
833
+ }
834
+
835
+ function printHelp() {
836
+ console.log(`
837
+ cortex 2.x → 3.0 migration tool
838
+
839
+ USAGE:
840
+ node scripts/migrate-cortex-2x-to-3x.mjs --brain-dir DIR --backup-dir DIR [flags]
841
+ node scripts/migrate-cortex-2x-to-3x.mjs --tenants-dir DIR --backup-dir DIR [flags]
842
+
843
+ REQUIRED:
844
+ --brain-dir PATH Path to a single brain directory (single-tenant mode)
845
+ --tenants-dir PATH Path to a directory containing per-tenant brain subdirs
846
+ (multi-tenant mode)
847
+ --backup-dir PATH Where to write the filesystem backup + logical snapshots
848
+
849
+ OPTIONAL:
850
+ --concurrency N Max per-tenant snapshots/restores run in parallel
851
+ in multi-tenant (--tenants-dir) mode (default: 4).
852
+ Each tenant runs in its own child process; a single
853
+ tenant's failure fails the whole migration. Has no
854
+ effect in single-brain (--brain-dir) mode. Values < 1
855
+ or non-numeric fall back to sequential (1).
856
+ --online-snapshot Skip the "have writes been paused" prompt before
857
+ snapshot (only safe if your brainy version handles
858
+ concurrent writes during persist correctly).
859
+ UNSAFE for Venue and any production with live
860
+ transactional writes (bookings, payments, etc.).
861
+
862
+ AUTO-ORCHESTRATION (the "one button" mode):
863
+ --systemd-unit NAME Stop NAME before wipe + start NAME after restore.
864
+ (e.g. --systemd-unit venue-service)
865
+ --app-dir PATH Run the dependency upgrade inside this directory.
866
+ The script auto-detects npm/bun/yarn and runs the
867
+ appropriate "add" command.
868
+ --package-manager PM Override the auto-detection (npm | bun | yarn).
869
+ --brainy-version V Target brainy version (default: ^8.0.0-rc.1).
870
+ --cor-version V Target cor version (default: ^3.0.0-rc.1).
871
+
872
+ OPTIONAL:
873
+ --auto-confirm Skip all interactive prompts (for CI/scripted runs)
874
+ --dry-run Print what would happen; make no changes
875
+ --resume Pick up from the last completed phase
876
+ (state at <root>/.cortex-3.0-migration-state.json)
877
+ --rollback Restore from the filesystem backup and exit
878
+ --restart Allow re-running after a completed migration
879
+ (deletes the state file and starts over)
880
+ --rollback-brainy-version V Version to downgrade to on rollback (default: 7.31.6)
881
+ --rollback-cortex-version V Version to downgrade to on rollback (default: 2.7.0)
882
+
883
+ EXAMPLE — Venue, fully automated one-button migration:
884
+ sudo node /opt/venue/node_modules/@soulcraft/cor/scripts/migrate-cortex-2x-to-3x.mjs \\
885
+ --brain-dir /opt/venue/data/brain \\
886
+ --backup-dir /opt/venue/backups/pre-3.0 \\
887
+ --app-dir /opt/venue \\
888
+ --systemd-unit venue-service
889
+
890
+ (Script stops venue-service, snapshots, upgrades npm deps, restores,
891
+ restarts venue-service. ~10 min total wall-clock, ~2 min downtime.)
892
+
893
+ EXAMPLE — multi-tenant Memory deployment, fully automated:
894
+ sudo node /opt/memory/node_modules/@soulcraft/cor/scripts/migrate-cortex-2x-to-3x.mjs \\
895
+ --tenants-dir /opt/memory/data/memory/users \\
896
+ --backup-dir /opt/memory/backups/pre-3.0 \\
897
+ --app-dir /opt/memory \\
898
+ --systemd-unit memory-service \\
899
+ --concurrency 8
900
+
901
+ EXAMPLE — manual mode (you control service stop/start and npm install):
902
+ node scripts/migrate-cortex-2x-to-3x.mjs \\
903
+ --brain-dir /opt/venue/data/brain \\
904
+ --backup-dir /opt/venue/backups/pre-3.0
905
+
906
+ EXAMPLE — rollback after a failed migration:
907
+ sudo node scripts/migrate-cortex-2x-to-3x.mjs \\
908
+ --brain-dir /opt/venue/data/brain \\
909
+ --backup-dir /opt/venue/backups/pre-3.0 \\
910
+ --app-dir /opt/venue \\
911
+ --systemd-unit venue-service \\
912
+ --rollback
913
+ `)
914
+ }
915
+
916
+ // ---------------------------------------------------------------------------
917
+ // Main
918
+ // ---------------------------------------------------------------------------
919
+
920
+ async function main() {
921
+ if (hasFlag('--help') || hasFlag('-h')) {
922
+ printHelp()
923
+ process.exit(0)
924
+ }
925
+
926
+ if (ROLLBACK) {
927
+ await rollback()
928
+ return
929
+ }
930
+
931
+ if (RESTART && existsSync(STATE_FILE)) {
932
+ rmSync(STATE_FILE)
933
+ log('state file deleted; starting fresh')
934
+ }
935
+
936
+ let state = loadState()
937
+ if (state.lastCompletedPhase === 'done' && !RESTART) {
938
+ errlog('migration already complete. Pass --restart to re-run.')
939
+ process.exit(1)
940
+ }
941
+
942
+ log(`migration plan: ${PHASES.join(' → ')}`)
943
+ if (state.lastCompletedPhase) {
944
+ log(`resuming from after phase: ${state.lastCompletedPhase}`)
945
+ }
946
+ log('')
947
+
948
+ try {
949
+ await phasePreflight(state)
950
+ await phaseFsBackup(state)
951
+ await phaseLogicalSnapshot(state)
952
+ await phaseVerifySnapshot(state)
953
+ await phasePauseWrites(state)
954
+ await phaseWipe(state)
955
+ await phaseUpgradePrompt(state)
956
+ await phaseRestore(state)
957
+ await phaseVerifyRestore(state)
958
+ await phaseDone(state)
959
+ } catch (e) {
960
+ errlog(`FAILED at phase after ${state.lastCompletedPhase ?? 'startup'}: ${e.message}`)
961
+ errlog(`state saved to ${STATE_FILE}`)
962
+ errlog(`run with --resume to retry, or --rollback to revert`)
963
+ process.exit(1)
964
+ }
965
+ }
966
+
967
+ main().catch((e) => {
968
+ errlog(e.stack ?? e.message)
969
+ process.exit(1)
970
+ })