@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.
@@ -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, path: string, table: SQLiteTable);
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 merge(database: DatabaseSync, stage: string, loadedAt: string, into: string): void;
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, path, table) {
9
- super(configuration, path, table);
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 load keeps none of the stored rows: its index is built once
59
- // the commit has emptied the table.
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 result of applying the staged operations one at a time: a staged
91
- // DELETE removes its key, and only records after a key's last DELETE count.
92
- // replace keeps the newest extraction, so a restated fact overwrites the
93
- // loaded one; cursor_newer keeps the greatest cursor (the first on ties) and
94
- // the guard that rejects out-of-order replay.
95
- merge(database, stage, loadedAt, into) {
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 same = (left, right) => keys.map((key) => `${left}.${key} = ${right}.${key}`).join(' AND ');
98
- database.exec(`DELETE FROM ${into} WHERE (${keys.join(', ')}) IN (SELECT ${keys.join(', ')} FROM ${stage} WHERE ${op} = 'D')`);
99
- const { cursor } = this;
100
- const guarded = this.configuration.dedupPolicy !== 'replace' && cursor !== undefined;
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(`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 ${same('"deleted"', '"staged"')} WHERE "staged".${op} = 'R' AND ("deleted"."_elt_last" IS NULL OR "staged".${seq} > "deleted"."_elt_last")) ` +
108
- `INSERT INTO ${into} AS "_elt_target" (${this.fields.join(', ')}) SELECT ${columns.join(', ')}, ? FROM ${stage} WHERE ${seq} IN (SELECT ${seq} FROM "ranked" WHERE "_elt_rank" = 1) ORDER BY ${seq} ` +
109
- `ON CONFLICT (${keys.map((key) => `${key} COLLATE BINARY`).join(', ')}) DO UPDATE SET ${this.fields.map((field) => `${field} = excluded.${field}`).join(', ')}${guarded ? ` WHERE excluded.${cursor.quotedName} COLLATE BINARY > "_elt_target".${cursor.quotedName}` : ''}`)
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
- constructor({ path }: {
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
- constructor({ path }) {
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 database = openDestination(this.path);
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(database, binding, loadedAt),
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.path, table);
77
+ return new SQLiteDeduplicatingWriter(configuration, this, table);
59
78
  case 'append':
60
- return new SQLiteAppendWriter(configuration, this.path, table);
79
+ return new SQLiteAppendWriter(configuration, this, table);
61
80
  case 'overwrite':
62
- return new SQLiteOverwriteWriter(configuration, this.path, table);
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<number>;
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; triggers drop a row's chunks in the same transaction as the row.
9
- // A load stages chunks in a connection-private TEMP table and publishes them
10
- // inside a commit, so a load holds no transaction while it reads files.
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
- // An empty file still stores one empty chunk, so its id stays reserved. Ids
45
- // continue after both the published and the staged chunks.
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 highest = (table) => Number(this.#database
48
- .prepare(`SELECT coalesce(max("file"), 0) AS "file" FROM ${table}`)
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 file = Math.max(this.published ? highest(quote(this.name)) : 0, highest(`temp.${this.#staged}`)) + 1;
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
- insert.run(file, n++, chunk);
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(file, 0, new Uint8Array());
57
- return file;
74
+ insert.run(id, 0, new Uint8Array());
75
+ return { id, sha256: sha256.digest() };
58
76
  }
59
- // Moves the staged chunks into the store, inside a commit, and attaches the
60
- // triggers to the target once it exists. A reload swaps in a table without
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
- ownColumns = ['loaded_at'];
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;