@raft-hlc-sync-protocol/raft-sync-lib 1.0.3 → 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 +71 -22
- package/README.zh-CN.md +71 -22
- package/dialects.js +135 -5
- package/index.js +16 -6
- package/package.json +2 -2
- package/sync-engine.js +45 -9
- package/sync-protocol.js +1 -109
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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()` |
|
|
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,
|
|
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/dialects.js
CHANGED
|
@@ -17,8 +17,6 @@
|
|
|
17
17
|
* 若不指定 dialect 或指定不认识的值,所有方言方法必须由 db 自行实现,否则报错。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { getInfraSchemaSQL, getTriggersSQL, dropTriggersSQL } from './sync-protocol.js';
|
|
21
|
-
|
|
22
20
|
// ═══════════════════════════════════════════════════
|
|
23
21
|
// SQLite
|
|
24
22
|
// ═══════════════════════════════════════════════════
|
|
@@ -34,9 +32,101 @@ const SQLITE = {
|
|
|
34
32
|
return `INSERT OR REPLACE INTO ${table} (${cols}) VALUES (${ph})`;
|
|
35
33
|
},
|
|
36
34
|
|
|
37
|
-
infraSchemaSQL
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
infraSchemaSQL() {
|
|
36
|
+
return [
|
|
37
|
+
`CREATE TABLE IF NOT EXISTS _sync_log (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
table_name TEXT NOT NULL,
|
|
40
|
+
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
41
|
+
row_key TEXT NOT NULL,
|
|
42
|
+
row_data TEXT,
|
|
43
|
+
_hlc TEXT NOT NULL,
|
|
44
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
45
|
+
)`,
|
|
46
|
+
`CREATE INDEX IF NOT EXISTS idx_sync_log_id ON _sync_log (id)`,
|
|
47
|
+
`CREATE TABLE IF NOT EXISTS _sync_peers (
|
|
48
|
+
peer_key TEXT NOT NULL,
|
|
49
|
+
last_sync_id INTEGER NOT NULL DEFAULT 0,
|
|
50
|
+
last_sync_at TEXT,
|
|
51
|
+
PRIMARY KEY (peer_key)
|
|
52
|
+
)`,
|
|
53
|
+
`CREATE TABLE IF NOT EXISTS _tombstones (
|
|
54
|
+
table_name TEXT NOT NULL,
|
|
55
|
+
row_key TEXT NOT NULL,
|
|
56
|
+
_hlc TEXT NOT NULL,
|
|
57
|
+
PRIMARY KEY (table_name, row_key)
|
|
58
|
+
)`,
|
|
59
|
+
];
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
triggersSQL(tableName, def) {
|
|
63
|
+
const blobSet = new Set(def.blobColumns || []);
|
|
64
|
+
|
|
65
|
+
const keyExpr = (ref) => {
|
|
66
|
+
const parts = def.keyColumns.map((col) => `'${col}', ${ref}.${col}`);
|
|
67
|
+
return `json_object(${parts.join(', ')})`;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const dataExpr = (ref) => {
|
|
71
|
+
const parts = def.dataColumns.map((col) => {
|
|
72
|
+
if (blobSet.has(col)) return `'${col}', hex(${ref}.${col})`;
|
|
73
|
+
return `'${col}', ${ref}.${col}`;
|
|
74
|
+
});
|
|
75
|
+
return `json_object(${parts.join(', ')})`;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
// BEFORE INSERT:强制要求 _hlc 非空且非默认值 '0'
|
|
80
|
+
`
|
|
81
|
+
CREATE TRIGGER IF NOT EXISTS _sync_trg_${tableName}_hlc_insert
|
|
82
|
+
BEFORE INSERT ON ${tableName}
|
|
83
|
+
BEGIN
|
|
84
|
+
SELECT RAISE(ABORT, 'sync-lib: _hlc is required for INSERT on ${tableName}')
|
|
85
|
+
WHERE NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0';
|
|
86
|
+
END`,
|
|
87
|
+
// BEFORE UPDATE:强制要求 _hlc 非空且非默认值 '0',且必须严格大于旧值(防止时钟回退)
|
|
88
|
+
`
|
|
89
|
+
CREATE TRIGGER IF NOT EXISTS _sync_trg_${tableName}_hlc_update
|
|
90
|
+
BEFORE UPDATE ON ${tableName}
|
|
91
|
+
BEGIN
|
|
92
|
+
SELECT RAISE(ABORT, 'sync-lib: _hlc is required for UPDATE on ${tableName}')
|
|
93
|
+
WHERE NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0';
|
|
94
|
+
SELECT RAISE(ABORT, 'sync-lib: _hlc must advance on UPDATE on ${tableName}')
|
|
95
|
+
WHERE NEW._hlc <= OLD._hlc;
|
|
96
|
+
END`,
|
|
97
|
+
`
|
|
98
|
+
CREATE TRIGGER IF NOT EXISTS _sync_trg_${tableName}_insert
|
|
99
|
+
AFTER INSERT ON ${tableName}
|
|
100
|
+
BEGIN
|
|
101
|
+
INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
|
|
102
|
+
VALUES ('${tableName}', 'INSERT', ${keyExpr('NEW')}, ${dataExpr('NEW')}, NEW._hlc);
|
|
103
|
+
END`,
|
|
104
|
+
`
|
|
105
|
+
CREATE TRIGGER IF NOT EXISTS _sync_trg_${tableName}_update
|
|
106
|
+
AFTER UPDATE ON ${tableName}
|
|
107
|
+
BEGIN
|
|
108
|
+
INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
|
|
109
|
+
VALUES ('${tableName}', 'UPDATE', ${keyExpr('NEW')}, ${dataExpr('NEW')}, NEW._hlc);
|
|
110
|
+
END`,
|
|
111
|
+
`
|
|
112
|
+
CREATE TRIGGER IF NOT EXISTS _sync_trg_${tableName}_delete
|
|
113
|
+
AFTER DELETE ON ${tableName}
|
|
114
|
+
BEGIN
|
|
115
|
+
INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
|
|
116
|
+
VALUES ('${tableName}', 'DELETE', ${keyExpr('OLD')}, NULL, OLD._hlc);
|
|
117
|
+
END`,
|
|
118
|
+
];
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
dropTriggersSQL(tableName) {
|
|
122
|
+
return [
|
|
123
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc_insert`,
|
|
124
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc_update`,
|
|
125
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_insert`,
|
|
126
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_update`,
|
|
127
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_delete`,
|
|
128
|
+
];
|
|
129
|
+
},
|
|
40
130
|
};
|
|
41
131
|
|
|
42
132
|
// ═══════════════════════════════════════════════════
|
|
@@ -98,6 +188,22 @@ const POSTGRESQL = {
|
|
|
98
188
|
}).join(', ');
|
|
99
189
|
|
|
100
190
|
return [
|
|
191
|
+
// HLC 校验函数:INSERT/UPDATE 时强制要求 _hlc 有效且单调递增
|
|
192
|
+
`CREATE OR REPLACE FUNCTION _sync_trg_${tableName}_hlc_fn() RETURNS TRIGGER AS $$
|
|
193
|
+
BEGIN
|
|
194
|
+
IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
|
|
195
|
+
RAISE EXCEPTION 'sync-lib: _hlc is required for % on ${tableName}', TG_OP;
|
|
196
|
+
END IF;
|
|
197
|
+
IF TG_OP = 'UPDATE' AND NEW._hlc <= OLD._hlc THEN
|
|
198
|
+
RAISE EXCEPTION 'sync-lib: _hlc must advance on UPDATE on ${tableName}';
|
|
199
|
+
END IF;
|
|
200
|
+
RETURN NEW;
|
|
201
|
+
END; $$ LANGUAGE plpgsql`,
|
|
202
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc ON ${tableName}`,
|
|
203
|
+
`CREATE TRIGGER _sync_trg_${tableName}_hlc
|
|
204
|
+
BEFORE INSERT OR UPDATE ON ${tableName}
|
|
205
|
+
FOR EACH ROW EXECUTE FUNCTION _sync_trg_${tableName}_hlc_fn()`,
|
|
206
|
+
// 变更记录函数:写入 _sync_log
|
|
101
207
|
`CREATE OR REPLACE FUNCTION _sync_trg_${tableName}_fn() RETURNS TRIGGER AS $$
|
|
102
208
|
BEGIN
|
|
103
209
|
IF TG_OP = 'DELETE' THEN
|
|
@@ -119,6 +225,8 @@ FOR EACH ROW EXECUTE FUNCTION _sync_trg_${tableName}_fn()`,
|
|
|
119
225
|
|
|
120
226
|
dropTriggersSQL(tableName) {
|
|
121
227
|
return [
|
|
228
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc ON ${tableName}`,
|
|
229
|
+
`DROP FUNCTION IF EXISTS _sync_trg_${tableName}_hlc_fn`,
|
|
122
230
|
`DROP TRIGGER IF EXISTS _sync_trg_${tableName} ON ${tableName}`,
|
|
123
231
|
`DROP FUNCTION IF EXISTS _sync_trg_${tableName}_fn`,
|
|
124
232
|
];
|
|
@@ -180,6 +288,26 @@ const MYSQL = {
|
|
|
180
288
|
}).join(', ');
|
|
181
289
|
|
|
182
290
|
return [
|
|
291
|
+
// HLC 校验触发器:INSERT 时强制要求 _hlc 有效
|
|
292
|
+
`CREATE TRIGGER _sync_trg_${tableName}_hlc_insert
|
|
293
|
+
BEFORE INSERT ON ${tableName} FOR EACH ROW
|
|
294
|
+
BEGIN
|
|
295
|
+
IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
|
|
296
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc is required for INSERT on ${tableName}';
|
|
297
|
+
END IF;
|
|
298
|
+
END`,
|
|
299
|
+
// HLC 校验触发器:UPDATE 时强制要求 _hlc 有效且单调递增
|
|
300
|
+
`CREATE TRIGGER _sync_trg_${tableName}_hlc_update
|
|
301
|
+
BEFORE UPDATE ON ${tableName} FOR EACH ROW
|
|
302
|
+
BEGIN
|
|
303
|
+
IF NEW._hlc IS NULL OR NEW._hlc = '' OR NEW._hlc = '0' THEN
|
|
304
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc is required for UPDATE on ${tableName}';
|
|
305
|
+
END IF;
|
|
306
|
+
IF NEW._hlc <= OLD._hlc THEN
|
|
307
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'sync-lib: _hlc must advance on UPDATE on ${tableName}';
|
|
308
|
+
END IF;
|
|
309
|
+
END`,
|
|
310
|
+
// 变更记录触发器
|
|
183
311
|
`CREATE TRIGGER _sync_trg_${tableName}_insert
|
|
184
312
|
AFTER INSERT ON ${tableName} FOR EACH ROW
|
|
185
313
|
INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
|
|
@@ -197,6 +325,8 @@ VALUES ('${tableName}', 'DELETE', JSON_OBJECT(${keyExpr('OLD')}), NULL, OLD._hlc
|
|
|
197
325
|
|
|
198
326
|
dropTriggersSQL(tableName) {
|
|
199
327
|
return [
|
|
328
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc_insert`,
|
|
329
|
+
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_hlc_update`,
|
|
200
330
|
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_insert`,
|
|
201
331
|
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_update`,
|
|
202
332
|
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_delete`,
|
package/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* import { SyncEngine } from './sync-lib/index.js';
|
|
12
12
|
*
|
|
13
|
-
* // 1. 外部实现 DatabaseAdapter
|
|
13
|
+
* // 1. 外部实现 DatabaseAdapter 基础查询接口
|
|
14
14
|
* const db = {
|
|
15
15
|
* run(sql, params) { ... }, // → { changes: number }
|
|
16
16
|
* get(sql, params) { ... }, // → Object | null
|
|
@@ -23,15 +23,28 @@
|
|
|
23
23
|
* nodeId: 'node-1',
|
|
24
24
|
* db,
|
|
25
25
|
*
|
|
26
|
+
* // ── 方言(推荐)──
|
|
27
|
+
* // 指定内置方言后,db 上缺失的 SQL 方法由 dialects.js 自动填充:
|
|
28
|
+
* // beginTransaction / commit / rollback
|
|
29
|
+
* // upsertSQL / infraSchemaSQL / triggersSQL / dropTriggersSQL
|
|
30
|
+
* // 支持:'sqlite' | 'postgresql' | 'postgres' | 'pg' | 'mysql'
|
|
31
|
+
* dialect: 'sqlite',
|
|
32
|
+
*
|
|
33
|
+
* // ── 方言方法优先级 ──
|
|
34
|
+
* // db 上已有的方法 > dialect 自动填充 > 报错(未指定 dialect 且方法缺失时)
|
|
35
|
+
* // 即:用户在 db 上手动注入的方法始终优先于内置方言实现。
|
|
36
|
+
* // 例如:db.upsertSQL = (table, cols, keys) => '...' // 覆盖内置 SQLite upsert
|
|
37
|
+
*
|
|
26
38
|
* // ── 必须回调 ──
|
|
27
39
|
* onSendToPeer: (peerId, msg) => transport.send(peerId, msg),
|
|
28
40
|
* onClosePeer: (peerId, reason) => transport.close(peerId),
|
|
29
41
|
* onLeaderChanged: (shardId, leaderId, isLocal) => { ... },
|
|
42
|
+
* onExecuteProxyRequest: async (payload) => handleRequest(payload),
|
|
43
|
+
* // ⚠️ 必须注册:任何节点都可能当选 Leader,必须能处理 Follower 转发的写请求
|
|
30
44
|
*
|
|
31
45
|
* // ── 可选回调 ──
|
|
32
46
|
* onWriteCompleted: () => engine.notifyLocalWrite(),
|
|
33
47
|
* onError: (ctx, err) => logger.error(ctx, err),
|
|
34
|
-
* onExecuteProxyRequest: (payload) => handleRequest(payload),
|
|
35
48
|
*
|
|
36
49
|
* // ── 可选配置(全部有默认值)──
|
|
37
50
|
* numShards: 16,
|
|
@@ -106,6 +119,7 @@
|
|
|
106
119
|
* ├── hlc.js ← HLC 混合逻辑时钟(纯算法)
|
|
107
120
|
* ├── leader-election.js ← Raft 简化版选举状态机(onXXX 回调)
|
|
108
121
|
* ├── sync-protocol.js ← 消息编解码 + 幂等数据应用(DatabaseAdapter 接口)
|
|
122
|
+
* ├── dialects.js ← 内置 SQL 方言(SQLite/PostgreSQL/MySQL),所有 SQL 逻辑的唯一来源
|
|
109
123
|
* └── sync-engine.js ← 主引擎(组合以上模块,对外统一入口)
|
|
110
124
|
*/
|
|
111
125
|
|
|
@@ -151,8 +165,4 @@ export {
|
|
|
151
165
|
updatePeerSyncId,
|
|
152
166
|
// 墓碑
|
|
153
167
|
cleanTombstones,
|
|
154
|
-
// Schema / Trigger SQL 生成
|
|
155
|
-
getInfraSchemaSQL,
|
|
156
|
-
getTriggersSQL,
|
|
157
|
-
dropTriggersSQL,
|
|
158
168
|
} from './sync-protocol.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raft-hlc-sync-protocol/raft-sync-lib",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "pure javascript raft and hlc sync protocol",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"raft",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"database",
|
|
10
10
|
"sqlite",
|
|
11
11
|
"mysql",
|
|
12
|
-
"
|
|
12
|
+
"postgresql"
|
|
13
13
|
],
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"author": "xnet",
|
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
|
* ═══════════════════════════════════════════════════════════════
|
|
@@ -139,9 +142,6 @@ import {
|
|
|
139
142
|
cleanTombstones,
|
|
140
143
|
getPeerLastSyncId,
|
|
141
144
|
updatePeerSyncId,
|
|
142
|
-
getInfraSchemaSQL,
|
|
143
|
-
getTriggersSQL,
|
|
144
|
-
dropTriggersSQL,
|
|
145
145
|
} from './sync-protocol.js';
|
|
146
146
|
|
|
147
147
|
// ========== 工具函数 ==========
|
|
@@ -182,7 +182,12 @@ export class SyncEngine {
|
|
|
182
182
|
* ── 可选回调 ──
|
|
183
183
|
* @param {function(): void} [options.onWriteCompleted] - 同步数据写入完成通知
|
|
184
184
|
* @param {function(string, Error): void} [options.onError] - 错误通知
|
|
185
|
-
*
|
|
185
|
+
*
|
|
186
|
+
* ── 必须回调 ──
|
|
187
|
+
* @param {function(object): Promise<object>} options.onExecuteProxyRequest - 代理请求执行
|
|
188
|
+
* Leader 收到 Follower 转发的写请求后的执行回调。
|
|
189
|
+
* 节点无法控制自己是否当选 Leader,因此此回调始终必须注册。
|
|
190
|
+
* payload / response 格式由外部业务自行定义,对模块透明。
|
|
186
191
|
*
|
|
187
192
|
* ── 方言 ──
|
|
188
193
|
* @param {string} [options.dialect] - 数据库方言:'sqlite' | 'postgresql' | 'mysql'
|
|
@@ -220,7 +225,16 @@ export class SyncEngine {
|
|
|
220
225
|
// ===== 可选回调 =====
|
|
221
226
|
this._onWriteCompleted = options.onWriteCompleted || (() => {});
|
|
222
227
|
this._onError = options.onError || (() => {});
|
|
223
|
-
|
|
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;
|
|
224
238
|
|
|
225
239
|
// ===== 外部配置 =====
|
|
226
240
|
this._numShards = options.numShards ?? 16;
|
|
@@ -281,12 +295,21 @@ export class SyncEngine {
|
|
|
281
295
|
* @param {string} name - 表名
|
|
282
296
|
* @param {object} def
|
|
283
297
|
* @param {string[]} def.keyColumns - 主键列名数组
|
|
284
|
-
* @param {string[]} def.dataColumns -
|
|
298
|
+
* @param {string[]} def.dataColumns - 全部列名数组(必须包含 '_hlc')
|
|
285
299
|
* @param {string[]} [def.blobColumns] - BLOB 列名(触发器中用 hex 序列化)
|
|
286
300
|
* @param {function(object): void} [def.validator] - 数据验证,不合法时抛异常
|
|
287
301
|
* @param {function(object): object} [def.deserializer] - 反序列化(如 hex→Buffer)
|
|
288
302
|
*/
|
|
289
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
|
+
}
|
|
290
313
|
this._tableRegistry.set(name, {
|
|
291
314
|
keyColumns: def.keyColumns,
|
|
292
315
|
dataColumns: def.dataColumns,
|
|
@@ -646,7 +669,12 @@ export class SyncEngine {
|
|
|
646
669
|
}
|
|
647
670
|
|
|
648
671
|
if (leaderNodeId === this.nodeId) {
|
|
649
|
-
if (!this._onExecuteProxyRequest)
|
|
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
|
+
}
|
|
650
678
|
return this._onExecuteProxyRequest(payload);
|
|
651
679
|
}
|
|
652
680
|
|
|
@@ -846,7 +874,15 @@ export class SyncEngine {
|
|
|
846
874
|
const { requestId, payload } = msg;
|
|
847
875
|
try {
|
|
848
876
|
if (!this._onExecuteProxyRequest) {
|
|
849
|
-
|
|
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 }));
|
|
850
886
|
return;
|
|
851
887
|
}
|
|
852
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
|
-
*
|
|
410
|
+
* 适用于不支持触发器的数据库。
|
|
411
411
|
* 外部业务代码在每次写操作后调用此方法。
|
|
412
412
|
*
|
|
413
413
|
* @param {DatabaseAdapter} db
|
|
@@ -424,111 +424,3 @@ export function logChange(db, tableName, operation, rowKeyJson, rowDataJson, hlc
|
|
|
424
424
|
);
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
-
// ╔═══════════════════════════════════════╗
|
|
428
|
-
// ║ Schema / Trigger SQL 生成 ║
|
|
429
|
-
// ╚═══════════════════════════════════════╝
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* 返回同步基础设施表的建表 SQL(SQLite 语法,作为默认降级实现)
|
|
433
|
-
* 包含:_sync_log, _sync_peers, _tombstones
|
|
434
|
-
*
|
|
435
|
-
* 若需其他数据库方言,请在 DatabaseAdapter 上实现 infraSchemaSQL() 方法覆盖此默认值。
|
|
436
|
-
* SyncEngine.initSchema() 优先调用 db.infraSchemaSQL(),不存在时才使用本函数。
|
|
437
|
-
*
|
|
438
|
-
* @returns {string[]} SQL 语句数组(SQLite 方言)
|
|
439
|
-
*/
|
|
440
|
-
export function getInfraSchemaSQL() {
|
|
441
|
-
return [
|
|
442
|
-
`CREATE TABLE IF NOT EXISTS _sync_log (
|
|
443
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
444
|
-
table_name TEXT NOT NULL,
|
|
445
|
-
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
446
|
-
row_key TEXT NOT NULL,
|
|
447
|
-
row_data TEXT,
|
|
448
|
-
_hlc TEXT NOT NULL,
|
|
449
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
450
|
-
)`,
|
|
451
|
-
`CREATE INDEX IF NOT EXISTS idx_sync_log_id ON _sync_log (id)`,
|
|
452
|
-
|
|
453
|
-
`CREATE TABLE IF NOT EXISTS _sync_peers (
|
|
454
|
-
peer_key TEXT NOT NULL,
|
|
455
|
-
last_sync_id INTEGER NOT NULL DEFAULT 0,
|
|
456
|
-
last_sync_at TEXT,
|
|
457
|
-
PRIMARY KEY (peer_key)
|
|
458
|
-
)`,
|
|
459
|
-
|
|
460
|
-
`CREATE TABLE IF NOT EXISTS _tombstones (
|
|
461
|
-
table_name TEXT NOT NULL,
|
|
462
|
-
row_key TEXT NOT NULL,
|
|
463
|
-
_hlc TEXT NOT NULL,
|
|
464
|
-
PRIMARY KEY (table_name, row_key)
|
|
465
|
-
)`,
|
|
466
|
-
];
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* 为指定表生成 SQLite 同步触发器 SQL
|
|
471
|
-
* 触发器自动将 INSERT/UPDATE/DELETE 变更写入 _sync_log
|
|
472
|
-
*
|
|
473
|
-
* 注意:这是 SQLite 专用语法。其他数据库请使用 logChange() 手动记录。
|
|
474
|
-
*
|
|
475
|
-
* @param {string} tableName
|
|
476
|
-
* @param {TableDef} def - 表定义
|
|
477
|
-
* @returns {string[]} SQL 语句数组
|
|
478
|
-
*/
|
|
479
|
-
export function getTriggersSQL(tableName, def) {
|
|
480
|
-
const sqls = [];
|
|
481
|
-
|
|
482
|
-
const keyExpr = (ref) => {
|
|
483
|
-
const parts = def.keyColumns.map((col) => `'${col}', ${ref}.${col}`);
|
|
484
|
-
return `json_object(${parts.join(', ')})`;
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
const dataExpr = (ref) => {
|
|
488
|
-
const blobSet = new Set(def.blobColumns || []);
|
|
489
|
-
const parts = def.dataColumns.map((col) => {
|
|
490
|
-
if (blobSet.has(col)) return `'${col}', hex(${ref}.${col})`;
|
|
491
|
-
return `'${col}', ${ref}.${col}`;
|
|
492
|
-
});
|
|
493
|
-
return `json_object(${parts.join(', ')})`;
|
|
494
|
-
};
|
|
495
|
-
|
|
496
|
-
sqls.push(`
|
|
497
|
-
CREATE TRIGGER IF NOT EXISTS _sync_trg_${tableName}_insert
|
|
498
|
-
AFTER INSERT ON ${tableName}
|
|
499
|
-
BEGIN
|
|
500
|
-
INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
|
|
501
|
-
VALUES ('${tableName}', 'INSERT', ${keyExpr('NEW')}, ${dataExpr('NEW')}, NEW._hlc);
|
|
502
|
-
END`);
|
|
503
|
-
|
|
504
|
-
sqls.push(`
|
|
505
|
-
CREATE TRIGGER IF NOT EXISTS _sync_trg_${tableName}_update
|
|
506
|
-
AFTER UPDATE ON ${tableName}
|
|
507
|
-
BEGIN
|
|
508
|
-
INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
|
|
509
|
-
VALUES ('${tableName}', 'UPDATE', ${keyExpr('NEW')}, ${dataExpr('NEW')}, NEW._hlc);
|
|
510
|
-
END`);
|
|
511
|
-
|
|
512
|
-
sqls.push(`
|
|
513
|
-
CREATE TRIGGER IF NOT EXISTS _sync_trg_${tableName}_delete
|
|
514
|
-
AFTER DELETE ON ${tableName}
|
|
515
|
-
BEGIN
|
|
516
|
-
INSERT INTO _sync_log (table_name, operation, row_key, row_data, _hlc)
|
|
517
|
-
VALUES ('${tableName}', 'DELETE', ${keyExpr('OLD')}, NULL, OLD._hlc);
|
|
518
|
-
END`);
|
|
519
|
-
|
|
520
|
-
return sqls;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* 返回删除指定表同步触发器的 SQL
|
|
525
|
-
* @param {string} tableName
|
|
526
|
-
* @returns {string[]}
|
|
527
|
-
*/
|
|
528
|
-
export function dropTriggersSQL(tableName) {
|
|
529
|
-
return [
|
|
530
|
-
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_insert`,
|
|
531
|
-
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_update`,
|
|
532
|
-
`DROP TRIGGER IF EXISTS _sync_trg_${tableName}_delete`,
|
|
533
|
-
];
|
|
534
|
-
}
|