@delali/sirannon-db 0.3.6-next.77 → 0.3.6-next.78

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 (72) hide show
  1. package/dist/backup/index.d.ts +5 -5
  2. package/dist/backup/index.mjs +5 -3
  3. package/dist/backup-scheduler/index.d.ts +2 -2
  4. package/dist/backup-scheduler/index.mjs +2 -2
  5. package/dist/chunk-22TYGH7J.mjs +55 -0
  6. package/dist/{chunk-SB3MIFLG.mjs → chunk-2ZQOZFX7.mjs} +29 -6
  7. package/dist/{chunk-UIDQGPJY.mjs → chunk-4O7WYZHN.mjs} +38 -3
  8. package/dist/{chunk-ZFFUVYYE.mjs → chunk-5DKWHT6K.mjs} +109 -1
  9. package/dist/chunk-5O4J6LQQ.mjs +35 -0
  10. package/dist/{chunk-CZL4D3BM.mjs → chunk-6CABU54O.mjs} +2 -2
  11. package/dist/{chunk-JAQ2I4OT.mjs → chunk-A5ULJVK7.mjs} +11 -11
  12. package/dist/{chunk-RLBKZZ24.mjs → chunk-AC7TKWNH.mjs} +1 -0
  13. package/dist/chunk-BKO4SCEC.mjs +99 -0
  14. package/dist/{chunk-KOTBP4AW.mjs → chunk-C7E4H6UN.mjs} +24 -4
  15. package/dist/{chunk-G4AG3ESO.mjs → chunk-FBA7P4ZA.mjs} +114 -149
  16. package/dist/{chunk-DKML5425.mjs → chunk-GBVB6WSC.mjs} +2 -2
  17. package/dist/chunk-I4JY5QPA.mjs +51 -0
  18. package/dist/{chunk-L6EX6K27.mjs → chunk-MO4APBYG.mjs} +54 -13
  19. package/dist/{chunk-RPWBYASV.mjs → chunk-MTOQ6QIS.mjs} +1 -1
  20. package/dist/chunk-MX77PGEN.mjs +60 -0
  21. package/dist/{chunk-BTUD6XQO.mjs → chunk-N3MHZV3B.mjs} +11 -29
  22. package/dist/{chunk-UHUDPQFI.mjs → chunk-OJXV4L3N.mjs} +1 -3
  23. package/dist/chunk-QF7WF7RJ.mjs +34 -0
  24. package/dist/chunk-RLBV4YTB.mjs +22 -0
  25. package/dist/{chunk-ISDT4RBD.mjs → chunk-ULUZUBON.mjs} +1 -1
  26. package/dist/{chunk-AP4367HK.mjs → chunk-VGRAAQX3.mjs} +4 -4
  27. package/dist/chunk-W74HFQFD.mjs +153 -0
  28. package/dist/chunk-Y4SP7CRF.mjs +9 -0
  29. package/dist/{chunk-JFVEGN7J.mjs → chunk-ZAUHAJ4G.mjs} +2 -1
  30. package/dist/{chunk-DDIHBUEN.mjs → chunk-ZWW53O5P.mjs} +19 -7
  31. package/dist/client/index.d.ts +8 -8
  32. package/dist/client/topology.d.ts +4 -4
  33. package/dist/{client-base-Doaqwrx3.d.ts → client-base-DJZbSY55.d.ts} +2 -2
  34. package/dist/{client-streams-MKPE4MXW.mjs → client-streams-F7IG3PTU.mjs} +13 -16
  35. package/dist/core/index.d.ts +48 -12
  36. package/dist/core/index.mjs +1812 -107
  37. package/dist/core/writer-worker.mjs +28 -4
  38. package/dist/{database-fz-8CJBP.d.ts → database-BZ96ZzAx.d.ts} +411 -184
  39. package/dist/{database-backup-BwTNiBm2.d.ts → database-backup-BhM8yJ1A.d.ts} +32 -2
  40. package/dist/driver/better-sqlite3.d.ts +11 -1
  41. package/dist/driver/better-sqlite3.mjs +212 -23
  42. package/dist/driver/bun.d.ts +1 -1
  43. package/dist/driver/bun.mjs +15 -22
  44. package/dist/driver/expo.d.ts +1 -1
  45. package/dist/driver/expo.mjs +185 -16
  46. package/dist/driver/node.d.ts +1 -1
  47. package/dist/driver/node.mjs +15 -62
  48. package/dist/driver/wa-sqlite.d.ts +1 -1
  49. package/dist/driver/wa-sqlite.mjs +14 -153
  50. package/dist/{change-tracker-Dpo99EI-.d.ts → errors-DLMAGQFb.d.ts} +200 -2
  51. package/dist/file-migrations/index.d.ts +1 -1
  52. package/dist/peer-refusal-RR37UOLV.mjs +1 -0
  53. package/dist/{protocol-DZSYSv0S.d.ts → protocol-DE10taas.d.ts} +1 -1
  54. package/dist/replication/coordinator/etcd.mjs +1 -1
  55. package/dist/replication/index.d.ts +7 -7
  56. package/dist/replication/index.mjs +66 -9
  57. package/dist/{restore-options-CcMukOLO.d.ts → restore-options-B6HV_5XT.d.ts} +6 -1
  58. package/dist/server/index.d.ts +10 -10
  59. package/dist/server/index.mjs +506 -350
  60. package/dist/{server-options-B9CsAZp5.d.ts → server-options-Bhdlvehe.d.ts} +14 -1
  61. package/dist/{server-streams-SBYCHWWM.mjs → server-streams-Q2AZAW3N.mjs} +6 -30
  62. package/dist/{sirannon-D0CjDULa.d.ts → sirannon-Ba1o2HUQ.d.ts} +5 -5
  63. package/dist/transport/grpc.d.ts +15 -4
  64. package/dist/transport/grpc.mjs +34 -37
  65. package/dist/transport/memory.d.ts +4 -3
  66. package/dist/transport/memory.mjs +8 -26
  67. package/dist/{types-DTmi7pXp.d.ts → types-BWyv_N18.d.ts} +189 -1
  68. package/dist/{types-BlZ9GG1a.d.ts → types-CDnlkVRK.d.ts} +8 -2
  69. package/package.json +17 -9
  70. package/dist/chunk-DBN57KVR.mjs +0 -171
  71. package/dist/chunk-W67A4H2E.mjs +0 -23
  72. package/dist/chunk-YW7IKQ7T.mjs +0 -62
@@ -1,34 +1,36 @@
1
- import { Transaction, MetricsCollector, readOutcome, failedStatementOutcome, settleLateOutcome, ROW_COUNTS_UNKNOWN, leavesOutcomeUnknown, LWWResolver, REMOTE_ORIGIN_NODE_ID, BatchReader, BatchApplier, recordLocalColumnVersions, writeOutcome, batchWriteOutcome, StatementRecorder, loadPersistedHlc, isWellFormedHlc, HLC_CLOCK_META_KEY, readOneOutcome } from '../chunk-AP4367HK.mjs';
2
- export { MetricsCollector, Transaction } from '../chunk-AP4367HK.mjs';
3
- import '../chunk-ZFFUVYYE.mjs';
4
- import { executeGroup } from '../chunk-ISDT4RBD.mjs';
5
- import { query, queryRowsForWire, queryOne, asQueryError, runInSavepoint, runWriterTransaction, withoutRowCounts, execute, executeBatchSummary, bindParams, executeBatch, queryMetered, queryForWire, queryOneMetered } from '../chunk-DDIHBUEN.mjs';
6
- export { execute, executeBatch, query, queryOne } from '../chunk-DDIHBUEN.mjs';
1
+ import { Transaction, MetricsCollector, readOutcome, failedStatementOutcome, settleLateOutcome, ROW_COUNTS_UNKNOWN, leavesOutcomeUnknown, LWWResolver, REMOTE_ORIGIN_NODE_ID, BatchReader, BatchApplier, recordLocalColumnVersions, writeOutcome, batchWriteOutcome, StatementRecorder, loadPersistedHlc, isWellFormedHlc, HLC_CLOCK_META_KEY, readOneOutcome } from '../chunk-VGRAAQX3.mjs';
2
+ export { MetricsCollector, Transaction } from '../chunk-VGRAAQX3.mjs';
3
+ import { executeGroup } from '../chunk-ULUZUBON.mjs';
4
+ import { query, queryRowsForWire, queryOne, asQueryError, runInSavepoint, runWriterTransaction, withoutRowCounts, execute, executeBatchSummary, bindParams, executeBatch, queryMetered, queryForWire, queryOneMetered } from '../chunk-ZWW53O5P.mjs';
5
+ export { execute, executeBatch, query, queryOne } from '../chunk-ZWW53O5P.mjs';
7
6
  import { unsupported, findClauses, readSource, readSortTerms, readWindow, readSelectItems } from '../chunk-D3YF2G7X.mjs';
8
- import { reconcileMigrationChecksums, migrationContentChecksum, resolveDeviceRetentionPolicy, ensureCdcEpoch, SubscriptionBuilderImpl, ChangeTracker, SubscriptionManager, startPolling, DEVICE_SYNC_PUSHED_SEQ_META_KEY, runBulkLoad, parseColumnDefinitions, TransactionGrouper, PrimedSubscription, dropCdcTriggers, ROWID_ALIASES } from '../chunk-SB3MIFLG.mjs';
9
- export { ChangeTracker, isBulkLoadDurability, runBulkLoad } from '../chunk-SB3MIFLG.mjs';
10
- import { planBackupRestore, backupPiecesSafeToDelete } from '../chunk-YW7IKQ7T.mjs';
11
- export { backupPiecesSafeToDelete, planBackupRestore } from '../chunk-YW7IKQ7T.mjs';
12
- import { ensureMigrationsTable, prepareUpdateMigrationChecksum, prepareInsertMigration, selectAppliedMigrationsNewestFirst, prepareDeleteMigration, selectAppliedMigrations, selectTableExists, selectMetaValue, upsertMetaValue, SEQ_STRING_RE, replaceMigrationHistory, highestMigrationVersion, selectCountOutboundChanges, ensureMetaTable, ensureChangesTable, ensureBatchApplyTables, ensureStagedPushChangesTable, PkResolver, selectMaxChangeSeq, selectMaxAppliedSourceSeq, tableColumnsWithGenerated, TABLE_XINFO_HIDDEN, selectTableSql, primaryKeyColumns, tableIndexList, upsertMetaMaxChangeSeqSql, updateChangeStampsAboveMetaSeqSql, upsertMetaValueAndMaxChangeSeqSql, insertMetaValueIfAbsent, HLC, selectMaxChangeHlc, setForeignKeysEnabled, IDENTIFIER_RE, validateDdlSafety, ensureStagedChangesTable, upsertStagedChangeSql, selectFirstStagedTransactionEnd, deleteStagedChangesUpToSeq, selectMaxStagedSeq, deleteAllStagedChanges, deleteMetaValue, selectStagedChangesInRange, assertSafeIdentifier } from '../chunk-RPWBYASV.mjs';
7
+ import { reconcileMigrationChecksums, migrationContentChecksum, resolveDeviceRetentionPolicy, ensureCdcEpoch, SubscriptionBuilderImpl, ChangeTracker, SubscriptionManager, startPolling, DEVICE_SYNC_PUSHED_SEQ_META_KEY, runBulkLoad, parseColumnDefinitions, dropMainSchemaTriggers, createMainSchemaTriggers, TransactionGrouper, PrimedSubscription, dropCdcTriggers, escId, ROWID_ALIASES, installKeyChangeCaptureTriggers, dropKeyChangeCaptureTriggers } from '../chunk-2ZQOZFX7.mjs';
8
+ export { ChangeTracker, isBulkLoadDurability, runBulkLoad } from '../chunk-2ZQOZFX7.mjs';
9
+ import { EncryptionKeyring, planBackupRestore, backupPiecesSafeToDelete, resolveEncryptionKey, wipeEncryptionKey } from '../chunk-W74HFQFD.mjs';
10
+ export { backupPiecesSafeToDelete, planBackupRestore } from '../chunk-W74HFQFD.mjs';
11
+ import { ensureMigrationsTable, prepareUpdateMigrationChecksum, prepareInsertMigration, selectAppliedMigrationsNewestFirst, prepareDeleteMigration, selectAppliedMigrations, selectTableExists, selectMetaValue, deleteMetaValue, ensureMetaTable, upsertMetaValue, SEQ_STRING_RE, replaceMigrationHistory, highestMigrationVersion, selectCountOutboundChanges, ensureChangesTable, ensureBatchApplyTables, ensureStagedPushChangesTable, PkResolver, selectMaxChangeSeq, selectMaxAppliedSourceSeq, tableColumnsWithGenerated, TABLE_XINFO_HIDDEN, selectTableSql, primaryKeyColumns, tableIndexList, upsertMetaMaxChangeSeqSql, updateChangeStampsAboveMetaSeqSql, upsertMetaValueAndMaxChangeSeqSql, insertMetaValueIfAbsent, HLC, selectMaxChangeHlc, setForeignKeysEnabled, IDENTIFIER_RE, validateDdlSafety, ensureStagedChangesTable, upsertStagedChangeSql, selectFirstStagedTransactionEnd, deleteStagedChangesUpToSeq, selectMaxStagedSeq, deleteAllStagedChanges, selectStagedChangesInRange, assertSafeIdentifier } from '../chunk-MTOQ6QIS.mjs';
12
+ import '../chunk-5DKWHT6K.mjs';
13
13
  import { ReplicationError } from '../chunk-BLMRNSUG.mjs';
14
- import { MIGRATIONS_TABLE, META_TABLE, CHANGES_TABLE, assertSqlAllowed, LIVE_PROBE_TABLE_PREFIX, eventTimestampToChangedAt, encodeTaggedValues, INTERNAL_TABLE_PREFIX, isReservedIdentifier, decodeTaggedValues, changedAtToEventTimestamp } from '../chunk-JFVEGN7J.mjs';
14
+ import { MIGRATIONS_TABLE, META_TABLE, CHANGES_TABLE, assertSqlAllowed, LIVE_PROBE_TABLE_PREFIX, eventTimestampToChangedAt, encodeTaggedValues, INTERNAL_TABLE_PREFIX, isReservedIdentifier, decodeTaggedValues, changedAtToEventTimestamp, KEY_CHANGE_CAPTURE_PREFIX } from '../chunk-ZAUHAJ4G.mjs';
15
15
  import { tokenizeSql, isKeyword, parameterName } from '../chunk-SEL4A4WT.mjs';
16
16
  import '../chunk-GVCNMPOS.mjs';
17
- import { defaultStagingDir } from '../chunk-JAQ2I4OT.mjs';
18
- export { defineDriver } from '../chunk-JAQ2I4OT.mjs';
19
- import { startCopyWithoutHoldingWriter } from '../chunk-W67A4H2E.mjs';
17
+ import { defaultStagingDir } from '../chunk-Y4SP7CRF.mjs';
18
+ import { startCopyWithoutHoldingWriter, readMainDatabasePath } from '../chunk-I4JY5QPA.mjs';
20
19
  import { lateRowCountsOf, lateGroupOutcomesOf, withoutLateRowCounts, withLateRowCounts } from '../chunk-K43WJORM.mjs';
20
+ export { defineDriver } from '../chunk-A5ULJVK7.mjs';
21
21
  import { rowCountsOf, withRowCounts, allMetered } from '../chunk-E2GRX4ZD.mjs';
22
- import { DEFAULT_SYNCHRONOUS } from '../chunk-YCMB6GR4.mjs';
23
- export { DEFAULT_SYNCHRONOUS, isSynchronousLevel, synchronousPragmaValue } from '../chunk-YCMB6GR4.mjs';
24
- import { DEFAULT_CHAIN_NAME } from '../chunk-CZL4D3BM.mjs';
25
- export { DEFAULT_CHAIN_NAME, readBackupChains } from '../chunk-CZL4D3BM.mjs';
22
+ import { DEFAULT_CHAIN_NAME } from '../chunk-6CABU54O.mjs';
23
+ export { DEFAULT_CHAIN_NAME, readBackupChains } from '../chunk-6CABU54O.mjs';
26
24
  import { assertDeadline, randomHex } from '../chunk-BGJ5JVYL.mjs';
