@syncular/relay 0.0.1-60

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.
Files changed (68) hide show
  1. package/dist/client-role/forward-engine.d.ts +63 -0
  2. package/dist/client-role/forward-engine.d.ts.map +1 -0
  3. package/dist/client-role/forward-engine.js +263 -0
  4. package/dist/client-role/forward-engine.js.map +1 -0
  5. package/dist/client-role/index.d.ts +9 -0
  6. package/dist/client-role/index.d.ts.map +1 -0
  7. package/dist/client-role/index.js +9 -0
  8. package/dist/client-role/index.js.map +1 -0
  9. package/dist/client-role/pull-engine.d.ts +70 -0
  10. package/dist/client-role/pull-engine.d.ts.map +1 -0
  11. package/dist/client-role/pull-engine.js +233 -0
  12. package/dist/client-role/pull-engine.js.map +1 -0
  13. package/dist/client-role/sequence-mapper.d.ts +65 -0
  14. package/dist/client-role/sequence-mapper.d.ts.map +1 -0
  15. package/dist/client-role/sequence-mapper.js +161 -0
  16. package/dist/client-role/sequence-mapper.js.map +1 -0
  17. package/dist/index.d.ts +37 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +44 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/migrate.d.ts +18 -0
  22. package/dist/migrate.d.ts.map +1 -0
  23. package/dist/migrate.js +99 -0
  24. package/dist/migrate.js.map +1 -0
  25. package/dist/mode-manager.d.ts +60 -0
  26. package/dist/mode-manager.d.ts.map +1 -0
  27. package/dist/mode-manager.js +114 -0
  28. package/dist/mode-manager.js.map +1 -0
  29. package/dist/realtime.d.ts +102 -0
  30. package/dist/realtime.d.ts.map +1 -0
  31. package/dist/realtime.js +305 -0
  32. package/dist/realtime.js.map +1 -0
  33. package/dist/relay.d.ts +188 -0
  34. package/dist/relay.d.ts.map +1 -0
  35. package/dist/relay.js +315 -0
  36. package/dist/relay.js.map +1 -0
  37. package/dist/schema.d.ts +158 -0
  38. package/dist/schema.d.ts.map +1 -0
  39. package/dist/schema.js +7 -0
  40. package/dist/schema.js.map +1 -0
  41. package/dist/server-role/index.d.ts +54 -0
  42. package/dist/server-role/index.d.ts.map +1 -0
  43. package/dist/server-role/index.js +198 -0
  44. package/dist/server-role/index.js.map +1 -0
  45. package/dist/server-role/pull.d.ts +25 -0
  46. package/dist/server-role/pull.d.ts.map +1 -0
  47. package/dist/server-role/pull.js +24 -0
  48. package/dist/server-role/pull.js.map +1 -0
  49. package/dist/server-role/push.d.ts +27 -0
  50. package/dist/server-role/push.d.ts.map +1 -0
  51. package/dist/server-role/push.js +98 -0
  52. package/dist/server-role/push.js.map +1 -0
  53. package/package.json +61 -0
  54. package/src/__tests__/relay.test.ts +464 -0
  55. package/src/bun-types.d.ts +50 -0
  56. package/src/client-role/forward-engine.ts +352 -0
  57. package/src/client-role/index.ts +9 -0
  58. package/src/client-role/pull-engine.ts +301 -0
  59. package/src/client-role/sequence-mapper.ts +201 -0
  60. package/src/index.ts +50 -0
  61. package/src/migrate.ts +113 -0
  62. package/src/mode-manager.ts +142 -0
  63. package/src/realtime.ts +370 -0
  64. package/src/relay.ts +421 -0
  65. package/src/schema.ts +171 -0
  66. package/src/server-role/index.ts +342 -0
  67. package/src/server-role/pull.ts +37 -0
  68. package/src/server-role/push.ts +130 -0
