@chronicle.app/etl-sqlite 0.4.0 → 0.5.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/dist/SqliteExtractor.d.ts +27 -1
- package/dist/SqliteExtractor.js +68 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/months.d.ts +14 -0
- package/dist/months.js +16 -0
- package/dist/snapshot.d.ts +21 -0
- package/dist/snapshot.js +69 -0
- package/package.json +3 -3
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { type Delivery, Extractor } from '@chronicle.app/etl';
|
|
3
|
+
import { type DatabaseColumns, type DatabaseSchema, type Delivery, Extractor } from '@chronicle.app/etl';
|
|
4
|
+
/**
|
|
5
|
+
* Refuse an open database unless it has every column one of `databases`
|
|
6
|
+
* needs, naming `path` as what was given. Its columns, when it has them. For
|
|
7
|
+
* a reader that opens its database itself rather than through a
|
|
8
|
+
* {@link SqliteExtractor}.
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertDatabaseSchema(db: DatabaseSync, databases: DatabaseSchema[], path: string): DatabaseColumns;
|
|
4
11
|
/** Read an existing source database without changing its journal mode. */
|
|
5
12
|
export declare abstract class SqliteExtractor<SelfClass extends typeof SqliteExtractor = typeof SqliteExtractor> extends Extractor<SelfClass> {
|
|
6
13
|
static delivery: Delivery;
|
|
@@ -21,8 +28,27 @@ export declare abstract class SqliteExtractor<SelfClass extends typeof SqliteExt
|
|
|
21
28
|
until?: Date | undefined;
|
|
22
29
|
limit?: number | undefined;
|
|
23
30
|
}>;
|
|
31
|
+
/**
|
|
32
|
+
* The tables a reader reads and the columns its queries use, which its
|
|
33
|
+
* manifest's `databases` repeats for `scan`. A reader that declares them
|
|
34
|
+
* reads a snapshot of its input, and only an input with every column a
|
|
35
|
+
* schema needs.
|
|
36
|
+
*/
|
|
37
|
+
static databases?: DatabaseSchema[];
|
|
24
38
|
protected db: DatabaseSync | null;
|
|
39
|
+
/** The checked database's columns, for {@link has}. */
|
|
40
|
+
private columns;
|
|
41
|
+
private snapshotDir;
|
|
25
42
|
setup(): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Refuse the open database unless it has every column one of `databases`
|
|
45
|
+
* needs, naming `path` as what was given. `setup()` checks a declared
|
|
46
|
+
* schema; a reader that declares none, as one whose input is a copy
|
|
47
|
+
* already, can check one after it.
|
|
48
|
+
*/
|
|
49
|
+
protected assertSchema(databases: DatabaseSchema[], path: string): void;
|
|
50
|
+
/** Whether the database has a column a schema marks as optional, as `table.column`. */
|
|
51
|
+
protected has(column: string): boolean;
|
|
26
52
|
/**
|
|
27
53
|
* Open a database read-only without changing its journal mode. `setup()`
|
|
28
54
|
* opens `input` with this; subclasses can call it to read another file, such
|
package/dist/SqliteExtractor.js
CHANGED
|
@@ -1,19 +1,80 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
1
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
6
|
import { z } from 'zod';
|
|
3
|
-
import { Extractor, assertReadable } from '@chronicle.app/etl';
|
|
7
|
+
import { Extractor, InputNotFound, assertReadable, columnsOf, matchSchema, } from '@chronicle.app/etl';
|
|
8
|
+
import { snapshot } from './snapshot.js';
|
|
9
|
+
/**
|
|
10
|
+
* Refuse an open database unless it has every column one of `databases`
|
|
11
|
+
* needs, naming `path` as what was given. Its columns, when it has them. For
|
|
12
|
+
* a reader that opens its database itself rather than through a
|
|
13
|
+
* {@link SqliteExtractor}.
|
|
14
|
+
*/
|
|
15
|
+
export function assertDatabaseSchema(db, databases, path) {
|
|
16
|
+
const columns = columnsOf(db);
|
|
17
|
+
const match = matchSchema(columns, databases);
|
|
18
|
+
if (!match || match.missing.length > 0 || match.schema.supported === false) {
|
|
19
|
+
throw new InputNotFound(match ? 'That’s a version of the database this can’t read' : 'That isn’t the database', {
|
|
20
|
+
path,
|
|
21
|
+
...(match && { fields: { missing: match.missing } }),
|
|
22
|
+
hint: 'Run `chronicle scan <path>` to see what it is.',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return columns;
|
|
26
|
+
}
|
|
4
27
|
/** Read an existing source database without changing its journal mode. */
|
|
5
28
|
export class SqliteExtractor extends Extractor {
|
|
6
29
|
static delivery = 'local';
|
|
7
30
|
static schema = Extractor.schema.extend({
|
|
8
31
|
input: z.string().min(1).describe('Path to an existing SQLite database'),
|
|
9
32
|
});
|
|
33
|
+
/**
|
|
34
|
+
* The tables a reader reads and the columns its queries use, which its
|
|
35
|
+
* manifest's `databases` repeats for `scan`. A reader that declares them
|
|
36
|
+
* reads a snapshot of its input, and only an input with every column a
|
|
37
|
+
* schema needs.
|
|
38
|
+
*/
|
|
39
|
+
static databases;
|
|
10
40
|
db = null;
|
|
41
|
+
/** The checked database's columns, for {@link has}. */
|
|
42
|
+
columns = null;
|
|
43
|
+
snapshotDir = null;
|
|
11
44
|
async setup() {
|
|
12
45
|
if (this.db)
|
|
13
46
|
throw new Error('Database already initialized');
|
|
14
47
|
await super.setup();
|
|
15
48
|
const { input } = this.config;
|
|
16
|
-
|
|
49
|
+
const { databases } = this.constructor;
|
|
50
|
+
if (!databases) {
|
|
51
|
+
this.db = this.openDatabase(input);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
assertReadable(input, 'database');
|
|
55
|
+
this.snapshotDir = await mkdtemp(join(tmpdir(), 'chronicle-sqlite-'));
|
|
56
|
+
try {
|
|
57
|
+
this.db = this.openDatabase(await snapshot(input, this.snapshotDir));
|
|
58
|
+
this.assertSchema(databases, input);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
await this.teardown();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Refuse the open database unless it has every column one of `databases`
|
|
67
|
+
* needs, naming `path` as what was given. `setup()` checks a declared
|
|
68
|
+
* schema; a reader that declares none, as one whose input is a copy
|
|
69
|
+
* already, can check one after it.
|
|
70
|
+
*/
|
|
71
|
+
assertSchema(databases, path) {
|
|
72
|
+
this.columns = assertDatabaseSchema(this.db, databases, path);
|
|
73
|
+
}
|
|
74
|
+
/** Whether the database has a column a schema marks as optional, as `table.column`. */
|
|
75
|
+
has(column) {
|
|
76
|
+
const [table, name] = column.toLowerCase().split('.');
|
|
77
|
+
return this.columns?.get(table)?.has(name) ?? false;
|
|
17
78
|
}
|
|
18
79
|
/**
|
|
19
80
|
* Open a database read-only without changing its journal mode. `setup()`
|
|
@@ -31,6 +92,11 @@ export class SqliteExtractor extends Extractor {
|
|
|
31
92
|
this.db.close();
|
|
32
93
|
this.db = null;
|
|
33
94
|
}
|
|
95
|
+
if (this.snapshotDir) {
|
|
96
|
+
rmSync(this.snapshotDir, { recursive: true, force: true });
|
|
97
|
+
this.snapshotDir = null;
|
|
98
|
+
}
|
|
99
|
+
this.columns = null;
|
|
34
100
|
await super.teardown();
|
|
35
101
|
}
|
|
36
102
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from './SqliteExtractor.js';
|
|
2
|
+
export * from './snapshot.js';
|
|
2
3
|
export * from './statements.js';
|
|
3
4
|
export * from './timeRange.js';
|
|
4
5
|
export * from './appleEpoch.js';
|
|
5
6
|
export * from './chromeEpoch.js';
|
|
7
|
+
export * from './months.js';
|
|
6
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
package/dist/months.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A month bucket in SQL, `YYYY-MM` in local time, as a survey's `byMonth`
|
|
3
|
+
* keys it, from an expression in Unix seconds.
|
|
4
|
+
* @param unixSeconds A trusted, developer-supplied SQL expression
|
|
5
|
+
*/
|
|
6
|
+
export declare function monthSql(unixSeconds: string): string;
|
|
7
|
+
/** Rows of a `GROUP BY` month, as a survey's `byMonth`: a row without a month is left out. */
|
|
8
|
+
export declare function countsByMonth(rows: Array<{
|
|
9
|
+
month: string | null;
|
|
10
|
+
n: number;
|
|
11
|
+
}>): {
|
|
12
|
+
[month: string]: number;
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=months.d.ts.map
|
package/dist/months.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A month bucket in SQL, `YYYY-MM` in local time, as a survey's `byMonth`
|
|
3
|
+
* keys it, from an expression in Unix seconds.
|
|
4
|
+
* @param unixSeconds A trusted, developer-supplied SQL expression
|
|
5
|
+
*/
|
|
6
|
+
export function monthSql(unixSeconds) {
|
|
7
|
+
return `strftime('%Y-%m', ${unixSeconds}, 'unixepoch', 'localtime')`;
|
|
8
|
+
}
|
|
9
|
+
/** Rows of a `GROUP BY` month, as a survey's `byMonth`: a row without a month is left out. */
|
|
10
|
+
export function countsByMonth(rows) {
|
|
11
|
+
const byMonth = {};
|
|
12
|
+
for (const { month, n } of rows)
|
|
13
|
+
if (month)
|
|
14
|
+
byMonth[month] = n;
|
|
15
|
+
return byMonth;
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type DatabaseSchema } from '@chronicle.app/etl';
|
|
2
|
+
/**
|
|
3
|
+
* Copy a database and its `-wal` into `dir`, to read it without writing
|
|
4
|
+
* beside it, waiting on an app's lock, or missing what's only in the
|
|
5
|
+
* `-wal` so far. The copy is a clone where the filesystem can make one, as
|
|
6
|
+
* APFS can, which is instant, and a full copy elsewhere, made off the event
|
|
7
|
+
* loop. A copy that caught a save is taken again.
|
|
8
|
+
*/
|
|
9
|
+
export declare function snapshot(database: string, dir: string, { attempts, retryMs }?: {
|
|
10
|
+
attempts?: number;
|
|
11
|
+
retryMs?: number;
|
|
12
|
+
}): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Write an empty database with only a schema's tables and columns, for a
|
|
15
|
+
* test that a reader's queries need nothing it doesn't declare. Without
|
|
16
|
+
* `optional`, the columns it does without are left out too.
|
|
17
|
+
*/
|
|
18
|
+
export declare function writeSchemaDatabase(path: string, schema: DatabaseSchema, { optional }?: {
|
|
19
|
+
optional?: boolean;
|
|
20
|
+
}): void;
|
|
21
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { constants as fsConstants, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { copyFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { EXIT_CODES, ExtractorError, delay } from '@chronicle.app/etl';
|
|
6
|
+
/** The files a database is saved in: itself, and its write-ahead log. */
|
|
7
|
+
const SAVED = ['', '-wal'];
|
|
8
|
+
/**
|
|
9
|
+
* Copy a database and its `-wal` into `dir`, to read it without writing
|
|
10
|
+
* beside it, waiting on an app's lock, or missing what's only in the
|
|
11
|
+
* `-wal` so far. The copy is a clone where the filesystem can make one, as
|
|
12
|
+
* APFS can, which is instant, and a full copy elsewhere, made off the event
|
|
13
|
+
* loop. A copy that caught a save is taken again.
|
|
14
|
+
*/
|
|
15
|
+
export async function snapshot(database, dir, { attempts = 3, retryMs = 200 } = {}) {
|
|
16
|
+
const copy = join(dir, basename(database));
|
|
17
|
+
for (let attempt = 1;; attempt++) {
|
|
18
|
+
const before = savedState(database);
|
|
19
|
+
for (const suffix of SAVED) {
|
|
20
|
+
rmSync(copy + suffix, { force: true });
|
|
21
|
+
try {
|
|
22
|
+
await copyFile(database + suffix, copy + suffix, fsConstants.COPYFILE_FICLONE);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
// A database can have no -wal, and one gone by the time its copy
|
|
26
|
+
// starts is a save, which the state after catches.
|
|
27
|
+
if (!suffix || error.code !== 'ENOENT')
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (before === savedState(database))
|
|
32
|
+
return copy;
|
|
33
|
+
if (attempt >= attempts) {
|
|
34
|
+
throw new ExtractorError('The database kept changing while it was copied', {
|
|
35
|
+
code: 'input-busy',
|
|
36
|
+
exitCode: EXIT_CODES.transient,
|
|
37
|
+
hint: 'Run it again in a moment.',
|
|
38
|
+
fields: { path: database },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
await delay(retryMs);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The size and time each of a database's files was saved, to tell whether one changed. */
|
|
45
|
+
function savedState(database) {
|
|
46
|
+
return SAVED.map(suffix => {
|
|
47
|
+
const file = statSync(database + suffix, { throwIfNoEntry: false });
|
|
48
|
+
return file ? `${file.size}:${file.mtimeMs}` : '-';
|
|
49
|
+
}).join(' ');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Write an empty database with only a schema's tables and columns, for a
|
|
53
|
+
* test that a reader's queries need nothing it doesn't declare. Without
|
|
54
|
+
* `optional`, the columns it does without are left out too.
|
|
55
|
+
*/
|
|
56
|
+
export function writeSchemaDatabase(path, schema, { optional = true } = {}) {
|
|
57
|
+
const db = new DatabaseSync(path);
|
|
58
|
+
try {
|
|
59
|
+
for (const [table, columns] of Object.entries(schema.tables)) {
|
|
60
|
+
const kept = columns
|
|
61
|
+
.filter(column => optional || !column.endsWith('?'))
|
|
62
|
+
.map(column => `"${column.replace(/\?$/, '')}"`);
|
|
63
|
+
db.exec(`CREATE TABLE "${table}" (${kept.join(', ')})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
db.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/etl-sqlite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.5.0",
|
|
23
|
+
"@chronicle.app/tsconfig": "0.5.0",
|
|
24
24
|
"@types/node": "^22.10.1",
|
|
25
25
|
"typescript": "^5.7.2"
|
|
26
26
|
},
|