mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
2693 lines
114 KiB
JavaScript
2693 lines
114 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import { closeSync, constants, existsSync, fstatSync, ftruncateSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, writeSync, } from 'node:fs';
|
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
import { userInfo } from 'node:os';
|
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
import { reference, algo, events, util } from '@evomap/evolver-core';
|
|
const ACTIVE_FILE = 'memory_graph.v2.jsonl';
|
|
const COMPACT_FILE = 'memory_graph.compact.jsonl';
|
|
const LOCK_FILE = 'memory_graph.lock';
|
|
const RECOVERY_FILE = 'memory_graph.recovery.json';
|
|
const EPOCH_FILE = 'memory_graph.epoch.json';
|
|
const ROTATION_JOURNAL_FILE = 'memory_graph.rotation.json';
|
|
const ARCHIVE_PATTERN = /^memory_graph\.v2\.(\d{13})\.jsonl\.gz$/;
|
|
const ROTATION_GENERATION_PATTERN = /^\d{13}$/;
|
|
const ROTATION_TRANSACTION_PATTERN = /^txn_[a-f0-9]{64}$/;
|
|
const ROTATION_STAGE_PATTERN = /^memory_graph\.rotation\.(\d{13})\.(compact|archive|active)\.stage$/;
|
|
const ROTATION_TEMP_PATTERN = /^\.(?:memory_graph\.rotation\.json|memory_graph\.rotation\.(\d{13})\.(compact|archive|active)\.stage)\.(\d{13})\.tmp$/;
|
|
const RESET_BACKUP_PATTERN = /^memory_graph\.reset\.(\d{13,})$/;
|
|
const RESET_PENDING_PATTERN = /^memory_graph\.reset\.pending\.(\d{13,})$/;
|
|
const LEGACY_EPOCH_ID = 'legacy';
|
|
const DEFAULT_MAX_COMPACT_BYTES = 2 * 1024 * 1024;
|
|
const MAX_ROTATION_JOURNAL_BYTES = 64 * 1024;
|
|
export function resolveLocalMemoryUserId(info = userInfo()) {
|
|
if (Number.isInteger(info.uid) && info.uid >= 0)
|
|
return String(info.uid);
|
|
return `account:${process.platform}:${info.username.trim().toLowerCase()}:${resolve(info.homedir)}`;
|
|
}
|
|
export function resolveLocalMemoryUserIdentity(graphDir, info = userInfo()) {
|
|
const userId = resolveLocalMemoryUserId(info);
|
|
if (info.uid !== -1)
|
|
return { userId, legacyUserIds: [] };
|
|
const fromHome = relative(resolve(info.homedir), resolve(graphDir));
|
|
const privateToAccount = fromHome === '' || (!fromHome.startsWith('..') && !isAbsolute(fromHome));
|
|
return { userId, legacyUserIds: privateToAccount ? ['-1'] : [] };
|
|
}
|
|
export class MemoryGraphImportStateRejectedError extends Error {
|
|
constructor() {
|
|
super('memory_graph_import_state_rejected');
|
|
this.name = 'MemoryGraphImportStateRejectedError';
|
|
}
|
|
}
|
|
const memoryGraphV1OutcomePlans = new WeakMap();
|
|
export class MemoryGraphBusyError extends Error {
|
|
constructor() {
|
|
super('memory_graph_busy');
|
|
this.name = 'MemoryGraphBusyError';
|
|
}
|
|
}
|
|
export class LocalMemoryGraph {
|
|
dir;
|
|
referenceScope;
|
|
userScope;
|
|
readableUserScopes;
|
|
now;
|
|
maxActiveBytes;
|
|
maxTailBytes;
|
|
maxLineBytes;
|
|
maxCompactBytes;
|
|
maxCompactEdges;
|
|
archiveRetention;
|
|
onRotationPhase;
|
|
importedFingerprints = new Map();
|
|
rotationCleanupFailed = false;
|
|
constructor(options) {
|
|
this.dir = resolve(options.dir);
|
|
this.referenceScope = reference.referenceScopeForEventsPath(join(this.dir, 'root_events.jsonl'));
|
|
this.userScope = scopeHash(`user:${options.userId}`);
|
|
this.readableUserScopes = new Set([
|
|
this.userScope,
|
|
...(options.legacyUserIds ?? []).map((userId) => scopeHash(`user:${userId}`)),
|
|
]);
|
|
this.now = options.now ?? Date.now;
|
|
this.maxActiveBytes = positiveInt(options.maxActiveBytes, 4 * 1024 * 1024);
|
|
this.maxTailBytes = positiveInt(options.maxTailBytes, 512 * 1024);
|
|
this.maxLineBytes = positiveInt(options.maxLineBytes, 16 * 1024);
|
|
this.maxCompactBytes = positiveInt(options.maxCompactBytes, DEFAULT_MAX_COMPACT_BYTES);
|
|
this.maxCompactEdges = positiveInt(options.maxCompactEdges, 4096);
|
|
this.archiveRetention = positiveInt(options.archiveRetention, 3);
|
|
this.onRotationPhase = options.onRotationPhase;
|
|
}
|
|
query(input) {
|
|
const diagnostics = emptyDiagnostics();
|
|
const workspaceScope = this.workspaceScope(input.workspace);
|
|
if (!workspaceScope)
|
|
return { genes: [], diagnostics: { ...diagnostics, recovery: 'degraded' } };
|
|
try {
|
|
return this.withGraphLock(() => {
|
|
const records = this.readQueryableRecords(diagnostics).filter((record) => {
|
|
if (record.provenance !== 'v2_local' && record.provenance !== 'v1_import') {
|
|
diagnostics.provenanceRejected += 1;
|
|
return false;
|
|
}
|
|
if (record.workspaceScope !== workspaceScope || !this.readableUserScopes.has(record.userScope)) {
|
|
diagnostics.scopeRejected += 1;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (this.rotationCleanupFailed)
|
|
diagnostics.recovery = 'degraded';
|
|
if (records.length === 0 && diagnostics.recovery === 'healthy')
|
|
diagnostics.recovery = 'empty';
|
|
return reference.withReferenceScope(this.referenceScope, () => algo.deriveMemoryGraphAdvice(records, input.signals, this.now(), diagnostics));
|
|
});
|
|
}
|
|
catch (error) {
|
|
if (error instanceof MemoryGraphBusyError) {
|
|
return { genes: [], diagnostics: { ...diagnostics, recovery: 'degraded', busy: true } };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
recordOutcome(input) {
|
|
if (!reference.isExecutionEligible(input, this.referenceScope))
|
|
return;
|
|
const workspaceScope = this.workspaceScope(input.workspace);
|
|
if (!workspaceScope)
|
|
return;
|
|
const geneId = algo.safeMemoryGeneId(input.geneId);
|
|
if (!geneId)
|
|
return;
|
|
const signals = algo.normalizeMemorySignals(input.signals);
|
|
const record = {
|
|
version: 2,
|
|
kind: 'outcome',
|
|
provenance: 'v2_local',
|
|
workspaceScope,
|
|
userScope: this.userScope,
|
|
signalFingerprint: algo.memorySignalFingerprint(signals),
|
|
signals,
|
|
geneId,
|
|
status: input.status,
|
|
score: clampScore(input.score),
|
|
at: validIso(input.at) ?? new Date(this.now()).toISOString(),
|
|
};
|
|
let persisted = false;
|
|
try {
|
|
this.withGraphLock(() => { persisted = this.appendRecordUnlocked(record); });
|
|
}
|
|
catch (error) {
|
|
// The append is already durable, so surfacing only the cleanup failure would invite duplicate retries.
|
|
if (persisted && error instanceof util.LockReleaseError)
|
|
return;
|
|
throw error;
|
|
}
|
|
}
|
|
importV1Outcome(workspace, raw, source = 'legacy-memory-graph') {
|
|
const workspaceScope = this.workspaceScope(workspace);
|
|
if (!workspaceScope)
|
|
return false;
|
|
const sourceScope = scopeHash(`source:${source}`);
|
|
const parsed = parseV1Outcome(raw, workspaceScope, this.userScope, sourceScope);
|
|
if (!parsed)
|
|
return false;
|
|
return this.withGraphLock(() => {
|
|
const dedupeScope = `${workspaceScope}\u0000${this.userScope}\u0000${sourceScope}`;
|
|
const cache = this.importFingerprintCache(dedupeScope, workspaceScope, sourceScope);
|
|
if (hasImportFingerprint(cache.values, parsed))
|
|
return false;
|
|
if (!this.appendRecordUnlocked(parsed))
|
|
return false;
|
|
addImportFingerprints(cache.values, parsed);
|
|
cache.stateFingerprint = this.importStateFingerprint();
|
|
return true;
|
|
});
|
|
}
|
|
/** Builds a stable import forecast without creating or locking the target graph. */
|
|
planV1Outcomes(workspace, raws, source = 'legacy-memory-graph') {
|
|
const total = raws.length;
|
|
const sourceScope = scopeHash(`source:${source}`);
|
|
const workspacePath = workspace ? resolve(workspace) : null;
|
|
const workspaceScope = workspacePath ? this.workspaceScope(workspacePath) : null;
|
|
if (!workspacePath || !workspaceScope) {
|
|
return sealV1OutcomePlan({ total, importable: 0, duplicates: 0, rejected: 0, deferred: total }, {
|
|
graphDir: this.dir,
|
|
userScope: this.userScope,
|
|
workspacePath: null,
|
|
workspaceScope: null,
|
|
sourceScope,
|
|
records: Object.freeze([]),
|
|
});
|
|
}
|
|
const target = this.readStableImportState();
|
|
const fingerprints = collectImportFingerprints(target.records, workspaceScope, this.userScope, sourceScope);
|
|
const records = [];
|
|
let importable = 0;
|
|
let duplicates = 0;
|
|
let rejected = 0;
|
|
for (const raw of raws) {
|
|
const parsed = parseV1Outcome(raw, workspaceScope, this.userScope, sourceScope);
|
|
if (!parsed) {
|
|
rejected += 1;
|
|
continue;
|
|
}
|
|
const normalized = { ...parsed, signals: [...parsed.signals] };
|
|
Object.freeze(normalized.signals);
|
|
Object.freeze(normalized);
|
|
if (hasImportFingerprint(fingerprints, parsed)) {
|
|
duplicates += 1;
|
|
records.push(normalized);
|
|
continue;
|
|
}
|
|
if (!this.importRecordFits(parsed, target.epochId)) {
|
|
rejected += 1;
|
|
continue;
|
|
}
|
|
records.push(normalized);
|
|
addImportFingerprints(fingerprints, normalized);
|
|
importable += 1;
|
|
}
|
|
Object.freeze(records);
|
|
return sealV1OutcomePlan({
|
|
total,
|
|
importable,
|
|
duplicates,
|
|
rejected,
|
|
deferred: 0,
|
|
}, {
|
|
graphDir: this.dir,
|
|
userScope: this.userScope,
|
|
workspacePath,
|
|
workspaceScope,
|
|
sourceScope,
|
|
records,
|
|
});
|
|
}
|
|
/** Applies only the normalized records sealed by planV1Outcomes. */
|
|
applyV1OutcomePlan(plan) {
|
|
const data = memoryGraphV1OutcomePlans.get(plan);
|
|
if (!data || data.graphDir !== this.dir || data.userScope !== this.userScope) {
|
|
throw new Error('memory_graph_import_plan_rejected');
|
|
}
|
|
if (data.records.length === 0)
|
|
return 0;
|
|
const workspacePath = data.workspacePath;
|
|
const workspaceScope = data.workspaceScope;
|
|
if (!workspacePath || !workspaceScope
|
|
|| this.workspaceScope(workspacePath) !== workspaceScope) {
|
|
throw new Error('memory_graph_import_plan_rejected');
|
|
}
|
|
let committedImported;
|
|
try {
|
|
return this.withGraphLock(() => {
|
|
const dedupeScope = `${workspaceScope}\u0000${this.userScope}\u0000${data.sourceScope}`;
|
|
const cache = this.importFingerprintCache(dedupeScope, workspaceScope, data.sourceScope);
|
|
let imported = 0;
|
|
for (const record of data.records) {
|
|
if (hasImportFingerprint(cache.values, record))
|
|
continue;
|
|
if (!this.appendRecordUnlocked(record))
|
|
continue;
|
|
addImportFingerprints(cache.values, record);
|
|
imported += 1;
|
|
}
|
|
cache.stateFingerprint = this.importStateFingerprint();
|
|
committedImported = imported;
|
|
return imported;
|
|
});
|
|
}
|
|
catch (error) {
|
|
// The completed appends are durable, so a cleanup-only failure must not invite replay.
|
|
if (committedImported !== undefined && committedImported > 0
|
|
&& error instanceof util.LockReleaseError)
|
|
return committedImported;
|
|
throw error;
|
|
}
|
|
}
|
|
maintain() {
|
|
return this.withGraphLock(() => this.withRotationCleanupStatus(this.maintainUnlocked()));
|
|
}
|
|
maintainUnlocked() {
|
|
this.ensureSecureDir();
|
|
const state = this.managedGraphStateFiles();
|
|
const epoch = this.readEpochState();
|
|
const health = this.inspectHealthUnlocked();
|
|
const recoveryDegraded = this.recoveryMarkerState(epoch) === 'degraded';
|
|
if ((health.archives > 0 || recoveryDegraded) && this.compactRequiresRecovery(epoch)) {
|
|
const recovered = this.recoverFromArchivesUnlocked(epoch);
|
|
if (recovered.recovery === 'recovered' || recovered.recovery === 'degraded'
|
|
|| (recovered.recovery === 'empty' && health.activeRecords === 0))
|
|
return recovered;
|
|
}
|
|
const activeState = state.find((file) => file.name === ACTIVE_FILE);
|
|
const activeBaseline = epoch.baseline?.active;
|
|
const activeBaselineTruncated = Boolean(activeState && activeBaseline
|
|
&& serializedIdentityMatches(activeBaseline.snapshot, activeState.identity)
|
|
&& activeState.identity.size < BigInt(activeBaseline.offset));
|
|
const active = activeBaselineTruncated
|
|
? null
|
|
: this.activePayloadForEpoch(epoch, this.maxActiveBytes);
|
|
if (active === null || !active.exists || active.logicalSize < this.maxActiveBytes) {
|
|
return {
|
|
rotated: false,
|
|
compactedRecords: health.compactedRecords,
|
|
corruptLines: health.corruptLines,
|
|
oversizedLines: health.oversizedLines,
|
|
archives: health.archives,
|
|
recovery: health.recovery,
|
|
};
|
|
}
|
|
return this.rotateAndCompactUnlocked(epoch);
|
|
}
|
|
recoverFromArchives() {
|
|
return this.withGraphLock(() => this.withRotationCleanupStatus(this.recoverFromArchivesUnlocked()));
|
|
}
|
|
/** Enforce archive retention without changing active or compact state. */
|
|
prune() {
|
|
return this.withGraphLock(() => {
|
|
this.ensureSecureDir();
|
|
this.managedGraphStateFiles();
|
|
this.readEpochState();
|
|
this.pruneArchives();
|
|
const health = this.inspectHealthUnlocked();
|
|
return this.withRotationCleanupStatus({
|
|
rotated: false,
|
|
compactedRecords: health.compactedRecords,
|
|
corruptLines: health.corruptLines,
|
|
oversizedLines: health.oversizedLines,
|
|
archives: health.archives,
|
|
recovery: health.recovery,
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* Copy every managed graph file into a non-active backup, then atomically
|
|
* advance the local storage epoch.
|
|
* The CLI requires explicit opt-in (`--yes`) before calling this method.
|
|
*/
|
|
resetGraph() {
|
|
let committed;
|
|
try {
|
|
return this.withGraphLock(() => {
|
|
this.ensureSecureDir();
|
|
this.managedGraphStateFiles();
|
|
this.readEpochState();
|
|
this.ensureEpochStateFile();
|
|
const state = this.managedGraphStateFiles();
|
|
const backup = this.createResetBackupDirectory();
|
|
const copied = [];
|
|
try {
|
|
for (const file of state) {
|
|
const payload = this.readResetSource(file);
|
|
const destination = join(backup.pendingDir, file.name);
|
|
this.writeAtomic(destination, payload);
|
|
const copiedIdentity = lstatSync(destination, { bigint: true });
|
|
const copiedPayload = this.readRawBounded(destination, payload.length);
|
|
if (!copiedIdentity.isFile() || copiedIdentity.isSymbolicLink()
|
|
|| copiedPayload === null || !copiedPayload.equals(payload)) {
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
}
|
|
copied.push({ name: file.name, path: destination, identity: copiedIdentity });
|
|
}
|
|
this.assertManagedStateUnchanged(state);
|
|
this.publishResetBackup(backup);
|
|
this.assertManagedStateUnchanged(state);
|
|
}
|
|
catch {
|
|
this.cleanupResetPendingDirectory(backup, copied);
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
}
|
|
const epochState = {
|
|
version: 1,
|
|
epochId: backup.backupId,
|
|
activatedAt: new Date(this.now()).toISOString(),
|
|
baseline: this.epochBaselineFromState(state),
|
|
};
|
|
const report = {
|
|
rotated: false,
|
|
compactedRecords: 0,
|
|
corruptLines: 0,
|
|
oversizedLines: 0,
|
|
archives: state.filter((file) => ARCHIVE_PATTERN.test(file.name)).length,
|
|
recovery: 'empty',
|
|
backupId: backup.backupId,
|
|
backupFiles: copied.length,
|
|
epochId: backup.backupId,
|
|
};
|
|
if (this.rotationCleanupFailed)
|
|
report.recovery = 'degraded';
|
|
try {
|
|
this.writeEpochState(epochState);
|
|
}
|
|
catch {
|
|
// The completed backup is intentionally retained. Until this atomic
|
|
// publish succeeds, readers continue using the previous epoch.
|
|
throw new Error('memory_graph_reset_failed');
|
|
}
|
|
committed = report;
|
|
this.importedFingerprints.clear();
|
|
return committed;
|
|
});
|
|
}
|
|
catch (error) {
|
|
if (committed) {
|
|
const reason = error instanceof util.LockReleaseError
|
|
? error.reason
|
|
: 'release_failed';
|
|
return { ...committed, lockReleaseWarning: reason };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
managedGraphStateFiles() {
|
|
const names = [
|
|
...this.archiveFiles(),
|
|
RECOVERY_FILE,
|
|
EPOCH_FILE,
|
|
ACTIVE_FILE,
|
|
COMPACT_FILE,
|
|
];
|
|
const state = [];
|
|
for (const name of names) {
|
|
const path = join(this.dir, name);
|
|
let identity;
|
|
try {
|
|
identity = lstatSync(path, { bigint: true });
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT')
|
|
continue;
|
|
throw new Error('memory_graph_path_rejected');
|
|
}
|
|
if (!identity.isFile() || identity.isSymbolicLink())
|
|
throw new Error('memory_graph_path_rejected');
|
|
state.push({ name, path, identity });
|
|
}
|
|
return state;
|
|
}
|
|
createResetBackupDirectory() {
|
|
const latest = readdirSync(this.dir)
|
|
.map((name) => BigInt(RESET_BACKUP_PATTERN.exec(name)?.[1]
|
|
?? RESET_PENDING_PATTERN.exec(name)?.[1]
|
|
?? 0))
|
|
.reduce((max, value) => value > max ? value : max, 0n);
|
|
const now = BigInt(Math.max(0, Math.floor(this.now())));
|
|
const timestamp = now > latest ? now : latest + 1n;
|
|
const backupId = timestamp.toString().padStart(13, '0');
|
|
const backupDir = join(this.dir, `memory_graph.reset.${backupId}`);
|
|
const pendingDir = join(this.dir, `memory_graph.reset.pending.${backupId}`);
|
|
let created = false;
|
|
try {
|
|
assertPathMissing(backupDir, 'memory_graph_reset_backup_failed');
|
|
assertPathMissing(pendingDir, 'memory_graph_reset_backup_failed');
|
|
mkdirSync(pendingDir, { mode: 0o700 });
|
|
created = true;
|
|
const pendingIdentity = lstatSync(pendingDir, { bigint: true });
|
|
if (!pendingIdentity.isDirectory() || pendingIdentity.isSymbolicLink()) {
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
}
|
|
return { backupId, backupDir, pendingDir, pendingIdentity };
|
|
}
|
|
catch {
|
|
if (created) {
|
|
try {
|
|
rmdirSync(pendingDir);
|
|
}
|
|
catch {
|
|
// Leave an unexpected directory untouched for operator inspection.
|
|
}
|
|
}
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
}
|
|
}
|
|
publishResetBackup(backup) {
|
|
const identity = lstatSync(backup.pendingDir, { bigint: true });
|
|
if (!identity.isDirectory() || identity.isSymbolicLink()
|
|
|| !sameGraphFileIdentity(backup.pendingIdentity, identity)) {
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
}
|
|
assertPathMissing(backup.backupDir, 'memory_graph_reset_backup_failed');
|
|
renameSync(backup.pendingDir, backup.backupDir);
|
|
const published = lstatSync(backup.backupDir, { bigint: true });
|
|
if (!published.isDirectory() || published.isSymbolicLink()
|
|
|| !sameGraphFileIdentity(backup.pendingIdentity, published)) {
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
}
|
|
}
|
|
cleanupResetPendingDirectory(backup, copied) {
|
|
for (const file of [...copied].reverse()) {
|
|
try {
|
|
const current = lstatSync(file.path, { bigint: true });
|
|
if (current.isFile() && !current.isSymbolicLink()
|
|
&& sameGraphFileIdentity(file.identity, current))
|
|
unlinkSync(file.path);
|
|
}
|
|
catch {
|
|
// Live graph state was never changed; leave uncertain backup artifacts untouched.
|
|
}
|
|
}
|
|
try {
|
|
const current = lstatSync(backup.pendingDir, { bigint: true });
|
|
if (current.isDirectory() && !current.isSymbolicLink()
|
|
&& sameGraphFileIdentity(backup.pendingIdentity, current)
|
|
&& readdirSync(backup.pendingDir).length === 0)
|
|
rmdirSync(backup.pendingDir);
|
|
}
|
|
catch {
|
|
// A completed rename or an uncertain replacement must remain untouched.
|
|
}
|
|
}
|
|
readResetSource(file) {
|
|
const limit = file.name === ACTIVE_FILE
|
|
? this.maxActiveBytes + this.maxLineBytes
|
|
: file.name === COMPACT_FILE
|
|
? this.maxCompactBytes + this.maxLineBytes
|
|
: file.name === RECOVERY_FILE || file.name === EPOCH_FILE
|
|
? this.maxLineBytes
|
|
: this.maxActiveBytes + this.maxLineBytes;
|
|
if (file.identity.size > BigInt(limit))
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
const fd = openNoFollow(file.path, constants.O_RDONLY, 0o600);
|
|
try {
|
|
const before = fstatSync(fd, { bigint: true });
|
|
if (!before.isFile() || before.isSymbolicLink()
|
|
|| !sameGraphFileSnapshot(file.identity, before)
|
|
|| before.size > BigInt(limit))
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
const payload = readFdBytes(fd, Number(before.size), 0);
|
|
const after = fstatSync(fd, { bigint: true });
|
|
if (payload.length !== Number(before.size)
|
|
|| !sameGraphFileSnapshot(before, after))
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
return payload;
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
assertManagedStateUnchanged(expected) {
|
|
const current = this.managedGraphStateFiles();
|
|
if (current.length !== expected.length)
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
for (let index = 0; index < expected.length; index += 1) {
|
|
const before = expected[index];
|
|
const after = current[index];
|
|
if (!before || !after || before.name !== after.name
|
|
|| !sameGraphFileSnapshot(before.identity, after.identity)) {
|
|
throw new Error('memory_graph_reset_backup_failed');
|
|
}
|
|
}
|
|
}
|
|
ensureEpochStateFile() {
|
|
const path = join(this.dir, EPOCH_FILE);
|
|
try {
|
|
lstatSync(path);
|
|
this.readEpochState();
|
|
return;
|
|
}
|
|
catch (error) {
|
|
if (!isObject(error) || error['code'] !== 'ENOENT') {
|
|
if (error instanceof Error && error.message === 'memory_graph_epoch_rejected')
|
|
throw error;
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
}
|
|
}
|
|
if (this.hasResetBackup())
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
this.writeEpochState({
|
|
version: 1,
|
|
epochId: LEGACY_EPOCH_ID,
|
|
activatedAt: new Date(this.now()).toISOString(),
|
|
});
|
|
}
|
|
currentEpochId() {
|
|
return this.readEpochState().epochId;
|
|
}
|
|
epochBaselineFromState(state) {
|
|
const active = state.find((file) => file.name === ACTIVE_FILE);
|
|
const compact = state.find((file) => file.name === COMPACT_FILE);
|
|
const recovery = state.find((file) => file.name === RECOVERY_FILE);
|
|
const archives = state
|
|
.filter((file) => ARCHIVE_PATTERN.test(file.name))
|
|
.slice(-this.archiveRetention)
|
|
.map((file) => ({ name: file.name, snapshot: serializeGraphFileSnapshot(file.identity) }));
|
|
if (active && active.identity.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
}
|
|
return {
|
|
active: active
|
|
? { snapshot: serializeGraphFileSnapshot(active.identity), offset: Number(active.identity.size) }
|
|
: null,
|
|
compact: compact ? serializeGraphFileSnapshot(compact.identity) : null,
|
|
recovery: recovery ? serializeGraphFileSnapshot(recovery.identity) : null,
|
|
archives,
|
|
};
|
|
}
|
|
readEpochState() {
|
|
const path = join(this.dir, EPOCH_FILE);
|
|
let identity;
|
|
try {
|
|
identity = lstatSync(path, { bigint: true });
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT') {
|
|
if (this.hasResetBackup())
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
return { version: 1, epochId: LEGACY_EPOCH_ID, activatedAt: new Date(0).toISOString() };
|
|
}
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
}
|
|
if (!identity.isFile() || identity.isSymbolicLink()
|
|
|| identity.size > BigInt(this.maxLineBytes))
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
let fd;
|
|
try {
|
|
fd = openNoFollow(path, constants.O_RDONLY, 0o600);
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
}
|
|
try {
|
|
const before = fstatSync(fd, { bigint: true });
|
|
if (!before.isFile() || before.isSymbolicLink()
|
|
|| !sameGraphFileSnapshot(identity, before))
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
const payload = readFdBytes(fd, Number(before.size), 0);
|
|
const after = fstatSync(fd, { bigint: true });
|
|
if (payload.length !== Number(before.size)
|
|
|| !sameGraphFileSnapshot(before, after))
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
const raw = JSON.parse(payload.toString('utf8'));
|
|
if (!isObject(raw) || raw['version'] !== 1)
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
const epochId = safeEpochId(raw['epochId']);
|
|
const activatedAt = typeof raw['activatedAt'] === 'string' ? validIso(raw['activatedAt']) : null;
|
|
if (!epochId || !activatedAt)
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
const hasBaseline = Object.prototype.hasOwnProperty.call(raw, 'baseline');
|
|
const baseline = hasBaseline ? parseEpochBaseline(raw['baseline']) : null;
|
|
if (hasBaseline && !baseline)
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
if (epochId !== LEGACY_EPOCH_ID) {
|
|
if (!/^\d{13,}$/.test(epochId))
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
let backup;
|
|
try {
|
|
backup = lstatSync(join(this.dir, `memory_graph.reset.${epochId}`));
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
}
|
|
if (!backup.isDirectory() || backup.isSymbolicLink()) {
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
}
|
|
}
|
|
return {
|
|
version: 1,
|
|
epochId,
|
|
activatedAt,
|
|
...(baseline ? { baseline } : {}),
|
|
};
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
writeEpochState(state) {
|
|
const payload = Buffer.from(JSON.stringify(state) + '\n', 'utf8');
|
|
if (payload.length > this.maxLineBytes)
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
this.writeAtomic(join(this.dir, EPOCH_FILE), payload);
|
|
}
|
|
hasResetBackup() {
|
|
return readdirSync(this.dir).some((name) => RESET_BACKUP_PATTERN.test(name));
|
|
}
|
|
compactMatchesEpochBaseline(epoch) {
|
|
const baseline = epoch.baseline?.compact;
|
|
if (!baseline)
|
|
return false;
|
|
try {
|
|
const current = lstatSync(join(this.dir, COMPACT_FILE), { bigint: true });
|
|
return current.isFile() && !current.isSymbolicLink()
|
|
&& serializedSnapshotMatches(baseline, current);
|
|
}
|
|
catch {
|
|
return false;
|
|
}
|
|
}
|
|
readCompactForEpoch(epoch, diagnostics) {
|
|
return this.compactMatchesEpochBaseline(epoch) ? [] : this.readCompact(diagnostics);
|
|
}
|
|
activeLogicalWindow(epoch, identity) {
|
|
const active = epoch.baseline?.active;
|
|
if (!active || !serializedIdentityMatches(active.snapshot, identity)) {
|
|
return {
|
|
offset: 0,
|
|
size: identity.size > BigInt(Number.MAX_SAFE_INTEGER) ? 0 : Number(identity.size),
|
|
invalid: identity.size > BigInt(Number.MAX_SAFE_INTEGER),
|
|
};
|
|
}
|
|
const offset = active.offset;
|
|
if (identity.size < BigInt(offset) || identity.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
return { offset, size: 0, invalid: true };
|
|
}
|
|
return { offset, size: Number(identity.size) - offset, invalid: false };
|
|
}
|
|
activePayloadForEpoch(epoch, maxBytes) {
|
|
const path = join(this.dir, ACTIVE_FILE);
|
|
let identity;
|
|
try {
|
|
identity = lstatSync(path, { bigint: true });
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT') {
|
|
return { payload: Buffer.alloc(0), logicalSize: 0, exists: false };
|
|
}
|
|
throw new Error('memory_graph_rotation_backup_failed');
|
|
}
|
|
if (!identity.isFile() || identity.isSymbolicLink()) {
|
|
throw new Error('memory_graph_path_rejected');
|
|
}
|
|
const window = this.activeLogicalWindow(epoch, identity);
|
|
if (window.invalid || window.size > maxBytes) {
|
|
throw new Error('memory_graph_rotation_backup_failed');
|
|
}
|
|
const fd = openNoFollow(path, constants.O_RDONLY, 0o600);
|
|
try {
|
|
const before = fstatSync(fd, { bigint: true });
|
|
if (!before.isFile() || before.isSymbolicLink()
|
|
|| !sameGraphFileSnapshot(identity, before)) {
|
|
throw new Error('memory_graph_rotation_backup_failed');
|
|
}
|
|
const payload = readFdBytes(fd, window.size, window.offset);
|
|
const after = fstatSync(fd, { bigint: true });
|
|
if (payload.length !== window.size || !sameGraphFileSnapshot(before, after)) {
|
|
throw new Error('memory_graph_rotation_backup_failed');
|
|
}
|
|
return { payload, logicalSize: window.size, exists: true };
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
readActiveForEpoch(epoch, diagnostics, maxBytes) {
|
|
const path = join(this.dir, ACTIVE_FILE);
|
|
if (!existsSync(path) || !this.secureRegularFile(path))
|
|
return [];
|
|
const fd = openNoFollow(path, constants.O_RDONLY, 0o600);
|
|
try {
|
|
const identity = fstatSync(fd, { bigint: true });
|
|
if (!identity.isFile() || identity.isSymbolicLink())
|
|
return [];
|
|
const window = this.activeLogicalWindow(epoch, identity);
|
|
if (window.invalid) {
|
|
diagnostics.truncated = true;
|
|
return [];
|
|
}
|
|
const bytes = Math.min(window.size, maxBytes);
|
|
const position = window.offset + window.size - bytes;
|
|
const buffer = readFdBytes(fd, bytes, position);
|
|
diagnostics.bytesRead += buffer.length;
|
|
diagnostics.truncated ||= window.size > buffer.length;
|
|
if (window.size <= buffer.length)
|
|
return this.parseBuffer(buffer, diagnostics);
|
|
const newline = buffer.indexOf(0x0a);
|
|
return this.parseBuffer(newline >= 0 ? buffer.subarray(newline + 1) : Buffer.alloc(0), diagnostics);
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
currentArchiveFiles(epoch) {
|
|
const baseline = new Map((epoch.baseline?.archives ?? [])
|
|
.map((entry) => [entry.name, entry.snapshot]));
|
|
return this.archiveFiles().slice(-this.archiveRetention).filter((file) => {
|
|
const expected = baseline.get(file);
|
|
if (!expected)
|
|
return true;
|
|
try {
|
|
const current = lstatSync(join(this.dir, file), { bigint: true });
|
|
return !current.isFile() || current.isSymbolicLink()
|
|
|| !serializedSnapshotMatches(expected, current);
|
|
}
|
|
catch {
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
recoverFromArchivesUnlocked(epochState) {
|
|
this.ensureSecureDir();
|
|
this.managedGraphStateFiles();
|
|
const epoch = epochState ?? this.readEpochState();
|
|
const currentEpoch = epoch.epochId;
|
|
const diagnostics = emptyDiagnostics();
|
|
const compactRecords = this.readCompactForEpoch(epoch, diagnostics)
|
|
.filter((record) => recordEpochId(record) === currentEpoch);
|
|
const archiveRecords = [];
|
|
const maxArchiveBytes = gzipRotationByteBound(this.maxActiveBytes);
|
|
const maxArchiveOutputBytes = safeRotationByteSum(this.maxActiveBytes, this.maxLineBytes);
|
|
for (const file of this.currentArchiveFiles(epoch)) {
|
|
const path = join(this.dir, file);
|
|
if (maxArchiveBytes === null || maxArchiveOutputBytes === null) {
|
|
diagnostics.truncated = true;
|
|
continue;
|
|
}
|
|
if (!this.secureRegularFile(path)) {
|
|
diagnostics.corruptLines += 1;
|
|
continue;
|
|
}
|
|
try {
|
|
const compressed = this.readRawBounded(path, maxArchiveBytes);
|
|
if (compressed === null) {
|
|
diagnostics.truncated = true;
|
|
continue;
|
|
}
|
|
const raw = gunzipSync(compressed, { maxOutputLength: maxArchiveOutputBytes });
|
|
archiveRecords.push(...this.parseBuffer(raw, diagnostics)
|
|
.filter((record) => recordEpochId(record) === currentEpoch));
|
|
}
|
|
catch {
|
|
diagnostics.corruptLines += 1;
|
|
}
|
|
}
|
|
const compacted = mergeRecoveryRecords(compactRecords, archiveRecords, this.maxCompactEdges);
|
|
const incomplete = this.recoveryMarkerState(epoch) === 'degraded'
|
|
|| diagnostics.corruptLines > 0
|
|
|| diagnostics.oversizedLines > 0
|
|
|| diagnostics.truncated;
|
|
// Persist the conservative state before publishing a partial compact so a
|
|
// marker-write failure cannot leave an apparently healthy partial recovery.
|
|
if (incomplete)
|
|
this.writeRecoveryMarker(currentEpoch, 'degraded');
|
|
const compactedRecords = compacted.length > 0 ? this.writeCompact(compacted).records : 0;
|
|
const recovery = incomplete
|
|
|| (compacted.length > 0 && compactedRecords === 0)
|
|
? 'degraded'
|
|
: compactedRecords > 0 ? 'recovered' : 'empty';
|
|
if (!incomplete)
|
|
this.writeRecoveryMarker(currentEpoch, recovery);
|
|
return {
|
|
rotated: false,
|
|
compactedRecords,
|
|
corruptLines: diagnostics.corruptLines,
|
|
oversizedLines: diagnostics.oversizedLines,
|
|
archives: this.archiveFiles().length,
|
|
recovery,
|
|
};
|
|
}
|
|
inspectHealth() {
|
|
try {
|
|
const stat = lstatSync(this.dir);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
throw new Error('memory_graph_dir_rejected');
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT')
|
|
return emptyHealth();
|
|
throw new Error('memory_graph_dir_rejected');
|
|
}
|
|
try {
|
|
return this.withGraphLock(() => {
|
|
const health = this.inspectHealthUnlocked();
|
|
return this.rotationCleanupFailed ? { ...health, recovery: 'degraded' } : health;
|
|
});
|
|
}
|
|
catch (error) {
|
|
if (error instanceof MemoryGraphBusyError) {
|
|
return { ...emptyHealth(), recovery: 'degraded', busy: true };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
inspectHealthUnlocked() {
|
|
let dirStat;
|
|
try {
|
|
dirStat = lstatSync(this.dir);
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT')
|
|
return emptyHealth();
|
|
throw new Error('memory_graph_dir_rejected');
|
|
}
|
|
if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
|
|
throw new Error('memory_graph_dir_rejected');
|
|
this.managedGraphStateFiles();
|
|
const epoch = this.readEpochState();
|
|
const epochId = epoch.epochId;
|
|
const compactDiagnostics = emptyDiagnostics();
|
|
const activeDiagnostics = emptyDiagnostics();
|
|
const compactPath = join(this.dir, COMPACT_FILE);
|
|
const activePath = join(this.dir, ACTIVE_FILE);
|
|
const compactedRecords = this.readCompactForEpoch(epoch, compactDiagnostics)
|
|
.filter((record) => recordEpochId(record) === epochId).length;
|
|
const activeRecords = this.readActiveForEpoch(epoch, activeDiagnostics, this.maxActiveBytes)
|
|
.filter((record) => recordEpochId(record) === epochId).length;
|
|
const archives = this.archiveFiles().length;
|
|
let activeOversized = false;
|
|
if (existsSync(activePath) && this.secureRegularFile(activePath)) {
|
|
const activeIdentity = lstatSync(activePath, { bigint: true });
|
|
const window = this.activeLogicalWindow(epoch, activeIdentity);
|
|
activeOversized = window.invalid || window.size > this.maxActiveBytes;
|
|
}
|
|
const oversizedFiles = Number(!this.compactMatchesEpochBaseline(epoch)
|
|
&& fileExceeds(compactPath, this.maxCompactBytes))
|
|
+ Number(activeOversized);
|
|
const rejectedFiles = Number(existsSync(compactPath) && !this.secureRegularFile(compactPath))
|
|
+ Number(existsSync(activePath) && !this.secureRegularFile(activePath));
|
|
const corruptLines = compactDiagnostics.corruptLines + activeDiagnostics.corruptLines + rejectedFiles;
|
|
const oversizedLines = compactDiagnostics.oversizedLines + activeDiagnostics.oversizedLines;
|
|
const archiveProbe = compactedRecords === 0 && archives > 0
|
|
? this.inspectArchivesForEpoch(epoch)
|
|
: { containsCurrent: false, unreadable: false };
|
|
const compactMissingWithArchives = archiveProbe.containsCurrent || archiveProbe.unreadable;
|
|
const marker = this.recoveryMarkerState(epoch);
|
|
const degraded = corruptLines > 0 || oversizedLines > 0 || oversizedFiles > 0
|
|
|| compactMissingWithArchives || marker === 'degraded';
|
|
const records = compactedRecords + activeRecords;
|
|
return {
|
|
recovery: degraded ? 'degraded' : records === 0 ? 'empty' : marker === 'recovered' ? 'recovered' : 'healthy',
|
|
compactedRecords,
|
|
activeRecords,
|
|
corruptLines,
|
|
oversizedLines,
|
|
oversizedFiles,
|
|
archives,
|
|
};
|
|
}
|
|
withGraphLock(operation) {
|
|
this.ensureSecureDir();
|
|
const lockPath = join(this.dir, LOCK_FILE);
|
|
try {
|
|
util.acquireLock(lockPath);
|
|
}
|
|
catch (error) {
|
|
if (error instanceof util.LockTimeoutError)
|
|
throw new MemoryGraphBusyError();
|
|
throw error;
|
|
}
|
|
this.rotationCleanupFailed = false;
|
|
let value;
|
|
let operationError;
|
|
let operationFailed = false;
|
|
try {
|
|
this.ensureSecureDir();
|
|
this.reconcileRotationUnlocked();
|
|
value = operation();
|
|
}
|
|
catch (error) {
|
|
operationFailed = true;
|
|
operationError = error;
|
|
}
|
|
let releaseError;
|
|
try {
|
|
const released = util.releaseLock(lockPath);
|
|
const reliable = released.released
|
|
&& (released.reason === 'released' || released.reason === 'released_with_cleanup_error');
|
|
if (!reliable)
|
|
releaseError = new util.LockReleaseError(released.reason);
|
|
}
|
|
catch (error) {
|
|
releaseError = error;
|
|
}
|
|
if (operationFailed)
|
|
throw operationError;
|
|
if (releaseError !== undefined)
|
|
throw releaseError;
|
|
return value;
|
|
}
|
|
importFingerprintCache(dedupeScope, workspaceScope, sourceScope) {
|
|
const stateFingerprint = this.importStateFingerprint();
|
|
let cache = this.importedFingerprints.get(dedupeScope);
|
|
if (!cache || cache.stateFingerprint !== stateFingerprint) {
|
|
cache = {
|
|
stateFingerprint,
|
|
values: collectImportFingerprints(this.readImportRecords(), workspaceScope, this.userScope, sourceScope),
|
|
};
|
|
this.importedFingerprints.set(dedupeScope, cache);
|
|
}
|
|
return cache;
|
|
}
|
|
readStableImportState() {
|
|
let directoryIdentity;
|
|
try {
|
|
directoryIdentity = lstatSync(this.dir, { bigint: true });
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT') {
|
|
return { epochId: LEGACY_EPOCH_ID, records: [] };
|
|
}
|
|
throw new MemoryGraphImportStateRejectedError();
|
|
}
|
|
if (!directoryIdentity.isDirectory() || directoryIdentity.isSymbolicLink()) {
|
|
throw new MemoryGraphImportStateRejectedError();
|
|
}
|
|
const before = this.importStateFingerprint();
|
|
const epochId = this.currentEpochId();
|
|
const records = this.readImportRecordsFromExistingDir();
|
|
let afterDirectoryIdentity;
|
|
try {
|
|
afterDirectoryIdentity = lstatSync(this.dir, { bigint: true });
|
|
}
|
|
catch {
|
|
throw new MemoryGraphImportStateRejectedError();
|
|
}
|
|
const after = this.importStateFingerprint();
|
|
if (!sameGraphFileSnapshot(directoryIdentity, afterDirectoryIdentity) || before !== after) {
|
|
throw new MemoryGraphImportStateRejectedError();
|
|
}
|
|
return { epochId, records };
|
|
}
|
|
persistedRecordLine(record, epochId) {
|
|
return Buffer.from(JSON.stringify({ ...record, epochId }) + '\n', 'utf8');
|
|
}
|
|
importRecordFits(record, epochId) {
|
|
const line = this.persistedRecordLine(record, epochId);
|
|
return line.length <= this.maxLineBytes && line.length <= this.maxActiveBytes;
|
|
}
|
|
importStateFingerprint() {
|
|
const epochId = this.currentEpochId();
|
|
return [EPOCH_FILE, COMPACT_FILE, ACTIVE_FILE].map((file) => {
|
|
const path = join(this.dir, file);
|
|
let stat;
|
|
try {
|
|
stat = lstatSync(path, { bigint: true });
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT')
|
|
return `${file}:missing`;
|
|
throw new MemoryGraphImportStateRejectedError();
|
|
}
|
|
if (!stat.isFile() || stat.isSymbolicLink())
|
|
throw new MemoryGraphImportStateRejectedError();
|
|
return `${file}:${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
}).concat(`epoch:${epochId}`).join('|');
|
|
}
|
|
appendRecordUnlocked(record) {
|
|
this.ensureSecureDir();
|
|
const state = this.managedGraphStateFiles();
|
|
const epoch = this.readEpochState();
|
|
const line = this.persistedRecordLine(record, epoch.epochId);
|
|
if (line.length > this.maxLineBytes || line.length > this.maxActiveBytes)
|
|
return false;
|
|
const activePath = join(this.dir, ACTIVE_FILE);
|
|
let activeState = state.find((file) => file.name === ACTIVE_FILE);
|
|
const activeWindow = activeState
|
|
? this.activeLogicalWindow(epoch, activeState.identity)
|
|
: { offset: 0, size: 0, invalid: false };
|
|
if (activeWindow.invalid)
|
|
throw new Error('memory_graph_epoch_rejected');
|
|
if (activeWindow.size + line.length > this.maxActiveBytes) {
|
|
const maintenance = this.rotateAndCompactUnlocked(epoch);
|
|
if (!maintenance.rotated)
|
|
return false;
|
|
activeState = this.managedGraphStateFiles().find((file) => file.name === ACTIVE_FILE);
|
|
}
|
|
const fd = openNoFollow(activePath, constants.O_WRONLY | constants.O_CREAT, 0o600);
|
|
try {
|
|
const stat = fstatSync(fd, { bigint: true });
|
|
if (!stat.isFile() || stat.isSymbolicLink()
|
|
|| (activeState && !sameGraphFileSnapshot(activeState.identity, stat))
|
|
|| (!activeState && stat.size !== 0n)
|
|
|| stat.size > BigInt(Number.MAX_SAFE_INTEGER))
|
|
return false;
|
|
const originalSize = Number(stat.size);
|
|
try {
|
|
writeAll(fd, line, originalSize);
|
|
fsyncSync(fd);
|
|
return true;
|
|
}
|
|
catch (error) {
|
|
try {
|
|
ftruncateSync(fd, originalSize);
|
|
fsyncSync(fd);
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_append_rollback_failed');
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
rotateAndCompactUnlocked(epochState) {
|
|
this.ensureSecureDir();
|
|
const state = this.managedGraphStateFiles();
|
|
const epoch = epochState ?? this.readEpochState();
|
|
const currentEpoch = epoch.epochId;
|
|
const diagnostics = emptyDiagnostics();
|
|
const compactPath = join(this.dir, COMPACT_FILE);
|
|
const activeState = state.find((file) => file.name === ACTIVE_FILE);
|
|
const compactState = state.find((file) => file.name === COMPACT_FILE);
|
|
const activePayload = this.activePayloadForEpoch(epoch, this.maxActiveBytes);
|
|
if (!activeState || activePayload.logicalSize === 0) {
|
|
throw new Error('memory_graph_rotation_backup_failed');
|
|
}
|
|
const activeWindow = this.activeLogicalWindow(epoch, activeState.identity);
|
|
if (activeWindow.invalid || activeWindow.size !== activePayload.logicalSize) {
|
|
throw new Error('memory_graph_rotation_backup_failed');
|
|
}
|
|
const activeSource = {
|
|
snapshot: serializeGraphFileSnapshot(activeState.identity),
|
|
offset: activeWindow.offset,
|
|
...rotationFileDigest(activePayload.payload),
|
|
};
|
|
let compactSource = null;
|
|
if (compactState) {
|
|
const compactBound = this.compactMatchesEpochBaseline(epoch)
|
|
? this.maxCompactBytes + this.maxLineBytes
|
|
: this.maxCompactBytes;
|
|
const verified = this.readVerifiedRotationFile(compactPath, compactBound, false, 'memory_graph_rotation_backup_failed');
|
|
if (!verified || !sameGraphFileSnapshot(compactState.identity, verified.identity)) {
|
|
throw new Error('memory_graph_rotation_backup_failed');
|
|
}
|
|
compactSource = {
|
|
snapshot: serializeGraphFileSnapshot(verified.identity),
|
|
...rotationFileDigest(verified.payload),
|
|
};
|
|
}
|
|
const records = [
|
|
...this.readCompactForEpoch(epoch, diagnostics),
|
|
...this.parseBuffer(activePayload.payload, diagnostics),
|
|
]
|
|
.filter((record) => recordEpochId(record) === currentEpoch);
|
|
const compacted = compactRecords(records, this.maxCompactEdges);
|
|
const fitted = fitCompactRecords(compacted, this.maxCompactBytes);
|
|
if (compacted.length > 0 && fitted.records.length === 0) {
|
|
return {
|
|
rotated: false,
|
|
compactedRecords: 0,
|
|
corruptLines: diagnostics.corruptLines,
|
|
oversizedLines: diagnostics.oversizedLines,
|
|
archives: this.archiveFiles().length,
|
|
recovery: 'degraded',
|
|
};
|
|
}
|
|
const compactPayload = fitted.payload;
|
|
const archivePayload = gzipSync(activePayload.payload);
|
|
const emptyActive = Buffer.alloc(0);
|
|
const archivePath = this.nextArchivePath();
|
|
const generation = ARCHIVE_PATTERN.exec(basename(archivePath))?.[1];
|
|
if (!generation)
|
|
throw new Error('memory_graph_rotation_backup_failed');
|
|
const stageNames = rotationStageNames(generation);
|
|
const preparedWithoutId = {
|
|
version: 1,
|
|
targetGeneration: generation,
|
|
phase: 'prepared',
|
|
epochId: currentEpoch,
|
|
createdAt: new Date(this.now()).toISOString(),
|
|
limits: {
|
|
maxActiveBytes: this.maxActiveBytes,
|
|
maxCompactBytes: this.maxCompactBytes,
|
|
maxLineBytes: this.maxLineBytes,
|
|
archiveRetention: this.archiveRetention,
|
|
},
|
|
source: { active: activeSource, compact: compactSource },
|
|
target: {
|
|
compact: {
|
|
stageName: stageNames.compact,
|
|
finalName: COMPACT_FILE,
|
|
records: fitted.records.length,
|
|
...rotationFileDigest(compactPayload),
|
|
},
|
|
archive: {
|
|
stageName: stageNames.archive,
|
|
finalName: basename(archivePath),
|
|
...rotationFileDigest(archivePayload),
|
|
},
|
|
active: {
|
|
stageName: stageNames.active,
|
|
finalName: ACTIVE_FILE,
|
|
...rotationFileDigest(emptyActive),
|
|
},
|
|
},
|
|
};
|
|
const preparedWithId = {
|
|
...preparedWithoutId,
|
|
transactionId: rotationTransactionId(preparedWithoutId),
|
|
};
|
|
const prepared = {
|
|
...preparedWithId,
|
|
journalDigest: rotationJournalDigest(preparedWithId),
|
|
};
|
|
for (const name of Object.values(stageNames)) {
|
|
assertPathMissing(join(this.dir, name), 'memory_graph_rotation_recovery_failed');
|
|
}
|
|
assertPathMissing(join(this.dir, ROTATION_JOURNAL_FILE), 'memory_graph_rotation_recovery_failed');
|
|
const preparedJournalIdentity = this.writeRotationJournal(prepared);
|
|
this.notifyRotationPhase(prepared, 'commit', 'journal_prepared');
|
|
this.writeRotationStage(prepared, 'compact', compactPayload);
|
|
this.notifyRotationPhase(prepared, 'commit', 'compact_staged');
|
|
this.writeRotationStage(prepared, 'archive', archivePayload);
|
|
this.notifyRotationPhase(prepared, 'commit', 'archive_staged');
|
|
this.writeRotationStage(prepared, 'active', emptyActive);
|
|
this.notifyRotationPhase(prepared, 'commit', 'active_staged');
|
|
this.assertRotationSourceMatches(prepared);
|
|
this.assertRotationStagesMatch(prepared);
|
|
this.assertRotationJournalUnchanged(prepared, preparedJournalIdentity);
|
|
const committedWithoutDigest = { ...prepared, phase: 'committed' };
|
|
const committed = {
|
|
...committedWithoutDigest,
|
|
journalDigest: rotationJournalDigest(committedWithoutDigest),
|
|
};
|
|
this.writeRotationJournal(committed);
|
|
this.notifyRotationPhase(committed, 'commit', 'journal_committed');
|
|
this.reconcileCommittedRotation(committed, 'commit');
|
|
const degraded = diagnostics.corruptLines > 0
|
|
|| diagnostics.oversizedLines > 0
|
|
|| diagnostics.truncated;
|
|
return {
|
|
rotated: true,
|
|
compactedRecords: fitted.records.length,
|
|
corruptLines: diagnostics.corruptLines,
|
|
oversizedLines: diagnostics.oversizedLines,
|
|
archives: this.archiveFiles().length,
|
|
recovery: degraded ? 'degraded' : fitted.records.length > 0 ? 'healthy' : 'empty',
|
|
};
|
|
}
|
|
reconcileRotationUnlocked() {
|
|
const journalPath = join(this.dir, ROTATION_JOURNAL_FILE);
|
|
const stageFiles = this.rotationStageFiles();
|
|
const tempFiles = this.rotationTempFiles();
|
|
let journalPresent = true;
|
|
try {
|
|
lstatSync(journalPath);
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT')
|
|
journalPresent = false;
|
|
else
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
}
|
|
if (!journalPresent) {
|
|
for (const name of tempFiles)
|
|
this.removeRotationTemp(name);
|
|
if (stageFiles.length > 0)
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
return;
|
|
}
|
|
const loaded = this.readRotationJournal();
|
|
const journal = loaded.journal;
|
|
const expectedStages = new Set(Object.values(rotationStageNames(journal.targetGeneration)));
|
|
const expectedTemps = new Set(Object.values(rotationTempNames(journal.targetGeneration)));
|
|
if (stageFiles.some((name) => !expectedStages.has(name))
|
|
|| tempFiles.some((name) => !expectedTemps.has(name))) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
const epoch = this.readEpochState();
|
|
if (epoch.epochId !== journal.epochId) {
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
}
|
|
if (journal.phase === 'prepared') {
|
|
this.assertRotationSourceMatches(journal);
|
|
for (const target of [
|
|
journal.target.compact,
|
|
journal.target.archive,
|
|
journal.target.active,
|
|
]) {
|
|
this.removeRotationStage(target);
|
|
}
|
|
for (const name of tempFiles)
|
|
this.removeRotationTemp(name);
|
|
this.notifyRotationPhase(journal, 'recovery', 'before_journal_clear');
|
|
this.removeVerifiedRotationFile(journalPath, loaded.identity, 'memory_graph_rotation_recovery_failed');
|
|
this.notifyRotationPhase(journal, 'recovery', 'journal_cleared');
|
|
return;
|
|
}
|
|
for (const name of tempFiles)
|
|
this.removeRotationTemp(name);
|
|
this.reconcileCommittedRotation(journal, 'recovery', loaded.identity);
|
|
}
|
|
reconcileCommittedRotation(journal, mode, journalIdentity) {
|
|
let identity = journalIdentity;
|
|
if (!identity) {
|
|
const loaded = this.readRotationJournal();
|
|
if (loaded.journal.transactionId !== journal.transactionId
|
|
|| loaded.journal.phase !== 'committed') {
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
}
|
|
identity = loaded.identity;
|
|
}
|
|
this.assertCommittedRotationCanApply(journal);
|
|
this.publishRotationFile(journal, journal.target.compact, journal.source.compact, mode, 'compact_published');
|
|
this.publishRotationFile(journal, journal.target.archive, null, mode, 'archive_published');
|
|
this.notifyRotationPhase(journal, mode, 'before_active_publish');
|
|
this.publishRotationActive(journal, mode);
|
|
for (const target of [
|
|
journal.target.compact,
|
|
journal.target.archive,
|
|
journal.target.active,
|
|
]) {
|
|
this.removeRotationStage(target);
|
|
}
|
|
const cleanupFailed = this.completeCommittedRotationCleanup(journal);
|
|
this.rotationCleanupFailed = this.rotationCleanupFailed || cleanupFailed;
|
|
this.notifyRotationPhase(journal, mode, 'before_journal_clear');
|
|
this.assertCommittedRotationApplied(journal);
|
|
this.removeVerifiedRotationFile(join(this.dir, ROTATION_JOURNAL_FILE), identity, 'memory_graph_rotation_recovery_failed');
|
|
this.notifyRotationPhase(journal, mode, 'journal_cleared');
|
|
}
|
|
completeCommittedRotationCleanup(journal) {
|
|
const epoch = this.readEpochState();
|
|
if (epoch.epochId !== journal.epochId) {
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
}
|
|
try {
|
|
this.managedGraphStateFiles();
|
|
this.clearRecoveryMarker(epoch, journal.limits.maxLineBytes);
|
|
this.pruneArchives(journal.limits.archiveRetention);
|
|
return false;
|
|
}
|
|
catch {
|
|
return true;
|
|
}
|
|
}
|
|
withRotationCleanupStatus(report) {
|
|
return this.rotationCleanupFailed ? { ...report, recovery: 'degraded' } : report;
|
|
}
|
|
assertCommittedRotationApplied(journal) {
|
|
for (const target of [
|
|
journal.target.compact,
|
|
journal.target.archive,
|
|
journal.target.active,
|
|
]) {
|
|
if (!this.rotationTargetMatches(join(this.dir, target.finalName), target)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
}
|
|
}
|
|
assertCommittedRotationCanApply(journal) {
|
|
this.assertRotationFileReady(journal.target.compact, journal.source.compact);
|
|
this.assertRotationFileReady(journal.target.archive, null);
|
|
const activeTarget = journal.target.active;
|
|
const activePath = join(this.dir, activeTarget.finalName);
|
|
if (this.rotationTargetMatches(activePath, activeTarget)) {
|
|
this.assertOptionalRotationStage(activeTarget);
|
|
return;
|
|
}
|
|
if (!this.rotationActiveSourceMatches(activePath, journal.source.active)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
this.requireRotationTarget(join(this.dir, activeTarget.stageName), activeTarget);
|
|
}
|
|
assertRotationFileReady(target, source) {
|
|
const finalPath = join(this.dir, target.finalName);
|
|
if (this.rotationTargetMatches(finalPath, target)) {
|
|
this.assertOptionalRotationStage(target);
|
|
return;
|
|
}
|
|
if (source === null) {
|
|
assertPathMissing(finalPath, 'memory_graph_rotation_recovery_failed');
|
|
}
|
|
else if (!this.rotationSourceMatches(finalPath, source)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
this.requireRotationTarget(join(this.dir, target.stageName), target);
|
|
}
|
|
assertOptionalRotationStage(target) {
|
|
const path = join(this.dir, target.stageName);
|
|
const staged = this.readVerifiedRotationFile(path, target.size, true, 'memory_graph_rotation_recovery_failed');
|
|
if (staged && !rotationDigestMatches(staged.payload, target)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
}
|
|
publishRotationFile(journal, target, source, mode, phase) {
|
|
const finalPath = join(this.dir, target.finalName);
|
|
if (this.rotationTargetMatches(finalPath, target)) {
|
|
this.removeRotationStage(target);
|
|
this.notifyRotationPhase(journal, mode, phase);
|
|
return;
|
|
}
|
|
if (source === null) {
|
|
assertPathMissing(finalPath, 'memory_graph_rotation_recovery_failed');
|
|
}
|
|
else if (!this.rotationSourceMatches(finalPath, source)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
const staged = this.requireRotationTarget(join(this.dir, target.stageName), target);
|
|
try {
|
|
renameSync(join(this.dir, target.stageName), finalPath);
|
|
this.syncGraphDirectory();
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
const published = this.requireRotationTarget(finalPath, target);
|
|
if (!sameGraphFileIdentity(staged.identity, published.identity)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
this.notifyRotationPhase(journal, mode, phase);
|
|
}
|
|
publishRotationActive(journal, mode) {
|
|
const target = journal.target.active;
|
|
const activePath = join(this.dir, target.finalName);
|
|
if (this.rotationTargetMatches(activePath, target)) {
|
|
this.removeRotationStage(target);
|
|
this.notifyRotationPhase(journal, mode, 'active_published');
|
|
return;
|
|
}
|
|
if (!this.rotationActiveSourceMatches(activePath, journal.source.active)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
const staged = this.requireRotationTarget(join(this.dir, target.stageName), target);
|
|
try {
|
|
renameSync(join(this.dir, target.stageName), activePath);
|
|
this.syncGraphDirectory();
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
const published = this.requireRotationTarget(activePath, target);
|
|
if (!sameGraphFileIdentity(staged.identity, published.identity)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
this.notifyRotationPhase(journal, mode, 'active_published');
|
|
}
|
|
writeRotationJournal(journal) {
|
|
const payload = Buffer.from(JSON.stringify(journal) + '\n', 'utf8');
|
|
if (payload.length > MAX_ROTATION_JOURNAL_BYTES) {
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
}
|
|
const committedIdentity = this.writeAtomic(join(this.dir, ROTATION_JOURNAL_FILE), payload, true, journal.targetGeneration);
|
|
const loaded = this.readRotationJournal();
|
|
if (!sameGraphFileIdentity(committedIdentity, loaded.identity)
|
|
|| loaded.journal.transactionId !== journal.transactionId
|
|
|| loaded.journal.phase !== journal.phase) {
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
}
|
|
return loaded.identity;
|
|
}
|
|
assertRotationJournalUnchanged(journal, expectedIdentity) {
|
|
const loaded = this.readRotationJournal();
|
|
if (!sameGraphFileSnapshot(expectedIdentity, loaded.identity)
|
|
|| loaded.journal.transactionId !== journal.transactionId
|
|
|| loaded.journal.phase !== journal.phase) {
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
}
|
|
}
|
|
readRotationJournal() {
|
|
const verified = this.readVerifiedRotationFile(join(this.dir, ROTATION_JOURNAL_FILE), MAX_ROTATION_JOURNAL_BYTES, false, 'memory_graph_rotation_journal_rejected');
|
|
if (!verified)
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
try {
|
|
const value = JSON.parse(verified.payload.toString('utf8'));
|
|
const journal = parseRotationJournal(value);
|
|
if (!journal)
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
return { journal, identity: verified.identity };
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_rotation_journal_rejected');
|
|
}
|
|
}
|
|
writeRotationStage(journal, kind, payload) {
|
|
const target = journal.target[kind];
|
|
if (!rotationDigestMatches(payload, target)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
const committedIdentity = this.writeAtomic(join(this.dir, target.stageName), payload, true, journal.targetGeneration);
|
|
const staged = this.requireRotationTarget(join(this.dir, target.stageName), target);
|
|
if (!sameGraphFileIdentity(committedIdentity, staged.identity)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
}
|
|
assertRotationStagesMatch(journal) {
|
|
for (const target of [
|
|
journal.target.compact,
|
|
journal.target.archive,
|
|
journal.target.active,
|
|
]) {
|
|
this.requireRotationTarget(join(this.dir, target.stageName), target);
|
|
}
|
|
}
|
|
assertRotationSourceMatches(journal) {
|
|
if (!this.rotationActiveSourceMatches(join(this.dir, ACTIVE_FILE), journal.source.active)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
const compactPath = join(this.dir, COMPACT_FILE);
|
|
if (journal.source.compact === null) {
|
|
assertPathMissing(compactPath, 'memory_graph_rotation_recovery_failed');
|
|
}
|
|
else if (!this.rotationSourceMatches(compactPath, journal.source.compact)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
}
|
|
rotationActiveSourceMatches(path, source) {
|
|
const physicalSize = Number(source.snapshot.size);
|
|
const verified = this.readVerifiedRotationFile(path, physicalSize, true, 'memory_graph_rotation_recovery_failed', { offset: source.offset, size: source.size });
|
|
if (!verified || !serializedSnapshotMatches(source.snapshot, verified.identity))
|
|
return false;
|
|
if (BigInt(source.offset) + BigInt(source.size) !== verified.identity.size)
|
|
return false;
|
|
return rotationDigestMatches(verified.payload, source);
|
|
}
|
|
rotationSourceMatches(path, source) {
|
|
const verified = this.readVerifiedRotationFile(path, source.size, true, 'memory_graph_rotation_recovery_failed');
|
|
return verified !== null
|
|
&& serializedSnapshotMatches(source.snapshot, verified.identity)
|
|
&& rotationDigestMatches(verified.payload, source);
|
|
}
|
|
rotationTargetMatches(path, target) {
|
|
let identity;
|
|
try {
|
|
identity = lstatSync(path, { bigint: true });
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT')
|
|
return false;
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
if (!identity.isFile() || identity.isSymbolicLink()) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
if (identity.size !== BigInt(target.size))
|
|
return false;
|
|
const verified = this.readVerifiedRotationFile(path, target.size, true, 'memory_graph_rotation_recovery_failed');
|
|
return verified !== null && rotationDigestMatches(verified.payload, target);
|
|
}
|
|
requireRotationTarget(path, target) {
|
|
const verified = this.readVerifiedRotationFile(path, target.size, false, 'memory_graph_rotation_recovery_failed');
|
|
if (!verified || !rotationDigestMatches(verified.payload, target)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
return verified;
|
|
}
|
|
removeRotationStage(target) {
|
|
const path = join(this.dir, target.stageName);
|
|
const verified = this.readVerifiedRotationFile(path, target.size, true, 'memory_graph_rotation_recovery_failed');
|
|
if (!verified)
|
|
return;
|
|
if (!rotationDigestMatches(verified.payload, target)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
this.removeVerifiedRotationFile(path, verified.identity, 'memory_graph_rotation_recovery_failed');
|
|
}
|
|
removeVerifiedRotationFile(path, identity, reason) {
|
|
let current;
|
|
try {
|
|
current = lstatSync(path, { bigint: true });
|
|
}
|
|
catch {
|
|
throw new Error(reason);
|
|
}
|
|
if (!current.isFile() || current.isSymbolicLink()
|
|
|| !sameGraphFileSnapshot(identity, current)) {
|
|
throw new Error(reason);
|
|
}
|
|
try {
|
|
unlinkSync(path);
|
|
this.syncGraphDirectory();
|
|
}
|
|
catch {
|
|
throw new Error(reason);
|
|
}
|
|
}
|
|
readVerifiedRotationFile(path, maxBytes, optional, reason, range) {
|
|
let pathBefore;
|
|
try {
|
|
pathBefore = lstatSync(path, { bigint: true });
|
|
}
|
|
catch (error) {
|
|
if (optional && isObject(error) && error['code'] === 'ENOENT')
|
|
return null;
|
|
throw new Error(reason);
|
|
}
|
|
if (!pathBefore.isFile() || pathBefore.isSymbolicLink()
|
|
|| pathBefore.size > BigInt(maxBytes)
|
|
|| pathBefore.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
throw new Error(reason);
|
|
}
|
|
let fd;
|
|
try {
|
|
fd = openNoFollow(path, constants.O_RDONLY, 0o600);
|
|
}
|
|
catch {
|
|
throw new Error(reason);
|
|
}
|
|
try {
|
|
const openedBefore = fstatSync(fd, { bigint: true });
|
|
if (!openedBefore.isFile() || openedBefore.isSymbolicLink()
|
|
|| !sameGraphFileSnapshot(pathBefore, openedBefore)) {
|
|
throw new Error(reason);
|
|
}
|
|
const readOffset = range?.offset ?? 0;
|
|
const readSize = range?.size ?? Number(openedBefore.size);
|
|
if (!Number.isSafeInteger(readOffset) || readOffset < 0
|
|
|| !Number.isSafeInteger(readSize) || readSize < 0
|
|
|| BigInt(readOffset) + BigInt(readSize) > openedBefore.size) {
|
|
throw new Error(reason);
|
|
}
|
|
const payload = readFdBytes(fd, readSize, readOffset);
|
|
const openedAfter = fstatSync(fd, { bigint: true });
|
|
let pathAfter;
|
|
try {
|
|
pathAfter = lstatSync(path, { bigint: true });
|
|
}
|
|
catch {
|
|
throw new Error(reason);
|
|
}
|
|
if (payload.length !== readSize
|
|
|| !sameGraphFileSnapshot(openedBefore, openedAfter)
|
|
|| !sameGraphFileSnapshot(openedAfter, pathAfter)) {
|
|
throw new Error(reason);
|
|
}
|
|
return { identity: openedAfter, payload };
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
rotationStageFiles() {
|
|
try {
|
|
return readdirSync(this.dir).filter((name) => ROTATION_STAGE_PATTERN.test(name));
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
}
|
|
rotationTempFiles() {
|
|
try {
|
|
return readdirSync(this.dir).filter((name) => ROTATION_TEMP_PATTERN.test(name));
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
}
|
|
removeRotationTemp(name) {
|
|
if (!ROTATION_TEMP_PATTERN.test(name)) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
const path = join(this.dir, name);
|
|
let identity;
|
|
try {
|
|
identity = lstatSync(path, { bigint: true });
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT')
|
|
return;
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
if (!identity.isFile() || identity.isSymbolicLink()) {
|
|
throw new Error('memory_graph_rotation_recovery_failed');
|
|
}
|
|
this.removeVerifiedRotationFile(path, identity, 'memory_graph_rotation_recovery_failed');
|
|
}
|
|
notifyRotationPhase(journal, mode, phase) {
|
|
this.onRotationPhase?.({
|
|
mode,
|
|
phase,
|
|
transactionId: journal.transactionId,
|
|
generation: journal.targetGeneration,
|
|
});
|
|
}
|
|
syncGraphDirectory() {
|
|
if (process.platform === 'win32')
|
|
return;
|
|
const fd = openSync(this.dir, constants.O_RDONLY);
|
|
try {
|
|
fsyncSync(fd);
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
compactRequiresRecovery(epoch) {
|
|
if (this.recoveryMarkerState(epoch) === 'degraded')
|
|
return true;
|
|
const path = join(this.dir, COMPACT_FILE);
|
|
const diagnostics = emptyDiagnostics();
|
|
const currentRecords = this.readCompactForEpoch(epoch, diagnostics)
|
|
.filter((record) => recordEpochId(record) === epoch.epochId);
|
|
const compactBaseline = this.compactMatchesEpochBaseline(epoch);
|
|
const compactUnusable = compactBaseline || !existsSync(path)
|
|
|| !this.secureRegularFile(path)
|
|
|| statSync(path).size === 0
|
|
|| currentRecords.length === 0
|
|
|| diagnostics.corruptLines > 0
|
|
|| diagnostics.oversizedLines > 0
|
|
|| diagnostics.truncated;
|
|
if (!compactUnusable)
|
|
return false;
|
|
const archives = this.inspectArchivesForEpoch(epoch);
|
|
return archives.containsCurrent || archives.unreadable;
|
|
}
|
|
readQueryableRecords(diagnostics) {
|
|
this.ensureSecureDir();
|
|
this.managedGraphStateFiles();
|
|
const epoch = this.readEpochState();
|
|
const epochId = epoch.epochId;
|
|
const compactPath = join(this.dir, COMPACT_FILE);
|
|
const compactDiagnostics = emptyDiagnostics();
|
|
let records = this.readCompactForEpoch(epoch, compactDiagnostics)
|
|
.filter((record) => recordEpochId(record) === epochId);
|
|
const archives = this.archiveFiles().length;
|
|
const compactExists = existsSync(compactPath);
|
|
const compactSecure = compactExists && this.secureRegularFile(compactPath);
|
|
const compactUnusable = (this.compactMatchesEpochBaseline(epoch)
|
|
|| !compactExists
|
|
|| !compactSecure
|
|
|| (compactSecure && statSync(compactPath).size === 0)
|
|
|| records.length === 0
|
|
|| compactDiagnostics.corruptLines > 0
|
|
|| compactDiagnostics.oversizedLines > 0
|
|
|| compactDiagnostics.truncated);
|
|
const archiveProbe = archives > 0 && compactUnusable
|
|
? this.inspectArchivesForEpoch(epoch)
|
|
: { containsCurrent: false, unreadable: false };
|
|
const compactNeedsRecovery = compactUnusable
|
|
&& (archiveProbe.containsCurrent || archiveProbe.unreadable);
|
|
mergeDiagnostics(diagnostics, compactDiagnostics);
|
|
if (compactNeedsRecovery) {
|
|
try {
|
|
const recovery = this.recoverFromArchivesUnlocked(epoch);
|
|
diagnostics.corruptLines += recovery.corruptLines;
|
|
diagnostics.oversizedLines += recovery.oversizedLines;
|
|
if (recovery.recovery === 'recovered'
|
|
|| (recovery.recovery === 'degraded' && recovery.compactedRecords > 0)) {
|
|
const recoveredDiagnostics = emptyDiagnostics();
|
|
records = this.readCompactForEpoch(epoch, recoveredDiagnostics)
|
|
.filter((record) => recordEpochId(record) === epochId);
|
|
mergeDiagnostics(diagnostics, recoveredDiagnostics);
|
|
diagnostics.recovery = recovery.recovery;
|
|
}
|
|
else if (recovery.recovery === 'empty') {
|
|
records = [];
|
|
diagnostics.recovery = 'empty';
|
|
}
|
|
else {
|
|
diagnostics.recovery = 'degraded';
|
|
}
|
|
}
|
|
catch {
|
|
diagnostics.recovery = 'degraded';
|
|
}
|
|
}
|
|
records.push(...this.readActiveForEpoch(epoch, diagnostics, this.maxActiveBytes)
|
|
.filter((record) => recordEpochId(record) === epochId));
|
|
if (this.recoveryMarkerState(epoch) === 'degraded')
|
|
diagnostics.recovery = 'degraded';
|
|
if (diagnostics.recovery !== 'recovered' && (diagnostics.corruptLines > 0 || diagnostics.oversizedLines > 0 || diagnostics.truncated))
|
|
diagnostics.recovery = 'degraded';
|
|
return records;
|
|
}
|
|
readImportRecords() {
|
|
this.ensureSecureDir();
|
|
return this.readImportRecordsFromExistingDir();
|
|
}
|
|
readImportRecordsFromExistingDir() {
|
|
this.managedGraphStateFiles();
|
|
const epoch = this.readEpochState();
|
|
const epochId = epoch.epochId;
|
|
const diagnostics = emptyDiagnostics();
|
|
const activePath = join(this.dir, ACTIVE_FILE);
|
|
if (existsSync(activePath)) {
|
|
if (!this.secureRegularFile(activePath)) {
|
|
throw new MemoryGraphImportStateRejectedError();
|
|
}
|
|
const activeWindow = this.activeLogicalWindow(epoch, lstatSync(activePath, { bigint: true }));
|
|
if (activeWindow.invalid || activeWindow.size > this.maxActiveBytes) {
|
|
throw new MemoryGraphImportStateRejectedError();
|
|
}
|
|
}
|
|
return [...this.readCompactForEpoch(epoch, diagnostics), ...this.readActiveForEpoch(epoch, diagnostics, this.maxActiveBytes)]
|
|
.filter((record) => recordEpochId(record) === epochId);
|
|
}
|
|
readCompact(diagnostics) {
|
|
const path = join(this.dir, COMPACT_FILE);
|
|
if (!existsSync(path) || !this.secureRegularFile(path))
|
|
return [];
|
|
if (statSync(path).size > this.maxCompactBytes) {
|
|
diagnostics.truncated = true;
|
|
diagnostics.oversizedLines += 1;
|
|
return [];
|
|
}
|
|
return this.readWholeBounded(path, diagnostics, this.maxCompactBytes);
|
|
}
|
|
readWholeBounded(path, diagnostics, maxBytes) {
|
|
if (!existsSync(path) || !this.secureRegularFile(path))
|
|
return [];
|
|
const fd = openNoFollow(path, constants.O_RDONLY, 0o600);
|
|
try {
|
|
const stat = fstatSync(fd);
|
|
if (!stat.isFile())
|
|
return [];
|
|
if (stat.size > maxBytes) {
|
|
diagnostics.truncated = true;
|
|
return this.readTailFromFd(fd, stat.size, diagnostics, maxBytes);
|
|
}
|
|
const raw = readFdBytes(fd, stat.size, 0);
|
|
diagnostics.bytesRead += raw.length;
|
|
return this.parseBuffer(raw, diagnostics);
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
readTail(path, diagnostics, maxBytes = this.maxTailBytes) {
|
|
if (!existsSync(path) || !this.secureRegularFile(path))
|
|
return [];
|
|
const fd = openNoFollow(path, constants.O_RDONLY, 0o600);
|
|
try {
|
|
const size = fstatSync(fd).size;
|
|
return this.readTailFromFd(fd, size, diagnostics, maxBytes);
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
readTailFromFd(fd, size, diagnostics, maxBytes) {
|
|
const bytes = Math.min(size, maxBytes);
|
|
const buffer = readFdBytes(fd, bytes, size - bytes);
|
|
diagnostics.bytesRead += buffer.length;
|
|
diagnostics.truncated ||= size > buffer.length;
|
|
if (size <= buffer.length)
|
|
return this.parseBuffer(buffer, diagnostics);
|
|
const newline = buffer.indexOf(0x0a);
|
|
return this.parseBuffer(newline >= 0 ? buffer.subarray(newline + 1) : Buffer.alloc(0), diagnostics);
|
|
}
|
|
parseBuffer(buffer, diagnostics) {
|
|
const out = [];
|
|
for (const line of buffer.toString('utf8').split('\n')) {
|
|
if (!line.trim())
|
|
continue;
|
|
if (Buffer.byteLength(line) > this.maxLineBytes) {
|
|
diagnostics.oversizedLines += 1;
|
|
continue;
|
|
}
|
|
try {
|
|
const raw = JSON.parse(line);
|
|
if (isObject(raw) && raw['version'] === 2 && raw['kind'] === 'outcome' && raw['provenance'] !== 'v2_local' && raw['provenance'] !== 'v1_import') {
|
|
diagnostics.provenanceRejected += 1;
|
|
continue;
|
|
}
|
|
const parsed = parseV2Record(raw);
|
|
if (parsed) {
|
|
out.push(parsed);
|
|
diagnostics.recordsRead += 1;
|
|
}
|
|
else {
|
|
diagnostics.corruptLines += 1;
|
|
}
|
|
}
|
|
catch {
|
|
diagnostics.corruptLines += 1;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
writeCompact(records) {
|
|
const fitted = fitCompactRecords(records, this.maxCompactBytes);
|
|
if (records.length > 0 && fitted.records.length === 0) {
|
|
return { records: 0, committedIdentity: null };
|
|
}
|
|
const committedIdentity = this.writeAtomic(join(this.dir, COMPACT_FILE), fitted.payload);
|
|
return { records: fitted.records.length, committedIdentity };
|
|
}
|
|
writeAtomic(path, payload, syncDirectory = false, tempTag = String(this.now())) {
|
|
this.ensureSecureDir();
|
|
if (existsSync(path) && !this.secureRegularFile(path))
|
|
throw new Error('memory_graph_path_rejected');
|
|
const temp = join(dirname(path), `.${basename(path)}.${tempTag}.tmp`);
|
|
const fd = openNoFollow(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
let tempIdentity = null;
|
|
let closed = false;
|
|
let published = false;
|
|
try {
|
|
tempIdentity = lstatSync(temp, { bigint: true });
|
|
const descriptorIdentity = fstatSync(fd, { bigint: true });
|
|
if (!tempIdentity.isFile() || tempIdentity.isSymbolicLink()
|
|
|| !descriptorIdentity.isFile() || descriptorIdentity.isSymbolicLink()
|
|
|| !sameGraphFileIdentity(tempIdentity, descriptorIdentity)) {
|
|
throw new Error('memory_graph_write_failed');
|
|
}
|
|
writeAll(fd, payload);
|
|
fsyncSync(fd);
|
|
closeSync(fd);
|
|
closed = true;
|
|
renameSync(temp, path);
|
|
published = true;
|
|
if (syncDirectory)
|
|
this.syncGraphDirectory();
|
|
}
|
|
finally {
|
|
if (!closed) {
|
|
try {
|
|
closeSync(fd);
|
|
}
|
|
catch {
|
|
// Preserve the original write/fsync failure.
|
|
}
|
|
}
|
|
if (!published && tempIdentity !== null) {
|
|
try {
|
|
const currentIdentity = lstatSync(temp, { bigint: true });
|
|
if (currentIdentity.isFile() && !currentIdentity.isSymbolicLink()
|
|
&& sameGraphFileIdentity(tempIdentity, currentIdentity))
|
|
unlinkSync(temp);
|
|
}
|
|
catch {
|
|
// Never remove an unknown replacement; a same-name leftover is safer than clobbering it.
|
|
}
|
|
}
|
|
}
|
|
if (tempIdentity === null || !published)
|
|
throw new Error('memory_graph_write_failed');
|
|
return tempIdentity;
|
|
}
|
|
ensureSecureDir() {
|
|
let stat;
|
|
try {
|
|
stat = lstatSync(this.dir);
|
|
}
|
|
catch (error) {
|
|
if (!isObject(error) || error['code'] !== 'ENOENT')
|
|
throw new Error('memory_graph_dir_rejected');
|
|
try {
|
|
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
stat = lstatSync(this.dir);
|
|
}
|
|
catch {
|
|
throw new Error('memory_graph_dir_rejected');
|
|
}
|
|
}
|
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
throw new Error('memory_graph_dir_rejected');
|
|
}
|
|
secureRegularFile(path) {
|
|
const stat = lstatSync(path);
|
|
return stat.isFile() && !stat.isSymbolicLink();
|
|
}
|
|
workspaceScope(workspace) {
|
|
try {
|
|
const absolute = resolve(workspace);
|
|
const linkStat = lstatSync(absolute);
|
|
if (!linkStat.isDirectory() || linkStat.isSymbolicLink())
|
|
return null;
|
|
const canonical = realpathSync(absolute);
|
|
return scopeHash(`workspace:${canonical}`);
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
archiveFiles() {
|
|
if (!existsSync(this.dir))
|
|
return [];
|
|
return readdirSync(this.dir).filter((file) => ARCHIVE_PATTERN.test(file)).sort();
|
|
}
|
|
nextArchivePath() {
|
|
const latest = this.archiveFiles().at(-1);
|
|
const latestTimestamp = latest ? Number(ARCHIVE_PATTERN.exec(latest)?.[1] ?? 0) : 0;
|
|
const timestamp = Math.max(Math.floor(this.now()), latestTimestamp + 1);
|
|
return join(this.dir, `memory_graph.v2.${timestamp}.jsonl.gz`);
|
|
}
|
|
pruneArchives(retention = this.archiveRetention) {
|
|
const files = this.archiveFiles();
|
|
for (const file of files.slice(0, Math.max(0, files.length - retention))) {
|
|
unlinkSync(join(this.dir, file));
|
|
}
|
|
}
|
|
writeRecoveryMarker(epochId, state) {
|
|
this.writeAtomic(join(this.dir, RECOVERY_FILE), Buffer.from(JSON.stringify({
|
|
version: 1,
|
|
recoveredAt: new Date(this.now()).toISOString(),
|
|
epochId,
|
|
state,
|
|
}) + '\n', 'utf8'));
|
|
}
|
|
inspectArchivesForEpoch(epoch) {
|
|
let unreadable = false;
|
|
const maxArchiveBytes = gzipRotationByteBound(this.maxActiveBytes);
|
|
const maxArchiveOutputBytes = safeRotationByteSum(this.maxActiveBytes, this.maxLineBytes);
|
|
for (const file of this.currentArchiveFiles(epoch)) {
|
|
const path = join(this.dir, file);
|
|
try {
|
|
if (maxArchiveBytes === null || maxArchiveOutputBytes === null) {
|
|
unreadable = true;
|
|
continue;
|
|
}
|
|
if (!this.secureRegularFile(path)) {
|
|
unreadable = true;
|
|
continue;
|
|
}
|
|
const compressed = this.readRawBounded(path, maxArchiveBytes);
|
|
if (compressed === null) {
|
|
unreadable = true;
|
|
continue;
|
|
}
|
|
const raw = gunzipSync(compressed, { maxOutputLength: maxArchiveOutputBytes });
|
|
const diagnostics = emptyDiagnostics();
|
|
const records = this.parseBuffer(raw, diagnostics);
|
|
unreadable ||= diagnostics.corruptLines > 0
|
|
|| diagnostics.oversizedLines > 0
|
|
|| diagnostics.truncated;
|
|
if (records.some((record) => recordEpochId(record) === epoch.epochId)) {
|
|
return { containsCurrent: true, unreadable };
|
|
}
|
|
}
|
|
catch {
|
|
unreadable = true;
|
|
}
|
|
}
|
|
return { containsCurrent: false, unreadable };
|
|
}
|
|
recoveryMarkerState(epoch, maxBytes = this.maxLineBytes) {
|
|
const path = join(this.dir, RECOVERY_FILE);
|
|
if (!existsSync(path))
|
|
return null;
|
|
try {
|
|
const identity = lstatSync(path, { bigint: true });
|
|
if (epoch.baseline?.recovery
|
|
&& serializedSnapshotMatches(epoch.baseline.recovery, identity))
|
|
return null;
|
|
if (!identity.isFile() || identity.isSymbolicLink())
|
|
return 'degraded';
|
|
const bytes = this.readRawBounded(path, maxBytes);
|
|
if (bytes === null)
|
|
return 'degraded';
|
|
const raw = JSON.parse(bytes.toString('utf8'));
|
|
if (!isObject(raw) || raw['version'] !== 1
|
|
|| typeof raw['recoveredAt'] !== 'string' || validIso(raw['recoveredAt']) === null)
|
|
return 'degraded';
|
|
const markerEpoch = Object.prototype.hasOwnProperty.call(raw, 'epochId')
|
|
? safeEpochId(raw['epochId'])
|
|
: LEGACY_EPOCH_ID;
|
|
if (markerEpoch !== epoch.epochId)
|
|
return 'degraded';
|
|
const state = raw['state'];
|
|
if (state === undefined)
|
|
return 'recovered';
|
|
return state === 'empty' || state === 'recovered' || state === 'degraded' ? state : 'degraded';
|
|
}
|
|
catch {
|
|
return 'degraded';
|
|
}
|
|
}
|
|
clearRecoveryMarker(epoch, maxBytes = this.maxLineBytes) {
|
|
if (this.recoveryMarkerState(epoch, maxBytes) === 'degraded')
|
|
return;
|
|
const path = join(this.dir, RECOVERY_FILE);
|
|
if (existsSync(path) && this.secureRegularFile(path))
|
|
unlinkSync(path);
|
|
}
|
|
readRawBounded(path, maxBytes) {
|
|
if (!existsSync(path) || !this.secureRegularFile(path))
|
|
return null;
|
|
const fd = openNoFollow(path, constants.O_RDONLY, 0o600);
|
|
try {
|
|
const stat = fstatSync(fd);
|
|
if (!stat.isFile() || stat.size > maxBytes)
|
|
return null;
|
|
return readFdBytes(fd, stat.size, 0);
|
|
}
|
|
finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
}
|
|
function rotationStageNames(generation) {
|
|
return {
|
|
compact: 'memory_graph.rotation.' + generation + '.compact.stage',
|
|
archive: 'memory_graph.rotation.' + generation + '.archive.stage',
|
|
active: 'memory_graph.rotation.' + generation + '.active.stage',
|
|
};
|
|
}
|
|
function rotationTempNames(generation) {
|
|
const stages = rotationStageNames(generation);
|
|
return {
|
|
journal: '.' + ROTATION_JOURNAL_FILE + '.' + generation + '.tmp',
|
|
compact: '.' + stages.compact + '.' + generation + '.tmp',
|
|
archive: '.' + stages.archive + '.' + generation + '.tmp',
|
|
active: '.' + stages.active + '.' + generation + '.tmp',
|
|
};
|
|
}
|
|
function rotationFileDigest(payload) {
|
|
return {
|
|
size: payload.length,
|
|
sha256: createHash('sha256').update(payload).digest('hex'),
|
|
};
|
|
}
|
|
function rotationDigestMatches(payload, expected) {
|
|
const actual = rotationFileDigest(payload);
|
|
return actual.size === expected.size && actual.sha256 === expected.sha256;
|
|
}
|
|
function rotationJournalImmutable(journal) {
|
|
return {
|
|
version: journal.version,
|
|
targetGeneration: journal.targetGeneration,
|
|
epochId: journal.epochId,
|
|
createdAt: journal.createdAt,
|
|
limits: {
|
|
maxActiveBytes: journal.limits.maxActiveBytes,
|
|
maxCompactBytes: journal.limits.maxCompactBytes,
|
|
maxLineBytes: journal.limits.maxLineBytes,
|
|
archiveRetention: journal.limits.archiveRetention,
|
|
},
|
|
source: {
|
|
active: {
|
|
snapshot: journal.source.active.snapshot,
|
|
offset: journal.source.active.offset,
|
|
size: journal.source.active.size,
|
|
sha256: journal.source.active.sha256,
|
|
},
|
|
compact: journal.source.compact === null ? null : {
|
|
snapshot: journal.source.compact.snapshot,
|
|
size: journal.source.compact.size,
|
|
sha256: journal.source.compact.sha256,
|
|
},
|
|
},
|
|
target: {
|
|
compact: {
|
|
stageName: journal.target.compact.stageName,
|
|
finalName: journal.target.compact.finalName,
|
|
size: journal.target.compact.size,
|
|
sha256: journal.target.compact.sha256,
|
|
records: journal.target.compact.records,
|
|
},
|
|
archive: {
|
|
stageName: journal.target.archive.stageName,
|
|
finalName: journal.target.archive.finalName,
|
|
size: journal.target.archive.size,
|
|
sha256: journal.target.archive.sha256,
|
|
},
|
|
active: {
|
|
stageName: journal.target.active.stageName,
|
|
finalName: journal.target.active.finalName,
|
|
size: journal.target.active.size,
|
|
sha256: journal.target.active.sha256,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
function rotationTransactionId(journal) {
|
|
return 'txn_' + createHash('sha256')
|
|
.update(JSON.stringify(rotationJournalImmutable(journal)))
|
|
.digest('hex');
|
|
}
|
|
function rotationJournalDigest(journal) {
|
|
return createHash('sha256').update(JSON.stringify({
|
|
...rotationJournalImmutable(journal),
|
|
transactionId: journal.transactionId,
|
|
phase: journal.phase,
|
|
})).digest('hex');
|
|
}
|
|
function parseRotationJournal(value) {
|
|
if (!isObject(value) || value['version'] !== 1)
|
|
return null;
|
|
const targetGeneration = typeof value['targetGeneration'] === 'string'
|
|
&& ROTATION_GENERATION_PATTERN.test(value['targetGeneration'])
|
|
? value['targetGeneration']
|
|
: null;
|
|
const transactionId = typeof value['transactionId'] === 'string'
|
|
&& ROTATION_TRANSACTION_PATTERN.test(value['transactionId'])
|
|
? value['transactionId']
|
|
: null;
|
|
const journalDigest = typeof value['journalDigest'] === 'string'
|
|
&& /^[a-f0-9]{64}$/.test(value['journalDigest'])
|
|
? value['journalDigest']
|
|
: null;
|
|
const phase = value['phase'] === 'prepared' || value['phase'] === 'committed'
|
|
? value['phase']
|
|
: null;
|
|
const epochId = safeEpochId(value['epochId']);
|
|
const createdAt = typeof value['createdAt'] === 'string' ? validIso(value['createdAt']) : null;
|
|
if (!targetGeneration || !transactionId || !journalDigest
|
|
|| !phase || !epochId || !createdAt)
|
|
return null;
|
|
const limitsValue = value['limits'];
|
|
if (!isObject(limitsValue))
|
|
return null;
|
|
const maxActiveBytes = parseRotationLimit(limitsValue['maxActiveBytes']);
|
|
const maxCompactBytes = parseRotationLimit(limitsValue['maxCompactBytes']);
|
|
const maxLineBytes = parseRotationLimit(limitsValue['maxLineBytes']);
|
|
const archiveRetention = parseRotationLimit(limitsValue['archiveRetention']);
|
|
if (maxActiveBytes === null || maxCompactBytes === null || maxLineBytes === null
|
|
|| archiveRetention === null)
|
|
return null;
|
|
const maxArchiveBytes = gzipRotationByteBound(maxActiveBytes);
|
|
const maxCompactWithLineBytes = safeRotationByteSum(maxCompactBytes, maxLineBytes);
|
|
if (maxArchiveBytes === null || maxCompactWithLineBytes === null)
|
|
return null;
|
|
const limits = {
|
|
maxActiveBytes,
|
|
maxCompactBytes,
|
|
maxLineBytes,
|
|
archiveRetention,
|
|
};
|
|
const sourceValue = value['source'];
|
|
const targetValue = value['target'];
|
|
if (!isObject(sourceValue) || !isObject(targetValue))
|
|
return null;
|
|
const activeSourceValue = sourceValue['active'];
|
|
if (!isObject(activeSourceValue))
|
|
return null;
|
|
const activeSnapshot = parseGraphFileSnapshot(activeSourceValue['snapshot']);
|
|
const activeDigest = parseRotationFileDigest(activeSourceValue, maxActiveBytes);
|
|
const activeOffset = activeSourceValue['offset'];
|
|
const activeSnapshotSize = activeSnapshot ? BigInt(activeSnapshot.size) : null;
|
|
if (!activeSnapshot || !activeDigest || !Number.isSafeInteger(activeOffset)
|
|
|| Number(activeOffset) < 0
|
|
|| activeSnapshotSize === null
|
|
|| activeSnapshotSize > BigInt(Number.MAX_SAFE_INTEGER)
|
|
|| BigInt(Number(activeOffset)) + BigInt(activeDigest.size) !== activeSnapshotSize)
|
|
return null;
|
|
const activeSource = {
|
|
snapshot: activeSnapshot,
|
|
offset: Number(activeOffset),
|
|
...activeDigest,
|
|
};
|
|
let compactSource = null;
|
|
if (sourceValue['compact'] !== null) {
|
|
if (!isObject(sourceValue['compact']))
|
|
return null;
|
|
const compactSnapshot = parseGraphFileSnapshot(sourceValue['compact']['snapshot']);
|
|
const compactDigest = parseRotationFileDigest(sourceValue['compact'], maxCompactWithLineBytes);
|
|
if (!compactSnapshot || !compactDigest
|
|
|| compactSnapshot.size !== String(compactDigest.size))
|
|
return null;
|
|
compactSource = { snapshot: compactSnapshot, ...compactDigest };
|
|
}
|
|
const stages = rotationStageNames(targetGeneration);
|
|
const compactTarget = parseRotationTarget(targetValue['compact'], maxCompactBytes, stages.compact, COMPACT_FILE);
|
|
const archiveTarget = parseRotationTarget(targetValue['archive'], maxArchiveBytes, stages.archive, 'memory_graph.v2.' + targetGeneration + '.jsonl.gz');
|
|
const activeTarget = parseRotationTarget(targetValue['active'], 0, stages.active, ACTIVE_FILE);
|
|
const records = isObject(targetValue['compact']) ? targetValue['compact']['records'] : null;
|
|
if (!compactTarget || !archiveTarget || !activeTarget
|
|
|| !Number.isSafeInteger(records) || Number(records) < 0
|
|
|| activeTarget.sha256 !== rotationFileDigest(Buffer.alloc(0)).sha256)
|
|
return null;
|
|
const journal = {
|
|
version: 1,
|
|
transactionId,
|
|
journalDigest,
|
|
targetGeneration,
|
|
phase,
|
|
epochId,
|
|
createdAt,
|
|
limits,
|
|
source: { active: activeSource, compact: compactSource },
|
|
target: {
|
|
compact: { ...compactTarget, records: Number(records) },
|
|
archive: archiveTarget,
|
|
active: activeTarget,
|
|
},
|
|
};
|
|
return rotationTransactionId(journal) === journal.transactionId
|
|
&& rotationJournalDigest(journal) === journal.journalDigest
|
|
? journal
|
|
: null;
|
|
}
|
|
function parseRotationLimit(value) {
|
|
return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : null;
|
|
}
|
|
function safeRotationByteSum(left, right) {
|
|
const sum = left + right;
|
|
return Number.isSafeInteger(sum) ? sum : null;
|
|
}
|
|
function gzipRotationByteBound(sourceBytes) {
|
|
if (!Number.isSafeInteger(sourceBytes) || sourceBytes < 0)
|
|
return null;
|
|
const source = BigInt(sourceBytes);
|
|
// Mirrors zlib deflateBound's conservative stream bounds plus the gzip wrapper.
|
|
const fixed = source + (source >> 3n) + (source >> 8n) + (source >> 9n) + 4n;
|
|
const stored = source + (source >> 5n) + (source >> 7n) + (source >> 11n) + 7n;
|
|
const bound = (fixed > stored ? fixed : stored) + 18n;
|
|
return bound <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(bound) : null;
|
|
}
|
|
function parseRotationTarget(value, maxBytes, stageName, finalName) {
|
|
if (!isObject(value) || value['stageName'] !== stageName || value['finalName'] !== finalName) {
|
|
return null;
|
|
}
|
|
const digest = parseRotationFileDigest(value, maxBytes);
|
|
return digest ? { stageName, finalName, ...digest } : null;
|
|
}
|
|
function parseRotationFileDigest(value, maxBytes) {
|
|
if (!isObject(value))
|
|
return null;
|
|
const size = value['size'];
|
|
const sha256 = value['sha256'];
|
|
if (!Number.isSafeInteger(size) || Number(size) < 0 || Number(size) > maxBytes
|
|
|| typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256))
|
|
return null;
|
|
return { size: Number(size), sha256 };
|
|
}
|
|
const MEMORY_GRAPH_REASON_PATTERN = /scoped memory-graph outcome ([+-]\d+\.\d{3}) \(boost=([+-]?\d+\.\d{2})\)/;
|
|
export function sanitizeMemoryGraphSelectionReason(value) {
|
|
if (typeof value !== 'string')
|
|
return undefined;
|
|
const match = MEMORY_GRAPH_REASON_PATTERN.exec(value);
|
|
if (!match?.[1] || !match[2])
|
|
return undefined;
|
|
const outcome = Number(match[1]);
|
|
const boost = Number(match[2]);
|
|
if (!Number.isFinite(outcome) || !Number.isFinite(boost) || Math.abs(outcome) > 1 || Math.abs(boost) > 1)
|
|
return undefined;
|
|
return `scoped memory-graph outcome ${match[1]} (boost=${match[2]})`;
|
|
}
|
|
export function formatMemoryGraphOperatorStatus(status) {
|
|
const state = status.recovery === 'healthy' || status.recovery === 'degraded'
|
|
|| status.recovery === 'recovered' || status.recovery === 'empty'
|
|
? status.recovery
|
|
: 'degraded';
|
|
const reason = sanitizeMemoryGraphSelectionReason(status.selectionReason);
|
|
return [
|
|
`state=${state}`,
|
|
...(status.busy === true ? ['busy=1'] : []),
|
|
`corrupt=${boundedOperatorCount(status.corruptLines)}`,
|
|
`oversized_lines=${boundedOperatorCount(status.oversizedLines)}`,
|
|
`oversized_files=${boundedOperatorCount(status.oversizedFiles)}`,
|
|
`archives=${boundedOperatorCount(status.archives)}`,
|
|
...(reason ? [`selection=${reason}`] : []),
|
|
].join(' ');
|
|
}
|
|
export function loadMemoryGraphOperatorStatus(env = process.env) {
|
|
const home = events.evomapHome(env);
|
|
const graphDir = join(home, 'evolution');
|
|
let health;
|
|
try {
|
|
health = new LocalMemoryGraph({
|
|
dir: graphDir,
|
|
...resolveLocalMemoryUserIdentity(graphDir),
|
|
}).inspectHealth();
|
|
}
|
|
catch {
|
|
health = { ...emptyHealth(), recovery: 'degraded', corruptLines: 1 };
|
|
}
|
|
let selectionReason;
|
|
try {
|
|
const rootEvents = events.readEvents(join(home, 'evolution', 'root_events.jsonl'));
|
|
for (let index = rootEvents.length - 1; index >= 0; index -= 1) {
|
|
const event = rootEvents[index];
|
|
if (event?.type !== 'decision.gene_selected')
|
|
continue;
|
|
selectionReason = sanitizeMemoryGraphSelectionReason(event.payload?.['selectedReason']);
|
|
if (selectionReason)
|
|
break;
|
|
}
|
|
}
|
|
catch {
|
|
// Health remains available even when the optional event rationale cannot be read.
|
|
}
|
|
return { ...health, ...(selectionReason ? { selectionReason } : {}) };
|
|
}
|
|
function parseV2Record(value) {
|
|
if (!isObject(value) || value['version'] !== 2 || value['kind'] !== 'outcome')
|
|
return null;
|
|
const provenance = value['provenance'];
|
|
if (provenance !== 'v2_local' && provenance !== 'v1_import')
|
|
return null;
|
|
const workspaceScope = safeToken(value['workspaceScope'], 128);
|
|
const userScope = safeToken(value['userScope'], 128);
|
|
const signalFingerprint = safeToken(value['signalFingerprint'], 4096);
|
|
const geneId = typeof value['geneId'] === 'string' ? algo.safeMemoryGeneId(value['geneId']) : '';
|
|
const status = value['status'];
|
|
const at = typeof value['at'] === 'string' ? validIso(value['at']) : null;
|
|
const hasEpoch = Object.prototype.hasOwnProperty.call(value, 'epochId');
|
|
const epochId = hasEpoch ? safeEpochId(value['epochId']) : null;
|
|
if (!workspaceScope || !userScope || !signalFingerprint || !geneId
|
|
|| (status !== 'success' && status !== 'failed') || !at || (hasEpoch && !epochId))
|
|
return null;
|
|
return {
|
|
version: 2,
|
|
kind: 'outcome',
|
|
provenance,
|
|
workspaceScope,
|
|
userScope,
|
|
signalFingerprint,
|
|
signals: algo.normalizeMemorySignals(Array.isArray(value['signals']) ? value['signals'].filter((item) => typeof item === 'string') : []),
|
|
geneId,
|
|
status,
|
|
score: clampScore(Number(value['score'])),
|
|
at,
|
|
...(epochId ? { epochId } : {}),
|
|
...(safeToken(value['sourceFingerprint'], 128) ? { sourceFingerprint: safeToken(value['sourceFingerprint'], 128) } : {}),
|
|
...(safeToken(value['sourceScope'], 128) ? { sourceScope: safeToken(value['sourceScope'], 128) } : {}),
|
|
...(safeToken(value['legacySourceFingerprint'], 128) ? { legacySourceFingerprint: safeToken(value['legacySourceFingerprint'], 128) } : {}),
|
|
...(positiveCount(value['successCount']) !== undefined ? { successCount: positiveCount(value['successCount']) } : {}),
|
|
...(positiveCount(value['failCount']) !== undefined ? { failCount: positiveCount(value['failCount']) } : {}),
|
|
};
|
|
}
|
|
function sealV1OutcomePlan(summary, data) {
|
|
const plan = Object.freeze({ ...summary });
|
|
if (data)
|
|
memoryGraphV1OutcomePlans.set(plan, data);
|
|
return plan;
|
|
}
|
|
function collectImportFingerprints(records, workspaceScope, userScope, sourceScope) {
|
|
const values = new Set();
|
|
for (const record of records) {
|
|
if (record.workspaceScope !== workspaceScope || record.userScope !== userScope)
|
|
continue;
|
|
if (record.sourceScope && record.sourceScope !== sourceScope)
|
|
continue;
|
|
addImportFingerprints(values, record);
|
|
}
|
|
return values;
|
|
}
|
|
function hasImportFingerprint(values, record) {
|
|
return Boolean((record.sourceFingerprint && values.has(record.sourceFingerprint))
|
|
|| (record.legacySourceFingerprint && values.has(record.legacySourceFingerprint)));
|
|
}
|
|
function addImportFingerprints(values, record) {
|
|
if (record.sourceFingerprint)
|
|
values.add(record.sourceFingerprint);
|
|
if (record.legacySourceFingerprint)
|
|
values.add(record.legacySourceFingerprint);
|
|
}
|
|
function parseV1Outcome(value, workspaceScope, userScope, sourceScope) {
|
|
if (!workspaceScope || !isObject(value) || value['type'] !== 'MemoryGraphEvent' || value['kind'] !== 'outcome')
|
|
return null;
|
|
const gene = isObject(value['gene']) ? value['gene'] : null;
|
|
const signal = isObject(value['signal']) ? value['signal'] : null;
|
|
const outcome = isObject(value['outcome']) ? value['outcome'] : null;
|
|
const geneId = typeof gene?.['id'] === 'string' ? algo.safeMemoryGeneId(gene['id']) : '';
|
|
const status = outcome?.['status'];
|
|
const at = typeof value['ts'] === 'string' ? validIso(value['ts']) : null;
|
|
if (!geneId || (status !== 'success' && status !== 'failed') || !at)
|
|
return null;
|
|
const signals = algo.normalizeMemorySignals(Array.isArray(signal?.['signals']) ? signal['signals'].filter((item) => typeof item === 'string') : []);
|
|
const score = clampScore(Number(outcome?.['score']));
|
|
const legacySourceFingerprint = scopeHash(JSON.stringify({
|
|
id: safeToken(value['id'], 240) ?? '',
|
|
at,
|
|
geneId,
|
|
status,
|
|
score,
|
|
signals,
|
|
}));
|
|
const sourceFingerprint = scopeHash(JSON.stringify({
|
|
workspaceScope,
|
|
userScope,
|
|
sourceScope,
|
|
legacySourceFingerprint,
|
|
}));
|
|
return {
|
|
version: 2,
|
|
kind: 'outcome',
|
|
provenance: 'v1_import',
|
|
workspaceScope,
|
|
userScope,
|
|
sourceScope,
|
|
signalFingerprint: algo.memorySignalFingerprint(signals),
|
|
signals,
|
|
geneId,
|
|
status,
|
|
score,
|
|
at,
|
|
sourceFingerprint,
|
|
legacySourceFingerprint,
|
|
};
|
|
}
|
|
function compactRecords(records, maxEdges) {
|
|
const byEdge = new Map();
|
|
for (const record of records) {
|
|
const key = compactRecordKey(record);
|
|
const current = byEdge.get(key);
|
|
const successCount = (record.successCount ?? (record.status === 'success' ? 1 : 0)) + (current?.successCount ?? (current?.status === 'success' ? 1 : 0));
|
|
const failCount = (record.failCount ?? (record.status === 'failed' ? 1 : 0)) + (current?.failCount ?? (current?.status === 'failed' ? 1 : 0));
|
|
const newest = !current || Date.parse(record.at) >= Date.parse(current.at) ? record : current;
|
|
byEdge.set(key, { ...newest, successCount, failCount });
|
|
}
|
|
return [...byEdge.values()]
|
|
.sort((left, right) => Date.parse(right.at) - Date.parse(left.at) || left.geneId.localeCompare(right.geneId))
|
|
.slice(0, maxEdges);
|
|
}
|
|
function compactRecordKey(record) {
|
|
const epoch = recordEpochId(record);
|
|
return record.provenance === 'v1_import' && record.sourceFingerprint
|
|
? `${epoch}\u0000import\u0000${record.workspaceScope}\u0000${record.userScope}\u0000${record.sourceScope ?? 'legacy-unscoped'}\u0000${record.sourceFingerprint}`
|
|
: `${epoch}\u0000${record.workspaceScope}\u0000${record.userScope}\u0000${record.signalFingerprint}\u0000${record.geneId}`;
|
|
}
|
|
function mergeRecoveryRecords(compact, archives, maxEdges) {
|
|
const preserved = compactRecords(compact, compact.length);
|
|
const preservedKeys = new Set(preserved.map(compactRecordKey));
|
|
const recovered = compactRecords(archives, archives.length)
|
|
.filter((record) => !preservedKeys.has(compactRecordKey(record)));
|
|
return compactRecords([...preserved, ...recovered], maxEdges);
|
|
}
|
|
function fitCompactRecords(records, maxBytes) {
|
|
const kept = [];
|
|
const lines = [];
|
|
let bytes = 0;
|
|
for (const record of records) {
|
|
const line = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
|
|
if (bytes + line.length > maxBytes)
|
|
break;
|
|
kept.push(record);
|
|
lines.push(line);
|
|
bytes += line.length;
|
|
}
|
|
return { records: kept, payload: Buffer.concat(lines, bytes) };
|
|
}
|
|
function emptyDiagnostics() {
|
|
return { bytesRead: 0, recordsRead: 0, corruptLines: 0, oversizedLines: 0, scopeRejected: 0, provenanceRejected: 0, truncated: false, recovery: 'healthy' };
|
|
}
|
|
function mergeDiagnostics(target, source) {
|
|
target.bytesRead += source.bytesRead;
|
|
target.recordsRead += source.recordsRead;
|
|
target.corruptLines += source.corruptLines;
|
|
target.oversizedLines += source.oversizedLines;
|
|
target.scopeRejected += source.scopeRejected;
|
|
target.provenanceRejected += source.provenanceRejected;
|
|
target.truncated ||= source.truncated;
|
|
if (source.busy === true)
|
|
target.busy = true;
|
|
if (target.recovery !== 'recovered' && source.recovery !== 'healthy')
|
|
target.recovery = source.recovery;
|
|
}
|
|
function emptyHealth() {
|
|
return {
|
|
recovery: 'empty',
|
|
compactedRecords: 0,
|
|
activeRecords: 0,
|
|
corruptLines: 0,
|
|
oversizedLines: 0,
|
|
oversizedFiles: 0,
|
|
archives: 0,
|
|
};
|
|
}
|
|
function fileExceeds(path, maxBytes) {
|
|
try {
|
|
const stat = lstatSync(path);
|
|
return stat.isFile() && !stat.isSymbolicLink() && stat.size > maxBytes;
|
|
}
|
|
catch {
|
|
return false;
|
|
}
|
|
}
|
|
function openNoFollow(path, flags, mode) {
|
|
return openSync(path, flags | constants.O_NOFOLLOW, mode);
|
|
}
|
|
function writeAll(fd, payload, position = null) {
|
|
let offset = 0;
|
|
while (offset < payload.length) {
|
|
const written = writeSync(fd, payload, offset, payload.length - offset, position === null ? null : position + offset);
|
|
if (written <= 0)
|
|
throw new Error('memory_graph_write_failed');
|
|
offset += written;
|
|
}
|
|
}
|
|
function sameGraphFileIdentity(left, right) {
|
|
const hasStableFileId = left.dev !== 0n || left.ino !== 0n || right.dev !== 0n || right.ino !== 0n;
|
|
if (hasStableFileId)
|
|
return left.dev === right.dev && left.ino === right.ino;
|
|
return left.birthtimeNs === right.birthtimeNs && left.mode === right.mode;
|
|
}
|
|
function sameGraphFileSnapshot(left, right) {
|
|
return sameGraphFileIdentity(left, right)
|
|
&& left.mode === right.mode
|
|
&& left.size === right.size
|
|
&& left.mtimeNs === right.mtimeNs
|
|
&& left.ctimeNs === right.ctimeNs;
|
|
}
|
|
function serializeGraphFileSnapshot(stat) {
|
|
return {
|
|
dev: String(stat.dev),
|
|
ino: String(stat.ino),
|
|
birthtimeNs: String(stat.birthtimeNs),
|
|
mode: String(stat.mode),
|
|
size: String(stat.size),
|
|
mtimeNs: String(stat.mtimeNs),
|
|
ctimeNs: String(stat.ctimeNs),
|
|
};
|
|
}
|
|
function serializedIdentityMatches(snapshot, stat) {
|
|
const hasStableFileId = snapshot.dev !== '0' || snapshot.ino !== '0'
|
|
|| stat.dev !== 0n || stat.ino !== 0n;
|
|
if (hasStableFileId)
|
|
return snapshot.dev === String(stat.dev) && snapshot.ino === String(stat.ino);
|
|
return snapshot.birthtimeNs === String(stat.birthtimeNs) && snapshot.mode === String(stat.mode);
|
|
}
|
|
function serializedSnapshotMatches(snapshot, stat) {
|
|
return serializedIdentityMatches(snapshot, stat)
|
|
&& snapshot.mode === String(stat.mode)
|
|
&& snapshot.size === String(stat.size)
|
|
&& snapshot.mtimeNs === String(stat.mtimeNs)
|
|
&& snapshot.ctimeNs === String(stat.ctimeNs);
|
|
}
|
|
function parseGraphFileSnapshot(value) {
|
|
if (!isObject(value))
|
|
return null;
|
|
const fields = ['dev', 'ino', 'birthtimeNs', 'mode', 'size', 'mtimeNs', 'ctimeNs'];
|
|
for (const field of fields) {
|
|
const candidate = value[field];
|
|
if (typeof candidate !== 'string' || !/^\d{1,40}$/.test(candidate))
|
|
return null;
|
|
}
|
|
return {
|
|
dev: value['dev'],
|
|
ino: value['ino'],
|
|
birthtimeNs: value['birthtimeNs'],
|
|
mode: value['mode'],
|
|
size: value['size'],
|
|
mtimeNs: value['mtimeNs'],
|
|
ctimeNs: value['ctimeNs'],
|
|
};
|
|
}
|
|
function parseEpochBaseline(value) {
|
|
if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, 'active')
|
|
|| !Object.prototype.hasOwnProperty.call(value, 'compact')
|
|
|| !Array.isArray(value['archives']))
|
|
return null;
|
|
let active = null;
|
|
if (value['active'] !== null) {
|
|
if (!isObject(value['active']))
|
|
return null;
|
|
const snapshot = parseGraphFileSnapshot(value['active']['snapshot']);
|
|
const offset = value['active']['offset'];
|
|
if (!snapshot || !Number.isSafeInteger(offset) || Number(offset) < 0
|
|
|| snapshot.size !== String(offset))
|
|
return null;
|
|
active = { snapshot, offset: Number(offset) };
|
|
}
|
|
const compact = value['compact'] === null
|
|
? null
|
|
: parseGraphFileSnapshot(value['compact']);
|
|
if (value['compact'] !== null && !compact)
|
|
return null;
|
|
const hasRecovery = Object.prototype.hasOwnProperty.call(value, 'recovery');
|
|
const recovery = !hasRecovery || value['recovery'] === null
|
|
? null
|
|
: parseGraphFileSnapshot(value['recovery']);
|
|
if (hasRecovery && value['recovery'] !== null && !recovery)
|
|
return null;
|
|
const archives = [];
|
|
const names = new Set();
|
|
for (const entry of value['archives']) {
|
|
if (!isObject(entry) || typeof entry['name'] !== 'string'
|
|
|| !ARCHIVE_PATTERN.test(entry['name']) || names.has(entry['name']))
|
|
return null;
|
|
const snapshot = parseGraphFileSnapshot(entry['snapshot']);
|
|
if (!snapshot)
|
|
return null;
|
|
names.add(entry['name']);
|
|
archives.push({ name: entry['name'], snapshot });
|
|
}
|
|
return { active, compact, ...(hasRecovery ? { recovery } : {}), archives };
|
|
}
|
|
function assertPathMissing(path, reason) {
|
|
try {
|
|
lstatSync(path);
|
|
}
|
|
catch (error) {
|
|
if (isObject(error) && error['code'] === 'ENOENT')
|
|
return;
|
|
throw new Error(reason);
|
|
}
|
|
throw new Error(reason);
|
|
}
|
|
function recordEpochId(record) {
|
|
return record.epochId ?? LEGACY_EPOCH_ID;
|
|
}
|
|
function readFdBytes(fd, length, position) {
|
|
const buffer = Buffer.alloc(length);
|
|
let offset = 0;
|
|
while (offset < length) {
|
|
const read = readSync(fd, buffer, offset, length - offset, position + offset);
|
|
if (read === 0)
|
|
break;
|
|
offset += read;
|
|
}
|
|
return offset === length ? buffer : buffer.subarray(0, offset);
|
|
}
|
|
function scopeHash(value) {
|
|
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
}
|
|
function safeToken(value, maxChars) {
|
|
if (typeof value !== 'string')
|
|
return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 && trimmed.length <= maxChars ? trimmed : null;
|
|
}
|
|
function safeEpochId(value) {
|
|
const token = safeToken(value, 128);
|
|
return token && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(token) ? token : null;
|
|
}
|
|
function boundedOperatorCount(value) {
|
|
const count = Number(value);
|
|
return Number.isFinite(count) && count > 0 ? Math.min(1_000_000, Math.floor(count)) : 0;
|
|
}
|
|
function positiveCount(value) {
|
|
const count = Number(value);
|
|
return Number.isFinite(count) && count > 0 ? Math.min(1_000_000, Math.floor(count)) : undefined;
|
|
}
|
|
function positiveInt(value, fallback) {
|
|
const parsed = Math.floor(Number(value));
|
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
function clampScore(value) {
|
|
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
|
}
|
|
function validIso(value) {
|
|
const parsed = Date.parse(value);
|
|
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
}
|
|
function isObject(value) {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
} |