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.
package/dist/index.cjs ADDED
@@ -0,0 +1,1680 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ CompactionManager: () => CompactionManager,
34
+ MergeEngine: () => MergeEngine,
35
+ MySQLStore: () => MySQLStore,
36
+ PgStore: () => PgStore,
37
+ SQLiteStore: () => SQLiteStore,
38
+ ServerPresenceManager: () => ServerPresenceManager,
39
+ SnapshotManager: () => SnapshotManager,
40
+ WsHub: () => WsHub,
41
+ createServer: () => createServer,
42
+ createWALStream: () => createWALStream,
43
+ defineSchema: () => import_meridian_shared5.defineSchema,
44
+ z: () => import_meridian_shared5.z
45
+ });
46
+ module.exports = __toCommonJS(index_exports);
47
+
48
+ // src/pg-store.ts
49
+ var import_pg = __toESM(require("pg"), 1);
50
+ var import_meridian_shared = require("meridian-shared");
51
+ var { Pool } = import_pg.default;
52
+ var PgStore = class {
53
+ pool;
54
+ config;
55
+ changeCallbacks = /* @__PURE__ */ new Set();
56
+ listenClient = null;
57
+ minSeq = 0;
58
+ constructor(config) {
59
+ this.config = config;
60
+ this.pool = new Pool({ connectionString: config.connectionString });
61
+ }
62
+ /**
63
+ * Initialize the database — create tables, sequences, triggers.
64
+ */
65
+ async init() {
66
+ await this.pool.query(`
67
+ CREATE SEQUENCE IF NOT EXISTS meridian_seq;
68
+ `);
69
+ for (const [name, fields] of Object.entries(this.config.schema.collections)) {
70
+ await this.createTable(name, fields);
71
+ }
72
+ await this.startListening();
73
+ await this.updateMinSeq();
74
+ }
75
+ /**
76
+ * Get the table name with optional namespace prefix.
77
+ */
78
+ tableName(collection) {
79
+ return this.config.namespace ? `${this.config.namespace}_${collection}` : collection;
80
+ }
81
+ /**
82
+ * Create a table for a collection with Meridian system columns.
83
+ */
84
+ async createTable(collection, fields) {
85
+ const table = this.tableName(collection);
86
+ const tableExistsResult = await this.pool.query(`
87
+ SELECT EXISTS (
88
+ SELECT FROM information_schema.tables
89
+ WHERE table_name = $1
90
+ );
91
+ `, [table]);
92
+ const tableExists = tableExistsResult.rows[0].exists;
93
+ if (!tableExists) {
94
+ const columns = ["id TEXT PRIMARY KEY"];
95
+ for (const [name, def] of Object.entries(fields)) {
96
+ if (name === "id") continue;
97
+ const sqlType = (0, import_meridian_shared.fieldTypeToSQL)(def.type);
98
+ columns.push(`${name} ${sqlType}`);
99
+ }
100
+ columns.push(`_meridian_meta JSONB DEFAULT '{}'::jsonb`);
101
+ columns.push(`_meridian_seq BIGINT DEFAULT nextval('meridian_seq')`);
102
+ columns.push(`_meridian_deleted BOOLEAN DEFAULT false`);
103
+ columns.push(`_meridian_updated_at TEXT`);
104
+ await this.pool.query(`
105
+ CREATE TABLE ${table} (
106
+ ${columns.join(",\n ")}
107
+ );
108
+ `);
109
+ await this.pool.query(`
110
+ CREATE INDEX idx_${table}_seq ON ${table}(_meridian_seq);
111
+ `);
112
+ } else {
113
+ const colsResult = await this.pool.query(`
114
+ SELECT column_name
115
+ FROM information_schema.columns
116
+ WHERE table_name = $1
117
+ `, [table]);
118
+ const existingColumns = new Set(colsResult.rows.map((r) => r.column_name));
119
+ for (const [name, def] of Object.entries(fields)) {
120
+ if (name === "id" || existingColumns.has(name)) continue;
121
+ const sqlType = (0, import_meridian_shared.fieldTypeToSQL)(def.type);
122
+ await this.pool.query(`
123
+ ALTER TABLE ${table} ADD COLUMN ${name} ${sqlType};
124
+ `);
125
+ }
126
+ }
127
+ await this.pool.query(`
128
+ CREATE OR REPLACE FUNCTION meridian_notify_${table}() RETURNS trigger AS $$
129
+ BEGIN
130
+ PERFORM pg_notify('meridian_changes', json_build_object(
131
+ 'table', '${collection}',
132
+ 'id', NEW.id,
133
+ 'op', TG_OP
134
+ )::text);
135
+ RETURN NEW;
136
+ END;
137
+ $$ LANGUAGE plpgsql;
138
+ `);
139
+ await this.pool.query(`
140
+ DROP TRIGGER IF EXISTS meridian_trigger_${table} ON ${table};
141
+ CREATE TRIGGER meridian_trigger_${table}
142
+ AFTER INSERT OR UPDATE ON ${table}
143
+ FOR EACH ROW EXECUTE FUNCTION meridian_notify_${table}();
144
+ `);
145
+ }
146
+ // ─── CRDT Operations ───────────────────────────────────────────────────────
147
+ /**
148
+ * Apply CRDT operations from a client.
149
+ * Performs field-level LWW merge with existing data.
150
+ * @returns Array of server changes with assigned sequence numbers and any conflicts
151
+ */
152
+ async applyOperations(ops) {
153
+ const client = await this.pool.connect();
154
+ const changes = [];
155
+ const allConflicts = [];
156
+ try {
157
+ await client.query("BEGIN");
158
+ const grouped = /* @__PURE__ */ new Map();
159
+ for (const op of ops) {
160
+ const key = `${op.collection}:${op.docId}`;
161
+ if (!grouped.has(key)) grouped.set(key, []);
162
+ grouped.get(key).push(op);
163
+ }
164
+ for (const [key, docOps] of grouped) {
165
+ const firstColon = key.indexOf(":");
166
+ const collection = key.slice(0, firstColon);
167
+ const docId = key.slice(firstColon + 1);
168
+ const table = this.tableName(collection);
169
+ const existing = await client.query(
170
+ `SELECT * FROM ${table} WHERE id = $1 FOR UPDATE`,
171
+ [docId]
172
+ );
173
+ const remoteMap = {};
174
+ for (const op of docOps) {
175
+ remoteMap[op.field] = {
176
+ value: op.value,
177
+ hlc: op.hlc,
178
+ nodeId: op.nodeId
179
+ };
180
+ }
181
+ let finalMap;
182
+ if (existing.rows.length > 0) {
183
+ const row = existing.rows[0];
184
+ const existingMeta = row._meridian_meta || {};
185
+ const existingMap = (0, import_meridian_shared.reconstructLWWMap)(row, existingMeta);
186
+ const { merged, conflicts } = (0, import_meridian_shared.mergeLWWMaps)(existingMap, remoteMap);
187
+ finalMap = merged;
188
+ if (conflicts.length > 0) {
189
+ allConflicts.push(...conflicts);
190
+ }
191
+ } else {
192
+ finalMap = remoteMap;
193
+ }
194
+ const values = (0, import_meridian_shared.extractValues)(finalMap);
195
+ const metadata = (0, import_meridian_shared.extractMetadata)(finalMap);
196
+ const latestHlc = (0, import_meridian_shared.getLatestHLC)(finalMap);
197
+ const deleted = (0, import_meridian_shared.isDeleted)(finalMap);
198
+ const fields = Object.keys(this.config.schema.collections[collection] || {}).filter((f) => f !== "id");
199
+ if (existing.rows.length > 0) {
200
+ const setClauses = [];
201
+ const params = [];
202
+ let paramIdx = 1;
203
+ for (const field of fields) {
204
+ if (field in values) {
205
+ setClauses.push(`${field} = $${paramIdx}`);
206
+ params.push(values[field]);
207
+ paramIdx++;
208
+ }
209
+ }
210
+ setClauses.push(`_meridian_meta = $${paramIdx}`);
211
+ params.push(JSON.stringify(metadata));
212
+ paramIdx++;
213
+ setClauses.push(`_meridian_deleted = $${paramIdx}`);
214
+ params.push(deleted);
215
+ paramIdx++;
216
+ setClauses.push(`_meridian_updated_at = $${paramIdx}`);
217
+ params.push(latestHlc);
218
+ paramIdx++;
219
+ setClauses.push(`_meridian_seq = nextval('meridian_seq')`);
220
+ params.push(docId);
221
+ const result = await client.query(
222
+ `UPDATE ${table} SET ${setClauses.join(", ")} WHERE id = $${paramIdx} RETURNING _meridian_seq`,
223
+ params
224
+ );
225
+ const seq = Number(result.rows[0]._meridian_seq);
226
+ for (const op of docOps) {
227
+ changes.push({ seq, op });
228
+ }
229
+ } else {
230
+ const insertFields = ["id"];
231
+ const insertValues = [docId];
232
+ const placeholders = ["$1"];
233
+ let paramIdx = 2;
234
+ for (const field of fields) {
235
+ if (field in values) {
236
+ insertFields.push(field);
237
+ insertValues.push(values[field]);
238
+ placeholders.push(`$${paramIdx}`);
239
+ paramIdx++;
240
+ }
241
+ }
242
+ insertFields.push("_meridian_meta");
243
+ insertValues.push(JSON.stringify(metadata));
244
+ placeholders.push(`$${paramIdx}`);
245
+ paramIdx++;
246
+ insertFields.push("_meridian_deleted");
247
+ insertValues.push(deleted);
248
+ placeholders.push(`$${paramIdx}`);
249
+ paramIdx++;
250
+ insertFields.push("_meridian_updated_at");
251
+ insertValues.push(latestHlc);
252
+ placeholders.push(`$${paramIdx}`);
253
+ const result = await client.query(
254
+ `INSERT INTO ${table} (${insertFields.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING _meridian_seq`,
255
+ insertValues
256
+ );
257
+ const seq = Number(result.rows[0]._meridian_seq);
258
+ for (const op of docOps) {
259
+ changes.push({ seq, op });
260
+ }
261
+ }
262
+ }
263
+ await client.query("COMMIT");
264
+ } catch (e) {
265
+ await client.query("ROLLBACK");
266
+ throw e;
267
+ } finally {
268
+ client.release();
269
+ }
270
+ return { changes, conflicts: allConflicts };
271
+ }
272
+ /**
273
+ * Get all changes since a given sequence number.
274
+ * Used for pull protocol.
275
+ *
276
+ * @returns null if seqNum is below minSeq (compaction gap), otherwise changes
277
+ */
278
+ async getChangesSince(since) {
279
+ if (since > 0 && since < this.minSeq) {
280
+ return null;
281
+ }
282
+ const changes = [];
283
+ const queries = [];
284
+ for (const collection of Object.keys(this.config.schema.collections)) {
285
+ const table = this.tableName(collection);
286
+ queries.push(`SELECT '${collection}' as _collection, id, _meridian_seq, _meridian_meta, _meridian_deleted, row_to_json(t) as _data FROM ${table} t WHERE _meridian_seq > $1`);
287
+ }
288
+ if (queries.length === 0) return changes;
289
+ const query = queries.join(" UNION ALL ") + " ORDER BY _meridian_seq ASC";
290
+ const result = await this.pool.query(query, [since]);
291
+ for (const row of result.rows) {
292
+ const collection = row._collection;
293
+ const meta = row._meridian_meta || {};
294
+ const seq = Number(row._meridian_seq);
295
+ const data = row._data;
296
+ const docId = row.id;
297
+ const fields = Object.keys(this.config.schema.collections[collection] || {}).filter((f) => f !== "id");
298
+ for (const field of fields) {
299
+ if (field in data && data[field] !== void 0 && data[field] !== null) {
300
+ const hlc = meta[field] || `0-0000-server`;
301
+ changes.push({
302
+ seq,
303
+ op: {
304
+ id: `${docId}-${field}-${hlc}`,
305
+ collection,
306
+ docId,
307
+ field,
308
+ value: data[field],
309
+ hlc,
310
+ nodeId: "server"
311
+ }
312
+ });
313
+ }
314
+ }
315
+ if (row._meridian_deleted) {
316
+ const hlc = meta[import_meridian_shared.DELETED_FIELD] || `0-0000-server`;
317
+ changes.push({
318
+ seq,
319
+ op: {
320
+ id: `${docId}-${import_meridian_shared.DELETED_FIELD}-${hlc}`,
321
+ collection,
322
+ docId,
323
+ field: import_meridian_shared.DELETED_FIELD,
324
+ value: true,
325
+ hlc,
326
+ nodeId: "server"
327
+ }
328
+ });
329
+ }
330
+ }
331
+ return changes;
332
+ }
333
+ /**
334
+ * Get the current minimum available sequence number.
335
+ */
336
+ getMinSeq() {
337
+ return this.minSeq;
338
+ }
339
+ // ─── Compaction ─────────────────────────────────────────────────────────────
340
+ /**
341
+ * Delete tombstoned rows older than maxAge.
342
+ * @returns Number of rows deleted
343
+ */
344
+ async compact(maxAgeMs) {
345
+ const client = await this.pool.connect();
346
+ let totalDeleted = 0;
347
+ const cutoffTime = Date.now() - maxAgeMs;
348
+ try {
349
+ await client.query("BEGIN");
350
+ for (const collection of Object.keys(this.config.schema.collections)) {
351
+ const table = this.tableName(collection);
352
+ const result = await client.query(
353
+ `DELETE FROM ${table} WHERE _meridian_deleted = true AND
354
+ CAST(SPLIT_PART(_meridian_updated_at, '-', 1) AS BIGINT) < $1`,
355
+ [cutoffTime]
356
+ );
357
+ totalDeleted += result.rowCount ?? 0;
358
+ }
359
+ await this.updateMinSeqWithClient(client);
360
+ await client.query("COMMIT");
361
+ } catch (e) {
362
+ await client.query("ROLLBACK");
363
+ throw e;
364
+ } finally {
365
+ client.release();
366
+ }
367
+ return totalDeleted;
368
+ }
369
+ async updateMinSeqWithClient(client) {
370
+ let minSeq = Infinity;
371
+ for (const collection of Object.keys(this.config.schema.collections)) {
372
+ const table = this.tableName(collection);
373
+ const result = await client.query(
374
+ `SELECT MIN(_meridian_seq) as min_seq FROM ${table}`
375
+ );
376
+ const seq = result.rows[0]?.min_seq ? Number(result.rows[0].min_seq) : Infinity;
377
+ if (seq < minSeq) {
378
+ minSeq = seq;
379
+ }
380
+ }
381
+ this.minSeq = minSeq === Infinity ? 0 : minSeq;
382
+ }
383
+ async updateMinSeq() {
384
+ let minSeq = Infinity;
385
+ for (const collection of Object.keys(this.config.schema.collections)) {
386
+ const table = this.tableName(collection);
387
+ try {
388
+ const result = await this.pool.query(
389
+ `SELECT MIN(_meridian_seq) as min_seq FROM ${table}`
390
+ );
391
+ if (result.rows[0]?.min_seq !== null) {
392
+ minSeq = Math.min(minSeq, Number(result.rows[0].min_seq));
393
+ }
394
+ } catch {
395
+ }
396
+ }
397
+ this.minSeq = minSeq === Infinity ? 0 : minSeq;
398
+ }
399
+ // ─── LISTEN/NOTIFY ──────────────────────────────────────────────────────────
400
+ async startListening() {
401
+ this.listenClient = await this.pool.connect();
402
+ this.listenClient.on("notification", (msg) => {
403
+ if (msg.channel === "meridian_changes" && msg.payload) {
404
+ try {
405
+ const data = JSON.parse(msg.payload);
406
+ for (const cb of this.changeCallbacks) {
407
+ cb(data.table, data.id);
408
+ }
409
+ } catch (e) {
410
+ console.error("[Meridian PgStore] Failed to parse notification:", e);
411
+ }
412
+ }
413
+ });
414
+ await this.listenClient.query("LISTEN meridian_changes");
415
+ }
416
+ /**
417
+ * Register a callback for database changes.
418
+ */
419
+ onChange(callback) {
420
+ this.changeCallbacks.add(callback);
421
+ return () => this.changeCallbacks.delete(callback);
422
+ }
423
+ // ─── Cleanup ───────────────────────────────────────────────────────────────
424
+ /**
425
+ * Close the database connection pool.
426
+ */
427
+ async close() {
428
+ if (this.listenClient) {
429
+ this.listenClient.release();
430
+ this.listenClient = null;
431
+ }
432
+ await this.pool.end();
433
+ }
434
+ };
435
+
436
+ // src/ws-hub.ts
437
+ var import_ws = require("ws");
438
+ var HEARTBEAT_INTERVAL = 3e4;
439
+ var AUTH_EXPIRY_WARNING = 5 * 60 * 1e3;
440
+ var AUTH_CHECK_INTERVAL = 6e4;
441
+ var WsHub = class {
442
+ wss = null;
443
+ config;
444
+ clients = /* @__PURE__ */ new Map();
445
+ heartbeatInterval = null;
446
+ authCheckInterval = null;
447
+ constructor(config) {
448
+ this.config = config;
449
+ }
450
+ /**
451
+ * Start the WebSocket server.
452
+ */
453
+ start() {
454
+ const { port, path = "/sync" } = this.config;
455
+ this.wss = new import_ws.WebSocketServer({ port, path });
456
+ this.wss.on("connection", (ws, req) => {
457
+ this.handleConnection(ws, req);
458
+ });
459
+ this.startHeartbeat();
460
+ this.startAuthCheck();
461
+ this.log(`\u{1F50C} WebSocket server listening on ws://localhost:${port}${path}`);
462
+ }
463
+ /**
464
+ * Stop the WebSocket server.
465
+ */
466
+ stop() {
467
+ if (this.heartbeatInterval) {
468
+ clearInterval(this.heartbeatInterval);
469
+ this.heartbeatInterval = null;
470
+ }
471
+ if (this.authCheckInterval) {
472
+ clearInterval(this.authCheckInterval);
473
+ this.authCheckInterval = null;
474
+ }
475
+ for (const client of this.clients.values()) {
476
+ client.ws.close();
477
+ }
478
+ this.clients.clear();
479
+ this.wss?.close();
480
+ this.wss = null;
481
+ }
482
+ // ─── Connection Handling ────────────────────────────────────────────────────
483
+ handleConnection(ws, req) {
484
+ const clientId = `client-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
485
+ const client = {
486
+ id: clientId,
487
+ ws,
488
+ userId: null,
489
+ namespace: null,
490
+ subscribedCollections: /* @__PURE__ */ new Set(),
491
+ authExpiresAt: null,
492
+ lastActivity: Date.now()
493
+ };
494
+ this.clients.set(clientId, client);
495
+ this.log(`\u{1F517} Client connected: ${clientId}`);
496
+ ws.on("message", async (data) => {
497
+ client.lastActivity = Date.now();
498
+ const raw = data.toString();
499
+ if (raw === "ping") {
500
+ ws.send("pong");
501
+ return;
502
+ }
503
+ if (raw === "pong") {
504
+ return;
505
+ }
506
+ try {
507
+ const msg = JSON.parse(raw);
508
+ await this.handleClientMessage(clientId, msg, client);
509
+ } catch (e) {
510
+ this.sendTo(client, {
511
+ type: "error",
512
+ code: "PARSE_ERROR",
513
+ message: "Failed to parse message"
514
+ });
515
+ }
516
+ });
517
+ ws.on("close", () => {
518
+ this.log(`\u{1F50C} Client disconnected: ${clientId}`);
519
+ this.clients.delete(clientId);
520
+ this.config.onDisconnect?.(clientId);
521
+ });
522
+ ws.on("error", (err) => {
523
+ this.log(`\u274C Client error (${clientId}):`, err.message);
524
+ });
525
+ }
526
+ async handleClientMessage(clientId, msg, client) {
527
+ if (msg.type === "auth") {
528
+ if (this.config.auth) {
529
+ try {
530
+ const result = await this.config.auth(msg.token);
531
+ client.userId = result.userId;
532
+ client.namespace = result.namespace ?? null;
533
+ client.authExpiresAt = result.expiresAt ?? null;
534
+ this.log(`\u{1F511} Client ${clientId} authenticated as ${result.userId}`);
535
+ this.sendTo(client, { type: "auth-ack" });
536
+ } catch (e) {
537
+ this.sendTo(client, {
538
+ type: "error",
539
+ code: "AUTH_FAILED",
540
+ message: e instanceof Error ? e.message : "Authentication failed"
541
+ });
542
+ client.ws.close();
543
+ return;
544
+ }
545
+ }
546
+ return;
547
+ }
548
+ if (this.config.auth && !client.userId) {
549
+ this.sendTo(client, {
550
+ type: "error",
551
+ code: "AUTH_REQUIRED",
552
+ message: "Authentication required. Send an auth message first."
553
+ });
554
+ return;
555
+ }
556
+ if (msg.type === "subscribe") {
557
+ for (const collection of msg.collections) {
558
+ client.subscribedCollections.add(collection);
559
+ }
560
+ if (this.config.onSubscribe) {
561
+ this.config.onSubscribe(clientId, msg.collections, msg.filter);
562
+ }
563
+ this.log(`\u{1F4CB} Client ${clientId} subscribed to: ${msg.collections.join(", ")}`);
564
+ return;
565
+ }
566
+ this.config.onMessage(clientId, msg, client);
567
+ }
568
+ // ─── Broadcasting ──────────────────────────────────────────────────────────
569
+ /**
570
+ * Send a message to a specific client.
571
+ */
572
+ sendTo(client, msg) {
573
+ if (client.ws.readyState === import_ws.WebSocket.OPEN) {
574
+ client.ws.send(JSON.stringify(msg));
575
+ }
576
+ }
577
+ /**
578
+ * Send a message to a client by ID.
579
+ */
580
+ sendToId(clientId, msg) {
581
+ const client = this.clients.get(clientId);
582
+ if (client) {
583
+ this.sendTo(client, msg);
584
+ }
585
+ }
586
+ /**
587
+ * Broadcast a message to all clients subscribed to a collection.
588
+ * Excludes the sender.
589
+ */
590
+ broadcastToCollection(collection, msg, excludeClientId, namespace) {
591
+ for (const [id, client] of this.clients) {
592
+ if (id === excludeClientId) continue;
593
+ if (namespace !== void 0 && client.namespace !== namespace) continue;
594
+ if (client.subscribedCollections.has(collection)) {
595
+ this.sendTo(client, msg);
596
+ }
597
+ }
598
+ }
599
+ /**
600
+ * Broadcast a message to all connected clients.
601
+ */
602
+ broadcastToAll(msg, namespace) {
603
+ for (const client of this.clients.values()) {
604
+ if (namespace !== void 0 && client.namespace !== namespace) continue;
605
+ this.sendTo(client, msg);
606
+ }
607
+ }
608
+ /**
609
+ * Get all connected client IDs.
610
+ */
611
+ getClientIds() {
612
+ return Array.from(this.clients.keys());
613
+ }
614
+ /**
615
+ * Get a connected client by ID.
616
+ */
617
+ getClient(clientId) {
618
+ return this.clients.get(clientId);
619
+ }
620
+ // ─── Heartbeat ──────────────────────────────────────────────────────────────
621
+ startHeartbeat() {
622
+ this.heartbeatInterval = setInterval(() => {
623
+ const now = Date.now();
624
+ for (const [id, client] of this.clients) {
625
+ if (client.ws.readyState !== import_ws.WebSocket.OPEN || now - client.lastActivity > HEARTBEAT_INTERVAL * 2) {
626
+ this.log(`\u26A0\uFE0F Disconnecting inactive client: ${id}`);
627
+ client.ws.terminate();
628
+ this.clients.delete(id);
629
+ this.config.onDisconnect?.(id);
630
+ } else {
631
+ client.ws.send("ping");
632
+ }
633
+ }
634
+ }, HEARTBEAT_INTERVAL);
635
+ }
636
+ // ─── Auth Expiry Check ─────────────────────────────────────────────────────
637
+ startAuthCheck() {
638
+ if (!this.config.auth) return;
639
+ this.authCheckInterval = setInterval(() => {
640
+ const now = Date.now();
641
+ for (const client of this.clients.values()) {
642
+ if (!client.authExpiresAt) continue;
643
+ const timeLeft = client.authExpiresAt - now;
644
+ if (timeLeft <= 0) {
645
+ this.sendTo(client, { type: "auth-expired" });
646
+ client.ws.close();
647
+ } else if (timeLeft <= AUTH_EXPIRY_WARNING) {
648
+ this.sendTo(client, {
649
+ type: "auth-expiring",
650
+ expiresIn: Math.floor(timeLeft / 1e3)
651
+ });
652
+ }
653
+ }
654
+ }, AUTH_CHECK_INTERVAL);
655
+ }
656
+ // ─── Utilities ──────────────────────────────────────────────────────────────
657
+ log(...args) {
658
+ if (this.config.debug) {
659
+ console.log("[Meridian WsHub]", ...args);
660
+ }
661
+ }
662
+ };
663
+
664
+ // src/merge.ts
665
+ var import_meridian_shared2 = require("meridian-shared");
666
+ var MergeEngine = class {
667
+ config;
668
+ conflictLog = [];
669
+ maxConflictLog = 1e3;
670
+ /** Per-client subscribe filters: clientId → collection → filter */
671
+ clientFilters = /* @__PURE__ */ new Map();
672
+ ruleEvaluator = null;
673
+ constructor(config) {
674
+ this.config = config;
675
+ if (config.permissions) {
676
+ this.ruleEvaluator = new import_meridian_shared2.RuleEvaluator(config.permissions);
677
+ }
678
+ }
679
+ /** Store a client's subscribe filter for partial sync */
680
+ setClientFilter(clientId, collections, filter) {
681
+ const map = /* @__PURE__ */ new Map();
682
+ if (filter) {
683
+ for (const [col, f] of Object.entries(filter)) {
684
+ map.set(col, f);
685
+ }
686
+ }
687
+ for (const col of collections) {
688
+ if (!map.has(col)) map.set(col, {});
689
+ }
690
+ this.clientFilters.set(clientId, map);
691
+ }
692
+ /** Remove client filters on disconnect */
693
+ removeClientFilter(clientId) {
694
+ this.clientFilters.delete(clientId);
695
+ }
696
+ /**
697
+ * Process a push from a client.
698
+ * Merges operations with existing state, assigns seqNums, and broadcasts.
699
+ *
700
+ * @param clientId - The sending client's ID
701
+ * @param ops - CRDT operations from the client
702
+ * @param client - The connected client object
703
+ */
704
+ async processPush(clientId, ops, client) {
705
+ if (ops.length === 0) return;
706
+ this.log(`\u2B07\uFE0F Processing ${ops.length} ops from ${clientId}`);
707
+ try {
708
+ const { changes, conflicts } = await this.config.pgStore.applyOperations(ops);
709
+ if (changes.length === 0) return;
710
+ for (const conflict of conflicts) {
711
+ const op = ops.find((o) => o.field === conflict.field && (o.value === conflict.winnerValue || o.value === conflict.loserValue));
712
+ if (op) {
713
+ const conflictRecord = {
714
+ ...conflict,
715
+ collection: op.collection,
716
+ docId: op.docId,
717
+ timestamp: Date.now()
718
+ };
719
+ this.conflictLog.push(conflictRecord);
720
+ if (this.conflictLog.length > this.maxConflictLog) {
721
+ this.conflictLog.shift();
722
+ }
723
+ if (this.config.onConflict) {
724
+ this.config.onConflict(conflictRecord);
725
+ }
726
+ }
727
+ }
728
+ const lastSeq = Math.max(...changes.map((c) => c.seq));
729
+ const opIds = ops.map((op) => op.id);
730
+ this.config.wsHub.sendTo(client, {
731
+ type: "ack",
732
+ lastSeq,
733
+ opIds
734
+ });
735
+ const collections = new Set(ops.map((op) => op.collection));
736
+ for (const collection of collections) {
737
+ const collectionChanges = changes.filter((c) => c.op.collection === collection);
738
+ if (collectionChanges.length > 0) {
739
+ this.config.wsHub.broadcastToCollection(
740
+ collection,
741
+ { type: "changes", changes: collectionChanges },
742
+ clientId,
743
+ client.namespace
744
+ );
745
+ }
746
+ }
747
+ this.log(`\u2705 Applied ${changes.length} changes, lastSeq=${lastSeq}`);
748
+ } catch (e) {
749
+ this.log(`\u274C Merge failed:`, e);
750
+ for (const op of ops) {
751
+ this.config.wsHub.sendTo(client, {
752
+ type: "reject",
753
+ opId: op.id,
754
+ code: "VALIDATION",
755
+ reason: e instanceof Error ? e.message : "Merge failed"
756
+ });
757
+ }
758
+ }
759
+ }
760
+ /**
761
+ * Process a pull request from a client.
762
+ * Returns changes since the given sequence number.
763
+ */
764
+ async processPull(clientId, since, client) {
765
+ this.log(`\u2B07\uFE0F Pull request from ${clientId}: since=${since}`);
766
+ const changes = await this.config.pgStore.getChangesSince(since);
767
+ if (changes === null) {
768
+ this.config.wsHub.sendTo(client, {
769
+ type: "full-sync-required",
770
+ reason: "compaction",
771
+ minSeq: this.config.pgStore.getMinSeq()
772
+ });
773
+ return;
774
+ }
775
+ const clientFilter = this.clientFilters.get(clientId);
776
+ let filtered = changes.filter((c) => {
777
+ if (client.subscribedCollections.size > 0 && !client.subscribedCollections.has(c.op.collection)) {
778
+ return false;
779
+ }
780
+ if (clientFilter) {
781
+ const colFilter = clientFilter.get(c.op.collection);
782
+ if (colFilter && Object.keys(colFilter).length > 0) {
783
+ return true;
784
+ }
785
+ }
786
+ return true;
787
+ });
788
+ if (this.ruleEvaluator && client.userId) {
789
+ const authCtx = { userId: client.userId };
790
+ const permissionFiltered = [];
791
+ for (const change of filtered) {
792
+ const allowed = await this.ruleEvaluator.check(
793
+ change.op.collection,
794
+ "read",
795
+ authCtx,
796
+ { existing: null, incoming: null }
797
+ );
798
+ if (allowed) permissionFiltered.push(change);
799
+ }
800
+ filtered = permissionFiltered;
801
+ }
802
+ if (filtered.length > 0) {
803
+ this.config.wsHub.sendTo(client, {
804
+ type: "changes",
805
+ changes: filtered
806
+ });
807
+ }
808
+ this.log(`\u{1F4E4} Sent ${filtered.length} changes to ${clientId}`);
809
+ }
810
+ /**
811
+ * Get the conflict log.
812
+ */
813
+ getConflictLog() {
814
+ return [...this.conflictLog];
815
+ }
816
+ log(...args) {
817
+ if (this.config.debug) {
818
+ console.log("[Meridian Merge]", ...args);
819
+ }
820
+ }
821
+ };
822
+
823
+ // src/presence.ts
824
+ var ServerPresenceManager = class {
825
+ presence = /* @__PURE__ */ new Map();
826
+ wsHub;
827
+ debug;
828
+ constructor(wsHub, debug = false) {
829
+ this.wsHub = wsHub;
830
+ this.debug = debug;
831
+ }
832
+ /**
833
+ * Update presence for a client and broadcast to peers.
834
+ */
835
+ update(clientId, data, client) {
836
+ this.presence.set(clientId, data);
837
+ if (this.debug) {
838
+ console.log(`[Meridian Presence] Updated: ${clientId}`, data);
839
+ }
840
+ this.broadcastAll(client.namespace);
841
+ }
842
+ /**
843
+ * Remove presence for a disconnected client.
844
+ */
845
+ remove(clientId) {
846
+ const had = this.presence.delete(clientId);
847
+ if (had && this.debug) {
848
+ console.log(`[Meridian Presence] Removed: ${clientId}`);
849
+ }
850
+ this.broadcastAll(null);
851
+ }
852
+ /**
853
+ * Get all current presence data.
854
+ */
855
+ getAll() {
856
+ return Object.fromEntries(this.presence);
857
+ }
858
+ /**
859
+ * Send current presence state to a specific client (e.g., on reconnect).
860
+ */
861
+ sendCurrentState(client) {
862
+ this.wsHub.sendTo(client, {
863
+ type: "presence",
864
+ peers: Object.fromEntries(this.presence)
865
+ });
866
+ }
867
+ broadcastAll(namespace) {
868
+ this.wsHub.broadcastToAll(
869
+ {
870
+ type: "presence",
871
+ peers: Object.fromEntries(this.presence)
872
+ },
873
+ namespace
874
+ );
875
+ }
876
+ /**
877
+ * Clear all presence data.
878
+ */
879
+ clear() {
880
+ this.presence.clear();
881
+ }
882
+ };
883
+
884
+ // src/compaction.ts
885
+ var DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
886
+ var DEFAULT_INTERVAL = 24 * 60 * 60 * 1e3;
887
+ var CompactionManager = class {
888
+ pgStore;
889
+ wsHub;
890
+ config;
891
+ timer = null;
892
+ constructor(pgStore, wsHub, config) {
893
+ this.pgStore = pgStore;
894
+ this.wsHub = wsHub;
895
+ this.config = {
896
+ tombstoneMaxAge: config?.tombstoneMaxAge ?? DEFAULT_MAX_AGE,
897
+ interval: config?.interval ?? DEFAULT_INTERVAL,
898
+ debug: config?.debug ?? false
899
+ };
900
+ }
901
+ /**
902
+ * Start the compaction scheduler.
903
+ */
904
+ start() {
905
+ this.log(`\u{1F9F9} Compaction scheduler started (interval: ${this.config.interval}ms, maxAge: ${this.config.tombstoneMaxAge}ms)`);
906
+ setTimeout(() => this.runCompaction(), 5e3);
907
+ this.timer = setInterval(() => this.runCompaction(), this.config.interval);
908
+ }
909
+ /**
910
+ * Stop the compaction scheduler.
911
+ */
912
+ stop() {
913
+ if (this.timer) {
914
+ clearInterval(this.timer);
915
+ this.timer = null;
916
+ }
917
+ }
918
+ /**
919
+ * Run compaction now.
920
+ */
921
+ async runCompaction() {
922
+ this.log("\u{1F9F9} Running compaction...");
923
+ try {
924
+ const deleted = await this.pgStore.compact(this.config.tombstoneMaxAge);
925
+ const minSeq = this.pgStore.getMinSeq();
926
+ if (deleted > 0) {
927
+ this.log(`\u{1F9F9} Compacted ${deleted} tombstones. New minSeq: ${minSeq}`);
928
+ this.wsHub.broadcastToAll({
929
+ type: "compaction",
930
+ minSeq
931
+ });
932
+ } else {
933
+ this.log("\u{1F9F9} No tombstones to compact");
934
+ }
935
+ return deleted;
936
+ } catch (e) {
937
+ this.log("\u274C Compaction failed:", e);
938
+ return 0;
939
+ }
940
+ }
941
+ log(...args) {
942
+ if (this.config.debug) {
943
+ console.log("[Meridian Compaction]", ...args);
944
+ }
945
+ }
946
+ };
947
+
948
+ // src/server.ts
949
+ function createServer(config) {
950
+ const {
951
+ port,
952
+ database,
953
+ schema,
954
+ path = "/sync",
955
+ auth,
956
+ compaction,
957
+ onConflict,
958
+ permissions,
959
+ debug = false
960
+ } = config;
961
+ const pgStore = new PgStore({
962
+ connectionString: database,
963
+ schema
964
+ });
965
+ let mergeEngine;
966
+ let presenceManager;
967
+ const wsHub = new WsHub({
968
+ port,
969
+ path,
970
+ auth,
971
+ debug,
972
+ onMessage: (clientId, msg, client) => {
973
+ switch (msg.type) {
974
+ case "push":
975
+ mergeEngine.processPush(clientId, msg.ops, client);
976
+ break;
977
+ case "pull":
978
+ mergeEngine.processPull(clientId, msg.since, client);
979
+ break;
980
+ case "presence":
981
+ presenceManager.update(clientId, msg.data, client);
982
+ break;
983
+ default:
984
+ break;
985
+ }
986
+ },
987
+ onDisconnect: (clientId) => {
988
+ presenceManager.remove(clientId);
989
+ mergeEngine.removeClientFilter(clientId);
990
+ },
991
+ onSubscribe: (clientId, collections, filter) => {
992
+ mergeEngine.setClientFilter(clientId, collections, filter);
993
+ }
994
+ });
995
+ mergeEngine = new MergeEngine({
996
+ pgStore,
997
+ wsHub,
998
+ debug,
999
+ onConflict,
1000
+ permissions
1001
+ });
1002
+ presenceManager = new ServerPresenceManager(wsHub, debug);
1003
+ const compactionManager = new CompactionManager(pgStore, wsHub, {
1004
+ tombstoneMaxAge: compaction?.tombstoneMaxAge,
1005
+ interval: compaction?.interval,
1006
+ debug
1007
+ });
1008
+ pgStore.onChange((tableName, docId) => {
1009
+ if (debug) {
1010
+ console.log(`[Meridian Server] \u{1F4E2} DB change: ${tableName}/${docId}`);
1011
+ }
1012
+ });
1013
+ return {
1014
+ async start() {
1015
+ if (debug) {
1016
+ console.log("[Meridian Server] \u{1F680} Starting...");
1017
+ console.log(`[Meridian Server] \u{1F4E6} Schema v${schema.version}: ${Object.keys(schema.collections).join(", ")}`);
1018
+ console.log(`[Meridian Server] \u{1F418} Database: ${database.replace(/\/\/.*@/, "//***@")}`);
1019
+ }
1020
+ await pgStore.init();
1021
+ wsHub.start();
1022
+ compactionManager.start();
1023
+ if (debug) {
1024
+ console.log(`[Meridian Server] \u2705 Ready on ws://localhost:${port}${path}`);
1025
+ }
1026
+ },
1027
+ async stop() {
1028
+ if (debug) {
1029
+ console.log("[Meridian Server] \u{1F6D1} Stopping...");
1030
+ }
1031
+ compactionManager.stop();
1032
+ wsHub.stop();
1033
+ presenceManager.clear();
1034
+ await pgStore.close();
1035
+ if (debug) {
1036
+ console.log("[Meridian Server] \u2705 Stopped");
1037
+ }
1038
+ },
1039
+ async compact() {
1040
+ return compactionManager.runCompaction();
1041
+ },
1042
+ getClientCount() {
1043
+ return wsHub.getClientIds().length;
1044
+ }
1045
+ };
1046
+ }
1047
+
1048
+ // src/wal-stream.ts
1049
+ var import_pg2 = require("pg");
1050
+ var NotifyStream = class {
1051
+ client;
1052
+ config;
1053
+ connected = false;
1054
+ constructor(config) {
1055
+ this.config = config;
1056
+ this.client = new import_pg2.Client({ connectionString: config.connectionString });
1057
+ }
1058
+ async start() {
1059
+ await this.client.connect();
1060
+ this.connected = true;
1061
+ const channel = this.config.channel || "meridian_changes";
1062
+ this.client.on("notification", (msg) => {
1063
+ try {
1064
+ const payload = JSON.parse(msg.payload || "{}");
1065
+ if (this.config.debug) {
1066
+ console.log(`[WAL-NOTIFY] ${channel}:`, payload.collection, payload.docId);
1067
+ }
1068
+ this.config.onChange({
1069
+ collection: payload.collection,
1070
+ docId: payload.docId,
1071
+ operation: payload.operation || "UPDATE",
1072
+ fields: payload.fields,
1073
+ seq: payload.seq
1074
+ });
1075
+ } catch (err) {
1076
+ console.error("[WAL-NOTIFY] Failed to parse notification:", err);
1077
+ }
1078
+ });
1079
+ await this.client.query(`LISTEN ${channel}`);
1080
+ if (this.config.debug) {
1081
+ console.log(`[WAL-NOTIFY] Listening on channel "${channel}"`);
1082
+ }
1083
+ await this.createNotifyTriggers();
1084
+ }
1085
+ async createNotifyTriggers() {
1086
+ await this.client.query(`
1087
+ CREATE OR REPLACE FUNCTION meridian_notify() RETURNS trigger AS $$
1088
+ BEGIN
1089
+ PERFORM pg_notify(
1090
+ $1,
1091
+ json_build_object(
1092
+ 'collection', TG_TABLE_NAME,
1093
+ 'docId', NEW.id,
1094
+ 'operation', TG_OP,
1095
+ 'seq', NEW._meridian_seq
1096
+ )::text
1097
+ );
1098
+ RETURN NEW;
1099
+ END;
1100
+ $$ LANGUAGE plpgsql;
1101
+ `, [this.config.channel || "meridian_changes"]);
1102
+ }
1103
+ async stop() {
1104
+ if (!this.connected) return;
1105
+ const channel = this.config.channel || "meridian_changes";
1106
+ await this.client.query(`UNLISTEN ${channel}`);
1107
+ await this.client.end();
1108
+ this.connected = false;
1109
+ }
1110
+ };
1111
+ var WALStream = class {
1112
+ config;
1113
+ client = null;
1114
+ pollingInterval = null;
1115
+ constructor(config) {
1116
+ this.config = config;
1117
+ }
1118
+ async start() {
1119
+ this.client = new import_pg2.Client({ connectionString: this.config.connectionString });
1120
+ await this.client.connect();
1121
+ const pubName = this.config.publication || "meridian_pub";
1122
+ const slotName = this.config.slot || "meridian_slot";
1123
+ await this.client.query(`CREATE EXTENSION IF NOT EXISTS wal2json`);
1124
+ await this.client.query(`
1125
+ DO $$
1126
+ BEGIN
1127
+ IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = '${pubName}') THEN
1128
+ CREATE PUBLICATION ${pubName} FOR ALL TABLES;
1129
+ END IF;
1130
+ END $$;
1131
+ `);
1132
+ const { rows: slotRows } = await this.client.query(
1133
+ `SELECT slot_name FROM pg_replication_slots WHERE slot_name = $1`,
1134
+ [slotName]
1135
+ );
1136
+ if (slotRows.length === 0) {
1137
+ await this.client.query(
1138
+ `SELECT pg_create_logical_replication_slot($1, 'wal2json')`,
1139
+ [slotName]
1140
+ );
1141
+ }
1142
+ const POLL_INTERVAL = 100;
1143
+ this.pollingInterval = setInterval(async () => {
1144
+ try {
1145
+ const { rows } = await this.client.query(
1146
+ `SELECT data FROM pg_logical_slot_get_changes($1, NULL, NULL)`,
1147
+ [slotName]
1148
+ );
1149
+ for (const row of rows) {
1150
+ const parsed = this.parsePGOutput(row.data);
1151
+ if (parsed) {
1152
+ this.config.onChange({
1153
+ collection: parsed.collection,
1154
+ docId: parsed.docId,
1155
+ operation: parsed.operation,
1156
+ seq: parsed.seq
1157
+ });
1158
+ }
1159
+ }
1160
+ } catch (err) {
1161
+ console.error("[WAL] Poll error:", err);
1162
+ }
1163
+ }, POLL_INTERVAL);
1164
+ if (this.config.debug) {
1165
+ console.log(`[WAL] Publication "${pubName}", slot "${slotName}", polling every ${POLL_INTERVAL}ms`);
1166
+ }
1167
+ }
1168
+ /** Parse wal2json JSON output into a WALChange */
1169
+ parsePGOutput(data) {
1170
+ try {
1171
+ const parsed = JSON.parse(data);
1172
+ if (!parsed.change || parsed.change.length === 0) return null;
1173
+ const change = parsed.change[0];
1174
+ const table = change.table;
1175
+ const kind = change.kind;
1176
+ let docId;
1177
+ if (change.columnvalues && change.columnnames) {
1178
+ const idIndex = change.columnnames.indexOf("id");
1179
+ docId = idIndex >= 0 ? String(change.columnvalues[idIndex]) : String(change.columnvalues[0]);
1180
+ } else if (change.oldkeys?.keyvalues) {
1181
+ const idIndex = change.oldkeys.keynames.indexOf("id");
1182
+ docId = idIndex >= 0 ? String(change.oldkeys.keyvalues[idIndex]) : String(change.oldkeys.keyvalues[0]);
1183
+ }
1184
+ if (!docId || !table) return null;
1185
+ const operation = kind === "delete" ? "DELETE" : kind === "update" ? "UPDATE" : "INSERT";
1186
+ let seq;
1187
+ if (change.columnnames && change.columnvalues) {
1188
+ const seqIndex = change.columnnames.indexOf("_meridian_seq");
1189
+ if (seqIndex >= 0) seq = parseInt(change.columnvalues[seqIndex], 10);
1190
+ }
1191
+ return { collection: table, docId, operation, seq };
1192
+ } catch {
1193
+ return null;
1194
+ }
1195
+ }
1196
+ async stop() {
1197
+ if (this.pollingInterval) {
1198
+ clearInterval(this.pollingInterval);
1199
+ this.pollingInterval = null;
1200
+ }
1201
+ if (this.client) {
1202
+ await this.client.end();
1203
+ this.client = null;
1204
+ }
1205
+ }
1206
+ };
1207
+ function createWALStream(config) {
1208
+ const mode = config.mode || "notify";
1209
+ if (mode === "wal") {
1210
+ return new WALStream(config);
1211
+ }
1212
+ return new NotifyStream(config);
1213
+ }
1214
+
1215
+ // src/sqlite-store.ts
1216
+ var import_meridian_shared3 = require("meridian-shared");
1217
+ var SQLiteStore = class {
1218
+ driver;
1219
+ config;
1220
+ lastSeq = 0;
1221
+ minSeq = 0;
1222
+ constructor(config) {
1223
+ this.driver = config.driver;
1224
+ this.config = config;
1225
+ }
1226
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
1227
+ async init() {
1228
+ const { schema } = this.config;
1229
+ this.driver.exec(`
1230
+ CREATE TABLE IF NOT EXISTS _meridian_sync (
1231
+ key TEXT PRIMARY KEY,
1232
+ value TEXT NOT NULL
1233
+ )
1234
+ `);
1235
+ for (const [name, colSchema] of Object.entries(schema.collections)) {
1236
+ this.createTable(name, colSchema);
1237
+ }
1238
+ const row = this.driver.prepare("SELECT value FROM _meridian_sync WHERE key = 'last_seq'").get();
1239
+ this.lastSeq = row ? parseInt(row.value, 10) : 0;
1240
+ if (this.config.debug) {
1241
+ console.log(`[SQLite Store] Initialized. Last seq: ${this.lastSeq}`);
1242
+ }
1243
+ }
1244
+ createTable(name, schema) {
1245
+ const columns = ["id TEXT PRIMARY KEY"];
1246
+ for (const [field, def] of Object.entries(schema)) {
1247
+ if (field === "id") continue;
1248
+ const sqlType = (0, import_meridian_shared3.fieldTypeToSQL)(def.type);
1249
+ const defaultClause = def.defaultValue !== void 0 ? ` DEFAULT ${JSON.stringify(def.defaultValue)}` : "";
1250
+ columns.push(`"${field}" ${sqlType}${defaultClause}`);
1251
+ }
1252
+ columns.push("_meridian_meta TEXT");
1253
+ columns.push("_meridian_seq INTEGER");
1254
+ columns.push("_meridian_deleted INTEGER DEFAULT 0");
1255
+ columns.push("_meridian_updated_at TEXT");
1256
+ this.driver.exec(`CREATE TABLE IF NOT EXISTS "${name}" (${columns.join(", ")})`);
1257
+ this.driver.exec(`CREATE INDEX IF NOT EXISTS idx_${name}_seq ON "${name}" (_meridian_seq)`);
1258
+ }
1259
+ // ─── CRUD ──────────────────────────────────────────────────────────────────
1260
+ async applyOperations(ops) {
1261
+ const changes = [];
1262
+ const conflicts = [];
1263
+ const grouped = /* @__PURE__ */ new Map();
1264
+ for (const op of ops) {
1265
+ const key = `${op.collection}:${op.docId}`;
1266
+ if (!grouped.has(key)) grouped.set(key, []);
1267
+ grouped.get(key).push(op);
1268
+ }
1269
+ for (const [key, docOps] of grouped) {
1270
+ const [collection, docId] = key.split(":");
1271
+ const existing = this.driver.prepare(`SELECT * FROM "${collection}" WHERE id = ?`).get(docId);
1272
+ const existingMeta = existing?._meridian_meta ? JSON.parse(existing._meridian_meta) : null;
1273
+ const remoteMap = {};
1274
+ for (const op of docOps) {
1275
+ remoteMap[op.field] = { value: op.value, hlc: op.hlc, nodeId: op.nodeId };
1276
+ }
1277
+ let finalMap;
1278
+ if (existingMeta) {
1279
+ const { merged, conflicts: fieldConflicts } = (0, import_meridian_shared3.mergeLWWMaps)(existingMeta, remoteMap);
1280
+ finalMap = merged;
1281
+ for (const c of fieldConflicts) {
1282
+ conflicts.push({ collection, docId, ...c });
1283
+ }
1284
+ } else {
1285
+ finalMap = remoteMap;
1286
+ finalMap[import_meridian_shared3.DELETED_FIELD] = { value: false, hlc: docOps[0].hlc, nodeId: docOps[0].nodeId };
1287
+ }
1288
+ if ((0, import_meridian_shared3.isDeleted)(finalMap)) {
1289
+ this.driver.prepare(`DELETE FROM "${collection}" WHERE id = ?`).run(docId);
1290
+ } else {
1291
+ const values = (0, import_meridian_shared3.extractValues)(finalMap);
1292
+ const defaults = (0, import_meridian_shared3.getDefaults)(this.config.schema.collections[collection]);
1293
+ for (const [field, defaultVal] of Object.entries(defaults)) {
1294
+ if (!(field in values)) values[field] = defaultVal;
1295
+ }
1296
+ const meta = JSON.stringify((0, import_meridian_shared3.extractMetadata)(finalMap));
1297
+ this.lastSeq++;
1298
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1299
+ if (existing) {
1300
+ const setClauses = [];
1301
+ const params = [];
1302
+ for (const [field, value] of Object.entries(values)) {
1303
+ setClauses.push(`"${field}" = ?`);
1304
+ params.push(value);
1305
+ }
1306
+ setClauses.push("_meridian_meta = ?");
1307
+ params.push(meta);
1308
+ setClauses.push("_meridian_seq = ?");
1309
+ params.push(this.lastSeq);
1310
+ setClauses.push("_meridian_updated_at = ?");
1311
+ params.push(now);
1312
+ this.driver.prepare(`UPDATE "${collection}" SET ${setClauses.join(", ")} WHERE id = ?`).run(...params, docId);
1313
+ } else {
1314
+ const fields = Object.keys(values);
1315
+ const placeholders = fields.map(() => "?").join(", ");
1316
+ const params = fields.map((f) => values[f]);
1317
+ params.push(meta, this.lastSeq, 0, now);
1318
+ this.driver.prepare(`INSERT INTO "${collection}" (${fields.map((f) => `"${f}"`).join(", ")}, _meridian_meta, _meridian_seq, _meridian_deleted, _meridian_updated_at) VALUES (${placeholders}, ?, ?, 0, ?)`).run(...params);
1319
+ }
1320
+ }
1321
+ for (const op of docOps) {
1322
+ changes.push({ seq: this.lastSeq, op });
1323
+ }
1324
+ }
1325
+ this.driver.prepare("INSERT OR REPLACE INTO _meridian_sync (key, value) VALUES ('last_seq', ?)").run(this.lastSeq.toString());
1326
+ return { changes, conflicts };
1327
+ }
1328
+ async getChangesSince(since) {
1329
+ if (since < this.minSeq) return null;
1330
+ const changes = [];
1331
+ for (const name of Object.keys(this.config.schema.collections)) {
1332
+ const rows = this.driver.prepare(`SELECT * FROM "${name}" WHERE _meridian_seq > ?`).all(since);
1333
+ for (const row of rows) {
1334
+ const meta = row._meridian_meta ? JSON.parse(row._meridian_meta) : {};
1335
+ for (const [field, hlcStr] of Object.entries(meta)) {
1336
+ changes.push({
1337
+ seq: row._meridian_seq,
1338
+ op: {
1339
+ id: `${row.id}-${field}-${hlcStr}`,
1340
+ collection: name,
1341
+ docId: row.id,
1342
+ field,
1343
+ value: row[field],
1344
+ hlc: hlcStr,
1345
+ nodeId: "server"
1346
+ }
1347
+ });
1348
+ }
1349
+ }
1350
+ }
1351
+ return changes;
1352
+ }
1353
+ getMinSeq() {
1354
+ return this.minSeq;
1355
+ }
1356
+ async compact(maxAgeMs) {
1357
+ const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
1358
+ let deleted = 0;
1359
+ for (const name of Object.keys(this.config.schema.collections)) {
1360
+ const result = this.driver.prepare(`DELETE FROM "${name}" WHERE _meridian_deleted = 1 AND _meridian_updated_at < ?`).run(cutoff);
1361
+ deleted += result.changes;
1362
+ }
1363
+ this.minSeq = this.lastSeq;
1364
+ if (this.config.debug) {
1365
+ console.log(`[SQLite Store] Compaction: deleted ${deleted} tombstones`);
1366
+ }
1367
+ return deleted;
1368
+ }
1369
+ async close() {
1370
+ this.driver.close();
1371
+ }
1372
+ };
1373
+
1374
+ // src/mysql-store.ts
1375
+ var import_meridian_shared4 = require("meridian-shared");
1376
+ function mysqlType(fieldType) {
1377
+ switch (fieldType) {
1378
+ case "string":
1379
+ return "TEXT";
1380
+ case "number":
1381
+ return "DOUBLE";
1382
+ case "boolean":
1383
+ return "TINYINT(1)";
1384
+ case "array":
1385
+ case "object":
1386
+ return "JSON";
1387
+ default:
1388
+ return "TEXT";
1389
+ }
1390
+ }
1391
+ var MySQLStore = class {
1392
+ pool;
1393
+ config;
1394
+ lastSeq = 0;
1395
+ minSeq = 0;
1396
+ constructor(config) {
1397
+ this.pool = config.pool;
1398
+ this.config = config;
1399
+ }
1400
+ async init() {
1401
+ const { schema } = this.config;
1402
+ await this.pool.execute(`CREATE TABLE IF NOT EXISTS _meridian_sync (
1403
+ \`key\` VARCHAR(255) PRIMARY KEY,
1404
+ \`value\` TEXT NOT NULL
1405
+ ) ENGINE=InnoDB`);
1406
+ for (const [name, colSchema] of Object.entries(schema.collections)) {
1407
+ const columns = ["id VARCHAR(255) PRIMARY KEY"];
1408
+ for (const [field, def] of Object.entries(colSchema)) {
1409
+ if (field === "id") continue;
1410
+ const sqlType = mysqlType(def.type);
1411
+ const defaultVal = def.defaultValue !== void 0 ? ` DEFAULT ${JSON.stringify(def.defaultValue)}` : "";
1412
+ columns.push(`\`${field}\` ${sqlType}${defaultVal}`);
1413
+ }
1414
+ columns.push("_meridian_meta JSON");
1415
+ columns.push("_meridian_seq BIGINT");
1416
+ columns.push("_meridian_deleted TINYINT(1) DEFAULT 0");
1417
+ columns.push("_meridian_updated_at VARCHAR(30)");
1418
+ await this.pool.execute(
1419
+ `CREATE TABLE IF NOT EXISTS \`${name}\` (${columns.join(", ")}) ENGINE=InnoDB`
1420
+ );
1421
+ await this.pool.execute(
1422
+ `CREATE INDEX IF NOT EXISTS idx_${name}_seq ON \`${name}\` (_meridian_seq)`
1423
+ );
1424
+ }
1425
+ const [rows] = await this.pool.query(
1426
+ "SELECT value FROM _meridian_sync WHERE `key` = 'last_seq'"
1427
+ );
1428
+ this.lastSeq = rows.length > 0 ? parseInt(rows[0].value, 10) : 0;
1429
+ if (this.config.debug) {
1430
+ console.log(`[MySQL Store] Initialized. Last seq: ${this.lastSeq}`);
1431
+ }
1432
+ }
1433
+ async applyOperations(ops) {
1434
+ const changes = [];
1435
+ const conflicts = [];
1436
+ const grouped = /* @__PURE__ */ new Map();
1437
+ for (const op of ops) {
1438
+ const key = `${op.collection}:${op.docId}`;
1439
+ if (!grouped.has(key)) grouped.set(key, []);
1440
+ grouped.get(key).push(op);
1441
+ }
1442
+ for (const [key, docOps] of grouped) {
1443
+ const [collection, docId] = key.split(":");
1444
+ const [rows] = await this.pool.query(
1445
+ `SELECT * FROM \`${collection}\` WHERE id = ?`,
1446
+ [docId]
1447
+ );
1448
+ const existing = rows.length > 0 ? rows[0] : null;
1449
+ const existingMeta = existing?._meridian_meta ? JSON.parse(existing._meridian_meta) : null;
1450
+ const remoteMap = {};
1451
+ for (const op of docOps) {
1452
+ remoteMap[op.field] = { value: op.value, hlc: op.hlc, nodeId: op.nodeId };
1453
+ }
1454
+ let finalMap;
1455
+ if (existingMeta) {
1456
+ const { merged, conflicts: fieldConflicts } = (0, import_meridian_shared4.mergeLWWMaps)(existingMeta, remoteMap);
1457
+ finalMap = merged;
1458
+ for (const c of fieldConflicts) {
1459
+ conflicts.push({ collection, docId, ...c });
1460
+ }
1461
+ } else {
1462
+ finalMap = remoteMap;
1463
+ finalMap[import_meridian_shared4.DELETED_FIELD] = { value: false, hlc: docOps[0].hlc, nodeId: docOps[0].nodeId };
1464
+ }
1465
+ this.lastSeq++;
1466
+ const meta = JSON.stringify((0, import_meridian_shared4.extractMetadata)(finalMap));
1467
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1468
+ if ((0, import_meridian_shared4.isDeleted)(finalMap)) {
1469
+ await this.pool.execute(`DELETE FROM \`${collection}\` WHERE id = ?`, [docId]);
1470
+ } else {
1471
+ const values = (0, import_meridian_shared4.extractValues)(finalMap);
1472
+ const defaults = (0, import_meridian_shared4.getDefaults)(this.config.schema.collections[collection]);
1473
+ for (const [field, defaultVal] of Object.entries(defaults)) {
1474
+ if (!(field in values)) values[field] = defaultVal;
1475
+ }
1476
+ if (existing) {
1477
+ const setClauses = [];
1478
+ const params = [];
1479
+ for (const [field, value] of Object.entries(values)) {
1480
+ setClauses.push(`\`${field}\` = ?`);
1481
+ params.push(value);
1482
+ }
1483
+ setClauses.push("_meridian_meta = ?");
1484
+ params.push(meta);
1485
+ setClauses.push("_meridian_seq = ?");
1486
+ params.push(this.lastSeq);
1487
+ setClauses.push("_meridian_updated_at = ?");
1488
+ params.push(now);
1489
+ params.push(docId);
1490
+ await this.pool.execute(
1491
+ `UPDATE \`${collection}\` SET ${setClauses.join(", ")} WHERE id = ?`,
1492
+ params
1493
+ );
1494
+ } else {
1495
+ const fields = Object.keys(values).filter((f) => f !== "id");
1496
+ const allFields = [...fields, "_meridian_meta", "_meridian_seq", "_meridian_deleted", "_meridian_updated_at"];
1497
+ const placeholders = allFields.map(() => "?").join(", ");
1498
+ const params = [
1499
+ docId,
1500
+ ...fields.map((f) => values[f]),
1501
+ meta,
1502
+ this.lastSeq,
1503
+ 0,
1504
+ now
1505
+ ];
1506
+ await this.pool.execute(
1507
+ `INSERT INTO \`${collection}\` (id, ${fields.map((f) => `\`${f}\``).join(", ")}, _meridian_meta, _meridian_seq, _meridian_deleted, _meridian_updated_at) VALUES (?, ${placeholders})`,
1508
+ params
1509
+ );
1510
+ }
1511
+ }
1512
+ for (const op of docOps) {
1513
+ changes.push({ seq: this.lastSeq, op });
1514
+ }
1515
+ }
1516
+ await this.pool.execute(
1517
+ "INSERT INTO _meridian_sync (`key`, value) VALUES ('last_seq', ?) ON DUPLICATE KEY UPDATE value = ?",
1518
+ [this.lastSeq.toString(), this.lastSeq.toString()]
1519
+ );
1520
+ return { changes, conflicts };
1521
+ }
1522
+ async getChangesSince(since) {
1523
+ if (since < this.minSeq) return null;
1524
+ const changes = [];
1525
+ for (const name of Object.keys(this.config.schema.collections)) {
1526
+ const [rows] = await this.pool.query(
1527
+ `SELECT * FROM \`${name}\` WHERE _meridian_seq > ?`,
1528
+ [since]
1529
+ );
1530
+ for (const row of rows) {
1531
+ const meta = row._meridian_meta ? JSON.parse(row._meridian_meta) : {};
1532
+ for (const [field, hlcStr] of Object.entries(meta)) {
1533
+ changes.push({
1534
+ seq: row._meridian_seq,
1535
+ op: {
1536
+ id: `${row.id}-${field}-${hlcStr}`,
1537
+ collection: name,
1538
+ docId: row.id,
1539
+ field,
1540
+ value: row[field],
1541
+ hlc: hlcStr,
1542
+ nodeId: "server"
1543
+ }
1544
+ });
1545
+ }
1546
+ }
1547
+ }
1548
+ return changes;
1549
+ }
1550
+ getMinSeq() {
1551
+ return this.minSeq;
1552
+ }
1553
+ async compact(maxAgeMs) {
1554
+ const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
1555
+ let deleted = 0;
1556
+ for (const name of Object.keys(this.config.schema.collections)) {
1557
+ const [result] = await this.pool.execute(
1558
+ `DELETE FROM \`${name}\` WHERE _meridian_deleted = 1 AND _meridian_updated_at < ?`,
1559
+ [cutoff]
1560
+ );
1561
+ deleted += result.affectedRows;
1562
+ }
1563
+ this.minSeq = this.lastSeq;
1564
+ return deleted;
1565
+ }
1566
+ async close() {
1567
+ await this.pool.end();
1568
+ }
1569
+ };
1570
+
1571
+ // src/snapshot.ts
1572
+ var SnapshotManager = class {
1573
+ config;
1574
+ snapshots = /* @__PURE__ */ new Map();
1575
+ opCounter = 0;
1576
+ constructor(config) {
1577
+ this.config = config;
1578
+ }
1579
+ /**
1580
+ * Track an operation. Creates a snapshot when the interval is reached.
1581
+ */
1582
+ async trackOp() {
1583
+ this.opCounter++;
1584
+ if (this.opCounter >= this.config.interval) {
1585
+ await this.createSnapshot();
1586
+ }
1587
+ }
1588
+ /**
1589
+ * Create a snapshot of all collections at the current sequence number.
1590
+ */
1591
+ async createSnapshot() {
1592
+ const changes = await this.config.store.getChangesSince(this.config.store.getMinSeq());
1593
+ const seq = changes ? Math.max(...changes.map((c) => c.seq), this.config.store.getMinSeq()) : this.config.store.getMinSeq();
1594
+ const collections = {};
1595
+ for (const name of Object.keys(this.config.schema.collections)) {
1596
+ if (changes) {
1597
+ const docs = changes.filter((c) => c.op.collection === name).reduce((acc, c) => {
1598
+ if (!acc[c.op.docId]) acc[c.op.docId] = { id: c.op.docId };
1599
+ acc[c.op.docId][c.op.field] = c.op.value;
1600
+ return acc;
1601
+ }, {});
1602
+ collections[name] = {
1603
+ name,
1604
+ count: Object.keys(docs).length,
1605
+ documents: Object.values(docs)
1606
+ };
1607
+ }
1608
+ }
1609
+ const snapshot = {
1610
+ seq,
1611
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1612
+ collections
1613
+ };
1614
+ this.snapshots.set(seq, snapshot);
1615
+ this.opCounter = 0;
1616
+ if (this.snapshots.size > this.config.maxSnapshots) {
1617
+ const oldest = Math.min(...this.snapshots.keys());
1618
+ this.snapshots.delete(oldest);
1619
+ }
1620
+ if (this.config.debug) {
1621
+ let totalDocs = 0;
1622
+ for (const c of Object.values(collections)) totalDocs += c.count;
1623
+ console.log(`[Snapshot] Created at seq=${seq}: ${totalDocs} docs across ${Object.keys(collections).length} collections`);
1624
+ }
1625
+ return snapshot;
1626
+ }
1627
+ /**
1628
+ * Get the most recent snapshot at or before the given sequence number.
1629
+ */
1630
+ getSnapshotForSeq(seq) {
1631
+ let best = null;
1632
+ for (const [snapSeq, snap] of this.snapshots) {
1633
+ if (snapSeq <= seq && (!best || snapSeq > best.seq)) {
1634
+ best = snap;
1635
+ }
1636
+ }
1637
+ return best;
1638
+ }
1639
+ /**
1640
+ * Estimate bandwidth savings from using a snapshot vs full replay.
1641
+ *
1642
+ * @param totalOps - Total operations since seq 0
1643
+ * @param snapshotSeq - Sequence number of the snapshot
1644
+ * @returns Percentage of operations saved
1645
+ */
1646
+ estimateSavings(totalOps, snapshotSeq) {
1647
+ return Math.round((1 - totalOps / (snapshotSeq || 1)) * 100);
1648
+ }
1649
+ /**
1650
+ * Get all stored snapshots (for debugging/management).
1651
+ */
1652
+ getSnapshots() {
1653
+ return Array.from(this.snapshots.values()).sort((a, b) => b.seq - a.seq);
1654
+ }
1655
+ /**
1656
+ * Clear all snapshots (e.g., after schema change).
1657
+ */
1658
+ clear() {
1659
+ this.snapshots.clear();
1660
+ this.opCounter = 0;
1661
+ }
1662
+ };
1663
+
1664
+ // src/index.ts
1665
+ var import_meridian_shared5 = require("meridian-shared");
1666
+ // Annotate the CommonJS export names for ESM import in node:
1667
+ 0 && (module.exports = {
1668
+ CompactionManager,
1669
+ MergeEngine,
1670
+ MySQLStore,
1671
+ PgStore,
1672
+ SQLiteStore,
1673
+ ServerPresenceManager,
1674
+ SnapshotManager,
1675
+ WsHub,
1676
+ createServer,
1677
+ createWALStream,
1678
+ defineSchema,
1679
+ z
1680
+ });