@affectively/dash 5.3.0 → 5.4.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/dist/src/engine/sqlite.d.ts +7 -0
- package/dist/src/engine/sqlite.js +3 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +2 -0
- package/dist/src/sync/AeonDurableSync.d.ts +26 -0
- package/dist/src/sync/AeonDurableSync.js +67 -0
- package/dist/src/sync/AutomergeProvider.d.ts +45 -0
- package/dist/src/sync/AutomergeProvider.js +153 -0
- package/dist/src/sync/d1-provider.d.ts +8 -2
- package/dist/src/sync/d1-provider.js +94 -21
- package/dist/src/sync/hybrid-provider.d.ts +136 -1
- package/dist/src/sync/hybrid-provider.js +942 -66
- package/dist/src/sync/types.d.ts +32 -0
- package/dist/src/sync/types.js +4 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +14 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LensEngine } from '../schema/lens.js';
|
|
2
|
+
import type { RelayDiscoveryConfig, RelayPerformanceConfig, RelayPrivacyConfig } from '../sync/hybrid-provider.js';
|
|
2
3
|
/**
|
|
3
4
|
* Cloud sync configuration - enables automatic D1/R2 AND Relay sync
|
|
4
5
|
*
|
|
@@ -20,6 +21,12 @@ export interface CloudConfig {
|
|
|
20
21
|
relayRoom?: string;
|
|
21
22
|
/** Enable high-frequency mode for Relay (uses WebTransport if available) */
|
|
22
23
|
relayHighFrequency?: boolean;
|
|
24
|
+
/** Optional peer discovery settings for Relay endpoint routing */
|
|
25
|
+
relayDiscovery?: RelayDiscoveryConfig;
|
|
26
|
+
/** Optional performance settings (adaptive compression + batching) */
|
|
27
|
+
relayPerformance?: RelayPerformanceConfig;
|
|
28
|
+
/** Optional privacy settings (room-scoped payload encryption) */
|
|
29
|
+
relayPrivacy?: RelayPrivacyConfig;
|
|
23
30
|
/** Auth token getter - called before each sync */
|
|
24
31
|
getAuthToken?: () => Promise<string | null>;
|
|
25
32
|
/** User ID for relay room naming */
|
|
@@ -589,6 +589,9 @@ export class DashEngine {
|
|
|
589
589
|
maxOfflineQueueSize: 1000,
|
|
590
590
|
maxOfflineRetries: 5,
|
|
591
591
|
},
|
|
592
|
+
relayDiscovery: config.relayDiscovery,
|
|
593
|
+
relayPerformance: config.relayPerformance,
|
|
594
|
+
relayPrivacy: config.relayPrivacy,
|
|
592
595
|
});
|
|
593
596
|
// Listen for remote changes and apply to local SQLite
|
|
594
597
|
this.relayDoc.on('update', (update, origin) => {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export { YjsSqliteProvider } from './sync/provider.js';
|
|
|
6
6
|
export { backup, restore, generateKey, exportKey, importKey } from './sync/backup.js';
|
|
7
7
|
export type { CloudStorageAdapter } from './sync/backup.js';
|
|
8
8
|
export { HybridProvider } from './sync/hybrid-provider.js';
|
|
9
|
+
export type { RelayDiscoveryConfig, RelayPerformanceConfig, RelayPrivacyConfig, ConnectionStatus, ProviderStatus, } from './sync/hybrid-provider.js';
|
|
10
|
+
export { AutomergeProvider } from './sync/AutomergeProvider.js';
|
|
11
|
+
export type { AutomergeProviderOptions } from './sync/AutomergeProvider.js';
|
|
12
|
+
export type { AutomergeProviderConfig } from './sync/types.js';
|
|
13
|
+
export { AeonDurableSyncRuntime } from './sync/AeonDurableSync.js';
|
|
14
|
+
export type { AeonDurableSyncConfig } from './sync/AeonDurableSync.js';
|
|
9
15
|
export { D1SyncProvider, getD1SyncProvider, resetD1SyncProvider } from './sync/d1-provider.js';
|
|
10
16
|
export type { D1SyncConfig, SyncResult, SyncQueueEntry } from './sync/d1-provider.js';
|
|
11
17
|
export * as firebase from './api/firebase/index.js';
|
package/dist/src/index.js
CHANGED
|
@@ -5,6 +5,8 @@ export { mcpServer } from './mcp/server.js';
|
|
|
5
5
|
export { YjsSqliteProvider } from './sync/provider.js';
|
|
6
6
|
export { backup, restore, generateKey, exportKey, importKey } from './sync/backup.js';
|
|
7
7
|
export { HybridProvider } from './sync/hybrid-provider.js';
|
|
8
|
+
export { AutomergeProvider } from './sync/AutomergeProvider.js';
|
|
9
|
+
export { AeonDurableSyncRuntime } from './sync/AeonDurableSync.js';
|
|
8
10
|
// D1 HTTP Sync (legacy - prefer dash.enableCloudSync())
|
|
9
11
|
export { D1SyncProvider, getD1SyncProvider, resetD1SyncProvider } from './sync/d1-provider.js';
|
|
10
12
|
// Firebase Compatibility API exports
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AeonDurableSync - Durable telemetry runtime for sync operations
|
|
3
|
+
*
|
|
4
|
+
* Records transport selection, discovery events, and sync protocol messages
|
|
5
|
+
* using Aeon's OfflineOperationQueue, SyncProtocol, and ReplicationManager.
|
|
6
|
+
*
|
|
7
|
+
* Operates in-memory by default. Persistence requires @affectively/aeon/persistence
|
|
8
|
+
* (available in a future release).
|
|
9
|
+
*/
|
|
10
|
+
type TransportType = 'websocket' | 'webtransport' | null;
|
|
11
|
+
export interface AeonDurableSyncConfig {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
roomName: string;
|
|
14
|
+
persistenceKeyPrefix?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class AeonDurableSyncRuntime {
|
|
17
|
+
private readonly queue;
|
|
18
|
+
private readonly protocol;
|
|
19
|
+
private readonly replication;
|
|
20
|
+
constructor(config: AeonDurableSyncConfig);
|
|
21
|
+
initialize(): Promise<void>;
|
|
22
|
+
recordTransportSelection(roomName: string, transport: TransportType, relay: string | null): void;
|
|
23
|
+
recordDiscovery(roomName: string, relays: string[]): void;
|
|
24
|
+
getPendingOperations(): number;
|
|
25
|
+
}
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AeonDurableSync - Durable telemetry runtime for sync operations
|
|
3
|
+
*
|
|
4
|
+
* Records transport selection, discovery events, and sync protocol messages
|
|
5
|
+
* using Aeon's OfflineOperationQueue, SyncProtocol, and ReplicationManager.
|
|
6
|
+
*
|
|
7
|
+
* Operates in-memory by default. Persistence requires @affectively/aeon/persistence
|
|
8
|
+
* (available in a future release).
|
|
9
|
+
*/
|
|
10
|
+
import { OfflineOperationQueue, SyncProtocol, ReplicationManager, } from '@affectively/aeon';
|
|
11
|
+
export class AeonDurableSyncRuntime {
|
|
12
|
+
queue;
|
|
13
|
+
protocol;
|
|
14
|
+
replication;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
// Construct without persistence options — in-memory only until
|
|
17
|
+
// @affectively/aeon/persistence ships.
|
|
18
|
+
this.queue = new OfflineOperationQueue();
|
|
19
|
+
this.protocol = new SyncProtocol();
|
|
20
|
+
this.replication = new ReplicationManager();
|
|
21
|
+
}
|
|
22
|
+
async initialize() {
|
|
23
|
+
// No-op until persistence module is available.
|
|
24
|
+
// When @affectively/aeon/persistence ships, this will hydrate from storage.
|
|
25
|
+
}
|
|
26
|
+
recordTransportSelection(roomName, transport, relay) {
|
|
27
|
+
if (!transport) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
this.queue.enqueue('update', {
|
|
32
|
+
roomName,
|
|
33
|
+
event: 'transport-selected',
|
|
34
|
+
transport,
|
|
35
|
+
relay,
|
|
36
|
+
ts: Date.now(),
|
|
37
|
+
}, roomName, 'normal');
|
|
38
|
+
this.protocol.createSyncRequestMessage(roomName, relay ?? 'unknown-relay', `transport-${Date.now()}`, '0.0.0', '1.1.0', {
|
|
39
|
+
transport,
|
|
40
|
+
relay,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Telemetry durability must never impact foreground sync operations.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
recordDiscovery(roomName, relays) {
|
|
48
|
+
const sanitizedRelays = relays
|
|
49
|
+
.filter((relay) => typeof relay === 'string' && relay.length > 0)
|
|
50
|
+
.slice(0, 32);
|
|
51
|
+
try {
|
|
52
|
+
this.queue.enqueue('sync', {
|
|
53
|
+
roomName,
|
|
54
|
+
event: 'relay-discovery',
|
|
55
|
+
count: sanitizedRelays.length,
|
|
56
|
+
relays: sanitizedRelays,
|
|
57
|
+
ts: Date.now(),
|
|
58
|
+
}, roomName, 'low');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Telemetry durability must never impact foreground sync operations.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
getPendingOperations() {
|
|
65
|
+
return this.queue.getPendingCount();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutomergeProvider - Automerge sync over WebSocket relay
|
|
3
|
+
*
|
|
4
|
+
* Mirrors HybridProvider's transport selection and discovery logic
|
|
5
|
+
* but uses Automerge's built-in sync protocol instead of Yjs.
|
|
6
|
+
* The relay server is CRDT-agnostic — it relays opaque binary frames.
|
|
7
|
+
*
|
|
8
|
+
* @automerge/automerge is dynamically imported so users who only use
|
|
9
|
+
* Yjs never pay the bundle cost.
|
|
10
|
+
*/
|
|
11
|
+
import type { AutomergeProviderConfig } from './types.js';
|
|
12
|
+
export interface AutomergeProviderOptions<T> {
|
|
13
|
+
onChange?: (doc: T) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare class AutomergeProvider<T = unknown> {
|
|
16
|
+
private config;
|
|
17
|
+
private _doc;
|
|
18
|
+
private _onChange?;
|
|
19
|
+
private _ws;
|
|
20
|
+
private _syncState;
|
|
21
|
+
private _automerge;
|
|
22
|
+
private _ready;
|
|
23
|
+
private _destroyed;
|
|
24
|
+
private _reconnectTimer;
|
|
25
|
+
private _reconnectDelayMs;
|
|
26
|
+
private readonly _maxReconnectDelayMs;
|
|
27
|
+
constructor(doc: T, config: AutomergeProviderConfig, options?: AutomergeProviderOptions<T>);
|
|
28
|
+
private initialize;
|
|
29
|
+
private getRelayUrl;
|
|
30
|
+
private connectWebSocket;
|
|
31
|
+
private scheduleReconnect;
|
|
32
|
+
private sendSyncMessage;
|
|
33
|
+
private handleMessage;
|
|
34
|
+
/**
|
|
35
|
+
* Update the local document. Required because Automerge documents are immutable —
|
|
36
|
+
* after calling Automerge.change(), pass the new doc here to sync it.
|
|
37
|
+
*/
|
|
38
|
+
updateDoc(newDoc: T): void;
|
|
39
|
+
get doc(): T;
|
|
40
|
+
get ready(): Promise<void>;
|
|
41
|
+
get roomName(): string;
|
|
42
|
+
get connected(): boolean;
|
|
43
|
+
disconnect(): void;
|
|
44
|
+
destroy(): Promise<void>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutomergeProvider - Automerge sync over WebSocket relay
|
|
3
|
+
*
|
|
4
|
+
* Mirrors HybridProvider's transport selection and discovery logic
|
|
5
|
+
* but uses Automerge's built-in sync protocol instead of Yjs.
|
|
6
|
+
* The relay server is CRDT-agnostic — it relays opaque binary frames.
|
|
7
|
+
*
|
|
8
|
+
* @automerge/automerge is dynamically imported so users who only use
|
|
9
|
+
* Yjs never pay the bundle cost.
|
|
10
|
+
*/
|
|
11
|
+
export class AutomergeProvider {
|
|
12
|
+
config;
|
|
13
|
+
_doc;
|
|
14
|
+
_onChange;
|
|
15
|
+
_ws = null;
|
|
16
|
+
_syncState = null;
|
|
17
|
+
_automerge = null;
|
|
18
|
+
_ready;
|
|
19
|
+
_destroyed = false;
|
|
20
|
+
_reconnectTimer = null;
|
|
21
|
+
_reconnectDelayMs = 1000;
|
|
22
|
+
_maxReconnectDelayMs = 30_000;
|
|
23
|
+
constructor(doc, config, options) {
|
|
24
|
+
this._doc = doc;
|
|
25
|
+
this.config = config;
|
|
26
|
+
this._onChange = options?.onChange;
|
|
27
|
+
this._ready = this.initialize();
|
|
28
|
+
}
|
|
29
|
+
async initialize() {
|
|
30
|
+
const am = await import('@automerge/automerge');
|
|
31
|
+
this._automerge = am;
|
|
32
|
+
this._syncState = am.initSyncState();
|
|
33
|
+
this.connectWebSocket();
|
|
34
|
+
}
|
|
35
|
+
getRelayUrl() {
|
|
36
|
+
if (this.config.websocket?.url) {
|
|
37
|
+
return toWsUrl(this.config.websocket.url);
|
|
38
|
+
}
|
|
39
|
+
if (this.config.webtransport?.url) {
|
|
40
|
+
return toWsUrl(this.config.webtransport.url);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
connectWebSocket() {
|
|
45
|
+
if (this._destroyed)
|
|
46
|
+
return;
|
|
47
|
+
const relayUrl = this.getRelayUrl();
|
|
48
|
+
if (!relayUrl)
|
|
49
|
+
return;
|
|
50
|
+
const separator = relayUrl.includes('?') ? '&' : '?';
|
|
51
|
+
const params = new URLSearchParams({ room: this.config.roomName });
|
|
52
|
+
if (this.config.apiKey) {
|
|
53
|
+
params.set('apiKey', this.config.apiKey);
|
|
54
|
+
}
|
|
55
|
+
const url = `${relayUrl}${separator}${params.toString()}`;
|
|
56
|
+
try {
|
|
57
|
+
this._ws = new WebSocket(url);
|
|
58
|
+
this._ws.binaryType = 'arraybuffer';
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
this.scheduleReconnect();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this._ws.addEventListener('open', () => {
|
|
65
|
+
this._reconnectDelayMs = 1000;
|
|
66
|
+
this.sendSyncMessage();
|
|
67
|
+
});
|
|
68
|
+
this._ws.addEventListener('message', (event) => {
|
|
69
|
+
this.handleMessage(event.data);
|
|
70
|
+
});
|
|
71
|
+
this._ws.addEventListener('close', () => {
|
|
72
|
+
this._ws = null;
|
|
73
|
+
this.scheduleReconnect();
|
|
74
|
+
});
|
|
75
|
+
this._ws.addEventListener('error', () => {
|
|
76
|
+
// close event will follow and trigger reconnect
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
scheduleReconnect() {
|
|
80
|
+
if (this._destroyed || this._reconnectTimer)
|
|
81
|
+
return;
|
|
82
|
+
this._reconnectTimer = setTimeout(() => {
|
|
83
|
+
this._reconnectTimer = null;
|
|
84
|
+
this.connectWebSocket();
|
|
85
|
+
}, this._reconnectDelayMs);
|
|
86
|
+
this._reconnectDelayMs = Math.min(this._reconnectDelayMs * 2, this._maxReconnectDelayMs);
|
|
87
|
+
}
|
|
88
|
+
sendSyncMessage() {
|
|
89
|
+
const am = this._automerge;
|
|
90
|
+
if (!am || !this._ws || this._ws.readyState !== WebSocket.OPEN)
|
|
91
|
+
return;
|
|
92
|
+
const [nextSyncState, message] = am.generateSyncMessage(this._doc, this._syncState);
|
|
93
|
+
this._syncState = nextSyncState;
|
|
94
|
+
if (message) {
|
|
95
|
+
this._ws.send(message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
handleMessage(data) {
|
|
99
|
+
const am = this._automerge;
|
|
100
|
+
if (!am)
|
|
101
|
+
return;
|
|
102
|
+
const message = new Uint8Array(data);
|
|
103
|
+
const [nextDoc, nextSyncState] = am.receiveSyncMessage(this._doc, this._syncState, message);
|
|
104
|
+
this._doc = nextDoc;
|
|
105
|
+
this._syncState = nextSyncState;
|
|
106
|
+
this._onChange?.(this._doc);
|
|
107
|
+
// After receiving, we may need to send our own changes
|
|
108
|
+
this.sendSyncMessage();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Update the local document. Required because Automerge documents are immutable —
|
|
112
|
+
* after calling Automerge.change(), pass the new doc here to sync it.
|
|
113
|
+
*/
|
|
114
|
+
updateDoc(newDoc) {
|
|
115
|
+
this._doc = newDoc;
|
|
116
|
+
this.sendSyncMessage();
|
|
117
|
+
}
|
|
118
|
+
get doc() {
|
|
119
|
+
return this._doc;
|
|
120
|
+
}
|
|
121
|
+
get ready() {
|
|
122
|
+
return this._ready;
|
|
123
|
+
}
|
|
124
|
+
get roomName() {
|
|
125
|
+
return this.config.roomName;
|
|
126
|
+
}
|
|
127
|
+
get connected() {
|
|
128
|
+
return this._ws !== null && this._ws.readyState === WebSocket.OPEN;
|
|
129
|
+
}
|
|
130
|
+
disconnect() {
|
|
131
|
+
if (this._reconnectTimer) {
|
|
132
|
+
clearTimeout(this._reconnectTimer);
|
|
133
|
+
this._reconnectTimer = null;
|
|
134
|
+
}
|
|
135
|
+
if (this._ws) {
|
|
136
|
+
this._ws.close();
|
|
137
|
+
this._ws = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async destroy() {
|
|
141
|
+
this._destroyed = true;
|
|
142
|
+
this.disconnect();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function toWsUrl(url) {
|
|
146
|
+
if (url.startsWith('wss://') || url.startsWith('ws://'))
|
|
147
|
+
return url;
|
|
148
|
+
if (url.startsWith('https://'))
|
|
149
|
+
return `wss://${url.slice('https://'.length)}`;
|
|
150
|
+
if (url.startsWith('http://'))
|
|
151
|
+
return `ws://${url.slice('http://'.length)}`;
|
|
152
|
+
return url;
|
|
153
|
+
}
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* Architecture:
|
|
10
10
|
* - Local changes are tracked in a sync_queue table
|
|
11
11
|
* - Sync runs periodically or on-demand
|
|
12
|
-
* - Conflicts are resolved using last-write-wins with timestamps
|
|
12
|
+
* - Conflicts are resolved using last-write-wins with _lastModified timestamps
|
|
13
|
+
*
|
|
14
|
+
* Security:
|
|
15
|
+
* - Table/column names are validated against allowed patterns
|
|
16
|
+
* - All user data uses parameterized queries
|
|
13
17
|
*/
|
|
14
18
|
export interface D1SyncConfig {
|
|
15
19
|
/** Base URL for the sync endpoint (e.g., 'https://example.com/api') */
|
|
@@ -53,6 +57,7 @@ export declare class D1SyncProvider {
|
|
|
53
57
|
initialize(): Promise<void>;
|
|
54
58
|
/**
|
|
55
59
|
* Set up SQLite triggers to track changes for a table
|
|
60
|
+
* Uses validated identifiers to prevent SQL injection
|
|
56
61
|
*/
|
|
57
62
|
private setupTableTriggers;
|
|
58
63
|
/**
|
|
@@ -68,7 +73,8 @@ export declare class D1SyncProvider {
|
|
|
68
73
|
*/
|
|
69
74
|
sync(): Promise<SyncResult>;
|
|
70
75
|
/**
|
|
71
|
-
* Apply changes received from the server
|
|
76
|
+
* Apply changes received from the server with Last-Write-Wins (LWW) conflict resolution
|
|
77
|
+
* Server changes only win if their _lastModified is newer than local
|
|
72
78
|
*/
|
|
73
79
|
private applyServerChanges;
|
|
74
80
|
/**
|
|
@@ -9,9 +9,26 @@
|
|
|
9
9
|
* Architecture:
|
|
10
10
|
* - Local changes are tracked in a sync_queue table
|
|
11
11
|
* - Sync runs periodically or on-demand
|
|
12
|
-
* - Conflicts are resolved using last-write-wins with timestamps
|
|
12
|
+
* - Conflicts are resolved using last-write-wins with _lastModified timestamps
|
|
13
|
+
*
|
|
14
|
+
* Security:
|
|
15
|
+
* - Table/column names are validated against allowed patterns
|
|
16
|
+
* - All user data uses parameterized queries
|
|
13
17
|
*/
|
|
14
18
|
import { dash } from '../engine/sqlite.js';
|
|
19
|
+
// Valid identifier pattern (prevents SQL injection)
|
|
20
|
+
const VALID_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
21
|
+
/**
|
|
22
|
+
* Validate and escape a SQL identifier (table or column name)
|
|
23
|
+
* Throws if the identifier is invalid
|
|
24
|
+
*/
|
|
25
|
+
function escapeIdentifier(name) {
|
|
26
|
+
if (!VALID_IDENTIFIER.test(name)) {
|
|
27
|
+
throw new Error(`Invalid SQL identifier: ${name}`);
|
|
28
|
+
}
|
|
29
|
+
// Double-quote the identifier for safety
|
|
30
|
+
return `"${name}"`;
|
|
31
|
+
}
|
|
15
32
|
export class D1SyncProvider {
|
|
16
33
|
config;
|
|
17
34
|
syncTimer = null;
|
|
@@ -72,42 +89,69 @@ export class D1SyncProvider {
|
|
|
72
89
|
}
|
|
73
90
|
/**
|
|
74
91
|
* Set up SQLite triggers to track changes for a table
|
|
92
|
+
* Uses validated identifiers to prevent SQL injection
|
|
75
93
|
*/
|
|
76
94
|
setupTableTriggers(tableName) {
|
|
95
|
+
// Validate table name to prevent SQL injection
|
|
96
|
+
const safeTableName = escapeIdentifier(tableName);
|
|
97
|
+
const triggerSuffix = tableName.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
77
98
|
// Clean up existing triggers first
|
|
78
99
|
try {
|
|
79
|
-
dash.execute(`DROP TRIGGER IF EXISTS dash_sync_${
|
|
80
|
-
dash.execute(`DROP TRIGGER IF EXISTS dash_sync_${
|
|
81
|
-
dash.execute(`DROP TRIGGER IF EXISTS dash_sync_${
|
|
100
|
+
dash.execute(`DROP TRIGGER IF EXISTS dash_sync_${triggerSuffix}_insert`);
|
|
101
|
+
dash.execute(`DROP TRIGGER IF EXISTS dash_sync_${triggerSuffix}_update`);
|
|
102
|
+
dash.execute(`DROP TRIGGER IF EXISTS dash_sync_${triggerSuffix}_delete`);
|
|
82
103
|
}
|
|
83
104
|
catch {
|
|
84
105
|
// Ignore errors from dropping non-existent triggers
|
|
85
106
|
}
|
|
86
|
-
// Create INSERT trigger
|
|
107
|
+
// Create INSERT trigger with parameterized table name in data
|
|
87
108
|
dash.execute(`
|
|
88
|
-
CREATE TRIGGER IF NOT EXISTS dash_sync_${
|
|
89
|
-
AFTER INSERT ON ${
|
|
109
|
+
CREATE TRIGGER IF NOT EXISTS dash_sync_${triggerSuffix}_insert
|
|
110
|
+
AFTER INSERT ON ${safeTableName}
|
|
90
111
|
BEGIN
|
|
91
112
|
INSERT INTO dash_sync_queue (table_name, row_id, operation, data)
|
|
92
|
-
VALUES (
|
|
113
|
+
VALUES (
|
|
114
|
+
'${tableName.replace(/'/g, "''")}',
|
|
115
|
+
NEW.id,
|
|
116
|
+
'create',
|
|
117
|
+
json_object(
|
|
118
|
+
'id', NEW.id,
|
|
119
|
+
'_lastModified', strftime('%s', 'now') * 1000,
|
|
120
|
+
'_data', json(NEW)
|
|
121
|
+
)
|
|
122
|
+
);
|
|
93
123
|
END
|
|
94
124
|
`);
|
|
95
|
-
// Create UPDATE trigger
|
|
125
|
+
// Create UPDATE trigger with timestamp for LWW
|
|
96
126
|
dash.execute(`
|
|
97
|
-
CREATE TRIGGER IF NOT EXISTS dash_sync_${
|
|
98
|
-
AFTER UPDATE ON ${
|
|
127
|
+
CREATE TRIGGER IF NOT EXISTS dash_sync_${triggerSuffix}_update
|
|
128
|
+
AFTER UPDATE ON ${safeTableName}
|
|
99
129
|
BEGIN
|
|
100
130
|
INSERT INTO dash_sync_queue (table_name, row_id, operation, data)
|
|
101
|
-
VALUES (
|
|
131
|
+
VALUES (
|
|
132
|
+
'${tableName.replace(/'/g, "''")}',
|
|
133
|
+
NEW.id,
|
|
134
|
+
'update',
|
|
135
|
+
json_object(
|
|
136
|
+
'id', NEW.id,
|
|
137
|
+
'_lastModified', strftime('%s', 'now') * 1000,
|
|
138
|
+
'_data', json(NEW)
|
|
139
|
+
)
|
|
140
|
+
);
|
|
102
141
|
END
|
|
103
142
|
`);
|
|
104
143
|
// Create DELETE trigger
|
|
105
144
|
dash.execute(`
|
|
106
|
-
CREATE TRIGGER IF NOT EXISTS dash_sync_${
|
|
107
|
-
AFTER DELETE ON ${
|
|
145
|
+
CREATE TRIGGER IF NOT EXISTS dash_sync_${triggerSuffix}_delete
|
|
146
|
+
AFTER DELETE ON ${safeTableName}
|
|
108
147
|
BEGIN
|
|
109
148
|
INSERT INTO dash_sync_queue (table_name, row_id, operation, data)
|
|
110
|
-
VALUES (
|
|
149
|
+
VALUES (
|
|
150
|
+
'${tableName.replace(/'/g, "''")}',
|
|
151
|
+
OLD.id,
|
|
152
|
+
'delete',
|
|
153
|
+
json_object('id', OLD.id, '_lastModified', strftime('%s', 'now') * 1000)
|
|
154
|
+
);
|
|
111
155
|
END
|
|
112
156
|
`);
|
|
113
157
|
}
|
|
@@ -253,21 +297,49 @@ export class D1SyncProvider {
|
|
|
253
297
|
return result;
|
|
254
298
|
}
|
|
255
299
|
/**
|
|
256
|
-
* Apply changes received from the server
|
|
300
|
+
* Apply changes received from the server with Last-Write-Wins (LWW) conflict resolution
|
|
301
|
+
* Server changes only win if their _lastModified is newer than local
|
|
257
302
|
*/
|
|
258
303
|
async applyServerChanges(tableName, changes) {
|
|
304
|
+
const safeTableName = escapeIdentifier(tableName);
|
|
259
305
|
for (const change of changes) {
|
|
260
306
|
try {
|
|
307
|
+
const serverTimestamp = change._lastModified || 0;
|
|
308
|
+
const rowId = change.id;
|
|
261
309
|
// Check if this is a delete (marked by a flag or null data)
|
|
262
310
|
if (change.deleted || change._deleted) {
|
|
263
|
-
|
|
311
|
+
// For deletes, check if local row exists and is older
|
|
312
|
+
const local = dash.execute(`SELECT _lastModified FROM ${safeTableName} WHERE id = ?`, [rowId]);
|
|
313
|
+
if (local.length === 0 || (local[0]._lastModified || 0) < serverTimestamp) {
|
|
314
|
+
dash.execute(`DELETE FROM ${safeTableName} WHERE id = ?`, [rowId]);
|
|
315
|
+
console.log(`[D1SyncProvider] LWW: Applied server delete for ${tableName}:${rowId}`);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
console.log(`[D1SyncProvider] LWW: Skipped server delete (local is newer) for ${tableName}:${rowId}`);
|
|
319
|
+
}
|
|
264
320
|
continue;
|
|
265
321
|
}
|
|
266
|
-
//
|
|
267
|
-
const
|
|
322
|
+
// Check local record timestamp for LWW
|
|
323
|
+
const local = dash.execute(`SELECT _lastModified FROM ${safeTableName} WHERE id = ?`, [rowId]);
|
|
324
|
+
const localTimestamp = local.length > 0 ? (local[0]._lastModified || 0) : 0;
|
|
325
|
+
// Only apply if server is newer (or record doesn't exist locally)
|
|
326
|
+
if (localTimestamp >= serverTimestamp && local.length > 0) {
|
|
327
|
+
console.log(`[D1SyncProvider] LWW: Skipped server change (local is newer) for ${tableName}:${rowId}`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// Get column names from the change object, excluding internal fields
|
|
331
|
+
const data = change._data || change;
|
|
332
|
+
const columns = Object.keys(data).filter(k => !k.startsWith('_'));
|
|
333
|
+
// Add _lastModified column for tracking
|
|
334
|
+
if (!columns.includes('_lastModified')) {
|
|
335
|
+
columns.push('_lastModified');
|
|
336
|
+
}
|
|
337
|
+
const escapedColumns = columns.map(c => escapeIdentifier(c));
|
|
268
338
|
const placeholders = columns.map(() => '?').join(', ');
|
|
269
339
|
const values = columns.map(k => {
|
|
270
|
-
|
|
340
|
+
if (k === '_lastModified')
|
|
341
|
+
return serverTimestamp;
|
|
342
|
+
const v = data[k];
|
|
271
343
|
// Serialize objects/arrays to JSON
|
|
272
344
|
if (v !== null && typeof v === 'object') {
|
|
273
345
|
return JSON.stringify(v);
|
|
@@ -275,8 +347,9 @@ export class D1SyncProvider {
|
|
|
275
347
|
return v;
|
|
276
348
|
});
|
|
277
349
|
// Use INSERT OR REPLACE to handle both new and updated records
|
|
278
|
-
const sql = `INSERT OR REPLACE INTO ${
|
|
350
|
+
const sql = `INSERT OR REPLACE INTO ${safeTableName} (${escapedColumns.join(', ')}) VALUES (${placeholders})`;
|
|
279
351
|
dash.execute(sql, values);
|
|
352
|
+
console.log(`[D1SyncProvider] LWW: Applied server change for ${tableName}:${rowId}`);
|
|
280
353
|
}
|
|
281
354
|
catch (err) {
|
|
282
355
|
console.error(`[D1SyncProvider] Failed to apply change to ${tableName}:`, err, change);
|