jsql-neo 4.0.2 → 4.1.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/bin/jsql CHANGED
@@ -15,6 +15,8 @@ function printModules(list) {
15
15
 
16
16
  const manager = new ModuleManager();
17
17
 
18
+ const RAW_ARGV = process.argv.slice(2);
19
+
18
20
  const cli = yaggs()
19
21
  .usage('jsql <command> [options]')
20
22
  .option('help', { alias: 'h', type: 'boolean', description: 'Show help' })
@@ -50,6 +52,10 @@ const cli = yaggs()
50
52
  switch (action) {
51
53
  case 'add': {
52
54
  if (!argv.address) return fail(new Error("'mod add' requires --address <path>"));
55
+ const ext = path.extname(argv.address).toLowerCase();
56
+ if (ext === '.js' || ext === '.cjs' || ext === '.mjs') {
57
+ console.warn("Warning: JS modules run without sandbox isolation (trust model). Use .json files for data-only plugins.");
58
+ }
53
59
  const entry = manager.add(argv.address);
54
60
  out(useJson ? entry : `Module '${entry.name}' added (${entry.path}). Use 'jsql mod enable ${entry.name}' to enable.`);
55
61
  break;
@@ -89,6 +95,69 @@ const cli = yaggs()
89
95
  })
90
96
  .command('version', 'Show version', null, () => {
91
97
  console.log(require('../package.json').version);
98
+ })
99
+ .command('server', 'Run the MySQL-compatible server in background', (sub) => {
100
+ sub.option('port', { alias: 'p', type: 'number', description: 'Listen port (default 3306)' });
101
+ sub.option('host', { type: 'string', description: 'Listen host (default 127.0.0.1)' });
102
+ sub.option('data-dir', { type: 'string', description: 'Directory to store databases (default in-memory)' });
103
+ sub.option('auth', { alias: 'a', type: 'string', description: 'User credential: user:password[:db1,db2] (repeatable)' });
104
+ sub.option('config', { type: 'string', description: 'Path to config file (JSON)' });
105
+ sub.option('no-auth', { type: 'boolean', description: 'Allow connections without authentication (local dev only)' });
106
+ }, (argv) => {
107
+ const { serverControl } = require('./jsql-server-control');
108
+ const action = argv._[0];
109
+ const fail = (err) => {
110
+ console.error(`Error: ${err.message}`);
111
+ process.exitCode = 1;
112
+ };
113
+ const options = {};
114
+ if (argv.config) options.config = argv.config;
115
+ if (argv.port != null) options.port = argv.port;
116
+ if (argv.host != null) options.host = argv.host;
117
+ if (argv['data-dir'] != null) options.dataDir = argv['data-dir'];
118
+ if (argv['no-auth'] === true) options.noAuth = true;
119
+ const authSpecs = [];
120
+ for (let i = 0; i < RAW_ARGV.length; i++) {
121
+ const a = RAW_ARGV[i];
122
+ if (a === '--auth' || a === '-a') {
123
+ if (RAW_ARGV[i + 1]) authSpecs.push(RAW_ARGV[++i]);
124
+ } else if (a.startsWith('--auth=')) {
125
+ authSpecs.push(a.slice(7));
126
+ }
127
+ }
128
+ if (authSpecs.length > 0) options.auth = authSpecs;
129
+
130
+ try {
131
+ switch (action) {
132
+ case 'start': {
133
+ serverControl.start(options).then(
134
+ (msg) => console.log(msg),
135
+ (err) => fail(err)
136
+ );
137
+ break;
138
+ }
139
+ case 'stop': {
140
+ serverControl.stop(options).then(
141
+ (msg) => console.log(msg),
142
+ (err) => fail(err)
143
+ );
144
+ break;
145
+ }
146
+ case 'status': {
147
+ serverControl.status(options).then(
148
+ (msg) => console.log(msg),
149
+ (err) => fail(err)
150
+ );
151
+ break;
152
+ }
153
+ case undefined:
154
+ return fail(new Error("Missing action. Usage: jsql server <start|stop|status>"));
155
+ default:
156
+ return fail(new Error(`Unknown server action: ${action}`));
157
+ }
158
+ } catch (e) {
159
+ fail(e);
160
+ }
92
161
  });
93
162
 
94
163
  cli.run(process.argv.slice(2)).catch(e => {
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // © Vexify 2026 All Rights Reserved.
3
+ // jsql-neo 后台常驻服务入口 — 由 `jsql start` 以 detached 子进程方式拉起。
4
+ // 用法: node bin/jsql-server --config <config.json>
5
+ // 支持 SIGTERM/SIGINT 优雅关闭。
6
+
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const { createMysqlServer } = require('../lib/mysql_server');
10
+
11
+ function parseArgs(argv) {
12
+ const args = { _: [] };
13
+ for (let i = 0; i < argv.length; i++) {
14
+ const a = argv[i];
15
+ if (a === '--config') { args.config = argv[++i]; }
16
+ else if (a.startsWith('--config=')) { args.config = a.slice(9); }
17
+ else args._.push(a);
18
+ }
19
+ return args;
20
+ }
21
+
22
+ function loadConfig(configPath) {
23
+ const cfg = { port: 3306, host: '127.0.0.1' };
24
+ if (configPath && fs.existsSync(configPath)) {
25
+ Object.assign(cfg, JSON.parse(fs.readFileSync(configPath, 'utf8')));
26
+ }
27
+ return cfg;
28
+ }
29
+
30
+ function buildAuth(cfg) {
31
+ if (cfg.auth) {
32
+ if (typeof cfg.auth !== 'object' || Array.isArray(cfg.auth)) {
33
+ throw new Error('config.auth must be an object map { user: "password" | { password, databases } }');
34
+ }
35
+ return cfg.auth;
36
+ }
37
+ if (cfg.user != null || cfg.password != null) {
38
+ const auth = {};
39
+ auth[cfg.user || 'root'] = cfg.password || '';
40
+ return auth;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ async function main() {
46
+ const args = parseArgs(process.argv.slice(2));
47
+ const cfg = loadConfig(args.config);
48
+ const auth = buildAuth(cfg);
49
+ const options = {
50
+ port: cfg.port || 3306,
51
+ host: cfg.host || '127.0.0.1',
52
+ dataDir: cfg.dataDir || null,
53
+ maxConnections: cfg.maxConnections,
54
+ idleTimeout: cfg.idleTimeout,
55
+ handshakeTimeout: cfg.handshakeTimeout,
56
+ safety: cfg.safety !== false,
57
+ allowComments: cfg.allowComments === true,
58
+ noAuth: cfg.noAuth === true,
59
+ };
60
+ if (auth) options.auth = auth;
61
+ if (cfg.onSecurityEvent) options.onSecurityEvent = cfg.onSecurityEvent;
62
+
63
+ const server = createMysqlServer(options);
64
+
65
+ let closing = false;
66
+ const shutdown = (code) => {
67
+ if (closing) return;
68
+ closing = true;
69
+ server.close(() => process.exit(code));
70
+ setTimeout(() => process.exit(code), 2000).unref();
71
+ };
72
+ process.on('SIGTERM', () => shutdown(0));
73
+ process.on('SIGINT', () => shutdown(0));
74
+
75
+ server.listen(err => {
76
+ if (err) {
77
+ console.error(`[jsql-server] failed to start: ${err.message}`);
78
+ process.exit(1);
79
+ return;
80
+ }
81
+ const addr = server.address;
82
+ console.log(`[jsql-server] listening on ${options.host}:${addr ? addr.port : options.port} (dataDir: ${options.dataDir || 'in-memory'})`);
83
+ console.log(`[jsql-server] pid ${process.pid}`);
84
+ });
85
+ }
86
+
87
+ main().catch(e => {
88
+ console.error(`[jsql-server] ${e.message}`);
89
+ process.exit(1);
90
+ });
@@ -0,0 +1,177 @@
1
+ // © Vexify 2026 All Rights Reserved.
2
+ // jsql server start/stop/status — 后台常驻服务控制(对标 `net mysql80 start`)。
3
+ // 状态文件放在 ~/.jsql/ 下:server.pid / server.log / server.json(保存启动配置)。
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawn } = require('child_process');
9
+
10
+ const STATE_DIR = path.join(os.homedir(), '.jsql');
11
+ const PID_FILE = path.join(STATE_DIR, 'server.pid');
12
+ const LOG_FILE = path.join(STATE_DIR, 'server.log');
13
+ const CONF_FILE = path.join(STATE_DIR, 'server.json');
14
+
15
+ function ensureStateDir() {
16
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
17
+ }
18
+
19
+ function readPid() {
20
+ try {
21
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
22
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
23
+ } catch (e) {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function isAlive(pid) {
29
+ if (!pid) return false;
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch (e) {
34
+ return e.code === 'EPERM';
35
+ }
36
+ }
37
+
38
+ function isPortOpen(port, host) {
39
+ return new Promise(resolve => {
40
+ const net = require('net');
41
+ const sock = net.connect({ port, host: host || '127.0.0.1' });
42
+ sock.once('connect', () => { sock.destroy(); resolve(true); });
43
+ sock.once('error', () => resolve(false));
44
+ setTimeout(() => { sock.destroy(); resolve(false); }, 800);
45
+ });
46
+ }
47
+
48
+ function parseAuthSpecs(list) {
49
+ const auth = {};
50
+ for (const spec of list) {
51
+ const idx = spec.indexOf(':');
52
+ if (idx <= 0) {
53
+ auth[spec] = '';
54
+ continue;
55
+ }
56
+ const user = spec.slice(0, idx);
57
+ const rest = spec.slice(idx + 1);
58
+ const idx2 = rest.indexOf(':');
59
+ const password = idx2 === -1 ? rest : rest.slice(0, idx2);
60
+ const dbPart = idx2 === -1 ? '' : rest.slice(idx2 + 1);
61
+ const dbs = dbPart
62
+ ? dbPart.split(',').map(s => s.trim()).filter(Boolean)
63
+ : null;
64
+ auth[user] = dbs ? { password, databases: dbs } : password;
65
+ }
66
+ return auth;
67
+ }
68
+
69
+ async function start(opts = {}) {
70
+ ensureStateDir();
71
+ const existing = readPid();
72
+ if (existing && isAlive(existing)) {
73
+ return `jsql server is already running (pid ${existing}).`;
74
+ }
75
+
76
+ const configPath = opts.config || CONF_FILE;
77
+ let cfg = { port: 3306, host: '127.0.0.1' };
78
+ if (fs.existsSync(configPath)) {
79
+ try { cfg = Object.assign(cfg, JSON.parse(fs.readFileSync(configPath, 'utf8'))); } catch (e) {}
80
+ }
81
+ if (opts.port != null) cfg.port = opts.port;
82
+ if (opts.host != null) cfg.host = opts.host;
83
+ if (opts.dataDir != null) cfg.dataDir = path.resolve(opts.dataDir);
84
+ if (opts.noAuth) {
85
+ cfg.noAuth = true;
86
+ delete cfg.auth;
87
+ delete cfg.user;
88
+ delete cfg.password;
89
+ } else {
90
+ cfg.noAuth = false;
91
+ }
92
+ if (opts.auth) cfg.auth = parseAuthSpecs(Array.isArray(opts.auth) ? opts.auth : [opts.auth]);
93
+ fs.writeFileSync(CONF_FILE, JSON.stringify(cfg, null, 2), 'utf8');
94
+
95
+ const entry = path.join(__dirname, 'jsql-server');
96
+ const logFd = fs.openSync(LOG_FILE, 'a');
97
+ const child = spawn(process.execPath, [entry, '--config', CONF_FILE], {
98
+ detached: true,
99
+ stdio: ['ignore', logFd, logFd],
100
+ env: Object.assign({}, process.env, { JSQL_SERVER_MAIN: '1' }),
101
+ });
102
+ child.once('error', err => {
103
+ fs.closeSync(logFd);
104
+ throw err;
105
+ });
106
+ child.unref();
107
+
108
+ fs.writeFileSync(PID_FILE, String(child.pid), 'utf8');
109
+
110
+ const ready = await waitReady(cfg.port, cfg.host, 5000);
111
+ if (!ready) {
112
+ if (isAlive(child.pid)) {
113
+ try { process.kill(child.pid, 'SIGTERM'); } catch (e) {}
114
+ fs.unlinkSync(PID_FILE);
115
+ }
116
+ throw new Error(`server failed to start (see ${LOG_FILE})`);
117
+ }
118
+ return `jsql server started (pid ${child.pid}, ${cfg.host}:${cfg.port}, dataDir: ${cfg.dataDir || 'in-memory'}). Log: ${LOG_FILE}`;
119
+ }
120
+
121
+ function waitReady(port, host, timeoutMs) {
122
+ const deadline = Date.now() + timeoutMs;
123
+ return new Promise(resolve => {
124
+ const tick = async () => {
125
+ if (await isPortOpen(port, host)) return resolve(true);
126
+ if (Date.now() > deadline) return resolve(false);
127
+ setTimeout(tick, 200);
128
+ };
129
+ tick();
130
+ });
131
+ }
132
+
133
+ async function stop(opts = {}) {
134
+ const pid = readPid();
135
+ if (!pid) return 'jsql server is not running.';
136
+ if (!isAlive(pid)) {
137
+ fs.unlinkSync(PID_FILE);
138
+ return 'jsql server is not running.';
139
+ }
140
+ try {
141
+ process.kill(pid, 'SIGTERM');
142
+ } catch (e) {
143
+ throw new Error(`failed to stop server (pid ${pid}): ${e.message}`);
144
+ }
145
+ const deadline = Date.now() + 5000;
146
+ while (Date.now() < deadline) {
147
+ if (!isAlive(pid)) break;
148
+ await new Promise(r => setTimeout(r, 150));
149
+ }
150
+ if (isAlive(pid)) {
151
+ try { process.kill(pid, 'SIGKILL'); } catch (e) {}
152
+ }
153
+ fs.unlinkSync(PID_FILE);
154
+ return `jsql server stopped (pid ${pid}).`;
155
+ }
156
+
157
+ async function status(opts = {}) {
158
+ const pid = readPid();
159
+ if (!pid || !isAlive(pid)) {
160
+ return 'jsql server is not running.';
161
+ }
162
+ let cfg = {};
163
+ try { cfg = JSON.parse(fs.readFileSync(CONF_FILE, 'utf8')); } catch (e) {}
164
+ const port = (opts.port != null ? opts.port : cfg.port) || 3306;
165
+ const host = (opts.host != null ? opts.host : cfg.host) || '127.0.0.1';
166
+ const up = await isPortOpen(port, host);
167
+ const lines = [
168
+ `jsql server is running (pid ${pid}).`,
169
+ ` ${host}:${port} ${up ? 'listening' : 'NOT responding'}`,
170
+ ` dataDir: ${cfg.dataDir || 'in-memory'}`,
171
+ ` log: ${LOG_FILE}`,
172
+ ];
173
+ if (cfg.auth) lines.push(` users: ${Object.keys(cfg.auth).join(', ')}`);
174
+ return lines.join('\n');
175
+ }
176
+
177
+ module.exports = { serverControl: { start, stop, status }, STATE_DIR, PID_FILE, LOG_FILE, CONF_FILE };
package/lib/database.js CHANGED
@@ -134,20 +134,53 @@ class Database {
134
134
  try {
135
135
  if (fs.existsSync(this._metaPath)) {
136
136
  const parsed = JSON.parse(fs.readFileSync(this._metaPath, 'utf8'));
137
- if (parsed && parsed.tables) this._meta = parsed;
137
+ if (parsed && parsed.tables) {
138
+ this._meta = parsed;
139
+ // 清洗 meta 中的 file 字段:拒绝绝对路径/.. 穿越,防恶意 meta.json 写任意位置
140
+ for (const name of Object.keys(this._meta.tables)) {
141
+ const meta = this._meta.tables[name];
142
+ if (meta && meta.file !== undefined) {
143
+ if (this._safeTableFile(meta.file) === null) {
144
+ delete this._meta.tables[name];
145
+ }
146
+ }
147
+ }
148
+ }
138
149
  }
139
150
  } catch (e) {
140
151
  this._meta = { version: 1, tables: {} };
141
152
  }
153
+ for (const name of Object.keys(this._meta.tables)) {
154
+ if (this[name] === undefined && !(name in this)) {
155
+ Object.defineProperty(this, name, {
156
+ get: () => this._ensureTable(name) || this._tables[name],
157
+ enumerable: true,
158
+ configurable: true
159
+ });
160
+ }
161
+ }
142
162
  this._startMonitor();
143
163
  }
144
164
 
145
165
  _tableFile(name) {
146
166
  const meta = this._meta.tables[name];
147
- if (meta && meta.file) return path.join(this._filePath, meta.file);
167
+ if (meta && meta.file) {
168
+ const safe = this._safeTableFile(meta.file);
169
+ if (safe) return path.join(this._filePath, safe);
170
+ }
148
171
  return path.join(this._filePath, encodeURIComponent(name) + '.jsql');
149
172
  }
150
173
 
174
+ _safeTableFile(file) {
175
+ if (typeof file !== 'string' || file.length === 0) return null;
176
+ if (path.isAbsolute(file)) return null;
177
+ if (file.includes('/') || file.includes('\\')) return null;
178
+ const base = path.basename(file);
179
+ if (base !== file || base === '.' || base === '..') return null;
180
+ if (file.includes('\0')) return null;
181
+ return file;
182
+ }
183
+
151
184
  _saveMeta() {
152
185
  if (!this._dirMode) return;
153
186
  try {
@@ -166,7 +199,7 @@ class Database {
166
199
  if (!this._dirMode) return null;
167
200
  const meta = this._meta.tables[name];
168
201
  if (!meta) return null;
169
- const file = path.join(this._filePath, meta.file);
202
+ const file = this._tableFile(name);
170
203
  if (!fs.existsSync(file)) return null;
171
204
  try {
172
205
  const fmt = new JSQLFormat(file);
@@ -197,13 +230,13 @@ class Database {
197
230
  if (this._flushInterval <= 0) {
198
231
  this._flushTimer = setImmediate(() => {
199
232
  this._flushTimer = null;
200
- try { this._flushDirty(); } catch (e) {}
233
+ try { this._flushDirty(); } catch (e) { this._emit('error', e); }
201
234
  });
202
235
  return;
203
236
  }
204
237
  this._flushTimer = setTimeout(() => {
205
238
  this._flushTimer = null;
206
- try { this._flushDirty(); } catch (e) {}
239
+ try { this._flushDirty(); } catch (e) { this._emit('error', e); }
207
240
  }, this._flushInterval);
208
241
  if (this._flushTimer.unref) this._flushTimer.unref();
209
242
  }
@@ -211,8 +244,15 @@ class Database {
211
244
  _flushDirty() {
212
245
  if (!this._dirMode || this._dirtyTables.size === 0) return;
213
246
  const names = [...this._dirtyTables];
214
- this._dirtyTables.clear();
215
- for (const name of names) this._flushTable(name);
247
+ for (const name of names) {
248
+ try {
249
+ this._flushTable(name);
250
+ this._dirtyTables.delete(name);
251
+ } catch (e) {
252
+ // 保留脏标记,下次 flush 重试;错误对外传播而非静默
253
+ this._emit('error', e);
254
+ }
255
+ }
216
256
  this._saveMeta();
217
257
  }
218
258
 
@@ -374,6 +414,9 @@ class Database {
374
414
  // ============================================================
375
415
 
376
416
  createTable(name, schema) {
417
+ if (!name || name.length > 64 || name === '.' || name === '..' || /[\x00-\x1f/\\]|\.\./.test(name)) {
418
+ throw createError('ER_BAD_TABLE_NAME', name);
419
+ }
377
420
  if (!this._runHooks('beforeCreateTable', [name, schema])) throw createError('ER_PLUGIN_ABORT', 'createTable aborted by plugin');
378
421
  if (this._tables[name] || (this._dirMode && this._meta.tables[name])) {
379
422
  throw createError('ER_TABLE_EXISTS_ERROR', name);
@@ -1287,7 +1330,7 @@ class Database {
1287
1330
  }
1288
1331
  }
1289
1332
  } else if (targetVersion < current) {
1290
- for (const m of this._migrations.reverse()) {
1333
+ for (const m of [...this._migrations].reverse()) {
1291
1334
  if (m.version <= current && m.version > targetVersion) {
1292
1335
  m.down(this);
1293
1336
  this._setCurrentVersion(m.version - 1);
@@ -1448,7 +1491,14 @@ class Database {
1448
1491
  clearTimeout(this._autoSaveTimer);
1449
1492
  this._autoSaveTimer = null;
1450
1493
  }
1451
- if (this._dirty && !this._memoryMode) {
1494
+ if (this._dirMode) {
1495
+ try {
1496
+ this._flushDirty();
1497
+ this._saveMeta();
1498
+ } catch (e) {
1499
+ this._emit('error', e);
1500
+ }
1501
+ } else if (this._dirty && !this._memoryMode) {
1452
1502
  this.save();
1453
1503
  }
1454
1504
  if (this._jsqlFormat) this._jsqlFormat._close();
@@ -1475,6 +1525,45 @@ class Database {
1475
1525
  this._markDirtyLegacy();
1476
1526
  }
1477
1527
 
1528
+ async beginTx() {
1529
+ this._txSnapshot = {};
1530
+ for (const [name, table] of Object.entries(this._tables)) {
1531
+ this._txSnapshot[name] = { rows: JSON.parse(JSON.stringify(table.toJSON())), schema: table._schema };
1532
+ }
1533
+ this._txId = (this._txId || 0) + 1;
1534
+ return this._txId;
1535
+ }
1536
+
1537
+ async commitTx() {
1538
+ this._txSnapshot = null;
1539
+ this._txId = undefined;
1540
+ if (this._dirMode) {
1541
+ try { this._flushDirty(); } catch (e) {}
1542
+ } else if (!this._memoryMode) {
1543
+ this.save();
1544
+ }
1545
+ }
1546
+
1547
+ async rollbackTx() {
1548
+ const snap = this._txSnapshot;
1549
+ this._txSnapshot = null;
1550
+ this._txId = undefined;
1551
+ if (!snap) return;
1552
+ for (const name of Object.keys(this._tables)) {
1553
+ if (!snap[name]) delete this._tables[name];
1554
+ }
1555
+ for (const [name, entry] of Object.entries(snap)) {
1556
+ if (this._tables[name]) {
1557
+ this._tables[name]._loadRows(entry.rows);
1558
+ } else {
1559
+ const table = new Table(name, entry.schema, this);
1560
+ table._loadRows(entry.rows);
1561
+ this._tables[name] = table;
1562
+ }
1563
+ }
1564
+ this._dirty = true;
1565
+ }
1566
+
1478
1567
  // ============================================================
1479
1568
  // 加密(内部)
1480
1569
  // ============================================================
package/lib/errors.js CHANGED
@@ -62,6 +62,12 @@ const ErrorCodes = {
62
62
  ER_TRIGGER_EXISTS: { code: 1359, msg: "Trigger '%s' already exists" },
63
63
  ER_TRIGGER_NOT_FOUND: { code: 1360, msg: "Trigger '%s' does not exist" },
64
64
  ER_PLUGIN_ERR: { code: 1125, msg: "Plugin error: %s" },
65
+ ER_PLUGIN_ABORT: { code: 1125, msg: "Operation aborted by plugin: %s" },
66
+ ER_TABLE_EXISTS_ERROR: { code: 1050, msg: "Table '%s' already exists" },
67
+ ER_BAD_TABLE_NAME: { code: 1058, msg: "Invalid table name '%s'" },
68
+ ER_BAD_REGEX: { code: 1258, msg: "Unsafe regular expression rejected: %s" },
69
+ ER_DBATTACH_EXISTS: { code: 1007, msg: "Database '%s' already attached" },
70
+ ER_DBATTACH_NOT_FOUND: { code: 1049, msg: "Attached database '%s' not found" },
65
71
  };
66
72
 
67
73
  /**
@@ -305,19 +305,25 @@ class JSQLFormat {
305
305
  }
306
306
 
307
307
  _reset() {
308
- fs.ftruncateSync(this.fd, SZ.SUPER);
309
- const sb = Buffer.alloc(SZ.SUPER); sb.write('JSQL', 0, 'utf8');
310
- fs.writeSync(this.fd, sb, 0, SZ.SUPER, 0);
311
308
  this.nBlocks = 1;
312
309
  this._nextTableId = 1;
313
310
  this.tables.clear();
314
311
  this.pkIdx.clear();
315
312
  }
316
313
 
314
+ _truncateToSuper() {
315
+ fs.ftruncateSync(this.fd, SZ.SUPER);
316
+ const sb = Buffer.alloc(SZ.SUPER); sb.write('JSQL', 0, 'utf8');
317
+ fs.writeSync(this.fd, sb, 0, SZ.SUPER, 0);
318
+ this.nBlocks = 1;
319
+ }
320
+
317
321
  saveAll(allTables) {
318
322
  this._open();
319
323
  this._reset();
320
324
 
325
+ const pending = [];
326
+
321
327
  for (const [name, info] of Object.entries(allTables)) {
322
328
  const { schema, rows, pkField } = info;
323
329
  if (!rows || rows.length === 0) continue;
@@ -338,8 +344,16 @@ class JSQLFormat {
338
344
  // Scratch buffer for row encoding (max row size fits in one block)
339
345
  const scratch = Buffer.allocUnsafe(SZ.DATA);
340
346
 
347
+ // Phase 1: encode every row into memory first. Any error thrown here
348
+ // (oversized row, bad value) aborts before the file is touched.
341
349
  for (const row of rows) {
342
350
  const end = encRowInPlace(row, encoders, scratch, 0);
351
+ if (end > SZ.DATA) {
352
+ throw new Error(
353
+ `row in table '${name}' is too large (${end} bytes > max row size ${SZ.DATA} bytes); ` +
354
+ 'split the value or reduce field sizes before saving'
355
+ );
356
+ }
343
357
  if (bp + end > SZ.BLOCK) {
344
358
  const raw = Buffer.from(buf.subarray(SZ.HEADER, bp));
345
359
  rawBlocks.push(raw);
@@ -368,13 +382,19 @@ class JSQLFormat {
368
382
  buf.writeUInt32LE(bp - SZ.HEADER, 40);
369
383
  }
370
384
 
385
+ pkEntries.sort((a, b) => a.hash - b.hash);
386
+ pending.push({ name, tableId, rawBlocks, blockIds, pkEntries });
387
+ }
388
+
389
+ // Phase 2: all rows encoded successfully — now write to disk.
390
+ for (const p of pending) {
371
391
  // Compress and write blocks
372
- for (let i = 0; i < rawBlocks.length; i++) {
373
- const bid = blockIds[i];
374
- const compBuf = zlib.gzipSync(rawBlocks[i], { level: 1 });
392
+ for (let i = 0; i < p.rawBlocks.length; i++) {
393
+ const bid = p.blockIds[i];
394
+ const compBuf = zlib.gzipSync(p.rawBlocks[i], { level: 1 });
375
395
  const block = Buffer.alloc(SZ.BLOCK);
376
- mkHeader(BT.DATA, bid, tableId).copy(block);
377
- block.writeUInt32LE(rawBlocks[i].length, 40);
396
+ mkHeader(BT.DATA, bid, p.tableId).copy(block);
397
+ block.writeUInt32LE(p.rawBlocks[i].length, 40);
378
398
  block[48] = 1;
379
399
  block.writeUInt32LE(compBuf.length, 44);
380
400
  compBuf.copy(block, SZ.HEADER);
@@ -382,13 +402,12 @@ class JSQLFormat {
382
402
  fs.writeSync(this.fd, block, 0, SZ.BLOCK, bid * SZ.BLOCK);
383
403
  }
384
404
 
385
- const t = this.tables.get(name);
386
- t.rowCount = rows.length;
387
- t.dataBlock = blockIds.length > 0 ? blockIds[blockIds.length - 1] : 0;
405
+ const t = this.tables.get(p.name);
406
+ t.rowCount = p.pkEntries.length;
407
+ t.dataBlock = p.blockIds.length > 0 ? p.blockIds[p.blockIds.length - 1] : 0;
388
408
 
389
- pkEntries.sort((a, b) => a.hash - b.hash);
390
- this.pkIdx.set(name, pkEntries);
391
- this._saveIdx(name);
409
+ this.pkIdx.set(p.name, p.pkEntries);
410
+ this._saveIdx(p.name);
392
411
  }
393
412
 
394
413
  this._saveSuper();