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.
@@ -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 };