@oh-my-pi/pi-utils 18.2.1 → 18.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/types/sqlite.d.ts +27 -7
- package/package.json +2 -2
- package/src/sqlite.ts +242 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.2.2] - 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added asynchronous and synchronous SQLite database opening APIs with path-attributed errors, optional corruption recovery that preserves private database and sidecar backups, and automatic retries for transient busy errors during asynchronous opens.
|
|
10
|
+
|
|
5
11
|
## [18.2.1] - 2026-09-15
|
|
6
12
|
|
|
7
13
|
### Added
|
package/dist/types/sqlite.d.ts
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
|
+
/** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
/** Controls opt-in replacement of an unrecoverably corrupt SQLite store. */
|
|
4
|
+
export interface SqliteOpenOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Preserve a corrupt store and its sidecars, recreate it, and run the
|
|
7
|
+
* initializer once more. Disabled by default.
|
|
8
|
+
*/
|
|
9
|
+
recoverCorruption?: boolean;
|
|
10
|
+
/** Runs after preservation and before the replacement is initialized. */
|
|
11
|
+
onCorruptionPreserved?: (backupPath: string, error: unknown) => void;
|
|
12
|
+
}
|
|
1
13
|
/**
|
|
2
|
-
*
|
|
14
|
+
* Opens and initializes a store, retrying BUSY failures up to four total attempts.
|
|
15
|
+
* Installs the busy handler before initialization and closes failed connections.
|
|
16
|
+
* The initializer may run again on a fresh connection; on success it owns the handle.
|
|
3
17
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* credential store and the model cache.
|
|
18
|
+
* With corruption recovery enabled, recovery is serialized across processes.
|
|
19
|
+
* The identity observed by the failed handle is checked under that lock, so a
|
|
20
|
+
* waiter adopts a replacement made by a peer instead of quarantining it.
|
|
21
|
+
* Final failures retain their SQLite codes and include the database path.
|
|
9
22
|
*/
|
|
10
|
-
|
|
23
|
+
export declare function openSqliteDatabase<T>(dbPath: string, initialize: (db: Database) => T | Promise<T>, options?: SqliteOpenOptions): Promise<T>;
|
|
24
|
+
/**
|
|
25
|
+
* Synchronous counterpart to {@link openSqliteDatabase}. It performs no BUSY
|
|
26
|
+
* retry loop; corruption recovery, when enabled, is bounded to one replacement.
|
|
27
|
+
*/
|
|
28
|
+
export declare function openSqliteDatabaseSync<T>(dbPath: string, initialize: (db: Database) => T, options?: SqliteOpenOptions): T;
|
|
29
|
+
/** Adds the failing store's path to an error without losing SQLite result codes or its original stack. */
|
|
30
|
+
export declare function annotateSqliteError(error: unknown, dbPath: string): Error;
|
|
11
31
|
/** Checkpoints committed WAL frames without waiting for concurrent readers. */
|
|
12
32
|
export declare function checkpointWal(db: Database): void;
|
|
13
33
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.2.
|
|
3
|
+
"version": "18.2.2",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@oh-my-pi/pi-natives": "18.2.
|
|
57
|
+
"@oh-my-pi/pi-natives": "18.2.2"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "^1.3.14"
|
package/src/sqlite.ts
CHANGED
|
@@ -1,13 +1,248 @@
|
|
|
1
|
+
/** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import { getDbBusyTimeoutMs } from "./env";
|
|
5
|
+
import { withFileLockSync } from "./file-lock";
|
|
6
|
+
import { isEnoent } from "./fs-error";
|
|
7
|
+
import * as logger from "./logger";
|
|
8
|
+
|
|
9
|
+
const BUSY_MAX_ATTEMPTS = 4;
|
|
10
|
+
const BUSY_BASE_DELAY_MS = 100;
|
|
11
|
+
const SQLITE_STORE_SUFFIXES = ["-wal", "-shm", "-journal", ""];
|
|
12
|
+
|
|
13
|
+
type SqliteFileIdentity = string | null | undefined;
|
|
14
|
+
|
|
15
|
+
class SqliteAttemptFailure extends Error {
|
|
16
|
+
readonly original: unknown;
|
|
17
|
+
readonly identity: SqliteFileIdentity;
|
|
18
|
+
readonly canRecover: boolean;
|
|
19
|
+
readonly db?: Database;
|
|
20
|
+
|
|
21
|
+
constructor(original: unknown, identity: SqliteFileIdentity, options: { canRecover?: boolean; db?: Database } = {}) {
|
|
22
|
+
super(original instanceof Error ? original.message : String(original));
|
|
23
|
+
this.original = original;
|
|
24
|
+
this.identity = identity;
|
|
25
|
+
this.canRecover = options.canRecover ?? true;
|
|
26
|
+
this.db = options.db;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sqliteFileIdentity(dbPath: string): SqliteFileIdentity {
|
|
31
|
+
try {
|
|
32
|
+
const stat = fs.statSync(dbPath);
|
|
33
|
+
return `${stat.dev}:${stat.ino}:${stat.birthtimeMs}`;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return isEnoent(error) ? null : undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function closeFailedDatabase(db: Database | undefined, error: unknown, identity: SqliteFileIdentity): void {
|
|
40
|
+
try {
|
|
41
|
+
db?.close();
|
|
42
|
+
} catch (closeError) {
|
|
43
|
+
const original = error instanceof Error ? error : new Error(String(error));
|
|
44
|
+
const detail = closeError instanceof Error ? closeError.message : String(closeError);
|
|
45
|
+
original.message += `; failed to close the SQLite handle: ${detail}`;
|
|
46
|
+
throw new SqliteAttemptFailure(original, identity, { canRecover: false });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Controls opt-in replacement of an unrecoverably corrupt SQLite store. */
|
|
51
|
+
export interface SqliteOpenOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Preserve a corrupt store and its sidecars, recreate it, and run the
|
|
54
|
+
* initializer once more. Disabled by default.
|
|
55
|
+
*/
|
|
56
|
+
recoverCorruption?: boolean;
|
|
57
|
+
/** Runs after preservation and before the replacement is initialized. */
|
|
58
|
+
onCorruptionPreserved?: (backupPath: string, error: unknown) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function openWithBusyRetries<T>(
|
|
62
|
+
dbPath: string,
|
|
63
|
+
initialize: (db: Database) => T | Promise<T>,
|
|
64
|
+
options: SqliteOpenOptions,
|
|
65
|
+
): Promise<T> {
|
|
66
|
+
for (let attempt = 0; ; attempt++) {
|
|
67
|
+
let db: Database | undefined;
|
|
68
|
+
const identity = sqliteFileIdentity(dbPath);
|
|
69
|
+
try {
|
|
70
|
+
db = new Database(dbPath);
|
|
71
|
+
// WAL recovery can bypass the busy handler; both it and retries are needed (#2421).
|
|
72
|
+
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
73
|
+
return await initialize(db);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
76
|
+
throw new SqliteAttemptFailure(error, identity, { db });
|
|
77
|
+
}
|
|
78
|
+
closeFailedDatabase(db, error, identity);
|
|
79
|
+
if (!isSqliteBusyError(error) || attempt + 1 >= BUSY_MAX_ATTEMPTS) {
|
|
80
|
+
throw new SqliteAttemptFailure(error, identity);
|
|
81
|
+
}
|
|
82
|
+
await Bun.sleep(BUSY_BASE_DELAY_MS * 2 ** attempt);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openOnce<T>(dbPath: string, initialize: (db: Database) => T, options: SqliteOpenOptions): T {
|
|
88
|
+
let db: Database | undefined;
|
|
89
|
+
const identity = sqliteFileIdentity(dbPath);
|
|
90
|
+
try {
|
|
91
|
+
db = new Database(dbPath);
|
|
92
|
+
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
93
|
+
return initialize(db);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
96
|
+
throw new SqliteAttemptFailure(error, identity, { db });
|
|
97
|
+
}
|
|
98
|
+
closeFailedDatabase(db, error, identity);
|
|
99
|
+
throw new SqliteAttemptFailure(error, identity);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function quarantineCorruptSqliteStore(dbPath: string, db: Database | undefined): string {
|
|
104
|
+
const backupPath = `${dbPath}.corrupt-${Date.now()}-${crypto.randomUUID()}`;
|
|
105
|
+
const preserved: string[] = [];
|
|
106
|
+
// Closing a failed WAL connection can truncate its WAL. Copy evidence
|
|
107
|
+
// before closing, and remove originals only after every copy succeeds.
|
|
108
|
+
for (const suffix of SQLITE_STORE_SUFFIXES) {
|
|
109
|
+
try {
|
|
110
|
+
fs.chmodSync(`${dbPath}${suffix}`, 0o600);
|
|
111
|
+
fs.copyFileSync(`${dbPath}${suffix}`, `${backupPath}${suffix}`, fs.constants.COPYFILE_EXCL);
|
|
112
|
+
preserved.push(suffix);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (isEnoent(error) && suffix !== "") continue;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
db?.close();
|
|
119
|
+
|
|
120
|
+
const removed: string[] = [];
|
|
121
|
+
try {
|
|
122
|
+
// Remove the main file last so a failed sidecar removal cannot leave
|
|
123
|
+
// a path at which another startup creates an empty database.
|
|
124
|
+
for (const suffix of preserved) {
|
|
125
|
+
try {
|
|
126
|
+
fs.unlinkSync(`${dbPath}${suffix}`);
|
|
127
|
+
removed.push(suffix);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!isEnoent(error)) throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
for (const suffix of removed) {
|
|
134
|
+
try {
|
|
135
|
+
fs.copyFileSync(`${backupPath}${suffix}`, `${dbPath}${suffix}`, fs.constants.COPYFILE_EXCL);
|
|
136
|
+
} catch (rollbackError) {
|
|
137
|
+
logger.error("SQLite quarantine rollback failed; original preserved at backup path", {
|
|
138
|
+
path: `${dbPath}${suffix}`,
|
|
139
|
+
backupPath: `${backupPath}${suffix}`,
|
|
140
|
+
error: String(rollbackError),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
return backupPath;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function corruptionPreservationError(corruption: unknown, dbPath: string, preservationError: unknown): Error {
|
|
150
|
+
const annotated = annotateSqliteError(corruption, dbPath);
|
|
151
|
+
const detail = preservationError instanceof Error ? preservationError.message : String(preservationError);
|
|
152
|
+
annotated.message += `; failed to preserve the corrupt database: ${detail}`;
|
|
153
|
+
return annotated;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function recoverCorruptDatabase(dbPath: string, error: unknown, options: SqliteOpenOptions): void {
|
|
157
|
+
if (!(error instanceof SqliteAttemptFailure)) throw annotateSqliteError(error, dbPath);
|
|
158
|
+
const failure = error;
|
|
159
|
+
if (!options.recoverCorruption || !failure.canRecover || !isSqliteCorruptionError(failure.original)) {
|
|
160
|
+
throw annotateSqliteError(failure.original, dbPath);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let backupPath: string | null;
|
|
164
|
+
try {
|
|
165
|
+
try {
|
|
166
|
+
backupPath = withFileLockSync(`${dbPath}.recovery`, () => {
|
|
167
|
+
const currentIdentity = sqliteFileIdentity(dbPath);
|
|
168
|
+
if (failure.identity === undefined || currentIdentity === undefined) {
|
|
169
|
+
throw new Error("could not verify the corrupt database file identity");
|
|
170
|
+
}
|
|
171
|
+
if (currentIdentity !== failure.identity) return null;
|
|
172
|
+
return quarantineCorruptSqliteStore(dbPath, failure.db);
|
|
173
|
+
});
|
|
174
|
+
} finally {
|
|
175
|
+
closeFailedDatabase(failure.db, failure.original, failure.identity);
|
|
176
|
+
}
|
|
177
|
+
} catch (preservationError) {
|
|
178
|
+
throw corruptionPreservationError(failure.original, dbPath, preservationError);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (backupPath === null) return;
|
|
182
|
+
logger.warn("SQLite database corrupt; preserved damaged store before recreating it", {
|
|
183
|
+
path: dbPath,
|
|
184
|
+
backupPath,
|
|
185
|
+
warning: "Stored credentials from this database may require re-login.",
|
|
186
|
+
});
|
|
187
|
+
options.onCorruptionPreserved?.(backupPath, failure.original);
|
|
188
|
+
}
|
|
189
|
+
|
|
1
190
|
/**
|
|
2
|
-
*
|
|
191
|
+
* Opens and initializes a store, retrying BUSY failures up to four total attempts.
|
|
192
|
+
* Installs the busy handler before initialization and closes failed connections.
|
|
193
|
+
* The initializer may run again on a fresh connection; on success it owns the handle.
|
|
3
194
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* credential store and the model cache.
|
|
195
|
+
* With corruption recovery enabled, recovery is serialized across processes.
|
|
196
|
+
* The identity observed by the failed handle is checked under that lock, so a
|
|
197
|
+
* waiter adopts a replacement made by a peer instead of quarantining it.
|
|
198
|
+
* Final failures retain their SQLite codes and include the database path.
|
|
9
199
|
*/
|
|
10
|
-
|
|
200
|
+
export async function openSqliteDatabase<T>(
|
|
201
|
+
dbPath: string,
|
|
202
|
+
initialize: (db: Database) => T | Promise<T>,
|
|
203
|
+
options: SqliteOpenOptions = {},
|
|
204
|
+
): Promise<T> {
|
|
205
|
+
try {
|
|
206
|
+
return await openWithBusyRetries(dbPath, initialize, options);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
recoverCorruptDatabase(dbPath, error, options);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
return await openWithBusyRetries(dbPath, initialize, {});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw annotateSqliteError(error instanceof SqliteAttemptFailure ? error.original : error, dbPath);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Synchronous counterpart to {@link openSqliteDatabase}. It performs no BUSY
|
|
220
|
+
* retry loop; corruption recovery, when enabled, is bounded to one replacement.
|
|
221
|
+
*/
|
|
222
|
+
export function openSqliteDatabaseSync<T>(
|
|
223
|
+
dbPath: string,
|
|
224
|
+
initialize: (db: Database) => T,
|
|
225
|
+
options: SqliteOpenOptions = {},
|
|
226
|
+
): T {
|
|
227
|
+
try {
|
|
228
|
+
return openOnce(dbPath, initialize, options);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
recoverCorruptDatabase(dbPath, error, options);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
return openOnce(dbPath, initialize, {});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
throw annotateSqliteError(error instanceof SqliteAttemptFailure ? error.original : error, dbPath);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Adds the failing store's path to an error without losing SQLite result codes or its original stack. */
|
|
241
|
+
export function annotateSqliteError(error: unknown, dbPath: string): Error {
|
|
242
|
+
const annotated = error instanceof Error ? error : new Error(String(error));
|
|
243
|
+
annotated.message = `Database ${JSON.stringify(dbPath)}: ${annotated.message}`;
|
|
244
|
+
return annotated;
|
|
245
|
+
}
|
|
11
246
|
|
|
12
247
|
/** Checkpoints committed WAL frames without waiting for concurrent readers. */
|
|
13
248
|
export function checkpointWal(db: Database): void {
|