create-walle 0.9.23 → 0.9.25
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/package.json +1 -1
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +30 -9
- package/template/wall-e/brain.js +4 -4
- package/template/wall-e/shared/sqlite-owner-guard.js +30 -0
- package/template/wall-e/shared/sqlite-owner-write-queue.js +225 -0
- package/template/wall-e/shared/sqlite-storage-policy.js +111 -0
- package/template/wall-e/shared/sqlite-write-lock.js +428 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-walle",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.25",
|
|
4
4
|
"description": "CTM + Wall-E \u2014 AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini, Aider, OpenCode, and more, plus prompt editor, task queue, remote phone and tablet access, code/doc review, and an agent that learns from Slack, email & calendar.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-walle": "bin/create-walle.js"
|
package/template/package.json
CHANGED
package/template/wall-e/agent.js
CHANGED
|
@@ -1,21 +1,42 @@
|
|
|
1
1
|
process.env.WALL_E_PROCESS_ROLE = process.env.WALL_E_PROCESS_ROLE || 'walle-daemon';
|
|
2
|
+
|
|
3
|
+
function loadOptionalCtmLib(name) {
|
|
4
|
+
const request = `../claude-task-manager/lib/${name}`;
|
|
5
|
+
try {
|
|
6
|
+
return require(request);
|
|
7
|
+
} catch (err) {
|
|
8
|
+
if (err && err.code === 'MODULE_NOT_FOUND' && String(err.message || '').includes(request)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
throw err;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
2
15
|
// Refuse to boot under a Node major that mismatches the pinned one BEFORE brain
|
|
3
|
-
// (and its native better-sqlite3) loads. No-op in shipped installs
|
|
4
|
-
|
|
16
|
+
// (and its native better-sqlite3) loads. No-op in shipped installs and cloud
|
|
17
|
+
// builds where the CTM sibling app is not present.
|
|
18
|
+
const nodePinGuard = loadOptionalCtmLib('node-pin-guard');
|
|
19
|
+
if (nodePinGuard) nodePinGuard.assertPinnedNode();
|
|
5
20
|
try {
|
|
6
21
|
if (process.env.WALL_E_STORAGE_MIGRATION_BYPASS !== '1' && process.env.CTM_STORAGE_MIGRATION_BYPASS !== '1') {
|
|
7
|
-
const
|
|
8
|
-
if (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
22
|
+
const storageMigration = loadOptionalCtmLib('storage-migration');
|
|
23
|
+
if (storageMigration) {
|
|
24
|
+
const guard = storageMigration.startupGuard('Wall-E', process.env);
|
|
25
|
+
if (!guard.ok) {
|
|
26
|
+
console.error('[storage-migration] Wall-E startup paused while storage migration is active.');
|
|
27
|
+
process.exit(75);
|
|
28
|
+
} else if (guard.stale) {
|
|
29
|
+
console.warn('[storage-migration] Ignoring stale storage migration lock.');
|
|
30
|
+
}
|
|
13
31
|
}
|
|
14
32
|
}
|
|
15
33
|
} catch (e) {
|
|
16
34
|
console.warn('[storage-migration] Startup guard failed open:', e.message);
|
|
17
35
|
}
|
|
18
|
-
const
|
|
36
|
+
const processTitleLib = loadOptionalCtmLib('process-title');
|
|
37
|
+
const formatProcessTitle = processTitleLib
|
|
38
|
+
? processTitleLib.processTitle
|
|
39
|
+
: ((role, tag) => `Wall-E ${role}${tag ? `-${tag}` : ''}`);
|
|
19
40
|
|
|
20
41
|
const brain = require('./brain'); // brain.js loads .env from project root
|
|
21
42
|
const ingest = require('./loops/ingest');
|
package/template/wall-e/brain.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// --- WALL-E Brain: SQLite Database Layer (WAL mode, better-sqlite3) ---
|
|
2
2
|
const {
|
|
3
3
|
assertNoForeignSqliteOwner,
|
|
4
|
-
} = require('
|
|
4
|
+
} = require('./shared/sqlite-owner-guard');
|
|
5
5
|
|
|
6
6
|
assertNoForeignSqliteOwner({
|
|
7
7
|
blockedEnv: 'CTM_PROCESS_ROLE',
|
|
@@ -23,13 +23,13 @@ const { findSupportedModel, supportedModelIdsForProvider } = require('./llm/supp
|
|
|
23
23
|
const { isPortkeyProviderConfig } = require('./llm/portkey');
|
|
24
24
|
const {
|
|
25
25
|
enforceSqliteStoragePolicy,
|
|
26
|
-
} = require('
|
|
26
|
+
} = require('./shared/sqlite-storage-policy');
|
|
27
27
|
const {
|
|
28
28
|
installSqliteWriteLock,
|
|
29
|
-
} = require('
|
|
29
|
+
} = require('./shared/sqlite-write-lock');
|
|
30
30
|
const {
|
|
31
31
|
createSqliteOwnerWriteQueue,
|
|
32
|
-
} = require('
|
|
32
|
+
} = require('./shared/sqlite-owner-write-queue');
|
|
33
33
|
const _brainEvents = new EventEmitter();
|
|
34
34
|
_brainEvents.setMaxListeners(20);
|
|
35
35
|
let _stripNoise;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function _truthy(value) {
|
|
4
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function assertNoForeignSqliteOwner({
|
|
8
|
+
blockedEnv,
|
|
9
|
+
overrideEnv,
|
|
10
|
+
databaseLabel,
|
|
11
|
+
ownerLabel,
|
|
12
|
+
} = {}) {
|
|
13
|
+
const blockedRole = blockedEnv ? process.env[blockedEnv] : '';
|
|
14
|
+
if (!blockedRole || (overrideEnv && _truthy(process.env[overrideEnv]))) return;
|
|
15
|
+
|
|
16
|
+
const err = new Error(
|
|
17
|
+
`${databaseLabel || 'SQLite database'} must be accessed through its ${ownerLabel || 'owner'} API; ` +
|
|
18
|
+
`refusing direct access inside process role ${blockedEnv}=${blockedRole}.` +
|
|
19
|
+
(overrideEnv ? ` Set ${overrideEnv}=1 only for explicit standalone repair.` : '')
|
|
20
|
+
);
|
|
21
|
+
err.code = 'SQLITE_OWNER_VIOLATION';
|
|
22
|
+
err.blockedEnv = blockedEnv;
|
|
23
|
+
err.blockedRole = blockedRole;
|
|
24
|
+
err.overrideEnv = overrideEnv || '';
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
assertNoForeignSqliteOwner,
|
|
30
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_DEPTH = 1000;
|
|
4
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 5000;
|
|
5
|
+
|
|
6
|
+
function _durationMs(value, fallback) {
|
|
7
|
+
const n = Number(value);
|
|
8
|
+
return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : fallback;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function _queueError(code, message, extra = {}) {
|
|
12
|
+
const err = new Error(message);
|
|
13
|
+
err.code = code;
|
|
14
|
+
for (const [key, value] of Object.entries(extra)) err[key] = value;
|
|
15
|
+
return err;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class SqliteOwnerWriteQueue {
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.name = options.name || options.label || 'sqlite-owner-write-queue';
|
|
21
|
+
this.maxDepth = Math.max(1, Math.trunc(Number(options.maxDepth) || DEFAULT_MAX_DEPTH));
|
|
22
|
+
this._queue = [];
|
|
23
|
+
this._running = false;
|
|
24
|
+
this._active = null;
|
|
25
|
+
this._accepting = true;
|
|
26
|
+
this._closed = false;
|
|
27
|
+
this._enqueued = 0;
|
|
28
|
+
this._completed = 0;
|
|
29
|
+
this._failed = 0;
|
|
30
|
+
this._idleWaiters = [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
enqueue(fn, options = {}) {
|
|
34
|
+
if (typeof fn !== 'function') {
|
|
35
|
+
throw new TypeError('SqliteOwnerWriteQueue.enqueue requires a function');
|
|
36
|
+
}
|
|
37
|
+
if (!this._accepting || this._closed) {
|
|
38
|
+
return Promise.reject(_queueError(
|
|
39
|
+
'SQLITE_OWNER_WRITE_QUEUE_CLOSED',
|
|
40
|
+
`${this.name} is closed`
|
|
41
|
+
));
|
|
42
|
+
}
|
|
43
|
+
if (this._queue.length >= this.maxDepth) {
|
|
44
|
+
return Promise.reject(_queueError(
|
|
45
|
+
'SQLITE_OWNER_WRITE_QUEUE_FULL',
|
|
46
|
+
`${this.name} is full`,
|
|
47
|
+
{ pending: this._queue.length, maxDepth: this.maxDepth }
|
|
48
|
+
));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const signal = options.signal || null;
|
|
52
|
+
if (signal?.aborted) {
|
|
53
|
+
return Promise.reject(_queueError(
|
|
54
|
+
'SQLITE_OWNER_WRITE_QUEUE_ABORTED',
|
|
55
|
+
`${this.name} task aborted before enqueue`
|
|
56
|
+
));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const label = options.label || 'write';
|
|
60
|
+
let task;
|
|
61
|
+
const promise = new Promise((resolve, reject) => {
|
|
62
|
+
task = {
|
|
63
|
+
fn,
|
|
64
|
+
label,
|
|
65
|
+
resolve,
|
|
66
|
+
reject,
|
|
67
|
+
enqueuedAt: Date.now(),
|
|
68
|
+
started: false,
|
|
69
|
+
signal,
|
|
70
|
+
abortListener: null,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (signal) {
|
|
75
|
+
task.abortListener = () => {
|
|
76
|
+
if (task.started) return;
|
|
77
|
+
const idx = this._queue.indexOf(task);
|
|
78
|
+
if (idx >= 0) this._queue.splice(idx, 1);
|
|
79
|
+
task.reject(_queueError(
|
|
80
|
+
'SQLITE_OWNER_WRITE_QUEUE_ABORTED',
|
|
81
|
+
`${this.name} task aborted while queued`,
|
|
82
|
+
{ label }
|
|
83
|
+
));
|
|
84
|
+
this._notifyIdleIfNeeded();
|
|
85
|
+
};
|
|
86
|
+
signal.addEventListener('abort', task.abortListener, { once: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this._enqueued += 1;
|
|
90
|
+
this._queue.push(task);
|
|
91
|
+
this._pump();
|
|
92
|
+
return promise;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getStatus() {
|
|
96
|
+
return {
|
|
97
|
+
name: this.name,
|
|
98
|
+
active: this._active,
|
|
99
|
+
running: this._running,
|
|
100
|
+
pending: this._queue.length,
|
|
101
|
+
accepting: this._accepting,
|
|
102
|
+
closed: this._closed,
|
|
103
|
+
maxDepth: this.maxDepth,
|
|
104
|
+
enqueued: this._enqueued,
|
|
105
|
+
completed: this._completed,
|
|
106
|
+
failed: this._failed,
|
|
107
|
+
idle: !this._running && this._queue.length === 0,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async drain(options = {}) {
|
|
112
|
+
const timeoutMs = _durationMs(options.timeoutMs, DEFAULT_DRAIN_TIMEOUT_MS);
|
|
113
|
+
if (!this._running && this._queue.length === 0) {
|
|
114
|
+
return { ok: true, timedOut: false, ...this.getStatus() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (timeoutMs === 0) {
|
|
118
|
+
await new Promise((resolve) => this._idleWaiters.push(resolve));
|
|
119
|
+
return { ok: true, timedOut: false, ...this.getStatus() };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let timer = null;
|
|
123
|
+
let idleWaiter = null;
|
|
124
|
+
try {
|
|
125
|
+
return await Promise.race([
|
|
126
|
+
new Promise((resolve) => {
|
|
127
|
+
idleWaiter = () => resolve({
|
|
128
|
+
ok: true,
|
|
129
|
+
timedOut: false,
|
|
130
|
+
...this.getStatus(),
|
|
131
|
+
});
|
|
132
|
+
this._idleWaiters.push(idleWaiter);
|
|
133
|
+
}),
|
|
134
|
+
new Promise((resolve) => {
|
|
135
|
+
timer = setTimeout(() => resolve({
|
|
136
|
+
ok: false,
|
|
137
|
+
timedOut: true,
|
|
138
|
+
...this.getStatus(),
|
|
139
|
+
}), timeoutMs);
|
|
140
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
141
|
+
}),
|
|
142
|
+
]);
|
|
143
|
+
} finally {
|
|
144
|
+
if (timer) clearTimeout(timer);
|
|
145
|
+
if (idleWaiter) {
|
|
146
|
+
const idx = this._idleWaiters.indexOf(idleWaiter);
|
|
147
|
+
if (idx >= 0) this._idleWaiters.splice(idx, 1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async close(options = {}) {
|
|
153
|
+
const drain = !!options.drain;
|
|
154
|
+
this._accepting = false;
|
|
155
|
+
this._closed = true;
|
|
156
|
+
if (!drain) {
|
|
157
|
+
this._rejectPending(_queueError(
|
|
158
|
+
'SQLITE_OWNER_WRITE_QUEUE_CLOSED',
|
|
159
|
+
`${this.name} closed with pending writes rejected`
|
|
160
|
+
));
|
|
161
|
+
return { ok: true, drained: false, ...this.getStatus() };
|
|
162
|
+
}
|
|
163
|
+
const result = await this.drain({ timeoutMs: options.timeoutMs });
|
|
164
|
+
return { ...result, drained: !result.timedOut };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_pump() {
|
|
168
|
+
if (this._running) return;
|
|
169
|
+
const task = this._queue.shift();
|
|
170
|
+
if (!task) {
|
|
171
|
+
this._notifyIdleIfNeeded();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this._running = true;
|
|
176
|
+
this._active = task.label;
|
|
177
|
+
task.started = true;
|
|
178
|
+
if (task.signal && task.abortListener) {
|
|
179
|
+
task.signal.removeEventListener('abort', task.abortListener);
|
|
180
|
+
task.abortListener = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
Promise.resolve()
|
|
184
|
+
.then(() => task.fn())
|
|
185
|
+
.then((value) => {
|
|
186
|
+
this._completed += 1;
|
|
187
|
+
task.resolve(value);
|
|
188
|
+
}, (err) => {
|
|
189
|
+
this._failed += 1;
|
|
190
|
+
task.reject(err);
|
|
191
|
+
})
|
|
192
|
+
.finally(() => {
|
|
193
|
+
this._running = false;
|
|
194
|
+
this._active = null;
|
|
195
|
+
this._pump();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_rejectPending(err) {
|
|
200
|
+
const pending = this._queue.splice(0);
|
|
201
|
+
for (const task of pending) {
|
|
202
|
+
if (task.signal && task.abortListener) {
|
|
203
|
+
task.signal.removeEventListener('abort', task.abortListener);
|
|
204
|
+
task.abortListener = null;
|
|
205
|
+
}
|
|
206
|
+
task.reject(err);
|
|
207
|
+
}
|
|
208
|
+
this._notifyIdleIfNeeded();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_notifyIdleIfNeeded() {
|
|
212
|
+
if (this._running || this._queue.length > 0) return;
|
|
213
|
+
const waiters = this._idleWaiters.splice(0);
|
|
214
|
+
for (const resolve of waiters) resolve();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function createSqliteOwnerWriteQueue(options = {}) {
|
|
219
|
+
return new SqliteOwnerWriteQueue(options);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
SqliteOwnerWriteQueue,
|
|
224
|
+
createSqliteOwnerWriteQueue,
|
|
225
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const CLOUD_STORAGE_PATTERNS = Object.freeze([
|
|
7
|
+
{ id: 'dropbox', label: 'Dropbox', regex: /(^|[\\/])(?:Dropbox|CloudStorage[\\/][^\\/]*Dropbox[^\\/]*)([\\/]|$)/i },
|
|
8
|
+
{ id: 'icloud', label: 'iCloud Drive', regex: /(^|[\\/])(?:Mobile Documents|CloudStorage[\\/][^\\/]*iCloud[^\\/]*)([\\/]|$)/i },
|
|
9
|
+
{ id: 'onedrive', label: 'OneDrive', regex: /(^|[\\/])(?:OneDrive|CloudStorage[\\/][^\\/]*OneDrive[^\\/]*)([\\/]|$)/i },
|
|
10
|
+
{ id: 'google-drive', label: 'Google Drive', regex: /(^|[\\/])(?:Google Drive|CloudStorage[\\/][^\\/]*GoogleDrive[^\\/]*)([\\/]|$)/i },
|
|
11
|
+
{ id: 'box', label: 'Box Drive', regex: /(^|[\\/])(?:Box|CloudStorage[\\/][^\\/]*Box[^\\/]*)([\\/]|$)/i },
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const VALID_POLICY_MODES = new Set(['off', 'warn', 'error']);
|
|
15
|
+
const warnedKeys = new Set();
|
|
16
|
+
|
|
17
|
+
function _normalizePath(value) {
|
|
18
|
+
const raw = String(value || '').trim();
|
|
19
|
+
if (!raw) return '';
|
|
20
|
+
try {
|
|
21
|
+
const dir = path.dirname(raw);
|
|
22
|
+
const base = path.basename(raw);
|
|
23
|
+
const realDir = fs.existsSync(dir) ? fs.realpathSync(dir) : path.resolve(dir);
|
|
24
|
+
return path.join(realDir, base);
|
|
25
|
+
} catch {
|
|
26
|
+
return path.resolve(raw);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _truthy(value) {
|
|
31
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sqliteStoragePolicyMode({ env = process.env, prefix = '' } = {}) {
|
|
35
|
+
const normalizedPrefix = String(prefix || '').trim().replace(/[^A-Z0-9_]/gi, '_').toUpperCase();
|
|
36
|
+
if (normalizedPrefix && _truthy(env[`${normalizedPrefix}_ALLOW_UNSAFE_CLOUD_SQLITE`])) return 'off';
|
|
37
|
+
if (_truthy(env.ALLOW_UNSAFE_CLOUD_SQLITE)) return 'off';
|
|
38
|
+
|
|
39
|
+
const raw = String(
|
|
40
|
+
(normalizedPrefix && env[`${normalizedPrefix}_SQLITE_STORAGE_POLICY`])
|
|
41
|
+
|| env.SQLITE_STORAGE_POLICY
|
|
42
|
+
|| 'warn'
|
|
43
|
+
).trim().toLowerCase();
|
|
44
|
+
return VALID_POLICY_MODES.has(raw) ? raw : 'warn';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function classifySqlitePathRisk(dbPath) {
|
|
48
|
+
const normalizedPath = _normalizePath(dbPath);
|
|
49
|
+
const normalizedForMatch = normalizedPath.replace(/\\/g, '/');
|
|
50
|
+
const match = CLOUD_STORAGE_PATTERNS.find((candidate) => candidate.regex.test(normalizedForMatch));
|
|
51
|
+
if (!match) {
|
|
52
|
+
return {
|
|
53
|
+
risky: false,
|
|
54
|
+
reason: '',
|
|
55
|
+
provider: '',
|
|
56
|
+
providerLabel: '',
|
|
57
|
+
dbPath: normalizedPath,
|
|
58
|
+
message: '',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
risky: true,
|
|
63
|
+
reason: 'cloud_sync_sqlite_wal',
|
|
64
|
+
provider: match.id,
|
|
65
|
+
providerLabel: match.label,
|
|
66
|
+
dbPath: normalizedPath,
|
|
67
|
+
message:
|
|
68
|
+
`${match.label} sync storage is unsafe for a live SQLite WAL database. `
|
|
69
|
+
+ 'Move the live DB directory to a local disk path and sync backups/exports instead.',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function enforceSqliteStoragePolicy(dbPath, {
|
|
74
|
+
env = process.env,
|
|
75
|
+
prefix = '',
|
|
76
|
+
label = 'SQLite database',
|
|
77
|
+
logger = console,
|
|
78
|
+
} = {}) {
|
|
79
|
+
const risk = classifySqlitePathRisk(dbPath);
|
|
80
|
+
const mode = sqliteStoragePolicyMode({ env, prefix });
|
|
81
|
+
const result = { ...risk, mode, blocked: false, label };
|
|
82
|
+
if (!risk.risky || mode === 'off') return result;
|
|
83
|
+
|
|
84
|
+
const message = `${label}: ${risk.message} Path: ${risk.dbPath}`;
|
|
85
|
+
if (mode === 'error') {
|
|
86
|
+
const err = new Error(message);
|
|
87
|
+
err.code = 'SQLITE_UNSAFE_CLOUD_STORAGE';
|
|
88
|
+
err.storageRisk = result;
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const warnKey = `${label}:${risk.provider}:${risk.dbPath}`;
|
|
93
|
+
if (!warnedKeys.has(warnKey)) {
|
|
94
|
+
warnedKeys.add(warnKey);
|
|
95
|
+
try {
|
|
96
|
+
logger.warn(`[sqlite-storage] ${message}`);
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resetSqliteStoragePolicyWarnings() {
|
|
103
|
+
warnedKeys.clear();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
classifySqlitePathRisk,
|
|
108
|
+
enforceSqliteStoragePolicy,
|
|
109
|
+
resetSqliteStoragePolicyWarnings,
|
|
110
|
+
sqliteStoragePolicyMode,
|
|
111
|
+
};
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { threadId } = require('node:worker_threads');
|
|
7
|
+
|
|
8
|
+
const WRITE_SQL_RE = /^\s*(?:INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|VACUUM|REINDEX|ANALYZE|ATTACH|DETACH|BEGIN|COMMIT|ROLLBACK)\b/i;
|
|
9
|
+
const WRITE_PRAGMA_RE = /^\s*(?:journal_mode|wal_checkpoint|user_version|foreign_keys|synchronous|locking_mode|auto_vacuum|optimize)\b/i;
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 0;
|
|
11
|
+
const DEFAULT_STALE_MS = 10 * 60 * 1000;
|
|
12
|
+
const DEFAULT_POLL_MS = 25;
|
|
13
|
+
const DEFAULT_CLAIM_STALE_MS = 1000;
|
|
14
|
+
const DEFAULT_STALE_RECOVERY_TIMEOUT_MS = 5000;
|
|
15
|
+
const GLOBAL_STATE_KEY = Symbol.for('ctm.sqliteWriteLockState');
|
|
16
|
+
|
|
17
|
+
const globalState = globalThis[GLOBAL_STATE_KEY] || (globalThis[GLOBAL_STATE_KEY] = {
|
|
18
|
+
token: `${process.pid}:${threadId}:${crypto.randomBytes(8).toString('hex')}`,
|
|
19
|
+
locks: new Map(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function _sleepSync(ms) {
|
|
23
|
+
const buffer = new SharedArrayBuffer(4);
|
|
24
|
+
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function _pidAlive(pid) {
|
|
28
|
+
const n = Number(pid);
|
|
29
|
+
if (!Number.isFinite(n) || n <= 0) return false;
|
|
30
|
+
try {
|
|
31
|
+
process.kill(n, 0);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _readLock(lockPath) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _inspectLock(lockPath) {
|
|
47
|
+
let stat = null;
|
|
48
|
+
try {
|
|
49
|
+
stat = fs.statSync(lockPath);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error?.code === 'ENOENT') return { exists: false, stat: null, raw: '', info: null, parseError: null };
|
|
52
|
+
return { exists: true, stat: null, raw: '', info: null, parseError: error };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let raw = '';
|
|
56
|
+
try {
|
|
57
|
+
raw = fs.readFileSync(lockPath, 'utf8');
|
|
58
|
+
try {
|
|
59
|
+
return { exists: true, stat, raw, info: JSON.parse(raw), parseError: null };
|
|
60
|
+
} catch (parseError) {
|
|
61
|
+
return { exists: true, stat, raw, info: null, parseError };
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code === 'ENOENT') return { exists: false, stat: null, raw: '', info: null, parseError: null };
|
|
65
|
+
return { exists: true, stat, raw, info: null, parseError: error };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Canonical path is stable for the process lifetime (the parent directory's symlink
|
|
70
|
+
// chain doesn't change), so cache it. _acquire runs this on every write — without the
|
|
71
|
+
// cache it pays a realpathSync.native syscall per write, which is pure overhead on the
|
|
72
|
+
// event loop (and worse when the lock file lives on a sync'd filesystem like Dropbox).
|
|
73
|
+
const _canonicalPathCache = new Map();
|
|
74
|
+
function _canonicalLockPath(lockPath) {
|
|
75
|
+
const resolved = path.resolve(lockPath);
|
|
76
|
+
const cached = _canonicalPathCache.get(resolved);
|
|
77
|
+
if (cached !== undefined) return cached;
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
result = fs.realpathSync.native(resolved);
|
|
81
|
+
} catch {
|
|
82
|
+
try {
|
|
83
|
+
const dir = fs.realpathSync.native(path.dirname(resolved));
|
|
84
|
+
result = path.join(dir, path.basename(resolved));
|
|
85
|
+
} catch {
|
|
86
|
+
result = resolved;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
_canonicalPathCache.set(resolved, result);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _releaseLockFile(lockPath, token) {
|
|
94
|
+
try {
|
|
95
|
+
const info = _readLock(lockPath);
|
|
96
|
+
if (info?.token === token) fs.unlinkSync(lockPath);
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _lockAgeMs(inspected, now = Date.now()) {
|
|
101
|
+
const createdAt = Number(inspected?.info?.created_at_ms || 0);
|
|
102
|
+
const basis = createdAt || Number(inspected?.stat?.mtimeMs || 0);
|
|
103
|
+
if (!basis) return Number.POSITIVE_INFINITY;
|
|
104
|
+
return Math.max(0, now - basis);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _lockUnchanged(a, b) {
|
|
108
|
+
if (!a?.exists || !b?.exists) return false;
|
|
109
|
+
if (a.info?.token || b.info?.token) return a.info?.token && a.info.token === b.info?.token;
|
|
110
|
+
if (!a.stat || !b.stat) return false;
|
|
111
|
+
if (a.raw || b.raw) return a.raw === b.raw && a.stat.size === b.stat.size && Math.abs(a.stat.mtimeMs - b.stat.mtimeMs) < 1;
|
|
112
|
+
return a.stat.dev === b.stat.dev &&
|
|
113
|
+
a.stat.ino === b.stat.ino &&
|
|
114
|
+
a.stat.size === b.stat.size &&
|
|
115
|
+
Math.abs(a.stat.mtimeMs - b.stat.mtimeMs) < 1 &&
|
|
116
|
+
Math.abs(a.stat.ctimeMs - b.stat.ctimeMs) < 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _unlinkIfUnchanged(lockPath, observed) {
|
|
120
|
+
const current = _inspectLock(lockPath);
|
|
121
|
+
if (!current.exists) return true;
|
|
122
|
+
if (!_lockUnchanged(observed, current)) return false;
|
|
123
|
+
try {
|
|
124
|
+
fs.unlinkSync(lockPath);
|
|
125
|
+
return true;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return error?.code === 'ENOENT';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _staleReason(inspected, staleMs, claimStaleMs) {
|
|
132
|
+
if (!inspected?.exists) return 'missing';
|
|
133
|
+
const ageMs = _lockAgeMs(inspected);
|
|
134
|
+
if (!inspected.info) {
|
|
135
|
+
return ageMs > claimStaleMs ? 'orphaned-claim' : '';
|
|
136
|
+
}
|
|
137
|
+
if (ageMs > staleMs) return 'expired';
|
|
138
|
+
return _pidAlive(inspected.info?.pid) ? '' : 'dead-owner';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _writeLockClaim(lockPath, payload) {
|
|
142
|
+
const dir = path.dirname(lockPath);
|
|
143
|
+
const base = path.basename(lockPath);
|
|
144
|
+
const tempPath = path.join(dir, `.${base}.${process.pid}.${threadId}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
145
|
+
const text = JSON.stringify(payload);
|
|
146
|
+
try {
|
|
147
|
+
fs.writeFileSync(tempPath, text, { flag: 'wx', mode: 0o600 });
|
|
148
|
+
fs.linkSync(tempPath, lockPath);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error?.code !== 'EEXIST' && ['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV'].includes(error?.code)) {
|
|
151
|
+
let fd = null;
|
|
152
|
+
try {
|
|
153
|
+
fd = fs.openSync(lockPath, 'wx', 0o600);
|
|
154
|
+
fs.writeFileSync(fd, text);
|
|
155
|
+
return;
|
|
156
|
+
} finally {
|
|
157
|
+
try { if (fd !== null) fs.closeSync(fd); } catch {}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
} finally {
|
|
162
|
+
try { fs.unlinkSync(tempPath); } catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _durationMs(value, fallback, min = 0) {
|
|
167
|
+
const n = Number(value);
|
|
168
|
+
return Number.isFinite(n) ? Math.max(min, Math.trunc(n)) : fallback;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _lockBusyError(lockPath, info) {
|
|
172
|
+
const owner = info?.pid ? ` pid=${info.pid}` : '';
|
|
173
|
+
const label = info?.label ? ` label=${info.label}` : '';
|
|
174
|
+
const err = new Error(`SQLite write lock busy${owner}${label}: ${lockPath}`);
|
|
175
|
+
err.code = 'SQLITE_WRITE_LOCK_BUSY';
|
|
176
|
+
err.lockPath = lockPath;
|
|
177
|
+
err.lockInfo = info || null;
|
|
178
|
+
return err;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _stripLeadingSqlComments(sql) {
|
|
182
|
+
let text = String(sql || '');
|
|
183
|
+
while (true) {
|
|
184
|
+
const next = text.replace(/^\s+/, '');
|
|
185
|
+
if (next.startsWith('--')) {
|
|
186
|
+
const idx = next.indexOf('\n');
|
|
187
|
+
text = idx === -1 ? '' : next.slice(idx + 1);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (next.startsWith('/*')) {
|
|
191
|
+
const idx = next.indexOf('*/');
|
|
192
|
+
text = idx === -1 ? '' : next.slice(idx + 2);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
return next;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _acquire(lockPath, opts = {}) {
|
|
200
|
+
const timeoutMs = _durationMs(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 0);
|
|
201
|
+
const staleMs = _durationMs(opts.staleMs ?? DEFAULT_STALE_MS, DEFAULT_STALE_MS, 1000);
|
|
202
|
+
// Floor of 1ms (not 5) so a background writer can poll tightly and reliably win
|
|
203
|
+
// the lock in the brief window after the main thread releases — the main thread
|
|
204
|
+
// can never wait, so the worker winning fast is how we avoid starving it.
|
|
205
|
+
const pollMs = _durationMs(opts.pollMs ?? DEFAULT_POLL_MS, DEFAULT_POLL_MS, 1);
|
|
206
|
+
const claimStaleMs = _durationMs(opts.claimStaleMs ?? DEFAULT_CLAIM_STALE_MS, DEFAULT_CLAIM_STALE_MS, 1);
|
|
207
|
+
const staleRecoveryTimeoutMs = _durationMs(
|
|
208
|
+
opts.staleRecoveryTimeoutMs ?? DEFAULT_STALE_RECOVERY_TIMEOUT_MS,
|
|
209
|
+
DEFAULT_STALE_RECOVERY_TIMEOUT_MS,
|
|
210
|
+
0
|
|
211
|
+
);
|
|
212
|
+
const startedAt = Date.now();
|
|
213
|
+
const deadline = startedAt + timeoutMs;
|
|
214
|
+
const dir = path.dirname(lockPath);
|
|
215
|
+
const canonical = _canonicalLockPath(lockPath);
|
|
216
|
+
let staleRecoveryDeadline = 0;
|
|
217
|
+
let contended = false;
|
|
218
|
+
const held = globalState.locks.get(canonical);
|
|
219
|
+
if (held) {
|
|
220
|
+
held.depth += 1;
|
|
221
|
+
return {
|
|
222
|
+
waitedMs: 0,
|
|
223
|
+
contended: false,
|
|
224
|
+
release: () => {
|
|
225
|
+
held.depth -= 1;
|
|
226
|
+
if (held.depth <= 0) {
|
|
227
|
+
globalState.locks.delete(canonical);
|
|
228
|
+
_releaseLockFile(held.lockPath, held.token);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
235
|
+
|
|
236
|
+
while (true) {
|
|
237
|
+
try {
|
|
238
|
+
const token = globalState.token;
|
|
239
|
+
_writeLockClaim(lockPath, {
|
|
240
|
+
pid: process.pid,
|
|
241
|
+
thread_id: threadId,
|
|
242
|
+
token,
|
|
243
|
+
created_at_ms: Date.now(),
|
|
244
|
+
label: opts.label || '',
|
|
245
|
+
});
|
|
246
|
+
const entry = { depth: 1, lockPath, token };
|
|
247
|
+
globalState.locks.set(canonical, entry);
|
|
248
|
+
return {
|
|
249
|
+
waitedMs: Date.now() - startedAt,
|
|
250
|
+
contended,
|
|
251
|
+
release: () => {
|
|
252
|
+
entry.depth -= 1;
|
|
253
|
+
if (entry.depth > 0) return;
|
|
254
|
+
globalState.locks.delete(canonical);
|
|
255
|
+
_releaseLockFile(lockPath, token);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err?.code !== 'EEXIST') throw err;
|
|
260
|
+
contended = true;
|
|
261
|
+
const observed = _inspectLock(lockPath);
|
|
262
|
+
const staleReason = _staleReason(observed, staleMs, claimStaleMs);
|
|
263
|
+
if (staleReason) {
|
|
264
|
+
if (_unlinkIfUnchanged(lockPath, observed)) continue;
|
|
265
|
+
if (!staleRecoveryDeadline) staleRecoveryDeadline = Date.now() + staleRecoveryTimeoutMs;
|
|
266
|
+
if (Date.now() < staleRecoveryDeadline) {
|
|
267
|
+
_sleepSync(pollMs);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
} else if (!observed.info) {
|
|
271
|
+
const waitMs = Math.min(pollMs, Math.max(1, claimStaleMs - _lockAgeMs(observed)));
|
|
272
|
+
_sleepSync(waitMs);
|
|
273
|
+
continue;
|
|
274
|
+
} else {
|
|
275
|
+
staleRecoveryDeadline = 0;
|
|
276
|
+
}
|
|
277
|
+
if (Date.now() >= deadline) {
|
|
278
|
+
throw _lockBusyError(lockPath, observed.info);
|
|
279
|
+
}
|
|
280
|
+
_sleepSync(Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function createSqliteWriteLock(lockPath, opts = {}) {
|
|
286
|
+
let depth = 0;
|
|
287
|
+
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null;
|
|
288
|
+
return function withSqliteWriteLock(fn, label = opts.label || '') {
|
|
289
|
+
// Reentrant within the same connection: already inside a held write lock, so
|
|
290
|
+
// no re-acquire and no event (the outer call owns the timing).
|
|
291
|
+
if (depth > 0) return fn();
|
|
292
|
+
depth += 1;
|
|
293
|
+
let acquired = null;
|
|
294
|
+
const waitStart = onEvent ? Date.now() : 0;
|
|
295
|
+
try {
|
|
296
|
+
try {
|
|
297
|
+
acquired = _acquire(lockPath, { ...opts, label });
|
|
298
|
+
} catch (err) {
|
|
299
|
+
// Surface contention to instrumentation even when the acquire fails fast
|
|
300
|
+
// (e.g. main-thread 0ms timeout) so callers can see busy/starvation.
|
|
301
|
+
if (onEvent) {
|
|
302
|
+
try {
|
|
303
|
+
// Pass the busy error so instrumentation can attribute the lock HOLDER
|
|
304
|
+
// (the error message carries the holder pid). Extra field; existing
|
|
305
|
+
// consumers that ignore it are unaffected.
|
|
306
|
+
onEvent({ label, waitedMs: Date.now() - waitStart, heldMs: 0, contended: true, acquired: false, error: err });
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
const heldStart = onEvent ? Date.now() : 0;
|
|
312
|
+
try {
|
|
313
|
+
return fn();
|
|
314
|
+
} finally {
|
|
315
|
+
try { acquired.release(); } finally {
|
|
316
|
+
if (onEvent) {
|
|
317
|
+
try {
|
|
318
|
+
onEvent({
|
|
319
|
+
label,
|
|
320
|
+
waitedMs: acquired.waitedMs,
|
|
321
|
+
heldMs: Date.now() - heldStart,
|
|
322
|
+
contended: acquired.contended,
|
|
323
|
+
acquired: true,
|
|
324
|
+
});
|
|
325
|
+
} catch {}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} finally {
|
|
330
|
+
depth -= 1;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Async, bounded retry for SQLITE_WRITE_LOCK_BUSY. Safe because the busy error is
|
|
336
|
+
// always thrown while ACQUIRING the lock — before the wrapped write runs — so the
|
|
337
|
+
// failed write never partially applied. Used by background (worker) write paths so
|
|
338
|
+
// transient contention retries instead of surfacing to the user. Never use this on
|
|
339
|
+
// the main thread's hot path with a blocking sleep; the delay here is async.
|
|
340
|
+
async function retryOnWriteLockBusy(fn, opts = {}) {
|
|
341
|
+
const retries = Number.isInteger(opts.retries) ? Math.max(0, opts.retries) : 2;
|
|
342
|
+
const backoffMs = Number.isFinite(opts.backoffMs) ? Math.max(1, opts.backoffMs) : 50;
|
|
343
|
+
let attempt = 0;
|
|
344
|
+
while (true) {
|
|
345
|
+
try {
|
|
346
|
+
return await fn();
|
|
347
|
+
} catch (err) {
|
|
348
|
+
if (err?.code !== 'SQLITE_WRITE_LOCK_BUSY' || attempt >= retries) throw err;
|
|
349
|
+
attempt += 1;
|
|
350
|
+
if (typeof opts.onRetry === 'function') {
|
|
351
|
+
try { opts.onRetry({ attempt, retries, error: err }); } catch {}
|
|
352
|
+
}
|
|
353
|
+
const jitter = Math.floor(Math.random() * backoffMs);
|
|
354
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt + jitter));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isLikelyWriteSql(sql) {
|
|
360
|
+
return WRITE_SQL_RE.test(_stripLeadingSqlComments(sql));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isLikelyWritePragma(pragma) {
|
|
364
|
+
const text = _stripLeadingSqlComments(pragma);
|
|
365
|
+
if (/^\s*PRAGMA\b/i.test(text)) return WRITE_PRAGMA_RE.test(text.replace(/^\s*PRAGMA\b/i, ''));
|
|
366
|
+
return WRITE_PRAGMA_RE.test(text);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function containsLikelyWriteSql(sql) {
|
|
370
|
+
return String(sql || '')
|
|
371
|
+
.split(';')
|
|
372
|
+
.some((statement) => {
|
|
373
|
+
const text = _stripLeadingSqlComments(statement);
|
|
374
|
+
if (!text) return false;
|
|
375
|
+
return isLikelyWriteSql(text) || isLikelyWritePragma(text);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function installSqliteWriteLock(db, dbPath, opts = {}) {
|
|
380
|
+
if (!db || db.__sqliteWriteLockInstalled) return db;
|
|
381
|
+
const lockPath = opts.lockPath || `${dbPath}.write-lock`;
|
|
382
|
+
const withLock = createSqliteWriteLock(lockPath, opts);
|
|
383
|
+
|
|
384
|
+
const originalPrepare = db.prepare.bind(db);
|
|
385
|
+
db.prepare = function prepareWithWriteLock(sql, ...args) {
|
|
386
|
+
const stmt = originalPrepare(sql, ...args);
|
|
387
|
+
if (!isLikelyWriteSql(sql) || typeof stmt.run !== 'function') return stmt;
|
|
388
|
+
const originalRun = stmt.run.bind(stmt);
|
|
389
|
+
stmt.run = (...runArgs) => withLock(() => originalRun(...runArgs), 'statement');
|
|
390
|
+
return stmt;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const originalExec = db.exec.bind(db);
|
|
394
|
+
db.exec = function execWithWriteLock(sql, ...args) {
|
|
395
|
+
if (!containsLikelyWriteSql(sql)) return originalExec(sql, ...args);
|
|
396
|
+
return withLock(() => originalExec(sql, ...args), 'exec');
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const originalPragma = db.pragma.bind(db);
|
|
400
|
+
db.pragma = function pragmaWithWriteLock(pragma, ...args) {
|
|
401
|
+
if (!isLikelyWritePragma(pragma)) return originalPragma(pragma, ...args);
|
|
402
|
+
return withLock(() => originalPragma(pragma, ...args), 'pragma');
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const originalTransaction = db.transaction.bind(db);
|
|
406
|
+
db.transaction = function transactionWithWriteLock(fn) {
|
|
407
|
+
const txn = originalTransaction(fn);
|
|
408
|
+
const wrap = (call, label) => (...args) => withLock(() => call(...args), label);
|
|
409
|
+
const wrapped = wrap(txn, 'transaction');
|
|
410
|
+
for (const mode of ['default', 'deferred', 'immediate', 'exclusive']) {
|
|
411
|
+
if (typeof txn[mode] === 'function') wrapped[mode] = wrap(txn[mode].bind(txn), `transaction:${mode}`);
|
|
412
|
+
}
|
|
413
|
+
return wrapped;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
Object.defineProperty(db, '__sqliteWriteLockInstalled', { value: true });
|
|
417
|
+
Object.defineProperty(db, '__sqliteWriteLockPath', { value: lockPath });
|
|
418
|
+
return db;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
module.exports = {
|
|
422
|
+
createSqliteWriteLock,
|
|
423
|
+
containsLikelyWriteSql,
|
|
424
|
+
installSqliteWriteLock,
|
|
425
|
+
isLikelyWritePragma,
|
|
426
|
+
isLikelyWriteSql,
|
|
427
|
+
retryOnWriteLockBusy,
|
|
428
|
+
};
|