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/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/index.cjs +1117 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +285 -0
- package/dist/index.d.ts +285 -0
- package/dist/index.js +1109 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
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
|