@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.
@@ -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) => {
@@ -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_${tableName}_insert`);
80
- dash.execute(`DROP TRIGGER IF EXISTS dash_sync_${tableName}_update`);
81
- dash.execute(`DROP TRIGGER IF EXISTS dash_sync_${tableName}_delete`);
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_${tableName}_insert
89
- AFTER INSERT ON ${tableName}
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 ('${tableName}', NEW.id, 'create', json(NEW));
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_${tableName}_update
98
- AFTER UPDATE ON ${tableName}
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 ('${tableName}', NEW.id, 'update', json(NEW));
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_${tableName}_delete
107
- AFTER DELETE ON ${tableName}
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 ('${tableName}', OLD.id, 'delete', NULL);
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
- dash.execute(`DELETE FROM ${tableName} WHERE id = ?`, [change.id]);
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
- // Get column names from the change object
267
- const columns = Object.keys(change).filter(k => !k.startsWith('_'));
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
- const v = change[k];
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 ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`;
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);