@delali/sirannon-db 0.2.3-next.38 → 0.3.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.
Files changed (41) hide show
  1. package/dist/backup/index.d.ts +5 -5
  2. package/dist/backup/index.mjs +4 -4
  3. package/dist/backup-scheduler/index.d.ts +58 -8
  4. package/dist/backup-scheduler/index.mjs +3 -2
  5. package/dist/{change-tracker-BLKRX8ic.d.ts → change-tracker-uG9gWvsY.d.ts} +1 -1
  6. package/dist/chunk-2QCPVE67.mjs +143 -0
  7. package/dist/{chunk-P53I2G6J.mjs → chunk-63VBFSCS.mjs} +2 -2
  8. package/dist/{chunk-CR6HTXSC.mjs → chunk-7SC2TZAS.mjs} +1 -1
  9. package/dist/chunk-DVS4QSIZ.mjs +124 -0
  10. package/dist/{chunk-IPDHHIG2.mjs → chunk-DXXCLTFF.mjs} +205 -45
  11. package/dist/{chunk-G7AXGAEA.mjs → chunk-OJQHM4MT.mjs} +202 -100
  12. package/dist/{chunk-KSYFFYAP.mjs → chunk-U445A3C4.mjs} +1 -70
  13. package/dist/{chunk-GJ6SX53Z.mjs → chunk-W77EEVOT.mjs} +4 -120
  14. package/dist/client/index.d.ts +8 -8
  15. package/dist/client/topology.d.ts +4 -4
  16. package/dist/{client-base-DldBgJWS.d.ts → client-base-C-B8u5Lm.d.ts} +2 -2
  17. package/dist/core/index.d.ts +9 -9
  18. package/dist/core/index.mjs +14 -5
  19. package/dist/{database-DSbJl-k5.d.ts → database-BlhUdJAO.d.ts} +7 -6
  20. package/dist/{database-backup-CliAhaEm.d.ts → database-backup-C090lMHc.d.ts} +2 -2
  21. package/dist/driver/better-sqlite3.d.ts +1 -1
  22. package/dist/driver/better-sqlite3.mjs +7 -6
  23. package/dist/driver/bun.d.ts +1 -1
  24. package/dist/driver/expo.d.ts +1 -1
  25. package/dist/driver/node.d.ts +1 -1
  26. package/dist/driver/node.mjs +7 -6
  27. package/dist/driver/wa-sqlite.d.ts +1 -1
  28. package/dist/file-migrations/index.d.ts +1 -1
  29. package/dist/{protocol-BK0Yxu5P.d.ts → protocol-QekTGoKn.d.ts} +1 -1
  30. package/dist/replication/index.d.ts +7 -7
  31. package/dist/{restore-options-O5gYlr3n.d.ts → restore-options-C9MW9gpm.d.ts} +1 -1
  32. package/dist/server/index.d.ts +9 -9
  33. package/dist/server/index.mjs +5 -4
  34. package/dist/{server-options-CFgBQDLz.d.ts → server-options-DeK6ZG8u.d.ts} +1 -1
  35. package/dist/{sirannon-Bo0PX2Cj.d.ts → sirannon-C9cTgFrw.d.ts} +2 -2
  36. package/dist/transport/grpc.d.ts +3 -3
  37. package/dist/transport/memory.d.ts +3 -3
  38. package/dist/{types-CL8uxXIV.d.ts → types-C1yBGTdx.d.ts} +2 -2
  39. package/dist/{types-CopTgHG3.d.ts → types-DApb7dvw.d.ts} +151 -5
  40. package/package.json +8 -7
  41. package/dist/chunk-GBM4AU24.mjs +0 -42
@@ -1,13 +1,38 @@
1
1
  import { startCopyWithoutHoldingWriter } from './chunk-W67A4H2E.mjs';
2
- import { destinationWithDeadline, DEFAULT_DESTINATION_TIMEOUT_MS } from './chunk-GBM4AU24.mjs';
2
+ import { readLogPosition } from './chunk-2QCPVE67.mjs';
3
+ import { destinationWithDeadline, DEFAULT_DESTINATION_TIMEOUT_MS, assertDeadline, withinDeadline } from './chunk-DVS4QSIZ.mjs';
3
4
  import { randomHex } from './chunk-V5FUJEYY.mjs';
4
5
  import { BackupError, SirannonError } from './chunk-PBRXXISQ.mjs';
