remult-sqlite-s3 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.js ADDED
@@ -0,0 +1,1109 @@
1
+ import { SqlDatabase } from 'remult';
2
+ import { SqliteCoreDataProvider } from 'remult/remult-sqlite-core-js';
3
+ import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
4
+ import { createHash, randomUUID } from 'crypto';
5
+ import { hostname } from 'os';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, unlinkSync, statSync } from 'fs';
7
+ import { dirname } from 'path';
8
+ import Database from 'better-sqlite3';
9
+
10
+ // src/index.ts
11
+ var S3Operations = class {
12
+ client;
13
+ bucket;
14
+ keyPrefix;
15
+ maxRetries;
16
+ constructor(config, maxRetries = 3) {
17
+ const clientConfig = {
18
+ region: config.region
19
+ };
20
+ if (config.endpoint) {
21
+ clientConfig.endpoint = config.endpoint;
22
+ }
23
+ if (config.credentials) {
24
+ clientConfig.credentials = {
25
+ accessKeyId: config.credentials.accessKeyId,
26
+ secretAccessKey: config.credentials.secretAccessKey
27
+ };
28
+ }
29
+ if (config.forcePathStyle) {
30
+ clientConfig.forcePathStyle = true;
31
+ }
32
+ this.client = new S3Client(clientConfig);
33
+ this.bucket = config.bucket;
34
+ this.keyPrefix = config.keyPrefix.replace(/\/$/, "");
35
+ this.maxRetries = maxRetries;
36
+ }
37
+ getFullKey(key) {
38
+ return `${this.keyPrefix}/${key}`;
39
+ }
40
+ /**
41
+ * Get an object from S3
42
+ */
43
+ async get(key) {
44
+ return this.withRetry(async () => {
45
+ try {
46
+ const response = await this.client.send(
47
+ new GetObjectCommand({
48
+ Bucket: this.bucket,
49
+ Key: this.getFullKey(key)
50
+ })
51
+ );
52
+ if (!response.Body) {
53
+ return null;
54
+ }
55
+ const chunks = [];
56
+ for await (const chunk of response.Body) {
57
+ chunks.push(chunk);
58
+ }
59
+ return Buffer.concat(chunks);
60
+ } catch (error) {
61
+ if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
62
+ return null;
63
+ }
64
+ throw error;
65
+ }
66
+ });
67
+ }
68
+ /**
69
+ * Get object as JSON
70
+ */
71
+ async getJson(key) {
72
+ const data = await this.get(key);
73
+ if (!data) return null;
74
+ return JSON.parse(data.toString("utf-8"));
75
+ }
76
+ /**
77
+ * Put an object to S3
78
+ */
79
+ async put(key, data) {
80
+ return this.withRetry(async () => {
81
+ await this.client.send(
82
+ new PutObjectCommand({
83
+ Bucket: this.bucket,
84
+ Key: this.getFullKey(key),
85
+ Body: typeof data === "string" ? Buffer.from(data, "utf-8") : data
86
+ })
87
+ );
88
+ });
89
+ }
90
+ /**
91
+ * Put JSON object to S3
92
+ */
93
+ async putJson(key, data) {
94
+ await this.put(key, JSON.stringify(data, null, 2));
95
+ }
96
+ /**
97
+ * Delete an object from S3
98
+ */
99
+ async delete(key) {
100
+ return this.withRetry(async () => {
101
+ await this.client.send(
102
+ new DeleteObjectCommand({
103
+ Bucket: this.bucket,
104
+ Key: this.getFullKey(key)
105
+ })
106
+ );
107
+ });
108
+ }
109
+ /**
110
+ * Check if an object exists
111
+ */
112
+ async exists(key) {
113
+ try {
114
+ await this.client.send(
115
+ new HeadObjectCommand({
116
+ Bucket: this.bucket,
117
+ Key: this.getFullKey(key)
118
+ })
119
+ );
120
+ return true;
121
+ } catch (error) {
122
+ if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) {
123
+ return false;
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+ /**
129
+ * List objects with a prefix
130
+ */
131
+ async list(prefix) {
132
+ return this.withRetry(async () => {
133
+ const keys = [];
134
+ let continuationToken;
135
+ do {
136
+ const response = await this.client.send(
137
+ new ListObjectsV2Command({
138
+ Bucket: this.bucket,
139
+ Prefix: this.getFullKey(prefix),
140
+ ContinuationToken: continuationToken
141
+ })
142
+ );
143
+ if (response.Contents) {
144
+ for (const obj of response.Contents) {
145
+ if (obj.Key) {
146
+ const relativeKey = obj.Key.substring(this.keyPrefix.length + 1);
147
+ keys.push(relativeKey);
148
+ }
149
+ }
150
+ }
151
+ continuationToken = response.NextContinuationToken;
152
+ } while (continuationToken);
153
+ return keys;
154
+ });
155
+ }
156
+ /**
157
+ * Conditional put - only succeeds if key doesn't exist
158
+ * Used for lock acquisition
159
+ */
160
+ async putIfNotExists(key, data) {
161
+ try {
162
+ const exists = await this.exists(key);
163
+ if (exists) {
164
+ return false;
165
+ }
166
+ await this.put(key, data);
167
+ return true;
168
+ } catch (error) {
169
+ return false;
170
+ }
171
+ }
172
+ /**
173
+ * Conditional update - only succeeds if current content matches expected
174
+ * Used for lock refresh
175
+ */
176
+ async updateIfMatch(key, expectedContent, newContent) {
177
+ try {
178
+ const current = await this.get(key);
179
+ if (!current || current.toString("utf-8") !== expectedContent) {
180
+ return false;
181
+ }
182
+ await this.put(key, newContent);
183
+ return true;
184
+ } catch (error) {
185
+ return false;
186
+ }
187
+ }
188
+ /**
189
+ * Execute with retry logic
190
+ */
191
+ async withRetry(fn) {
192
+ let lastError = null;
193
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
194
+ try {
195
+ return await fn();
196
+ } catch (error) {
197
+ lastError = error;
198
+ if (error.$metadata?.httpStatusCode >= 400 && error.$metadata?.httpStatusCode < 500 && error.$metadata?.httpStatusCode !== 429) {
199
+ throw error;
200
+ }
201
+ const delay = Math.min(1e3 * Math.pow(2, attempt) + Math.random() * 1e3, 3e4);
202
+ await new Promise((resolve) => setTimeout(resolve, delay));
203
+ }
204
+ }
205
+ throw lastError;
206
+ }
207
+ /**
208
+ * Close the S3 client
209
+ */
210
+ destroy() {
211
+ this.client.destroy();
212
+ }
213
+ };
214
+ var LOCK_KEY = "lock.json";
215
+ var S3Lock = class {
216
+ s3;
217
+ lockId;
218
+ holder;
219
+ generation = "";
220
+ ttl;
221
+ refreshInterval;
222
+ refreshTimer = null;
223
+ acquired = false;
224
+ forceLock;
225
+ onEvent;
226
+ verbose;
227
+ constructor(s3, config, onEvent, verbose = false) {
228
+ this.s3 = s3;
229
+ this.lockId = randomUUID();
230
+ this.holder = `${hostname()}-${process.pid}-${this.lockId.substring(0, 8)}`;
231
+ this.ttl = config.lockTtl;
232
+ this.refreshInterval = config.lockRefresh;
233
+ this.forceLock = config.forceLock;
234
+ this.onEvent = onEvent;
235
+ this.verbose = verbose;
236
+ }
237
+ /**
238
+ * Acquire the lock
239
+ * Returns the current generation ID if successful
240
+ */
241
+ async acquire() {
242
+ this.log("Attempting to acquire lock...");
243
+ const existingLock = await this.s3.getJson(LOCK_KEY);
244
+ if (existingLock) {
245
+ const expiresAt = new Date(existingLock.expiresAt).getTime();
246
+ const now = Date.now();
247
+ if (expiresAt > now && !this.forceLock) {
248
+ throw new Error(
249
+ `Database is locked by another process: ${existingLock.holder} (expires in ${Math.round((expiresAt - now) / 1e3)}s)`
250
+ );
251
+ }
252
+ if (this.forceLock && expiresAt > now) {
253
+ this.log(`Force taking over lock from ${existingLock.holder}`);
254
+ } else {
255
+ this.log(`Taking over expired lock from ${existingLock.holder}`);
256
+ }
257
+ this.generation = existingLock.generation;
258
+ }
259
+ if (!this.generation) {
260
+ this.generation = randomUUID();
261
+ this.log(`Creating new generation: ${this.generation}`);
262
+ }
263
+ const lockInfo = {
264
+ lockId: this.lockId,
265
+ holder: this.holder,
266
+ acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
267
+ expiresAt: new Date(Date.now() + this.ttl).toISOString(),
268
+ generation: this.generation
269
+ };
270
+ await this.s3.putJson(LOCK_KEY, lockInfo);
271
+ const verifyLock = await this.s3.getJson(LOCK_KEY);
272
+ if (!verifyLock || verifyLock.lockId !== this.lockId) {
273
+ throw new Error("Failed to acquire lock - race condition detected");
274
+ }
275
+ this.acquired = true;
276
+ this.startRefresh();
277
+ this.onEvent({ type: "lock_acquired", lockId: this.lockId });
278
+ this.log(`Lock acquired: ${this.holder}`);
279
+ return this.generation;
280
+ }
281
+ /**
282
+ * Release the lock
283
+ */
284
+ async release() {
285
+ if (!this.acquired) return;
286
+ this.stopRefresh();
287
+ try {
288
+ const currentLock = await this.s3.getJson(LOCK_KEY);
289
+ if (currentLock && currentLock.lockId === this.lockId) {
290
+ await this.s3.delete(LOCK_KEY);
291
+ this.log("Lock released");
292
+ }
293
+ } catch (error) {
294
+ this.log(`Error releasing lock: ${error}`);
295
+ }
296
+ this.acquired = false;
297
+ }
298
+ /**
299
+ * Check if we still hold the lock
300
+ */
301
+ async isValid() {
302
+ if (!this.acquired) return false;
303
+ try {
304
+ const currentLock = await this.s3.getJson(LOCK_KEY);
305
+ return currentLock?.lockId === this.lockId;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+ /**
311
+ * Get current generation
312
+ */
313
+ getGeneration() {
314
+ return this.generation;
315
+ }
316
+ /**
317
+ * Set generation (used during recovery)
318
+ */
319
+ setGeneration(generation) {
320
+ this.generation = generation;
321
+ }
322
+ /**
323
+ * Create a new generation (used when starting fresh)
324
+ */
325
+ async newGeneration() {
326
+ this.generation = randomUUID();
327
+ if (this.acquired) {
328
+ const lockInfo = {
329
+ lockId: this.lockId,
330
+ holder: this.holder,
331
+ acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
332
+ expiresAt: new Date(Date.now() + this.ttl).toISOString(),
333
+ generation: this.generation
334
+ };
335
+ await this.s3.putJson(LOCK_KEY, lockInfo);
336
+ }
337
+ return this.generation;
338
+ }
339
+ startRefresh() {
340
+ this.refreshTimer = setInterval(async () => {
341
+ try {
342
+ await this.refresh();
343
+ } catch (error) {
344
+ this.log(`Lock refresh failed: ${error}`);
345
+ this.onEvent({ type: "lock_lost", reason: String(error) });
346
+ this.stopRefresh();
347
+ this.acquired = false;
348
+ }
349
+ }, this.refreshInterval);
350
+ }
351
+ stopRefresh() {
352
+ if (this.refreshTimer) {
353
+ clearInterval(this.refreshTimer);
354
+ this.refreshTimer = null;
355
+ }
356
+ }
357
+ async refresh() {
358
+ if (!this.acquired) return;
359
+ const currentLock = await this.s3.getJson(LOCK_KEY);
360
+ if (!currentLock || currentLock.lockId !== this.lockId) {
361
+ throw new Error("Lock was taken by another process");
362
+ }
363
+ const lockInfo = {
364
+ ...currentLock,
365
+ expiresAt: new Date(Date.now() + this.ttl).toISOString()
366
+ };
367
+ await this.s3.putJson(LOCK_KEY, lockInfo);
368
+ this.log("Lock refreshed");
369
+ }
370
+ log(message) {
371
+ if (this.verbose) {
372
+ console.log(`[S3Lock] ${message}`);
373
+ }
374
+ }
375
+ };
376
+ var WALManager = class {
377
+ s3;
378
+ dbPath;
379
+ walPath;
380
+ generation;
381
+ walThreshold;
382
+ onEvent;
383
+ verbose;
384
+ walIndex = 0;
385
+ lastUploadedOffset = 0;
386
+ pendingUpload = null;
387
+ constructor(s3, dbPath, generation, config, onEvent, verbose = false) {
388
+ this.s3 = s3;
389
+ this.dbPath = dbPath;
390
+ this.walPath = `${dbPath}-wal`;
391
+ this.generation = generation;
392
+ this.walThreshold = config.walThreshold;
393
+ this.onEvent = onEvent;
394
+ this.verbose = verbose;
395
+ }
396
+ /**
397
+ * Set the generation ID
398
+ */
399
+ setGeneration(generation) {
400
+ this.generation = generation;
401
+ this.walIndex = 0;
402
+ this.lastUploadedOffset = 0;
403
+ }
404
+ /**
405
+ * Set the starting WAL index (used during recovery)
406
+ */
407
+ setWalIndex(index) {
408
+ this.walIndex = index;
409
+ }
410
+ /**
411
+ * Check if WAL needs to be synced based on threshold
412
+ */
413
+ needsSync() {
414
+ if (!existsSync(this.walPath)) {
415
+ return false;
416
+ }
417
+ try {
418
+ const stat = statSync(this.walPath);
419
+ const pendingBytes = stat.size - this.lastUploadedOffset;
420
+ return pendingBytes >= this.walThreshold;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+ /**
426
+ * Get the current WAL size
427
+ */
428
+ getWalSize() {
429
+ if (!existsSync(this.walPath)) {
430
+ return 0;
431
+ }
432
+ try {
433
+ return statSync(this.walPath).size;
434
+ } catch {
435
+ return 0;
436
+ }
437
+ }
438
+ /**
439
+ * Sync WAL to S3 (non-blocking in async mode)
440
+ */
441
+ async syncAsync() {
442
+ if (this.pendingUpload) {
443
+ await this.pendingUpload;
444
+ }
445
+ if (!this.needsSync()) {
446
+ return;
447
+ }
448
+ this.pendingUpload = this.uploadWalSegment();
449
+ this.pendingUpload.catch((error) => {
450
+ this.log(`Async WAL upload failed: ${error}`);
451
+ this.onEvent({
452
+ type: "sync_error",
453
+ error,
454
+ context: "wal_upload",
455
+ willRetry: true
456
+ });
457
+ }).finally(() => {
458
+ this.pendingUpload = null;
459
+ });
460
+ }
461
+ /**
462
+ * Sync WAL to S3 (blocking)
463
+ */
464
+ async sync() {
465
+ if (this.pendingUpload) {
466
+ await this.pendingUpload;
467
+ }
468
+ if (!existsSync(this.walPath)) {
469
+ return;
470
+ }
471
+ await this.uploadWalSegment();
472
+ }
473
+ /**
474
+ * Force sync all pending WAL data
475
+ */
476
+ async flush() {
477
+ if (this.pendingUpload) {
478
+ await this.pendingUpload;
479
+ }
480
+ if (!existsSync(this.walPath)) {
481
+ return;
482
+ }
483
+ const stat = statSync(this.walPath);
484
+ if (stat.size > this.lastUploadedOffset) {
485
+ await this.uploadWalSegment();
486
+ }
487
+ }
488
+ /**
489
+ * Wait for any pending uploads to complete
490
+ */
491
+ async waitForPending() {
492
+ if (this.pendingUpload) {
493
+ await this.pendingUpload;
494
+ }
495
+ }
496
+ async uploadWalSegment() {
497
+ if (!existsSync(this.walPath)) {
498
+ return;
499
+ }
500
+ const walData = readFileSync(this.walPath);
501
+ const currentSize = walData.length;
502
+ if (currentSize <= this.lastUploadedOffset) {
503
+ return;
504
+ }
505
+ const segment = walData.subarray(this.lastUploadedOffset);
506
+ const checksum = createHash("sha256").update(segment).digest("hex");
507
+ this.walIndex++;
508
+ const indexStr = this.walIndex.toString().padStart(10, "0");
509
+ const segmentKey = `generations/${this.generation}/wal/${indexStr}.wal`;
510
+ await this.s3.put(segmentKey, segment);
511
+ const meta = {
512
+ generation: this.generation,
513
+ index: this.walIndex,
514
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
515
+ size: segment.length,
516
+ checksum,
517
+ startOffset: this.lastUploadedOffset,
518
+ endOffset: currentSize
519
+ };
520
+ await this.s3.putJson(`generations/${this.generation}/wal/${indexStr}.meta.json`, meta);
521
+ this.lastUploadedOffset = currentSize;
522
+ this.log(`Uploaded WAL segment ${this.walIndex} (${segment.length} bytes)`);
523
+ this.onEvent({
524
+ type: "wal_uploaded",
525
+ generation: this.generation,
526
+ index: this.walIndex,
527
+ size: segment.length
528
+ });
529
+ }
530
+ log(message) {
531
+ if (this.verbose) {
532
+ console.log(`[WALManager] ${message}`);
533
+ }
534
+ }
535
+ };
536
+ var RecoveryManager = class {
537
+ s3;
538
+ dbPath;
539
+ onEvent;
540
+ verbose;
541
+ constructor(s3, dbPath, onEvent, verbose = false) {
542
+ this.s3 = s3;
543
+ this.dbPath = dbPath;
544
+ this.onEvent = onEvent;
545
+ this.verbose = verbose;
546
+ }
547
+ /**
548
+ * Find the latest generation in S3 that has a valid snapshot
549
+ */
550
+ async findLatestGeneration() {
551
+ const generations = await this.s3.list("generations/");
552
+ const genSet = /* @__PURE__ */ new Set();
553
+ for (const key of generations) {
554
+ const match = key.match(/^generations\/([^/]+)\//);
555
+ if (match) {
556
+ genSet.add(match[1]);
557
+ }
558
+ }
559
+ if (genSet.size === 0) {
560
+ return null;
561
+ }
562
+ let latestGen = null;
563
+ let latestTime = 0;
564
+ for (const gen of genSet) {
565
+ const hasSnapshot = await this.s3.exists(`generations/${gen}/snapshot.db`);
566
+ if (!hasSnapshot) {
567
+ this.log(`Skipping generation ${gen} - no snapshot`);
568
+ continue;
569
+ }
570
+ const genInfo = await this.s3.getJson(`generations/${gen}/generation.json`);
571
+ if (genInfo) {
572
+ const createdAt = new Date(genInfo.createdAt).getTime();
573
+ if (createdAt > latestTime) {
574
+ latestTime = createdAt;
575
+ latestGen = gen;
576
+ }
577
+ }
578
+ }
579
+ return latestGen;
580
+ }
581
+ /**
582
+ * Recover database from S3
583
+ * Returns the generation ID and highest WAL index
584
+ */
585
+ async recover(generation) {
586
+ this.log(`Starting recovery for generation: ${generation}`);
587
+ this.onEvent({ type: "recovery_started", generation });
588
+ const dir = dirname(this.dbPath);
589
+ if (!existsSync(dir)) {
590
+ mkdirSync(dir, { recursive: true });
591
+ }
592
+ this.cleanupLocalFiles();
593
+ const snapshotMeta = await this.s3.getJson(
594
+ `generations/${generation}/snapshot.meta.json`
595
+ );
596
+ if (!snapshotMeta) {
597
+ throw new Error(`No snapshot metadata found for generation ${generation}`);
598
+ }
599
+ const snapshotData = await this.s3.get(`generations/${generation}/snapshot.db`);
600
+ if (!snapshotData) {
601
+ throw new Error(`No snapshot found for generation ${generation}`);
602
+ }
603
+ const checksum = createHash("sha256").update(snapshotData).digest("hex");
604
+ if (checksum !== snapshotMeta.checksum) {
605
+ throw new Error(
606
+ `Snapshot checksum mismatch: expected ${snapshotMeta.checksum}, got ${checksum}`
607
+ );
608
+ }
609
+ writeFileSync(this.dbPath, snapshotData);
610
+ this.log(`Restored snapshot (${snapshotData.length} bytes)`);
611
+ const walFiles = await this.s3.list(`generations/${generation}/wal/`);
612
+ const walMetas = [];
613
+ for (const key of walFiles) {
614
+ if (key.endsWith(".meta.json")) {
615
+ const meta = await this.s3.getJson(key);
616
+ if (meta) {
617
+ walMetas.push(meta);
618
+ }
619
+ }
620
+ }
621
+ walMetas.sort((a, b) => a.index - b.index);
622
+ let highestWalIndex = 0;
623
+ const walPath = `${this.dbPath}-wal`;
624
+ for (const meta of walMetas) {
625
+ const indexStr = meta.index.toString().padStart(10, "0");
626
+ const walData = await this.s3.get(`generations/${generation}/wal/${indexStr}.wal`);
627
+ if (!walData) {
628
+ this.log(`Warning: WAL segment ${meta.index} not found, stopping recovery`);
629
+ break;
630
+ }
631
+ const walChecksum = createHash("sha256").update(walData).digest("hex");
632
+ if (walChecksum !== meta.checksum) {
633
+ this.log(`Warning: WAL segment ${meta.index} checksum mismatch, stopping recovery`);
634
+ break;
635
+ }
636
+ appendFileSync(walPath, walData);
637
+ highestWalIndex = meta.index;
638
+ this.log(`Applied WAL segment ${meta.index} (${walData.length} bytes)`);
639
+ }
640
+ this.log(`Recovery completed: ${walMetas.length} WAL segments applied`);
641
+ this.onEvent({
642
+ type: "recovery_completed",
643
+ generation,
644
+ segments: walMetas.length
645
+ });
646
+ return {
647
+ generation,
648
+ walIndex: highestWalIndex,
649
+ recovered: true
650
+ };
651
+ }
652
+ /**
653
+ * Check if local database exists and is valid
654
+ */
655
+ localDatabaseExists() {
656
+ return existsSync(this.dbPath);
657
+ }
658
+ /**
659
+ * Clean up local database files
660
+ */
661
+ cleanupLocalFiles() {
662
+ const files = [
663
+ this.dbPath,
664
+ `${this.dbPath}-wal`,
665
+ `${this.dbPath}-shm`
666
+ ];
667
+ for (const file of files) {
668
+ if (existsSync(file)) {
669
+ try {
670
+ unlinkSync(file);
671
+ } catch (error) {
672
+ this.log(`Warning: Could not delete ${file}: ${error}`);
673
+ }
674
+ }
675
+ }
676
+ }
677
+ log(message) {
678
+ if (this.verbose) {
679
+ console.log(`[Recovery] ${message}`);
680
+ }
681
+ }
682
+ };
683
+ var S3SyncManager = class {
684
+ s3;
685
+ lock;
686
+ walManager;
687
+ recoveryManager;
688
+ db;
689
+ dbPath;
690
+ sqliteOptions;
691
+ syncConfig;
692
+ onEvent;
693
+ verbose;
694
+ generation = "";
695
+ snapshotTimer = null;
696
+ closed = false;
697
+ constructor(options) {
698
+ this.dbPath = options.dbPath;
699
+ this.sqliteOptions = options.sqliteOptions;
700
+ this.syncConfig = options.sync;
701
+ this.onEvent = options.onEvent;
702
+ this.verbose = options.verbose;
703
+ this.s3 = new S3Operations(options.s3, options.sync.maxRetries);
704
+ this.lock = new S3Lock(this.s3, options.sync, options.onEvent, options.verbose);
705
+ this.recoveryManager = new RecoveryManager(this.s3, this.dbPath, options.onEvent, options.verbose);
706
+ }
707
+ /**
708
+ * Initialize the sync manager
709
+ * - Acquire lock
710
+ * - Recover from S3 if needed
711
+ * - Open database
712
+ * - Start sync timers
713
+ */
714
+ async initialize() {
715
+ this.log("Initializing S3SyncManager...");
716
+ const lockGeneration = await this.lock.acquire();
717
+ const latestGeneration = await this.recoveryManager.findLatestGeneration();
718
+ let recovered = false;
719
+ if (latestGeneration) {
720
+ if (!this.recoveryManager.localDatabaseExists()) {
721
+ this.log("No local database found, recovering from S3...");
722
+ const result = await this.recoveryManager.recover(latestGeneration);
723
+ this.generation = result.generation;
724
+ recovered = true;
725
+ this.lock.setGeneration(this.generation);
726
+ } else {
727
+ this.generation = lockGeneration;
728
+ this.log("Local database exists, using existing generation");
729
+ }
730
+ } else {
731
+ this.generation = await this.lock.newGeneration();
732
+ this.log("No S3 data found, starting new generation");
733
+ }
734
+ this.walManager = new WALManager(
735
+ this.s3,
736
+ this.dbPath,
737
+ this.generation,
738
+ this.syncConfig,
739
+ this.onEvent,
740
+ this.verbose
741
+ );
742
+ const dir = dirname(this.dbPath);
743
+ if (!existsSync(dir)) {
744
+ mkdirSync(dir, { recursive: true });
745
+ }
746
+ this.db = new Database(this.dbPath, this.sqliteOptions);
747
+ this.db.pragma("journal_mode = WAL");
748
+ await this.createGenerationInfo();
749
+ if (!latestGeneration || recovered) {
750
+ await this.snapshot();
751
+ }
752
+ this.startSnapshotTimer();
753
+ this.log(`Initialized with generation: ${this.generation}`);
754
+ this.onEvent({ type: "initialized", generation: this.generation, recovered });
755
+ return this.db;
756
+ }
757
+ /**
758
+ * Called after write operations
759
+ */
760
+ async onWrite() {
761
+ if (this.closed) return;
762
+ if (this.syncConfig.mode === "sync") {
763
+ await this.snapshot();
764
+ }
765
+ }
766
+ /**
767
+ * Force sync WAL to S3
768
+ */
769
+ async syncWal() {
770
+ if (this.closed) return;
771
+ await this.snapshot();
772
+ }
773
+ /**
774
+ * Force full sync (WAL + check for snapshot)
775
+ */
776
+ async sync() {
777
+ if (this.closed) return;
778
+ await this.walManager.flush();
779
+ if (this.walManager.getWalSize() > this.syncConfig.walThreshold * 10) {
780
+ await this.snapshot();
781
+ }
782
+ }
783
+ /**
784
+ * Create a full snapshot
785
+ */
786
+ async snapshot() {
787
+ if (this.closed) return;
788
+ this.log("Creating snapshot...");
789
+ await this.walManager.flush();
790
+ this.db.pragma("wal_checkpoint(TRUNCATE)");
791
+ const dbData = readFileSync(this.dbPath);
792
+ const checksum = createHash("sha256").update(dbData).digest("hex");
793
+ await this.s3.put(`generations/${this.generation}/snapshot.db`, dbData);
794
+ const meta = {
795
+ generation: this.generation,
796
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
797
+ size: dbData.length,
798
+ checksum
799
+ };
800
+ await this.s3.putJson(`generations/${this.generation}/snapshot.meta.json`, meta);
801
+ await this.updateGenerationInfo();
802
+ this.log(`Snapshot created (${dbData.length} bytes)`);
803
+ this.onEvent({
804
+ type: "snapshot_uploaded",
805
+ generation: this.generation,
806
+ size: dbData.length
807
+ });
808
+ }
809
+ /**
810
+ * Close the sync manager
811
+ */
812
+ async close() {
813
+ if (this.closed) return;
814
+ this.log("Closing S3SyncManager...");
815
+ this.stopSnapshotTimer();
816
+ await this.walManager.waitForPending();
817
+ try {
818
+ await this.snapshot();
819
+ this.log("Final snapshot completed");
820
+ } catch (error) {
821
+ this.log(`Error during final snapshot: ${error}`);
822
+ }
823
+ this.closed = true;
824
+ if (this.db) {
825
+ this.db.close();
826
+ this.log("Database closed");
827
+ }
828
+ await this.lock.release();
829
+ this.log("Lock released");
830
+ this.s3.destroy();
831
+ this.log("S3SyncManager closed");
832
+ }
833
+ /**
834
+ * Get the current generation
835
+ */
836
+ getGeneration() {
837
+ return this.generation;
838
+ }
839
+ async createGenerationInfo() {
840
+ const info = {
841
+ id: this.generation,
842
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
843
+ snapshotIndex: 0,
844
+ walIndex: 0
845
+ };
846
+ await this.s3.putJson(`generations/${this.generation}/generation.json`, info);
847
+ }
848
+ async updateGenerationInfo() {
849
+ const existing = await this.s3.getJson(
850
+ `generations/${this.generation}/generation.json`
851
+ );
852
+ const info = {
853
+ id: this.generation,
854
+ createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
855
+ snapshotIndex: (existing?.snapshotIndex ?? 0) + 1,
856
+ walIndex: existing?.walIndex ?? 0
857
+ };
858
+ await this.s3.putJson(`generations/${this.generation}/generation.json`, info);
859
+ }
860
+ startSnapshotTimer() {
861
+ this.snapshotTimer = setInterval(async () => {
862
+ try {
863
+ await this.snapshot();
864
+ } catch (error) {
865
+ this.log(`Scheduled snapshot failed: ${error}`);
866
+ this.onEvent({
867
+ type: "sync_error",
868
+ error,
869
+ context: "scheduled_snapshot",
870
+ willRetry: true
871
+ });
872
+ }
873
+ }, this.syncConfig.snapshotInterval);
874
+ }
875
+ stopSnapshotTimer() {
876
+ if (this.snapshotTimer) {
877
+ clearInterval(this.snapshotTimer);
878
+ this.snapshotTimer = null;
879
+ }
880
+ }
881
+ log(message) {
882
+ if (this.verbose) {
883
+ console.log(`[S3SyncManager] ${message}`);
884
+ }
885
+ }
886
+ };
887
+
888
+ // src/provider.ts
889
+ var BetterSqlite3S3SqlResult = class {
890
+ constructor(result) {
891
+ this.result = result;
892
+ this.rows = result;
893
+ }
894
+ rows;
895
+ getColumnKeyInResultForIndexInSelect(index) {
896
+ if (this.result.length === 0) return "";
897
+ return Object.keys(this.result[0])[index];
898
+ }
899
+ };
900
+ var BetterSqlite3S3Command = class {
901
+ constructor(db, syncManager, syncMode = "async") {
902
+ this.db = db;
903
+ this.syncManager = syncManager;
904
+ this.syncMode = syncMode;
905
+ }
906
+ values = {};
907
+ i = 0;
908
+ /** @deprecated use `param` instead */
909
+ addParameterAndReturnSqlToken(val) {
910
+ return this.param(val);
911
+ }
912
+ param(val) {
913
+ if (val instanceof Date) {
914
+ val = val.valueOf();
915
+ }
916
+ if (typeof val === "boolean") {
917
+ val = val ? 1 : 0;
918
+ }
919
+ const key = ":" + this.i++;
920
+ this.values[key.substring(1)] = val;
921
+ return key;
922
+ }
923
+ async execute(sql) {
924
+ const stmt = this.db.prepare(sql);
925
+ if (stmt.reader) {
926
+ const rows = stmt.all(this.values);
927
+ return new BetterSqlite3S3SqlResult(rows);
928
+ } else {
929
+ stmt.run(this.values);
930
+ if (this.syncManager) {
931
+ if (this.syncMode === "sync") {
932
+ await this.syncManager.syncWal();
933
+ } else {
934
+ this.syncManager.onWrite().catch(console.error);
935
+ }
936
+ }
937
+ return new BetterSqlite3S3SqlResult([]);
938
+ }
939
+ }
940
+ };
941
+ var BetterSqlite3S3DataProvider = class extends SqliteCoreDataProvider {
942
+ db;
943
+ syncManager;
944
+ options;
945
+ initialized = false;
946
+ initPromise = null;
947
+ constructor(options) {
948
+ super(
949
+ () => {
950
+ this.ensureInitialized();
951
+ return new BetterSqlite3S3Command(this.db, this.syncManager, this.options.sync.mode);
952
+ },
953
+ async () => {
954
+ await this.close();
955
+ }
956
+ );
957
+ this.options = this.applyDefaults(options);
958
+ }
959
+ /**
960
+ * Async initialization - must be called before use
961
+ */
962
+ async init() {
963
+ if (this.initialized) return;
964
+ if (this.initPromise) return this.initPromise;
965
+ this.initPromise = this.doInit();
966
+ return this.initPromise;
967
+ }
968
+ async doInit() {
969
+ this.syncManager = new S3SyncManager({
970
+ dbPath: this.options.file,
971
+ sqliteOptions: this.options.sqliteOptions,
972
+ s3: this.options.s3,
973
+ sync: this.options.sync,
974
+ onEvent: this.options.onEvent,
975
+ verbose: this.options.verbose
976
+ });
977
+ this.db = await this.syncManager.initialize();
978
+ this.initialized = true;
979
+ }
980
+ /**
981
+ * Get the underlying database (for advanced use cases)
982
+ */
983
+ get rawDatabase() {
984
+ this.ensureInitialized();
985
+ return this.db;
986
+ }
987
+ /**
988
+ * Get the sync manager (for manual sync control)
989
+ */
990
+ get sync() {
991
+ this.ensureInitialized();
992
+ return this.syncManager;
993
+ }
994
+ /**
995
+ * Force sync to S3
996
+ */
997
+ async forceSync() {
998
+ this.ensureInitialized();
999
+ await this.syncManager.sync();
1000
+ }
1001
+ /**
1002
+ * Force a full snapshot
1003
+ */
1004
+ async snapshot() {
1005
+ this.ensureInitialized();
1006
+ await this.syncManager.snapshot();
1007
+ }
1008
+ /**
1009
+ * Close the database and release resources
1010
+ */
1011
+ async close() {
1012
+ if (!this.initialized) return;
1013
+ await this.syncManager.close();
1014
+ this.initialized = false;
1015
+ }
1016
+ /**
1017
+ * Check if the provider is initialized
1018
+ */
1019
+ get isInitialized() {
1020
+ return this.initialized;
1021
+ }
1022
+ /**
1023
+ * Get the current generation ID
1024
+ */
1025
+ get generation() {
1026
+ this.ensureInitialized();
1027
+ return this.syncManager.getGeneration();
1028
+ }
1029
+ ensureInitialized() {
1030
+ if (!this.initialized) {
1031
+ throw new Error(
1032
+ "BetterSqlite3S3DataProvider not initialized. Call init() before using."
1033
+ );
1034
+ }
1035
+ }
1036
+ applyDefaults(options) {
1037
+ const prefix = options.s3.keyPrefix ? `${options.s3.keyPrefix.replace(/\/$/, "")}/${options.s3.databaseName}` : options.s3.databaseName;
1038
+ return {
1039
+ file: options.file,
1040
+ sqliteOptions: options.sqliteOptions ?? {},
1041
+ s3: {
1042
+ bucket: options.s3.bucket,
1043
+ databaseName: options.s3.databaseName,
1044
+ keyPrefix: prefix,
1045
+ region: options.s3.region ?? "us-east-1",
1046
+ endpoint: options.s3.endpoint,
1047
+ credentials: options.s3.credentials,
1048
+ forcePathStyle: options.s3.forcePathStyle
1049
+ },
1050
+ sync: {
1051
+ mode: options.sync?.mode ?? "async",
1052
+ walThreshold: options.sync?.walThreshold ?? 1024 * 1024,
1053
+ // 1MB
1054
+ snapshotInterval: options.sync?.snapshotInterval ?? 5 * 60 * 1e3,
1055
+ // 5 min
1056
+ maxRetries: options.sync?.maxRetries ?? 3,
1057
+ lockTtl: options.sync?.lockTtl ?? 60 * 1e3,
1058
+ // 60s
1059
+ lockRefresh: options.sync?.lockRefresh ?? 30 * 1e3,
1060
+ // 30s
1061
+ forceLock: options.sync?.forceLock ?? false
1062
+ },
1063
+ onEvent: options.onEvent ?? (() => {
1064
+ }),
1065
+ verbose: options.verbose ?? false
1066
+ };
1067
+ }
1068
+ };
1069
+
1070
+ // src/index.ts
1071
+ function createS3DataProvider(options) {
1072
+ let provider;
1073
+ let dataProvider;
1074
+ let initPromise;
1075
+ return async () => {
1076
+ if (dataProvider) return dataProvider;
1077
+ if (initPromise) return initPromise;
1078
+ initPromise = (async () => {
1079
+ provider = new BetterSqlite3S3DataProvider(options);
1080
+ await provider.init();
1081
+ dataProvider = new SqlDatabase(provider);
1082
+ let shuttingDown = false;
1083
+ const shutdown = async (signal) => {
1084
+ if (shuttingDown) return;
1085
+ shuttingDown = true;
1086
+ if (options.verbose) {
1087
+ console.log(`[remult-sqlite-s3] ${signal} received, closing...`);
1088
+ }
1089
+ try {
1090
+ await provider?.close();
1091
+ if (options.verbose) {
1092
+ console.log("[remult-sqlite-s3] Shutdown complete");
1093
+ }
1094
+ } catch (error) {
1095
+ console.error("[remult-sqlite-s3] Shutdown error:", error);
1096
+ }
1097
+ process.exit(0);
1098
+ };
1099
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1100
+ process.on("SIGINT", () => shutdown("SIGINT"));
1101
+ return dataProvider;
1102
+ })();
1103
+ return initPromise;
1104
+ };
1105
+ }
1106
+
1107
+ export { BetterSqlite3S3DataProvider, S3SyncManager, createS3DataProvider };
1108
+ //# sourceMappingURL=index.js.map
1109
+ //# sourceMappingURL=index.js.map