@eltjs/elt-sqlite 0.1.3 → 0.3.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 +75 -9
- package/dist/computed-dataset.d.ts +3 -2
- package/dist/computed-dataset.js +92 -16
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sqlite-append-writer.d.ts +4 -3
- package/dist/sqlite-append-writer.js +4 -4
- package/dist/sqlite-as-of.d.ts +29 -0
- package/dist/sqlite-as-of.js +183 -0
- package/dist/sqlite-deduplicating-writer.d.ts +6 -4
- package/dist/sqlite-deduplicating-writer.js +50 -26
- package/dist/sqlite-destination.d.ts +12 -2
- package/dist/sqlite-destination.js +37 -18
- package/dist/sqlite-file-store.d.ts +5 -2
- package/dist/sqlite-file-store.js +41 -25
- package/dist/sqlite-history.d.ts +14 -0
- package/dist/sqlite-history.js +130 -0
- package/dist/sqlite-identifiers.js +9 -1
- package/dist/sqlite-loads.d.ts +38 -0
- package/dist/sqlite-loads.js +247 -0
- package/dist/sqlite-overwrite-writer.d.ts +4 -3
- package/dist/sqlite-overwrite-writer.js +4 -4
- package/dist/sqlite-table.js +1 -1
- package/dist/sqlite-writer.d.ts +19 -9
- package/dist/sqlite-writer.js +220 -72
- package/package.json +2 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { constants } from 'node:sqlite';
|
|
2
|
+
import { UnavailableError, open } from '@zukhruf/sqlite-open';
|
|
3
|
+
import { busyTimeout } from './open-destination.js';
|
|
4
|
+
import { histories } from './sqlite-history.js';
|
|
5
|
+
import { identifiers } from './sqlite-identifiers.js';
|
|
6
|
+
import { committedAt, expiry, parsePosition, readNumbering, } from './sqlite-loads.js';
|
|
7
|
+
import { attempts, checks, coverage } from './sqlite-sync-history-schema.js';
|
|
8
|
+
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
9
|
+
// A reader of the file, or null while no file is at the path.
|
|
10
|
+
export function reader(path) {
|
|
11
|
+
try {
|
|
12
|
+
return open(path, { readOnly: true, timeout: busyTimeout });
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error instanceof UnavailableError)
|
|
16
|
+
return null;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Reads the file as of the position, or as of its newest position. One read
|
|
21
|
+
// transaction holds the snapshot that the numbering and every later read
|
|
22
|
+
// see, so a commit after the reading begins shows in none of them.
|
|
23
|
+
export function readAsOf(path, position) {
|
|
24
|
+
const asked = position === undefined ? undefined : parsePosition(position);
|
|
25
|
+
const database = reader(path);
|
|
26
|
+
if (database === null)
|
|
27
|
+
return { expired: `No load has committed to ${path}` };
|
|
28
|
+
using stack = new DisposableStack();
|
|
29
|
+
stack.use(database);
|
|
30
|
+
database.exec('BEGIN');
|
|
31
|
+
const numbered = readNumbering(database);
|
|
32
|
+
if (asked !== undefined) {
|
|
33
|
+
const expired = expiry(numbered, asked);
|
|
34
|
+
if (expired !== undefined)
|
|
35
|
+
return { expired };
|
|
36
|
+
refuseInsidePass(numbered, asked, path);
|
|
37
|
+
}
|
|
38
|
+
const number = asked?.number ?? numbered?.ends.at(-1);
|
|
39
|
+
const at = number === undefined ? undefined : committedAt(database, number);
|
|
40
|
+
if (numbered === null || number === undefined || at === undefined)
|
|
41
|
+
return {
|
|
42
|
+
expired: position === undefined
|
|
43
|
+
? `${path} has no position yet`
|
|
44
|
+
: `Position ${position} names no commit of ${path}`,
|
|
45
|
+
};
|
|
46
|
+
if (number < numbered.newest)
|
|
47
|
+
shadow(database, number);
|
|
48
|
+
stack.move();
|
|
49
|
+
return {
|
|
50
|
+
sql: database,
|
|
51
|
+
position: `${numbered.epoch}:${number}`,
|
|
52
|
+
at: Temporal.Instant.from(at),
|
|
53
|
+
[Symbol.dispose]: () => database.close(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// No later load can straddle a position, so one that a reader was given
|
|
57
|
+
// stays one; a commit that is not a position stays inside its pass.
|
|
58
|
+
function refuseInsidePass(numbered, { epoch, number }, file) {
|
|
59
|
+
if (!numbered?.ends.includes(number))
|
|
60
|
+
throw new TypeError(`${epoch}:${number} is not a position of ${file}: a reading of a commit inside a pass would show a part of that pass. Read a position that positions() lists.`);
|
|
61
|
+
}
|
|
62
|
+
// Shadows, for this connection, each name a reader queries with a TEMP view
|
|
63
|
+
// of what it showed as of commit number. A TEMP view takes the place of a
|
|
64
|
+
// table or view of main for an unqualified name, and SQLite resolves the
|
|
65
|
+
// names of a view when it is read, so the views can be made in any order.
|
|
66
|
+
function shadow(database, number) {
|
|
67
|
+
const targets = versioned(database, 'main');
|
|
68
|
+
for (const version of targets)
|
|
69
|
+
database.exec(`CREATE TEMP VIEW ${quote(version.target)} AS ${selectAsOf(database, version, number, 'main')}`);
|
|
70
|
+
shadowSyncHistory(database);
|
|
71
|
+
const views = storedViews(database);
|
|
72
|
+
for (const { sql } of views)
|
|
73
|
+
database.exec(sql.replace(/^CREATE VIEW /, 'CREATE TEMP VIEW '));
|
|
74
|
+
refuseQualified(database, views.map(({ name }) => name), new Set(targets.map(({ target }) => identifiers.key(target))));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The SELECT that reads one target of the file as of a position, for a reader
|
|
78
|
+
* that copies the target, such as into a database of its own: the current
|
|
79
|
+
* rows that the commit of the position had made, and the history rows that it
|
|
80
|
+
* had made and a later commit replaced. `schema` names the file where
|
|
81
|
+
* `database` attached it, main by default. Run the SELECT in the read
|
|
82
|
+
* transaction that this checks the position in, since a later commit can
|
|
83
|
+
* expire the position. A position of another epoch, past the newest commit,
|
|
84
|
+
* or older than the retention, is expired.
|
|
85
|
+
*/
|
|
86
|
+
export function asOfSelect(database, target, position, { schema = 'main' } = {}) {
|
|
87
|
+
const asked = parsePosition(position);
|
|
88
|
+
const numbered = readNumbering(database, schema);
|
|
89
|
+
const expired = expiry(numbered, asked);
|
|
90
|
+
if (expired !== undefined)
|
|
91
|
+
return { expired };
|
|
92
|
+
refuseInsidePass(numbered, asked, schema === 'main' ? 'the file' : `the file attached as ${schema}`);
|
|
93
|
+
const version = versioned(database, schema).find((version) => identifiers.key(version.target) === identifiers.key(target));
|
|
94
|
+
if (version === undefined)
|
|
95
|
+
throw new TypeError(`${quote(target)} is not a target of ${schema}`);
|
|
96
|
+
return selectAsOf(database, version, asked.number, schema);
|
|
97
|
+
}
|
|
98
|
+
// A target as of commit number: its current rows that the commit had made,
|
|
99
|
+
// and the history rows that the commit had made and a later commit replaced.
|
|
100
|
+
// A column that the history lacks reads as NULL there.
|
|
101
|
+
function selectAsOf(database, { target, history }, number, schema) {
|
|
102
|
+
const columns = columnsOf(database, quote(target), schema);
|
|
103
|
+
const kept = new Set(columnsOf(database, quote(history), schema).map((column) => identifiers.key(column)));
|
|
104
|
+
const current = columns.map(quote).join(', ');
|
|
105
|
+
const replaced = columns
|
|
106
|
+
.map((column) => kept.has(identifiers.key(column))
|
|
107
|
+
? quote(column)
|
|
108
|
+
: `NULL AS ${quote(column)}`)
|
|
109
|
+
.join(', ');
|
|
110
|
+
const file = quote(schema);
|
|
111
|
+
return `SELECT ${current} FROM ${file}.${quote(target)} WHERE "committed_in" <= ${number} UNION ALL SELECT ${replaced} FROM ${file}.${quote(history)} WHERE "committed_in" <= ${number} AND "replaced_in" > ${number}`;
|
|
112
|
+
}
|
|
113
|
+
// The sync history keeps no versions, so each of its tables reads as a view
|
|
114
|
+
// that fails, and so does each view over them.
|
|
115
|
+
function shadowSyncHistory(database) {
|
|
116
|
+
database.function('_elt_unversioned', { deterministic: false }, () => {
|
|
117
|
+
throw new Error('The sync history cannot be read as of a position: it keeps no row versions');
|
|
118
|
+
});
|
|
119
|
+
for (const table of [attempts, coverage, checks]) {
|
|
120
|
+
const columns = columnsOf(database, table);
|
|
121
|
+
if (columns.length > 0)
|
|
122
|
+
database.exec(`CREATE TEMP VIEW ${table} AS SELECT ${columns.map((column) => `NULL AS ${quote(column)}`).join(', ')} WHERE "_elt_unversioned"()`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function storedViews(database) {
|
|
126
|
+
return database
|
|
127
|
+
.prepare(String.raw `SELECT "name", "sql" FROM main.sqlite_schema WHERE "type" = 'view' AND "name" NOT LIKE 'sqlite\_%' ESCAPE '\'`)
|
|
128
|
+
.all()
|
|
129
|
+
.map(({ name, sql }) => ({ name: String(name), sql: String(sql) }));
|
|
130
|
+
}
|
|
131
|
+
// Refuses a stored view that reads a versioned target as main."target": the
|
|
132
|
+
// name skips the TEMP view of the target, so the view would show the target
|
|
133
|
+
// as it is now. SQLite tells which view reads each table while it prepares a
|
|
134
|
+
// statement, so each view is prepared once with an authorizer that watches.
|
|
135
|
+
function refuseQualified(database, views, targets) {
|
|
136
|
+
let found;
|
|
137
|
+
database.setAuthorizer((action, table, _column, schema, view) => {
|
|
138
|
+
if (action === constants.SQLITE_READ &&
|
|
139
|
+
schema === 'main' &&
|
|
140
|
+
table !== null &&
|
|
141
|
+
targets.has(identifiers.key(table)) &&
|
|
142
|
+
identifiers.key(view ?? '') !== identifiers.key(table))
|
|
143
|
+
found ??= { view: view ?? '', target: table };
|
|
144
|
+
return constants.SQLITE_OK;
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
for (const view of views) {
|
|
148
|
+
try {
|
|
149
|
+
database.prepare(`SELECT * FROM temp.${quote(view)}`);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// A view that fails to prepare fails when it is read, too.
|
|
153
|
+
}
|
|
154
|
+
if (found !== undefined)
|
|
155
|
+
throw new TypeError(`View ${quote(found.view)} reads ${quote(found.target)} as main.${quote(found.target)}, which shows it as it is now, not as of a position; name the table without main.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
database.setAuthorizer(null);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Each target with its history, as the file attached as schema records them.
|
|
163
|
+
function versioned(database, schema) {
|
|
164
|
+
const file = quote(schema);
|
|
165
|
+
if (database
|
|
166
|
+
.prepare(`SELECT 1 FROM ${file}.sqlite_schema WHERE "type" = 'table' AND "name" = ?`)
|
|
167
|
+
.get(histories) === undefined)
|
|
168
|
+
return [];
|
|
169
|
+
return database
|
|
170
|
+
.prepare(`SELECT t."name" AS "target", h."history" FROM ${file}.${quote(histories)} AS h JOIN ${file}.sqlite_schema AS t ON t."type" = 'table' AND lower(t."name") = h."target" JOIN ${file}.sqlite_schema AS k ON k."type" = 'table' AND k."name" = h."history"`)
|
|
171
|
+
.all()
|
|
172
|
+
.map(({ target, history }) => ({
|
|
173
|
+
target: String(target),
|
|
174
|
+
history: String(history),
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
// The columns of a table of schema, named as SQL quotes it.
|
|
178
|
+
function columnsOf(database, table, schema = 'main') {
|
|
179
|
+
return database
|
|
180
|
+
.prepare(`PRAGMA ${quote(schema)}.table_info(${table})`)
|
|
181
|
+
.all()
|
|
182
|
+
.map(({ name }) => String(name));
|
|
183
|
+
}
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
|
|
2
2
|
import type { CopyConfiguration, Deduplication, KeyValue } from '@eltjs/elt';
|
|
3
3
|
import type { SQLiteColumn } from './sqlite-column.ts';
|
|
4
|
+
import type { SQLiteDestination } from './sqlite-destination.ts';
|
|
5
|
+
import type { SQLiteHistory } from './sqlite-history.ts';
|
|
4
6
|
import type { SQLiteTable } from './sqlite-table.ts';
|
|
5
|
-
import { SQLiteWriter } from './sqlite-writer.ts';
|
|
7
|
+
import { SQLiteWriter, type Stamp } from './sqlite-writer.ts';
|
|
6
8
|
export declare class SQLiteDeduplicatingWriter extends SQLiteWriter {
|
|
7
9
|
#private;
|
|
8
10
|
readonly deduplication: Deduplication;
|
|
9
11
|
readonly keys: readonly SQLiteColumn[];
|
|
10
12
|
readonly cursor?: SQLiteColumn;
|
|
11
|
-
constructor(configuration: CopyConfiguration,
|
|
13
|
+
constructor(configuration: CopyConfiguration, destination: SQLiteDestination, table: SQLiteTable);
|
|
12
14
|
protected get replaces(): boolean;
|
|
13
15
|
protected inspect(database: DatabaseSync): void;
|
|
14
16
|
protected initialize(database: DatabaseSync, replacing: boolean): void;
|
|
15
|
-
protected replace(database: DatabaseSync): void;
|
|
16
17
|
protected build(database: DatabaseSync, into: string): void;
|
|
17
18
|
protected adopt(database: DatabaseSync): void;
|
|
18
|
-
protected
|
|
19
|
+
protected kept(stage: string): string;
|
|
20
|
+
protected merge(database: DatabaseSync, stage: string, { loadedAt, committedIn }: Stamp, into: string, history: SQLiteHistory | null): void;
|
|
19
21
|
protected encode(record: unknown): SQLInputValue[];
|
|
20
22
|
protected deletionKeys(key: Readonly<Record<string, KeyValue>>): readonly [readonly SQLiteColumn[], SQLInputValue[]];
|
|
21
23
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { hash } from 'node:crypto';
|
|
2
2
|
import { SQLiteColumns } from './sqlite-columns.js';
|
|
3
|
-
import { SQLiteWriter, op, seq } from './sqlite-writer.js';
|
|
3
|
+
import { SQLiteWriter, op, seq, version } from './sqlite-writer.js';
|
|
4
4
|
export class SQLiteDeduplicatingWriter extends SQLiteWriter {
|
|
5
5
|
deduplication;
|
|
6
6
|
keys;
|
|
7
7
|
cursor;
|
|
8
|
-
constructor(configuration,
|
|
9
|
-
super(configuration,
|
|
8
|
+
constructor(configuration, destination, table) {
|
|
9
|
+
super(configuration, destination, table);
|
|
10
10
|
this.deduplication = configuration.deduplication();
|
|
11
11
|
const inferred = SQLiteColumns.fromSchema(configuration.stream.jsonSchema);
|
|
12
12
|
const column = (field) => {
|
|
@@ -55,18 +55,14 @@ export class SQLiteDeduplicatingWriter extends SQLiteWriter {
|
|
|
55
55
|
.get())
|
|
56
56
|
throw new TypeError('Existing rows repeat a deduplication key; replace them with overwrite_dedup before deduplicating incrementally');
|
|
57
57
|
}
|
|
58
|
-
// A replacing
|
|
59
|
-
//
|
|
58
|
+
// A replacing commit builds the index once the rows that leave are gone:
|
|
59
|
+
// under a narrower key, they can repeat a key.
|
|
60
60
|
initialize(database, replacing) {
|
|
61
61
|
if (replacing)
|
|
62
62
|
this.dropDedupIndexes(database, this.#dedupIndex);
|
|
63
63
|
else
|
|
64
64
|
this.#index(database);
|
|
65
65
|
}
|
|
66
|
-
replace(database) {
|
|
67
|
-
super.replace(database);
|
|
68
|
-
this.#index(database);
|
|
69
|
-
}
|
|
70
66
|
// The target's key index, built only when it does not exist yet.
|
|
71
67
|
#index(database) {
|
|
72
68
|
this.dropDedupIndexes(database, this.#dedupIndex);
|
|
@@ -87,27 +83,55 @@ export class SQLiteDeduplicatingWriter extends SQLiteWriter {
|
|
|
87
83
|
database.exec(`DROP INDEX IF EXISTS ${this.#provisionalIndex}`);
|
|
88
84
|
this.#index(database);
|
|
89
85
|
}
|
|
90
|
-
// The
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
86
|
+
// The cursor of the guard that rejects out-of-order replay: cursor_newer's,
|
|
87
|
+
// when the copy has a cursor.
|
|
88
|
+
get #guard() {
|
|
89
|
+
return this.configuration.dedupPolicy === 'replace'
|
|
90
|
+
? undefined
|
|
91
|
+
: this.cursor;
|
|
92
|
+
}
|
|
93
|
+
#same(left, right) {
|
|
94
|
+
return this.keys
|
|
95
|
+
.map(({ quotedName }) => `${left}.${quotedName} = ${right}.${quotedName}`)
|
|
96
|
+
.join(' AND ');
|
|
97
|
+
}
|
|
98
|
+
// The seq of each staged record that applying the staged operations one at
|
|
99
|
+
// a time keeps: only records after a key's last DELETE count. replace keeps
|
|
100
|
+
// the newest extraction, so a restated fact overwrites the loaded one;
|
|
101
|
+
// cursor_newer keeps the greatest cursor (the first on ties).
|
|
102
|
+
#accepted(stage) {
|
|
96
103
|
const keys = this.keys.map((column) => column.quotedName);
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const order = guarded
|
|
102
|
-
? `"staged".${cursor.quotedName} COLLATE BINARY DESC, "staged".${seq}`
|
|
103
|
-
: `"staged".${seq} DESC`;
|
|
104
|
+
const guard = this.#guard;
|
|
105
|
+
const order = guard === undefined
|
|
106
|
+
? `"staged".${seq} DESC`
|
|
107
|
+
: `"staged".${guard.quotedName} COLLATE BINARY DESC, "staged".${seq}`;
|
|
104
108
|
// Rank identifiers only: sorting whole mail bodies/files multiplies temporary disk usage.
|
|
109
|
+
return `WITH "deleted" AS (SELECT ${keys.join(', ')}, max(${seq}) AS "_elt_last" FROM ${stage} WHERE ${op} = 'D' GROUP BY ${keys.join(', ')}), "ranked" AS (SELECT "staged".${seq}, row_number() OVER (PARTITION BY ${keys.map((key) => `"staged".${key}`).join(', ')} ORDER BY ${order}) AS "_elt_rank" FROM ${stage} AS "staged" LEFT JOIN "deleted" ON ${this.#same('"deleted"', '"staged"')} WHERE "staged".${op} = 'R' AND ("deleted"."_elt_last" IS NULL OR "staged".${seq} > "deleted"."_elt_last")) SELECT ${seq} FROM "ranked" WHERE "_elt_rank" = 1`;
|
|
110
|
+
}
|
|
111
|
+
kept(stage) {
|
|
112
|
+
return `${seq} IN (${this.#accepted(stage)})`;
|
|
113
|
+
}
|
|
114
|
+
// A staged DELETE removes its key, and the records that #accepted keeps are
|
|
115
|
+
// upserted, with the guard, if any, rejecting out-of-order replay. A stored
|
|
116
|
+
// row with the values of the record that replaces it stays as it is, with
|
|
117
|
+
// its commit and its loaded_at; history keeps each stored row that a DELETE
|
|
118
|
+
// removes or a record with other values replaces.
|
|
119
|
+
merge(database, stage, { loadedAt, committedIn }, into, history) {
|
|
120
|
+
const keys = this.keys.map((column) => column.quotedName);
|
|
121
|
+
const deleted = `(${keys.join(', ')}) IN (SELECT ${keys.join(', ')} FROM ${stage} WHERE ${op} = 'D')`;
|
|
122
|
+
history?.keep(database, committedIn, `FROM ${into} AS "_elt_old" WHERE ${deleted}`);
|
|
123
|
+
database.exec(`DELETE FROM ${into} WHERE ${deleted}`);
|
|
124
|
+
const accepted = this.#accepted(stage);
|
|
125
|
+
const guard = this.#guard;
|
|
126
|
+
// Whether a staged record replaces the stored row: its values differ,
|
|
127
|
+
// and the guard, if any, lets it in.
|
|
128
|
+
const replaces = (record, row) => `${record}.${version} IS NOT ${row}.${version}${guard === undefined ? '' : ` AND ${record}.${guard.quotedName} COLLATE BINARY > ${row}.${guard.quotedName}`}`;
|
|
129
|
+
history?.keep(database, committedIn, `FROM ${stage} AS "_elt_new" JOIN ${into} AS "_elt_old" ON ${this.#same('"_elt_new"', '"_elt_old"')} WHERE "_elt_new".${seq} IN (${accepted}) AND ${replaces('"_elt_new"', '"_elt_old"')}`);
|
|
105
130
|
const columns = this.table.columns.map((column) => column.quotedName);
|
|
106
131
|
database
|
|
107
|
-
.prepare(`
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
.run(loadedAt);
|
|
132
|
+
.prepare(`INSERT INTO ${into} AS "_elt_target" (${this.written.join(', ')}) SELECT ${columns.join(', ')}, ?, ?, ${version} FROM ${stage} WHERE ${seq} IN (${accepted}) ORDER BY ${seq} ` +
|
|
133
|
+
`ON CONFLICT (${keys.map((key) => `${key} COLLATE BINARY`).join(', ')}) DO UPDATE SET ${this.written.map((field) => `${field} = excluded.${field}`).join(', ')} WHERE ${replaces('excluded', '"_elt_target"')}`)
|
|
134
|
+
.run(loadedAt, committedIn);
|
|
111
135
|
}
|
|
112
136
|
encode(record) {
|
|
113
137
|
this.deduplication.key(record);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CopyConfiguration, Load, PipelineClock } from '@eltjs/elt';
|
|
2
2
|
import { Destination } from '@eltjs/elt';
|
|
3
|
+
import { type SQLiteReading } from './sqlite-as-of.ts';
|
|
3
4
|
import type { SQLiteColumn } from './sqlite-column.ts';
|
|
4
5
|
import { SQLiteColumns } from './sqlite-columns.ts';
|
|
5
6
|
import { SQLiteTable } from './sqlite-table.ts';
|
|
@@ -7,13 +8,22 @@ import type { SQLiteWriter } from './sqlite-writer.ts';
|
|
|
7
8
|
export declare class SQLiteDestination extends Destination<SQLiteTable> {
|
|
8
9
|
readonly supportedDestinationSyncModes: readonly ["overwrite", "append", "append_dedup", "overwrite_dedup"];
|
|
9
10
|
readonly path: string;
|
|
10
|
-
|
|
11
|
+
readonly retention: Temporal.Duration;
|
|
12
|
+
constructor({ path, retention, }: {
|
|
11
13
|
path: string;
|
|
14
|
+
retention?: Temporal.Duration;
|
|
12
15
|
});
|
|
13
16
|
location(target: SQLiteTable): string;
|
|
14
|
-
load({ clock, }: {
|
|
17
|
+
load({ clock, connection, }: {
|
|
15
18
|
clock: PipelineClock;
|
|
19
|
+
connection: string;
|
|
16
20
|
}): Promise<Load<SQLiteTable>>;
|
|
21
|
+
positions(after?: string): readonly string[] | {
|
|
22
|
+
readonly expired: string;
|
|
23
|
+
};
|
|
24
|
+
reading(position?: string): SQLiteReading | {
|
|
25
|
+
readonly expired: string;
|
|
26
|
+
};
|
|
17
27
|
table(name: string, configure?: (columns: SQLiteColumns) => readonly SQLiteColumn[]): SQLiteTable;
|
|
18
28
|
createWriter(configuration: CopyConfiguration, target: SQLiteTable): SQLiteWriter;
|
|
19
29
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { Destination } from '@eltjs/elt';
|
|
3
|
-
import { openDestination } from './open-destination.js';
|
|
4
3
|
import { SQLiteAppendWriter } from './sqlite-append-writer.js';
|
|
4
|
+
import { readAsOf, reader } from './sqlite-as-of.js';
|
|
5
5
|
import { SQLiteColumns } from './sqlite-columns.js';
|
|
6
6
|
import { SQLiteDeduplicatingWriter } from './sqlite-deduplicating-writer.js';
|
|
7
|
+
import { SQLiteLoad, positionsAfter } from './sqlite-loads.js';
|
|
7
8
|
import { SQLiteOverwriteWriter } from './sqlite-overwrite-writer.js';
|
|
8
9
|
import { SQLiteTable } from './sqlite-table.js';
|
|
9
10
|
export class SQLiteDestination extends Destination {
|
|
@@ -14,11 +15,17 @@ export class SQLiteDestination extends Destination {
|
|
|
14
15
|
'overwrite_dedup',
|
|
15
16
|
]);
|
|
16
17
|
path;
|
|
17
|
-
|
|
18
|
+
// How long the history keeps a position readable after its commit; Delta
|
|
19
|
+
// Lake keeps its log for the same 30 days by default.
|
|
20
|
+
retention;
|
|
21
|
+
constructor({ path, retention = Temporal.Duration.from({ days: 30 }), }) {
|
|
18
22
|
super();
|
|
19
23
|
if (!path)
|
|
20
24
|
throw new TypeError('SQLite requires a database path');
|
|
25
|
+
if (retention.sign < 0)
|
|
26
|
+
throw new RangeError(`Retention ${retention} must not be negative`);
|
|
21
27
|
this.path = path === ':memory:' ? path : resolve(path);
|
|
28
|
+
this.retention = retention;
|
|
22
29
|
Object.freeze(this);
|
|
23
30
|
}
|
|
24
31
|
location(target) {
|
|
@@ -26,22 +33,34 @@ export class SQLiteDestination extends Destination {
|
|
|
26
33
|
}
|
|
27
34
|
// Each stream's commit is its own short transaction, so no transaction
|
|
28
35
|
// stays open while the sources read.
|
|
29
|
-
async load({ clock, }) {
|
|
30
|
-
const
|
|
31
|
-
const loadedAt = new Date(clock.Date.now()).toISOString();
|
|
36
|
+
async load({ clock, connection, }) {
|
|
37
|
+
const load = SQLiteLoad.open(this.path, { connector: connection }, { clock, retention: this.retention });
|
|
32
38
|
return {
|
|
33
|
-
prepare: async (configuration, target, binding) => this.createWriter(configuration, target).prepare(
|
|
34
|
-
[Symbol.asyncDispose]: async () =>
|
|
35
|
-
try {
|
|
36
|
-
if (database.isTransaction)
|
|
37
|
-
database.exec('ROLLBACK');
|
|
38
|
-
}
|
|
39
|
-
finally {
|
|
40
|
-
database.close();
|
|
41
|
-
}
|
|
42
|
-
},
|
|
39
|
+
prepare: async (configuration, target, binding) => this.createWriter(configuration, target).prepare(load, binding),
|
|
40
|
+
[Symbol.asyncDispose]: async () => load[Symbol.dispose](),
|
|
43
41
|
};
|
|
44
42
|
}
|
|
43
|
+
// The positions of the file after a position, ascending, or all of them;
|
|
44
|
+
// each one, `${epoch}:${number}`, names a commit where no pass is half
|
|
45
|
+
// read. A position of another epoch, or past the newest commit, is
|
|
46
|
+
// expired, and so is one older than the retention.
|
|
47
|
+
positions(after) {
|
|
48
|
+
if (this.path === ':memory:')
|
|
49
|
+
throw new TypeError('Positions require a SQLite destination file');
|
|
50
|
+
using database = reader(this.path);
|
|
51
|
+
return positionsAfter(database, after);
|
|
52
|
+
}
|
|
53
|
+
// The file as of a position, or as of its newest position: each target,
|
|
54
|
+
// its reader view and each view over them show the rows that the commit of
|
|
55
|
+
// the position left, and the sync history refuses to be read. At the
|
|
56
|
+
// newest commit the reading reads the tables as they are. It opens the
|
|
57
|
+
// file read-only, in one read transaction. A position of another epoch,
|
|
58
|
+
// past the newest commit, or older than the retention, is expired.
|
|
59
|
+
reading(position) {
|
|
60
|
+
if (this.path === ':memory:')
|
|
61
|
+
throw new TypeError('Readings require a SQLite destination file');
|
|
62
|
+
return readAsOf(this.path, position);
|
|
63
|
+
}
|
|
45
64
|
table(name, configure) {
|
|
46
65
|
return new SQLiteTable(name, configure?.(new SQLiteColumns()));
|
|
47
66
|
}
|
|
@@ -55,11 +74,11 @@ export class SQLiteDestination extends Destination {
|
|
|
55
74
|
switch (configuration.destinationSyncMode) {
|
|
56
75
|
case 'append_dedup':
|
|
57
76
|
case 'overwrite_dedup':
|
|
58
|
-
return new SQLiteDeduplicatingWriter(configuration, this
|
|
77
|
+
return new SQLiteDeduplicatingWriter(configuration, this, table);
|
|
59
78
|
case 'append':
|
|
60
|
-
return new SQLiteAppendWriter(configuration, this
|
|
79
|
+
return new SQLiteAppendWriter(configuration, this, table);
|
|
61
80
|
case 'overwrite':
|
|
62
|
-
return new SQLiteOverwriteWriter(configuration, this
|
|
81
|
+
return new SQLiteOverwriteWriter(configuration, this, table);
|
|
63
82
|
default:
|
|
64
83
|
throw new TypeError(`Destination does not support ${configuration.destinationSyncMode}`);
|
|
65
84
|
}
|
|
@@ -7,10 +7,13 @@ export declare class SQLiteFileStore {
|
|
|
7
7
|
static readonly chunkSize: number;
|
|
8
8
|
readonly name: string;
|
|
9
9
|
constructor(database: DatabaseSync, table: SQLiteTable, column: SQLiteColumn);
|
|
10
|
-
static tableName(table: SQLiteTable, column: Pick<SQLiteColumn, 'name'>): string;
|
|
10
|
+
static tableName(table: Pick<SQLiteTable, 'location'>, column: Pick<SQLiteColumn, 'name'>): string;
|
|
11
11
|
get published(): boolean;
|
|
12
12
|
stage(): void;
|
|
13
|
-
save(content: FileContent): Promise<
|
|
13
|
+
save(content: FileContent): Promise<{
|
|
14
|
+
readonly id: number;
|
|
15
|
+
readonly sha256: Uint8Array;
|
|
16
|
+
}>;
|
|
14
17
|
publish(): void;
|
|
15
18
|
discard(): void;
|
|
16
19
|
unstage(): void;
|
|
@@ -1,24 +1,28 @@
|
|
|
1
|
-
import { hash } from 'node:crypto';
|
|
1
|
+
import { createHash, hash } from 'node:crypto';
|
|
2
|
+
import { Declaration } from '@zukhruf/sqlite-drift';
|
|
2
3
|
import { identifiers } from './sqlite-identifiers.js';
|
|
3
4
|
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
4
5
|
const tablePrefix = '_elt_files_';
|
|
5
6
|
const chunks = '("file" INTEGER NOT NULL, "n" INTEGER NOT NULL, "bytes" BLOB NOT NULL, PRIMARY KEY ("file", "n")) STRICT';
|
|
7
|
+
// The highest id that each store gave a file it published. Ids only go up: a
|
|
8
|
+
// row of the history keeps the id of its file for a reading as of a position,
|
|
9
|
+
// so no other file may take that id, even after the chunks of the file, or the
|
|
10
|
+
// whole store, are gone.
|
|
11
|
+
const ids = new Declaration([
|
|
12
|
+
'CREATE TABLE "_elt_file_ids" ("store" TEXT PRIMARY KEY, "last" INTEGER NOT NULL) STRICT',
|
|
13
|
+
], { rows: 'replace' });
|
|
6
14
|
// A file column's bytes, in ordered chunks: SQLite caps one BLOB at 1e9 bytes
|
|
7
15
|
// and a whole-file BLOB would hold the file in memory. The row keeps the
|
|
8
|
-
// file's id
|
|
9
|
-
//
|
|
10
|
-
//
|
|
16
|
+
// file's id. A load stages chunks in a connection-private TEMP table and
|
|
17
|
+
// publishes them inside a commit, so a load holds no transaction while it
|
|
18
|
+
// reads files.
|
|
11
19
|
export class SQLiteFileStore {
|
|
12
20
|
static chunkSize = 4 * 1024 * 1024;
|
|
13
21
|
name;
|
|
14
22
|
#database;
|
|
15
|
-
#table;
|
|
16
|
-
#column;
|
|
17
23
|
#staged;
|
|
18
24
|
constructor(database, table, column) {
|
|
19
25
|
this.#database = database;
|
|
20
|
-
this.#table = table;
|
|
21
|
-
this.#column = column;
|
|
22
26
|
this.name = SQLiteFileStore.tableName(table, column);
|
|
23
27
|
this.#staged = quote(`${tablePrefix}stage_${this.name.slice(tablePrefix.length)}`);
|
|
24
28
|
}
|
|
@@ -34,6 +38,14 @@ export class SQLiteFileStore {
|
|
|
34
38
|
.prepare(`SELECT 1 FROM sqlite_schema WHERE "type" = 'table' AND lower("name") = ?`)
|
|
35
39
|
.get(location) !== undefined);
|
|
36
40
|
}
|
|
41
|
+
#lastGivenId() {
|
|
42
|
+
if (!this.#exists('_elt_file_ids'))
|
|
43
|
+
return 0;
|
|
44
|
+
const row = this.#database
|
|
45
|
+
.prepare('SELECT "last" FROM "_elt_file_ids" WHERE "store" = ?')
|
|
46
|
+
.get(this.name);
|
|
47
|
+
return Number(row?.last ?? 0);
|
|
48
|
+
}
|
|
37
49
|
get published() {
|
|
38
50
|
return this.#exists(this.name);
|
|
39
51
|
}
|
|
@@ -41,33 +53,37 @@ export class SQLiteFileStore {
|
|
|
41
53
|
this.#database.exec(`DROP TABLE IF EXISTS temp.${this.#staged}`);
|
|
42
54
|
this.#database.exec(`CREATE TEMP TABLE ${this.#staged} ${chunks}`);
|
|
43
55
|
}
|
|
44
|
-
//
|
|
45
|
-
//
|
|
56
|
+
// Stages the file's chunks under an id above each id the store gave and each
|
|
57
|
+
// staged id. An empty file still stores one empty chunk, so its id stays
|
|
58
|
+
// reserved. Gives the id and the SHA-256 of the bytes, which names the
|
|
59
|
+
// content.
|
|
46
60
|
async save(content) {
|
|
47
|
-
const
|
|
48
|
-
|
|
61
|
+
const given = this.#lastGivenId();
|
|
62
|
+
const staged = Number(this.#database
|
|
63
|
+
.prepare(`SELECT coalesce(max("file"), 0) AS "file" FROM temp.${this.#staged}`)
|
|
49
64
|
.get()?.file);
|
|
50
|
-
const
|
|
65
|
+
const id = Math.max(given, staged) + 1;
|
|
51
66
|
const insert = this.#database.prepare(`INSERT INTO temp.${this.#staged} ("file", "n", "bytes") VALUES (?, ?, ?)`);
|
|
67
|
+
const sha256 = createHash('sha256');
|
|
52
68
|
let n = 0;
|
|
53
|
-
for await (const chunk of content.chunks(SQLiteFileStore.chunkSize))
|
|
54
|
-
|
|
69
|
+
for await (const chunk of content.chunks(SQLiteFileStore.chunkSize)) {
|
|
70
|
+
sha256.update(chunk);
|
|
71
|
+
insert.run(id, n++, chunk);
|
|
72
|
+
}
|
|
55
73
|
if (n === 0)
|
|
56
|
-
insert.run(
|
|
57
|
-
return
|
|
74
|
+
insert.run(id, 0, new Uint8Array());
|
|
75
|
+
return { id, sha256: sha256.digest() };
|
|
58
76
|
}
|
|
59
|
-
// Moves the staged chunks into the store,
|
|
60
|
-
//
|
|
61
|
-
// them, which its next commit attaches again.
|
|
77
|
+
// Moves the staged chunks into the store, and records the highest id they
|
|
78
|
+
// take, inside a commit.
|
|
62
79
|
publish() {
|
|
63
80
|
const table = quote(this.name);
|
|
64
|
-
const column = this.#column.quotedName;
|
|
65
81
|
this.#database.exec(`CREATE TABLE IF NOT EXISTS ${table} ${chunks}`);
|
|
66
|
-
if (this.#exists(this.#table.location)) {
|
|
67
|
-
this.#database.exec(`CREATE TRIGGER IF NOT EXISTS ${quote(`${this.name}_delete`)} AFTER DELETE ON ${this.#table.quotedName} BEGIN DELETE FROM ${table} WHERE "file" = old.${column}; END`);
|
|
68
|
-
this.#database.exec(`CREATE TRIGGER IF NOT EXISTS ${quote(`${this.name}_update`)} AFTER UPDATE OF ${column} ON ${this.#table.quotedName} WHEN old.${column} IS NOT new.${column} BEGIN DELETE FROM ${table} WHERE "file" = old.${column}; END`);
|
|
69
|
-
}
|
|
70
82
|
this.#database.exec(`INSERT INTO ${table} SELECT * FROM temp.${this.#staged}`);
|
|
83
|
+
ids.apply(this.#database);
|
|
84
|
+
this.#database
|
|
85
|
+
.prepare(`INSERT INTO "_elt_file_ids" ("store", "last") SELECT ?, "file" FROM (SELECT max("file") AS "file" FROM temp.${this.#staged}) WHERE "file" IS NOT NULL ON CONFLICT ("store") DO UPDATE SET "last" = excluded."last"`)
|
|
86
|
+
.run(this.name);
|
|
71
87
|
this.discard();
|
|
72
88
|
}
|
|
73
89
|
discard() {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
|
|
2
|
+
import type { SQLiteTable } from './sqlite-table.ts';
|
|
3
|
+
export declare const histories = "_elt_histories";
|
|
4
|
+
export declare class SQLiteHistory {
|
|
5
|
+
#private;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
constructor(table: Pick<SQLiteTable, 'name' | 'location'>);
|
|
8
|
+
create(database: DatabaseSync, written: readonly string[]): void;
|
|
9
|
+
keep(database: DatabaseSync, replacedIn: number, rows: string, params?: readonly SQLInputValue[]): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function expireHistory(database: DatabaseSync, through: number): void;
|
|
12
|
+
export declare function rowHash(columns: readonly {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
}[], values: readonly SQLInputValue[]): string;
|