27
25
  import { invokeCallerCallback } from '../chunk-HO2XTXVX.mjs';
26
+ import { DEFAULT_SYNCHRONOUS } from '../chunk-YCMB6GR4.mjs';
27
+ export { DEFAULT_SYNCHRONOUS, isSynchronousLevel, synchronousPragmaValue } from '../chunk-YCMB6GR4.mjs';
28
+ import { EncryptionError } from '../chunk-5O4J6LQQ.mjs';
29
+ export { EncryptionError } from '../chunk-5O4J6LQQ.mjs';
28
30
  import { LAZY_DOWN_SQL, parseMigrationFilename } from '../chunk-VDN6FHU4.mjs';
29
31
  export { MIGRATION_FILENAME_PATTERN, parseMigrationFilename } from '../chunk-VDN6FHU4.mjs';
30
32
  import { resolveEffectiveBaseline, planPendingMigrations, SQLITE_USER_VERSION_MAX, applyBaselineOption } from '../chunk-BZWDPF4G.mjs';
31
- import { SirannonError, ConnectionPoolError, MigrationError, ReadOnlyError, MaxDatabasesError, DatabaseAlreadyExistsError, DatabaseNotFoundError, WriteOverloadError, ExtensionError, CDCError, QueryError, TransactionError } from '../chunk-KINDFBQP.mjs';
33
+ import { ConnectionPoolError, SirannonError, MigrationError, ReadOnlyError, MaxDatabasesError, DatabaseAlreadyExistsError, DatabaseNotFoundError, ExtensionError, WriteOverloadError, CDCError, QueryError, TransactionError } from '../chunk-KINDFBQP.mjs';
32
34
  export { BackupError, CDCError, ConnectionPoolError, DatabaseAlreadyExistsError, DatabaseNotFoundError, ExtensionError, ForbiddenSqlError, HookDeniedError, MaxDatabasesError, MigrationError, QueryError, ReadOnlyError, RequestDeniedError, SirannonError, TransactionError, WriteOverloadError } from '../chunk-KINDFBQP.mjs';
33
35
 
34
36
  // src/core/connection-pool.ts
@@ -41,53 +43,82 @@ async function closeAllSilently(connections) {
41
43
  }
42
44
  }
43
45
  }
