nodio-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,6 @@
1
+ NODIO_SERVER_PORT=4000
2
+ NODIO_MONGO_URI=mongodb://127.0.0.1:27017/nodio
3
+ NODIO_HEARTBEAT_INTERVAL_MS=30000
4
+ NODIO_OFFLINE_AFTER_MISSES=3
5
+ NODIO_MIN_REPLICAS=5
6
+ NODIO_EMERGENCY_REPLICA_FLOOR=2
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # Nodio-CLI
2
+
3
+ Nodio is a CLI-based distributed storage network.
4
+
5
+ This implementation includes:
6
+ - Central server (Express + MongoDB) for node registry, shard metadata, placement planning, heartbeat tracking, and replication task orchestration
7
+ - `nodio-node` donor CLI for running storage nodes, sending heartbeats every 30s, storing shard blobs, and executing replication tasks
8
+ - `nodio` user CLI for encrypted upload/download with shard distribution and reconstruction
9
+
10
+ ## Requirements
11
+
12
+ - Node.js 20+
13
+ - MongoDB running locally or reachable by URI
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install
19
+ ```
20
+
21
+ ## Configure
22
+
23
+ ```bash
24
+ cp .env.example .env
25
+ ```
26
+
27
+ Environment values:
28
+ - `NODIO_SERVER_PORT`: central API port (default `4000`)
29
+ - `NODIO_MONGO_URI`: MongoDB connection string
30
+ - `NODIO_HEARTBEAT_INTERVAL_MS`: expected heartbeat interval (`30000`)
31
+ - `NODIO_OFFLINE_AFTER_MISSES`: offline threshold in missed heartbeats (`3`)
32
+ - `NODIO_MIN_REPLICAS`: target replicas for placement planning (`5`)
33
+ - `NODIO_EMERGENCY_REPLICA_FLOOR`: immediate repair threshold (`2`)
34
+
35
+ ## Start Central Server
36
+
37
+ ```bash
38
+ npm run start:server
39
+ ```
40
+
41
+ Server API base: `http://127.0.0.1:4000/api`
42
+
43
+ ## Deploy On Render (Central Server)
44
+
45
+ Use a **Web Service** for the central server.
46
+
47
+ - Build Command: `npm install`
48
+ - Start Command: `npm run start:server`
49
+ - Health Check Path: `/api/health`
50
+
51
+ Required environment variables on Render:
52
+ - `NODIO_MONGO_URI` (MongoDB Atlas URI)
53
+
54
+ Optional environment variables:
55
+ - `NODIO_HEARTBEAT_INTERVAL_MS`
56
+ - `NODIO_OFFLINE_AFTER_MISSES`
57
+ - `NODIO_MIN_REPLICAS`
58
+ - `NODIO_EMERGENCY_REPLICA_FLOOR`
59
+
60
+ Notes:
61
+ - Render sets `PORT` automatically, and the server now uses it.
62
+ - Donor nodes (`nodio-node`) should run on persistent machines or VMs, not on ephemeral web-service instances.
63
+
64
+ ## Start Donor Node CLI
65
+
66
+ ```bash
67
+ npm run start:node -- \
68
+ --node-id node-a \
69
+ --server http://127.0.0.1:4000 \
70
+ --host 127.0.0.1 \
71
+ --port 5001 \
72
+ --storage-dir ./.nodio-node-a \
73
+ --capacity-gb 10
74
+ ```
75
+
76
+ Run more nodes by changing `--node-id`, `--port`, and `--storage-dir`.
77
+
78
+ ## User CLI (Phase 2)
79
+
80
+ Upload (encrypt + shard + distribute):
81
+
82
+ ```bash
83
+ npm run start:cli -- upload \
84
+ --file ./example.txt \
85
+ --server http://127.0.0.1:4000 \
86
+ --shard-size-mb 1 \
87
+ --replicas 5
88
+ ```
89
+
90
+ The upload command prints:
91
+ - `fileId`
92
+ - `aes256KeyBase64` (required for download)
93
+
94
+ Download (fetch + verify + decrypt + reconstruct):
95
+
96
+ ```bash
97
+ npm run start:cli -- download \
98
+ --file-id <FILE_ID> \
99
+ --key-base64 <AES_256_KEY_BASE64> \
100
+ --server http://127.0.0.1:4000 \
101
+ --output ./restored-example.txt
102
+ ```
103
+
104
+ ## Core Behavior Implemented
105
+
106
+ - Files can be represented as shards with metadata in MongoDB (`files`, `shards`, and `shard placements`)
107
+ - Placement planning enforces distinct nodes per shard and defaults to 5 replicas
108
+ - User uploads enforce at least 5 replicas per shard
109
+ - Node heartbeats every 30 seconds update status and available storage
110
+ - If a node misses 3 heartbeat intervals, it is marked offline
111
+ - When live replicas of a shard drop below 2, the server immediately creates replication tasks to healthy nodes
112
+ - Donor nodes fetch pending replication tasks through heartbeats and self-heal by copying shard data from source nodes
113
+ - User downloads verify shard checksums, decrypt with AES-256-GCM metadata, and reconstruct files in shard order
114
+
115
+ ## Implemented API Surface
116
+
117
+ - `POST /api/nodes/register`
118
+ - `POST /api/nodes/heartbeat`
119
+ - `POST /api/replication-tasks/:taskId/complete`
120
+ - `POST /api/files/register`
121
+ - `POST /api/shards/register`
122
+ - `POST /api/shards/placement-plan`
123
+ - `GET /api/files/:fileId/manifest`
124
+
125
+ ## Next Step
126
+
127
+ Add authentication between user CLI, central server, and donor nodes for production security.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "nodio-cli",
3
+ "version": "1.0.0",
4
+ "description": "Nodio distributed storage network",
5
+ "main": "src/server/index.js",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "nodio-server": "src/server/index.js",
9
+ "nodio-node": "src/node/index.js",
10
+ "nodio": "src/user/index.js"
11
+ },
12
+ "scripts": {
13
+ "start:server": "node ./src/server/index.js",
14
+ "start:node": "node ./src/node/index.js",
15
+ "start:cli": "node ./src/user/index.js",
16
+ "lint": "node -e \"console.log('No linter configured')\""
17
+ },
18
+ "keywords": [
19
+ "distributed-storage",
20
+ "cli",
21
+ "nodio"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "axios": "^1.11.0",
27
+ "commander": "^14.0.1",
28
+ "dotenv": "^17.2.2",
29
+ "express": "^5.1.0",
30
+ "mongoose": "^8.18.0",
31
+ "uuid": "^13.0.0"
32
+ }
33
+ }
package/render.yaml ADDED
@@ -0,0 +1,22 @@
1
+ services:
2
+ - type: web
3
+ name: nodio-server
4
+ env: node
5
+ rootDir: .
6
+ buildCommand: npm install
7
+ startCommand: npm run start:server
8
+ healthCheckPath: /api/health
9
+ autoDeploy: true
10
+ envVars:
11
+ - key: NODE_VERSION
12
+ value: 20
13
+ - key: NODIO_MONGO_URI
14
+ sync: false
15
+ - key: NODIO_HEARTBEAT_INTERVAL_MS
16
+ value: "30000"
17
+ - key: NODIO_OFFLINE_AFTER_MISSES
18
+ value: "3"
19
+ - key: NODIO_MIN_REPLICAS
20
+ value: "5"
21
+ - key: NODIO_EMERGENCY_REPLICA_FLOOR
22
+ value: "2"
@@ -0,0 +1,41 @@
1
+ const crypto = require('crypto');
2
+
3
+ function sha256Hex(buffer) {
4
+ return crypto.createHash('sha256').update(buffer).digest('hex');
5
+ }
6
+
7
+ function encryptAes256Gcm(plainBuffer, keyBuffer) {
8
+ if (!Buffer.isBuffer(keyBuffer) || keyBuffer.length !== 32) {
9
+ throw new Error('keyBuffer must be a 32-byte buffer for AES-256');
10
+ }
11
+
12
+ const iv = crypto.randomBytes(12);
13
+ const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
14
+ const encrypted = Buffer.concat([cipher.update(plainBuffer), cipher.final()]);
15
+ const authTag = cipher.getAuthTag();
16
+ return {
17
+ cipherText: encrypted,
18
+ iv: iv.toString('base64'),
19
+ authTag: authTag.toString('base64')
20
+ };
21
+ }
22
+
23
+ function decryptAes256Gcm(cipherBuffer, keyBuffer, ivBase64, authTagBase64) {
24
+ if (!Buffer.isBuffer(keyBuffer) || keyBuffer.length !== 32) {
25
+ throw new Error('keyBuffer must be a 32-byte buffer for AES-256');
26
+ }
27
+
28
+ const decipher = crypto.createDecipheriv(
29
+ 'aes-256-gcm',
30
+ keyBuffer,
31
+ Buffer.from(ivBase64, 'base64')
32
+ );
33
+ decipher.setAuthTag(Buffer.from(authTagBase64, 'base64'));
34
+ return Buffer.concat([decipher.update(cipherBuffer), decipher.final()]);
35
+ }
36
+
37
+ module.exports = {
38
+ sha256Hex,
39
+ encryptAes256Gcm,
40
+ decryptAes256Gcm
41
+ };
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { Command } = require('commander');
5
+ const { v4: uuidv4 } = require('uuid');
6
+ const { NodioNodeRuntime } = require('./runtime');
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name('nodio-node')
12
+ .description('Nodio donor node CLI')
13
+ .option('--node-id <id>', 'unique node ID', uuidv4())
14
+ .option('--server <url>', 'central server URL', 'http://127.0.0.1:4000')
15
+ .option('--host <host>', 'host/IP exposed to network', '127.0.0.1')
16
+ .option('--port <port>', 'port to expose shard API', '5001')
17
+ .option('--storage-dir <path>', 'local shard storage directory', path.join(os.homedir(), '.nodio-node'))
18
+ .option('--capacity-gb <gb>', 'donated capacity in GB', '10')
19
+ .option('--heartbeat-ms <ms>', 'heartbeat interval in milliseconds', '30000');
20
+
21
+ program.action(async (options) => {
22
+ const port = Number(options.port);
23
+ const capacityGb = Number(options.capacityGb);
24
+ const heartbeatMs = Number(options.heartbeatMs);
25
+
26
+ if (!Number.isInteger(port) || port <= 0) {
27
+ throw new Error('port must be a positive integer');
28
+ }
29
+ if (!Number.isFinite(capacityGb) || capacityGb <= 0) {
30
+ throw new Error('capacity-gb must be greater than 0');
31
+ }
32
+ if (!Number.isFinite(heartbeatMs) || heartbeatMs <= 0) {
33
+ throw new Error('heartbeat-ms must be greater than 0');
34
+ }
35
+
36
+ const runtime = new NodioNodeRuntime({
37
+ nodeId: options.nodeId,
38
+ serverUrl: options.server,
39
+ publicUrl: `http://${options.host}:${port}`,
40
+ port,
41
+ storageDir: path.resolve(options.storageDir),
42
+ capacityBytes: Math.floor(capacityGb * 1024 * 1024 * 1024),
43
+ heartbeatIntervalMs: heartbeatMs
44
+ });
45
+
46
+ await runtime.start();
47
+ });
48
+
49
+ program.parseAsync(process.argv).catch((error) => {
50
+ console.error('[nodio-node]', error.message);
51
+ process.exit(1);
52
+ });
@@ -0,0 +1,154 @@
1
+ const express = require('express');
2
+ const axios = require('axios');
3
+ const { LocalShardStore } = require('./storage');
4
+
5
+ function normalizeUrl(url) {
6
+ return String(url || '').replace(/\/+$/, '');
7
+ }
8
+
9
+ class NodioNodeRuntime {
10
+ constructor(options) {
11
+ this.nodeId = options.nodeId;
12
+ this.serverUrl = normalizeUrl(options.serverUrl);
13
+ this.publicUrl = normalizeUrl(options.publicUrl);
14
+ this.port = Number(options.port);
15
+ this.capacityBytes = Number(options.capacityBytes);
16
+ this.heartbeatIntervalMs = Number(options.heartbeatIntervalMs || 30000);
17
+ this.shardStore = new LocalShardStore(options.storageDir);
18
+ this.heartbeatTimer = null;
19
+ }
20
+
21
+ async start() {
22
+ await this.shardStore.init();
23
+ await this.startShardServer();
24
+ await this.registerNode();
25
+ await this.sendHeartbeat();
26
+
27
+ this.heartbeatTimer = setInterval(async () => {
28
+ try {
29
+ await this.sendHeartbeat();
30
+ } catch (error) {
31
+ console.error('[heartbeat]', error.message);
32
+ }
33
+ }, this.heartbeatIntervalMs);
34
+ }
35
+
36
+ async startShardServer() {
37
+ const app = express();
38
+ app.use('/shards/:shardId', express.raw({ type: '*/*', limit: '200mb' }));
39
+
40
+ app.put('/shards/:shardId', async (req, res) => {
41
+ try {
42
+ const { shardId } = req.params;
43
+ if (!Buffer.isBuffer(req.body)) {
44
+ return res.status(400).json({ error: 'binary payload is required' });
45
+ }
46
+ const record = await this.shardStore.saveShard(shardId, req.body);
47
+ res.json({ ok: true, shard: record });
48
+ } catch (error) {
49
+ res.status(500).json({ error: error.message });
50
+ }
51
+ });
52
+
53
+ app.get('/shards/:shardId', async (req, res) => {
54
+ try {
55
+ const { shardId } = req.params;
56
+ if (!(await this.shardStore.hasShard(shardId))) {
57
+ return res.status(404).json({ error: 'shard not found' });
58
+ }
59
+
60
+ const data = await this.shardStore.readShard(shardId);
61
+ res.setHeader('Content-Type', 'application/octet-stream');
62
+ res.send(data);
63
+ } catch (error) {
64
+ res.status(500).json({ error: error.message });
65
+ }
66
+ });
67
+
68
+ app.get('/health', (_req, res) => {
69
+ res.json({ ok: true, nodeId: this.nodeId });
70
+ });
71
+
72
+ await new Promise((resolve) => {
73
+ app.listen(this.port, () => {
74
+ console.log(`Nodio node ${this.nodeId} listening on port ${this.port}`);
75
+ resolve();
76
+ });
77
+ });
78
+ }
79
+
80
+ async freeBytes() {
81
+ const used = await this.shardStore.usedBytes();
82
+ return Math.max(this.capacityBytes - used, 0);
83
+ }
84
+
85
+ async registerNode() {
86
+ const response = await axios.post(`${this.serverUrl}/api/nodes/register`, {
87
+ nodeId: this.nodeId,
88
+ url: this.publicUrl,
89
+ capacityBytes: this.capacityBytes,
90
+ freeBytes: await this.freeBytes()
91
+ });
92
+
93
+ const interval = Number(response.data.heartbeatIntervalMs);
94
+ if (Number.isFinite(interval) && interval > 0) {
95
+ this.heartbeatIntervalMs = interval;
96
+ }
97
+
98
+ console.log(
99
+ `Registered node ${this.nodeId} | min replicas: ${response.data.minReplicas} | emergency floor: ${response.data.emergencyReplicaFloor}`
100
+ );
101
+ }
102
+
103
+ async sendHeartbeat() {
104
+ const shardIds = await this.shardStore.listShardIds();
105
+ const response = await axios.post(`${this.serverUrl}/api/nodes/heartbeat`, {
106
+ nodeId: this.nodeId,
107
+ freeBytes: await this.freeBytes(),
108
+ shardIds
109
+ });
110
+
111
+ const tasks = response.data.replicationTasks || [];
112
+ for (const task of tasks) {
113
+ await this.executeReplicationTask(task);
114
+ }
115
+
116
+ console.log(
117
+ `[heartbeat] ${new Date().toISOString()} | shards=${shardIds.length} | tasks=${tasks.length}`
118
+ );
119
+ }
120
+
121
+ async executeReplicationTask(task) {
122
+ const sourceShardUrl = `${normalizeUrl(task.sourceUrl)}/shards/${task.shardId}`;
123
+ try {
124
+ const sourceResponse = await axios.get(sourceShardUrl, {
125
+ responseType: 'arraybuffer',
126
+ timeout: 30000
127
+ });
128
+ const data = Buffer.from(sourceResponse.data);
129
+ await this.shardStore.saveShard(task.shardId, data);
130
+
131
+ await axios.post(`${this.serverUrl}/api/replication-tasks/${task.taskId}/complete`, {
132
+ nodeId: this.nodeId,
133
+ success: true
134
+ });
135
+
136
+ console.log(`[replication] completed task=${task.taskId} shard=${task.shardId}`);
137
+ } catch (error) {
138
+ const message = error.response?.data?.error || error.message;
139
+ await axios
140
+ .post(`${this.serverUrl}/api/replication-tasks/${task.taskId}/complete`, {
141
+ nodeId: this.nodeId,
142
+ success: false,
143
+ errorMessage: message
144
+ })
145
+ .catch(() => null);
146
+
147
+ console.error(`[replication] failed task=${task.taskId} shard=${task.shardId}: ${message}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ module.exports = {
153
+ NodioNodeRuntime
154
+ };
@@ -0,0 +1,80 @@
1
+ const fs = require('fs/promises');
2
+ const path = require('path');
3
+ const { sha256Hex } = require('../common/crypto');
4
+
5
+ class LocalShardStore {
6
+ constructor(baseDir) {
7
+ this.baseDir = baseDir;
8
+ this.indexFile = path.join(baseDir, 'index.json');
9
+ this.shardsDir = path.join(baseDir, 'shards');
10
+ }
11
+
12
+ async init() {
13
+ await fs.mkdir(this.shardsDir, { recursive: true });
14
+ try {
15
+ await fs.access(this.indexFile);
16
+ } catch {
17
+ await fs.writeFile(this.indexFile, JSON.stringify({ shards: {} }, null, 2), 'utf-8');
18
+ }
19
+ }
20
+
21
+ async readIndex() {
22
+ const raw = await fs.readFile(this.indexFile, 'utf-8');
23
+ const parsed = JSON.parse(raw);
24
+ if (!parsed.shards || typeof parsed.shards !== 'object') {
25
+ return { shards: {} };
26
+ }
27
+ return parsed;
28
+ }
29
+
30
+ async writeIndex(index) {
31
+ await fs.writeFile(this.indexFile, JSON.stringify(index, null, 2), 'utf-8');
32
+ }
33
+
34
+ shardPath(shardId) {
35
+ return path.join(this.shardsDir, `${shardId}.bin`);
36
+ }
37
+
38
+ async saveShard(shardId, dataBuffer) {
39
+ const filePath = this.shardPath(shardId);
40
+ await fs.writeFile(filePath, dataBuffer);
41
+
42
+ const index = await this.readIndex();
43
+ index.shards[shardId] = {
44
+ shardId,
45
+ sizeBytes: dataBuffer.byteLength,
46
+ checksum: sha256Hex(dataBuffer),
47
+ updatedAt: new Date().toISOString()
48
+ };
49
+ await this.writeIndex(index);
50
+ return index.shards[shardId];
51
+ }
52
+
53
+ async readShard(shardId) {
54
+ const filePath = this.shardPath(shardId);
55
+ return fs.readFile(filePath);
56
+ }
57
+
58
+ async hasShard(shardId) {
59
+ try {
60
+ await fs.access(this.shardPath(shardId));
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ async listShardIds() {
68
+ const index = await this.readIndex();
69
+ return Object.keys(index.shards);
70
+ }
71
+
72
+ async usedBytes() {
73
+ const index = await this.readIndex();
74
+ return Object.values(index.shards).reduce((sum, shard) => sum + Number(shard.sizeBytes || 0), 0);
75
+ }
76
+ }
77
+
78
+ module.exports = {
79
+ LocalShardStore
80
+ };
@@ -0,0 +1,17 @@
1
+ const path = require('path');
2
+ require('dotenv').config({ path: path.resolve(process.cwd(), '.env') });
3
+
4
+ function getServerConfig() {
5
+ return {
6
+ port: Number(process.env.PORT || process.env.NODIO_SERVER_PORT || 4000),
7
+ mongoUri: process.env.NODIO_MONGO_URI || 'mongodb://127.0.0.1:27017/nodio',
8
+ heartbeatIntervalMs: Number(process.env.NODIO_HEARTBEAT_INTERVAL_MS || 30000),
9
+ offlineAfterMisses: Number(process.env.NODIO_OFFLINE_AFTER_MISSES || 3),
10
+ minReplicas: Number(process.env.NODIO_MIN_REPLICAS || 5),
11
+ emergencyReplicaFloor: Number(process.env.NODIO_EMERGENCY_REPLICA_FLOOR || 2)
12
+ };
13
+ }
14
+
15
+ module.exports = {
16
+ getServerConfig
17
+ };
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ const express = require('express');
3
+ const mongoose = require('mongoose');
4
+ const { getServerConfig } = require('./config');
5
+ const { buildRoutes } = require('./routes');
6
+ const { NodeModel } = require('./models');
7
+ const { markNodeOfflineAndRecover } = require('./services');
8
+
9
+ async function startServer() {
10
+ const config = getServerConfig();
11
+
12
+ await mongoose.connect(config.mongoUri);
13
+
14
+ const app = express();
15
+ app.use(express.json({ limit: '10mb' }));
16
+ app.use('/api', buildRoutes(config));
17
+
18
+ app.use((error, _req, res, _next) => {
19
+ const status = error.statusCode || 500;
20
+ const message = error.message || 'internal server error';
21
+ if (status >= 500) {
22
+ console.error('[server-error]', error);
23
+ }
24
+ res.status(status).json({ error: message });
25
+ });
26
+
27
+ const offlineThresholdMs = config.heartbeatIntervalMs * config.offlineAfterMisses;
28
+
29
+ setInterval(async () => {
30
+ try {
31
+ const staleBefore = new Date(Date.now() - offlineThresholdMs);
32
+ const staleNodes = await NodeModel.find({
33
+ status: 'online',
34
+ lastHeartbeatAt: { $lt: staleBefore }
35
+ })
36
+ .select('nodeId')
37
+ .lean();
38
+
39
+ for (const node of staleNodes) {
40
+ const changed = await markNodeOfflineAndRecover(node.nodeId, config.emergencyReplicaFloor);
41
+ if (changed) {
42
+ console.warn(`[heartbeat] node ${node.nodeId} marked offline`);
43
+ }
44
+ }
45
+ } catch (error) {
46
+ console.error('[offline-monitor]', error);
47
+ }
48
+ }, config.heartbeatIntervalMs);
49
+
50
+ app.listen(config.port, () => {
51
+ console.log(`Nodio central server listening on port ${config.port}`);
52
+ });
53
+ }
54
+
55
+ startServer().catch((error) => {
56
+ console.error('[startup]', error);
57
+ process.exit(1);
58
+ });
@@ -0,0 +1,84 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const nodeSchema = new mongoose.Schema(
4
+ {
5
+ nodeId: { type: String, required: true, unique: true, index: true },
6
+ url: { type: String, required: true },
7
+ capacityBytes: { type: Number, required: true, min: 1 },
8
+ freeBytes: { type: Number, required: true, min: 0 },
9
+ status: { type: String, enum: ['online', 'offline'], default: 'online', index: true },
10
+ lastHeartbeatAt: { type: Date, default: Date.now, index: true }
11
+ },
12
+ { timestamps: true }
13
+ );
14
+
15
+ const fileSchema = new mongoose.Schema(
16
+ {
17
+ fileId: { type: String, required: true, unique: true, index: true },
18
+ originalName: { type: String, required: true },
19
+ sizeBytes: { type: Number, required: true, min: 0 },
20
+ shardCount: { type: Number, required: true, min: 1 },
21
+ cipher: { type: String, required: true, default: 'aes-256-gcm' },
22
+ metadata: { type: mongoose.Schema.Types.Mixed, default: {} }
23
+ },
24
+ { timestamps: true }
25
+ );
26
+
27
+ const shardSchema = new mongoose.Schema(
28
+ {
29
+ shardId: { type: String, required: true, unique: true, index: true },
30
+ fileId: { type: String, required: true, index: true },
31
+ order: { type: Number, required: true, min: 0 },
32
+ sizeBytes: { type: Number, required: true, min: 0 },
33
+ checksum: { type: String, required: true }
34
+ },
35
+ { timestamps: true }
36
+ );
37
+
38
+ const shardPlacementSchema = new mongoose.Schema(
39
+ {
40
+ shardId: { type: String, required: true, index: true },
41
+ fileId: { type: String, required: true, index: true },
42
+ nodeId: { type: String, required: true, index: true },
43
+ status: { type: String, enum: ['available', 'lost'], default: 'available', index: true }
44
+ },
45
+ { timestamps: true }
46
+ );
47
+
48
+ shardPlacementSchema.index({ shardId: 1, nodeId: 1 }, { unique: true });
49
+
50
+ const replicationTaskSchema = new mongoose.Schema(
51
+ {
52
+ shardId: { type: String, required: true, index: true },
53
+ fileId: { type: String, required: true, index: true },
54
+ sourceNodeId: { type: String, required: true, index: true },
55
+ targetNodeId: { type: String, required: true, index: true },
56
+ status: {
57
+ type: String,
58
+ enum: ['pending', 'in_progress', 'completed', 'failed'],
59
+ default: 'pending',
60
+ index: true
61
+ },
62
+ errorMessage: { type: String, default: null },
63
+ attempts: { type: Number, default: 0 }
64
+ },
65
+ { timestamps: true }
66
+ );
67
+
68
+ replicationTaskSchema.index(
69
+ { shardId: 1, targetNodeId: 1, status: 1 },
70
+ {
71
+ unique: true,
72
+ partialFilterExpression: {
73
+ status: { $in: ['pending', 'in_progress'] }
74
+ }
75
+ }
76
+ );
77
+
78
+ module.exports = {
79
+ NodeModel: mongoose.model('Node', nodeSchema),
80
+ FileModel: mongoose.model('File', fileSchema),
81
+ ShardModel: mongoose.model('Shard', shardSchema),
82
+ ShardPlacementModel: mongoose.model('ShardPlacement', shardPlacementSchema),
83
+ ReplicationTaskModel: mongoose.model('ReplicationTask', replicationTaskSchema)
84
+ };