whalibmob 5.30.0 → 5.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -0
- package/index.d.ts +203 -0
- package/index.js +24 -0
- package/lib/store/Backend.js +143 -0
- package/lib/store/FileBackend.js +178 -0
- package/lib/store/MemoryBackend.js +73 -0
- package/lib/store/SqliteBackend.js +306 -0
- package/lib/store/migrate.js +82 -0
- package/llms.txt +167 -0
- package/package.json +2 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The session on disk, as JSON files — what whalibmob has always done.
|
|
4
|
+
//
|
|
5
|
+
// This is the default and it writes exactly the files the library wrote
|
|
6
|
+
// before there was a backend at all: same directory, same names, same
|
|
7
|
+
// contents. A session saved by an older version opens here without being
|
|
8
|
+
// touched, and one saved here opens in an older version. The file names are
|
|
9
|
+
// the contract with everything already installed, so they are settled in the
|
|
10
|
+
// table below and not derived anywhere else.
|
|
11
|
+
//
|
|
12
|
+
// A number has two halves that never share state — the one registered over
|
|
13
|
+
// the Mobile API and the companion linked over the Web API — and a backend
|
|
14
|
+
// covers one of them. Which one is decided by `web` at construction, and it
|
|
15
|
+
// is the whole difference between the two: the companion's files carry .web
|
|
16
|
+
// ahead of the suffix, so 919634847671.signal.json and
|
|
17
|
+
// 919634847671.web.signal.json sit side by side in one directory without ever
|
|
18
|
+
// being confused for each other.
|
|
19
|
+
//
|
|
20
|
+
// Writes go to a temporary file and are renamed into place. The old code
|
|
21
|
+
// wrote some files straight and others through a rename; doing it one way
|
|
22
|
+
// everywhere costs nothing and means a session cannot be left half written by
|
|
23
|
+
// a process that died mid-write — which, for the file holding a number's
|
|
24
|
+
// identity keys, is the difference between a session and a lost number.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const { preKeyId } = require('./Backend');
|
|
30
|
+
|
|
31
|
+
// key → the part of the file name that follows the number (and the .web that
|
|
32
|
+
// marks the companion half). These are the names in SessionPaths.SESSION_SUFFIXES;
|
|
33
|
+
// the two lists describe the same files and have to stay in step.
|
|
34
|
+
const KEY_SUFFIX = Object.freeze({
|
|
35
|
+
'auth': '.json',
|
|
36
|
+
'signal': '.signal.json',
|
|
37
|
+
'sender-key': '.sk.json',
|
|
38
|
+
'tc-token': '.tctoken.json',
|
|
39
|
+
'device-cache': '.device-cache.json',
|
|
40
|
+
'lid-mapping': '.lid-mapping.json',
|
|
41
|
+
'lid-reverse-mapping': '.lid-reverse-mapping.json',
|
|
42
|
+
'history': '.history.json',
|
|
43
|
+
'messages': '.messages.json',
|
|
44
|
+
'app-state': '.appState.json',
|
|
45
|
+
'app-state-keys': '.appStateKeys.json'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// The one-time pre-keys are a file each, named by id.
|
|
49
|
+
const PRE_KEY_INFIX = '.pre-key-';
|
|
50
|
+
|
|
51
|
+
function _escapeRe(s) {
|
|
52
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class FileBackend {
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* @param {string} opts.dir the directory this session's files live in
|
|
59
|
+
* @param {string} opts.phone the number, digits only
|
|
60
|
+
* @param {boolean} [opts.web] the companion half rather than the mobile one
|
|
61
|
+
*/
|
|
62
|
+
constructor(opts) {
|
|
63
|
+
if (!opts || !opts.dir) throw new TypeError('FileBackend: dir is required');
|
|
64
|
+
if (!opts.phone) throw new TypeError('FileBackend: phone is required');
|
|
65
|
+
|
|
66
|
+
this.dir = String(opts.dir);
|
|
67
|
+
this.phone = String(opts.phone).replace(/\D/g, '');
|
|
68
|
+
this.web = !!opts.web;
|
|
69
|
+
|
|
70
|
+
if (!this.phone) throw new TypeError('FileBackend: phone must contain digits');
|
|
71
|
+
|
|
72
|
+
// <phone> for the mobile half, <phone>.web for the companion.
|
|
73
|
+
this._stem = this.phone + (this.web ? '.web' : '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The file a key is kept in. Absolute, whether or not it exists. */
|
|
77
|
+
fileFor(key) {
|
|
78
|
+
const id = preKeyId(key);
|
|
79
|
+
if (id !== null) {
|
|
80
|
+
return path.join(this.dir, this._stem + PRE_KEY_INFIX + id + '.json');
|
|
81
|
+
}
|
|
82
|
+
const suffix = KEY_SUFFIX[key];
|
|
83
|
+
if (!suffix) throw new Error('FileBackend: unknown key ' + JSON.stringify(key));
|
|
84
|
+
return path.join(this.dir, this._stem + suffix);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
read(key) {
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = fs.readFileSync(this.fileFor(key), 'utf8');
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// A key that was never written reads as absent, the way a fresh session
|
|
93
|
+
// reads. Anything else — a permission problem, a directory where a file
|
|
94
|
+
// should be — is the caller's to know about, because silently treating
|
|
95
|
+
// it as absent starts a brand new session over the top of a real one.
|
|
96
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
write(key, value) {
|
|
103
|
+
if (typeof value !== 'string') {
|
|
104
|
+
throw new TypeError('FileBackend: value must be a string, got ' + typeof value);
|
|
105
|
+
}
|
|
106
|
+
const file = this.fileFor(key);
|
|
107
|
+
const dir = path.dirname(file);
|
|
108
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
|
|
110
|
+
// Rename is atomic within a filesystem, so a reader sees either the whole
|
|
111
|
+
// old file or the whole new one and never the half-written middle. The
|
|
112
|
+
// temporary name carries the pid so two processes writing one session do
|
|
113
|
+
// not land on each other's scratch file.
|
|
114
|
+
const tmp = file + '.tmp-' + process.pid + '-' + Date.now().toString(36);
|
|
115
|
+
try {
|
|
116
|
+
fs.writeFileSync(tmp, value, 'utf8');
|
|
117
|
+
fs.renameSync(tmp, file);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
remove(key) {
|
|
125
|
+
try {
|
|
126
|
+
fs.unlinkSync(this.fileFor(key));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
list(prefix) {
|
|
134
|
+
const want = prefix == null ? '' : String(prefix);
|
|
135
|
+
|
|
136
|
+
let entries;
|
|
137
|
+
try {
|
|
138
|
+
entries = fs.readdirSync(this.dir);
|
|
139
|
+
} catch (_) {
|
|
140
|
+
return []; // no directory yet is no keys, not an error
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const found = [];
|
|
144
|
+
|
|
145
|
+
// The pre-keys, by name. The mobile half must not pick up the companion's:
|
|
146
|
+
// <phone>.pre-key-1.json and <phone>.web.pre-key-1.json both start with the
|
|
147
|
+
// number, so the stem — which carries the .web or does not — is what the
|
|
148
|
+
// pattern is anchored on.
|
|
149
|
+
const preKeyRe = new RegExp(
|
|
150
|
+
'^' + _escapeRe(this._stem + PRE_KEY_INFIX) + '(\\d+)\\.json$');
|
|
151
|
+
|
|
152
|
+
for (const name of entries) {
|
|
153
|
+
const m = preKeyRe.exec(name);
|
|
154
|
+
if (!m) continue;
|
|
155
|
+
const key = 'pre-key/' + Number(m[1]);
|
|
156
|
+
if (key.startsWith(want)) found.push(key);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The named keys. Read off the file names rather than statted one by one,
|
|
160
|
+
// so a directory of 812 pre-keys is still one readdir.
|
|
161
|
+
const present = new Set(entries);
|
|
162
|
+
for (const key of Object.keys(KEY_SUFFIX)) {
|
|
163
|
+
if (!key.startsWith(want)) continue;
|
|
164
|
+
if (present.has(this._stem + KEY_SUFFIX[key])) found.push(key);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return found.sort();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The name table hangs off the class rather than travelling as a second export:
|
|
172
|
+
// anything that needs it — a tool migrating a session between backends, say —
|
|
173
|
+
// has the class already, and a loose export of the same object would be one more
|
|
174
|
+
// thing to keep reachable from the package for no gain.
|
|
175
|
+
FileBackend.KEY_SUFFIX = KEY_SUFFIX;
|
|
176
|
+
FileBackend.PRE_KEY_INFIX = PRE_KEY_INFIX;
|
|
177
|
+
|
|
178
|
+
module.exports = { FileBackend };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A session that is never written down.
|
|
4
|
+
//
|
|
5
|
+
// It exists for two reasons. The first is tests: a contract this small is only
|
|
6
|
+
// worth having if every backend behaves the same way, and the way to know that
|
|
7
|
+
// is to run one suite against all of them — which needs a second backend that
|
|
8
|
+
// is obviously correct, so that a disagreement points at the one being judged
|
|
9
|
+
// rather than at the referee.
|
|
10
|
+
//
|
|
11
|
+
// The second is throwaway sessions. A companion link opened to read one thing
|
|
12
|
+
// and dropped, a number being tried out, a test against the live server — none
|
|
13
|
+
// of them want 834 files left behind in ~/.waSession. Handed this, they leave
|
|
14
|
+
// nothing at all; when the process ends the session is simply gone, and the
|
|
15
|
+
// device is unlinked or has to re-pair the next time.
|
|
16
|
+
//
|
|
17
|
+
// Which is also the warning. Nothing here survives the process. A registered
|
|
18
|
+
// number whose credentials only ever lived in memory is not recoverable — the
|
|
19
|
+
// registration is spent, the number is taken, and the keys that proved it was
|
|
20
|
+
// yours are gone. Use it for sessions that were never meant to outlive the run.
|
|
21
|
+
|
|
22
|
+
const { isValidKey } = require('./Backend');
|
|
23
|
+
|
|
24
|
+
class MemoryBackend {
|
|
25
|
+
constructor() {
|
|
26
|
+
this._data = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
read(key) {
|
|
30
|
+
this._assertKey(key);
|
|
31
|
+
const v = this._data.get(key);
|
|
32
|
+
return v === undefined ? null : v;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
write(key, value) {
|
|
36
|
+
this._assertKey(key);
|
|
37
|
+
if (typeof value !== 'string') {
|
|
38
|
+
throw new TypeError('MemoryBackend: value must be a string, got ' + typeof value);
|
|
39
|
+
}
|
|
40
|
+
this._data.set(key, value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
remove(key) {
|
|
44
|
+
this._assertKey(key);
|
|
45
|
+
this._data.delete(key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
list(prefix) {
|
|
49
|
+
const want = prefix == null ? '' : String(prefix);
|
|
50
|
+
return [...this._data.keys()].filter(k => k.startsWith(want)).sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** How many keys are held. Not part of the contract — for tests. */
|
|
54
|
+
get size() {
|
|
55
|
+
return this._data.size;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop everything. Not part of the contract — for tests. */
|
|
59
|
+
clear() {
|
|
60
|
+
this._data.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The file backend rejects a key it has no file name for, so this one has to
|
|
64
|
+
// reject it too — otherwise a typo passes in tests and fails in production,
|
|
65
|
+
// which is the one failure a second implementation exists to catch.
|
|
66
|
+
_assertKey(key) {
|
|
67
|
+
if (!isValidKey(key)) {
|
|
68
|
+
throw new Error('MemoryBackend: unknown key ' + JSON.stringify(key));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { MemoryBackend };
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The session in one database instead of 834 files.
|
|
4
|
+
//
|
|
5
|
+
// A number's state is twenty-two named files plus a file for each of its 812
|
|
6
|
+
// one-time pre-keys. That works, and on a laptop nobody notices. On a phone
|
|
7
|
+
// under Termux, on a container with a small inode budget, or with fifty
|
|
8
|
+
// numbers in one folder, it is 40 000 files whose directory has to be read
|
|
9
|
+
// every time the pre-key pool is counted.
|
|
10
|
+
//
|
|
11
|
+
// The same state here is a handful of rows. A write is a transaction, so a
|
|
12
|
+
// process that dies mid-write leaves the previous value intact rather than a
|
|
13
|
+
// half-written file; and one database can hold every number and both of their
|
|
14
|
+
// halves at once, while each backend still sees only its own slice of it.
|
|
15
|
+
//
|
|
16
|
+
// ─── The driver ─────────────────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// Node ships SQLite of its own from 22.5 as node:sqlite, synchronous and with
|
|
19
|
+
// nothing to install. That is what this uses when it is there, which keeps the
|
|
20
|
+
// promise whalibmob makes everywhere else in the library: no native build step,
|
|
21
|
+
// nothing for node-gyp to fail at, and Termux stays a place it runs.
|
|
22
|
+
//
|
|
23
|
+
// better-sqlite3 is accepted as well, for Node older than 22.5, and is used
|
|
24
|
+
// when node:sqlite is missing. It is a native module and has to compile, which
|
|
25
|
+
// is why it is not the one reached for first and why it is not a dependency of
|
|
26
|
+
// this package.
|
|
27
|
+
//
|
|
28
|
+
// ─── Why the values are BLOBs ───────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// node:sqlite binds a JavaScript string to a TEXT column as a C string, and a
|
|
31
|
+
// C string stops at the first NUL. A value carrying one is silently cut short
|
|
32
|
+
// — the session still loads, the keys in it are simply wrong from that byte
|
|
33
|
+
// on, which is the worst way for a bug like this to present. Bound as a BLOB
|
|
34
|
+
// the bytes go in and come back exactly, whatever is in them, so that is what
|
|
35
|
+
// the column is and the conversion happens here rather than in the caller.
|
|
36
|
+
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
|
|
40
|
+
const { preKeyId, isValidKey } = require('./Backend');
|
|
41
|
+
|
|
42
|
+
// Bumped when the shape of the table changes; kept in the file's user_version.
|
|
43
|
+
const SCHEMA_VERSION = 1;
|
|
44
|
+
|
|
45
|
+
const TABLE = 'session_state';
|
|
46
|
+
|
|
47
|
+
const SCHEMA = `
|
|
48
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
49
|
+
phone TEXT NOT NULL,
|
|
50
|
+
half TEXT NOT NULL,
|
|
51
|
+
key TEXT NOT NULL,
|
|
52
|
+
value BLOB NOT NULL,
|
|
53
|
+
PRIMARY KEY (phone, half, key)
|
|
54
|
+
) WITHOUT ROWID;
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
// ─── Driver detection ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
let _driverCache;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load node:sqlite without its ExperimentalWarning reaching the console.
|
|
63
|
+
*
|
|
64
|
+
* The warning is true and it is also not the user's business: they asked
|
|
65
|
+
* whalibmob for a session store, not for a lecture about a Node flag they did
|
|
66
|
+
* not set. Only that one warning is swallowed, and only around this require —
|
|
67
|
+
* anything else Node has to say still gets through.
|
|
68
|
+
*/
|
|
69
|
+
function _requireNodeSqlite() {
|
|
70
|
+
const original = process.emitWarning;
|
|
71
|
+
process.emitWarning = function (warning, ...rest) {
|
|
72
|
+
const name = (rest[0] && rest[0].type) || rest[0];
|
|
73
|
+
const text = typeof warning === 'string' ? warning : (warning && warning.message) || '';
|
|
74
|
+
if (name === 'ExperimentalWarning' && /SQLite/i.test(text)) return;
|
|
75
|
+
return original.call(process, warning, ...rest);
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
return require('node:sqlite');
|
|
79
|
+
} finally {
|
|
80
|
+
process.emitWarning = original;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The SQLite driver available here, as a uniform shape.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} [prefer] 'node' or 'better-sqlite3' to demand one
|
|
88
|
+
* @returns {{ name: string, open: (file: string) => object }}
|
|
89
|
+
*/
|
|
90
|
+
function resolveDriver(prefer) {
|
|
91
|
+
if (!prefer && _driverCache) return _driverCache;
|
|
92
|
+
|
|
93
|
+
const tryNode = () => {
|
|
94
|
+
let sqlite;
|
|
95
|
+
try { sqlite = _requireNodeSqlite(); } catch (_) { return null; }
|
|
96
|
+
if (!sqlite || typeof sqlite.DatabaseSync !== 'function') return null;
|
|
97
|
+
return {
|
|
98
|
+
name: 'node:sqlite',
|
|
99
|
+
open: (file) => new sqlite.DatabaseSync(file)
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const tryBetter = () => {
|
|
104
|
+
let Database;
|
|
105
|
+
try { Database = require('better-sqlite3'); } catch (_) { return null; }
|
|
106
|
+
return {
|
|
107
|
+
name: 'better-sqlite3',
|
|
108
|
+
open: (file) => new Database(file)
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
let driver = null;
|
|
113
|
+
if (prefer === 'node') driver = tryNode();
|
|
114
|
+
else if (prefer === 'better-sqlite3') driver = tryBetter();
|
|
115
|
+
else driver = tryNode() || tryBetter();
|
|
116
|
+
|
|
117
|
+
if (!driver) {
|
|
118
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
119
|
+
const tooOld = major < 22 || (major === 22 && minor < 5);
|
|
120
|
+
throw new Error(
|
|
121
|
+
'SqliteBackend: no SQLite driver available.\n' +
|
|
122
|
+
(tooOld
|
|
123
|
+
? ` Node ${process.versions.node} has no built-in SQLite — it arrives in 22.5.0.\n` +
|
|
124
|
+
' Either upgrade Node, or install the fallback driver:\n' +
|
|
125
|
+
' npm install better-sqlite3\n'
|
|
126
|
+
: ` node:sqlite should be present on Node ${process.versions.node} but could not ` +
|
|
127
|
+
'be loaded.\n Install the fallback driver instead:\n' +
|
|
128
|
+
' npm install better-sqlite3\n') +
|
|
129
|
+
' Or leave it out entirely: the default FileBackend needs nothing installed.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!prefer) _driverCache = driver;
|
|
134
|
+
return driver;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Connections ─────────────────────────────────────────────────────────────
|
|
138
|
+
//
|
|
139
|
+
// A number's two halves are two backends over one file, and fifty numbers in
|
|
140
|
+
// one database are a hundred. Each opening its own handle would be a hundred
|
|
141
|
+
// handles onto the same file for no reason, so they are shared by resolved
|
|
142
|
+
// path and counted, and the file is closed when the last backend using it
|
|
143
|
+
// lets go.
|
|
144
|
+
|
|
145
|
+
const _open = new Map(); // resolved path → { db, driver, refs }
|
|
146
|
+
|
|
147
|
+
function _acquire(file, prefer) {
|
|
148
|
+
const key = path.resolve(file);
|
|
149
|
+
const existing = _open.get(key);
|
|
150
|
+
if (existing) { existing.refs++; return existing; }
|
|
151
|
+
|
|
152
|
+
const dir = path.dirname(key);
|
|
153
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
154
|
+
|
|
155
|
+
const driver = resolveDriver(prefer);
|
|
156
|
+
const db = driver.open(key);
|
|
157
|
+
|
|
158
|
+
// WAL lets a reader and a writer work at once, which is what a client
|
|
159
|
+
// flushing its Signal store while another reads the pre-key pool is doing.
|
|
160
|
+
// NORMAL is the synchronous level WAL is designed around: a crash of the
|
|
161
|
+
// process cannot lose a committed transaction, only a crash of the machine
|
|
162
|
+
// can, and paying FULL for every pre-key write is not worth that.
|
|
163
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
164
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
165
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
166
|
+
db.exec(SCHEMA);
|
|
167
|
+
|
|
168
|
+
const found = db.prepare('PRAGMA user_version').get();
|
|
169
|
+
const version = found ? Number(found.user_version) : 0;
|
|
170
|
+
if (version > SCHEMA_VERSION) {
|
|
171
|
+
db.close();
|
|
172
|
+
throw new Error(
|
|
173
|
+
`SqliteBackend: ${key} was written by a newer whalibmob (schema ` +
|
|
174
|
+
`${version}, this one understands ${SCHEMA_VERSION}). Upgrade whalibmob ` +
|
|
175
|
+
'rather than letting an older release write to it.'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (version < SCHEMA_VERSION) db.exec('PRAGMA user_version = ' + SCHEMA_VERSION);
|
|
179
|
+
|
|
180
|
+
const entry = { db, driver, refs: 1, key };
|
|
181
|
+
_open.set(key, entry);
|
|
182
|
+
return entry;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The count belongs to the shared entry; remembering that you have already let
|
|
186
|
+
// go belongs to whoever is letting go. Keeping that flag on the entry would
|
|
187
|
+
// mean the first backend to close it stopped every other one from ever
|
|
188
|
+
// decrementing, and the file would stay open for the life of the process.
|
|
189
|
+
function _release(entry) {
|
|
190
|
+
if (!entry) return;
|
|
191
|
+
entry.refs--;
|
|
192
|
+
if (entry.refs > 0) return;
|
|
193
|
+
_open.delete(entry.key);
|
|
194
|
+
try { entry.db.close(); } catch (_) {}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── The backend ─────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
class SqliteBackend {
|
|
200
|
+
/**
|
|
201
|
+
* @param {object} opts
|
|
202
|
+
* @param {string} opts.path the database file; created if missing
|
|
203
|
+
* @param {string} opts.phone the number, digits only
|
|
204
|
+
* @param {boolean} [opts.web] the companion half rather than the mobile one
|
|
205
|
+
* @param {string} [opts.driver] 'node' or 'better-sqlite3' to demand one
|
|
206
|
+
*/
|
|
207
|
+
constructor(opts) {
|
|
208
|
+
if (!opts || !opts.path) throw new TypeError('SqliteBackend: path is required');
|
|
209
|
+
if (!opts.phone) throw new TypeError('SqliteBackend: phone is required');
|
|
210
|
+
|
|
211
|
+
this.path = String(opts.path);
|
|
212
|
+
this.phone = String(opts.phone).replace(/\D/g, '');
|
|
213
|
+
this.web = !!opts.web;
|
|
214
|
+
|
|
215
|
+
if (!this.phone) throw new TypeError('SqliteBackend: phone must contain digits');
|
|
216
|
+
|
|
217
|
+
this._half = this.web ? 'web' : 'mobile';
|
|
218
|
+
this._conn = _acquire(this.path, opts.driver);
|
|
219
|
+
this.driver = this._conn.driver.name;
|
|
220
|
+
|
|
221
|
+
const db = this._conn.db;
|
|
222
|
+
// Prepared once. A pre-key sweep runs these 812 times and re-preparing the
|
|
223
|
+
// statement each time is most of what it would cost.
|
|
224
|
+
this._get = db.prepare(
|
|
225
|
+
`SELECT value FROM ${TABLE} WHERE phone = ? AND half = ? AND key = ?`);
|
|
226
|
+
this._put = db.prepare(
|
|
227
|
+
`INSERT INTO ${TABLE} (phone, half, key, value) VALUES (?, ?, ?, ?) ` +
|
|
228
|
+
'ON CONFLICT(phone, half, key) DO UPDATE SET value = excluded.value');
|
|
229
|
+
this._del = db.prepare(
|
|
230
|
+
`DELETE FROM ${TABLE} WHERE phone = ? AND half = ? AND key = ?`);
|
|
231
|
+
this._keys = db.prepare(
|
|
232
|
+
`SELECT key FROM ${TABLE} WHERE phone = ? AND half = ?`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
_assertKey(key) {
|
|
236
|
+
if (!isValidKey(key)) {
|
|
237
|
+
throw new Error('SqliteBackend: unknown key ' + JSON.stringify(key));
|
|
238
|
+
}
|
|
239
|
+
return key;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
read(key) {
|
|
243
|
+
this._assertKey(key);
|
|
244
|
+
const row = this._get.get(this.phone, this._half, key);
|
|
245
|
+
if (!row || row.value == null) return null;
|
|
246
|
+
// node:sqlite hands a BLOB back as a Uint8Array, better-sqlite3 as a
|
|
247
|
+
// Buffer. Buffer.from copes with either without copying twice.
|
|
248
|
+
return Buffer.from(row.value).toString('utf8');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
write(key, value) {
|
|
252
|
+
this._assertKey(key);
|
|
253
|
+
if (typeof value !== 'string') {
|
|
254
|
+
throw new TypeError('SqliteBackend: value must be a string, got ' + typeof value);
|
|
255
|
+
}
|
|
256
|
+
this._put.run(this.phone, this._half, key, Buffer.from(value, 'utf8'));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
remove(key) {
|
|
260
|
+
this._assertKey(key);
|
|
261
|
+
this._del.run(this.phone, this._half, key);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
list(prefix) {
|
|
265
|
+
const want = prefix == null ? '' : String(prefix);
|
|
266
|
+
const out = [];
|
|
267
|
+
for (const row of this._keys.all(this.phone, this._half)) {
|
|
268
|
+
if (row.key.startsWith(want)) out.push(row.key);
|
|
269
|
+
}
|
|
270
|
+
return out.sort();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── beyond the contract ───────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Let go of the database.
|
|
277
|
+
*
|
|
278
|
+
* The file is shared between every backend opened on it, so it is closed
|
|
279
|
+
* once the last of them has called this. Calling it twice is harmless; using
|
|
280
|
+
* the backend afterwards is not, and throws.
|
|
281
|
+
*/
|
|
282
|
+
close() {
|
|
283
|
+
if (this._closed) return;
|
|
284
|
+
this._closed = true;
|
|
285
|
+
_release(this._conn);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Every number the database holds, as `{ phone, half }`. */
|
|
289
|
+
static sessionsIn(file, opts) {
|
|
290
|
+
const conn = _acquire(file, opts && opts.driver);
|
|
291
|
+
try {
|
|
292
|
+
return conn.db
|
|
293
|
+
.prepare(`SELECT DISTINCT phone, half FROM ${TABLE} ORDER BY phone, half`)
|
|
294
|
+
.all()
|
|
295
|
+
.map(r => ({ phone: r.phone, half: r.half, web: r.half === 'web' }));
|
|
296
|
+
} finally {
|
|
297
|
+
_release(conn);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
SqliteBackend.SCHEMA_VERSION = SCHEMA_VERSION;
|
|
303
|
+
SqliteBackend.TABLE = TABLE;
|
|
304
|
+
SqliteBackend.resolveDriver = resolveDriver;
|
|
305
|
+
|
|
306
|
+
module.exports = { SqliteBackend, resolveDriver, SCHEMA_VERSION };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Moving a session from one backend to another.
|
|
4
|
+
//
|
|
5
|
+
// Somebody with a working number and 834 files who now wants one database has
|
|
6
|
+
// to get the first into the second, and the one thing that must not happen on
|
|
7
|
+
// the way is losing the credentials in the middle. So nothing is removed: the
|
|
8
|
+
// state is copied, the source is left exactly as it was, and if the result is
|
|
9
|
+
// wrong the old files are still sitting there to go back to.
|
|
10
|
+
//
|
|
11
|
+
// A destination that already holds a key is left alone unless `overwrite` says
|
|
12
|
+
// otherwise, which makes an interrupted copy safe to run again — it picks up
|
|
13
|
+
// what did not make it the first time and does not undo what did.
|
|
14
|
+
|
|
15
|
+
const { assertBackend } = require('./Backend');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Copy every key from one backend to another.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} from the backend to read from
|
|
21
|
+
* @param {object} to the backend to write to
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {boolean} [opts.overwrite] replace keys the destination already has
|
|
24
|
+
* @returns {{copied: string[], skipped: string[], bytes: number}}
|
|
25
|
+
*/
|
|
26
|
+
function copySession(from, to, opts) {
|
|
27
|
+
assertBackend(from, 'source backend');
|
|
28
|
+
assertBackend(to, 'destination backend');
|
|
29
|
+
if (from === to) throw new Error('copySession: source and destination are the same backend');
|
|
30
|
+
|
|
31
|
+
const overwrite = !!(opts && opts.overwrite);
|
|
32
|
+
const copied = [], skipped = [];
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
|
|
35
|
+
for (const key of from.list()) {
|
|
36
|
+
if (!overwrite && to.read(key) !== null) { skipped.push(key); continue; }
|
|
37
|
+
|
|
38
|
+
const value = from.read(key);
|
|
39
|
+
// A key that list() named and read() will not return has gone since the
|
|
40
|
+
// listing — a pre-key consumed by a message arriving mid-copy. It is not
|
|
41
|
+
// an error and it is not ours to invent a value for.
|
|
42
|
+
if (value === null) { skipped.push(key); continue; }
|
|
43
|
+
|
|
44
|
+
to.write(key, value);
|
|
45
|
+
bytes += Buffer.byteLength(value, 'utf8');
|
|
46
|
+
copied.push(key);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { copied: copied.sort(), skipped: skipped.sort(), bytes };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check that two backends hold the same state, key for key.
|
|
54
|
+
*
|
|
55
|
+
* Worth running after a copy and before deleting anything: it reads both sides
|
|
56
|
+
* rather than trusting that the copy said so.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{ok: boolean, missing: string[], differing: string[], extra: string[]}}
|
|
59
|
+
*/
|
|
60
|
+
function compareSessions(a, b) {
|
|
61
|
+
assertBackend(a, 'first backend');
|
|
62
|
+
assertBackend(b, 'second backend');
|
|
63
|
+
|
|
64
|
+
const aKeys = new Set(a.list());
|
|
65
|
+
const bKeys = new Set(b.list());
|
|
66
|
+
const missing = [], differing = [], extra = [];
|
|
67
|
+
|
|
68
|
+
for (const key of aKeys) {
|
|
69
|
+
if (!bKeys.has(key)) { missing.push(key); continue; }
|
|
70
|
+
if (a.read(key) !== b.read(key)) differing.push(key);
|
|
71
|
+
}
|
|
72
|
+
for (const key of bKeys) if (!aKeys.has(key)) extra.push(key);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: missing.length === 0 && differing.length === 0 && extra.length === 0,
|
|
76
|
+
missing: missing.sort(),
|
|
77
|
+
differing: differing.sort(),
|
|
78
|
+
extra: extra.sort()
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { copySession, compareSessions };
|