44
- var ConnectionPool = class _ConnectionPool {
45
- writer;
46
- readers;
47
- readerIndex = 0;
48
- closed = false;
49
- constructor(writer, readers) {
50
- this.writer = writer;
51
- this.readers = readers;
46
+ function writerHostOptions(options) {
47
+ const keyring = options.keyring;
48
+ if (!keyring?.encrypted) return options.workerHostOptions;
49
+ return { ...options.workerHostOptions, encryptionKey: () => keyring.freshKeyBytes() };
50
+ }
51
+ async function openWriter(options, keyed) {
52
+ const { driver, path, walMode = true, synchronous } = options;
53
+ const writerOptions = {
54
+ walMode,
55
+ synchronous,
56
+ ...options.walAutoCheckpoint === void 0 ? {} : { walAutoCheckpoint: options.walAutoCheckpoint }
57
+ };
58
+ if (!options.useWriterWorker) return driver.open(path, { ...writerOptions, ...keyed });
59
+ if (!driver.startWriterHost) {
60
+ throw new SirannonError(
61
+ "writerWorker is enabled but the driver does not carry a worker entry; use a driver that supports it or disable writerWorker",
62
+ "WRITER_WORKER_UNSUPPORTED"
63
+ );
52
64
  }
53
- static async create(options) {
54
- const { driver, path, readOnly = false, readPoolSize = 4, walMode = true, synchronous } = options;
55
- const writerOptions = {
56
- walMode,
57
- synchronous,
58
- ...options.walAutoCheckpoint === void 0 ? {} : { walAutoCheckpoint: options.walAutoCheckpoint }
59
- };
65
+ return driver.startWriterHost(path, writerOptions, writerHostOptions(options));
66
+ }
67
+ async function openConnections(options) {
68
+ const { driver, path, readOnly = false, readPoolSize = 4 } = options;
69
+ const open = async (keyed) => {
60
70
  let writer = null;
61
71
  const readers = [];
62
72
  try {
63
- if (!readOnly) {
64
- if (options.useWriterWorker) {
65
- if (!driver.startWriterHost) {
66
- throw new SirannonError(
67
- "writerWorker is enabled but the driver does not carry a worker entry; use a driver that supports it or disable writerWorker",
68
- "WRITER_WORKER_UNSUPPORTED"
69
- );
70
- }
71
- writer = await driver.startWriterHost(path, writerOptions, options.workerHostOptions);
72
- } else {
73
- writer = await driver.open(path, writerOptions);
74
- }
75
- }
73
+ if (!readOnly) writer = await openWriter(options, keyed);
76
74
  const poolSize = driver.capabilities.multipleConnections ? Math.max(readPoolSize, 1) : 0;
77
75
  for (let i = 0; i < poolSize; i++) {
78
- const reader = await driver.open(path, { readonly: true, walMode: false });
79
- readers.push(reader);
76
+ readers.push(await driver.open(path, { readonly: true, walMode: false, ...keyed }));
80
77
  }
81
78
  } catch (err) {
82
79
  await closeAllSilently([...readers, writer]);
83
80
  throw err;
84
81
  }
85
- return new _ConnectionPool(writer, readers);
82
+ return { writer, readers };
83
+ };
84
+ return options.keyring ? options.keyring.openWith(open) : open({});
85
+ }
86
+ var ConnectionPool = class _ConnectionPool {
87
+ constructor(options, opened) {
88
+ this.options = options;
89
+ this.writer = opened.writer;
90
+ this.readers = opened.readers;
86
91
  }
87
- acquireReader() {
92
+ writer;
93
+ readers;
94
+ readerIndex = 0;
95
+ closed = false;
96
+ isSuspended = false;
97
+ readsInFlight = 0;
98
+ readsSettled = null;
99
+ reopenFailure = null;
100
+ static async create(options) {
101
+ return new _ConnectionPool(options, await openConnections(options));
102
+ }
103
+ ensureAvailable() {
88
104
  if (this.closed) {
89
105
  throw new ConnectionPoolError("Connection pool is closed");
90
106
  }
107
+ if (this.isSuspended && this.reopenFailure !== null) {
108
+ if (this.reopenFailure instanceof SirannonError) throw this.reopenFailure;
109
+ throw new ConnectionPoolError(
110
+ "The database could not open its connections again after a key change replaced its file; close it and open it again under the new key"
111
+ );
112
+ }
113
+ if (this.isSuspended) {
114
+ throw new EncryptionError(
115
+ "The database is replacing its file to finish a key change, so its connections reopen once the replacement ends; retry the call",
116
+ "ENCRYPTION_CHANGE_IN_PROGRESS"
117
+ );
118
+ }
119
+ }
120
+ acquireReader() {
121
+ this.ensureAvailable();
91
122
  if (this.readers.length === 0) {
92
123
  if (!this.writer) {
93
124
  throw new ConnectionPoolError("No connections available");
@@ -99,26 +130,128 @@ var ConnectionPool = class _ConnectionPool {
99
130
  return reader;
100
131
  }
101
132
  acquireWriter() {
102
- if (this.closed) {
103
- throw new ConnectionPoolError("Connection pool is closed");
104
- }
133
+ this.ensureAvailable();
105
134
  if (!this.writer) {
106
135
  throw new ConnectionPoolError("No write connection available (database is read-only)");
107
136
  }
108
137
  return this.writer;
109
138
  }
139
+ /**
140
+ * Returns the next reader connection and keeps it open until the caller passes the lease back through
141
+ * {@link ConnectionPool.releaseReader}, so that {@link ConnectionPool.closeReaders} closes no reader under a read.
142
+ *
143
+ * @returns The reader connection, which the caller releases exactly once when its read settles.
144
+ */
145
+ leaseReader() {
146
+ const reader = this.acquireReader();
147
+ this.readsInFlight++;
148
+ return reader;
149
+ }
150
+ /** Ends a lease that {@link ConnectionPool.leaseReader} returned. */
151
+ releaseReader() {
152
+ this.readsInFlight--;
153
+ if (this.readsInFlight === 0) this.readsSettled?.();
154
+ }
155
+ /**
156
+ * Returns what a read on the next reader connection returns, and keeps that connection open until the read settles,
157
+ * so that {@link ConnectionPool.closeReaders} closes no reader under a read.
158
+ *
159
+ * @param read - The read to run on the reader.
160
+ * @returns The value of the read.
161
+ */
162
+ async withReader(read) {
163
+ const reader = this.leaseReader();
164
+ try {
165
+ return await read(reader);
166
+ } finally {
167
+ this.releaseReader();
168
+ }
169
+ }
110
170
  /** Returns the writer and every reader, so that the caller can apply an operation to every connection in the pool. */
111
171
  connections() {
112
- if (this.closed) {
113
- throw new ConnectionPoolError("Connection pool is closed");
114
- }
172
+ this.ensureAvailable();
115
173
  return this.writer ? [this.writer, ...this.readers] : [...this.readers];
116
174
  }
117
175
  get readerCount() {
118
176
  return this.readers.length;
119
177
  }
120
178
  get isReadOnly() {
121
- return this.writer === null;
179
+ return this.options.readOnly === true;
180
+ }
181
+ /**
182
+ * Waits for every read on a reader to settle, then closes every reader and leaves the writer open, so that a
183
+ * checkpoint on the writer can reach the end of the log. Every acquire fails with `ENCRYPTION_CHANGE_IN_PROGRESS`
184
+ * until {@link ConnectionPool.reopen} returns.
185
+ *
186
+ * @param drainTimeoutMs - How long to wait for the reads to settle.
187
+ * @throws An `EncryptionError` with `ENCRYPTION_CHANGE_ABORTED` when a read runs past the timeout, which leaves the
188
+ * readers open and the pool available.
189
+ */
190
+ async closeReaders(drainTimeoutMs) {
191
+ this.isSuspended = true;
192
+ const readers = this.readers;
193
+ this.readers = [];
194
+ if (!await this.readsDrained(drainTimeoutMs)) {
195
+ this.readers = readers;
196
+ this.isSuspended = false;
197
+ throw new EncryptionError(
198
+ `A read held a reader connection for longer than ${drainTimeoutMs} ms, so the key change stopped before it replaced the file`,
199
+ "ENCRYPTION_CHANGE_ABORTED"
200
+ );
201
+ }
202
+ await closeAllSilently(readers);
203
+ }
204
+ async readsDrained(timeoutMs) {
205
+ if (this.readsInFlight === 0) return true;
206
+ let timer;
207
+ const drained = await Promise.race([
208
+ new Promise((resolve) => {
209
+ this.readsSettled = () => resolve(true);
210
+ }),
211
+ new Promise((resolve) => {
212
+ timer = setTimeout(() => resolve(false), timeoutMs);
213
+ })
214
+ ]);
215
+ clearTimeout(timer);
216
+ this.readsSettled = null;
217
+ return drained;
218
+ }
219
+ /** Closes the writer after {@link ConnectionPool.closeReaders}. */
220
+ async closeWriter() {
221
+ const writer = this.writer;
222
+ this.writer = null;
223
+ if (writer) await writer.close();
224
+ }
225
+ /** `true` from {@link ConnectionPool.closeReaders} until {@link ConnectionPool.reopen} returns. */
226
+ get suspended() {
227
+ return this.isSuspended;
228
+ }
229
+ /**
230
+ * Opens the writer and the readers again, under the key that the pool's keyring holds now, and loads into them
231
+ * every extension that the database loaded into the connections they replace. When the connections fail to open,
232
+ * every acquire fails with that error until a later reopen succeeds.
233
+ */
234
+ async reopen() {
235
+ if (this.closed || !this.isSuspended) return;
236
+ await closeAllSilently([...this.readers, this.writer]);
237
+ this.readers = [];
238
+ this.writer = null;
239
+ let opened;
240
+ try {
241
+ opened = await openConnections(this.options);
242
+ } catch (err) {
243
+ this.reopenFailure = err;
244
+ throw err;
245
+ }
246
+ this.reopenFailure = null;
247
+ this.writer = opened.writer;
248
+ this.readers = opened.readers;
249
+ this.readerIndex = 0;
250
+ try {
251
+ await this.options.prepareReopened?.(opened.writer ? [opened.writer, ...opened.readers] : opened.readers);
252
+ } finally {
253
+ this.isSuspended = false;
254
+ }
122
255
  }
123
256
  async close() {
124
257
  if (this.closed) return;
@@ -322,6 +455,93 @@ function describeBackupCapabilities(capabilities, hasEngine, streams = false) {
322
455
  };
323
456
  }
324
457
 
458
+ // src/core/backup/copy-admission.ts
459
+ var BackupCopyAdmission = class {
460
+ constructor(runExclusive) {
461
+ this.runExclusive = runExclusive;
462
+ }
463
+ paused = false;
464
+ running = 0;
465
+ reopened = Promise.resolve();
466
+ openAgain = () => {
467
+ };
468
+ copiesStopped = null;
469
+ /**
470
+ * Runs an operation while no other operation holds the writer, after any key change that stopped copies has ended.
471
+ *
472
+ * @param op - The operation to run.
473
+ */
474
+ runAdmitted = async (op) => {
475
+ const admission = { ran: false };
476
+ while (!admission.ran) {
477
+ await this.reopened;
478
+ await this.runExclusive(async () => {
479
+ if (this.paused) return;
480
+ admission.ran = true;
481
+ await op();
482
+ });
483
+ }
484
+ };
485
+ /**
486
+ * Returns a connection that calls the pool's writer of the moment on every call, and that counts each copy it
487
+ * starts until SQLite stops writing that copy, including a copy that the caller stopped waiting for.
488
+ *
489
+ * @param acquireWriter - Returns the pool's writer.
490
+ * @returns The connection for the backup engine to copy through.
491
+ */
492
+ writer(acquireWriter) {
493
+ const track = (copy) => this.track(copy);
494
+ return {
495
+ exec: (sql) => acquireWriter().exec(sql),
496
+ prepare: (sql) => acquireWriter().prepare(sql),
497
+ transaction: (fn) => acquireWriter().transaction(fn),
498
+ close: () => acquireWriter().close(),
499
+ get copyRunsOffCallerThread() {
500
+ return acquireWriter().copyRunsOffCallerThread;
501
+ },
502
+ get copyDatabase() {
503
+ const writer = acquireWriter();
504
+ const copy = writer.copyDatabase;
505
+ if (copy === void 0) return void 0;
506
+ return (request) => track(copy.call(writer, request));
507
+ }
508
+ };
509
+ }
510
+ /** Stops new copies from starting, then waits until SQLite has stopped writing every copy that started before. */
511
+ async pause() {
512
+ await this.runExclusive(async () => {
513
+ if (this.paused) return;
514
+ this.paused = true;
515
+ this.reopened = new Promise((resolve) => {
516
+ this.openAgain = resolve;
517
+ });
518
+ });
519
+ while (this.running > 0) {
520
+ await new Promise((resolve) => {
521
+ this.copiesStopped = resolve;
522
+ });
523
+ }
524
+ }
525
+ /** Lets copies start again, on the connections that the pool holds now. */
526
+ resume() {
527
+ if (!this.paused) return;
528
+ this.paused = false;
529
+ this.openAgain();
530
+ }
531
+ track(copy) {
532
+ this.running++;
533
+ const stopped = () => {
534
+ this.running--;
535
+ if (this.running > 0) return;
536
+ const copiesStopped = this.copiesStopped;
537
+ this.copiesStopped = null;
538
+ copiesStopped?.();
539
+ };
540
+ copy.then(stopped, stopped);
541
+ return copy;
542
+ }
543
+ };
544
+
325
545
  // src/core/database-backup.ts
326
546
  function assertChangeLogCaptureSupported(driver, id, path, walMode, cycle) {
327
547
  if (cycle.destinationTimeoutMs !== void 0) assertDeadline(cycle.destinationTimeoutMs, "The destination deadline");
@@ -339,18 +559,23 @@ function assertChangeLogCaptureSupported(driver, id, path, walMode, cycle) {
339
559
  }
340
560
  }
341
561
  var DatabaseBackupController = class {
342
- constructor(runExclusive, acquireWriter, driverCapabilities, databaseId, sourcePath, engine) {
562
+ constructor(runExclusive, acquireWriter, driverCapabilities, databaseId, sourcePath, engine, keyId = () => void 0) {
343
563
  this.runExclusive = runExclusive;
344
564
  this.acquireWriter = acquireWriter;
345
565
  this.driverCapabilities = driverCapabilities;
346
566
  this.databaseId = databaseId;
347
567
  this.sourcePath = sourcePath;
348
568
  this.engine = engine;
569
+ this.keyId = keyId;
570
+ this.copies = new BackupCopyAdmission(runExclusive);
571
+ this.copyWriter = this.copies.writer(acquireWriter);
349
572
  }
350
573
  cancellers = [];
351
574
  cycle = null;
352
575
  cycleOptions = null;
353
576
  cycleStarted = Promise.resolve();
577
+ copies;
578
+ copyWriter;
354
579
  require() {
355
580
  if (!this.engine) {
356
581
  throw new SirannonError(
@@ -379,16 +604,16 @@ var DatabaseBackupController = class {
379
604
  async backup(destPath) {
380
605
  const engine = this.require();
381
606
  const copy = await startCopyWithoutHoldingWriter(
382
- this.runExclusive,
383
- (onFirstStep) => engine.backup(this.acquireWriter(), destPath, onFirstStep)
607
+ this.copies.runAdmitted,
608
+ (onFirstStep) => engine.backup(this.copyWriter, destPath, onFirstStep)
384
609
  );
385
610
  return { ...copy, databaseId: this.databaseId, sourcePath: this.sourcePath };
386
611
  }
387
612
  backupTo(options) {
388
613
  const engine = this.require();
389
614
  return startCopyWithoutHoldingWriter(
390
- this.runExclusive,
391
- (onFirstStep) => engine.copyToDestination(this.acquireWriter(), {
615
+ this.copies.runAdmitted,
616
+ (onFirstStep) => engine.copyToDestination(this.copyWriter, {
392
617
  ...options,
393
618
  databaseId: this.databaseId,
394
619
  sourcePath: this.sourcePath,
@@ -399,11 +624,11 @@ var DatabaseBackupController = class {
399
624
  schedule(options) {
400
625
  const engine = this.require();
401
626
  this.cancellers.push(
402
- engine.schedule(this.acquireWriter(), {
627
+ engine.schedule(this.copyWriter, {
403
628
  ...options,
404
629
  databaseId: this.databaseId,
405
630
  sourcePath: this.sourcePath,
406
- runExclusive: this.runExclusive
631
+ runExclusive: this.copies.runAdmitted
407
632
  })
408
633
  );
409
634
  }
@@ -422,7 +647,8 @@ var DatabaseBackupController = class {
422
647
  sourcePath: this.sourcePath,
423
648
  runExclusive: this.runExclusive,
424
649
  acquireWriter: this.acquireWriter,
425
- fullCopy: (copyOptions) => this.backupTo(copyOptions)
650
+ fullCopy: (copyOptions) => this.backupTo(copyOptions),
651
+ keyId: this.keyId
426
652
  });
427
653
  this.cycleStarted = this.cycle.start().catch((err) => {
428
654
  const onError = options.onError;
@@ -471,6 +697,46 @@ var DatabaseBackupController = class {
471
697
  await this.cycleStarted;
472
698
  await cycle.stop();
473
699
  }
700
+ /**
701
+ * Stops the backup cycle after one last capture, ahead of a key change that replaces the file.
702
+ *
703
+ * @returns `true` when a cycle was running, which {@link DatabaseBackupController.resumeCycle} starts again.
704
+ */
705
+ async pauseCycle() {
706
+ if (!this.cycle || !this.cycleOptions) return false;
707
+ await this.stopCycle();
708
+ return true;
709
+ }
710
+ /**
711
+ * Stops new backup copies from starting ahead of a key change that replaces the file, and waits until SQLite has
712
+ * stopped writing every copy that started before, so that no copy reads pages from both files.
713
+ */
714
+ pauseCopies() {
715
+ return this.copies.pause();
716
+ }
717
+ /** Lets backup copies start again once a key change has replaced the file or given up. */
718
+ resumeCopies() {
719
+ this.copies.resume();
720
+ }
721
+ /**
722
+ * Drops the chain that the paused backup cycle extended, so that the cycle starts a new chain with a full copy
723
+ * whenever it starts again, including in a process that starts after a crash during the key change.
724
+ */
725
+ async forgetPausedChain() {
726
+ await this.cycle?.forgetChain();
727
+ }
728
+ /**
729
+ * Starts the backup cycle again after a key change, on a new chain whose first piece is a full copy when the file
730
+ * now holds ciphertext under another key.
731
+ *
732
+ * @param options - Whether the cycle starts a new chain.
733
+ */
734
+ async resumeCycle(options) {
735
+ const cycleOptions = this.cycleOptions;
736
+ if (!cycleOptions) return;
737
+ if (options.newChain) await this.cycle?.forgetChain();
738
+ this.startCycle(cycleOptions);
739
+ }
474
740
  cancelAll() {
475
741
  for (const cancel of this.cancellers) {
476
742
  try {
@@ -669,6 +935,7 @@ var DatabaseCdcController = class {
669
935
  stopPolling = null;
670
936
  stamper = null;
671
937
  epochRequest = null;
938
+ liveConnectionListeners = /* @__PURE__ */ new Set();
672
939
  ensureEpoch() {
673
940
  this.epochRequest ??= this.runExclusive(() => ensureCdcEpoch(this.acquireWriter())).catch((err) => {
674
941
  this.epochRequest = null;
@@ -693,7 +960,7 @@ var DatabaseCdcController = class {
693
960
  async liveConnection() {
694
961
  const open = this.openSnapshotConnection;
695
962
  if (open === null) {
696
- return { conn: this.acquireWriter(), run: this.runExclusive };
963
+ return { conn: await this.runExclusive(async () => this.acquireWriter()), run: this.runExclusive };
697
964
  }
698
965
  this.liveConnRequest ??= open().catch((err) => {
699
966
  this.liveConnRequest = null;
@@ -708,6 +975,46 @@ var DatabaseCdcController = class {
708
975
  await request.then((conn) => conn.close()).catch(() => {
709
976
  });
710
977
  }
978
+ /**
979
+ * Registers a function that the controller calls when a key change closes the live connection to replace the file,
980
+ * so that each live query plans again on the connection that opens afterwards.
981
+ *
982
+ * @param listener - The function to call.
983
+ * @returns A function that removes the registration.
984
+ */
985
+ onLiveConnectionReplaced(listener) {
986
+ this.liveConnectionListeners.add(listener);
987
+ return () => {
988
+ this.liveConnectionListeners.delete(listener);
989
+ };
990
+ }
991
+ /**
992
+ * Closes the live connection before a key change replaces the file, after telling every live query to plan again
993
+ * on the connection that the next call opens once the replacement ends. Where live queries read through the
994
+ * pool's writer, {@link DatabaseCdcController.resumeOnReopenedPool} tells them instead, once that writer exists.
995
+ */
996
+ async releaseLiveConnection() {
997
+ if (this.openSnapshotConnection === null) return;
998
+ const request = this.liveConnRequest;
999
+ this.liveConnRequest = null;
1000
+ this.replanLiveQueries();
1001
+ if (request !== null) await request.then((conn) => conn.close()).catch(() => {
1002
+ });
1003
+ }
1004
+ /**
1005
+ * Starts polling again where polling was on, and has every live query that reads through the pool's writer plan
1006
+ * again, after the pool opened its connections to the file that a key change left in place. The key change calls
1007
+ * this while it holds the writer, so no live read reaches the closed writer.
1008
+ */
1009
+ resumeOnReopenedPool() {
1010
+ if (this.openSnapshotConnection === null) this.replanLiveQueries();
1011
+ if (this.stopPolling === null) return;
1012
+ this.stop();
1013
+ this.ensurePolling();
1014
+ }
1015
+ replanLiveQueries() {
1016
+ for (const listener of [...this.liveConnectionListeners]) listener();
1017
+ }
711
1018
  get subscriptions() {
712
1019
  return this.subscriptionManager;
713
1020
  }
@@ -812,7 +1119,7 @@ var DatabaseCdcController = class {
812
1119
  if (this.stopPolling) return;
813
1120
  if (!this.tracker || !this.subscriptionManager) return;
814
1121
  this.stopPolling = startPolling(
815
- this.acquireWriter(),
1122
+ () => this.acquireWriter(),
816
1123
  this.tracker,
817
1124
  this.subscriptionManager,
818
1125
  this.pollInterval,
@@ -1526,6 +1833,7 @@ var DatabaseSyncController = class {
1526
1833
  this.cdc = cdc;
1527
1834
  }
1528
1835
  pkResolver = null;
1836
+ cachedOn = null;
1529
1837
  tablesReady = false;
1530
1838
  metaReady = false;
1531
1839
  outboxReader = null;
@@ -1735,24 +2043,31 @@ var DatabaseSyncController = class {
1735
2043
  this.tablesReady = true;
1736
2044
  }
1737
2045
  }
2046
+ resolverOn(writer) {
2047
+ if (this.pkResolver === null || this.cachedOn !== writer) {
2048
+ this.pkResolver = new PkResolver(writer);
2049
+ this.outboxReader = null;
2050
+ this.cachedOn = writer;
2051
+ }
2052
+ return this.pkResolver;
2053
+ }
1738
2054
  async ensureOutboxReader() {
1739
2055
  const writer = this.acquireWriter();
1740
2056
  await this.ensureSyncTables(writer);
1741
2057
  const stamper = await this.cdc.ensureStamper();
1742
- this.pkResolver ??= new PkResolver(writer);
1743
- this.outboxReader ??= new BatchReader(writer, stamper.nodeId, CHANGES_TABLE, this.pkResolver, true, true);
2058
+ const pkResolver = this.resolverOn(writer);
2059
+ this.outboxReader ??= new BatchReader(writer, stamper.nodeId, CHANGES_TABLE, pkResolver, true, true);
1744
2060
  return this.outboxReader;
1745
2061
  }
1746
2062
  async ensureApplier() {
1747
2063
  const writer = this.acquireWriter();
1748
2064
  await this.ensureSyncTables(writer);
1749
2065
  const stamper = await this.cdc.ensureStamper();
1750
- this.pkResolver ??= new PkResolver(writer);
1751
2066
  return new BatchApplier(
1752
2067
  writer,
1753
2068
  stamper.nodeId,
1754
2069
  stamper.hlc,
1755
- this.pkResolver,
2070
+ this.resolverOn(writer),
1756
2071
  (fromNodeId) => this.lastAppliedSeq(fromNodeId),
1757
2072
  {
1758
2073
  tracker: this.cdc.changeTracker ?? void 0,
@@ -2145,6 +2460,1129 @@ var DatabaseWriteController = class {
2145
2460
  }
2146
2461
  };
2147
2462
 
2463
+ // src/core/encryption/key-change-round.ts
2464
+ function keyChangeAborted(message, cause) {
2465
+ return new EncryptionError(message, "ENCRYPTION_CHANGE_ABORTED", cause);
2466
+ }
2467
+ function assertSameWriter(started, current) {
2468
+ if (started !== current) {
2469
+ throw keyChangeAborted(
2470
+ "The database replaced its writer connection during the key change, so writes went unrecorded"
2471
+ );
2472
+ }
2473
+ }
2474
+
2475
+ // src/core/system-catalog/key-change-capture-tables.ts
2476
+ function keyChangeCaptureTables(position, keyColumns) {
2477
+ return {
2478
+ log: `${KEY_CHANGE_CAPTURE_PREFIX}${position}_log`,
2479
+ touched: `${KEY_CHANGE_CAPTURE_PREFIX}${position}_touched`,
2480
+ slots: Array.from({ length: keyColumns }, (_, n) => `k${n}`)
2481
+ };
2482
+ }
2483
+ function keyChangeCaptureTriggerName(position, kind) {
2484
+ return `${KEY_CHANGE_CAPTURE_PREFIX}${position}_${kind}`;
2485
+ }
2486
+ async function ensureKeyChangeLogTable(conn, tables) {
2487
+ await conn.exec(`CREATE TEMP TABLE "${escId(tables.log)}" (${tables.slots.join(", ")})`);
2488
+ }
2489
+ async function selectDistinctKeyChangeLogKeys(conn, tables) {
2490
+ const columns = tables.slots.map(
2491
+ (slot) => `typeof(${slot}) AS t_${slot}, CASE WHEN typeof(${slot}) = 'integer' THEN CAST(${slot} AS TEXT) ELSE ${slot} END AS v_${slot}`
2492
+ ).join(", ");
2493
+ const stmt = await conn.prepare(`SELECT DISTINCT ${columns} FROM temp."${escId(tables.log)}"`);
2494
+ return await stmt.all();
2495
+ }
2496
+ async function deleteKeyChangeLogKeys(conn, tables) {
2497
+ await conn.exec(`DELETE FROM temp."${escId(tables.log)}"`);
2498
+ }
2499
+ async function dropKeyChangeLogTable(conn, tables) {
2500
+ await conn.exec(`DROP TABLE IF EXISTS temp."${escId(tables.log)}"`);
2501
+ }
2502
+ async function ensureEmptyKeyChangeTouchedTable(conn, tables) {
2503
+ await conn.exec(`CREATE TEMP TABLE IF NOT EXISTS "${escId(tables.touched)}" (${tables.slots.join(", ")})`);
2504
+ await conn.exec(`DELETE FROM temp."${escId(tables.touched)}"`);
2505
+ }
2506
+ function prepareInsertKeyChangeTouchedKey(conn, tables) {
2507
+ const values = tables.slots.map(() => "CASE WHEN ? = 'integer' THEN CAST(? AS INTEGER) ELSE ? END").join(", ");
2508
+ return conn.prepare(`INSERT INTO temp."${escId(tables.touched)}" VALUES (${values})`);
2509
+ }
2510
+ function selectKeyChangeTouchedKeysSql(tables) {
2511
+ return `SELECT ${tables.slots.join(", ")} FROM temp."${escId(tables.touched)}"`;
2512
+ }
2513
+
2514
+ // src/core/encryption/key-change-rowids.ts
2515
+ var PRIMARY_KEY_INDEX_ORIGIN = "pk";
2516
+ function quote(name) {
2517
+ return `"${escId(name)}"`;
2518
+ }
2519
+ async function readCount(conn, sql, ...params) {
2520
+ const row = await (await conn.prepare(sql)).get(...params);
2521
+ const value = row === void 0 ? void 0 : Object.values(row)[0];
2522
+ const count = typeof value === "bigint" ? Number(value) : value;
2523
+ if (typeof count !== "number" || !Number.isSafeInteger(count)) {
2524
+ throw new EncryptionError(`SQLite returned no count for '${sql}'`, "ENCRYPTION_CHANGE_ABORTED");
2525
+ }
2526
+ return count;
2527
+ }
2528
+ async function rowidTables(conn) {
2529
+ const rows = await (await conn.prepare(
2530
+ "SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'table' AND wr = 0 AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'"
2531
+ )).all();
2532
+ return rows.map((row) => row.name);
2533
+ }
2534
+ async function rowidIsPrimaryKey(conn, table, columns) {
2535
+ if (!columns.some((column) => Number(column.pk) > 0)) return false;
2536
+ const primaryKeyIndexes = await readCount(
2537
+ conn,
2538
+ "SELECT count(*) FROM pragma_index_list(?, 'main') WHERE origin = ?",
2539
+ table,
2540
+ PRIMARY_KEY_INDEX_ORIGIN
2541
+ );
2542
+ return primaryKeyIndexes === 0;
2543
+ }
2544
+ function freeRowidAlias(table, columns) {
2545
+ const taken = new Set(columns.map((column) => column.name.toLowerCase()));
2546
+ const name = ROWID_ALIASES.find((candidate) => !taken.has(candidate));
2547
+ if (name === void 0) {
2548
+ throw new EncryptionError(
2549
+ `The table ${table} has columns named rowid, _rowid_, and oid, so the key change cannot address its rows`,
2550
+ "ENCRYPTION_CHANGE_ABORTED"
2551
+ );
2552
+ }
2553
+ return name;
2554
+ }
2555
+ async function copyWithRowids(conn, table, columns, targetSchema) {
2556
+ const rowid = freeRowidAlias(table, columns);
2557
+ const stored = columns.filter((column) => {
2558
+ const hidden = Number(column.hidden);
2559
+ return hidden !== TABLE_XINFO_HIDDEN.generatedStored && hidden !== TABLE_XINFO_HIDDEN.generatedVirtual;
2560
+ }).map((column) => quote(column.name)).join(", ");
2561
+ const rows = await readCount(conn, `SELECT count(*) FROM main.${quote(table)}`);
2562
+ const changesBefore = await readCount(conn, "SELECT total_changes()");
2563
+ await conn.exec(`DELETE FROM ${targetSchema}.${quote(table)}`);
2564
+ await conn.exec(
2565
+ `INSERT INTO ${targetSchema}.${quote(table)} (${rowid}, ${stored}) SELECT ${rowid}, ${stored} FROM main.${quote(table)}`
2566
+ );
2567
+ const changes = await readCount(conn, "SELECT total_changes()") - changesBefore;
2568
+ if (changes !== rows * 2) {
2569
+ throw new EncryptionError(
2570
+ `Copying the ${rows} rows of the table ${table} with their rowids changed ${changes} rows, so the key change stopped`,
2571
+ "ENCRYPTION_CHANGE_ABORTED"
2572
+ );
2573
+ }
2574
+ }
2575
+ async function copyRowidsAfterSqlcipherExport(snapshot, targetSchema) {
2576
+ for (const table of await rowidTables(snapshot)) {
2577
+ const columns = await (await snapshot.prepare("SELECT name, pk, hidden FROM pragma_table_xinfo(?, 'main')")).all(table);
2578
+ if (await rowidIsPrimaryKey(snapshot, table, columns)) continue;
2579
+ await copyWithRowids(snapshot, table, columns, targetSchema);
2580
+ }
2581
+ }
2582
+
2583
+ // src/core/encryption/key-change-copy.ts
2584
+ var KEY_CHANGE_SCHEMA = "sirannon_key_change";
2585
+ var ROWID_KEEPING_EXPORT = "sirannon_export";
2586
+ var SQLCIPHER_EXPORT = "sqlcipher_export";
2587
+ var HEX_DIGITS = "0123456789abcdef";
2588
+ var QUOTE = 39;
2589
+ var LOWER_X = 120;
2590
+ function rawKeyspec(key) {
2591
+ const spec = new Uint8Array(key.length * 2 + 3);
2592
+ spec[0] = LOWER_X;
2593
+ spec[1] = QUOTE;
2594
+ for (let i = 0; i < key.length; i++) {
2595
+ spec[2 + i * 2] = HEX_DIGITS.charCodeAt(key[i] >> 4);
2596
+ spec[3 + i * 2] = HEX_DIGITS.charCodeAt(key[i] & 15);
2597
+ }
2598
+ spec[spec.length - 1] = QUOTE;
2599
+ return spec;
2600
+ }
2601
+ async function attachUnderKey(conn, path, schema, key) {
2602
+ const spec = key === void 0 ? "" : rawKeyspec(key);
2603
+ try {
2604
+ await (await conn.prepare(`ATTACH DATABASE ? AS ${schema} KEY ?`)).run(path, spec);
2605
+ } finally {
2606
+ if (spec instanceof Uint8Array) spec.fill(0);
2607
+ }
2608
+ }
2609
+ async function readInteger(conn, sql) {
2610
+ const row = await (await conn.prepare(sql)).get();
2611
+ const value = row === void 0 ? void 0 : Object.values(row)[0];
2612
+ if (typeof value === "bigint") return value;
2613
+ if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value);
2614
+ throw new EncryptionError(`SQLite returned no integer for '${sql}'`, "ENCRYPTION_CHANGE_ABORTED");
2615
+ }
2616
+ function readSchemaVersion2(conn) {
2617
+ return readInteger(conn, "PRAGMA main.schema_version");
2618
+ }
2619
+ async function attachNewFile(snapshot, newPath, key) {
2620
+ await attachUnderKey(snapshot, newPath, KEY_CHANGE_SCHEMA, key);
2621
+ const autoVacuum = await readInteger(snapshot, "PRAGMA main.auto_vacuum");
2622
+ await snapshot.exec(`PRAGMA ${KEY_CHANGE_SCHEMA}.auto_vacuum = ${autoVacuum}`);
2623
+ await snapshot.exec("PRAGMA foreign_keys = OFF");
2624
+ }
2625
+ async function exportSnapshot(snapshot) {
2626
+ const exportFunction = await (await snapshot.prepare(
2627
+ `SELECT name FROM pragma_function_list WHERE name IN ('${ROWID_KEEPING_EXPORT}', '${SQLCIPHER_EXPORT}') ORDER BY name = '${ROWID_KEEPING_EXPORT}' DESC LIMIT 1`
2628
+ )).get();
2629
+ if (exportFunction?.name !== ROWID_KEEPING_EXPORT && exportFunction?.name !== SQLCIPHER_EXPORT) {
2630
+ throw new EncryptionError(
2631
+ "The SQLite build has no function that copies a database under another key",
2632
+ "ENCRYPTION_UNSUPPORTED"
2633
+ );
2634
+ }
2635
+ await (await snapshot.prepare(`SELECT ${exportFunction.name}('${KEY_CHANGE_SCHEMA}')`)).get();
2636
+ if (exportFunction.name === SQLCIPHER_EXPORT) await copyRowidsAfterSqlcipherExport(snapshot, KEY_CHANGE_SCHEMA);
2637
+ const tables = await (await snapshot.prepare(
2638
+ `SELECT name FROM ${KEY_CHANGE_SCHEMA}.sqlite_schema WHERE type = 'table' AND sql NOT LIKE 'CREATE VIRTUAL%'`
2639
+ )).all();
2640
+ let rowsCopied = 0n;
2641
+ for (const { name } of tables) {
2642
+ rowsCopied += await readInteger(snapshot, `SELECT count(*) FROM ${KEY_CHANGE_SCHEMA}."${escId(name)}"`);
2643
+ }
2644
+ const userVersion = await readInteger(snapshot, "PRAGMA main.user_version");
2645
+ const applicationId = await readInteger(snapshot, "PRAGMA main.application_id");
2646
+ await snapshot.exec(`PRAGMA ${KEY_CHANGE_SCHEMA}.user_version = ${userVersion}`);
2647
+ await snapshot.exec(`PRAGMA ${KEY_CHANGE_SCHEMA}.application_id = ${applicationId}`);
2648
+ return Number(rowsCopied);
2649
+ }
2650
+ async function detachNewFile(snapshot) {
2651
+ await snapshot.exec(`DETACH DATABASE ${KEY_CHANGE_SCHEMA}`);
2652
+ }
2653
+ var ORIGINAL_SCHEMA = "sirannon_key_change_original";
2654
+ var UNCAPTURED_TABLES = ["sqlite_sequence", "sqlite_stat1", "sqlite_stat4"];
2655
+ function attachOriginal(target, path, key) {
2656
+ return attachUnderKey(target, path, ORIGINAL_SCHEMA, key);
2657
+ }
2658
+ async function detachOriginal(target) {
2659
+ await target.exec(`DETACH DATABASE ${ORIGINAL_SCHEMA}`);
2660
+ }
2661
+ async function tableExists(conn, schema, name) {
2662
+ const row = await (await conn.prepare(`SELECT 1 FROM ${schema}.sqlite_schema WHERE type = 'table' AND name = ?`)).get(
2663
+ name
2664
+ );
2665
+ return row !== void 0;
2666
+ }
2667
+ async function copyUncapturedTables(target) {
2668
+ for (const name of UNCAPTURED_TABLES) {
2669
+ const inOriginal = await tableExists(target, ORIGINAL_SCHEMA, name);
2670
+ const inNewFile = await tableExists(target, "main", name);
2671
+ if (inOriginal !== inNewFile) {
2672
+ throw new EncryptionError(
2673
+ `The table ${name} exists in only one of the original and the new file`,
2674
+ "ENCRYPTION_CHANGE_ABORTED"
2675
+ );
2676
+ }
2677
+ if (!inOriginal) continue;
2678
+ await target.exec(`DELETE FROM main.${name}; INSERT INTO main.${name} SELECT * FROM ${ORIGINAL_SCHEMA}.${name}`);
2679
+ }
2680
+ }
2681
+
2682
+ // src/core/encryption/key-change-trigger-capture.ts
2683
+ var SMALL_TRIGGER_ROUND_KEYS = 1024;
2684
+ function quoted(name) {
2685
+ return `"${escId(name)}"`;
2686
+ }
2687
+ async function describeTable(writer, row, position) {
2688
+ const columns = await (await writer.prepare(`PRAGMA main.table_xinfo(${quoted(row.name)})`)).all();
2689
+ const stored = columns.filter((column) => Number(column.hidden) === 0).map((column) => quoted(column.name));
2690
+ const withoutRowid = Number(row.wr) === 1;
2691
+ const alias = withoutRowid ? null : freeRowidAlias(row.name, columns);
2692
+ const keys = alias === null ? columns.filter((column) => Number(column.pk) > 0).sort((a, b) => Number(a.pk) - Number(b.pk)).map((column) => quoted(column.name)) : [alias];
2693
+ return {
2694
+ name: row.name,
2695
+ quoted: quoted(row.name),
2696
+ keys,
2697
+ columns: alias === null ? stored : [alias, ...stored],
2698
+ capture: keyChangeCaptureTables(position, keys.length),
2699
+ triggers: {
2700
+ insert: keyChangeCaptureTriggerName(position, "insert"),
2701
+ update: keyChangeCaptureTriggerName(position, "update"),
2702
+ delete: keyChangeCaptureTriggerName(position, "delete")
2703
+ }
2704
+ };
2705
+ }
2706
+ async function installCapture(writer, table) {
2707
+ await ensureKeyChangeLogTable(writer, table.capture);
2708
+ await installKeyChangeCaptureTriggers(writer, table.triggers, table.name, table.capture.log, table.keys);
2709
+ }
2710
+ async function drainTable(writer, table) {
2711
+ const keys = await selectDistinctKeyChangeLogKeys(writer, table.capture);
2712
+ if (keys.length > 0) await deleteKeyChangeLogKeys(writer, table.capture);
2713
+ return keys;
2714
+ }
2715
+ async function stageKeys(target, table, keys) {
2716
+ await ensureEmptyKeyChangeTouchedTable(target, table.capture);
2717
+ const insert = await prepareInsertKeyChangeTouchedKey(target, table.capture);
2718
+ for (const key of keys) {
2719
+ await insert.run(...table.capture.slots.flatMap((slot) => [key[`t_${slot}`], key[`v_${slot}`], key[`v_${slot}`]]));
2720
+ }
2721
+ }
2722
+ function touchedRows(table) {
2723
+ return `(${table.keys.join(", ")}) IN (${selectKeyChangeTouchedKeysSql(table.capture)})`;
2724
+ }
2725
+ async function applyKeys(target, drained) {
2726
+ if (drained.size === 0) return;
2727
+ await target.exec("BEGIN IMMEDIATE");
2728
+ try {
2729
+ for (const [table, keys] of drained) await stageKeys(target, table, keys);
2730
+ for (const table of drained.keys())
2731
+ await target.exec(`DELETE FROM main.${table.quoted} WHERE ${touchedRows(table)}`);
2732
+ for (const table of drained.keys()) {
2733
+ const columns = table.columns.join(", ");
2734
+ await target.exec(
2735
+ `INSERT INTO main.${table.quoted} (${columns}) SELECT ${columns} FROM ${ORIGINAL_SCHEMA}.${table.quoted} WHERE ${touchedRows(table)}`
2736
+ );
2737
+ }
2738
+ await target.exec("COMMIT");
2739
+ } catch (err) {
2740
+ await target.exec("ROLLBACK").catch(() => void 0);
2741
+ if (err instanceof SirannonError) throw err;
2742
+ throw keyChangeAborted("A write committed during the key change could not be applied exactly to the new file", err);
2743
+ }
2744
+ }
2745
+ async function rowCount(conn, schema, table) {
2746
+ const row = await (await conn.prepare(`SELECT CAST(count(*) AS TEXT) AS n FROM ${schema}.${table}`)).get();
2747
+ return row.n;
2748
+ }
2749
+ async function copyShadowTables(target, shadows) {
2750
+ for (const shadow of shadows) {
2751
+ await target.exec(
2752
+ `DELETE FROM main.${shadow}; INSERT INTO main.${shadow} SELECT * FROM ${ORIGINAL_SCHEMA}.${shadow}`
2753
+ );
2754
+ }
2755
+ }
2756
+ async function assertCountsMatch(target, tables) {
2757
+ for (const table of tables) {
2758
+ if (await rowCount(target, "main", table) !== await rowCount(target, ORIGINAL_SCHEMA, table)) {
2759
+ throw keyChangeAborted(
2760
+ `The new file holds a different number of rows in ${table} than the original, because a write removed rows without running its triggers`
2761
+ );
2762
+ }
2763
+ }
2764
+ }
2765
+ async function startTriggerCapture(writer) {
2766
+ const listed = await (await writer.prepare(
2767
+ "SELECT name, type, wr FROM pragma_table_list WHERE schema = 'main' AND type IN ('table', 'shadow') AND name NOT LIKE 'sqlite_%'"
2768
+ )).all();
2769
+ const regular = listed.filter((row) => row.type === "table");
2770
+ const shadows = listed.filter((row) => row.type === "shadow").map((row) => quoted(row.name));
2771
+ const tables = [];
2772
+ for (const row of regular) tables.push(await describeTable(writer, row, tables.length));
2773
+ const stop = async () => {
2774
+ for (const table of tables) {
2775
+ await dropKeyChangeCaptureTriggers(writer, table.triggers);
2776
+ await dropKeyChangeLogTable(writer, table.capture);
2777
+ }
2778
+ };
2779
+ try {
2780
+ for (const table of tables) await installCapture(writer, table);
2781
+ } catch (err) {
2782
+ await stop().catch(() => void 0);
2783
+ throw err;
2784
+ }
2785
+ return {
2786
+ async drain(current) {
2787
+ assertSameWriter(writer, current);
2788
+ const drained = /* @__PURE__ */ new Map();
2789
+ let total = 0;
2790
+ for (const table of tables) {
2791
+ const keys = await drainTable(writer, table);
2792
+ if (keys.length === 0) continue;
2793
+ drained.set(table, keys);
2794
+ total += keys.length;
2795
+ }
2796
+ return { small: total <= SMALL_TRIGGER_ROUND_KEYS, apply: (target) => applyKeys(target, drained) };
2797
+ },
2798
+ async finish(target) {
2799
+ await copyShadowTables(target, shadows);
2800
+ await assertCountsMatch(target, [...tables.map((table) => table.quoted), ...shadows]);
2801
+ },
2802
+ stop
2803
+ };
2804
+ }
2805
+
2806
+ // src/core/encryption/key-change-capture.ts
2807
+ var SMALL_SESSION_ROUND_BYTES = 256 * 1024;
2808
+ async function applySession(target, changes) {
2809
+ if (changes.byteLength === 0) return;
2810
+ const apply = target.applyCapturedChanges;
2811
+ if (!apply) {
2812
+ throw new EncryptionError(
2813
+ "The driver opens connections that cannot apply recorded changes, so a key change cannot run on it",
2814
+ "ENCRYPTION_UNSUPPORTED"
2815
+ );
2816
+ }
2817
+ try {
2818
+ await apply.call(target, changes);
2819
+ } catch (err) {
2820
+ if (err instanceof SirannonError) throw err;
2821
+ throw keyChangeAborted("A write committed during the key change could not be applied exactly to the new file", err);
2822
+ }
2823
+ }
2824
+ function startSessionCapture(writer, take, stop) {
2825
+ return {
2826
+ async drain(current) {
2827
+ assertSameWriter(writer, current);
2828
+ const changes = await take();
2829
+ return {
2830
+ small: changes.byteLength <= SMALL_SESSION_ROUND_BYTES,
2831
+ apply: (target) => applySession(target, changes)
2832
+ };
2833
+ },
2834
+ finish: async () => void 0,
2835
+ stop
2836
+ };
2837
+ }
2838
+ async function startKeyChangeCapture(writer) {
2839
+ const { startChangeCapture, takeCapturedChanges, stopChangeCapture } = writer;
2840
+ if (!startChangeCapture || !takeCapturedChanges || !stopChangeCapture) return startTriggerCapture(writer);
2841
+ await startChangeCapture.call(writer);
2842
+ return startSessionCapture(
2843
+ writer,
2844
+ () => takeCapturedChanges.call(writer),
2845
+ () => stopChangeCapture.call(writer)
2846
+ );
2847
+ }
2848
+
2849
+ // src/core/encryption/key-change-files.ts
2850
+ var FILE_IDENTITY_BYTES = 16;
2851
+ var NEW_FILE_SUFFIX = ".sirannon-key-change";
2852
+ var MARKER_SUFFIX = ".sirannon-key-change-marker";
2853
+ var SIDE_FILE_SUFFIXES = ["", "-journal", "-wal", "-shm"];
2854
+ function keyChangeFilePath(path) {
2855
+ return `${path}${NEW_FILE_SUFFIX}`;
2856
+ }
2857
+ function keyChangeMarkerPath(path) {
2858
+ return `${path}${MARKER_SUFFIX}`;
2859
+ }
2860
+ async function removeKeyChangeFile(files, path) {
2861
+ const newFile = keyChangeFilePath(path);
2862
+ for (const suffix of SIDE_FILE_SUFFIXES) await files.remove(`${newFile}${suffix}`);
2863
+ }
2864
+ function sameBytes(a, b) {
2865
+ if (a.length !== b.length) return false;
2866
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2867
+ return true;
2868
+ }
2869
+
2870
+ // src/core/encryption/key-change-marker.ts
2871
+ var MARKER_TABLE = "key_change_marker";
2872
+ var MARKER_JOURNAL_SUFFIX = "-journal";
2873
+ var HEX_DIGITS2 = "0123456789abcdef";
2874
+ var HEX_BYTE = /^[0-9a-f]{2}$/;
2875
+ function hexOf(bytes) {
2876
+ let hex = "";
2877
+ for (const byte of bytes) hex += HEX_DIGITS2[byte >> 4] + HEX_DIGITS2[byte & 15];
2878
+ return hex;
2879
+ }
2880
+ function bytesOfHex(hex) {
2881
+ if (hex.length % 2 !== 0) return null;
2882
+ const bytes = new Uint8Array(hex.length / 2);
2883
+ for (let i = 0; i < bytes.length; i++) {
2884
+ const pair = hex.slice(i * 2, i * 2 + 2);
2885
+ if (!HEX_BYTE.test(pair)) return null;
2886
+ bytes[i] = Number.parseInt(pair, 16);
2887
+ }
2888
+ return bytes;
2889
+ }
2890
+ function openMarker(driver, path, readonly) {
2891
+ return driver.open(keyChangeMarkerPath(path), { readonly, walMode: false, synchronous: "full" });
2892
+ }
2893
+ async function bumpWrites(conn, setBegun) {
2894
+ const begun = setBegun ? ", replacement_begun = 1" : "";
2895
+ await conn.exec(`UPDATE ${MARKER_TABLE} SET writes = writes + 1${begun}`);
2896
+ }
2897
+ async function removeKeyChangeMarker(files, path) {
2898
+ const markerPath = keyChangeMarkerPath(path);
2899
+ await files.remove(markerPath);
2900
+ await files.remove(`${markerPath}${MARKER_JOURNAL_SUFFIX}`);
2901
+ }
2902
+ async function createKeyChangeMarker(driver, path, identity) {
2903
+ const conn = await openMarker(driver, path, false);
2904
+ try {
2905
+ await conn.exec("PRAGMA journal_mode = DELETE");
2906
+ await conn.exec("PRAGMA synchronous = FULL");
2907
+ await conn.exec(
2908
+ `BEGIN IMMEDIATE; CREATE TABLE ${MARKER_TABLE} (identity TEXT NOT NULL, replacement_begun INTEGER NOT NULL, writes INTEGER NOT NULL); INSERT INTO ${MARKER_TABLE} VALUES ('${hexOf(identity)}', 0, 1); COMMIT`
2909
+ );
2910
+ } catch (err) {
2911
+ await conn.close().catch(() => void 0);
2912
+ throw err;
2913
+ }
2914
+ return {
2915
+ recordReplacementBegun: () => bumpWrites(conn, true),
2916
+ flushDirectory: () => bumpWrites(conn, false),
2917
+ close: () => conn.close()
2918
+ };
2919
+ }
2920
+ async function readKeyChangeMarker(driver, path, readonly) {
2921
+ let conn;
2922
+ try {
2923
+ conn = await openMarker(driver, path, readonly);
2924
+ } catch {
2925
+ return null;
2926
+ }
2927
+ try {
2928
+ const row = await (await conn.prepare(`SELECT identity, replacement_begun FROM ${MARKER_TABLE} LIMIT 1`)).get();
2929
+ if (row === void 0 || typeof row.identity !== "string") return null;
2930
+ const identity = bytesOfHex(row.identity);
2931
+ if (identity === null) return null;
2932
+ return { identity, replacementBegun: Number(row.replacement_begun) === 1 };
2933
+ } catch {
2934
+ return null;
2935
+ } finally {
2936
+ await conn.close().catch(() => void 0);
2937
+ }
2938
+ }
2939
+ async function flushDirectoryThroughMarker(driver, path) {
2940
+ const conn = await openMarker(driver, path, false);
2941
+ try {
2942
+ await bumpWrites(conn, false);
2943
+ } finally {
2944
+ await conn.close();
2945
+ }
2946
+ }
2947
+
2948
+ // src/core/encryption/key-metadata.ts
2949
+ var ENCRYPTION_KEY_ID_META_KEY = "encryption_key_id";
2950
+ var ENCRYPTION_CHANGED_AT_META_KEY = "encryption_changed_at";
2951
+ async function readKeyMetadata(conn) {
2952
+ if (!await selectTableExists(conn, META_TABLE)) return {};
2953
+ const keyId = await selectMetaValue(conn, ENCRYPTION_KEY_ID_META_KEY);
2954
+ const changedAt = Number(await selectMetaValue(conn, ENCRYPTION_CHANGED_AT_META_KEY));
2955
+ return {
2956
+ ...keyId === null ? {} : { keyId },
2957
+ ...Number.isSafeInteger(changedAt) && changedAt > 0 ? { changedAt } : {}
2958
+ };
2959
+ }
2960
+ async function writeKeyId(conn, keyId) {
2961
+ const stored = await selectTableExists(conn, META_TABLE) ? await selectMetaValue(conn, ENCRYPTION_KEY_ID_META_KEY) : null;
2962
+ if ((stored ?? void 0) === keyId) return;
2963
+ if (keyId === void 0) {
2964
+ await deleteMetaValue(conn, ENCRYPTION_KEY_ID_META_KEY);
2965
+ return;
2966
+ }
2967
+ await ensureMetaTable(conn);
2968
+ await upsertMetaValue(conn, ENCRYPTION_KEY_ID_META_KEY, keyId);
2969
+ }
2970
+ async function writeKeyChange(conn, keyId, changedAt) {
2971
+ await ensureMetaTable(conn);
2972
+ await upsertMetaValue(conn, ENCRYPTION_CHANGED_AT_META_KEY, String(changedAt));
2973
+ if (keyId === void 0) await deleteMetaValue(conn, ENCRYPTION_KEY_ID_META_KEY);
2974
+ else await upsertMetaValue(conn, ENCRYPTION_KEY_ID_META_KEY, keyId);
2975
+ }
2976
+
2977
+ // src/core/encryption/key-change-job.ts
2978
+ var MAX_CATCH_UP_ROUNDS = 16;
2979
+ var READ_CONNECTION_DRAIN_TIMEOUT_MS = 3e4;
2980
+ async function reopenPool(pool, cdc) {
2981
+ await pool.reopen();
2982
+ cdc.resumeOnReopenedPool();
2983
+ }
2984
+ async function assertSchemaUnchanged(writer, expected) {
2985
+ if (await readSchemaVersion2(writer) !== expected) {
2986
+ throw keyChangeAborted(
2987
+ "The schema changed while the key change copied the database, so the new file would miss that change"
2988
+ );
2989
+ }
2990
+ }
2991
+ async function checkpointTruncate(writer) {
2992
+ const row = await (await writer.prepare("PRAGMA wal_checkpoint(TRUNCATE)")).get();
2993
+ const busy = row === void 0 ? 0 : Number(Object.values(row)[0]);
2994
+ if (busy !== 0) {
2995
+ throw keyChangeAborted("Another connection held the write-ahead log open, so the key change could not empty it");
2996
+ }
2997
+ }
2998
+ function requireFiles(driver, path) {
2999
+ if (driver.files === void 0 || path === ":memory:" || path === "" || path.startsWith("file:")) {
3000
+ throw new EncryptionError(
3001
+ "A key change needs a database file on a driver that supplies file operations",
3002
+ "ENCRYPTION_UNSUPPORTED"
3003
+ );
3004
+ }
3005
+ return driver.files;
3006
+ }
3007
+ var KeyChangeRun = class {
3008
+ /**
3009
+ * Checks that the database can change its key, without touching any file.
3010
+ *
3011
+ * @param deps - The parts of the open database.
3012
+ * @param target - The key function of the target key, or `null` for a plaintext file.
3013
+ * @param progress - Receives the progress, and reports a close of the database.
3014
+ * @throws An `EncryptionError` with `ENCRYPTION_UNSUPPORTED` when the driver or the path cannot hold a key change.
3015
+ */
3016
+ constructor(deps, target, progress) {
3017
+ this.deps = deps;
3018
+ this.target = target;
3019
+ this.progress = progress;
3020
+ this.files = requireFiles(deps.driver, deps.path);
3021
+ this.newPath = keyChangeFilePath(deps.path);
3022
+ this.targetRing = new EncryptionKeyring(target);
3023
+ }
3024
+ files;
3025
+ newPath;
3026
+ targetRing;
3027
+ snapshot = null;
3028
+ targetConn = null;
3029
+ capture = null;
3030
+ marker = null;
3031
+ triggers = [];
3032
+ schemaVersion = 0n;
3033
+ rounds = 0;
3034
+ connectionsClosed = false;
3035
+ cyclePaused = false;
3036
+ copiesPaused = false;
3037
+ chainForgotten = false;
3038
+ replaced = false;
3039
+ stranded = false;
3040
+ nativePath = "";
3041
+ /**
3042
+ * Runs the key change to the end.
3043
+ *
3044
+ * @returns `null` when every step succeeds, or the failure that came after the new file was in place, which leaves
3045
+ * the change done and the file under the target key.
3046
+ * @throws An `EncryptionError` with `ENCRYPTION_CHANGE_ABORTED` when the change stops, which leaves the database
3047
+ * under the key that it had, or the error of the target key function.
3048
+ */
3049
+ async run() {
3050
+ try {
3051
+ await this.copySnapshot();
3052
+ await this.markNewFile();
3053
+ await this.catchUp();
3054
+ await this.replaceFile();
3055
+ return null;
3056
+ } catch (err) {
3057
+ await this.cleanUp();
3058
+ if (this.replaced) return { cause: err };
3059
+ if (err instanceof SirannonError) throw err;
3060
+ if (this.stranded) {
3061
+ throw keyChangeAborted(
3062
+ "The key change stopped while it moved the new file into place, so the database stays closed until the next open finishes the move",
3063
+ err
3064
+ );
3065
+ }
3066
+ throw keyChangeAborted("The key change stopped, and the database keeps the key that it had", err);
3067
+ }
3068
+ }
3069
+ checkOpen() {
3070
+ if (this.progress.cancelled) throw keyChangeAborted("The database closed while the key change ran");
3071
+ }
3072
+ requireCapture() {
3073
+ if (this.capture === null)
3074
+ throw keyChangeAborted("The key change lost its record of the writes made since the copy");
3075
+ return this.capture;
3076
+ }
3077
+ requireTarget() {
3078
+ if (this.targetConn === null) throw keyChangeAborted("The key change lost its connection to the new file");
3079
+ return this.targetConn;
3080
+ }
3081
+ requireMarker() {
3082
+ if (this.marker === null) throw keyChangeAborted("The key change lost its marker");
3083
+ return this.marker;
3084
+ }
3085
+ async copySnapshot() {
3086
+ const { pool, writerLock, fileConnections, path } = this.deps;
3087
+ await removeKeyChangeFile(this.files, path);
3088
+ await removeKeyChangeMarker(this.files, path);
3089
+ const snapshot = await fileConnections.open({ walMode: false, walAutoCheckpoint: 0 });
3090
+ this.snapshot = snapshot;
3091
+ this.nativePath = await readMainDatabasePath(snapshot);
3092
+ if (this.nativePath === "") throw keyChangeAborted("SQLite returned no path for the database file");
3093
+ const targetKey = this.target === null ? void 0 : await resolveEncryptionKey(this.target);
3094
+ try {
3095
+ await attachNewFile(snapshot, keyChangeFilePath(this.nativePath), targetKey?.bytes);
3096
+ } finally {
3097
+ wipeEncryptionKey(targetKey);
3098
+ }
3099
+ this.schemaVersion = await writerLock.run(async () => {
3100
+ this.capture = await startKeyChangeCapture(pool.acquireWriter());
3101
+ await snapshot.exec("BEGIN");
3102
+ await (await snapshot.prepare("SELECT count(*) FROM sqlite_schema")).get();
3103
+ return readSchemaVersion2(snapshot);
3104
+ });
3105
+ const rowsCopied = await exportSnapshot(snapshot);
3106
+ await snapshot.exec("COMMIT");
3107
+ await detachNewFile(snapshot);
3108
+ this.snapshot = null;
3109
+ await snapshot.close();
3110
+ this.progress.update({ rowsCopied });
3111
+ this.checkOpen();
3112
+ }
3113
+ async markNewFile() {
3114
+ const { driver, path, keyring } = this.deps;
3115
+ const identity = await this.files.readStart(this.newPath, FILE_IDENTITY_BYTES);
3116
+ if (identity === null || identity.length !== FILE_IDENTITY_BYTES) {
3117
+ throw keyChangeAborted("The key change wrote a new file too short to hold a database");
3118
+ }
3119
+ this.marker = await createKeyChangeMarker(driver, path, identity);
3120
+ this.targetConn = await this.targetRing.openWith(
3121
+ (keyed) => driver.open(this.newPath, { walMode: false, synchronous: "full", ...keyed })
3122
+ );
3123
+ await this.targetConn.exec("PRAGMA foreign_keys = OFF");
3124
+ this.triggers = await dropMainSchemaTriggers(this.targetConn);
3125
+ const source = keyring.keySource;
3126
+ const originalKey = source === null ? void 0 : await resolveEncryptionKey(source);
3127
+ try {
3128
+ await attachOriginal(this.targetConn, this.nativePath, originalKey?.bytes);
3129
+ } finally {
3130
+ wipeEncryptionKey(originalKey);
3131
+ }
3132
+ }
3133
+ async catchUp() {
3134
+ const { pool, writerLock } = this.deps;
3135
+ this.progress.update({ state: "catching-up", rounds: 0 });
3136
+ for (let round = 0; round < MAX_CATCH_UP_ROUNDS; round++) {
3137
+ this.checkOpen();
3138
+ const drained = await writerLock.run(async () => {
3139
+ const writer = pool.acquireWriter();
3140
+ await assertSchemaUnchanged(writer, this.schemaVersion);
3141
+ return this.requireCapture().drain(writer);
3142
+ });
3143
+ await drained.apply(this.requireTarget());
3144
+ this.rounds++;
3145
+ this.progress.update({ rounds: this.rounds });
3146
+ if (drained.small) return;
3147
+ }
3148
+ }
3149
+ async replaceFile() {
3150
+ const { backups, fileConnections, writerLock } = this.deps;
3151
+ this.checkOpen();
3152
+ this.progress.update({ state: "swapping" });
3153
+ this.cyclePaused = await backups.pauseCycle();
3154
+ this.copiesPaused = true;
3155
+ await backups.pauseCopies();
3156
+ this.connectionsClosed = true;
3157
+ await fileConnections.closeForReplacement(READ_CONNECTION_DRAIN_TIMEOUT_MS);
3158
+ this.checkOpen();
3159
+ await writerLock.run(() => this.finishWithWritesHeld());
3160
+ this.connectionsClosed = false;
3161
+ await fileConnections.reopenAfterReplacement();
3162
+ this.copiesPaused = false;
3163
+ backups.resumeCopies();
3164
+ if (this.cyclePaused) await backups.resumeCycle({ newChain: true });
3165
+ }
3166
+ async finishNewFile(writer) {
3167
+ const target = this.requireTarget();
3168
+ const capture = this.requireCapture();
3169
+ await assertSchemaUnchanged(writer, this.schemaVersion);
3170
+ await (await capture.drain(writer)).apply(target);
3171
+ this.rounds++;
3172
+ this.progress.update({ rounds: this.rounds });
3173
+ await capture.finish(target);
3174
+ await copyUncapturedTables(target);
3175
+ await createMainSchemaTriggers(target, this.triggers);
3176
+ await detachOriginal(target);
3177
+ await writeKeyChange(target, this.targetRing.keyId, Date.now());
3178
+ this.targetConn = null;
3179
+ await target.close();
3180
+ await capture.stop();
3181
+ this.capture = null;
3182
+ }
3183
+ async finishWithWritesHeld() {
3184
+ const { pool, path, keyring, cdc } = this.deps;
3185
+ const writer = pool.acquireWriter();
3186
+ await this.finishNewFile(writer);
3187
+ const marker = this.requireMarker();
3188
+ await pool.closeReaders(READ_CONNECTION_DRAIN_TIMEOUT_MS);
3189
+ if (this.cyclePaused) {
3190
+ await this.deps.backups.forgetPausedChain();
3191
+ this.chainForgotten = true;
3192
+ }
3193
+ await marker.recordReplacementBegun();
3194
+ try {
3195
+ await checkpointTruncate(writer);
3196
+ await pool.closeWriter();
3197
+ await this.files.remove(`${path}-wal`);
3198
+ await this.files.remove(`${path}-shm`);
3199
+ await this.files.rename(this.newPath, path);
3200
+ this.replaced = true;
3201
+ await marker.flushDirectory();
3202
+ } catch (err) {
3203
+ await this.recoverFromFailedSwap();
3204
+ throw err;
3205
+ }
3206
+ keyring.replace(this.target);
3207
+ await reopenPool(pool, cdc);
3208
+ this.marker = null;
3209
+ await marker.close();
3210
+ await removeKeyChangeMarker(this.files, path);
3211
+ }
3212
+ async recoverFromFailedSwap() {
3213
+ const { pool, path, keyring, cdc } = this.deps;
3214
+ if (!this.replaced && await this.files.readStart(path, 1) === null) {
3215
+ try {
3216
+ await this.files.rename(this.newPath, path);
3217
+ this.replaced = true;
3218
+ } catch {
3219
+ this.stranded = true;
3220
+ return;
3221
+ }
3222
+ }
3223
+ if (this.replaced) keyring.replace(this.target);
3224
+ await reopenPool(pool, cdc);
3225
+ }
3226
+ async cleanUp() {
3227
+ const { pool, writerLock, fileConnections, backups, keyring, path, cdc } = this.deps;
3228
+ const snapshot = this.snapshot;
3229
+ if (snapshot !== null) {
3230
+ await snapshot.exec("ROLLBACK").catch(() => void 0);
3231
+ await detachNewFile(snapshot).catch(() => void 0);
3232
+ await snapshot.close().catch(() => void 0);
3233
+ }
3234
+ await this.targetConn?.close().catch(() => void 0);
3235
+ const capture = this.capture;
3236
+ if (capture !== null) await writerLock.run(() => capture.stop()).catch(() => void 0);
3237
+ await this.marker?.close().catch(() => void 0);
3238
+ this.marker = null;
3239
+ if (this.stranded) {
3240
+ if (this.copiesPaused) backups.resumeCopies();
3241
+ return;
3242
+ }
3243
+ if (this.replaced) keyring.replace(this.target);
3244
+ await writerLock.run(async () => {
3245
+ if (!pool.suspended) return;
3246
+ await reopenPool(pool, cdc);
3247
+ }).catch(() => void 0);
3248
+ if (!this.replaced) {
3249
+ await removeKeyChangeFile(this.files, path).catch(() => void 0);
3250
+ await removeKeyChangeMarker(this.files, path).catch(() => void 0);
3251
+ }
3252
+ if (this.connectionsClosed) await fileConnections.reopenAfterReplacement();
3253
+ if (this.copiesPaused) backups.resumeCopies();
3254
+ if (this.cyclePaused) {
3255
+ await backups.resumeCycle({ newChain: this.replaced || this.chainForgotten }).catch(() => void 0);
3256
+ }
3257
+ }
3258
+ };
3259
+
3260
+ // src/core/encryption/controller.ts
3261
+ var AEGIS_CIPHER_NAME = "aegis";
3262
+ var AEGIS_256_ALGORITHM_NAME = "aegis-256";
3263
+ function changeInProgress() {
3264
+ return new EncryptionError(
3265
+ "A key change is already running on this database; wait for it to end before you start another",
3266
+ "ENCRYPTION_CHANGE_IN_PROGRESS"
3267
+ );
3268
+ }
3269
+ function engineDuringChange() {
3270
+ return new EncryptionError(
3271
+ "A key change is replacing the file of this database, so a replication engine would stay on the replaced file; start the engine once the change ends",
3272
+ "ENCRYPTION_CHANGE_IN_PROGRESS"
3273
+ );
3274
+ }
3275
+ function describeReopenFailure(err) {
3276
+ if (err instanceof SirannonError) return { code: err.code, message: err.message };
3277
+ return {
3278
+ code: "DATABASE_OPEN_FAILED",
3279
+ message: "The database could not open its connections again after the key change put the new file in place; close it and open it with the new key"
3280
+ };
3281
+ }
3282
+ function invalidChange() {
3283
+ return new EncryptionError(
3284
+ "changeEncryption takes { key }, where key is a function that returns 32 key bytes, or null to remove encryption",
3285
+ "INVALID_ENCRYPTION_KEY"
3286
+ );
3287
+ }
3288
+ async function readText(conn, sql) {
3289
+ const row = await (await conn.prepare(sql)).get();
3290
+ const value = row === void 0 ? void 0 : Object.values(row)[0];
3291
+ return typeof value === "string" && value.length > 0 ? value : void 0;
3292
+ }
3293
+ async function readCipher(conn) {
3294
+ const hasEngineVersion = await readText(
3295
+ conn,
3296
+ "SELECT 'present' FROM pragma_function_list WHERE name = 'sqlite3mc_version'"
3297
+ );
3298
+ if (hasEngineVersion === void 0) {
3299
+ const sqlcipherVersion = await readText(conn, "PRAGMA cipher_version");
3300
+ return sqlcipherVersion === void 0 ? {} : { cipher: "sqlcipher-4", cipherVersion: sqlcipherVersion };
3301
+ }
3302
+ const engineVersion = await readText(conn, "SELECT sqlite3mc_version()");
3303
+ const cipher = await readText(conn, "PRAGMA cipher");
3304
+ const algorithm = await readText(conn, "PRAGMA algorithm");
3305
+ return {
3306
+ ...cipher === AEGIS_CIPHER_NAME && algorithm === AEGIS_256_ALGORITHM_NAME ? { cipher: "aegis-256" } : {},
3307
+ ...engineVersion === void 0 ? {} : { cipherVersion: engineVersion }
3308
+ };
3309
+ }
3310
+ var DatabaseEncryptionController = class {
3311
+ constructor(deps) {
3312
+ this.deps = deps;
3313
+ }
3314
+ change = { state: "idle" };
3315
+ running = null;
3316
+ closing = false;
3317
+ described = null;
3318
+ /** `true` while the database file opens under a key. */
3319
+ get encrypted() {
3320
+ return this.deps.keyring.encrypted;
3321
+ }
3322
+ /**
3323
+ * Returns whether the file is encrypted, the scheme that it is encrypted under, the engine's version, the stored
3324
+ * key identifier and change time, and the current or last key change.
3325
+ *
3326
+ * @returns The status.
3327
+ */
3328
+ async status() {
3329
+ const encrypted = this.deps.keyring.encrypted;
3330
+ if (this.described === null && !this.deps.pool.suspended) this.described = await this.describeFile(encrypted);
3331
+ const described = this.described ?? {};
3332
+ return {
3333
+ encrypted,
3334
+ ...encrypted && described.cipher !== void 0 ? { cipher: described.cipher } : {},
3335
+ ...encrypted && described.cipherVersion !== void 0 ? { cipherVersion: described.cipherVersion } : {},
3336
+ ...encrypted && described.keyId !== void 0 ? { keyId: described.keyId } : {},
3337
+ ...described.changedAt === void 0 ? {} : { changedAt: described.changedAt },
3338
+ change: { ...this.change }
3339
+ };
3340
+ }
3341
+ /**
3342
+ * Encrypts the database, changes its key, or removes encryption, and resolves once the change ends.
3343
+ *
3344
+ * @param change - The target key function, or `null` for a plaintext file.
3345
+ * @returns The status after the change.
3346
+ * @throws `READ_ONLY` on a read-only database, `ENCRYPTION_CHANGE_IN_PROGRESS` while another change runs,
3347
+ * `REPLICATION_ACTIVE` while a replication engine writes to the database, and the error that stopped the change.
3348
+ */
3349
+ async changeKey(change) {
3350
+ if (this.deps.readOnly) throw new ReadOnlyError(this.deps.databaseId);
3351
+ const target = this.targetOf(change);
3352
+ if (this.running !== null) throw changeInProgress();
3353
+ if (!this.deps.driver.capabilities.encryption) {
3354
+ throw new EncryptionError(
3355
+ "The driver that this Sirannon uses cannot encrypt a database, so it cannot change its key",
3356
+ "ENCRYPTION_UNSUPPORTED"
3357
+ );
3358
+ }
3359
+ const endReplacement = this.deps.replicationEngines.beginReplacement("key change", engineDuringChange);
3360
+ try {
3361
+ return await this.runChange(target);
3362
+ } finally {
3363
+ endReplacement();
3364
+ }
3365
+ }
3366
+ async runChange(target) {
3367
+ const isClosing = () => this.closing;
3368
+ const progress = {
3369
+ update: (fields) => {
3370
+ this.change = { ...this.change, ...fields };
3371
+ },
3372
+ get cancelled() {
3373
+ return isClosing();
3374
+ }
3375
+ };
3376
+ const run = new KeyChangeRun(this.deps, target, progress);
3377
+ this.change = { state: "copying", target: target === null ? "plaintext" : "encrypted", startedAt: Date.now() };
3378
+ const outcome = run.run().then(
3379
+ (reopenFailure) => this.finished(target, reopenFailure),
3380
+ (err) => this.failed(err)
3381
+ );
3382
+ this.running = outcome.then(() => void 0);
3383
+ try {
3384
+ const failure = await outcome;
3385
+ if (failure !== null) throw failure;
3386
+ } finally {
3387
+ this.running = null;
3388
+ }
3389
+ return this.status();
3390
+ }
3391
+ /** Stops a running key change at its next step, and waits for it to leave the database as it was. */
3392
+ async cancel() {
3393
+ this.closing = true;
3394
+ await this.running;
3395
+ }
3396
+ targetOf(change) {
3397
+ if (typeof change !== "object" || change === null || !("key" in change)) throw invalidChange();
3398
+ if (change.key === null) return null;
3399
+ if (typeof change.key !== "function") throw invalidChange();
3400
+ return change.key;
3401
+ }
3402
+ finished(target, reopenFailure) {
3403
+ this.change = {
3404
+ ...this.change,
3405
+ state: "done",
3406
+ finishedAt: Date.now(),
3407
+ ...reopenFailure === null ? {} : { reopenError: describeReopenFailure(reopenFailure.cause) }
3408
+ };
3409
+ this.described = null;
3410
+ this.deps.onKeyChanged?.(target);
3411
+ return null;
3412
+ }
3413
+ failed(err) {
3414
+ const code = err instanceof SirannonError ? err.code : "ENCRYPTION_CHANGE_ABORTED";
3415
+ const message = err instanceof Error ? err.message : "The key change stopped";
3416
+ this.change = { ...this.change, state: "failed", finishedAt: Date.now(), error: { code, message } };
3417
+ return err;
3418
+ }
3419
+ async describeFile(encrypted) {
3420
+ const { pool, writerLock } = this.deps;
3421
+ const read = async (conn) => {
3422
+ const metadata = await readKeyMetadata(conn);
3423
+ return { ...metadata, ...encrypted ? await readCipher(conn) : {} };
3424
+ };
3425
+ if (pool.readerCount > 0) return pool.withReader(read);
3426
+ return writerLock.run(() => read(pool.acquireReader()));
3427
+ }
3428
+ };
3429
+
3430
+ // src/core/encryption/key-change-recovery.ts
3431
+ function missingDatabaseFile(path) {
3432
+ return new SirannonError(
3433
+ `The database file ${path} is missing, and the marker of an interrupted key change does not show that the new file can take its place, so Sirannon leaves every file as it is`,
3434
+ "DATABASE_OPEN_FAILED"
3435
+ );
3436
+ }
3437
+ async function recoverInterruptedKeyChange(driver, path, readOnly) {
3438
+ const files = driver.files;
3439
+ if (files === void 0 || path === ":memory:" || path === "" || path.startsWith("file:")) return null;
3440
+ let markerStart;
3441
+ try {
3442
+ markerStart = await files.readStart(keyChangeMarkerPath(path), FILE_IDENTITY_BYTES);
3443
+ } catch (err) {
3444
+ if (err instanceof EncryptionError && err.code === "ENCRYPTION_UNSUPPORTED") return null;
3445
+ throw err;
3446
+ }
3447
+ if (markerStart === null) {
3448
+ if (!readOnly) await removeKeyChangeFile(files, path);
3449
+ return null;
3450
+ }
3451
+ const marker = await readKeyChangeMarker(driver, path, readOnly);
3452
+ const current = await files.readStart(path, FILE_IDENTITY_BYTES);
3453
+ if (current === null) {
3454
+ const newFile = await files.readStart(keyChangeFilePath(path), FILE_IDENTITY_BYTES);
3455
+ const movable = marker?.replacementBegun === true && newFile !== null && sameBytes(marker.identity, newFile);
3456
+ if (!movable) throw missingDatabaseFile(path);
3457
+ if (readOnly) return "key-after-change";
3458
+ await files.rename(keyChangeFilePath(path), path);
3459
+ await flushDirectoryThroughMarker(driver, path);
3460
+ await removeKeyChangeMarker(files, path);
3461
+ return "key-after-change";
3462
+ }
3463
+ const replaced = marker !== null && current.length === FILE_IDENTITY_BYTES && sameBytes(marker.identity, current);
3464
+ if (readOnly) return replaced ? "key-after-change" : "key-before-change";
3465
+ await removeKeyChangeFile(files, path);
3466
+ await removeKeyChangeMarker(files, path);
3467
+ return replaced ? "key-after-change" : "key-before-change";
3468
+ }
3469
+ var KEY_ERROR_CODES = /* @__PURE__ */ new Set(["ENCRYPTION_KEY_REJECTED", "ENCRYPTION_KEY_MISSING", "DATABASE_NOT_ENCRYPTED"]);
3470
+ function explainKeyAfterInterruptedChange(err, interrupted) {
3471
+ if (!(err instanceof EncryptionError) || !KEY_ERROR_CODES.has(err.code)) return err;
3472
+ const needed = interrupted === "key-after-change" ? "the key from after the change, or no key where the change removed encryption, because the new file replaced the original" : "the key from before the change, or no key where the file was plaintext, because the change stopped before the new file replaced the original";
3473
+ return new EncryptionError(
3474
+ `A key change on this database stopped part way, and the file needs ${needed}`,
3475
+ "ENCRYPTION_KEY_REJECTED",
3476
+ err
3477
+ );
3478
+ }
3479
+
3480
+ // src/core/file-connections.ts
3481
+ var DatabaseFileConnections = class {
3482
+ constructor(driver, path, keyring, extensions) {
3483
+ this.driver = driver;
3484
+ this.path = path;
3485
+ this.keyring = keyring;
3486
+ this.extensions = extensions;
3487
+ }
3488
+ active = 0;
3489
+ holders = /* @__PURE__ */ new Set();
3490
+ replacement = null;
3491
+ drained = null;
3492
+ /**
3493
+ * Opens a connection to the database file, after any file replacement under way has ended.
3494
+ *
3495
+ * @param options - The open settings, without a key, which this method adds.
3496
+ * @returns The connection, which the caller closes.
3497
+ */
3498
+ async open(options) {
3499
+ while (this.replacement !== null) await this.replacement.done;
3500
+ this.active++;
3501
+ let connection;
3502
+ try {
3503
+ connection = await this.extensions.open(
3504
+ () => this.keyring.openWith((keyed) => this.driver.open(this.path, { ...options, ...keyed }))
3505
+ );
3506
+ } catch (err) {
3507
+ this.settle();
3508
+ throw err;
3509
+ }
3510
+ const close = connection.close;
3511
+ let settled = false;
3512
+ connection.close = async () => {
3513
+ if (settled) return close.call(connection);
3514
+ settled = true;
3515
+ try {
3516
+ await close.call(connection);
3517
+ } finally {
3518
+ this.settle();
3519
+ }
3520
+ };
3521
+ return connection;
3522
+ }
3523
+ settle() {
3524
+ this.active--;
3525
+ if (this.active === 0) this.drained?.();
3526
+ }
3527
+ /**
3528
+ * Registers a part of the database that keeps a connection open between calls.
3529
+ *
3530
+ * @param holder - The release and reopen calls of that part.
3531
+ * @returns A function that removes the registration.
3532
+ */
3533
+ hold(holder) {
3534
+ this.holders.add(holder);
3535
+ return () => {
3536
+ this.holders.delete(holder);
3537
+ };
3538
+ }
3539
+ /**
3540
+ * Makes later opens wait, has every holder close its connection, and waits until every other connection to the
3541
+ * file has closed.
3542
+ *
3543
+ * @param timeoutMs - How long to wait for the connections that a call holds for the duration of one read.
3544
+ * @throws An `EncryptionError` with `ENCRYPTION_CHANGE_ABORTED` when a connection stays open past the deadline.
3545
+ */
3546
+ async closeForReplacement(timeoutMs) {
3547
+ let finish = () => {
3548
+ };
3549
+ const done = new Promise((resolve) => {
3550
+ finish = resolve;
3551
+ });
3552
+ this.replacement = { done, finish };
3553
+ for (const holder of [...this.holders]) {
3554
+ await holder.release().catch(() => void 0);
3555
+ }
3556
+ if (this.active === 0) return;
3557
+ let timer;
3558
+ const closed = await Promise.race([
3559
+ new Promise((resolve) => {
3560
+ this.drained = () => resolve(true);
3561
+ }),
3562
+ new Promise((resolve) => {
3563
+ timer = setTimeout(() => resolve(false), timeoutMs);
3564
+ })
3565
+ ]);
3566
+ clearTimeout(timer);
3567
+ this.drained = null;
3568
+ if (!closed) {
3569
+ throw new EncryptionError(
3570
+ `A read held a connection to the database file for longer than ${timeoutMs} ms, so the key change stopped before it replaced the file`,
3571
+ "ENCRYPTION_CHANGE_ABORTED"
3572
+ );
3573
+ }
3574
+ }
3575
+ /** Lets opens proceed again, and has every holder open its connection to the file that is now in place. */
3576
+ async reopenAfterReplacement() {
3577
+ const replacement = this.replacement;
3578
+ this.replacement = null;
3579
+ replacement?.finish();
3580
+ for (const holder of [...this.holders]) {
3581
+ await holder.reopen().catch(() => void 0);
3582
+ }
3583
+ }
3584
+ };
3585
+
2148
3586
  // src/core/hooks/registry.ts
2149
3587
  var HOOK_CONFIG_MAP = {
2150
3588
  onBeforeQuery: "beforeQuery",
@@ -2342,6 +3780,12 @@ var LoadedExtensions = class {
2342
3780
  if (!this.resolvedPaths.includes(resolved)) this.resolvedPaths.push(resolved);
2343
3781
  });
2344
3782
  }
3783
+ /** Loads every recorded extension into connections that replaced the ones that the database loaded them into. */
3784
+ reload(connections) {
3785
+ return this.runInTurn(async () => {
3786
+ for (const connection of connections) await this.loadRecorded(connection);
3787
+ });
3788
+ }
2345
3789
  /**
2346
3790
  * Opens a connection, loads every recorded extension into it, and returns a
2347
3791
  * wrapper that forwards each call to it. Closing the wrapper stops the
@@ -2368,6 +3812,63 @@ var LoadedExtensions = class {
2368
3812
  }
2369
3813
  };
2370
3814
 
3815
+ // src/core/replication-engines.ts
3816
+ var RegisteredReplicationEngines = class {
3817
+ constructor(databaseId) {
3818
+ this.databaseId = databaseId;
3819
+ }
3820
+ count = 0;
3821
+ replacements = /* @__PURE__ */ new Set();
3822
+ /**
3823
+ * Records one running engine, and returns the call that removes the record, which does nothing after its first
3824
+ * call.
3825
+ *
3826
+ * @returns The call that removes the record.
3827
+ * @throws The error of a file replacement under way, which would leave the engine on the replaced file.
3828
+ */
3829
+ register() {
3830
+ const replacement = this.replacements.values().next();
3831
+ if (replacement.done !== true) throw replacement.value();
3832
+ this.count++;
3833
+ let registered = true;
3834
+ return () => {
3835
+ if (!registered) return;
3836
+ registered = false;
3837
+ this.count--;
3838
+ };
3839
+ }
3840
+ /**
3841
+ * Throws when an engine runs, because the engine's writer connection would stay on the replaced file.
3842
+ *
3843
+ * @param operation - The operation that would replace the file, as the error message states it.
3844
+ * @throws A `ReplicationError` with `REPLICATION_ACTIVE` while an engine runs.
3845
+ */
3846
+ assertNone(operation) {
3847
+ if (this.count === 0) return;
3848
+ throw new ReplicationError(
3849
+ `A replication engine writes to database '${this.databaseId}' through a separate writer connection, and a ${operation} would leave that connection on the replaced file. Stop the engine, run the ${operation}, and then start the engine again.`,
3850
+ "REPLICATION_ACTIVE"
3851
+ );
3852
+ }
3853
+ /**
3854
+ * Refuses the operation while an engine runs, and otherwise refuses every engine that starts before the returned
3855
+ * call ends the replacement.
3856
+ *
3857
+ * @param operation - The operation that replaces the file, as the error message states it.
3858
+ * @param refuseEngine - Builds the error that an engine which starts during the replacement receives.
3859
+ * @returns The call that ends the replacement.
3860
+ * @throws A `ReplicationError` with `REPLICATION_ACTIVE` while an engine runs.
3861
+ */
3862
+ beginReplacement(operation, refuseEngine) {
3863
+ this.assertNone(operation);
3864
+ const refusal = () => refuseEngine();
3865
+ this.replacements.add(refusal);
3866
+ return () => {
3867
+ this.replacements.delete(refusal);
3868
+ };
3869
+ }
3870
+ };
3871
+
2371
3872
  // src/core/worker/config.ts
2372
3873
  var DEFAULT_MAX_PENDING_WRITES = 1024;
2373
3874
  var DEFAULT_RETRY_AFTER_MS = 1e3;
@@ -2497,6 +3998,23 @@ function swallow() {
2497
3998
  }
2498
3999
 
2499
4000
  // src/core/database-create.ts
4001
+ function encryptionKeyringFor(driver, options) {
4002
+ const encryption = options?.encryption;
4003
+ if (encryption === void 0) return new EncryptionKeyring(null);
4004
+ if (typeof encryption?.key !== "function") {
4005
+ throw new EncryptionError(
4006
+ "The encryption option needs a key function that returns 32 key bytes",
4007
+ "INVALID_ENCRYPTION_KEY"
4008
+ );
4009
+ }
4010
+ if (!driver.capabilities.encryption) {
4011
+ throw new EncryptionError(
4012
+ "The driver that this Sirannon uses cannot encrypt a database, so it cannot open one with the encryption option",
4013
+ "ENCRYPTION_UNSUPPORTED"
4014
+ );
4015
+ }
4016
+ return new EncryptionKeyring(encryption.key);
4017
+ }
2500
4018
  async function createDatabaseRuntime(id, path, driver, options, internals) {
2501
4019
  const writerWorker = resolveWriterWorkerConfig(options?.writerWorker);
2502
4020
  const readOnly = options?.readOnly ?? false;
@@ -2509,17 +4027,35 @@ async function createDatabaseRuntime(id, path, driver, options, internals) {
2509
4027
  if (options?.backups && !readOnly) {
2510
4028
  assertChangeLogCaptureSupported(driver, id, path, options.walMode ?? true, options.backups);
2511
4029
  }
2512
- const pool = await ConnectionPool.create({
2513
- driver,
2514
- path,
2515
- readOnly: options?.readOnly,
2516
- readPoolSize: options?.readPoolSize ?? 4,
2517
- walMode: options?.walMode ?? true,
2518
- synchronous: options?.synchronous,
2519
- ...options?.backups ? { walAutoCheckpoint: 0 } : {},
2520
- useWriterWorker: writerWorker.enabled && !readOnly,
2521
- workerHostOptions: writerWorker.host
2522
- });
4030
+ const keyring = encryptionKeyringFor(driver, options);
4031
+ const interrupted = await recoverInterruptedKeyChange(driver, path, readOnly);
4032
+ const extensions = new LoadedExtensions(driver);
4033
+ let pool;
4034
+ try {
4035
+ pool = await ConnectionPool.create({
4036
+ driver,
4037
+ path,
4038
+ readOnly: options?.readOnly,
4039
+ readPoolSize: options?.readPoolSize ?? 4,
4040
+ walMode: options?.walMode ?? true,
4041
+ synchronous: options?.synchronous,
4042
+ ...options?.backups ? { walAutoCheckpoint: 0 } : {},
4043
+ useWriterWorker: writerWorker.enabled && !readOnly,
4044
+ workerHostOptions: writerWorker.host,
4045
+ keyring,
4046
+ prepareReopened: (connections) => extensions.reload(connections)
4047
+ });
4048
+ } catch (err) {
4049
+ throw interrupted === null ? err : explainKeyAfterInterruptedChange(err, interrupted);
4050
+ }
4051
+ if (keyring.encrypted && !readOnly) {
4052
+ try {
4053
+ await writeKeyId(pool.acquireWriter(), keyring.keyId);
4054
+ } catch (err) {
4055
+ await pool.close().catch(() => void 0);
4056
+ throw err;
4057
+ }
4058
+ }
2523
4059
  const writeGate = new WriteGate(writerWorker.enabled ? writerWorker.maxPendingWrites : 0, writerWorker.retryAfterMs);
2524
4060
  const writerLock = new WriterLock(driver.createWriterContext?.());
2525
4061
  const hookRegistry = new HookRegistry();
@@ -2536,11 +4072,12 @@ async function createDatabaseRuntime(id, path, driver, options, internals) {
2536
4072
  driver.capabilities,
2537
4073
  id,
2538
4074
  path,
2539
- driver.createBackupEngine?.(driver)
4075
+ driver.createBackupEngine?.(driver),
4076
+ () => keyring.keyId
2540
4077
  );
2541
4078
  const canOpenSnapshotConnection = driver.capabilities.multipleConnections && path !== ":memory:";
2542
- const extensions = new LoadedExtensions(driver);
2543
- const openSnapshotConnection = () => extensions.open(() => driver.open(path, { readonly: true, walMode: false }));
4079
+ const fileConnections = new DatabaseFileConnections(driver, path, keyring, extensions);
4080
+ const openSnapshotConnection = () => fileConnections.open({ readonly: true, walMode: false });
2544
4081
  const cdc = new DatabaseCdcController(
2545
4082
  (op) => writerLock.run(op),
2546
4083
  () => pool.acquireWriter(),
@@ -2584,7 +4121,27 @@ async function createDatabaseRuntime(id, path, driver, options, internals) {
2584
4121
  stamper: () => cdc.activeStamper
2585
4122
  });
2586
4123
  if (options?.backups && !readOnly) backups.startCycle(options.backups);
4124
+ fileConnections.hold({ release: () => cdc.releaseLiveConnection(), reopen: async () => {
4125
+ } });
4126
+ const replicationEngines = new RegisteredReplicationEngines(id);
4127
+ const encryption = new DatabaseEncryptionController({
4128
+ databaseId: id,
4129
+ readOnly,
4130
+ replicationEngines,
4131
+ path,
4132
+ driver,
4133
+ keyring,
4134
+ pool,
4135
+ writerLock,
4136
+ fileConnections,
4137
+ cdc,
4138
+ backups,
4139
+ onKeyChanged: internals?.onEncryptionChanged
4140
+ });
2587
4141
  return {
4142
+ fileConnections,
4143
+ encryption,
4144
+ replicationEngines,
2588
4145
  pool,
2589
4146
  writeGate,
2590
4147
  writerLock,
@@ -2604,6 +4161,7 @@ async function closeDatabaseRuntime(runtime, closeListeners) {
2604
4161
  runtime.cdc.stop();
2605
4162
  runtime.backups.cancelAll();
2606
4163
  const releaseSteps = [
4164
+ () => runtime.encryption.cancel(),
2607
4165
  () => runtime.cdc.closeLiveConnection(),
2608
4166
  () => runtime.writes.drain(),
2609
4167
  () => runtime.writerLock.settle(),
@@ -2913,32 +4471,137 @@ var DatabaseBackups = class extends DatabaseLifecycle {
2913
4471
  }
2914
4472
  };
2915
4473
 
4474
+ // src/core/database-encryption.ts
4475
+ var DatabaseEncryption = class extends DatabaseBackups {
4476
+ /**
4477
+ * Returns whether the database file holds ciphertext, the version of the encryption engine, the identifier that
4478
+ * came with the current key, the time of the last finished key change, and the progress of the current or last
4479
+ * key change. The status never contains a key.
4480
+ *
4481
+ * @returns The encryption status.
4482
+ */
4483
+ async encryptionStatus() {
4484
+ this.ensureNotClosed();
4485
+ return this.runtime.encryption.status();
4486
+ }
4487
+ /**
4488
+ * Encrypts a plaintext database, gives an encrypted one a new key, or, with `key` set to `null`, stores it as
4489
+ * plaintext, while the database stays open for reads and writes.
4490
+ *
4491
+ * Sirannon copies a snapshot to a new file under the target key, applies the writes committed since the snapshot in
4492
+ * rounds, holds writes only for the last round, and then replaces the file. Until the replacement, the database
4493
+ * keeps its current key. After it, every connection opens under the new key, so open the database under that key
4494
+ * from then on. When the database captures backups, the next capture starts a new chain with a full copy under the
4495
+ * new key.
4496
+ *
4497
+ * On the Expo driver, the change needs expo-file-system 19.0.0 or later. Once an app has called this method, keep
4498
+ * expo-file-system 19.0.0 or later installed in every later build, because the open that recovers from an
4499
+ * interrupted change reads the files beside the database through it. Without it, the open cannot see that a change
4500
+ * stopped part way.
4501
+ *
4502
+ * @param change - The function that returns the target key, or `null` to remove encryption.
4503
+ * @returns The encryption status once the change ends.
4504
+ * @throws An `EncryptionError` with `ENCRYPTION_UNSUPPORTED` on an Expo app without expo-file-system 19.0.0 or
4505
+ * later, before the change writes any file, with `ENCRYPTION_CHANGE_IN_PROGRESS` while another change runs, or with
4506
+ * `ENCRYPTION_CHANGE_ABORTED` when a schema change or a write that the change cannot apply exactly stops it, which
4507
+ * leaves the database under its current key, `READ_ONLY` on a read-only database, and a `ReplicationError` with
4508
+ * `REPLICATION_ACTIVE` while a replication engine writes to the database, which the caller stops before the change
4509
+ * and starts again afterwards.
4510
+ */
4511
+ async changeEncryption(change) {
4512
+ this.ensureOpen();
4513
+ this.ensureWriterCanWait();
4514
+ return this.runtime.encryption.changeKey(change);
4515
+ }
4516
+ /**
4517
+ * Opens a connection to this database's file beyond the pool, under the database's current key, which waits while
4518
+ * a key change replaces the file. The caller closes the connection.
4519
+ *
4520
+ * @param options - The open settings, without a key.
4521
+ * @returns The connection.
4522
+ * @internal
4523
+ */
4524
+ openFileConnection(options) {
4525
+ this.ensureNotClosed();
4526
+ return this.runtime.fileConnections.open(options);
4527
+ }
4528
+ /**
4529
+ * Registers a part of the server that keeps a connection from {@link DatabaseEncryption.openFileConnection} open
4530
+ * between calls, so that a key change can close it before it replaces the file and reopen it afterwards.
4531
+ *
4532
+ * @param holder - The release and reopen calls.
4533
+ * @returns A function that removes the registration.
4534
+ * @internal
4535
+ */
4536
+ holdFileConnection(holder) {
4537
+ return this.runtime.fileConnections.hold(holder);
4538
+ }
4539
+ /**
4540
+ * Returns whether the database file opens under a key, which a replication engine announces to its peers. A key
4541
+ * change can alter it only while no engine runs, so the value holds for an engine's whole run.
4542
+ *
4543
+ * @returns `true` for an encrypted database.
4544
+ * @internal
4545
+ */
4546
+ get encryptedFile() {
4547
+ return this.runtime.encryption.encrypted;
4548
+ }
4549
+ /**
4550
+ * Records a replication engine that writes to this database through a separate writer connection, so that a key
4551
+ * change or a restore refuses to replace the file under that connection while the engine runs.
4552
+ *
4553
+ * @returns A function that removes the record, which the engine calls when it stops.
4554
+ * @throws `ENCRYPTION_CHANGE_IN_PROGRESS` while a key change runs, and `DATABASE_CLOSED` on a closed database.
4555
+ * @internal
4556
+ */
4557
+ registerReplicationEngine() {
4558
+ this.ensureNotClosed();
4559
+ return this.runtime.replicationEngines.register();
4560
+ }
4561
+ /**
4562
+ * Throws while a replication engine writes to this database, because replacing the file would leave the engine's
4563
+ * writer connection on the replaced file.
4564
+ *
4565
+ * @param operation - The operation that would replace the file, as the error message states it.
4566
+ * @throws A `ReplicationError` with `REPLICATION_ACTIVE` while an engine runs.
4567
+ * @internal
4568
+ */
4569
+ assertNoReplicationEngine(operation) {
4570
+ this.runtime.replicationEngines.assertNone(operation);
4571
+ }
4572
+ };
4573
+
2916
4574
  // src/core/database-reads.ts
2917
4575
  function readRows(deps, sql, params, options) {
2918
- if (readsUnobserved(deps)) return query(deps.pool.acquireReader(), sql, params);
4576
+ if (readsUnobserved(deps)) return query(deps.pool.leaseReader(), sql, params, deps.pool);
2919
4577
  return deps.observer.withQueryHooks(
2920
4578
  sql,
2921
4579
  params,
2922
4580
  options,
2923
- () => onReadConnection(deps, sql, (conn) => queryMetered(conn, sql, params), readOutcome)
4581
+ () => onReadConnection(deps, sql, (conn, leasedFrom) => queryMetered(conn, sql, params, leasedFrom), readOutcome)
2924
4582
  );
2925
4583
  }
2926
4584
  function readWireRows(deps, sql, params, options) {
2927
- if (readsUnobserved(deps)) return queryRowsForWire(deps.pool.acquireReader(), sql, params);
4585
+ if (readsUnobserved(deps)) return queryRowsForWire(deps.pool.leaseReader(), sql, params, deps.pool);
2928
4586
  return deps.observer.withQueryHooks(
2929
4587
  sql,
2930
4588
  params,
2931
4589
  options,
2932
- () => onReadConnection(deps, sql, (conn) => queryForWire(conn, sql, params), readOutcome)
4590
+ () => onReadConnection(deps, sql, (conn, leasedFrom) => queryForWire(conn, sql, params, leasedFrom), readOutcome)
2933
4591
  );
2934
4592
  }
2935
4593
  function readOneRow(deps, sql, params, options) {
2936
- if (readsUnobserved(deps)) return queryOne(deps.pool.acquireReader(), sql, params);
4594
+ if (readsUnobserved(deps)) return queryOne(deps.pool.leaseReader(), sql, params, deps.pool);
2937
4595
  return deps.observer.withQueryHooks(
2938
4596
  sql,
2939
4597
  params,
2940
4598
  options,
2941
- () => onReadConnection(deps, sql, (conn) => queryOneMetered(conn, sql, params), readOneOutcome)
4599
+ () => onReadConnection(
4600
+ deps,
4601
+ sql,
4602
+ (conn, leasedFrom) => queryOneMetered(conn, sql, params, leasedFrom),
4603
+ readOneOutcome
4604
+ )
2942
4605
  );
2943
4606
  }
2944
4607
  function readsUnobserved(deps) {
@@ -2947,7 +4610,7 @@ function readsUnobserved(deps) {
2947
4610
  async function onReadConnection(deps, sql, op, measure) {
2948
4611
  const refusal = deps.pool.readerCount === 0 ? deps.writerLock.callerRefusal() : null;
2949
4612
  if (refusal !== null) throw refusal;
2950
- const metered = deps.pool.readerCount === 0 ? await deps.writerLock.run(() => deps.observer.track(sql, () => op(deps.pool.acquireWriter()), measure)) : await deps.observer.track(sql, () => op(deps.pool.acquireReader()), measure);
4613
+ const metered = deps.pool.readerCount === 0 ? await deps.writerLock.run(() => deps.observer.track(sql, () => op(deps.pool.acquireWriter()), measure)) : await deps.observer.track(sql, () => op(deps.pool.leaseReader(), deps.pool), measure);
2951
4614
  return metered.value;
2952
4615
  }
2953
4616
 
@@ -3405,7 +5068,7 @@ var MaintainedLiveQuery = class _MaintainedLiveQuery {
3405
5068
  await this.source.wait(Math.floor(Math.random() * this.source.rereadJitterMs));
3406
5069
  }
3407
5070
  if (this.closed) return;
3408
- if (this.replanBeforeRead) {
5071
+ if (this.replanBeforeRead || this.source.connectionReplaced) {
3409
5072
  await this.source.replan();
3410
5073
  if (this.closed) return;
3411
5074
  this.replanBeforeRead = false;
@@ -4056,7 +5719,7 @@ function withoutTrailingSemicolon(tokens) {
4056
5719
  var DEFAULT_REREAD_JITTER_MS = 25;
4057
5720
  var DEFAULT_MAX_TRANSACTION_CHANGES = 1e4;
4058
5721
  var MAX_TRANSACTION_BYTES = 16 * 1048576;
4059
- var PRIMARY_KEY_INDEX_ORIGIN = "pk";
5722
+ var PRIMARY_KEY_INDEX_ORIGIN2 = "pk";
4060
5723
  var nextProbeTable = 0;
4061
5724
  async function openLiveQuery(deps, sql, params, options) {
4062
5725
  const shape = analyseStatement(sql);
@@ -4093,7 +5756,7 @@ async function planProbe(live, shape, params, meter) {
4093
5756
  }
4094
5757
  const pkColumns = primaryKeyColumns(info);
4095
5758
  const indexes = await run(() => tableIndexList(conn, shape.table));
4096
- const pkIsRowid = pkColumns.length === 1 && !indexes.some((index) => index.origin === PRIMARY_KEY_INDEX_ORIGIN);
5759
+ const pkIsRowid = pkColumns.length === 1 && !indexes.some((index) => index.origin === PRIMARY_KEY_INDEX_ORIGIN2);
4097
5760
  const plan = buildLivePlan(
4098
5761
  shape,
4099
5762
  {
@@ -4123,8 +5786,21 @@ function generatedExpression(shape, column, definitions) {
4123
5786
  return expression;
4124
5787
  }
4125
5788
  function buildSource(deps, live, shape, params, options, meter, initial) {
4126
- const delivery = new PrimedLiveDelivery(deps.cdc, live.conn, live.run, shape.table);
5789
+ let liveConnection = live;
5790
+ let delivery = new PrimedLiveDelivery(deps.cdc, live.conn, live.run, shape.table);
4127
5791
  let planned = initial;
5792
+ let connectionReplaced = false;
5793
+ let probeOnClosedConnection = false;
5794
+ let delivering = null;
5795
+ const stopWatchingReplacement = deps.cdc.onLiveConnectionReplaced(() => {
5796
+ connectionReplaced = true;
5797
+ probeOnClosedConnection = true;
5798
+ const current = delivering;
5799
+ delivering = null;
5800
+ if (current === null) return;
5801
+ current.stop();
5802
+ current.onLost();
5803
+ });
4128
5804
  return {
4129
5805
  get plan() {
4130
5806
  return planned.plan;
@@ -4132,24 +5808,46 @@ function buildSource(deps, live, shape, params, options, meter, initial) {
4132
5808
  get probe() {
4133
5809
  return planned.probe;
4134
5810
  },
5811
+ get connectionReplaced() {
5812
+ return connectionReplaced;
5813
+ },
4135
5814
  rereadJitterMs: options?.rereadJitterMs ?? DEFAULT_REREAD_JITTER_MS,
4136
5815
  maxTransactionChanges: options?.maxTransactionChanges ?? DEFAULT_MAX_TRANSACTION_CHANGES,
4137
5816
  maxTransactionBytes: MAX_TRANSACTION_BYTES,
4138
5817
  onError: options?.onError,
4139
5818
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
4140
5819
  read: () => readAtPosition2(deps.cdc, planned.plan, params, meter),
4141
- start: (handlers, sinceSeq) => delivery.start(handlers, sinceSeq),
5820
+ async start(handlers, sinceSeq) {
5821
+ const stop = await delivery.start(handlers, sinceSeq);
5822
+ delivering = { stop, onLost: handlers.onLost };
5823
+ return () => {
5824
+ delivering = null;
5825
+ stop();
5826
+ };
5827
+ },
4142
5828
  shapeChanged: (events) => events.some((event) => !capturedWithColumns(event, planned.columns)),
4143
5829
  async replan() {
4144
5830
  const replaced = planned;
4145
5831
  try {
4146
- planned = await planProbe(live, shape, params, meter);
5832
+ if (connectionReplaced) {
5833
+ liveConnection = await deps.cdc.liveConnection();
5834
+ delivery = new PrimedLiveDelivery(deps.cdc, liveConnection.conn, liveConnection.run, shape.table);
5835
+ connectionReplaced = false;
5836
+ planned = await planProbe(liveConnection, shape, params, meter);
5837
+ probeOnClosedConnection = false;
5838
+ await replaced.probe.close().catch(() => void 0);
5839
+ return;
5840
+ }
5841
+ planned = await planProbe(liveConnection, shape, params, meter);
4147
5842
  } catch (err) {
4148
5843
  throw asQueryError(err, shape.sql);
4149
5844
  }
4150
5845
  await replaced.probe.close();
4151
5846
  },
4152
- release: () => planned.probe.close()
5847
+ release: () => {
5848
+ stopWatchingReplacement();
5849
+ return probeOnClosedConnection ? Promise.resolve() : planned.probe.close();
5850
+ }
4153
5851
  };
4154
5852
  }
4155
5853
  function capturedWithColumns(event, columns) {
@@ -4188,7 +5886,7 @@ function toHeldRow(plan, row) {
4188
5886
  }
4189
5887
 
4190
5888
  // src/core/database.ts
4191
- var Database = class _Database extends DatabaseBackups {
5889
+ var Database = class _Database extends DatabaseEncryption {
4192
5890
  /** @internal */
4193
5891
  static async create(id, path, driver, options, internals) {
4194
5892
  const runtime = await createDatabaseRuntime(id, path, driver, options, internals);
@@ -4847,6 +6545,11 @@ var RegistryMigrationSet = class {
4847
6545
  };
4848
6546
 
4849
6547
  // src/core/registry-defaults.ts
6548
+ function rememberEncryptionKey(opened, key) {
6549
+ if (opened === void 0) return;
6550
+ const { encryption: _replaced, ...rest } = opened.options ?? {};
6551
+ opened.options = key === null ? rest : { ...rest, encryption: { key } };
6552
+ }
4850
6553
  function withRegistryDefaults(registry, options) {
4851
6554
  const defaults = {};
4852
6555
  if (registry.writerWorker !== void 0 && options?.writerWorker === void 0) {
@@ -4967,7 +6670,8 @@ var Sirannon = class {
4967
6670
  }
4968
6671
  db = await Database.create(id, path, this._driver, resolvedOptions, {
4969
6672
  parentHooks: this._hookRegistry,
4970
- metrics: this.metricsCollector ?? void 0
6673
+ metrics: this.metricsCollector ?? void 0,
6674
+ onEncryptionChanged: (key) => rememberEncryptionKey(this.openedWith.get(id), key)
4971
6675
  });
4972
6676
  } catch (err) {
4973
6677
  this.opening.delete(id);
@@ -5046,9 +6750,9 @@ var Sirannon = class {
5046
6750
  * Sirannon reopens the database even after a failed action.
5047
6751
  *
5048
6752
  * @param id - The identifier of the database to take offline.
5049
- * @param action - The function that Sirannon calls with the database file path once the database is closed.
6753
+ * @param action - The function that Sirannon calls with the database file path and its key once the database is closed.
5050
6754
  * @returns Whether the action returned, the value that it returned or the error that it threw, and any error that the reopen threw.
5051
- * @throws A `DatabaseNotFoundError` when no database is open under the identifier, a `ReadOnlyError` when the database is read-only, or the close error when the close fails.
6755
+ * @throws A `DatabaseNotFoundError` when no database is open under the identifier, a `ReadOnlyError` when the database is read-only, a `ReplicationError` with `REPLICATION_ACTIVE` while a replication engine writes to the database, or the close error when the close fails.
5052
6756
  *
5053
6757
  * @internal
5054
6758
  */
@@ -5058,13 +6762,14 @@ var Sirannon = class {
5058
6762
  const opened = this.openedWith.get(id);
5059
6763
  if (!db || !opened) throw new DatabaseNotFoundError(id);
5060
6764
  if (db.readOnly) throw new ReadOnlyError(id);
6765
+ db.assertNoReplicationEngine("restore");
5061
6766
  this.dbs.delete(id);
5062
6767
  this.opening.add(id);
5063
6768
  this.offline.add(id);
5064
6769
  const work = takeDatabaseOffline({
5065
6770
  database: db,
5066
6771
  path: opened.path,
5067
- action,
6772
+ action: (path) => action(path, opened.options?.encryption),
5068
6773
  reopen: async () => {
5069
6774
  this.opening.delete(id);
5070
6775
  try {
@@ -5251,4 +6956,4 @@ var NODE_HEALTH_REASONS = [
5251
6956
  "no-group-state"
5252
6957
  ];
5253
6958
 
5254
- export { ConnectionPool, Database, DatabaseBackups, DatabaseLifecycle, HookRegistry, LifecycleManager, MIGRATION_NAME_RE, MigrationRunner, NODE_HEALTH_REASONS, NODE_HEALTH_STATES, Sirannon, createTenantResolver, migrationsFromFiles, operationName, operationRef, readBearerToken, readHeader, readSubprotocolCredential, sanitizeTenantId, tenantPath, toSubprotocolCredential };
6959
+ export { ConnectionPool, Database, DatabaseBackups, DatabaseEncryption, DatabaseLifecycle, HookRegistry, LifecycleManager, MIGRATION_NAME_RE, MigrationRunner, NODE_HEALTH_REASONS, NODE_HEALTH_STATES, Sirannon, createTenantResolver, migrationsFromFiles, operationName, operationRef, readBearerToken, readHeader, readSubprotocolCredential, sanitizeTenantId, tenantPath, toSubprotocolCredential };