5
- import { existsSync, mkdirSync, rmSync, readdirSync, lstatSync } from 'fs';
6
- import { resolve, dirname, join } from 'path';
6
+ import { existsSync, mkdirSync, statSync, readdirSync, lstatSync, rmSync } from 'fs';
7
+ import { resolve, dirname, join, basename, extname } from 'path';
7
8
  import { open, mkdtemp, rm } from 'fs/promises';
8
9
  import { tmpdir } from 'os';
9
10
  import { createHash } from 'crypto';
10
11
 
12
+ // src/core/backup/report.ts
13
+ var DEFAULT_PIECE_BYTES = 16 * 1024 * 1024;
14
+ function defaultDestinationName() {
15
+ return `backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.db`;
16
+ }
17
+ function assertPieceBytes(pieceBytes) {
18
+ if (!Number.isInteger(pieceBytes) || pieceBytes <= 0) {
19
+ throw new SirannonError(
20
+ `Piece size must be a positive whole number of bytes, and it was ${pieceBytes}`,
21
+ "BACKUP_ERROR"
22
+ );
23
+ }
24
+ }
25
+ async function readPageSize(conn) {
26
+ const stmt = await conn.prepare("PRAGMA page_size");
27
+ const row = await stmt.get();
28
+ return row ? Number(row.page_size) : 0;
29
+ }
30
+ async function readMainDatabasePath(conn) {
31
+ const stmt = await conn.prepare("PRAGMA database_list");
32
+ const rows = await stmt.all();
33
+ const main = rows.find((row) => row.name === "main");
34
+ return main?.file ?? "";
35
+ }
11
36
  function destinationPieceError(name, index, err) {
12
37
  if (err instanceof SirannonError) return err;
13
38
  return new SirannonError(
@@ -60,25 +85,6 @@ async function sendFileInPieces(sourcePath, destination, name, pieceBytes, finge
60
85
  return { pieceCount: index, bytesWritten, ...digest ? { fingerprint: digest.digest("hex") } : {} };
61
86
  }
62
87
 
63
- // src/core/backup/report.ts
64
- var DEFAULT_PIECE_BYTES = 16 * 1024 * 1024;
65
- function defaultDestinationName() {
66
- return `backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.db`;
67
- }
68
- function assertPieceBytes(pieceBytes) {
69
- if (!Number.isInteger(pieceBytes) || pieceBytes <= 0) {
70
- throw new SirannonError(
71
- `Piece size must be a positive whole number of bytes, and it was ${pieceBytes}`,
72
- "BACKUP_ERROR"
73
- );
74
- }
75
- }
76
- async function readPageSize(conn) {
77
- const stmt = await conn.prepare("PRAGMA page_size");
78
- const row = await stmt.get();
79
- return row ? Number(row.page_size) : 0;
80
- }
81
-
82
88
  // src/core/backup/stepped-copy.ts
83
89
  var DEFAULT_PAGES_PER_STEP = 256;
84
90
  var DEFAULT_RESTART_LIMIT = 3;
@@ -182,11 +188,16 @@ async function copyDatabaseStepwise(conn, options) {
182
188
  });
183
189
  copy.catch(() => {
184
190
  });
191
+ let copyStopped = false;
192
+ const stops = () => {
193
+ copyStopped = true;
194
+ };
195
+ copy.then(stops, stops);
185
196
  try {
186
197
  const final = await Promise.race([copy, stalled]);
187
198
  return { pageCount: final.totalPages, restarts };
188
199
  } catch (err) {
189
- options.onCopyLeftRunning?.(copy);
200
+ if (!copyStopped) options.onCopyLeftRunning?.(copy);
190
201
  throw err;
191
202
  } finally {
192
203
  if (stallTimer) clearTimeout(stallTimer);
@@ -227,6 +238,7 @@ async function copyToDestinationStaged(conn, request) {
227
238
  }
228
239
  });
229
240
  const copyMs = Date.now() - copyStartedAt;
241
+ const logPosition = await readLogPosition(request.sourcePath);
230
242
  const transferStartedAt = Date.now();
231
243
  const sent = await sendFileInPieces(
232
244
  stagedPath,
@@ -264,6 +276,7 @@ async function copyToDestinationStaged(conn, request) {
264
276
  pieceCount: sent.pieceCount,
265
277
  pieceBytes,
266
278
  restarts: copy.restarts,
279
+ ...logPosition ? { logPosition } : {},
267
280
  ...sent.fingerprint ? { fingerprint: sent.fingerprint } : {}
268
281
  };
269
282
  } finally {
@@ -609,6 +622,7 @@ async function copyToDestinationStreamed(conn, request, support) {
609
622
  }
610
623
  });
