@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.
@@ -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;
@@ -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, path: string, table: SQLiteTable);
7
+ constructor(configuration: CopyConfiguration, destination: SQLiteDestination, table: SQLiteTable);
7
8
  protected get replaces(): boolean;
8
- protected merge(database: DatabaseSync, stage: string, loadedAt: string, into: string): void;
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, path, table) {
4
- super(configuration, path, table);
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, loadedAt, into) {
11
- this.append(database, stage, loadedAt, into);
10
+ merge(database, stage, stamp, into) {
11
+ this.append(database, stage, stamp, into);
12
12
  }
13
13
  }
@@ -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
  }
@@ -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 path: string;
19
+ readonly destination: SQLiteDestination;
12
20
  readonly table: SQLiteTable;
13
- constructor(configuration: CopyConfiguration, path: string, table: SQLiteTable);
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, loadedAt: string, into: string): void;
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: string, into: string): void;
29
+ protected append(database: DatabaseSync, stage: string, { loadedAt, committedIn }: Stamp, into: string): void;
21
30
  protected get replaces(): boolean;
22
- protected replace(database: DatabaseSync): void;
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: string, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
41
+ clear({ writer, clock, connection }: Clearing, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
32
42
  private describe;
33
43
  private installReaderView;
34
- prepare(database: DatabaseSync, { writer, reloading }: {
44
+ prepare(load: SQLiteLoad, { writer, reloading }: {
35
45
  writer: string;
36
46
  reloading: boolean;
37
- }, loadedAt: string): Stage;
47
+ }): Stage;
38
48
  }