@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.
- package/LICENSE +16 -0
- package/README.md +328 -0
- package/dist/aggregation/NativeAggregationEngine.d.ts +118 -0
- package/dist/aggregation/NativeAggregationEngine.js +186 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +236 -0
- package/dist/graph/GraphAccelerationAdapter.d.ts +153 -0
- package/dist/graph/GraphAccelerationAdapter.js +326 -0
- package/dist/graph/NativeGraphAdjacencyIndex.d.ts +286 -0
- package/dist/graph/NativeGraphAdjacencyIndex.js +944 -0
- package/dist/hnsw/AdaptiveDiskAnnModeSelector.d.ts +187 -0
- package/dist/hnsw/AdaptiveDiskAnnModeSelector.js +313 -0
- package/dist/hnsw/NativeDiskAnnWrapper.d.ts +489 -0
- package/dist/hnsw/NativeDiskAnnWrapper.js +1456 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +29 -0
- package/dist/legacyLayoutGuard.d.ts +93 -0
- package/dist/legacyLayoutGuard.js +237 -0
- package/dist/license/constants.d.ts +30 -0
- package/dist/license/constants.js +30 -0
- package/dist/license/onlineValidator.d.ts +43 -0
- package/dist/license/onlineValidator.js +225 -0
- package/dist/license/types.d.ts +39 -0
- package/dist/license/types.js +5 -0
- package/dist/license.d.ts +64 -0
- package/dist/license.js +165 -0
- package/dist/migration/MigrationCoordinator.d.ts +171 -0
- package/dist/migration/MigrationCoordinator.js +249 -0
- package/dist/migration/indexEpochGuard.d.ts +163 -0
- package/dist/migration/indexEpochGuard.js +202 -0
- package/dist/native/NativeEmbeddingEngine.d.ts +79 -0
- package/dist/native/NativeEmbeddingEngine.js +318 -0
- package/dist/native/NativeRoaringBitmap32.d.ts +114 -0
- package/dist/native/NativeRoaringBitmap32.js +221 -0
- package/dist/native/ffi.d.ts +20 -0
- package/dist/native/ffi.js +48 -0
- package/dist/native/idMapperTestSupport.d.ts +67 -0
- package/dist/native/idMapperTestSupport.js +112 -0
- package/dist/native/index.d.ts +49 -0
- package/dist/native/index.js +87 -0
- package/dist/native/napi.d.ts +21 -0
- package/dist/native/napi.js +88 -0
- package/dist/native/types.d.ts +1298 -0
- package/dist/native/types.js +16 -0
- package/dist/plugin.d.ts +50 -0
- package/dist/plugin.js +388 -0
- package/dist/providerContracts.d.ts +277 -0
- package/dist/providerContracts.js +38 -0
- package/dist/resource/OsMemoryProbe.d.ts +175 -0
- package/dist/resource/OsMemoryProbe.js +206 -0
- package/dist/resource/ResourceManager.d.ts +491 -0
- package/dist/resource/ResourceManager.js +960 -0
- package/dist/utils/ColumnManifest.d.ts +97 -0
- package/dist/utils/ColumnManifest.js +129 -0
- package/dist/utils/NativeColumnStore.d.ts +284 -0
- package/dist/utils/NativeColumnStore.js +685 -0
- package/dist/utils/NativeMetadataIndex.d.ts +882 -0
- package/dist/utils/NativeMetadataIndex.js +2631 -0
- package/dist/utils/NativeUnifiedCache.d.ts +87 -0
- package/dist/utils/NativeUnifiedCache.js +273 -0
- package/dist/utils/binaryIdMapperFactory.d.ts +59 -0
- package/dist/utils/binaryIdMapperFactory.js +94 -0
- package/dist/utils/collation.d.ts +30 -0
- package/dist/utils/collation.js +40 -0
- package/dist/utils/columnStoreTypes.d.ts +161 -0
- package/dist/utils/columnStoreTypes.js +29 -0
- package/dist/utils/nativeBinaryEntityIdMapper.d.ts +211 -0
- package/dist/utils/nativeBinaryEntityIdMapper.js +381 -0
- package/dist/utils/nativeEntityIdMapper.d.ts +111 -0
- package/dist/utils/nativeEntityIdMapper.js +170 -0
- package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
- package/docs/ADR-003-semantic-time-travel.md +100 -0
- package/docs/aggregation.md +251 -0
- package/docs/comparison.md +162 -0
- package/docs/deployment-limits.md +87 -0
- package/docs/diskann.md +184 -0
- package/docs/migration-3.0.md +396 -0
- package/docs/performance-budget.md +274 -0
- package/docs/performance.md +117 -0
- package/docs/scaling.md +439 -0
- package/docs/snapshot-safety.md +246 -0
- package/docs/u64-id-space.md +214 -0
- package/docs/verification-report.md +670 -0
- package/native/brainy-native.node +0 -0
- package/native/index.d.ts +3916 -0
- package/package.json +92 -0
- 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
|
+
})
|