@raft-hlc-sync-protocol/raft-sync-lib 1.0.4 → 1.0.5

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/README.md CHANGED
@@ -118,6 +118,12 @@ const engine = new SyncEngine({
118
118
  console.log(`Shard ${shardId}: leader=${leaderId} local=${isLocal}`);
119
119
  },
120
120
 
121
+ // Required: any node can be elected as Leader and must handle proxied write requests
122
+ onExecuteProxyRequest: async (payload) => {
123
+ // Execute the write request locally and return the result
124
+ return await yourBusinessLogic(payload);
125
+ },
126
+
121
127
  // Optional callbacks
122
128
  onWriteCompleted: () => engine.notifyLocalWrite(),
123
129
  onError: (ctx, err) => console.error(`[sync] ${ctx}:`, err.message),
@@ -126,6 +132,7 @@ const engine = new SyncEngine({
126
132
  engine.registerTable('users', {
127
133
  keyColumns: ['user_id'],
128
134
  dataColumns: ['user_id', 'name', 'email', '_hlc'], // must include _hlc
135
+ // registerTable validates: keyColumns non-empty, dataColumns non-empty, '_hlc' in dataColumns
129
136
  validator: (row) => { if (!row.user_id) throw new Error('missing user_id'); },
130
137
  });
131
138
 
@@ -296,19 +303,39 @@ const db = {
296
303
  },
297
304
 
298
305
  // Optional: inject PG triggers so initTriggers() works
306
+ // Note: must include HLC validation (same as built-in dialect)
299
307
  triggersSQL(tableName, def) {
300
- const keyExpr = def.keyColumns.map(c => `'${c}', NEW.${c}`).join(', ');
308
+ const keyExprNew = def.keyColumns.map(c => `'${c}', NEW.${c}`).join(', ');
309
+ const keyExprOld = def.keyColumns.map(c => `'${c}', OLD.${c}`).join(', ');
301
310
  const dataExpr = def.dataColumns.map(c => `'${c}', NEW.${c}`).join(', ');
302
311
  return [
312
+ // HLC validation: enforce valid, monotonically increasing _hlc
313
+ `CREATE OR REPLACE FUNCTION _sync_trg_${tableName}_hlc_fn() RETURNS TRIGGER AS $$
314
+ BEGIN
315
+ IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
316
+ RAISE EXCEPTION 'sync-lib: _hlc is required for % on ${tableName}', TG_OP;
317
+ END IF;
318
+ IF TG_OP = 'UPDATE' AND NEW._hlc <= OLD._hlc THEN
319
+ RAISE EXCEPTION 'sync-lib: _hlc must advance on UPDATE on ${tableName}';
320
+ END IF;
321
+ RETURN NEW;
322
+ END; $$ LANGUAGE plpgsql`,
323
+ `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc ON ${tableName}`,
324
+ `CREATE TRIGGER _sync_trg_${tableName}_hlc
325
+ BEFORE INSERT OR UPDATE ON ${tableName}
326
+ FOR EACH ROW EXECUTE FUNCTION _sync_trg_${tableName}_hlc_fn()`,
327
+ // Change logging
303
328
  `CREATE OR REPLACE FUNCTION _sync_trg_${tableName}_fn() RETURNS TRIGGER AS $$
304
329
  BEGIN
305
- INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
306
- VALUES ('${tableName}', TG_OP,
307
- json_build_object(${keyExpr})::text,
308
- CASE WHEN TG_OP = 'DELETE' THEN NULL
309
- ELSE json_build_object(${dataExpr})::text END,
310
- COALESCE(NEW._hlc, OLD._hlc));
311
- RETURN COALESCE(NEW, OLD);
330
+ IF TG_OP = 'DELETE' THEN
331
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
332
+ VALUES ('${tableName}', 'DELETE', json_build_object(${keyExprOld})::text, NULL, OLD._hlc);
333
+ RETURN OLD;
334
+ ELSE
335
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
336
+ VALUES ('${tableName}', TG_OP, json_build_object(${keyExprNew})::text, json_build_object(${dataExpr})::text, NEW._hlc);
337
+ RETURN NEW;
338
+ END IF;
312
339
  END; $$ LANGUAGE plpgsql`,
313
340
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName} ON ${tableName}`,
314
341
  `CREATE TRIGGER _sync_trg_${tableName}
@@ -318,6 +345,8 @@ const db = {
318
345
  },
319
346
  dropTriggersSQL(tableName) {
320
347
  return [
348
+ `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc ON ${tableName}`,
349
+ `DROP FUNCTION IF EXISTS _sync_trg_${tableName}_hlc_fn`,
321
350
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName} ON ${tableName}`,
322
351
  `DROP FUNCTION IF EXISTS _sync_trg_${tableName}_fn`,
323
352
  ];
@@ -380,27 +409,49 @@ const db = {
380
409
  },
381
410
 
382
411
  // Optional: inject MySQL triggers so initTriggers() works
412
+ // Note: must include HLC validation (same as built-in dialect)
383
413
  triggersSQL(tableName, def) {
384
414
  const keyExpr = def.keyColumns.map(c => `'${c}', NEW.${c}`).join(', ');
385
415
  const dataExpr = def.dataColumns.map(c => `'${c}', NEW.${c}`).join(', ');
386
416
  const delKeyExpr = def.keyColumns.map(c => `'${c}', OLD.${c}`).join(', ');
387
417
  return [
418
+ // HLC validation: enforce valid, monotonically increasing _hlc
419
+ `CREATE TRIGGER _sync_trg_${tableName}_hlc_insert
420
+ BEFORE INSERT ON ${tableName} FOR EACH ROW
421
+ BEGIN
422
+ IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
423
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc is required for INSERT on ${tableName}';
424
+ END IF;
425
+ END`,
426
+ `CREATE TRIGGER _sync_trg_${tableName}_hlc_update
427
+ BEFORE UPDATE ON ${tableName} FOR EACH ROW
428
+ BEGIN
429
+ IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
430
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc is required for UPDATE on ${tableName}';
431
+ END IF;
432
+ IF NEW._hlc <= OLD._hlc THEN
433
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc must advance on UPDATE on ${tableName}';
434
+ END IF;
435
+ END`,
436
+ // Change logging
388
437
  `CREATE TRIGGER _sync_trg_${tableName}_insert
389
- AFTER INSERT ON ${tableName} FOR EACH ROW
390
- INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
391
- VALUES ('${tableName}', 'INSERT', JSON_OBJECT(${keyExpr}), JSON_OBJECT(${dataExpr}), NEW._hlc)`,
438
+ AFTER INSERT ON ${tableName} FOR EACH ROW
439
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
440
+ VALUES ('${tableName}', 'INSERT', JSON_OBJECT(${keyExpr}), JSON_OBJECT(${dataExpr}), NEW._hlc)`,
392
441
  `CREATE TRIGGER _sync_trg_${tableName}_update
393
- AFTER UPDATE ON ${tableName} FOR EACH ROW
394
- INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
395
- VALUES ('${tableName}', 'UPDATE', JSON_OBJECT(${keyExpr}), JSON_OBJECT(${dataExpr}), NEW._hlc)`,
442
+ AFTER UPDATE ON ${tableName} FOR EACH ROW
443
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
444
+ VALUES ('${tableName}', 'UPDATE', JSON_OBJECT(${keyExpr}), JSON_OBJECT(${dataExpr}), NEW._hlc)`,
396
445
  `CREATE TRIGGER _sync_trg_${tableName}_delete
397
- AFTER DELETE ON ${tableName} FOR EACH ROW
398
- INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
399
- VALUES ('${tableName}', 'DELETE', JSON_OBJECT(${delKeyExpr}), NULL, OLD._hlc)`,
446
+ AFTER DELETE ON ${tableName} FOR EACH ROW
447
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
448
+ VALUES ('${tableName}', 'DELETE', JSON_OBJECT(${delKeyExpr}), NULL, OLD._hlc)`,
400
449
  ];
401
450
  },
402
451
  dropTriggersSQL(tableName) {
403
452
  return [
453
+ `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc_insert`,
454
+ `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc_update`,
404
455
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_insert`,
405
456
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_update`,
406
457
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_delete`,
@@ -419,7 +470,7 @@ const db = {
419
470
  |--------|-------------|
420
471
  | `registerTable(name, def)` | Register a business table for sync |
421
472
  | `initSchema()` | Create infrastructure tables (`_sync_log`, `_sync_peers`, `_tombstones`) |
422
- | `initTriggers()` | Create SQLite triggers (skip for other DBs) |
473
+ | `initTriggers()` | Create auto-logging triggers (dialect-aware; works for SQLite, PG, MySQL) |
423
474
  | `start()` / `stop()` | Start/stop leader election |
424
475
  | `peerConnected(id, opts)` | Notify: new peer connected |
425
476
  | `receiveMessage(id, raw)` | Notify: message received from peer |
@@ -444,19 +495,17 @@ const db = {
444
495
  ### Standalone utilities
445
496
 
446
497
  ```js
447
- import { HLC, DIALECTS, applyDialect, logChange, getInfraSchemaSQL, getTriggersSQL } from 'sync-lib';
498
+ import { HLC, DIALECTS, applyDialect, logChange, applyEntries } from 'sync-lib';
448
499
  ```
449
500
 
450
501
  | Export | Description |
451
502
  |--------|-------------|
452
503
  | `HLC` | Hybrid Logical Clock class |
453
504
  | `LeaderElection` | Leader election state machine |
454
- | `DIALECTS` | Built-in dialect definitions (sqlite, postgresql, mysql) |
505
+ | `DIALECTS` | Built-in dialect definitions (sqlite, postgresql, mysql). Each dialect provides `infraSchemaSQL`, `upsertSQL`, `triggersSQL`, `dropTriggersSQL` |
455
506
  | `applyDialect(db, name)` | Apply a dialect's methods to a db adapter |
456
507
  | `validateDialectMethods(db)` | Validate all required dialect methods exist |
457
508
  | `logChange(db, table, op, key, data, hlc)` | Manually log a change (alternative to triggers) |
458
- | `getInfraSchemaSQL()` | Built-in SQLite DDL for infrastructure tables |
459
- | `getTriggersSQL(table, def)` | Generate SQLite sync triggers |
460
509
  | `applyEntries(db, entries, hlc, registry)` | Apply remote entries idempotently |
461
510
 
462
511
  ---
package/README.zh-CN.md CHANGED
@@ -118,6 +118,12 @@ const engine = new SyncEngine({
118
118
  console.log(`分片 ${shardId}: leader=${leaderId} local=${isLocal}`);
119
119
  },
120
120
 
121
+ // 必需:任何节点都可能当选 Leader,必须能处理 Follower 转发来的写请求
122
+ onExecuteProxyRequest: async (payload) => {
123
+ // 在本地执行写请求并返回结果
124
+ return await yourBusinessLogic(payload);
125
+ },
126
+
121
127
  // 可选回调
122
128
  onWriteCompleted: () => engine.notifyLocalWrite(),
123
129
  onError: (ctx, err) => console.error(`[sync] ${ctx}:`, err.message),
@@ -126,6 +132,7 @@ const engine = new SyncEngine({
126
132
  engine.registerTable('users', {
127
133
  keyColumns: ['user_id'],
128
134
  dataColumns: ['user_id', 'name', 'email', '_hlc'], // 必须包含 _hlc
135
+ // registerTable 会校验:keyColumns 非空、dataColumns 非空、dataColumns 包含 '_hlc'
129
136
  validator: (row) => { if (!row.user_id) throw new Error('缺少 user_id'); },
130
137
  });
131
138
 
@@ -296,19 +303,39 @@ const db = {
296
303
  },
297
304
 
298
305
  // 可选:注入 PG 触发器,使 initTriggers() 自动创建
306
+ // 注意:必须包含 HLC 校验逻辑(与内置方言一致)
299
307
  triggersSQL(tableName, def) {
300
- const keyExpr = def.keyColumns.map(c => `'${c}', NEW.${c}`).join(', ');
308
+ const keyExprNew = def.keyColumns.map(c => `'${c}', NEW.${c}`).join(', ');
309
+ const keyExprOld = def.keyColumns.map(c => `'${c}', OLD.${c}`).join(', ');
301
310
  const dataExpr = def.dataColumns.map(c => `'${c}', NEW.${c}`).join(', ');
302
311
  return [
312
+ // HLC 校验:强制要求 _hlc 有效且单调递增
313
+ `CREATE OR REPLACE FUNCTION _sync_trg_${tableName}_hlc_fn() RETURNS TRIGGER AS $$
314
+ BEGIN
315
+ IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
316
+ RAISE EXCEPTION 'sync-lib: _hlc is required for % on ${tableName}', TG_OP;
317
+ END IF;
318
+ IF TG_OP = 'UPDATE' AND NEW._hlc <= OLD._hlc THEN
319
+ RAISE EXCEPTION 'sync-lib: _hlc must advance on UPDATE on ${tableName}';
320
+ END IF;
321
+ RETURN NEW;
322
+ END; $$ LANGUAGE plpgsql`,
323
+ `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc ON ${tableName}`,
324
+ `CREATE TRIGGER _sync_trg_${tableName}_hlc
325
+ BEFORE INSERT OR UPDATE ON ${tableName}
326
+ FOR EACH ROW EXECUTE FUNCTION _sync_trg_${tableName}_hlc_fn()`,
327
+ // 变更记录
303
328
  `CREATE OR REPLACE FUNCTION _sync_trg_${tableName}_fn() RETURNS TRIGGER AS $$
304
329
  BEGIN
305
- INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
306
- VALUES ('${tableName}', TG_OP,
307
- json_build_object(${keyExpr})::text,
308
- CASE WHEN TG_OP = 'DELETE' THEN NULL
309
- ELSE json_build_object(${dataExpr})::text END,
310
- COALESCE(NEW._hlc, OLD._hlc));
311
- RETURN COALESCE(NEW, OLD);
330
+ IF TG_OP = 'DELETE' THEN
331
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
332
+ VALUES ('${tableName}', 'DELETE', json_build_object(${keyExprOld})::text, NULL, OLD._hlc);
333
+ RETURN OLD;
334
+ ELSE
335
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
336
+ VALUES ('${tableName}', TG_OP, json_build_object(${keyExprNew})::text, json_build_object(${dataExpr})::text, NEW._hlc);
337
+ RETURN NEW;
338
+ END IF;
312
339
  END; $$ LANGUAGE plpgsql`,
313
340
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName} ON ${tableName}`,
314
341
  `CREATE TRIGGER _sync_trg_${tableName}
@@ -318,6 +345,8 @@ const db = {
318
345
  },
319
346
  dropTriggersSQL(tableName) {
320
347
  return [
348
+ `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc ON ${tableName}`,
349
+ `DROP FUNCTION IF EXISTS _sync_trg_${tableName}_hlc_fn`,
321
350
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName} ON ${tableName}`,
322
351
  `DROP FUNCTION IF EXISTS _sync_trg_${tableName}_fn`,
323
352
  ];
@@ -380,27 +409,49 @@ const db = {
380
409
  },
381
410
 
382
411
  // 可选:注入 MySQL 触发器,使 initTriggers() 自动创建
412
+ // 注意:必须包含 HLC 校验逻辑(与内置方言一致)
383
413
  triggersSQL(tableName, def) {
384
414
  const keyExpr = def.keyColumns.map(c => `'${c}', NEW.${c}`).join(', ');
385
415
  const dataExpr = def.dataColumns.map(c => `'${c}', NEW.${c}`).join(', ');
386
416
  const delKeyExpr = def.keyColumns.map(c => `'${c}', OLD.${c}`).join(', ');
387
417
  return [
418
+ // HLC 校验:强制要求 _hlc 有效且单调递增
419
+ `CREATE TRIGGER _sync_trg_${tableName}_hlc_insert
420
+ BEFORE INSERT ON ${tableName} FOR EACH ROW
421
+ BEGIN
422
+ IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
423
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc is required for INSERT on ${tableName}';
424
+ END IF;
425
+ END`,
426
+ `CREATE TRIGGER _sync_trg_${tableName}_hlc_update
427
+ BEFORE UPDATE ON ${tableName} FOR EACH ROW
428
+ BEGIN
429
+ IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
430
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc is required for UPDATE on ${tableName}';
431
+ END IF;
432
+ IF NEW._hlc <= OLD._hlc THEN
433
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc must advance on UPDATE on ${tableName}';
434
+ END IF;
435
+ END`,
436
+ // 变更记录
388
437
  `CREATE TRIGGER _sync_trg_${tableName}_insert
389
- AFTER INSERT ON ${tableName} FOR EACH ROW
390
- INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
391
- VALUES ('${tableName}', 'INSERT', JSON_OBJECT(${keyExpr}), JSON_OBJECT(${dataExpr}), NEW._hlc)`,
438
+ AFTER INSERT ON ${tableName} FOR EACH ROW
439
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
440
+ VALUES ('${tableName}', 'INSERT', JSON_OBJECT(${keyExpr}), JSON_OBJECT(${dataExpr}), NEW._hlc)`,
392
441
  `CREATE TRIGGER _sync_trg_${tableName}_update
393
- AFTER UPDATE ON ${tableName} FOR EACH ROW
394
- INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
395
- VALUES ('${tableName}', 'UPDATE', JSON_OBJECT(${keyExpr}), JSON_OBJECT(${dataExpr}), NEW._hlc)`,
442
+ AFTER UPDATE ON ${tableName} FOR EACH ROW
443
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
444
+ VALUES ('${tableName}', 'UPDATE', JSON_OBJECT(${keyExpr}), JSON_OBJECT(${dataExpr}), NEW._hlc)`,
396
445
  `CREATE TRIGGER _sync_trg_${tableName}_delete
397
- AFTER DELETE ON ${tableName} FOR EACH ROW
398
- INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
399
- VALUES ('${tableName}', 'DELETE', JSON_OBJECT(${delKeyExpr}), NULL, OLD._hlc)`,
446
+ AFTER DELETE ON ${tableName} FOR EACH ROW
447
+ INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
448
+ VALUES ('${tableName}', 'DELETE', JSON_OBJECT(${delKeyExpr}), NULL, OLD._hlc)`,
400
449
  ];
401
450
  },
402
451
  dropTriggersSQL(tableName) {
403
452
  return [
453
+ `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc_insert`,
454
+ `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc_update`,
404
455
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_insert`,
405
456
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_update`,
406
457
  `DROP TRIGGER IF EXISTS _sync_trg_${tableName}_delete`,
@@ -419,7 +470,7 @@ const db = {
419
470
  |------|------|
420
471
  | `registerTable(name, def)` | 注册业务表用于同步 |
421
472
  | `initSchema()` | 创建基础设施表(`_sync_log`、`_sync_peers`、`_tombstones`) |
422
- | `initTriggers()` | 创建 SQLite 触发器(其他数据库跳过) |
473
+ | `initTriggers()` | 创建同步触发器(方言感知,支持 SQLite、PG、MySQL) |
423
474
  | `start()` / `stop()` | 启动/停止 Leader 选举 |
424
475
  | `peerConnected(id, opts)` | 通知:新的对等节点已连接 |
425
476
  | `receiveMessage(id, raw)` | 通知:收到对等节点消息 |
@@ -444,19 +495,17 @@ const db = {
444
495
  ### 独立工具函数
445
496
 
446
497
  ```js
447
- import { HLC, DIALECTS, applyDialect, logChange, getInfraSchemaSQL, getTriggersSQL } from 'sync-lib';
498
+ import { HLC, DIALECTS, applyDialect, logChange, applyEntries } from 'sync-lib';
448
499
  ```
449
500
 
450
501
  | 导出 | 说明 |
451
502
  |------|------|
452
503
  | `HLC` | 混合逻辑时钟类 |
453
504
  | `LeaderElection` | Leader 选举状态机 |
454
- | `DIALECTS` | 内置方言定义(sqlite、postgresql、mysql |
505
+ | `DIALECTS` | 内置方言定义(sqlite、postgresql、mysql)。每种方言提供 `infraSchemaSQL`、`upsertSQL`、`triggersSQL`、`dropTriggersSQL` |
455
506
  | `applyDialect(db, name)` | 将方言方法应用到 db 适配器 |
456
507
  | `validateDialectMethods(db)` | 校验 db 是否具备所有必需的方言方法 |
457
508
  | `logChange(db, table, op, key, data, hlc)` | 手动记录变更(触发器的替代方案) |
458
- | `getInfraSchemaSQL()` | 内置 SQLite DDL(基础设施表) |
459
- | `getTriggersSQL(table, def)` | 生成 SQLite 同步触发器 |
460
509
  | `applyEntries(db, entries, hlc, registry)` | 幂等地应用远程条目 |
461
510
 
462
511
  ---
package/index.js CHANGED
@@ -39,11 +39,12 @@
39
39
  * onSendToPeer: (peerId, msg) => transport.send(peerId, msg),
40
40
  * onClosePeer: (peerId, reason) => transport.close(peerId),
41
41
  * onLeaderChanged: (shardId, leaderId, isLocal) => { ... },
42
+ * onExecuteProxyRequest: async (payload) => handleRequest(payload),
43
+ * // ⚠️ 必须注册:任何节点都可能当选 Leader,必须能处理 Follower 转发的写请求
42
44
  *
43
45
  * // ── 可选回调 ──
44
46
  * onWriteCompleted: () => engine.notifyLocalWrite(),
45
47
  * onError: (ctx, err) => logger.error(ctx, err),
46
- * onExecuteProxyRequest: (payload) => handleRequest(payload),
47
48
  *
48
49
  * // ── 可选配置(全部有默认值)──
49
50
  * numShards: 16,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raft-hlc-sync-protocol/raft-sync-lib",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "pure javascript raft and hlc sync protocol",
5
5
  "keywords": [
6
6
  "raft",
package/sync-engine.js CHANGED
@@ -62,9 +62,12 @@
62
62
  * │ 错误通知。context 是出错的位置字符串。 │
63
63
  * │ │
64
64
  * │ onExecuteProxyRequest(payload) → Promise<object> │
65
- * │ Leader 收到代理请求后的执行回调。
65
+ * │ Leader 收到 Follower 转发的写请求后的执行回调。
66
66
  * │ payload / response 格式由外部业务自行定义,对模块透明。 │
67
67
  * │ │
68
+ * │ ⚠️ 多节点部署时必须注册:节点无法控制自己是否当选 Leader, │
69
+ * │ 若当选后未注册此回调,Follower 转发来的请求将返回错误。 │
70
+ * │ │
68
71
  * └────────────────────────────────────────────────────────────┘
69
72
  *
70
73
  * ═══════════════════════════════════════════════════════════════
@@ -179,7 +182,12 @@ export class SyncEngine {
179
182
  * ── 可选回调 ──
180
183
  * @param {function(): void} [options.onWriteCompleted] - 同步数据写入完成通知
181
184
  * @param {function(string, Error): void} [options.onError] - 错误通知
182
- * @param {function(object): Promise<object>} [options.onExecuteProxyRequest] - 代理请求执行
185
+ *
186
+ * ── 必须回调 ──
187
+ * @param {function(object): Promise<object>} options.onExecuteProxyRequest - 代理请求执行
188
+ * Leader 收到 Follower 转发的写请求后的执行回调。
189
+ * 节点无法控制自己是否当选 Leader,因此此回调始终必须注册。
190
+ * payload / response 格式由外部业务自行定义,对模块透明。
183
191
  *
184
192
  * ── 方言 ──
185
193
  * @param {string} [options.dialect] - 数据库方言:'sqlite' | 'postgresql' | 'mysql'
@@ -217,7 +225,16 @@ export class SyncEngine {
217
225
  // ===== 可选回调 =====
218
226
  this._onWriteCompleted = options.onWriteCompleted || (() => {});
219
227
  this._onError = options.onError || (() => {});
220
- this._onExecuteProxyRequest = options.onExecuteProxyRequest || null;
228
+
229
+ // ===== 必须回调:onExecuteProxyRequest =====
230
+ // 节点无法控制自己是否当选 Leader,因此此回调始终必须注册。
231
+ if (typeof options.onExecuteProxyRequest !== 'function') {
232
+ throw new Error(
233
+ 'SyncEngine: onExecuteProxyRequest is required. ' +
234
+ 'Any node can be elected as Leader and must be able to handle proxied write requests from Followers.'
235
+ );
236
+ }
237
+ this._onExecuteProxyRequest = options.onExecuteProxyRequest;
221
238
 
222
239
  // ===== 外部配置 =====
223
240
  this._numShards = options.numShards ?? 16;
@@ -278,12 +295,21 @@ export class SyncEngine {
278
295
  * @param {string} name - 表名
279
296
  * @param {object} def
280
297
  * @param {string[]} def.keyColumns - 主键列名数组
281
- * @param {string[]} def.dataColumns - 全部列名数组(含 _hlc)
298
+ * @param {string[]} def.dataColumns - 全部列名数组(必须包含 '_hlc'
282
299
  * @param {string[]} [def.blobColumns] - BLOB 列名(触发器中用 hex 序列化)
283
300
  * @param {function(object): void} [def.validator] - 数据验证,不合法时抛异常
284
301
  * @param {function(object): object} [def.deserializer] - 反序列化(如 hex→Buffer)
285
302
  */
286
303
  registerTable(name, def) {
304
+ if (!def.keyColumns || def.keyColumns.length === 0) {
305
+ throw new Error(`registerTable('${name}'): keyColumns is required and must be non-empty`);
306
+ }
307
+ if (!def.dataColumns || def.dataColumns.length === 0) {
308
+ throw new Error(`registerTable('${name}'): dataColumns is required and must be non-empty`);
309
+ }
310
+ if (!def.dataColumns.includes('_hlc')) {
311
+ throw new Error(`registerTable('${name}'): dataColumns must include '_hlc' column`);
312
+ }
287
313
  this._tableRegistry.set(name, {
288
314
  keyColumns: def.keyColumns,
289
315
  dataColumns: def.dataColumns,
@@ -643,7 +669,12 @@ export class SyncEngine {
643
669
  }
644
670
 
645
671
  if (leaderNodeId === this.nodeId) {
646
- if (!this._onExecuteProxyRequest) throw new Error('onExecuteProxyRequest not registered');
672
+ if (!this._onExecuteProxyRequest) {
673
+ throw new Error(
674
+ 'onExecuteProxyRequest is not registered on this node, but it is the Leader. ' +
675
+ 'In a multi-node deployment, all nodes must register onExecuteProxyRequest.'
676
+ );
677
+ }
647
678
  return this._onExecuteProxyRequest(payload);
648
679
  }
649
680
 
@@ -843,7 +874,15 @@ export class SyncEngine {
843
874
  const { requestId, payload } = msg;
844
875
  try {
845
876
  if (!this._onExecuteProxyRequest) {
846
- this._sendToPeer(peerId, makeProxyRes(requestId, { error: 'proxy not supported' }));
877
+ // onExecuteProxyRequest 未注册:当前节点是 Leader 但无法处理代理请求。
878
+ // 这通常意味着多节点部署时忘记注册此回调。
879
+ const err = new Error(
880
+ 'onExecuteProxyRequest is not registered on this node, but it was elected as Leader. ' +
881
+ 'In a multi-node deployment, all nodes must register onExecuteProxyRequest ' +
882
+ 'because any node can become Leader.'
883
+ );
884
+ this._onError('proxy_exec', err);
885
+ this._sendToPeer(peerId, makeProxyRes(requestId, { error: err.message }));
847
886
  return;
848
887
  }
849
888
  const result = await this._onExecuteProxyRequest(payload);
package/sync-protocol.js CHANGED
@@ -407,7 +407,7 @@ export function cleanTombstones(db, retentionDays = 7) {
407
407
 
408
408
  /**
409
409
  * 手动记录一条数据变更到 _sync_log
410
- * 适用于不支持触发器或不使用 SQLite 的数据库。
410
+ * 适用于不支持触发器的数据库。
411
411
  * 外部业务代码在每次写操作后调用此方法。
412
412
  *
413
413
  * @param {DatabaseAdapter} db