Modernize Holepunch stack for HyperDB 6 and current replication APIs
Align P2NS with current Autopass, Hypercore, Hyperdrive, and Protomux behavior: add plugin DB recovery for incompatible persisted HyperDB data, stop unconditional spec rebuilds via fingerprinting, update SDK replication paths, and align proxy invite channels with p2ns.core-* protocols without replicating Autopass over the p2ns topic.
This commit is contained in:
@@ -1793,7 +1793,7 @@ const updated = sdk.db.updated('@example/members', { name: 'Alice' });
|
||||
// Returns: boolean
|
||||
|
||||
// Reload the internal snapshot
|
||||
sdk.db.reload();
|
||||
sdk.db.update(); // refreshes snapshot (HyperDB 6; sdk.db.reload() also calls update())
|
||||
|
||||
// Database properties
|
||||
if (sdk.db.closed) { /* ... */ }
|
||||
|
||||
@@ -348,8 +348,8 @@ function createPluginChannelsForPeer(peerId, conn, mux) {
|
||||
logDebug('ChannelManager', `Connection invalid for peer ${peerId.substring(0, 16)}..., skipping channel creation`);
|
||||
continue;
|
||||
}
|
||||
if (!mux || mux.destroyed) {
|
||||
logDebug('ChannelManager', `Mux invalid for peer ${peerId.substring(0, 16)}..., skipping channel creation`);
|
||||
if (!mux || (conn && conn.destroyed)) {
|
||||
logDebug('ChannelManager', `Mux/connection invalid for peer ${peerId.substring(0, 16)}..., skipping channel creation`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -808,45 +808,45 @@ async function waitForChannelOpen(peerChannel, timeout = 5000, checkInterval = 1
|
||||
* @param {number} checkInterval - Check interval in milliseconds (default: 100)
|
||||
* @returns {Promise<boolean>} True if channel is open bidirectionally, false if timeout
|
||||
*/
|
||||
async function waitForBidirectionalOpen(peerChannel, timeout = 2000, checkInterval = 100) {
|
||||
async function waitForBidirectionalOpen(peerChannel, timeout = 2000) {
|
||||
if (!peerChannel || !peerChannel.channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Try to get peerId from the channel info or connection
|
||||
let peerId = 'unknown';
|
||||
if (peerChannel.conn && peerChannel.conn.remotePublicKey) {
|
||||
peerId = peerChannel.conn.remotePublicKey.toString('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
logDebug('ChannelManager', `Waiting for bidirectional open on channel for peer ${peerId}..., timeout: ${timeout}ms`);
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Trust protomux's channel.opened as authoritative
|
||||
// When channel.opened is true, the channel is fully bidirectional
|
||||
if (peerChannel.channel.opened) {
|
||||
// Sync our tracked state with protomux state
|
||||
peerChannel.localOpened = true;
|
||||
peerChannel.remoteOpened = true;
|
||||
const elapsed = Date.now() - startTime;
|
||||
logDebug('ChannelManager', `Channel became bidirectional for peer ${peerId}... after ${elapsed}ms`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Protomux 3 may fire onopen before channel.opened flips true
|
||||
if (peerChannel.localOpened && peerChannel.remoteOpened) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wait before checking again
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
if (peerChannel.channel.opened) {
|
||||
peerChannel.localOpened = true;
|
||||
peerChannel.remoteOpened = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
logDebug('ChannelManager', `Channel not bidirectional for peer ${peerId}... within ${timeout}ms (waited ${elapsed}ms) - proceeding anyway (protomux will queue)`);
|
||||
return false;
|
||||
if (peerChannel.localOpened && peerChannel.remoteOpened) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logDebug('ChannelManager', `Waiting for channel fullyOpened for peer ${peerId}... (${timeout}ms)`);
|
||||
|
||||
try {
|
||||
const opened = await Promise.race([
|
||||
peerChannel.channel.fullyOpened(),
|
||||
new Promise((resolve) => setTimeout(() => resolve(false), timeout))
|
||||
]);
|
||||
|
||||
if (opened) {
|
||||
peerChannel.localOpened = true;
|
||||
peerChannel.remoteOpened = true;
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug('ChannelManager', `fullyOpened() error for peer ${peerId}...: ${err.message}`);
|
||||
}
|
||||
|
||||
logDebug('ChannelManager', `Channel not fully open for peer ${peerId}... within ${timeout}ms`);
|
||||
return peerChannel.channel.opened || false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -959,9 +959,6 @@ function sendToPeer(pluginDomain, protocol, peerId, data, waitTimeout = 2000) {
|
||||
// Wait a brief moment for channel to reopen, then continue
|
||||
}
|
||||
|
||||
// Try to send the message
|
||||
// Protomux channels can send messages even if not fully opened - they will be queued
|
||||
// Only fail if the channel or message object doesn't exist
|
||||
try {
|
||||
if (!peerChannel.channel) {
|
||||
logDebug('ChannelManager', `Channel object missing for peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol}`);
|
||||
@@ -972,16 +969,21 @@ function sendToPeer(pluginDomain, protocol, peerId, data, waitTimeout = 2000) {
|
||||
logDebug('ChannelManager', `Message handler missing for peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send the message - protomux will queue it if channel isn't fully open yet
|
||||
peerChannel.message.send(data);
|
||||
|
||||
if (peerChannel.channel.opened) {
|
||||
logDebug('ChannelManager', `Sent message to peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol} (channel open)`);
|
||||
} else {
|
||||
logDebug('ChannelManager', `Queued message for peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol} (channel opening)`);
|
||||
|
||||
if (peerChannel.channel.closed || peerChannel.conn?.destroyed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
const sent = peerChannel.message.send(data);
|
||||
if (!sent) {
|
||||
logDebug('ChannelManager', `Send backpressure on ${pluginDomain}-${protocol} for peer ${peerId.substring(0, 16)}...`);
|
||||
if (handler) {
|
||||
attemptImmediateReopen(pluginDomain, protocol, peerId, peerChannel, handler);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
logDebug('ChannelManager', `Sent message to peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError('ChannelManager', `Error sending message to peer ${peerId.substring(0, 16)}...: ${err.message}`);
|
||||
@@ -1033,19 +1035,8 @@ async function sendToPeerAsync(pluginDomain, protocol, peerId, data, waitTimeout
|
||||
// Check if channel is already open bidirectionally
|
||||
const isBidirectional = peerChannel.channel && peerChannel.channel.opened;
|
||||
|
||||
// Optionally wait for bidirectional opening (but don't block if it times out)
|
||||
// Protomux will queue messages even if channel isn't fully open
|
||||
if (!isBidirectional && waitTimeout > 0) {
|
||||
logDebug('ChannelManager', `Channel ${pluginDomain}-${protocol} not fully open for peer ${peerId.substring(0, 16)}..., waiting up to ${waitTimeout}ms`);
|
||||
const opened = await waitForBidirectionalOpen(peerChannel, waitTimeout);
|
||||
if (opened) {
|
||||
logDebug('ChannelManager', `Channel ${pluginDomain}-${protocol} is now bidirectional for peer ${peerId.substring(0, 16)}...`);
|
||||
} else {
|
||||
// Channel not fully open yet, but protomux can queue messages
|
||||
logDebug('ChannelManager', `Channel ${pluginDomain}-${protocol} not fully open yet for peer ${peerId.substring(0, 16)}..., sending anyway (protomux will queue)`);
|
||||
}
|
||||
} else if (!isBidirectional) {
|
||||
logDebug('ChannelManager', `Channel ${pluginDomain}-${protocol} not fully open for peer ${peerId.substring(0, 16)}..., sending anyway (protomux will queue)`);
|
||||
await waitForBidirectionalOpen(peerChannel, waitTimeout);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1058,14 +1049,21 @@ async function sendToPeerAsync(pluginDomain, protocol, peerId, data, waitTimeout
|
||||
logError('ChannelManager', `Message handler missing for peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send message - protomux will queue if channel isn't fully open
|
||||
peerChannel.message.send(data);
|
||||
if (isBidirectional) {
|
||||
logDebug('ChannelManager', `Sent message to peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol} (channel open)`);
|
||||
} else {
|
||||
logDebug('ChannelManager', `Queued message for peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol} (channel opening)`);
|
||||
|
||||
if (peerChannel.channel.closed || peerChannel.conn?.destroyed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sent = peerChannel.message.send(data);
|
||||
if (!sent) {
|
||||
logDebug('ChannelManager', `Send backpressure on ${pluginDomain}-${protocol} for peer ${peerId.substring(0, 16)}...`);
|
||||
if (handler) {
|
||||
attemptImmediateReopen(pluginDomain, protocol, peerId, peerChannel, handler);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
logDebug('ChannelManager', `Sent message to peer ${peerId.substring(0, 16)}... on ${pluginDomain}-${protocol}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError('ChannelManager', `Error sending message to peer ${peerId.substring(0, 16)}...: ${err.message}`);
|
||||
@@ -1112,9 +1110,12 @@ function broadcastToPeers(pluginDomain, protocol, data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send message - protomux will queue if channel not fully open
|
||||
peerChannel.message.send(data);
|
||||
sentCount++;
|
||||
if (peerChannel.channel.closed || peerChannel.conn?.destroyed) {
|
||||
continue;
|
||||
}
|
||||
if (peerChannel.message.send(data)) {
|
||||
sentCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
logError('ChannelManager', `Error broadcasting to peer ${peerId.substring(0, 16)}...: ${err.message}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
|
||||
const { getDatabaseWithRecovery } = require('./db-recovery');
|
||||
|
||||
// Per-plugin corestore instances
|
||||
const pluginStores = new Map();
|
||||
@@ -494,9 +495,17 @@ function getPluginCore(pluginDomain) {
|
||||
return pluginCores.get(pluginDomain) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create database with incompatible-data recovery (sanity scan + local wipe).
|
||||
*/
|
||||
async function getDatabaseSafe(pluginDomain, pluginDir, dbDir) {
|
||||
return getDatabaseWithRecovery(pluginDomain, pluginDir, dbDir, getDatabase);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeStore,
|
||||
getDatabase,
|
||||
getDatabaseSafe,
|
||||
closeDatabase,
|
||||
getDatabaseInstance,
|
||||
closeAllDatabases,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Plugin HyperDB recovery helpers — detect incompatible persisted data and reset safely.
|
||||
*/
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { logInfo, logWarn, logError, logDebug } = require('../infrastructure/logger');
|
||||
|
||||
const INCOMPATIBLE_PATTERNS = [
|
||||
/unknown collection type/i,
|
||||
/unsupported version/i,
|
||||
/decoding error/i,
|
||||
/cannot read properties of undefined \(reading 'decode'\)/i,
|
||||
/invalid collection/i,
|
||||
/bad collection type/i
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {Error|string} err
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isIncompatibleDbError(err) {
|
||||
const message = typeof err === 'string' ? err : (err && err.message) || '';
|
||||
return INCOMPATIBLE_PATTERNS.some((re) => re.test(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pluginDomain
|
||||
* @param {string} pluginDir
|
||||
* @returns {string}
|
||||
*/
|
||||
function getPluginDbStorageDir(pluginDomain, pluginDir) {
|
||||
return path.join(pluginDir, 'db');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe plugin local HyperDB storage and clear in-memory instances.
|
||||
* @param {string} pluginDomain
|
||||
* @param {string} pluginDir
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.reason]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function resetPluginDatabaseStorage(pluginDomain, pluginDir, options = {}) {
|
||||
const reason = options.reason || 'incompatible persisted data';
|
||||
const dbManager = require('./db-manager');
|
||||
const dbDir = getPluginDbStorageDir(pluginDomain, pluginDir);
|
||||
|
||||
logWarn(
|
||||
'DBRecovery',
|
||||
`Resetting plugin database for ${pluginDomain} (${reason}). Removing ${dbDir}`
|
||||
);
|
||||
|
||||
try {
|
||||
await dbManager.closeDatabase(pluginDomain, false);
|
||||
} catch (err) {
|
||||
logDebug('DBRecovery', `closeDatabase during reset: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(dbDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logError('DBRecovery', `Failed to remove db dir for ${pluginDomain}: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(dbDir, { recursive: true });
|
||||
logInfo('DBRecovery', `Plugin database storage reset for ${pluginDomain}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight sanity scan — iterate one entry per collection to catch decode errors early.
|
||||
* @param {Object} db - HyperDB instance
|
||||
* @param {Object} def - Database definition
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sanityScanDatabase(db, def) {
|
||||
if (!db || db.closed || !def || !Array.isArray(def.collections)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.ready();
|
||||
if (typeof db.update === 'function') {
|
||||
db.update();
|
||||
}
|
||||
|
||||
for (const collection of def.collections) {
|
||||
const collectionName = collection && collection.name;
|
||||
if (!collectionName) continue;
|
||||
|
||||
try {
|
||||
await db.find(collectionName, {}, { limit: 1 }).toArray();
|
||||
} catch (err) {
|
||||
if (isIncompatibleDbError(err)) {
|
||||
throw err;
|
||||
}
|
||||
logDebug('DBRecovery', `Sanity scan skipped collection ${collectionName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize DB with sanity scan; reset storage and retry once on incompatible data.
|
||||
* @param {string} pluginDomain
|
||||
* @param {string} pluginDir
|
||||
* @param {string} specDbDir - HyperDB definition directory (index.js)
|
||||
* @param {Function} getDatabase - dbManager.getDatabase bound
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getDatabaseWithRecovery(pluginDomain, pluginDir, specDbDir, getDatabase) {
|
||||
let retried = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const db = await getDatabase(pluginDomain, pluginDir, specDbDir);
|
||||
|
||||
const defPath = path.join(specDbDir, 'index.js');
|
||||
delete require.cache[require.resolve(defPath)];
|
||||
const def = require(defPath);
|
||||
const definition = def && def.default ? def.default : def;
|
||||
|
||||
await sanityScanDatabase(db, definition);
|
||||
return db;
|
||||
} catch (err) {
|
||||
if (!retried && isIncompatibleDbError(err)) {
|
||||
retried = true;
|
||||
logWarn(
|
||||
'DBRecovery',
|
||||
`Incompatible HyperDB data for ${pluginDomain}: ${err.message}. Wiping local db/ and recreating.`
|
||||
);
|
||||
logWarn(
|
||||
'DBRecovery',
|
||||
`If this persists after reset, bump version in plugin config.json to rotate the replicated core.`
|
||||
);
|
||||
await resetPluginDatabaseStorage(pluginDomain, pluginDir, { reason: err.message });
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isIncompatibleDbError,
|
||||
getPluginDbStorageDir,
|
||||
resetPluginDatabaseStorage,
|
||||
sanityScanDatabase,
|
||||
getDatabaseWithRecovery
|
||||
};
|
||||
@@ -288,96 +288,52 @@ function handleConnection(pluginDomain, driveName, conn) {
|
||||
logWarn('DriveReplicationManager', `Download error for ${driveKey} from peer ${peerId.slice(0, 16)}: ${downloadErr.message}`);
|
||||
}
|
||||
|
||||
// Also update and download the drive's metadata core (similar to HyperDB core updates)
|
||||
// Hyperdrive uses a metadata core for the file system structure
|
||||
// Metadata core (Hyperdrive 13: drive.core is a property)
|
||||
try {
|
||||
if (state.drive.core && typeof state.drive.core === 'function') {
|
||||
const metadataCore = state.drive.core();
|
||||
if (metadataCore && !metadataCore.closed) {
|
||||
await metadataCore.ready();
|
||||
const oldCoreLength = metadataCore.length;
|
||||
await metadataCore.update({ wait: true });
|
||||
const newCoreLength = metadataCore.length;
|
||||
|
||||
logInfo('DriveReplicationManager', `Metadata core for ${driveKey}: length ${oldCoreLength} -> ${newCoreLength}`);
|
||||
|
||||
// Always download core blocks if there's content, even if length unchanged
|
||||
// This ensures all blocks are available locally
|
||||
if (newCoreLength > 0) {
|
||||
// Download all blocks (not just new ones) to ensure nothing is missing
|
||||
metadataCore.download({ start: 0, end: newCoreLength });
|
||||
logInfo('DriveReplicationManager', `Downloading all metadata core blocks for ${driveKey} from peer ${peerId.slice(0, 16)}: 0 to ${newCoreLength}`);
|
||||
|
||||
// Wait for core download to complete
|
||||
await new Promise((resolve) => {
|
||||
const checkDownload = setInterval(() => {
|
||||
const downloaded = metadataCore.downloaded(0, newCoreLength);
|
||||
if (downloaded >= newCoreLength) {
|
||||
clearInterval(checkDownload);
|
||||
logInfo('DriveReplicationManager', `Metadata core download completed for ${driveKey} from peer ${peerId.slice(0, 16)} (${downloaded}/${newCoreLength} blocks)`);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Timeout after 15 seconds for core download
|
||||
setTimeout(() => {
|
||||
clearInterval(checkDownload);
|
||||
const downloaded = metadataCore.downloaded(0, newCoreLength);
|
||||
logWarn('DriveReplicationManager', `Metadata core download timeout for ${driveKey} from peer ${peerId.slice(0, 16)} (${downloaded}/${newCoreLength} blocks downloaded, continuing in background)`);
|
||||
resolve();
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
const metadataCore = state.drive.core;
|
||||
if (metadataCore && !metadataCore.closed) {
|
||||
await metadataCore.ready();
|
||||
const oldCoreLength = metadataCore.length;
|
||||
await metadataCore.update({ wait: true });
|
||||
const newCoreLength = metadataCore.length;
|
||||
|
||||
logInfo('DriveReplicationManager', `Metadata core for ${driveKey}: length ${oldCoreLength} -> ${newCoreLength}`);
|
||||
|
||||
if (newCoreLength > 0) {
|
||||
logInfo('DriveReplicationManager', `Downloading metadata core blocks for ${driveKey}: 0 to ${newCoreLength}`);
|
||||
const dl = metadataCore.download({ start: 0, end: newCoreLength });
|
||||
await Promise.race([
|
||||
dl.done(),
|
||||
new Promise((resolve) => setTimeout(resolve, 15000))
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (coreErr) {
|
||||
// Core update errors are not critical
|
||||
logDebug('DriveReplicationManager', `Error updating metadata core for ${driveKey}: ${coreErr.message}`);
|
||||
}
|
||||
|
||||
// Also download content core blocks (file data)
|
||||
// Hyperdrive has both metadata core (file structure) and content core (file data)
|
||||
|
||||
// Content / blobs core (Hyperdrive 13: await drive.getBlobs())
|
||||
try {
|
||||
if (state.drive.content && typeof state.drive.content === 'function') {
|
||||
const contentCore = state.drive.content();
|
||||
if (contentCore && !contentCore.closed) {
|
||||
await contentCore.ready();
|
||||
const oldContentLength = contentCore.length;
|
||||
await contentCore.update({ wait: true });
|
||||
const newContentLength = contentCore.length;
|
||||
|
||||
logInfo('DriveReplicationManager', `Content core for ${driveKey}: length ${oldContentLength} -> ${newContentLength}`);
|
||||
|
||||
// Download content core blocks if there's content
|
||||
if (newContentLength > 0) {
|
||||
// Download all content blocks to ensure file data is available
|
||||
contentCore.download({ start: 0, end: newContentLength });
|
||||
logInfo('DriveReplicationManager', `Downloading all content core blocks for ${driveKey} from peer ${peerId.slice(0, 16)}: 0 to ${newContentLength}`);
|
||||
|
||||
// Wait for content core download to complete
|
||||
await new Promise((resolve) => {
|
||||
const checkDownload = setInterval(() => {
|
||||
const downloaded = contentCore.downloaded(0, newContentLength);
|
||||
if (downloaded >= newContentLength) {
|
||||
clearInterval(checkDownload);
|
||||
logInfo('DriveReplicationManager', `Content core download completed for ${driveKey} from peer ${peerId.slice(0, 16)} (${downloaded}/${newContentLength} blocks)`);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Timeout after 30 seconds for content core download (can be large)
|
||||
setTimeout(() => {
|
||||
clearInterval(checkDownload);
|
||||
const downloaded = contentCore.downloaded(0, newContentLength);
|
||||
logWarn('DriveReplicationManager', `Content core download timeout for ${driveKey} from peer ${peerId.slice(0, 16)} (${downloaded}/${newContentLength} blocks downloaded, continuing in background)`);
|
||||
resolve();
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
const blobs = await state.drive.getBlobs();
|
||||
const contentCore = blobs && blobs.core;
|
||||
if (contentCore && !contentCore.closed) {
|
||||
await contentCore.ready();
|
||||
const oldContentLength = contentCore.length;
|
||||
await contentCore.update({ wait: true });
|
||||
const newContentLength = contentCore.length;
|
||||
|
||||
logInfo('DriveReplicationManager', `Content core for ${driveKey}: length ${oldContentLength} -> ${newContentLength}`);
|
||||
|
||||
if (newContentLength > 0) {
|
||||
logInfo('DriveReplicationManager', `Downloading content core blocks for ${driveKey}: 0 to ${newContentLength}`);
|
||||
const dl = contentCore.download({ start: 0, end: newContentLength });
|
||||
await Promise.race([
|
||||
dl.done(),
|
||||
new Promise((resolve) => setTimeout(resolve, 30000))
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (contentErr) {
|
||||
// Content core errors are not critical
|
||||
logDebug('DriveReplicationManager', `Error updating content core for ${driveKey}: ${contentErr.message}`);
|
||||
}
|
||||
} catch (downloadErr) {
|
||||
|
||||
@@ -11,6 +11,11 @@ const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
|
||||
const {
|
||||
computeSpecFingerprint,
|
||||
readStoredFingerprint,
|
||||
writeStoredFingerprint
|
||||
} = require('./hyperdb-spec-hash');
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
@@ -73,6 +78,7 @@ async function buildSchemaFromConfig(pluginDir, config) {
|
||||
// Build HyperDB definitions
|
||||
await buildHyperDBDefinition(pluginDir, schemaDir, dbDir, hyperdbConfig);
|
||||
|
||||
await writeSpecFingerprint(pluginDir, config, { schemaDir, dbDir });
|
||||
logInfo('HyperDBBuilder', `Built schemas for plugin in ${pluginDir}`);
|
||||
|
||||
return { schemaDir, dbDir };
|
||||
@@ -233,6 +239,18 @@ async function buildHyperDBDefinition(pluginDir, schemaDir, dbDir, hyperdbConfig
|
||||
logDebug('HyperDBBuilder', `HyperDB definitions saved to ${dbDir}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write spec fingerprint after a successful build
|
||||
* @param {string} pluginDir
|
||||
* @param {Object} config
|
||||
* @param {{ schemaDir: string, dbDir: string }} specDirs
|
||||
*/
|
||||
async function writeSpecFingerprint(pluginDir, config, specDirs) {
|
||||
const fingerprint = await computeSpecFingerprint(pluginDir, config, specDirs);
|
||||
await writeStoredFingerprint(specDirs.dbDir, fingerprint);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if schemas need to be rebuilt
|
||||
* @param {string} pluginDir - Plugin directory path
|
||||
@@ -260,9 +278,21 @@ async function needsRebuild(pluginDir, config) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For now, always rebuild to ensure consistency
|
||||
// In the future, could check file modification times vs config modification
|
||||
return true;
|
||||
const defPath = path.join(dbDir, 'index.js');
|
||||
try {
|
||||
await fs.access(defPath);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const current = await computeSpecFingerprint(pluginDir, config, { schemaDir, dbDir });
|
||||
const stored = await readStoredFingerprint(dbDir);
|
||||
if (!stored || stored !== current) {
|
||||
logDebug('HyperDBBuilder', `Spec fingerprint changed for ${config.domain || path.basename(pluginDir)} (rebuild needed)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Directories don't exist, need to build
|
||||
return true;
|
||||
@@ -282,12 +312,16 @@ async function ensureSchemaBuilt(pluginDir, config) {
|
||||
|
||||
const shouldRebuild = await needsRebuild(pluginDir, config);
|
||||
|
||||
const specDirs = resolveSpecDirs(pluginDir, config);
|
||||
|
||||
if (shouldRebuild) {
|
||||
logInfo('HyperDBBuilder', `Building HyperDB schemas for plugin in ${pluginDir}`);
|
||||
return await buildSchemaFromConfig(pluginDir, config);
|
||||
const built = await buildSchemaFromConfig(pluginDir, config);
|
||||
await writeSpecFingerprint(pluginDir, config, built);
|
||||
return built;
|
||||
}
|
||||
|
||||
return resolveSpecDirs(pluginDir, config);
|
||||
return specDirs;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -296,6 +330,7 @@ module.exports = {
|
||||
buildHyperDBDefinition,
|
||||
needsRebuild,
|
||||
ensureSchemaBuilt,
|
||||
resolveSpecDirs
|
||||
resolveSpecDirs,
|
||||
writeSpecFingerprint
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Hash plugin hyperdb config + spec sources to decide whether schemas need rebuilding.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function fileHash(filePath) {
|
||||
const data = await fs.readFile(filePath);
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
async function directoryHash(dirPath) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return '';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const nested = await directoryHash(full);
|
||||
if (nested) parts.push(`${entry.name}/:${nested}`);
|
||||
} else if (entry.isFile()) {
|
||||
const stat = await fs.stat(full);
|
||||
parts.push(`${entry.name}:${stat.mtimeMs}:${await fileHash(full)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pluginDir
|
||||
* @param {Object} config
|
||||
* @param {{ schemaDir: string, dbDir: string }} specDirs
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function computeSpecFingerprint(pluginDir, config, specDirs) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(JSON.stringify(config.hyperdb || {}));
|
||||
|
||||
const configPath = path.join(pluginDir, 'config.json');
|
||||
if (fsSync.existsSync(configPath)) {
|
||||
hash.update(await fileHash(configPath));
|
||||
}
|
||||
|
||||
const helpers = config.hyperdb && config.hyperdb.helpers;
|
||||
if (helpers) {
|
||||
const helpersPath = path.resolve(pluginDir, helpers);
|
||||
if (fsSync.existsSync(helpersPath)) {
|
||||
hash.update(await fileHash(helpersPath));
|
||||
}
|
||||
}
|
||||
|
||||
hash.update(await directoryHash(specDirs.schemaDir));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dbDir
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function readStoredFingerprint(dbDir) {
|
||||
try {
|
||||
return (await fs.readFile(path.join(dbDir, '.p2ns-spec-fingerprint'), 'utf8')).trim();
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dbDir
|
||||
* @param {string} fingerprint
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function writeStoredFingerprint(dbDir, fingerprint) {
|
||||
await fs.writeFile(path.join(dbDir, '.p2ns-spec-fingerprint'), fingerprint, 'utf8');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
computeSpecFingerprint,
|
||||
readStoredFingerprint,
|
||||
writeStoredFingerprint
|
||||
};
|
||||
@@ -217,7 +217,7 @@ async function initializePlugin(plugin) {
|
||||
|
||||
// Initialize SDK with plugin context (domain and config)
|
||||
// This will set global PLUGIN_DOMAIN from config.json
|
||||
sdk._initializePluginContext(plugin.domain, plugin.pluginDir, plugin.config);
|
||||
sdk._initializePluginContext(plugin.domain, plugin.pluginDir, plugin.config, plugin.dbConfig);
|
||||
// Initialize database if hyperdb config exists
|
||||
if (plugin.dbConfig) {
|
||||
try {
|
||||
@@ -306,7 +306,7 @@ async function initializePlugin(plugin) {
|
||||
|
||||
// Initialize database
|
||||
const dbManager = require('./db-manager');
|
||||
const db = await dbManager.getDatabase(plugin.domain, plugin.pluginDir, plugin.dbConfig.dbDir);
|
||||
const db = await dbManager.getDatabaseSafe(plugin.domain, plugin.pluginDir, plugin.dbConfig.dbDir);
|
||||
plugin.db = db;
|
||||
logInfo('PluginHandler', `Database initialized successfully for plugin ${plugin.domain}`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -129,6 +129,9 @@ async function handleConnection(pluginDomain, conn) {
|
||||
try {
|
||||
// Replicate the plugin's store over this connection
|
||||
// Note: Corestore handles connection multiplexing automatically
|
||||
if (typeof state.store.findingPeers === 'function') {
|
||||
state.store.findingPeers();
|
||||
}
|
||||
state.store.replicate(conn, { live: true });
|
||||
|
||||
// Track peer
|
||||
@@ -169,59 +172,31 @@ async function handleConnection(pluginDomain, conn) {
|
||||
|
||||
// Download any new data
|
||||
if (newLength > oldLength) {
|
||||
core.download({ start: oldLength, end: newLength });
|
||||
logInfo('ReplicationManager', `Downloading new data for ${pluginDomain} from peer ${peerId.slice(0, 16)}: ${oldLength} to ${newLength}`);
|
||||
|
||||
// Wait for download to complete
|
||||
await new Promise((resolve) => {
|
||||
let checkDownload = null;
|
||||
let downloadTimeout = null;
|
||||
|
||||
const cleanupTimers = () => {
|
||||
if (checkDownload) {
|
||||
clearInterval(checkDownload);
|
||||
resources.timers.delete(checkDownload);
|
||||
}
|
||||
if (downloadTimeout) {
|
||||
clearTimeout(downloadTimeout);
|
||||
resources.timers.delete(downloadTimeout);
|
||||
}
|
||||
conn.removeListener('close', cleanupTimers);
|
||||
conn.removeListener('error', cleanupTimers);
|
||||
};
|
||||
|
||||
checkDownload = setInterval(() => {
|
||||
try {
|
||||
if (resources.isClosed || core.closed) {
|
||||
cleanupTimers();
|
||||
try {
|
||||
const download = core.download({ start: oldLength, end: newLength });
|
||||
await Promise.race([
|
||||
download.done(),
|
||||
new Promise((resolve) => {
|
||||
const downloadTimeout = setTimeout(() => {
|
||||
logWarn('ReplicationManager', `Download timeout for ${pluginDomain} from peer ${peerId.slice(0, 16)}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const downloaded = core.downloaded(oldLength, newLength);
|
||||
if (downloaded >= (newLength - oldLength)) {
|
||||
cleanupTimers();
|
||||
logInfo('ReplicationManager', `Download completed for ${pluginDomain} from peer ${peerId.slice(0, 16)}`);
|
||||
}, 5000);
|
||||
resources.timers.add(downloadTimeout);
|
||||
conn.once('close', () => {
|
||||
clearTimeout(downloadTimeout);
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug('ReplicationManager', `Error checking download status for ${pluginDomain}: ${err.message}`);
|
||||
cleanupTimers();
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
resources.timers.add(checkDownload);
|
||||
|
||||
downloadTimeout = setTimeout(() => {
|
||||
cleanupTimers();
|
||||
logWarn('ReplicationManager', `Download timeout for ${pluginDomain} from peer ${peerId.slice(0, 16)}`);
|
||||
resolve();
|
||||
}, 5000);
|
||||
resources.timers.add(downloadTimeout);
|
||||
|
||||
// Ensure cleanup if connection closes during download check
|
||||
conn.once('close', cleanupTimers);
|
||||
conn.once('error', cleanupTimers);
|
||||
});
|
||||
});
|
||||
conn.once('error', () => {
|
||||
clearTimeout(downloadTimeout);
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
]);
|
||||
logInfo('ReplicationManager', `Download completed for ${pluginDomain} from peer ${peerId.slice(0, 16)}`);
|
||||
} catch (dlErr) {
|
||||
logDebug('ReplicationManager', `Download wait ended for ${pluginDomain}: ${dlErr.message}`);
|
||||
}
|
||||
}
|
||||
} else if (core && core.closed) {
|
||||
logDebug('ReplicationManager', `Core is closed for ${pluginDomain}, skipping update`);
|
||||
|
||||
+131
-175
@@ -63,12 +63,13 @@ let activePluginDomain = null;
|
||||
* @param {string} pluginDir - Plugin directory path
|
||||
* @param {Object} config - Plugin config object
|
||||
*/
|
||||
function _initializePluginContext(domain, pluginDir, config) {
|
||||
function _initializePluginContext(domain, pluginDir, config, dbConfig = null) {
|
||||
// Store in registry keyed by domain
|
||||
pluginRegistry.set(domain, {
|
||||
domain,
|
||||
pluginDir,
|
||||
config
|
||||
config,
|
||||
dbConfig
|
||||
});
|
||||
|
||||
// Set active domain for current request/operation
|
||||
@@ -95,6 +96,18 @@ function _getPluginDirForDomain(domain) {
|
||||
return ctx ? ctx.pluginDir : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HyperDB spec directory for a plugin (cache or plugin spec path).
|
||||
* @param {string} domain
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function _getPluginDbSpecDir(domain) {
|
||||
const ctx = pluginRegistry.get(domain);
|
||||
if (!ctx) return null;
|
||||
if (ctx.dbConfig && ctx.dbConfig.dbDir) return ctx.dbConfig.dbDir;
|
||||
return path.join(ctx.pluginDir, 'spec', 'hyperdb');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin domain - returns the active plugin domain or reads from config.json
|
||||
* @returns {string|null} Plugin domain or null
|
||||
@@ -3687,6 +3700,90 @@ const sdk = {
|
||||
* Provides HyperDB database access for plugins
|
||||
*/
|
||||
db: {
|
||||
/**
|
||||
* Reopen HyperDB session (preserves core for replication by default).
|
||||
* @param {boolean} preserveCore
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async _reopenDatabase(preserveCore = true) {
|
||||
const pluginDomain = _getPluginDomain();
|
||||
if (!pluginDomain) {
|
||||
throw new Error('Plugin domain not available');
|
||||
}
|
||||
|
||||
const dbManager = require('./db-manager');
|
||||
const pluginDir = _getPluginDirForDomain(pluginDomain);
|
||||
const dbDir = _getPluginDbSpecDir(pluginDomain);
|
||||
|
||||
if (!pluginDir || !dbDir) {
|
||||
throw new Error(`Plugin paths not found for ${pluginDomain}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await dbManager.closeDatabase(pluginDomain, preserveCore);
|
||||
} catch (closeErr) {
|
||||
logDebug('PluginSDK', `Error closing database during reopen: ${closeErr.message}`);
|
||||
}
|
||||
|
||||
const db = await dbManager.getDatabase(pluginDomain, pluginDir, dbDir);
|
||||
if (!db) {
|
||||
throw new Error('Failed to reopen database instance');
|
||||
}
|
||||
return db;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset local plugin DB storage and reopen (schema mismatch).
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async _resetAndReopenDatabase() {
|
||||
const pluginDomain = _getPluginDomain();
|
||||
const pluginDir = _getPluginDirForDomain(pluginDomain);
|
||||
const dbDir = _getPluginDbSpecDir(pluginDomain);
|
||||
if (!pluginDomain || !pluginDir || !dbDir) {
|
||||
throw new Error('Plugin domain not available');
|
||||
}
|
||||
|
||||
const { resetPluginDatabaseStorage } = require('./db-recovery');
|
||||
const dbManager = require('./db-manager');
|
||||
|
||||
await resetPluginDatabaseStorage(pluginDomain, pluginDir, { reason: 'SDK incompatible data recovery' });
|
||||
const db = await dbManager.getDatabaseSafe(pluginDomain, pluginDir, dbDir);
|
||||
if (!db) {
|
||||
throw new Error('Failed to recreate database after reset');
|
||||
}
|
||||
return db;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle incompatible persisted data or session errors during DB ops.
|
||||
* @param {Error} err
|
||||
* @param {Object|null} db
|
||||
* @param {number} attempt
|
||||
* @param {number} maxAttempts
|
||||
* @returns {Promise<Object|null>} New db instance to retry, or null to rethrow
|
||||
*/
|
||||
async _recoverFromDbError(err, db, attempt, maxAttempts) {
|
||||
const { isIncompatibleDbError } = require('./db-recovery');
|
||||
|
||||
if (isIncompatibleDbError(err) && attempt < maxAttempts) {
|
||||
logWarn('PluginSDK', `Incompatible database data: ${err.message}. Resetting local plugin DB...`);
|
||||
return this._resetAndReopenDatabase();
|
||||
}
|
||||
|
||||
const isSessionClosed = err.message && (
|
||||
err.message.includes('SESSION_CLOSED') ||
|
||||
err.message.includes('Database not available') ||
|
||||
err.message.includes('Database is closed')
|
||||
);
|
||||
|
||||
if (isSessionClosed && attempt < maxAttempts) {
|
||||
return this._reopenDatabase(true);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current plugin's database instance
|
||||
* @returns {Object|null} Database instance or null if not available
|
||||
@@ -3762,7 +3859,7 @@ const sdk = {
|
||||
throw new Error(`Plugin directory not found for ${pluginDomain}`);
|
||||
}
|
||||
|
||||
const dbDir = path.join(pluginDir, 'spec', 'hyperdb');
|
||||
const dbDir = _getPluginDbSpecDir(pluginDomain);
|
||||
logDebug('PluginSDK', `[db.ready] Recreating database at ${dbDir}...`);
|
||||
db = await dbManager.getDatabase(pluginDomain, pluginDir, dbDir);
|
||||
if (!db) {
|
||||
@@ -3827,49 +3924,11 @@ const sdk = {
|
||||
return;
|
||||
} catch (err) {
|
||||
logError('PluginSDK', `[db.insert] Attempt ${attempt} failed: ${err.message}`);
|
||||
const isSessionClosed = err.message && (
|
||||
err.message.includes('SESSION_CLOSED') ||
|
||||
err.message.includes('Database not available') ||
|
||||
err.message.includes('Database is closed')
|
||||
);
|
||||
|
||||
if (isSessionClosed && attempt < MAX_ATTEMPTS) {
|
||||
logWarn('PluginSDK', `[db.insert] Database session closed (attempt ${attempt}/${MAX_ATTEMPTS}), recreating database instance for ${pluginDomain}...`);
|
||||
|
||||
try {
|
||||
const dbManager = require('./db-manager');
|
||||
const existingCoreKey = dbManager.getPluginCoreKey(pluginDomain);
|
||||
if (!existingCoreKey) {
|
||||
logError('PluginSDK', `[db.insert] No existing core key found for ${pluginDomain}`);
|
||||
throw new Error('No existing core key found for plugin');
|
||||
}
|
||||
|
||||
try {
|
||||
await dbManager.closeDatabase(pluginDomain, true);
|
||||
} catch (closeErr) {
|
||||
logDebug('PluginSDK', `[db.insert] Error closing old database instance: ${closeErr.message}`);
|
||||
}
|
||||
|
||||
const pluginDir = _getPluginDirForDomain(pluginDomain);
|
||||
logDebug('PluginSDK', `[db.insert] pluginDir=${pluginDir}`);
|
||||
if (!pluginDir) {
|
||||
logError('PluginSDK', `[db.insert] Plugin directory not found for ${pluginDomain}`);
|
||||
throw new Error(`Plugin directory not found for ${pluginDomain}`);
|
||||
}
|
||||
|
||||
const dbDir = path.join(pluginDir, 'spec', 'hyperdb');
|
||||
logDebug('PluginSDK', `[db.insert] Recreating database at ${dbDir}...`);
|
||||
db = await dbManager.getDatabase(pluginDomain, pluginDir, dbDir);
|
||||
if (!db) {
|
||||
logError('PluginSDK', `[db.insert] Failed to recreate database instance`);
|
||||
throw new Error('Failed to recreate database instance');
|
||||
}
|
||||
|
||||
logInfo('PluginSDK', `Database instance recreated for ${pluginDomain}, retrying insert...`);
|
||||
continue;
|
||||
} catch (recreateErr) {
|
||||
logError('PluginSDK', `Failed to recreate database: ${recreateErr.message}`);
|
||||
}
|
||||
const recovered = await this._recoverFromDbError(err, db, attempt, MAX_ATTEMPTS);
|
||||
if (recovered) {
|
||||
db = recovered;
|
||||
logInfo('PluginSDK', `Database recovered for ${pluginDomain}, retrying insert...`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -3903,44 +3962,11 @@ const sdk = {
|
||||
}
|
||||
return await db.get(collection, query);
|
||||
} catch (err) {
|
||||
const isSessionClosed = err.message && (
|
||||
err.message.includes('SESSION_CLOSED') ||
|
||||
err.message.includes('Database not available') ||
|
||||
err.message.includes('Database is closed')
|
||||
);
|
||||
|
||||
if (isSessionClosed && attempt < MAX_ATTEMPTS) {
|
||||
logWarn('PluginSDK', `Database session closed during get (attempt ${attempt}/${MAX_ATTEMPTS}), recreating database instance for ${pluginDomain}...`);
|
||||
|
||||
try {
|
||||
const dbManager = require('./db-manager');
|
||||
const existingCoreKey = dbManager.getPluginCoreKey(pluginDomain);
|
||||
if (!existingCoreKey) {
|
||||
throw new Error('No existing core key found for plugin');
|
||||
}
|
||||
|
||||
try {
|
||||
await dbManager.closeDatabase(pluginDomain, true);
|
||||
} catch (closeErr) {
|
||||
logDebug('PluginSDK', `Error closing old database instance: ${closeErr.message}`);
|
||||
}
|
||||
|
||||
const pluginDir = _getPluginDirForDomain(pluginDomain);
|
||||
if (!pluginDir) {
|
||||
throw new Error(`Plugin directory not found for ${pluginDomain}`);
|
||||
}
|
||||
|
||||
const dbDir = path.join(pluginDir, 'spec', 'hyperdb');
|
||||
db = await dbManager.getDatabase(pluginDomain, pluginDir, dbDir);
|
||||
if (!db) {
|
||||
throw new Error('Failed to recreate database instance');
|
||||
}
|
||||
|
||||
logInfo('PluginSDK', `Database instance recreated for ${pluginDomain}, retrying get...`);
|
||||
continue;
|
||||
} catch (recreateErr) {
|
||||
logError('PluginSDK', `Failed to recreate database: ${recreateErr.message}`);
|
||||
}
|
||||
const recovered = await this._recoverFromDbError(err, db, attempt, MAX_ATTEMPTS);
|
||||
if (recovered) {
|
||||
db = recovered;
|
||||
logInfo('PluginSDK', `Database recovered for ${pluginDomain}, retrying get...`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -4004,59 +4030,11 @@ const sdk = {
|
||||
return results;
|
||||
|
||||
} catch (err) {
|
||||
const isSessionClosed = err.message && (
|
||||
err.message.includes('SESSION_CLOSED') ||
|
||||
err.message.includes('Database not available') ||
|
||||
err.message.includes('Database is closed')
|
||||
);
|
||||
|
||||
if (isSessionClosed && attempt < MAX_ATTEMPTS) {
|
||||
logWarn('PluginSDK', `Database session closed (attempt ${attempt}/${MAX_ATTEMPTS}), recreating database instance for ${pluginDomain}...`);
|
||||
|
||||
// Recreate database instance while preserving the core for replication
|
||||
try {
|
||||
const dbManager = require('./db-manager');
|
||||
|
||||
// Get the existing core key to ensure we use the same core
|
||||
const existingCoreKey = dbManager.getPluginCoreKey(pluginDomain);
|
||||
if (!existingCoreKey) {
|
||||
throw new Error('No existing core key found for plugin');
|
||||
}
|
||||
|
||||
// Close existing database instance but preserve the core for replication
|
||||
try {
|
||||
await dbManager.closeDatabase(pluginDomain, true); // preserveCore = true
|
||||
} catch (closeErr) {
|
||||
logDebug('PluginSDK', `Error closing old database instance: ${closeErr.message}`);
|
||||
}
|
||||
|
||||
// Get plugin directory for database initialization
|
||||
const pluginDir = _getPluginDirForDomain(pluginDomain);
|
||||
if (!pluginDir) {
|
||||
throw new Error(`Plugin directory not found for ${pluginDomain}`);
|
||||
}
|
||||
|
||||
const dbDir = path.join(pluginDir, 'spec', 'hyperdb');
|
||||
|
||||
// Create fresh database instance (getDatabase will reuse the existing core)
|
||||
db = await dbManager.getDatabase(pluginDomain, pluginDir, dbDir);
|
||||
if (!db) {
|
||||
throw new Error('Failed to recreate database instance');
|
||||
}
|
||||
|
||||
// Verify the core key matches
|
||||
const newCoreKey = dbManager.getPluginCoreKey(pluginDomain);
|
||||
if (!newCoreKey || !newCoreKey.equals(existingCoreKey)) {
|
||||
logWarn('PluginSDK', `Core key mismatch after recreation - this may break replication`);
|
||||
}
|
||||
|
||||
logInfo('PluginSDK', `Database instance recreated for ${pluginDomain} with core key ${existingCoreKey.toString('hex').slice(0, 16)}..., retrying query...`);
|
||||
continue;
|
||||
|
||||
} catch (recreateErr) {
|
||||
logError('PluginSDK', `Failed to recreate database: ${recreateErr.message}`);
|
||||
// Fall through to error handling
|
||||
}
|
||||
const recovered = await this._recoverFromDbError(err, db, attempt, MAX_ATTEMPTS);
|
||||
if (recovered) {
|
||||
db = recovered;
|
||||
logInfo('PluginSDK', `Database recovered for ${pluginDomain}, retrying query...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If it's a timeout error or we've exhausted retries, provide a clearer message
|
||||
@@ -4135,43 +4113,11 @@ const sdk = {
|
||||
err.message.includes('Database is closed')
|
||||
);
|
||||
|
||||
if (isSessionClosed && attempt < MAX_ATTEMPTS) {
|
||||
logWarn('PluginSDK', `[db.flush] Database session closed (attempt ${attempt}/${MAX_ATTEMPTS}), recreating database instance for ${pluginDomain}...`);
|
||||
|
||||
try {
|
||||
const dbManager = require('./db-manager');
|
||||
const existingCoreKey = dbManager.getPluginCoreKey(pluginDomain);
|
||||
if (!existingCoreKey) {
|
||||
logError('PluginSDK', `[db.flush] No existing core key found for ${pluginDomain}`);
|
||||
throw new Error('No existing core key found for plugin');
|
||||
}
|
||||
|
||||
try {
|
||||
await dbManager.closeDatabase(pluginDomain, true);
|
||||
} catch (closeErr) {
|
||||
logDebug('PluginSDK', `[db.flush] Error closing old database instance: ${closeErr.message}`);
|
||||
}
|
||||
|
||||
const pluginDir = _getPluginDirForDomain(pluginDomain);
|
||||
logDebug('PluginSDK', `[db.flush] pluginDir=${pluginDir}`);
|
||||
if (!pluginDir) {
|
||||
logError('PluginSDK', `[db.flush] Plugin directory not found for ${pluginDomain}`);
|
||||
throw new Error(`Plugin directory not found for ${pluginDomain}`);
|
||||
}
|
||||
|
||||
const dbDir = path.join(pluginDir, 'spec', 'hyperdb');
|
||||
logDebug('PluginSDK', `[db.flush] Recreating database at ${dbDir}...`);
|
||||
db = await dbManager.getDatabase(pluginDomain, pluginDir, dbDir);
|
||||
if (!db) {
|
||||
logError('PluginSDK', `[db.flush] Failed to recreate database instance`);
|
||||
throw new Error('Failed to recreate database instance');
|
||||
}
|
||||
|
||||
logInfo('PluginSDK', `[db.flush] Database instance recreated for ${pluginDomain}, retrying flush...`);
|
||||
continue;
|
||||
} catch (recreateErr) {
|
||||
logError('PluginSDK', `[db.flush] Failed to recreate database: ${recreateErr.message}`);
|
||||
}
|
||||
const recovered = await this._recoverFromDbError(err, db, attempt, MAX_ATTEMPTS);
|
||||
if (recovered) {
|
||||
db = recovered;
|
||||
logInfo('PluginSDK', `[db.flush] Database recovered for ${pluginDomain}, retrying flush...`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -4297,7 +4243,17 @@ const sdk = {
|
||||
if (!db) {
|
||||
return;
|
||||
}
|
||||
db.reload();
|
||||
if (typeof db.update === 'function') {
|
||||
db.update();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the internal snapshot from the underlying core (HyperDB 6).
|
||||
* @returns {void}
|
||||
*/
|
||||
update() {
|
||||
this.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -259,20 +259,13 @@ async function main() {
|
||||
// Configure Hyperswarm for better local network peer discovery
|
||||
const swarmOptions = {
|
||||
keyPair: state.keypair,
|
||||
// Disable ephemeral mode for persistent connections (helps with local network)
|
||||
ephemeral: false,
|
||||
// Increase connection timeout for local network peers
|
||||
maxPeers: parseInt(process.env.MAX_PEERS || '24', 10),
|
||||
// Enable connection retries
|
||||
queue: {
|
||||
multiplex: true
|
||||
}
|
||||
maxPeers: parseInt(process.env.MAX_PEERS || '24', 10)
|
||||
};
|
||||
|
||||
const swarm = new Hyperswarm(swarmOptions);
|
||||
state.swarm = swarm; // Store swarm in state for SDK access
|
||||
logInfo('Main', 'Hyperswarm instance created with persisted keypair');
|
||||
logInfo('Main', `Hyperswarm configured: maxPeers=${swarmOptions.maxPeers}, ephemeral=${swarmOptions.ephemeral}`);
|
||||
logInfo('Main', `Hyperswarm configured: maxPeers=${swarmOptions.maxPeers}`);
|
||||
|
||||
// Initialize replication manager for plugin databases
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "File Drop",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"domain": "file.drop",
|
||||
"enabled": false,
|
||||
"description": "Temporary 24-hour file storage with shareable links",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Global Profile",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"domain": "global.profile",
|
||||
"enabled": true,
|
||||
"description": "Universal user identity system for all P2NS plugins with P2P profile replication",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
const sdk = require('../../includes/plugins/sdk');
|
||||
const crypto = require('crypto');
|
||||
const { isIncompatibleDbError, dbErrorResponse } = require('./db-errors');
|
||||
|
||||
// Avatar sizes to generate
|
||||
const AVATAR_SIZES = [16, 32, 64, 128, 256, 512];
|
||||
@@ -36,6 +37,10 @@ async function getProfileFromDB(peerId) {
|
||||
const profile = await sdk.db.get('@profile/profiles', { peerId });
|
||||
return profile;
|
||||
} catch (err) {
|
||||
if (isIncompatibleDbError(err)) {
|
||||
sdk.log.warn('global.profile', `Incompatible DB data while reading profile: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
// Silently return null if database not initialized or timeout - this is expected during startup
|
||||
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('timeout'))) {
|
||||
sdk.log.debug('global.profile', `Database not ready for ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
|
||||
@@ -170,6 +175,10 @@ async function getAllProfilesFromDB() {
|
||||
sdk.log.debug('global.profile', `Found ${profiles?.length || 0} profiles in database`);
|
||||
return profiles || [];
|
||||
} catch (err) {
|
||||
if (isIncompatibleDbError(err)) {
|
||||
sdk.log.warn('global.profile', `Incompatible DB data while listing profiles: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
// Silently return empty array if database not initialized or timeout - this is expected during startup
|
||||
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('timeout') || err.message.includes('not available'))) {
|
||||
sdk.log.debug('global.profile', `Database not ready when getting all profiles: ${err.message}`);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared DB error handling for global.profile routes.
|
||||
*/
|
||||
|
||||
const { isIncompatibleDbError } = require('../../includes/plugins/db-recovery');
|
||||
|
||||
function isDbNotReadyError(err) {
|
||||
const msg = err && err.message ? err.message : '';
|
||||
return (
|
||||
msg.includes('Database not initialized') ||
|
||||
msg.includes('timeout') ||
|
||||
msg.includes('not ready')
|
||||
);
|
||||
}
|
||||
|
||||
function dbErrorResponse(err) {
|
||||
if (isIncompatibleDbError(err)) {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: 'Profile database is being reset after a schema upgrade',
|
||||
code: 'db_migration',
|
||||
hint: 'Retry in a few seconds. If this persists, delete plugin-sites/global.profile/db/ and restart P2NS.'
|
||||
}
|
||||
};
|
||||
}
|
||||
if (isDbNotReadyError(err)) {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: 'Profile database not ready',
|
||||
code: 'db_not_ready'
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: err.message || 'Database error' }
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isIncompatibleDbError,
|
||||
isDbNotReadyError,
|
||||
dbErrorResponse
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
const { dbErrorResponse } = require('../db-errors');
|
||||
|
||||
/**
|
||||
* Handle fields-related routes
|
||||
@@ -18,10 +19,8 @@ async function handleFieldsRoutes(req, res, path, query, method) {
|
||||
return sdk.router.json(res, { fields: fields || [] });
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error getting fields: ${err.message}`);
|
||||
if (err.message && err.message.includes('Database not initialized')) {
|
||||
return sdk.router.json(res, { fields: [], error: 'Database not ready' }, 503);
|
||||
}
|
||||
return sdk.router.error(res, 'Failed to retrieve fields', 500);
|
||||
const { status, body } = dbErrorResponse(err);
|
||||
return sdk.router.json(res, { fields: [], ...body }, status);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
const { getLocalPeerId } = require('../utils');
|
||||
const { getProfileFromDB, saveProfileToDB } = require('../database');
|
||||
const { dbErrorResponse } = require('../db-errors');
|
||||
const { broadcastProfileUpdate } = require('../websocket');
|
||||
|
||||
/**
|
||||
@@ -83,7 +84,8 @@ async function handleProfileRoutes(req, res, path, query, method) {
|
||||
}
|
||||
sdk.log.error('global.profile', `Error getting profile: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
return router.error(res, 'Failed to retrieve profile', 500, req);
|
||||
const { status, body } = dbErrorResponse(err);
|
||||
return router.json(res, body, status, req);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
const { getAllProfilesFromDB } = require('../database');
|
||||
const { dbErrorResponse } = require('../db-errors');
|
||||
|
||||
/**
|
||||
* Handle profiles-related routes
|
||||
@@ -102,7 +103,8 @@ async function handleProfilesRoutes(req, res, path, query, method) {
|
||||
});
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error getting profiles: ${err.message}`);
|
||||
return sdk.router.error(res, err.message, 500);
|
||||
const { status, body } = dbErrorResponse(err);
|
||||
return sdk.router.json(res, body, status, req);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -723,17 +723,13 @@ async function setupP2NS() {
|
||||
// Configure Hyperswarm for better local network peer discovery
|
||||
const swarmOptions = {
|
||||
keyPair: state.keypair,
|
||||
ephemeral: false,
|
||||
maxPeers: parseInt(process.env.MAX_PEERS || '24', 10),
|
||||
queue: {
|
||||
multiplex: true
|
||||
}
|
||||
maxPeers: parseInt(process.env.MAX_PEERS || '24', 10)
|
||||
};
|
||||
|
||||
const swarm = new Hyperswarm(swarmOptions);
|
||||
state.swarm = swarm;
|
||||
logInfo('Main', 'Hyperswarm instance created with persisted keypair');
|
||||
logInfo('Main', `Hyperswarm configured: maxPeers=${swarmOptions.maxPeers}, ephemeral=${swarmOptions.ephemeral}`);
|
||||
logInfo('Main', `Hyperswarm configured: maxPeers=${swarmOptions.maxPeers}`);
|
||||
|
||||
let dnsPass = null;
|
||||
let core = null;
|
||||
@@ -773,11 +769,12 @@ async function setupP2NS() {
|
||||
}
|
||||
|
||||
state.connectedPeers.add(peerId);
|
||||
store.replicate(conn, { live: true });
|
||||
|
||||
// Do not replicate the full Corestore on the p2ns topic — Autopass uses its own pairing/replication.
|
||||
logDebug('Swarm', 'Skipping p2ns-topic Corestore replication (Autopass handles dnsPass sync)');
|
||||
|
||||
const mux = Protomux.from(conn);
|
||||
const inviteChannel = mux.createChannel({ protocol: 'p2ns-invite' });
|
||||
const requestChannel = mux.createChannel({ protocol: 'p2ns-request' });
|
||||
const inviteChannel = mux.createChannel({ protocol: 'p2ns.core-invite' });
|
||||
const requestChannel = mux.createChannel({ protocol: 'p2ns.core-request' });
|
||||
|
||||
const inviteMessage = inviteChannel.addMessage({
|
||||
encoding: c.string,
|
||||
|
||||
Reference in New Issue
Block a user