meridian-server 0.1.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.
@@ -0,0 +1,658 @@
1
+ import { ClientMessage, ServerMessage, SchemaDefinition, ConflictRecord, PermissionRules, CRDTOperation, ServerChange, StorageAdapterConfig, ConflictInfo } from 'meridian-shared';
2
+ export { CRDTOperation, ConflictRecord, SchemaDefinition, ServerChange, defineSchema, z } from 'meridian-shared';
3
+ import { WebSocket } from 'ws';
4
+
5
+ /**
6
+ * Meridian Server — WebSocket Hub
7
+ *
8
+ * Manages WebSocket connections with:
9
+ * - Client authentication (pluggable JWT verifier)
10
+ * - Namespace isolation (multi-tenant)
11
+ * - Message routing (push/pull/subscribe/presence)
12
+ * - Heartbeat/ping-pong health checks
13
+ * - Auth token expiry tracking and refresh notifications
14
+ */
15
+
16
+ interface AuthResult {
17
+ userId: string;
18
+ namespace?: string;
19
+ expiresAt?: number;
20
+ }
21
+ interface WsHubConfig {
22
+ /** Port to listen on */
23
+ port: number;
24
+ /** Path for WebSocket endpoint */
25
+ path?: string;
26
+ /** Auth verifier — return user info or throw to reject */
27
+ auth?: (token: string) => Promise<AuthResult>;
28
+ /** Message handler */
29
+ onMessage: (clientId: string, message: ClientMessage, client: ConnectedClient) => void;
30
+ /** Disconnect handler */
31
+ onDisconnect?: (clientId: string) => void;
32
+ /** Subscribe handler — called when client subscribes with optional filter */
33
+ onSubscribe?: (clientId: string, collections: string[], filter?: Record<string, Record<string, unknown>>) => void;
34
+ /** Debug mode */
35
+ debug?: boolean;
36
+ }
37
+ interface ConnectedClient {
38
+ id: string;
39
+ ws: WebSocket;
40
+ userId: string | null;
41
+ namespace: string | null;
42
+ subscribedCollections: Set<string>;
43
+ authExpiresAt: number | null;
44
+ lastActivity: number;
45
+ }
46
+ /**
47
+ * WebSocket connection hub for Meridian server.
48
+ */
49
+ declare class WsHub {
50
+ private wss;
51
+ private readonly config;
52
+ private clients;
53
+ private heartbeatInterval;
54
+ private authCheckInterval;
55
+ constructor(config: WsHubConfig);
56
+ /**
57
+ * Start the WebSocket server.
58
+ */
59
+ start(): void;
60
+ /**
61
+ * Stop the WebSocket server.
62
+ */
63
+ stop(): void;
64
+ private handleConnection;
65
+ private handleClientMessage;
66
+ /**
67
+ * Send a message to a specific client.
68
+ */
69
+ sendTo(client: ConnectedClient, msg: ServerMessage): void;
70
+ /**
71
+ * Send a message to a client by ID.
72
+ */
73
+ sendToId(clientId: string, msg: ServerMessage): void;
74
+ /**
75
+ * Broadcast a message to all clients subscribed to a collection.
76
+ * Excludes the sender.
77
+ */
78
+ broadcastToCollection(collection: string, msg: ServerMessage, excludeClientId?: string, namespace?: string | null): void;
79
+ /**
80
+ * Broadcast a message to all connected clients.
81
+ */
82
+ broadcastToAll(msg: ServerMessage, namespace?: string | null): void;
83
+ /**
84
+ * Get all connected client IDs.
85
+ */
86
+ getClientIds(): string[];
87
+ /**
88
+ * Get a connected client by ID.
89
+ */
90
+ getClient(clientId: string): ConnectedClient | undefined;
91
+ private startHeartbeat;
92
+ private startAuthCheck;
93
+ private log;
94
+ }
95
+
96
+ /**
97
+ * Meridian Server — Main Entry Point
98
+ *
99
+ * `createServer()` wires together all server components:
100
+ * - PostgreSQL store (auto-DDL, CRDT merge)
101
+ * - WebSocket hub (auth, connections)
102
+ * - Merge engine (push/pull processing)
103
+ * - Presence manager
104
+ * - Compaction scheduler
105
+ *
106
+ * Usage:
107
+ * ```ts
108
+ * import { createServer } from 'meridian-server';
109
+ * import { defineSchema, z } from 'meridian-shared';
110
+ *
111
+ * const schema = defineSchema({
112
+ * version: 1,
113
+ * collections: {
114
+ * todos: { id: z.string(), title: z.string(), done: z.boolean() },
115
+ * },
116
+ * });
117
+ *
118
+ * const server = createServer({
119
+ * port: 3000,
120
+ * database: 'postgresql://user:pass@localhost:5432/mydb',
121
+ * schema,
122
+ * });
123
+ *
124
+ * await server.start();
125
+ * ```
126
+ */
127
+
128
+ interface MeridianServerConfig {
129
+ /** Port for WebSocket server */
130
+ port: number;
131
+ /** PostgreSQL connection string */
132
+ database: string;
133
+ /** Schema definition (same as client) */
134
+ schema: SchemaDefinition;
135
+ /** WebSocket path (default: '/sync') */
136
+ path?: string;
137
+ /**
138
+ * Authentication handler.
139
+ * Called with the token from client's auth message.
140
+ * Return user info or throw to reject.
141
+ */
142
+ auth?: (token: string) => Promise<AuthResult>;
143
+ /**
144
+ * Compaction settings for tombstone cleanup.
145
+ */
146
+ compaction?: {
147
+ /** Max age for tombstones in ms (default: 30 days) */
148
+ tombstoneMaxAge?: number;
149
+ /** Compaction interval in ms (default: 24 hours) */
150
+ interval?: number;
151
+ };
152
+ /**
153
+ * Conflict handler — called when a field-level conflict is resolved.
154
+ * Use this to implement custom merge logic for specific collections or fields.
155
+ */
156
+ onConflict?: (conflict: ConflictRecord & {
157
+ collection: string;
158
+ docId: string;
159
+ }) => void;
160
+ /**
161
+ * Permission rules for row-level access control.
162
+ * When provided, only rows the user is authorized to read are returned.
163
+ *
164
+ * ```ts
165
+ * permissions: defineRules({
166
+ * todos: {
167
+ * read: (auth, doc) => auth?.userId === doc.existing?.ownerId,
168
+ * write: (auth, doc) => auth != null,
169
+ * }
170
+ * })
171
+ * ```
172
+ */
173
+ permissions?: PermissionRules;
174
+ /**
175
+ * Enable debug logging.
176
+ * @default false
177
+ */
178
+ debug?: boolean;
179
+ }
180
+ interface MeridianServer {
181
+ /** Start the server */
182
+ start(): Promise<void>;
183
+ /** Stop the server gracefully */
184
+ stop(): Promise<void>;
185
+ /** Run compaction manually */
186
+ compact(): Promise<number>;
187
+ /** Get connected client count */
188
+ getClientCount(): number;
189
+ }
190
+ /**
191
+ * Create a Meridian sync server.
192
+ */
193
+ declare function createServer(config: MeridianServerConfig): MeridianServer;
194
+
195
+ /**
196
+ * Meridian Server — PostgreSQL Store
197
+ *
198
+ * Handles:
199
+ * - Auto-creation of tables from client schema
200
+ * - CRDT metadata storage via _meridian_meta JSONB column
201
+ * - Server-assigned monotonic sequence numbers
202
+ * - LISTEN/NOTIFY for change detection
203
+ * - Tombstone compaction
204
+ * - Changes-since queries for pull protocol
205
+ */
206
+
207
+ interface PgStoreConfig {
208
+ /** PostgreSQL connection string */
209
+ connectionString: string;
210
+ /** Schema definition */
211
+ schema: SchemaDefinition;
212
+ /** Optional namespace prefix for multi-tenant isolation */
213
+ namespace?: string;
214
+ }
215
+ /**
216
+ * PostgreSQL storage adapter for Meridian server.
217
+ */
218
+ declare class PgStore {
219
+ private pool;
220
+ private readonly config;
221
+ private changeCallbacks;
222
+ private listenClient;
223
+ private minSeq;
224
+ constructor(config: PgStoreConfig);
225
+ /**
226
+ * Initialize the database — create tables, sequences, triggers.
227
+ */
228
+ init(): Promise<void>;
229
+ /**
230
+ * Get the table name with optional namespace prefix.
231
+ */
232
+ private tableName;
233
+ /**
234
+ * Create a table for a collection with Meridian system columns.
235
+ */
236
+ private createTable;
237
+ /**
238
+ * Apply CRDT operations from a client.
239
+ * Performs field-level LWW merge with existing data.
240
+ * @returns Array of server changes with assigned sequence numbers and any conflicts
241
+ */
242
+ applyOperations(ops: CRDTOperation[]): Promise<{
243
+ changes: ServerChange[];
244
+ conflicts: ConflictRecord[];
245
+ }>;
246
+ /**
247
+ * Get all changes since a given sequence number.
248
+ * Used for pull protocol.
249
+ *
250
+ * @returns null if seqNum is below minSeq (compaction gap), otherwise changes
251
+ */
252
+ getChangesSince(since: number): Promise<ServerChange[] | null>;
253
+ /**
254
+ * Get the current minimum available sequence number.
255
+ */
256
+ getMinSeq(): number;
257
+ /**
258
+ * Delete tombstoned rows older than maxAge.
259
+ * @returns Number of rows deleted
260
+ */
261
+ compact(maxAgeMs: number): Promise<number>;
262
+ private updateMinSeqWithClient;
263
+ private updateMinSeq;
264
+ private startListening;
265
+ /**
266
+ * Register a callback for database changes.
267
+ */
268
+ onChange(callback: (tableName: string, docId: string) => void): () => void;
269
+ /**
270
+ * Close the database connection pool.
271
+ */
272
+ close(): Promise<void>;
273
+ }
274
+
275
+ /**
276
+ * Meridian Server — CRDT Merge Engine
277
+ *
278
+ * Handles server-side merge logic:
279
+ * - Receives client operations
280
+ * - Merges with existing PostgreSQL state
281
+ * - Assigns sequence numbers
282
+ * - Broadcasts results to other clients
283
+ * - Logs conflicts for debugging
284
+ */
285
+
286
+ interface MergeEngineConfig {
287
+ pgStore: PgStore;
288
+ wsHub: WsHub;
289
+ debug?: boolean;
290
+ /** Custom conflict handler — devs define their own merge logic */
291
+ onConflict?: (conflict: ConflictRecord & {
292
+ collection: string;
293
+ docId: string;
294
+ }) => void;
295
+ /** Permission rules for row-level access control */
296
+ permissions?: PermissionRules;
297
+ }
298
+ /**
299
+ * Server-side CRDT merge engine with partial sync and row-level permissions.
300
+ */
301
+ declare class MergeEngine {
302
+ private readonly config;
303
+ private conflictLog;
304
+ private readonly maxConflictLog;
305
+ /** Per-client subscribe filters: clientId → collection → filter */
306
+ private clientFilters;
307
+ private ruleEvaluator;
308
+ constructor(config: MergeEngineConfig);
309
+ /** Store a client's subscribe filter for partial sync */
310
+ setClientFilter(clientId: string, collections: string[], filter?: Record<string, Record<string, unknown>>): void;
311
+ /** Remove client filters on disconnect */
312
+ removeClientFilter(clientId: string): void;
313
+ /**
314
+ * Process a push from a client.
315
+ * Merges operations with existing state, assigns seqNums, and broadcasts.
316
+ *
317
+ * @param clientId - The sending client's ID
318
+ * @param ops - CRDT operations from the client
319
+ * @param client - The connected client object
320
+ */
321
+ processPush(clientId: string, ops: CRDTOperation[], client: ConnectedClient): Promise<void>;
322
+ /**
323
+ * Process a pull request from a client.
324
+ * Returns changes since the given sequence number.
325
+ */
326
+ processPull(clientId: string, since: number, client: ConnectedClient): Promise<void>;
327
+ /**
328
+ * Get the conflict log.
329
+ */
330
+ getConflictLog(): (ConflictRecord & {
331
+ collection: string;
332
+ docId: string;
333
+ timestamp: number;
334
+ })[];
335
+ private log;
336
+ }
337
+
338
+ /**
339
+ * Meridian Server — Presence Manager (Server-side)
340
+ *
341
+ * In-memory presence state for connected clients.
342
+ * - Stores presence data per client
343
+ * - Broadcasts updates to all peers
344
+ * - Auto-cleanup on disconnect (no TTL needed — instant)
345
+ */
346
+
347
+ type PresenceData = Record<string, unknown>;
348
+ declare class ServerPresenceManager {
349
+ private presence;
350
+ private wsHub;
351
+ private debug;
352
+ constructor(wsHub: WsHub, debug?: boolean);
353
+ /**
354
+ * Update presence for a client and broadcast to peers.
355
+ */
356
+ update(clientId: string, data: PresenceData, client: ConnectedClient): void;
357
+ /**
358
+ * Remove presence for a disconnected client.
359
+ */
360
+ remove(clientId: string): void;
361
+ /**
362
+ * Get all current presence data.
363
+ */
364
+ getAll(): Record<string, PresenceData>;
365
+ /**
366
+ * Send current presence state to a specific client (e.g., on reconnect).
367
+ */
368
+ sendCurrentState(client: ConnectedClient): void;
369
+ private broadcastAll;
370
+ /**
371
+ * Clear all presence data.
372
+ */
373
+ clear(): void;
374
+ }
375
+
376
+ /**
377
+ * Meridian Server — Tombstone Compaction
378
+ *
379
+ * Periodically removes soft-deleted rows older than a configurable max age.
380
+ * After compaction, notifies connected clients so they can clean up local data.
381
+ */
382
+
383
+ interface CompactionConfig {
384
+ /** Maximum age for tombstones in ms (default: 30 days) */
385
+ tombstoneMaxAge: number;
386
+ /** Compaction check interval in ms (default: 24 hours) */
387
+ interval: number;
388
+ /** Debug mode */
389
+ debug?: boolean;
390
+ }
391
+ /**
392
+ * Tombstone compaction scheduler.
393
+ */
394
+ declare class CompactionManager {
395
+ private readonly pgStore;
396
+ private readonly wsHub;
397
+ private readonly config;
398
+ private timer;
399
+ constructor(pgStore: PgStore, wsHub: WsHub, config?: Partial<CompactionConfig>);
400
+ /**
401
+ * Start the compaction scheduler.
402
+ */
403
+ start(): void;
404
+ /**
405
+ * Stop the compaction scheduler.
406
+ */
407
+ stop(): void;
408
+ /**
409
+ * Run compaction now.
410
+ */
411
+ runCompaction(): Promise<number>;
412
+ private log;
413
+ }
414
+
415
+ /**
416
+ * Meridian — WAL Streaming (PostgreSQL Logical Replication)
417
+ *
418
+ * Production-grade change streaming using PostgreSQL's
419
+ * LISTEN/NOTIFY + logical replication protocol.
420
+ *
421
+ * Two modes:
422
+ * 1. NOTIFY mode (default) — Fast, simple, uses pg_notify()
423
+ * triggers. Good for up to ~10K concurrent clients.
424
+ * 2. WAL mode — Uses wal2json logical decoding plugin for
425
+ * massive scale (100K+ clients). Requires:
426
+ * - `wal_level = logical` in postgresql.conf
427
+ * - `CREATE EXTENSION wal2json;` (if not already installed)
428
+ */
429
+ interface WALStreamConfig {
430
+ /** PostgreSQL connection string */
431
+ connectionString: string;
432
+ /** Mode: 'notify' (default) or 'wal' (logical replication) */
433
+ mode?: 'notify' | 'wal';
434
+ /** Channel name for NOTIFY mode */
435
+ channel?: string;
436
+ /** Publication name for WAL mode */
437
+ publication?: string;
438
+ /** Slot name for WAL mode */
439
+ slot?: string;
440
+ /** Called for each change received */
441
+ onChange: (change: WALChange) => void;
442
+ /** Debug logging */
443
+ debug?: boolean;
444
+ }
445
+ interface WALChange {
446
+ collection: string;
447
+ docId: string;
448
+ operation: 'INSERT' | 'UPDATE' | 'DELETE';
449
+ fields?: Record<string, unknown>;
450
+ seq?: number;
451
+ }
452
+ /**
453
+ * Create a WAL stream based on the configured mode.
454
+ *
455
+ * ```ts
456
+ * const stream = createWALStream({
457
+ * connectionString: process.env.DATABASE_URL!,
458
+ * mode: 'notify', // or 'wal'
459
+ * onChange: (change) => {
460
+ * // Broadcast to WebSocket clients
461
+ * wsHub.broadcastToCollection(change.collection, change);
462
+ * },
463
+ * debug: true,
464
+ * });
465
+ * await stream.start();
466
+ * ```
467
+ */
468
+ declare function createWALStream(config: WALStreamConfig): {
469
+ start(): Promise<void>;
470
+ stop(): Promise<void>;
471
+ };
472
+
473
+ /**
474
+ * Meridian — SQLite Storage Adapter
475
+ *
476
+ * Implements StorageAdapter for SQLite databases.
477
+ * Supports:
478
+ * - better-sqlite3 (Node.js server)
479
+ * - sql.js (WASM — browser/React Native)
480
+ * - Turso/libsql (edge/distributed SQLite)
481
+ *
482
+ * Usage:
483
+ * ```ts
484
+ * const store = new SQLiteStore({
485
+ * databasePath: './meridian.db',
486
+ * schema,
487
+ * });
488
+ * await store.init();
489
+ * ```
490
+ */
491
+
492
+ /**
493
+ * Minimal SQL driver interface — compatible with better-sqlite3, sql.js, and libsql.
494
+ */
495
+ interface SQLDriver {
496
+ exec(sql: string): void;
497
+ prepare(sql: string): SQLStatement;
498
+ close(): void;
499
+ }
500
+ interface SQLStatement {
501
+ run(...params: unknown[]): {
502
+ changes: number;
503
+ lastInsertRowid: number | bigint;
504
+ };
505
+ get(...params: unknown[]): Record<string, unknown> | undefined;
506
+ all(...params: unknown[]): Record<string, unknown>[];
507
+ }
508
+ interface SQLiteStoreConfig extends StorageAdapterConfig {
509
+ /** SQL driver instance (better-sqlite3 Database, sql.js Database, etc.) */
510
+ driver: SQLDriver;
511
+ }
512
+ declare class SQLiteStore {
513
+ private readonly driver;
514
+ private readonly config;
515
+ private lastSeq;
516
+ private minSeq;
517
+ constructor(config: SQLiteStoreConfig);
518
+ init(): Promise<void>;
519
+ private createTable;
520
+ applyOperations(ops: CRDTOperation[]): Promise<{
521
+ changes: ServerChange[];
522
+ conflicts: ConflictInfo[];
523
+ }>;
524
+ getChangesSince(since: number): Promise<ServerChange[] | null>;
525
+ getMinSeq(): number;
526
+ compact(maxAgeMs: number): Promise<number>;
527
+ close(): Promise<void>;
528
+ }
529
+
530
+ /**
531
+ * Meridian — MySQL Storage Adapter
532
+ *
533
+ * Implements StorageAdapter for MySQL databases.
534
+ * Uses mysql2 driver for Node.js.
535
+ *
536
+ * Usage:
537
+ * ```ts
538
+ * import mysql from 'mysql2/promise';
539
+ * const pool = mysql.createPool('mysql://localhost/meridian');
540
+ * const store = new MySQLStore({ pool, schema });
541
+ * await store.init();
542
+ * ```
543
+ */
544
+
545
+ interface MySQLPool {
546
+ execute(sql: string, params?: unknown[]): Promise<[ResultSetHeader, any]>;
547
+ query(sql: string, params?: unknown[]): Promise<[RowDataPacket[], any]>;
548
+ end(): Promise<void>;
549
+ }
550
+ interface ResultSetHeader {
551
+ insertId: number;
552
+ affectedRows: number;
553
+ }
554
+ interface RowDataPacket {
555
+ [key: string]: unknown;
556
+ }
557
+ interface MySQLStoreConfig {
558
+ pool: MySQLPool;
559
+ schema: SchemaDefinition;
560
+ debug?: boolean;
561
+ }
562
+ declare class MySQLStore {
563
+ private pool;
564
+ private config;
565
+ private lastSeq;
566
+ private minSeq;
567
+ constructor(config: MySQLStoreConfig);
568
+ init(): Promise<void>;
569
+ applyOperations(ops: CRDTOperation[]): Promise<{
570
+ changes: ServerChange[];
571
+ conflicts: ConflictInfo[];
572
+ }>;
573
+ getChangesSince(since: number): Promise<ServerChange[] | null>;
574
+ getMinSeq(): number;
575
+ compact(maxAgeMs: number): Promise<number>;
576
+ close(): Promise<void>;
577
+ }
578
+
579
+ /**
580
+ * Meridian — Snapshot Recovery
581
+ *
582
+ * Optimizes full re-sync by creating periodic snapshots of collection state.
583
+ * Instead of replaying all operations from seq=0, clients can load a snapshot
584
+ * and only replay operations since the snapshot's sequence number.
585
+ *
586
+ * Benefits:
587
+ * - New clients sync in O(snapshot + delta) instead of O(all_ops)
588
+ * - Bandwidth reduction for clients that have been offline for days
589
+ * - Faster recovery after compaction gaps
590
+ */
591
+
592
+ interface Snapshot {
593
+ /** Sequence number this snapshot was taken at */
594
+ seq: number;
595
+ /** ISO timestamp of snapshot creation */
596
+ createdAt: string;
597
+ /** Collection snapshots */
598
+ collections: Record<string, CollectionSnapshot>;
599
+ }
600
+ interface CollectionSnapshot {
601
+ /** Collection name */
602
+ name: string;
603
+ /** Number of documents */
604
+ count: number;
605
+ /** All non-deleted documents */
606
+ documents: Record<string, unknown>[];
607
+ }
608
+ interface SnapshotConfig {
609
+ /** Create a snapshot every N operations */
610
+ interval: number;
611
+ /** Maximum number of snapshots to keep */
612
+ maxSnapshots: number;
613
+ /** Database (pgStore or mysqlStore) */
614
+ store: PgStore | {
615
+ getChangesSince(since: number): Promise<ServerChange[] | null>;
616
+ getMinSeq(): number;
617
+ };
618
+ /** Schema definition */
619
+ schema: SchemaDefinition;
620
+ /** Debug logging */
621
+ debug?: boolean;
622
+ }
623
+ declare class SnapshotManager {
624
+ private config;
625
+ private snapshots;
626
+ private opCounter;
627
+ constructor(config: SnapshotConfig);
628
+ /**
629
+ * Track an operation. Creates a snapshot when the interval is reached.
630
+ */
631
+ trackOp(): Promise<void>;
632
+ /**
633
+ * Create a snapshot of all collections at the current sequence number.
634
+ */
635
+ createSnapshot(): Promise<Snapshot>;
636
+ /**
637
+ * Get the most recent snapshot at or before the given sequence number.
638
+ */
639
+ getSnapshotForSeq(seq: number): Snapshot | null;
640
+ /**
641
+ * Estimate bandwidth savings from using a snapshot vs full replay.
642
+ *
643
+ * @param totalOps - Total operations since seq 0
644
+ * @param snapshotSeq - Sequence number of the snapshot
645
+ * @returns Percentage of operations saved
646
+ */
647
+ estimateSavings(totalOps: number, snapshotSeq: number): number;
648
+ /**
649
+ * Get all stored snapshots (for debugging/management).
650
+ */
651
+ getSnapshots(): Snapshot[];
652
+ /**
653
+ * Clear all snapshots (e.g., after schema change).
654
+ */
655
+ clear(): void;
656
+ }
657
+
658
+ export { type AuthResult, type CollectionSnapshot, type CompactionConfig, CompactionManager, type ConnectedClient, MergeEngine, type MergeEngineConfig, type MeridianServer, type MeridianServerConfig, type MySQLPool, MySQLStore, type MySQLStoreConfig, PgStore, type PgStoreConfig, type SQLDriver, type SQLStatement, SQLiteStore, type SQLiteStoreConfig, ServerPresenceManager, type Snapshot, type SnapshotConfig, SnapshotManager, type WALChange, type WALStreamConfig, WsHub, type WsHubConfig, createServer, createWALStream };