611
624
  const copyMs = Date.now() - copyStartedAt;
625
+ const logPosition = await readLogPosition(request.sourcePath);
612
626
  const bytesStreamed = await host.finish(streamId);
613
627
  copying = false;
614
628
  await pumping;
@@ -635,6 +649,7 @@ async function copyToDestinationStreamed(conn, request, support) {
635
649
  pieceCount: piecesWritten,
636
650
  pieceBytes,
637
651
  restarts: copy.restarts,
652
+ ...logPosition ? { logPosition } : {},
638
653
  ...fingerprint ? { fingerprint } : {}
639
654
  };
640
655
  } catch (err) {
@@ -663,6 +678,20 @@ function once(action) {
663
678
  action();
664
679
  };
665
680
  }
681
+ function removeQuietly(path) {
682
+ try {
683
+ rmSync(path, { force: true });
684
+ } catch {
685
+ }
686
+ }
687
+ function removeOnceCopyStops(copy, path) {
688
+ const remove = () => removeQuietly(path);
689
+ copy.then(remove, remove);
690
+ }
691
+ function backupFailure(destPath, err) {
692
+ if (err instanceof SirannonError) return err;
693
+ return new BackupError(`Backup to '${destPath}' failed: ${err instanceof Error ? err.message : String(err)}`);
694
+ }
666
695
  function hasControlCharacters(s) {
667
696
  for (let i = 0; i < s.length; i++) {
668
697
  const code = s.charCodeAt(i);
@@ -674,6 +703,15 @@ var BackupManager = class {
674
703
  constructor(streaming) {
675
704
  this.streaming = streaming;
676
705
  }
706
+ /**
707
+ * Copies the database behind a connection to a file, while that database
708
+ * stays open for reads and writes.
709
+ *
710
+ * @param conn - Connection the copy runs on, which must be the connection that writes.
711
+ * @param destPath - Path to write the copy to. A file already there stops the copy.
712
+ * @param onFirstStep - Called once the copy's first step is done, so the caller can hand the writer back.
713
+ * @returns What the copy moved, how long it took, and how often it restarted.
714
+ */
677
715
  async backup(conn, destPath, onFirstStep) {
678
716
  if (hasControlCharacters(destPath)) {
679
717
  throw new BackupError("Backup path contains invalid characters");
@@ -696,15 +734,68 @@ var BackupManager = class {
696
734
  if (existsSync(resolved)) {
697
735
  throw new BackupError(`Backup destination '${destPath}' already exists`);
698
736
  }
737
+ let pageSize;
699
738
  try {
700
- await copyDatabaseStepwise(conn, { destPath: resolved, onStep: onFirstStep ? once(onFirstStep) : void 0 });
739
+ pageSize = await readPageSize(conn);
701
740
  } catch (err) {
702
- try {
703
- rmSync(resolved, { force: true });
704
- } catch {
705
- }
706
- if (err instanceof SirannonError) throw err;
707
- throw new BackupError(`Backup to '${destPath}' failed: ${err instanceof Error ? err.message : String(err)}`);
741
+ throw backupFailure(destPath, err);
742
+ }
743
+ const startedAt = Date.now();
744
+ const copy = await this.copyOrClearUp(conn, resolved, destPath, onFirstStep);
745
+ const finishedAt = Date.now();
746
+ return {
747
+ runId: randomHex(8),
748
+ destPath: resolved,
749
+ startedAt,
750
+ finishedAt,
751
+ durationMs: finishedAt - startedAt,
752
+ pageCount: copy.pageCount,
753
+ pageSize,
754
+ byteLength: this.fileBytes(resolved, destPath),
755
+ restarts: copy.restarts
756
+ };
757
+ }
758
+ /**
759
+ * Reads the size of the file a copy wrote.
760
+ *
761
+ * @param resolved - Absolute path of that file.
762
+ * @param destPath - Path the caller named, which the error states.
763
+ * @returns Bytes it holds.
764
+ */
765
+ fileBytes(resolved, destPath) {
766
+ try {
767
+ return statSync(resolved).size;
768
+ } catch (err) {
769
+ throw backupFailure(destPath, err);
770
+ }
771
+ }
772
+ /**
773
+ * Runs the copy, and removes the file it was writing where that copy fails, so
774
+ * that a database missing its later pages never stays on disk as though the
775
+ * copy had finished. Sirannon would otherwise count that half-written file
776
+ * among the copies it keeps, and it would evict a whole copy to make room for
777
+ * one nothing can restore.
778
+ *
779
+ * Sirannon stops waiting on a copy once the stall deadline passes, and SQLite
780
+ * keeps writing that copy to the file. A removal that raced a live copy would
781
+ * leave a truncated file in place, and on Windows it would fail outright.
782
+ * Sirannon therefore removes the file only once the copy stops, while the
783
+ * caller receives the failure at once and waits for none of that.
784
+ */
785
+ async copyOrClearUp(conn, resolved, destPath, onFirstStep) {
786
+ const abandoned = { copy: null };
787
+ try {
788
+ return await copyDatabaseStepwise(conn, {
789
+ destPath: resolved,
790
+ onStep: onFirstStep ? once(onFirstStep) : void 0,
791
+ onCopyLeftRunning: (copy) => {
792
+ abandoned.copy = copy;
793
+ }
794
+ });
795
+ } catch (err) {
796
+ if (abandoned.copy) removeOnceCopyStops(abandoned.copy, resolved);
797
+ else removeQuietly(resolved);
798
+ throw backupFailure(destPath, err);
708
799
  }
709
800
  }
710
801
  async copyToDestination(conn, request) {
@@ -1004,6 +1095,7 @@ var DEFAULT_MAX_FILES = 5;
1004
1095
  var MINUTE_RESOLUTION_MS = 6e4;
1005
1096
  var SECOND_RESOLUTION_MS = 1e3;
1006
1097
  var DST_LOOKBACK_MS = 3 * 60 * 60 * 1e3;
1098
+ var DEFAULT_ON_BACKUP_TIMEOUT_MS = 6e5;
1007
1099
  function toError(value) {
1008
1100
  if (value instanceof Error) {
1009
1101
  return value;
@@ -1063,12 +1155,11 @@ var BackupScheduler = class {
1063
1155
  * Starts repeating backups and returns a function that stops them.
1064
1156
  *
1065
1157
  * @param conn - Connection to the database being copied.
1066
- * @param options - Cron expression, destination directory, retention, time zone, and failure callback.
1067
- * @param runExclusive - Runs each copy with the database's writer held so that no write commits mid-copy.
1158
+ * @param request - Cron expression, destination directory, retention, and time zone, along with the callbacks and the database the copies come from.
1068
1159
  * @returns A function that stops the schedule.
1069
1160
  */
1070
- schedule(conn, options, runExclusive = runDirect) {
1071
- const { cron: cronExpr, destDir, maxFiles = DEFAULT_MAX_FILES, onError, timezone } = options;
1161
+ schedule(conn, request) {
1162
+ const { cron: cronExpr, destDir, maxFiles = DEFAULT_MAX_FILES, timezone } = request;
1072
1163
  let cron;
1073
1164
  try {
1074
1165
  cron = parseCron(cronExpr);
@@ -1084,6 +1175,8 @@ var BackupScheduler = class {
1084
1175
  throw new BackupError(`Invalid timezone '${timezone}': ${err instanceof Error ? err.message : String(err)}`);
1085
1176
  }
1086
1177
  }
1178
+ const onBackupTimeoutMs = request.onBackupTimeoutMs ?? DEFAULT_ON_BACKUP_TIMEOUT_MS;
1179
+ assertDeadline(onBackupTimeoutMs, "onBackupTimeoutMs");
1087
1180
  const resolvedDir = resolve(destDir);
1088
1181
  if (!existsSync(resolvedDir)) {
1089
1182
  try {
@@ -1094,32 +1187,99 @@ var BackupScheduler = class {
1094
1187
  );
1095
1188
  }
1096
1189
  }
1097
- return this.run(conn, cron, resolvedDir, maxFiles, timezone, runExclusive, onError);
1190
+ return this.run(conn, { ...request, cron, resolvedDir, maxFiles, timezone, onBackupTimeoutMs });
1191
+ }
1192
+ /**
1193
+ * Works out the database and the file every report names. A caller may supply
1194
+ * either of them, and Sirannon reads the file SQLite has open where the caller
1195
+ * named none. Sirannon puts that question to the connection that writes, so it
1196
+ * asks with nothing else holding the writer.
1197
+ *
1198
+ * @param conn - Connection the copies come from.
1199
+ * @param run - The schedule, which holds whichever of the two the caller named.
1200
+ * @param runExclusive - Runs the question with nothing else holding the writer.
1201
+ * @returns The database identifier and the path of the file the copies come from.
1202
+ * @throws A `BACKUP_ERROR` where the caller named no source file and SQLite has none open.
1203
+ */
1204
+ async namesFor(conn, run, runExclusive) {
1205
+ if (run.databaseId !== void 0 && run.sourcePath !== void 0) {
1206
+ return { databaseId: run.databaseId, sourcePath: run.sourcePath };
1207
+ }
1208
+ let sourcePath = run.sourcePath ?? "";
1209
+ if (sourcePath === "") {
1210
+ await runExclusive(async () => {
1211
+ sourcePath = await readMainDatabasePath(conn);
1212
+ });
1213
+ }
1214
+ if (sourcePath === "") {
1215
+ throw new BackupError(
1216
+ "This schedule names no source file, and SQLite reports no file open on the connection it copies, so no report could name one. Give the schedule a sourcePath."
1217
+ );
1218
+ }
1219
+ return { databaseId: run.databaseId ?? basename(sourcePath, extname(sourcePath)), sourcePath };
1220
+ }
1221
+ /**
1222
+ * Passes one finished copy to the caller's completion callback, and stops
1223
+ * waiting on that callback once its deadline passes.
1224
+ *
1225
+ * @param report - What the copy produced.
1226
+ * @param run - The schedule, which holds the callback and its deadline.
1227
+ */
1228
+ async handToCaller(report, run) {
1229
+ const { onBackup, onBackupTimeoutMs } = run;
1230
+ if (!onBackup) return;
1231
+ const handed = Promise.resolve(onBackup(report));
1232
+ if (onBackupTimeoutMs === 0) {
1233
+ await handed;
1234
+ return;
1235
+ }
1236
+ await withinDeadline(
1237
+ handed,
1238
+ onBackupTimeoutMs,
1239
+ () => new SirannonError(
1240
+ `The onBackup callback for database '${report.databaseId}' did not return within ${onBackupTimeoutMs}ms, so the schedule went on without it. The copy at '${report.destPath}' is on disk and whatever that callback was doing with it has not finished.`,
1241
+ "BACKUP_ERROR"
1242
+ )
1243
+ );
1098
1244
  }
1099
- run(conn, cron, resolvedDir, maxFiles, timezone, runExclusive, onError) {
1245
+ run(conn, run) {
1246
+ const { cron, resolvedDir, maxFiles, timezone, onError } = run;
1247
+ const runExclusive = run.runExclusive ?? runDirect;
1100
1248
  const tickMs = cron.hasSeconds ? SECOND_RESOLUTION_MS : MINUTE_RESOLUTION_MS;
1101
1249
  let timer = null;
1102
1250
  let stopped = false;
1103
1251
  let running = false;
1104
1252
  let tickState = INITIAL_TICK_STATE;
1253
+ const announce = (err) => {
1254
+ if (!onError) return;
1255
+ try {
1256
+ onError(toError(err));
1257
+ } catch {
1258
+ }
1259
+ };
1260
+ let source = null;
1105
1261
  const runBackup = async () => {
1262
+ let copy = null;
1106
1263
  try {
1264
+ const names = source ?? await this.namesFor(conn, run, runExclusive);
1265
+ source = names;
1107
1266
  const destPath = join(resolvedDir, this.manager.generateFilename());
1108
- await startCopyWithoutHoldingWriter(
1267
+ copy = await startCopyWithoutHoldingWriter(
1109
1268
  runExclusive,
1110
1269
  (onFirstStep) => this.manager.backup(conn, destPath, onFirstStep)
1111
1270
  );
1112
- this.manager.rotate(resolvedDir, maxFiles);
1271
+ await this.handToCaller({ ...copy, ...names }, run);
1113
1272
  } catch (err) {
1114
- if (onError) {
1115
- try {
1116
- onError(toError(err));
1117
- } catch {
1118
- }
1273
+ announce(err);
1274
+ }
1275
+ if (copy) {
1276
+ try {
1277
+ this.manager.rotate(resolvedDir, maxFiles);
1278
+ } catch (err) {
1279
+ announce(err);
1119
1280
  }
1120
- } finally {
1121
- running = false;
1122
1281
  }
1282
+ running = false;
1123
1283
  };
1124
1284
  const scheduleNext = () => {
1125
1285
  if (stopped) {