@chronicle.app/etl-sqlite 0.2.0 → 0.4.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 +36 -0
- package/dist/SqliteExtractor.d.ts +6 -0
- package/dist/SqliteExtractor.js +13 -2
- package/dist/chromeEpoch.d.ts +28 -0
- package/dist/chromeEpoch.js +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/statements.d.ts +29 -0
- package/dist/statements.js +37 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,10 +4,46 @@ SQLite extraction with built-in `node:sqlite` on Node >=22.13.0.
|
|
|
4
4
|
|
|
5
5
|
Extend `SqliteExtractor`, declare `source` and `strategy`, and implement `extract()` using the protected `db` connection. Pass an existing database path as `input`. Always call `teardown()` in a `finally` block, including after setup or extraction fails.
|
|
6
6
|
|
|
7
|
+
`setup()` opens `input` with the protected `openDatabase(path)`. Call it from a subclass to read a different file, such as a temporary copy of a database the source app keeps exclusively locked, or a database extracted from a backup. Close and clear `this.db` before reopening; `teardown()` closes whichever connection is open.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
override async setup(): Promise<void> {
|
|
11
|
+
await super.setup();
|
|
12
|
+
try {
|
|
13
|
+
this.db!.prepare('SELECT 1 FROM sqlite_schema').get();
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (!isSqliteBusy(err)) throw err;
|
|
16
|
+
this.db!.close();
|
|
17
|
+
this.db = null;
|
|
18
|
+
const copy = join(await mkdtemp(join(tmpdir(), 'source-')), 'copy.sqlite');
|
|
19
|
+
await copyFile(this.config.input, copy);
|
|
20
|
+
this.db = this.openDatabase(copy);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`isSqliteBusy(err)` reports whether an error is SQLITE_BUSY (including extended busy codes). Opening a locked database succeeds; the first read fails.
|
|
26
|
+
|
|
27
|
+
`allRows<T>(stmt, ...params)`, `getRow<T>(stmt, ...params)` and `iterateRows<T>(stmt, ...params)` wrap `all()`, `get()` and `iterate()` with a row type, so call sites need no `as unknown as T[]`. The type is not checked at runtime. `iterateRows` also keeps its statement referenced until iteration ends, so iterating `db.prepare(sql)` inline is safe. On Node 22.13, `iterate()` alone does not, and garbage collection mid-iteration throws "statement has been finalized".
|
|
28
|
+
|
|
7
29
|
The connection is read-only: missing databases are not created, and journal mode is not changed. Live WAL databases require access to the database and its sidecar files; a copied database must include uncheckpointed WAL contents or be a consistent SQLite backup.
|
|
8
30
|
|
|
9
31
|
`timeRangeConditions(column, range, options)` returns parameterized SQL conditions and values. The column is a trusted developer-supplied SQL identifier, not user input. Bounds are exclusive by default; conversion functions and inclusive operators are supported. Numeric dates are Unix milliseconds; the default SQL values are Unix seconds.
|
|
10
32
|
|
|
11
33
|
Apple epoch helpers retain the existing API: iOS inputs/outputs use nanoseconds since 2001, Safari inputs/outputs use seconds since 2001. `iosToUnixTimestamp` returns Unix milliseconds; other Unix arguments/results use seconds.
|
|
12
34
|
|
|
35
|
+
Chrome epoch helpers cover Chromium's microseconds since 1601. `chromeToUnixMsSql(column)` returns a SQL expression in Unix milliseconds, since current Chrome times are too large to read as numbers. `unixMsToChromeTimestamp(ms)` converts a bound back, as a `timeRangeConditions` `convert`.
|
|
36
|
+
|
|
13
37
|
Node SQLite returns large INTEGER values only when `statement.setReadBigInts(true)` is enabled. Use that for source nanosecond timestamps and convert deliberately at the application boundary.
|
|
38
|
+
|
|
39
|
+
## Porting from better-sqlite3
|
|
40
|
+
|
|
41
|
+
`node:sqlite` differs from better-sqlite3 in ways that fail at runtime rather than at compile time. All of these hold on Node 22.13.0:
|
|
42
|
+
|
|
43
|
+
- Parameters are spread, not passed as an array: `stmt.all(...values)`. `stmt.all(values)` throws `Unknown named parameter '0'`.
|
|
44
|
+
- Booleans can't be bound (`ERR_INVALID_ARG_TYPE`). Bind `1` or `0`.
|
|
45
|
+
- Reading an integer beyond `Number.MAX_SAFE_INTEGER` throws `ERR_OUT_OF_RANGE`. Call `stmt.setReadBigInts(true)`, or select the column with `CAST(column AS TEXT)`.
|
|
46
|
+
- BLOBs come back as `Uint8Array`, not `Buffer`; `Buffer.isBuffer()` is false. Wrap with `Buffer.from(value.buffer, value.byteOffset, value.byteLength)` where a `Buffer` is needed.
|
|
47
|
+
- Databases open read-only and their journal mode is never changed. Don't set `journal_mode` or other writing pragmas on source databases.
|
|
48
|
+
|
|
49
|
+
There is no query builder. Write SQL with `?` placeholders, and build `IN (...)` lists and optional `WHERE` clauses from arrays of conditions and values, as `timeRangeConditions` does.
|
|
@@ -23,6 +23,12 @@ export declare abstract class SqliteExtractor<SelfClass extends typeof SqliteExt
|
|
|
23
23
|
}>;
|
|
24
24
|
protected db: DatabaseSync | null;
|
|
25
25
|
setup(): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Open a database read-only without changing its journal mode. `setup()`
|
|
28
|
+
* opens `input` with this; subclasses can call it to read another file, such
|
|
29
|
+
* as a copy of a locked database, after closing and clearing `this.db`.
|
|
30
|
+
*/
|
|
31
|
+
protected openDatabase(path: string): DatabaseSync;
|
|
26
32
|
teardown(): Promise<void>;
|
|
27
33
|
}
|
|
28
34
|
//# sourceMappingURL=SqliteExtractor.d.ts.map
|
package/dist/SqliteExtractor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { Extractor } from '@chronicle.app/etl';
|
|
3
|
+
import { Extractor, assertReadable } from '@chronicle.app/etl';
|
|
4
4
|
/** Read an existing source database without changing its journal mode. */
|
|
5
5
|
export class SqliteExtractor extends Extractor {
|
|
6
6
|
static delivery = 'local';
|
|
@@ -13,7 +13,18 @@ export class SqliteExtractor extends Extractor {
|
|
|
13
13
|
throw new Error('Database already initialized');
|
|
14
14
|
await super.setup();
|
|
15
15
|
const { input } = this.config;
|
|
16
|
-
this.db =
|
|
16
|
+
this.db = this.openDatabase(input);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Open a database read-only without changing its journal mode. `setup()`
|
|
20
|
+
* opens `input` with this; subclasses can call it to read another file, such
|
|
21
|
+
* as a copy of a locked database, after closing and clearing `this.db`.
|
|
22
|
+
*/
|
|
23
|
+
openDatabase(path) {
|
|
24
|
+
// SQLite says only "unable to open database file"; say whether the file
|
|
25
|
+
// is missing or macOS privacy controls refused it.
|
|
26
|
+
assertReadable(path, 'database');
|
|
27
|
+
return new DatabaseSync(path, { readOnly: true });
|
|
17
28
|
}
|
|
18
29
|
async teardown() {
|
|
19
30
|
if (this.db) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome epoch timestamp utilities.
|
|
3
|
+
*
|
|
4
|
+
* Chromium stores times (History `visits.visit_time`, `urls.last_visit_time`)
|
|
5
|
+
* as microseconds since January 1, 1601 00:00:00 UTC, the Windows FILETIME
|
|
6
|
+
* epoch. Unix timestamps count from January 1, 1970 00:00:00 UTC. The
|
|
7
|
+
* difference is 11,644,473,600 seconds.
|
|
8
|
+
*
|
|
9
|
+
* Current Chrome times exceed Number.MAX_SAFE_INTEGER, so `node:sqlite` throws
|
|
10
|
+
* ERR_OUT_OF_RANGE when reading them as numbers. Convert in SQL with
|
|
11
|
+
* `chromeToUnixMsSql(column)`, or read with `statement.setReadBigInts(true)`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CHROME_EPOCH_OFFSET_MS = 11644473600000;
|
|
14
|
+
/**
|
|
15
|
+
* A SQL expression converting a Chrome time column to Unix milliseconds
|
|
16
|
+
* (integer division, so sub-millisecond precision is dropped).
|
|
17
|
+
* @param column A trusted, developer-supplied column name
|
|
18
|
+
*/
|
|
19
|
+
export declare function chromeToUnixMsSql(column: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Convert Unix milliseconds to a Chrome timestamp, for binding against a Chrome
|
|
22
|
+
* time column. The result is exact until the year 3800 although it exceeds
|
|
23
|
+
* Number.MAX_SAFE_INTEGER: it is a multiple of 1000, and so of 8, below 2^56.
|
|
24
|
+
* @param unixMs Unix timestamp in milliseconds
|
|
25
|
+
* @returns Chrome timestamp in microseconds since Jan 1, 1601
|
|
26
|
+
*/
|
|
27
|
+
export declare function unixMsToChromeTimestamp(unixMs: number): number;
|
|
28
|
+
//# sourceMappingURL=chromeEpoch.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome epoch timestamp utilities.
|
|
3
|
+
*
|
|
4
|
+
* Chromium stores times (History `visits.visit_time`, `urls.last_visit_time`)
|
|
5
|
+
* as microseconds since January 1, 1601 00:00:00 UTC, the Windows FILETIME
|
|
6
|
+
* epoch. Unix timestamps count from January 1, 1970 00:00:00 UTC. The
|
|
7
|
+
* difference is 11,644,473,600 seconds.
|
|
8
|
+
*
|
|
9
|
+
* Current Chrome times exceed Number.MAX_SAFE_INTEGER, so `node:sqlite` throws
|
|
10
|
+
* ERR_OUT_OF_RANGE when reading them as numbers. Convert in SQL with
|
|
11
|
+
* `chromeToUnixMsSql(column)`, or read with `statement.setReadBigInts(true)`.
|
|
12
|
+
*/
|
|
13
|
+
export const CHROME_EPOCH_OFFSET_MS = 11_644_473_600_000;
|
|
14
|
+
/**
|
|
15
|
+
* A SQL expression converting a Chrome time column to Unix milliseconds
|
|
16
|
+
* (integer division, so sub-millisecond precision is dropped).
|
|
17
|
+
* @param column A trusted, developer-supplied column name
|
|
18
|
+
*/
|
|
19
|
+
export function chromeToUnixMsSql(column) {
|
|
20
|
+
return `(${column} / 1000 - ${CHROME_EPOCH_OFFSET_MS})`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Convert Unix milliseconds to a Chrome timestamp, for binding against a Chrome
|
|
24
|
+
* time column. The result is exact until the year 3800 although it exceeds
|
|
25
|
+
* Number.MAX_SAFE_INTEGER: it is a multiple of 1000, and so of 8, below 2^56.
|
|
26
|
+
* @param unixMs Unix timestamp in milliseconds
|
|
27
|
+
* @returns Chrome timestamp in microseconds since Jan 1, 1601
|
|
28
|
+
*/
|
|
29
|
+
export function unixMsToChromeTimestamp(unixMs) {
|
|
30
|
+
return (Math.floor(unixMs) + CHROME_EPOCH_OFFSET_MS) * 1000;
|
|
31
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SQLInputValue, StatementSync } from 'node:sqlite';
|
|
2
|
+
type Parameters = SQLInputValue[] | [namedParameters: Record<string, SQLInputValue>, ...anonymousParameters: SQLInputValue[]];
|
|
3
|
+
/**
|
|
4
|
+
* Run a statement and type its rows. `node:sqlite` returns
|
|
5
|
+
* `Record<string, SQLOutputValue>`; `T` is the caller's description of the
|
|
6
|
+
* selected columns and is not checked at runtime.
|
|
7
|
+
*/
|
|
8
|
+
export declare function allRows<T>(statement: StatementSync, ...params: Parameters): T[];
|
|
9
|
+
/** Run a statement and type its first row, or `undefined` when there is none. */
|
|
10
|
+
export declare function getRow<T>(statement: StatementSync, ...params: Parameters): T | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Run a statement and iterate its rows one at a time, typed as `T`.
|
|
13
|
+
*
|
|
14
|
+
* The generator keeps `statement` referenced until iteration ends. On Node
|
|
15
|
+
* 22.13, the iterator from `iterate()` does not, so a statement prepared inline
|
|
16
|
+
* (`iterateRows(db.prepare(sql))`) can be garbage collected mid-iteration, and
|
|
17
|
+
* the next row throws "statement has been finalized".
|
|
18
|
+
*/
|
|
19
|
+
export declare function iterateRows<T>(statement: StatementSync, ...params: Parameters): Generator<T>;
|
|
20
|
+
/**
|
|
21
|
+
* Whether an error is SQLITE_BUSY, which `node:sqlite` reports as
|
|
22
|
+
* `{ code: 'ERR_SQLITE_ERROR', errcode: 5 }` when another connection holds a
|
|
23
|
+
* lock (for example an app that keeps its database exclusively locked).
|
|
24
|
+
* Extended result codes (primary code in the low byte), such as
|
|
25
|
+
* SQLITE_BUSY_SNAPSHOT, also match.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isSqliteBusy(err: unknown): boolean;
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=statements.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a statement and type its rows. `node:sqlite` returns
|
|
3
|
+
* `Record<string, SQLOutputValue>`; `T` is the caller's description of the
|
|
4
|
+
* selected columns and is not checked at runtime.
|
|
5
|
+
*/
|
|
6
|
+
export function allRows(statement, ...params) {
|
|
7
|
+
return statement.all(...params);
|
|
8
|
+
}
|
|
9
|
+
/** Run a statement and type its first row, or `undefined` when there is none. */
|
|
10
|
+
export function getRow(statement, ...params) {
|
|
11
|
+
return statement.get(...params);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Run a statement and iterate its rows one at a time, typed as `T`.
|
|
15
|
+
*
|
|
16
|
+
* The generator keeps `statement` referenced until iteration ends. On Node
|
|
17
|
+
* 22.13, the iterator from `iterate()` does not, so a statement prepared inline
|
|
18
|
+
* (`iterateRows(db.prepare(sql))`) can be garbage collected mid-iteration, and
|
|
19
|
+
* the next row throws "statement has been finalized".
|
|
20
|
+
*/
|
|
21
|
+
export function* iterateRows(statement, ...params) {
|
|
22
|
+
yield* statement.iterate(...params);
|
|
23
|
+
}
|
|
24
|
+
const SQLITE_BUSY = 5;
|
|
25
|
+
/**
|
|
26
|
+
* Whether an error is SQLITE_BUSY, which `node:sqlite` reports as
|
|
27
|
+
* `{ code: 'ERR_SQLITE_ERROR', errcode: 5 }` when another connection holds a
|
|
28
|
+
* lock (for example an app that keeps its database exclusively locked).
|
|
29
|
+
* Extended result codes (primary code in the low byte), such as
|
|
30
|
+
* SQLITE_BUSY_SNAPSHOT, also match.
|
|
31
|
+
*/
|
|
32
|
+
export function isSqliteBusy(err) {
|
|
33
|
+
if (typeof err !== 'object' || err === null)
|
|
34
|
+
return false;
|
|
35
|
+
const { code, errcode } = err;
|
|
36
|
+
return (code === 'ERR_SQLITE_ERROR' && typeof errcode === 'number' && errcode % 256 === SQLITE_BUSY);
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/etl-sqlite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"type": "module",
|
|
20
20
|
"description": "Read-only SQLite extraction using built-in node:sqlite",
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@chronicle.app/etl": "0.
|
|
23
|
-
"@chronicle.app/tsconfig": "0.
|
|
22
|
+
"@chronicle.app/etl": "0.4.0",
|
|
23
|
+
"@chronicle.app/tsconfig": "0.4.0",
|
|
24
24
|
"@types/node": "^22.10.1",
|
|
25
25
|
"typescript": "^5.7.2"
|
|
26
26
|
},
|