@eltjs/elt-sqlite 0.2.0 → 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 +73 -8
- package/dist/computed-dataset.d.ts +3 -2
- package/dist/computed-dataset.js +76 -13
- 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
|
@@ -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;
|
|
@@ -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;
|