package/src/relay.ts ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * @syncular/relay - Relay Server
3
+ *
4
+ * An edge relay server that acts as a local server to nearby clients
5
+ * while simultaneously acting as a client to the main server.
6
+ *
7
+ * Enables offline-first architectures where local network devices
8
+ * continue syncing when internet is lost.
9
+ */
10
+
11
+ import type { ScopeValues, SyncTransport } from '@syncular/core';
12
+ import type { ServerSyncDialect, TableRegistry } from '@syncular/server';
13
+ import type { Hono } from 'hono';
14
+ import type { Kysely } from 'kysely';
15
+ import { sql } from 'kysely';
16
+ import { ForwardEngine } from './client-role/forward-engine';
17
+ import { PullEngine } from './client-role/pull-engine';
18
+ import { SequenceMapper } from './client-role/sequence-mapper';
19
+ import { ensureRelaySchema } from './migrate';
20
+ import { ModeManager, type RelayMode } from './mode-manager';
21
+ import { RelayRealtime } from './realtime';
22
+ import type { ForwardConflictEntry, RelayDatabase } from './schema';
23
+
24
+ /**
25
+ * Events emitted by the relay server.
26
+ */
27
+ export interface RelayEvents {
28
+ modeChange: (mode: RelayMode) => void;
29
+ forwardConflict: (conflict: ForwardConflictEntry) => void;
30
+ error: (error: Error) => void;
31
+ }
32
+
33
+ /**
34
+ * Configuration options for creating a relay server.
35
+ */
36
+ export interface RelayServerOptions<DB extends RelayDatabase = RelayDatabase> {
37
+ /** Kysely database instance */
38
+ db: Kysely<DB>;
39
+ /** Server sync dialect (e.g., SQLite or Postgres) */
40
+ dialect: ServerSyncDialect;
41
+ /** Transport for communicating with the main server */
42
+ mainServerTransport: SyncTransport;
43
+ /** Client ID used when communicating with the main server */
44
+ mainServerClientId: string;
45
+ /** Actor ID used when communicating with the main server */
46
+ mainServerActorId: string;
47
+ /** Tables this relay subscribes to from the main server */
48
+ tables: string[];
49
+ /** Scope values for subscriptions to the main server */
50
+ scopes: ScopeValues;
51
+ /** Shape registry for handling operations */
52
+ shapes: TableRegistry<DB>;
53
+ /** Optional: WebSocket heartbeat interval in milliseconds (default: 30000) */
54
+ heartbeatIntervalMs?: number;
55
+ /** Optional: Forward engine retry interval in milliseconds (default: 5000) */
56
+ forwardRetryIntervalMs?: number;
57
+ /** Optional: Pull engine interval in milliseconds (default: 10000) */
58
+ pullIntervalMs?: number;
59
+ /** Optional: Health check interval in milliseconds (default: 30000) */
60
+ healthCheckIntervalMs?: number;
61
+ /** Optional: Prune interval in milliseconds (default: 3600000 = 1 hour). Set to 0 to disable. */
62
+ pruneIntervalMs?: number;
63
+ /** Optional: Maximum age of completed relay data before pruning (default: 604800000 = 7 days) */
64
+ pruneMaxAgeMs?: number;
65
+ }
66
+
67
+ /**
68
+ * Result of relay data pruning.
69
+ */
70
+ export interface PruneRelayResult {
71
+ deletedMappings: number;
72
+ deletedOutbox: number;
73
+ deletedConflicts: number;
74
+ }
75
+
76
+ type EventHandler<K extends keyof RelayEvents> = RelayEvents[K];
77
+
78
+ /**
79
+ * Relay server that acts as an edge relay between local clients and a main server.
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * import { createRelayServer } from '@syncular/relay';
84
+ * import { createSqliteServerDialect } from '@syncular/server-dialect-sqlite';
85
+ *
86
+ * const relay = createRelayServer({
87
+ * db: sqliteDb,
88
+ * dialect: createSqliteServerDialect(),
89
+ * mainServerTransport: createHttpTransport({ baseUrl: 'https://main.example.com/sync' }),
90
+ * mainServerClientId: 'relay-branch-001',
91
+ * mainServerActorId: 'relay-service',
92
+ * tables: ['tasks', 'projects'],
93
+ * scopes: { project_id: 'acme' },
94
+ * shapes: shapeRegistry,
95
+ * });
96
+ *
97
+ * // Mount routes for local clients
98
+ * app.route('/sync', relay.getRoutes());
99
+ *
100
+ * // Start background sync with main
101
+ * await relay.start();
102
+ *
103
+ * // Events
104
+ * relay.on('modeChange', (mode) => console.log(mode));
105
+ * relay.on('forwardConflict', (conflict) => handleConflict(conflict));
106
+ * ```
107
+ */
108
+ export class RelayServer<DB extends RelayDatabase = RelayDatabase> {
109
+ private readonly db: Kysely<DB>;
110
+ private readonly dialect: ServerSyncDialect;
111
+ private readonly mainServerTransport: SyncTransport;
112
+ private readonly mainServerClientId: string;
113
+ private readonly mainServerActorId: string;
114
+ private readonly tables: string[];
115
+ private readonly scopes: ScopeValues;
116
+ private readonly shapes: TableRegistry<DB>;
117
+
118
+ private readonly modeManager: ModeManager;
119
+ private readonly sequenceMapper: SequenceMapper<DB>;
120
+ private readonly forwardEngine: ForwardEngine<DB>;
121
+ private readonly pullEngine: PullEngine<DB>;
122
+ private readonly realtime: RelayRealtime;
123
+
124
+ private readonly eventHandlers = new Map<
125
+ string,
126
+ Set<(...args: never[]) => unknown>
127
+ >();
128
+
129
+ private readonly pruneIntervalMs: number;
130
+ private readonly pruneMaxAgeMs: number;
131
+ private lastPruneAtMs = 0;
132
+ private pruneInFlight: Promise<PruneRelayResult> | null = null;
133
+
134
+ private started = false;
135
+ private schemaInitialized = false;
136
+ private routes: Hono | null = null;
137
+
138
+ constructor(options: RelayServerOptions<DB>) {
139
+ this.db = options.db;
140
+ this.dialect = options.dialect;
141
+ this.mainServerTransport = options.mainServerTransport;
142
+ this.mainServerClientId = options.mainServerClientId;
143
+ this.mainServerActorId = options.mainServerActorId;
144
+ this.tables = options.tables;
145
+ this.scopes = options.scopes;
146
+ this.shapes = options.shapes;
147
+
148
+ this.pruneIntervalMs = options.pruneIntervalMs ?? 3600000;
149
+ this.pruneMaxAgeMs = options.pruneMaxAgeMs ?? 7 * 24 * 60 * 60 * 1000;
150
+
151
+ // Initialize mode manager
152
+ this.modeManager = new ModeManager({
153
+ healthCheckIntervalMs: options.healthCheckIntervalMs ?? 30000,
154
+ onModeChange: (mode) => this.emit('modeChange', mode),
155
+ });
156
+
157
+ // Initialize sequence mapper
158
+ this.sequenceMapper = new SequenceMapper({
159
+ db: this.db,
160
+ });
161
+
162
+ // Initialize realtime manager for local clients
163
+ this.realtime = new RelayRealtime({
164
+ heartbeatIntervalMs: options.heartbeatIntervalMs ?? 30000,
165
+ });
166
+
167
+ // Initialize forward engine (forwards local commits to main)
168
+ this.forwardEngine = new ForwardEngine({
169
+ db: this.db,
170
+ transport: this.mainServerTransport,
171
+ clientId: this.mainServerClientId,
172
+ sequenceMapper: this.sequenceMapper,
173
+ retryIntervalMs: options.forwardRetryIntervalMs ?? 5000,
174
+ onConflict: (conflict) => this.emit('forwardConflict', conflict),
175
+ onError: (error) => this.emit('error', error),
176
+ });
177
+
178
+ // Initialize pull engine (pulls changes from main)
179
+ this.pullEngine = new PullEngine({
180
+ db: this.db,
181
+ dialect: this.dialect,
182
+ transport: this.mainServerTransport,
183
+ clientId: this.mainServerClientId,
184
+ tables: this.tables,
185
+ scopes: this.scopes,
186
+ shapes: this.shapes,
187
+ sequenceMapper: this.sequenceMapper,
188
+ realtime: this.realtime,
189
+ intervalMs: options.pullIntervalMs ?? 10000,
190
+ onError: (error) => this.emit('error', error),
191
+ onPullComplete: async () => {
192
+ await this.maybePruneRelay();
193
+ },
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Subscribe to relay events.
199
+ */
200
+ on<K extends keyof RelayEvents>(
201
+ event: K,
202
+ handler: EventHandler<K>
203
+ ): () => void {
204
+ let handlers = this.eventHandlers.get(event);
205
+ if (!handlers) {
206
+ handlers = new Set();
207
+ this.eventHandlers.set(event, handlers);
208
+ }
209
+ handlers.add(handler);
210
+
211
+ return () => {
212
+ handlers?.delete(handler);
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Get the current mode (online/offline/reconnecting).
218
+ */
219
+ getMode(): RelayMode {
220
+ return this.modeManager.getMode();
221
+ }
222
+
223
+ /**
224
+ * Get the tables this relay subscribes to.
225
+ */
226
+ getTables(): readonly string[] {
227
+ return this.tables;
228
+ }
229
+
230
+ /**
231
+ * Get the scope values for subscriptions.
232
+ */
233
+ getScopes(): ScopeValues {
234
+ return this.scopes;
235
+ }
236
+
237
+ /**
238
+ * Get the realtime manager for WebSocket connections.
239
+ */
240
+ getRealtime(): RelayRealtime {
241
+ return this.realtime;
242
+ }
243
+
244
+ /**
245
+ * Start the relay server background processes.
246
+ *
247
+ * This initializes the database schema and starts:
248
+ * - Forward engine (sends local commits to main)
249
+ * - Pull engine (receives changes from main)
250
+ * - Mode manager (tracks online/offline state)
251
+ */
252
+ async start(): Promise<void> {
253
+ if (this.started) return;
254
+
255
+ // Initialize schema if needed
256
+ if (!this.schemaInitialized) {
257
+ await ensureRelaySchema(this.db, this.dialect);
258
+ this.schemaInitialized = true;
259
+ }
260
+
261
+ this.started = true;
262
+
263
+ // Start background processes
264
+ this.modeManager.start(async () => {
265
+ // Health check: try an empty pull
266
+ try {
267
+ await this.mainServerTransport.sync({
268
+ clientId: this.mainServerClientId,
269
+ pull: {
270
+ subscriptions: [],
271
+ limitCommits: 0,
272
+ },
273
+ });
274
+ return true;
275
+ } catch {
276
+ return false;
277
+ }
278
+ });
279
+
280
+ this.forwardEngine.start();
281
+ this.pullEngine.start();
282
+ }
283
+
284
+ /**
285
+ * Stop the relay server background processes.
286
+ */
287
+ async stop(): Promise<void> {
288
+ if (!this.started) return;
289
+
290
+ this.started = false;
291
+ this.modeManager.stop();
292
+ this.forwardEngine.stop();
293
+ this.pullEngine.stop();
294
+ this.realtime.closeAll();
295
+ }
296
+
297
+ /**
298
+ * Manually trigger a forward cycle (useful for testing).
299
+ */
300
+ async forwardOnce(): Promise<boolean> {
301
+ return this.forwardEngine.forwardOnce();
302
+ }
303
+
304
+ /**
305
+ * Manually trigger a pull cycle (useful for testing).
306
+ */
307
+ async pullOnce(): Promise<boolean> {
308
+ return this.pullEngine.pullOnce();
309
+ }
310
+
311
+ /**
312
+ * Prune old relay data (sequence mappings, forwarded outbox, resolved conflicts).
313
+ */
314
+ async pruneRelay(options?: { maxAgeMs?: number }): Promise<PruneRelayResult> {
315
+ const maxAgeMs = options?.maxAgeMs ?? this.pruneMaxAgeMs;
316
+ const threshold = Date.now() - maxAgeMs;
317
+
318
+ const deletedMappings =
319
+ await this.sequenceMapper.pruneOldMappings(maxAgeMs);
320
+
321
+ const outboxResult = await sql`
322
+ delete from ${sql.table('relay_forward_outbox')}
323
+ where status in ('forwarded', 'failed')
324
+ and updated_at < ${threshold}
325
+ `.execute(this.db);
326
+ const deletedOutbox = Number(outboxResult.numAffectedRows ?? 0);
327
+
328
+ const conflictsResult = await sql`
329
+ delete from ${sql.table('relay_forward_conflicts')}
330
+ where resolved_at is not null
331
+ and resolved_at < ${threshold}
332
+ `.execute(this.db);
333
+ const deletedConflicts = Number(conflictsResult.numAffectedRows ?? 0);
334
+
335
+ return { deletedMappings, deletedOutbox, deletedConflicts };
336
+ }
337
+
338
+ /**
339
+ * Rate-limited pruning. Skips if called within `pruneIntervalMs` of last prune.
340
+ * Returns zero counts if skipped or if pruning is disabled.
341
+ */
342
+ async maybePruneRelay(): Promise<PruneRelayResult> {
343
+ if (this.pruneIntervalMs <= 0) {
344
+ return { deletedMappings: 0, deletedOutbox: 0, deletedConflicts: 0 };
345
+ }
346
+
347
+ const now = Date.now();
348
+ if (now - this.lastPruneAtMs < this.pruneIntervalMs) {
349
+ return { deletedMappings: 0, deletedOutbox: 0, deletedConflicts: 0 };
350
+ }
351
+
352
+ if (this.pruneInFlight) return this.pruneInFlight;
353
+
354
+ this.pruneInFlight = (async () => {
355
+ try {
356
+ const result = await this.pruneRelay();
357
+ this.lastPruneAtMs = Date.now();
358
+ return result;
359
+ } finally {
360
+ this.pruneInFlight = null;
361
+ }
362
+ })();
363
+
364
+ return this.pruneInFlight;
365
+ }
366
+
367
+ /**
368
+ * Get Hono routes for local clients.
369
+ *
370
+ * Mount these routes to serve local sync clients:
371
+ * - POST /pull
372
+ * - POST /push
373
+ * - GET /realtime (WebSocket)
374
+ */
375
+ getRoutes(): Hono {
376
+ if (this.routes) return this.routes;
377
+
378
+ // Lazy import to avoid requiring hono as a hard dependency
379
+ const { createRelayRoutes } = require('./server-role');
380
+
381
+ const routes = createRelayRoutes({
382
+ db: this.db,
383
+ dialect: this.dialect,
384
+ shapes: this.shapes,
385
+ realtime: this.realtime,
386
+ onCommit: async (localCommitSeq: number, affectedTables: string[]) => {
387
+ // Notify local clients via WebSocket
388
+ this.realtime.notifyScopeKeys(affectedTables, localCommitSeq);
389
+
390
+ // Wake up forward engine to send to main
391
+ this.forwardEngine.wakeUp();
392
+ },
393
+ });
394
+
395
+ this.routes = routes;
396
+ return routes;
397
+ }
398
+
399
+ private emit<K extends keyof RelayEvents>(
400
+ event: K,
401
+ ...args: Parameters<RelayEvents[K]>
402
+ ): void {
403
+ const handlers = this.eventHandlers.get(event);
404
+ if (!handlers) return;
405
+
406
+ for (const handler of handlers) {
407
+ try {
408
+ (handler as (...args: unknown[]) => unknown)(...args);
409
+ } catch (err) {
410
+ console.error(`Error in ${event} handler:`, err);
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Create a new relay server instance.
418
+ */
419
+ export function createRelayServer(options: RelayServerOptions): RelayServer {
420
+ return new RelayServer(options);
421
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @syncular/relay - Database schema types
3
+ *
4
+ * Relay-specific tables for edge relay servers.
5
+ */
6
+
7
+ import type { SyncCoreDb } from '@syncular/server';
8
+ import type { Generated } from 'kysely';
9
+
10
+ /**
11
+ * Forward outbox status for commits awaiting forwarding to main server.
12
+ */
13
+ export type RelayForwardOutboxStatus =
14
+ | 'pending'
15
+ | 'forwarding'
16
+ | 'forwarded'
17
+ | 'failed';
18
+
19
+ /**
20
+ * Sequence map status for tracking local to main commit mapping.
21
+ */
22
+ export type RelaySequenceMapStatus = 'pending' | 'forwarded' | 'confirmed';
23
+
24
+ /**
25
+ * Forward outbox - Queue for commits to forward to main server.
26
+ *
27
+ * When local clients push to the relay, commits are stored here
28
+ * for subsequent forwarding to the main server.
29
+ */
30
+ interface RelayForwardOutboxTable {
31
+ /** Unique identifier for this outbox entry */
32
+ id: string;
33
+ /** Local commit sequence assigned by the relay */
34
+ local_commit_seq: number;
35
+ /** Original client_id from the local client */
36
+ client_id: string;
37
+ /** Original client_commit_id from the local client */
38
+ client_commit_id: string;
39
+ /** Operations JSON for forwarding */
40
+ operations_json: string;
41
+ /** Client schema version when commit was created */
42
+ schema_version: number;
43
+ /** Current status of this entry */
44
+ status: RelayForwardOutboxStatus;
45
+ /** Main server's commit_seq after forwarding (null until confirmed) */
46
+ main_commit_seq: number | null;
47
+ /** Error message if status is 'failed' */
48
+ error: string | null;
49
+ /** Last response JSON from main server */
50
+ last_response_json: string | null;
51
+ /** Creation timestamp */
52
+ created_at: Generated<number>;
53
+ /** Last update timestamp */
54
+ updated_at: Generated<number>;
55
+ /** Number of forward attempts */
56
+ attempt_count: Generated<number>;
57
+ }
58
+
59
+ /**
60
+ * Sequence map - Maps local to main commit sequences.
61
+ *
62
+ * Tracks the relationship between relay's local commit_seq
63
+ * and the main server's global commit_seq.
64
+ */
65
+ interface RelaySequenceMapTable {
66
+ /** Relay's local commit sequence */
67
+ local_commit_seq: number;
68
+ /** Main server's commit sequence (null if not yet forwarded) */
69
+ main_commit_seq: number | null;
70
+ /** Mapping status */
71
+ status: RelaySequenceMapStatus;
72
+ /** When this mapping was created */
73
+ created_at: Generated<number>;
74
+ /** When this mapping was last updated */
75
+ updated_at: Generated<number>;
76
+ }
77
+
78
+ /**
79
+ * Forward conflicts - Conflicts encountered when forwarding to main.
80
+ *
81
+ * When a local commit is rejected by the main server due to conflicts,
82
+ * the details are stored here for application-level handling.
83
+ */
84
+ interface RelayForwardConflictTable {
85
+ /** Unique identifier */
86
+ id: string;
87
+ /** Local commit sequence that caused the conflict */
88
+ local_commit_seq: number;
89
+ /** Original client_id from the local client */
90
+ client_id: string;
91
+ /** Original client_commit_id from the local client */
92
+ client_commit_id: string;
93
+ /** Full rejection response from main server */
94
+ response_json: string;
95
+ /** When the conflict was recorded */
96
+ created_at: number;
97
+ /** When the conflict was resolved (null if unresolved) */
98
+ resolved_at: number | null;
99
+ }
100
+
101
+ /**
102
+ * Relay config - Key-value store for relay state.
103
+ *
104
+ * Stores configuration and runtime state like:
105
+ * - scope_keys: subscribed scopes
106
+ * - main_cursor: last pulled commit_seq from main server
107
+ * - mode: online/offline/reconnecting
108
+ */
109
+ interface RelayConfigTable {
110
+ /** Configuration key */
111
+ key: string;
112
+ /** JSON-encoded value */
113
+ value_json: string;
114
+ }
115
+
116
+ /**
117
+ * Database interface for relay-specific tables.
118
+ * Merge this with SyncCoreDb for the full database interface.
119
+ */
120
+ export interface RelayDb {
121
+ relay_forward_outbox: RelayForwardOutboxTable;
122
+ relay_sequence_map: RelaySequenceMapTable;
123
+ relay_forward_conflicts: RelayForwardConflictTable;
124
+ relay_config: RelayConfigTable;
125
+ }
126
+
127
+ /**
128
+ * Full database interface required by the relay runtime.
129
+ *
130
+ * Includes:
131
+ * - Sync core tables (commit log, cursors, etc.)
132
+ * - Relay-specific tables (outbox, sequence map, config, conflicts)
133
+ */
134
+ export type RelayDatabase = SyncCoreDb & RelayDb;
135
+
136
+ /**
137
+ * Forward outbox entry (with parsed operations).
138
+ */
139
+ export interface ForwardOutboxEntry {
140
+ id: string;
141
+ local_commit_seq: number;
142
+ client_id: string;
143
+ client_commit_id: string;
144
+ operations: Array<{
145
+ table: string;
146
+ row_id: string;
147
+ op: 'upsert' | 'delete';
148
+ payload: Record<string, unknown> | null;
149
+ base_version?: number | null;
150
+ }>;
151
+ schema_version: number;
152
+ status: RelayForwardOutboxStatus;
153
+ main_commit_seq: number | null;
154
+ error: string | null;
155
+ created_at: number;
156
+ updated_at: number;
157
+ attempt_count: number;
158
+ }
159
+
160
+ /**
161
+ * Forward conflict entry (with parsed response).
162
+ */
163
+ export interface ForwardConflictEntry {
164
+ id: string;
165
+ local_commit_seq: number;
166
+ client_id: string;
167
+ client_commit_id: string;
168
+ response: unknown;
169
+ created_at: number;
170
+ resolved_at: number | null;
171
+ }