@eltjs/elt-sqlite 0.2.0 → 0.3.1
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 +81 -8
- package/dist/computed-dataset.d.ts +3 -2
- package/dist/computed-dataset.js +90 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/sqlite-append-writer.d.ts +4 -3
- package/dist/sqlite-append-writer.js +4 -4
- package/dist/sqlite-as-of-definitions.d.ts +19 -0
- package/dist/sqlite-as-of-definitions.js +66 -0
- package/dist/sqlite-as-of.d.ts +60 -0
- package/dist/sqlite-as-of.js +230 -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 +256 -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,130 @@
|
|
|
1
|
+
import { createHash, hash } from 'node:crypto';
|
|
2
|
+
import { Declaration } from '@zukhruf/sqlite-drift';
|
|
3
|
+
import { SQLiteFileStore } from './sqlite-file-store.js';
|
|
4
|
+
import { identifiers } from './sqlite-identifiers.js';
|
|
5
|
+
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
6
|
+
export const histories = '_elt_histories';
|
|
7
|
+
// Which table keeps the replaced rows of which target, by the target's
|
|
8
|
+
// location, so a reader finds the history of each target.
|
|
9
|
+
const registry = new Declaration([
|
|
10
|
+
`CREATE TABLE ${quote(histories)} ("target" TEXT PRIMARY KEY, "history" TEXT NOT NULL) STRICT`,
|
|
11
|
+
], { rows: 'replace' });
|
|
12
|
+
// The rows of a target that commits replaced or removed, each with the
|
|
13
|
+
// number of the commit that did, as a SQL Server temporal table keeps them
|
|
14
|
+
// beside its current table. Its columns declare no type, so each value keeps
|
|
15
|
+
// the type it had in the target whatever the target declares later. It takes
|
|
16
|
+
// each column of the target that it does not have, and never drops one.
|
|
17
|
+
export class SQLiteHistory {
|
|
18
|
+
name;
|
|
19
|
+
#table;
|
|
20
|
+
constructor(table) {
|
|
21
|
+
this.#table = table;
|
|
22
|
+
this.name = `_elt_history_${hash('sha256', table.location)}`;
|
|
23
|
+
}
|
|
24
|
+
// Made with the target, with written, the quoted columns that each row of
|
|
25
|
+
// the target holds, and recorded as its history. SQLite cannot add a
|
|
26
|
+
// column named rowid, _rowid_ or oid to a table, so the history has the
|
|
27
|
+
// target's columns from the start.
|
|
28
|
+
create(database, written) {
|
|
29
|
+
database.exec(`CREATE TABLE IF NOT EXISTS ${quote(this.name)} ("replaced_in" INTEGER NOT NULL, ${written.join(', ')})`);
|
|
30
|
+
database.exec(`CREATE INDEX IF NOT EXISTS ${quote(`${this.name}_replaced_in`)} ON ${quote(this.name)} ("replaced_in")`);
|
|
31
|
+
registry.apply(database);
|
|
32
|
+
database
|
|
33
|
+
.prepare(`INSERT INTO ${quote(histories)} ("target", "history") VALUES (?, ?) ON CONFLICT ("target") DO UPDATE SET "history" = excluded."history"`)
|
|
34
|
+
.run(this.#table.location, this.name);
|
|
35
|
+
}
|
|
36
|
+
// Copies the rows of the target that rows selects into the history, as
|
|
37
|
+
// replaced in commit replacedIn. rows is the FROM clause of a SELECT in
|
|
38
|
+
// which "_elt_old" names the target.
|
|
39
|
+
keep(database, replacedIn, rows, params = []) {
|
|
40
|
+
const columns = names(database, this.#table.name);
|
|
41
|
+
const kept = new Set(names(database, this.name).map((name) => identifiers.key(name)));
|
|
42
|
+
for (const column of columns)
|
|
43
|
+
if (!kept.has(identifiers.key(column)))
|
|
44
|
+
database.exec(`ALTER TABLE ${quote(this.name)} ADD COLUMN ${quote(column)}`);
|
|
45
|
+
database
|
|
46
|
+
.prepare(`INSERT INTO ${quote(this.name)} (${columns.map(quote).join(', ')}, "replaced_in") SELECT ${columns.map((column) => `"_elt_old".${quote(column)}`).join(', ')}, ? ${rows}`)
|
|
47
|
+
.run(replacedIn, ...params);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Deletes, in each history, the rows that only a reading before commit
|
|
51
|
+
// through reads: each row that a commit up to it replaced. The stored files
|
|
52
|
+
// that only those rows refer to go in the same transaction, so the chunks of
|
|
53
|
+
// the file stay the files that a row refers to. The rows of a reload's hidden
|
|
54
|
+
// target refer to no such file: each file id is given once, and a hidden row
|
|
55
|
+
// reaches a history only after its reload ends and drops the hidden target.
|
|
56
|
+
export function expireHistory(database, through) {
|
|
57
|
+
const exists = (name) => database
|
|
58
|
+
.prepare(`SELECT 1 FROM sqlite_schema WHERE "type" = 'table' AND lower("name") = lower(?)`)
|
|
59
|
+
.get(name) !== undefined;
|
|
60
|
+
if (!exists(histories))
|
|
61
|
+
return;
|
|
62
|
+
const pairs = database
|
|
63
|
+
.prepare(`SELECT "target", "history" FROM ${quote(histories)}`)
|
|
64
|
+
.all()
|
|
65
|
+
.map((row) => ({
|
|
66
|
+
target: String(row.target),
|
|
67
|
+
history: String(row.history),
|
|
68
|
+
}));
|
|
69
|
+
for (const { target, history } of pairs) {
|
|
70
|
+
if (!exists(history))
|
|
71
|
+
continue;
|
|
72
|
+
const current = new Set(exists(target)
|
|
73
|
+
? names(database, target).map((name) => identifiers.key(name))
|
|
74
|
+
: []);
|
|
75
|
+
for (const column of names(database, history)) {
|
|
76
|
+
const chunks = SQLiteFileStore.tableName({ location: target }, { name: column });
|
|
77
|
+
if (!exists(chunks))
|
|
78
|
+
continue;
|
|
79
|
+
const value = quote(column);
|
|
80
|
+
const kept = [
|
|
81
|
+
...(current.has(identifiers.key(column))
|
|
82
|
+
? [`SELECT ${value} FROM ${quote(target)} WHERE ${value} IS NOT NULL`]
|
|
83
|
+
: []),
|
|
84
|
+
`SELECT ${value} FROM ${quote(history)} WHERE "replaced_in" > ? AND ${value} IS NOT NULL`,
|
|
85
|
+
];
|
|
86
|
+
database
|
|
87
|
+
.prepare(`DELETE FROM ${quote(chunks)} WHERE "file" IN (SELECT ${value} FROM ${quote(history)} WHERE "replaced_in" <= ?) AND "file" NOT IN (${kept.join(' UNION ')})`)
|
|
88
|
+
.run(through, through);
|
|
89
|
+
}
|
|
90
|
+
database
|
|
91
|
+
.prepare(`DELETE FROM ${quote(history)} WHERE "replaced_in" <= ?`)
|
|
92
|
+
.run(through);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function names(database, table) {
|
|
96
|
+
return database
|
|
97
|
+
.prepare(`PRAGMA main.table_info(${quote(table)})`)
|
|
98
|
+
.all()
|
|
99
|
+
.map(({ name }) => String(name));
|
|
100
|
+
}
|
|
101
|
+
// The SHA-256 of the values of a staged row, which names its version: a
|
|
102
|
+
// commit that finds the stored row with the same hash leaves it as it is.
|
|
103
|
+
// Each value that is not null counts with its column's name and its type, so
|
|
104
|
+
// a column the stream adds, null in each stored row, makes no new version. A
|
|
105
|
+
// stored file counts as the SHA-256 of its bytes, not as its id.
|
|
106
|
+
export function rowHash(columns, values) {
|
|
107
|
+
const digest = createHash('sha256');
|
|
108
|
+
const add = (tag, data) => {
|
|
109
|
+
const bytes = typeof data === 'string'
|
|
110
|
+
? Buffer.from(data)
|
|
111
|
+
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
112
|
+
digest.update(`${tag}${bytes.byteLength}:`);
|
|
113
|
+
digest.update(bytes);
|
|
114
|
+
};
|
|
115
|
+
columns.forEach((column, index) => {
|
|
116
|
+
const value = values[index];
|
|
117
|
+
if (value === null || value === undefined)
|
|
118
|
+
return;
|
|
119
|
+
add('c', column.name);
|
|
120
|
+
if (typeof value === 'string')
|
|
121
|
+
add('t', value);
|
|
122
|
+
else if (typeof value === 'bigint')
|
|
123
|
+
add('n', String(value));
|
|
124
|
+
else if (typeof value === 'number')
|
|
125
|
+
add('n', Number.isInteger(value) ? String(BigInt(value)) : String(value));
|
|
126
|
+
else
|
|
127
|
+
add('b', value);
|
|
128
|
+
});
|
|
129
|
+
return digest.digest('hex');
|
|
130
|
+
}
|
|
@@ -3,7 +3,15 @@ import { Identifiers } from '@eltjs/elt';
|
|
|
3
3
|
// folds A-Z only, so `É` and `é` stay two names.
|
|
4
4
|
class SQLiteIdentifiers extends Identifiers {
|
|
5
5
|
maxBytes = Infinity;
|
|
6
|
-
|
|
6
|
+
// _rowid_ reads the id SQLite gives each row, which a difference needs, as
|
|
7
|
+
// long as no column takes the name.
|
|
8
|
+
ownColumns = [
|
|
9
|
+
'loaded_at',
|
|
10
|
+
'committed_in',
|
|
11
|
+
'_elt_row_hash',
|
|
12
|
+
'replaced_in',
|
|
13
|
+
'_rowid_',
|
|
14
|
+
];
|
|
7
15
|
key(name) {
|
|
8
16
|
return name.replaceAll(/[A-Z]/g, (letter) => letter.toLowerCase());
|
|
9
17
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { PipelineClock } from '@eltjs/elt';
|
|
3
|
+
export type LoadOwner = {
|
|
4
|
+
readonly connector: string;
|
|
5
|
+
} | {
|
|
6
|
+
readonly dataset: string;
|
|
7
|
+
};
|
|
8
|
+
export declare class SQLiteLoad implements Disposable {
|
|
9
|
+
#private;
|
|
10
|
+
readonly database: DatabaseSync;
|
|
11
|
+
readonly loadedAt: string;
|
|
12
|
+
private constructor();
|
|
13
|
+
static open(path: string, owner: LoadOwner, retaining: Retaining): SQLiteLoad;
|
|
14
|
+
static once(database: DatabaseSync, connection: string, retaining: Retaining): number;
|
|
15
|
+
commit(): number;
|
|
16
|
+
[Symbol.dispose](): void;
|
|
17
|
+
}
|
|
18
|
+
export type Retaining = {
|
|
19
|
+
readonly clock: PipelineClock;
|
|
20
|
+
readonly retention: Temporal.Duration;
|
|
21
|
+
};
|
|
22
|
+
export type Numbering = {
|
|
23
|
+
readonly epoch: string;
|
|
24
|
+
readonly newest: number;
|
|
25
|
+
readonly minValid: number;
|
|
26
|
+
readonly ends: readonly number[];
|
|
27
|
+
};
|
|
28
|
+
export type Position = {
|
|
29
|
+
readonly epoch: string;
|
|
30
|
+
readonly number: number;
|
|
31
|
+
};
|
|
32
|
+
export declare function positionsAfter(database: DatabaseSync | null, after: string | undefined): readonly string[] | {
|
|
33
|
+
readonly expired: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function expiry(numbered: Numbering | null, { epoch, number }: Position): string | undefined;
|
|
36
|
+
export declare function readNumbering(database: DatabaseSync, schema?: string): Numbering | null;
|
|
37
|
+
export declare function committedAt(database: DatabaseSync, number: number): string | undefined;
|
|
38
|
+
export declare function parsePosition(position: string): Position;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Declaration } from '@zukhruf/sqlite-drift';
|
|
3
|
+
import { transaction } from '@zukhruf/sqlite-transaction';
|
|
4
|
+
import { openDestination } from './open-destination.js';
|
|
5
|
+
import { expireHistory } from './sqlite-history.js';
|
|
6
|
+
const epochs = '"_elt_epoch"';
|
|
7
|
+
const loads = '"_elt_loads"';
|
|
8
|
+
const commits = '"_elt_commits"';
|
|
9
|
+
// How the file numbers its commits. The epoch names the numbering: it is
|
|
10
|
+
// made with the tables, so a file made again, or tables another version
|
|
11
|
+
// declared otherwise, start a numbering of their own. min_valid is the oldest
|
|
12
|
+
// position that the history still reads, as SQL Server's MIN_VALID_VERSION.
|
|
13
|
+
const numbering = new Declaration([
|
|
14
|
+
`CREATE TABLE ${epochs} ("id" INTEGER PRIMARY KEY CHECK ("id" = 1), "epoch" TEXT NOT NULL, "min_valid" INTEGER NOT NULL DEFAULT 0) STRICT`,
|
|
15
|
+
`CREATE TABLE ${loads} ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "connector" TEXT, "dataset" TEXT, "opened_at" TEXT NOT NULL, "closed_at" TEXT, CHECK (("connector" IS NULL) <> ("dataset" IS NULL))) STRICT`,
|
|
16
|
+
`CREATE TABLE ${commits} ("number" INTEGER PRIMARY KEY AUTOINCREMENT, "load" INTEGER NOT NULL REFERENCES ${loads} ("id"), "committed_at" TEXT NOT NULL) STRICT`,
|
|
17
|
+
`CREATE INDEX "_elt_commits_load" ON ${commits} ("load", "number")`,
|
|
18
|
+
], { rows: 'replace' });
|
|
19
|
+
// One load's connection to its file, and its record there: open from the
|
|
20
|
+
// start of the load until it is disposed, with a number for each commit.
|
|
21
|
+
export class SQLiteLoad {
|
|
22
|
+
database;
|
|
23
|
+
// When the load began, which each row it writes keeps in loaded_at.
|
|
24
|
+
loadedAt;
|
|
25
|
+
#id;
|
|
26
|
+
#owner;
|
|
27
|
+
#retaining;
|
|
28
|
+
constructor(database, id, loadedAt, owner, retaining) {
|
|
29
|
+
this.database = database;
|
|
30
|
+
this.#id = id;
|
|
31
|
+
this.loadedAt = loadedAt;
|
|
32
|
+
this.#owner = owner;
|
|
33
|
+
this.#retaining = retaining;
|
|
34
|
+
}
|
|
35
|
+
// A connection runs one pass at a time, and a dataset one refresh, so a
|
|
36
|
+
// load of the owner that is still open is one whose process ended during
|
|
37
|
+
// it: the new load closes it, as the sync history of a sole writer fails
|
|
38
|
+
// such a pass.
|
|
39
|
+
static open(path, owner, retaining) {
|
|
40
|
+
const database = openDestination(path);
|
|
41
|
+
const loadedAt = now(retaining.clock);
|
|
42
|
+
try {
|
|
43
|
+
const id = transaction(database, () => {
|
|
44
|
+
const id = begin(database, owner, loadedAt);
|
|
45
|
+
const [column, name] = owned(owner);
|
|
46
|
+
database
|
|
47
|
+
.prepare(`UPDATE ${loads} SET "closed_at" = ? WHERE ${column} = ? AND "closed_at" IS NULL AND "id" <> ?`)
|
|
48
|
+
.run(loadedAt, name, id);
|
|
49
|
+
return id;
|
|
50
|
+
}, { mode: 'immediate' });
|
|
51
|
+
return new SQLiteLoad(database, id, loadedAt, owner, retaining);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
database.close();
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// The number of a change that is a load of one commit, such as a clear,
|
|
59
|
+
// taken inside the caller's transaction: the load opens, commits and
|
|
60
|
+
// closes in it. It is not a pass, so it leaves the open loads of its
|
|
61
|
+
// connection open, and a pass that has commits before and after it keeps it
|
|
62
|
+
// from being a position until that pass ends.
|
|
63
|
+
static once(database, connection, retaining) {
|
|
64
|
+
const at = now(retaining.clock);
|
|
65
|
+
const id = begin(database, { connector: connection }, at);
|
|
66
|
+
const committed = Number(database
|
|
67
|
+
.prepare(`INSERT INTO ${commits} ("load", "committed_at") VALUES (?, ?) RETURNING "number"`)
|
|
68
|
+
.get(id, at)?.number);
|
|
69
|
+
database
|
|
70
|
+
.prepare(`UPDATE ${loads} SET "closed_at" = ? WHERE "id" = ?`)
|
|
71
|
+
.run(at, id);
|
|
72
|
+
retain(database, retaining);
|
|
73
|
+
return committed;
|
|
74
|
+
}
|
|
75
|
+
// The number of a commit of this load, taken inside the commit's
|
|
76
|
+
// transaction: SQLite runs one write transaction at a time, so the numbers
|
|
77
|
+
// follow the order of the commits. A load that a later load of its
|
|
78
|
+
// owner closed has ended, and a commit after its end would move a
|
|
79
|
+
// position that readers were already given.
|
|
80
|
+
commit() {
|
|
81
|
+
const row = this.database
|
|
82
|
+
.prepare(`INSERT INTO ${commits} ("load", "committed_at") SELECT "id", ? FROM ${loads} WHERE "id" = ? AND "closed_at" IS NULL RETURNING "number"`)
|
|
83
|
+
.get(now(this.#retaining.clock), this.#id);
|
|
84
|
+
if (row === undefined)
|
|
85
|
+
throw new Error('connector' in this.#owner
|
|
86
|
+
? `A later load of connection ${this.#owner.connector} closed this load, so it cannot commit; a connection runs one pass at a time`
|
|
87
|
+
: `A later refresh of dataset ${this.#owner.dataset} closed this one, so it cannot commit; a dataset refreshes one at a time`);
|
|
88
|
+
retain(this.database, this.#retaining);
|
|
89
|
+
return Number(row.number);
|
|
90
|
+
}
|
|
91
|
+
[Symbol.dispose]() {
|
|
92
|
+
try {
|
|
93
|
+
if (this.database.isTransaction)
|
|
94
|
+
this.database.exec('ROLLBACK');
|
|
95
|
+
transaction(this.database, () => this.database
|
|
96
|
+
.prepare(`UPDATE ${loads} SET "closed_at" = ? WHERE "id" = ? AND "closed_at" IS NULL`)
|
|
97
|
+
.run(now(this.#retaining.clock), this.#id), { mode: 'immediate' });
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
this.database.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Applies the retention inside a commit's transaction, as Delta Lake expires
|
|
105
|
+
// its log at a checkpoint: the newest position whose commit is older than the
|
|
106
|
+
// window becomes min_valid, and the history keeps no row that only readings
|
|
107
|
+
// before it read. min_valid is a position, never past the newest one, so the
|
|
108
|
+
// newest position stays readable while a pass stays open longer than the
|
|
109
|
+
// window. The numbering then keeps no commit before min_valid and no closed
|
|
110
|
+
// load left with none, so it holds only what a reading still reads: min_valid
|
|
111
|
+
// is a position, so each load that has a commit before it ends at or before
|
|
112
|
+
// it, and keeps min_valid's own commit, whose committed_at a reading there
|
|
113
|
+
// reads. A committed_in of a row only compares with numbers, and needs no
|
|
114
|
+
// commit row. An open load keeps its row with or without commits.
|
|
115
|
+
function retain(database, { clock, retention }) {
|
|
116
|
+
const cutoff = new Date(clock.Date.now() - retention.total('milliseconds')).toISOString();
|
|
117
|
+
const minValid = Number(database.prepare(`SELECT "min_valid" FROM ${epochs}`).get()?.min_valid);
|
|
118
|
+
const older = Number(database
|
|
119
|
+
.prepare(`SELECT coalesce(max("number"), 0) AS "number" FROM ${commits} WHERE "committed_at" < ?`)
|
|
120
|
+
.get(cutoff)?.number);
|
|
121
|
+
if (older <= minValid)
|
|
122
|
+
return;
|
|
123
|
+
const expired = readNumbering(database)?.ends.findLast((end) => end <= older);
|
|
124
|
+
if (expired === undefined || expired <= minValid)
|
|
125
|
+
return;
|
|
126
|
+
expireHistory(database, expired);
|
|
127
|
+
database.prepare(`UPDATE ${epochs} SET "min_valid" = ?`).run(expired);
|
|
128
|
+
database.prepare(`DELETE FROM ${commits} WHERE "number" < ?`).run(expired);
|
|
129
|
+
database
|
|
130
|
+
.prepare(`DELETE FROM ${loads} WHERE "closed_at" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${commits} AS c WHERE c."load" = ${loads}."id")`)
|
|
131
|
+
.run();
|
|
132
|
+
}
|
|
133
|
+
// Records a load of owner that opens at openedAt, with the numbering and
|
|
134
|
+
// its epoch if the file has none yet, and gives its id.
|
|
135
|
+
function begin(database, owner, openedAt) {
|
|
136
|
+
const [column, name] = owned(owner);
|
|
137
|
+
numbering.apply(database);
|
|
138
|
+
database
|
|
139
|
+
.prepare(`INSERT INTO ${epochs} ("id", "epoch") VALUES (1, ?) ON CONFLICT DO NOTHING`)
|
|
140
|
+
.run(randomUUID());
|
|
141
|
+
return Number(database
|
|
142
|
+
.prepare(`INSERT INTO ${loads} (${column}, "opened_at") VALUES (?, ?) RETURNING "id"`)
|
|
143
|
+
.get(name, openedAt)?.id);
|
|
144
|
+
}
|
|
145
|
+
// The column of _elt_loads that names owner, and its name there.
|
|
146
|
+
function owned(owner) {
|
|
147
|
+
return 'connector' in owner
|
|
148
|
+
? ['"connector"', owner.connector]
|
|
149
|
+
: ['"dataset"', owner.dataset];
|
|
150
|
+
}
|
|
151
|
+
// The positions of the file after `after`, ascending; each one that the
|
|
152
|
+
// retention keeps when after is undefined. database is null while no file is
|
|
153
|
+
// at the path.
|
|
154
|
+
export function positionsAfter(database, after) {
|
|
155
|
+
const since = after === undefined ? undefined : parsePosition(after);
|
|
156
|
+
const numbered = database === null
|
|
157
|
+
? null
|
|
158
|
+
: transaction(database, () => readNumbering(database), {
|
|
159
|
+
mode: 'deferred',
|
|
160
|
+
});
|
|
161
|
+
if (since === undefined)
|
|
162
|
+
return numbered === null
|
|
163
|
+
? []
|
|
164
|
+
: numbered.ends
|
|
165
|
+
.filter((end) => end >= numbered.minValid)
|
|
166
|
+
.map((end) => `${numbered.epoch}:${end}`);
|
|
167
|
+
const expired = expiry(numbered, since);
|
|
168
|
+
if (expired !== undefined)
|
|
169
|
+
return { expired };
|
|
170
|
+
return (numbered?.ends ?? [])
|
|
171
|
+
.filter((end) => end > since.number)
|
|
172
|
+
.map((end) => `${since.epoch}:${end}`);
|
|
173
|
+
}
|
|
174
|
+
// Why a position is not one of the file's commits, or undefined when it is.
|
|
175
|
+
export function expiry(numbered, { epoch, number }) {
|
|
176
|
+
const position = `${epoch}:${number}`;
|
|
177
|
+
if (numbered === null)
|
|
178
|
+
return `Position ${position} is of commits this file does not have`;
|
|
179
|
+
if (epoch !== numbered.epoch)
|
|
180
|
+
return `Position ${position} is of an earlier numbering: the commits of this file were numbered again since`;
|
|
181
|
+
if (number > numbered.newest)
|
|
182
|
+
return `Position ${position} is past the newest commit of this file, ${numbered.newest}`;
|
|
183
|
+
if (number < numbered.minValid)
|
|
184
|
+
return `Position ${position} is older than the retention of this file: its history keeps no row that only a reading before commit ${numbered.minValid} reads`;
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
// The numbering of the file, as the snapshot of the open read transaction
|
|
188
|
+
// reads it, or null when the file has none. schema names the file where
|
|
189
|
+
// database attached it. sqlite-drift compares the tables of main only, so the
|
|
190
|
+
// numbering of an attached file is read as it is, and tables it lacks give
|
|
191
|
+
// null. A position is a commit that ends a closed load where no load
|
|
192
|
+
// straddles it, so a reader there sees no part of a pass. A sweep line finds
|
|
193
|
+
// them: taking the loads in order of their first commits, an end is a
|
|
194
|
+
// position when no load that began at or before it ends after it. An open
|
|
195
|
+
// load has no end yet, so it reaches past every later commit.
|
|
196
|
+
export function readNumbering(database, schema = 'main') {
|
|
197
|
+
if (schema === 'main' && numbering.drift(database) !== 'fits')
|
|
198
|
+
return null;
|
|
199
|
+
const file = `"${schema.replaceAll('"', '""')}"`;
|
|
200
|
+
let row;
|
|
201
|
+
try {
|
|
202
|
+
row = database
|
|
203
|
+
.prepare(`SELECT "epoch", "min_valid" FROM ${file}.${epochs}`)
|
|
204
|
+
.get();
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (Error.isError(error) && /^no such (table|column): /.test(error.message))
|
|
208
|
+
return null;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
const epoch = row?.epoch;
|
|
212
|
+
if (typeof epoch !== 'string')
|
|
213
|
+
return null;
|
|
214
|
+
const minValid = Number(row?.min_valid);
|
|
215
|
+
const newest = Number(database
|
|
216
|
+
.prepare(`SELECT coalesce(max("number"), 0) AS "newest" FROM ${file}.${commits}`)
|
|
217
|
+
.get()?.newest);
|
|
218
|
+
const spans = database
|
|
219
|
+
.prepare(`SELECT min(c."number") AS "first", CASE WHEN l."closed_at" IS NULL THEN NULL ELSE max(c."number") END AS "last" FROM ${file}.${commits} AS c JOIN ${file}.${loads} AS l ON l."id" = c."load" GROUP BY l."id" ORDER BY "first"`)
|
|
220
|
+
.all()
|
|
221
|
+
.map((span) => ({
|
|
222
|
+
first: Number(span.first),
|
|
223
|
+
last: span.last === null ? Infinity : Number(span.last),
|
|
224
|
+
}));
|
|
225
|
+
const ends = [];
|
|
226
|
+
const pending = spans.values();
|
|
227
|
+
let span = pending.next();
|
|
228
|
+
let reach = 0;
|
|
229
|
+
for (const end of spans
|
|
230
|
+
.map(({ last }) => last)
|
|
231
|
+
.filter(Number.isFinite)
|
|
232
|
+
.sort((left, right) => left - right)) {
|
|
233
|
+
for (; !span.done && span.value.first <= end; span = pending.next())
|
|
234
|
+
reach = Math.max(reach, span.value.last);
|
|
235
|
+
if (reach === end)
|
|
236
|
+
ends.push(end);
|
|
237
|
+
}
|
|
238
|
+
return { epoch, newest, minValid, ends };
|
|
239
|
+
}
|
|
240
|
+
// When a commit of the file was made, or undefined when it has no commit of
|
|
241
|
+
// that number.
|
|
242
|
+
export function committedAt(database, number) {
|
|
243
|
+
const at = database
|
|
244
|
+
.prepare(`SELECT "committed_at" FROM ${commits} WHERE "number" = ?`)
|
|
245
|
+
.get(number)?.committed_at;
|
|
246
|
+
return typeof at === 'string' ? at : undefined;
|
|
247
|
+
}
|
|
248
|
+
export function parsePosition(position) {
|
|
249
|
+
const groups = /^(?<epoch>[^:]+):(?<number>0|[1-9]\d*)$/.exec(position)?.groups;
|
|
250
|
+
if (groups?.epoch === undefined || groups.number === undefined)
|
|
251
|
+
throw new TypeError(`${position} is not a position of a SQLite file`);
|
|
252
|
+
return { epoch: groups.epoch, number: Number(groups.number) };
|
|
253
|
+
}
|
|
254
|
+
function now(clock) {
|
|
255
|
+
return new Date(clock.Date.now()).toISOString();
|
|
256
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import type { CopyConfiguration } from '@eltjs/elt';
|
|
3
|
+
import type { SQLiteDestination } from './sqlite-destination.ts';
|
|
3
4
|
import type { SQLiteTable } from './sqlite-table.ts';
|
|
4
|
-
import { SQLiteWriter } from './sqlite-writer.ts';
|
|
5
|
+
import { SQLiteWriter, type Stamp } from './sqlite-writer.ts';
|
|
5
6
|
export declare class SQLiteOverwriteWriter extends SQLiteWriter {
|
|
6
|
-
constructor(configuration: CopyConfiguration,
|
|
7
|
+
constructor(configuration: CopyConfiguration, destination: SQLiteDestination, table: SQLiteTable);
|
|
7
8
|
protected get replaces(): boolean;
|
|
8
|
-
protected merge(database: DatabaseSync, stage: string,
|
|
9
|
+
protected merge(database: DatabaseSync, stage: string, stamp: Stamp, into: string): void;
|
|
9
10
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { SQLiteWriter } from './sqlite-writer.js';
|
|
2
2
|
export class SQLiteOverwriteWriter extends SQLiteWriter {
|
|
3
|
-
constructor(configuration,
|
|
4
|
-
super(configuration,
|
|
3
|
+
constructor(configuration, destination, table) {
|
|
4
|
+
super(configuration, destination, table);
|
|
5
5
|
Object.freeze(this);
|
|
6
6
|
}
|
|
7
7
|
get replaces() {
|
|
8
8
|
return true;
|
|
9
9
|
}
|
|
10
|
-
merge(database, stage,
|
|
11
|
-
this.append(database, stage,
|
|
10
|
+
merge(database, stage, stamp, into) {
|
|
11
|
+
this.append(database, stage, stamp, into);
|
|
12
12
|
}
|
|
13
13
|
}
|
package/dist/sqlite-table.js
CHANGED
|
@@ -78,6 +78,6 @@ export class SQLiteTable extends Target {
|
|
|
78
78
|
definition(name) {
|
|
79
79
|
if (this.columns.length === 0)
|
|
80
80
|
throw new TypeError('Resolve inferred columns before creating a table');
|
|
81
|
-
return `${name} (${this.columns.map((column) => column.definition).join(', ')}, "loaded_at" TEXT NOT NULL${canonical('timestamp', '"loaded_at"')}) STRICT`;
|
|
81
|
+
return `${name} (${this.columns.map((column) => column.definition).join(', ')}, "loaded_at" TEXT NOT NULL${canonical('timestamp', '"loaded_at"')}, "committed_in" INTEGER NOT NULL, "_elt_row_hash" TEXT) STRICT`;
|
|
82
82
|
}
|
|
83
83
|
}
|
package/dist/sqlite-writer.d.ts
CHANGED
|
@@ -1,38 +1,48 @@
|
|
|
1
1
|
import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
|
|
2
|
-
import type { CopyConfiguration, FieldValues, KeyValue, Stage } from '@eltjs/elt';
|
|
2
|
+
import type { Clearing, CopyConfiguration, FieldValues, KeyValue, Stage } from '@eltjs/elt';
|
|
3
3
|
import { Writer } from '@eltjs/elt';
|
|
4
4
|
import type { SQLiteColumn } from './sqlite-column.ts';
|
|
5
|
+
import type { SQLiteDestination } from './sqlite-destination.ts';
|
|
6
|
+
import { SQLiteHistory } from './sqlite-history.ts';
|
|
7
|
+
import { SQLiteLoad } from './sqlite-loads.ts';
|
|
5
8
|
import type { SQLiteTable } from './sqlite-table.ts';
|
|
6
9
|
export declare const seq = "\"_elt_seq\"";
|
|
7
10
|
export declare const op = "\"_elt_op\"";
|
|
11
|
+
export declare const version = "\"_elt_row_hash\"";
|
|
12
|
+
export type Stamp = {
|
|
13
|
+
readonly loadedAt: string;
|
|
14
|
+
readonly committedIn: number;
|
|
15
|
+
};
|
|
8
16
|
export declare abstract class SQLiteWriter extends Writer {
|
|
9
17
|
#private;
|
|
10
18
|
readonly configuration: CopyConfiguration;
|
|
11
|
-
readonly
|
|
19
|
+
readonly destination: SQLiteDestination;
|
|
12
20
|
readonly table: SQLiteTable;
|
|
13
|
-
|
|
21
|
+
readonly history: SQLiteHistory;
|
|
22
|
+
constructor(configuration: CopyConfiguration, destination: SQLiteDestination, table: SQLiteTable);
|
|
14
23
|
protected initialize(database: DatabaseSync, _replacing: boolean): void;
|
|
15
24
|
protected dropDedupIndexes(database: DatabaseSync, kept: string | null): void;
|
|
16
25
|
protected inspect(_database: DatabaseSync): void;
|
|
17
|
-
protected abstract merge(database: DatabaseSync, stage: string,
|
|
26
|
+
protected abstract merge(database: DatabaseSync, stage: string, stamp: Stamp, into: string, history: SQLiteHistory | null): void;
|
|
18
27
|
protected build(_database: DatabaseSync, _into: string): void;
|
|
19
28
|
protected adopt(_database: DatabaseSync): void;
|
|
20
|
-
protected append(database: DatabaseSync, stage: string, loadedAt:
|
|
29
|
+
protected append(database: DatabaseSync, stage: string, { loadedAt, committedIn }: Stamp, into: string): void;
|
|
21
30
|
protected get replaces(): boolean;
|
|
22
|
-
protected
|
|
31
|
+
protected kept(_stage: string): string;
|
|
23
32
|
private get hash();
|
|
24
33
|
protected get fields(): string[];
|
|
34
|
+
protected get written(): string[];
|
|
25
35
|
protected encode(record: unknown): SQLInputValue[];
|
|
26
36
|
protected deletionKeys(_key: Readonly<Record<string, KeyValue>>): readonly [readonly SQLiteColumn[], SQLInputValue[]];
|
|
27
37
|
private writers;
|
|
28
38
|
private own;
|
|
29
39
|
private exists;
|
|
30
40
|
private values;
|
|
31
|
-
clear(writer:
|
|
41
|
+
clear({ writer, clock, connection }: Clearing, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
|
|
32
42
|
private describe;
|
|
33
43
|
private installReaderView;
|
|
34
|
-
prepare(
|
|
44
|
+
prepare(load: SQLiteLoad, { writer, reloading }: {
|
|
35
45
|
writer: string;
|
|
36
46
|
reloading: boolean;
|
|
37
|
-
}
|
|
47
|
+
}): Stage;
|
|
38
48
|
}
|