@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.
@@ -0,0 +1,247 @@
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.
110
+ function retain(database, { clock, retention }) {
111
+ const cutoff = new Date(clock.Date.now() - retention.total('milliseconds')).toISOString();
112
+ const minValid = Number(database.prepare(`SELECT "min_valid" FROM ${epochs}`).get()?.min_valid);
113
+ const older = Number(database
114
+ .prepare(`SELECT coalesce(max("number"), 0) AS "number" FROM ${commits} WHERE "committed_at" < ?`)
115
+ .get(cutoff)?.number);
116
+ if (older <= minValid)
117
+ return;
118
+ const expired = readNumbering(database)?.ends.findLast((end) => end <= older);
119
+ if (expired === undefined || expired <= minValid)
120
+ return;
121
+ expireHistory(database, expired);
122
+ database.prepare(`UPDATE ${epochs} SET "min_valid" = ?`).run(expired);
123
+ }
124
+ // Records a load of owner that opens at openedAt, with the numbering and
125
+ // its epoch if the file has none yet, and gives its id.
126
+ function begin(database, owner, openedAt) {
127
+ const [column, name] = owned(owner);
128
+ numbering.apply(database);
129
+ database
130
+ .prepare(`INSERT INTO ${epochs} ("id", "epoch") VALUES (1, ?) ON CONFLICT DO NOTHING`)
131
+ .run(randomUUID());
132
+ return Number(database
133
+ .prepare(`INSERT INTO ${loads} (${column}, "opened_at") VALUES (?, ?) RETURNING "id"`)
134
+ .get(name, openedAt)?.id);
135
+ }
136
+ // The column of _elt_loads that names owner, and its name there.
137
+ function owned(owner) {
138
+ return 'connector' in owner
139
+ ? ['"connector"', owner.connector]
140
+ : ['"dataset"', owner.dataset];
141
+ }
142
+ // The positions of the file after `after`, ascending; each one that the
143
+ // retention keeps when after is undefined. database is null while no file is
144
+ // at the path.
145
+ export function positionsAfter(database, after) {
146
+ const since = after === undefined ? undefined : parsePosition(after);
147
+ const numbered = database === null
148
+ ? null
149
+ : transaction(database, () => readNumbering(database), {
150
+ mode: 'deferred',
151
+ });
152
+ if (since === undefined)
153
+ return numbered === null
154
+ ? []
155
+ : numbered.ends
156
+ .filter((end) => end >= numbered.minValid)
157
+ .map((end) => `${numbered.epoch}:${end}`);
158
+ const expired = expiry(numbered, since);
159
+ if (expired !== undefined)
160
+ return { expired };
161
+ return (numbered?.ends ?? [])
162
+ .filter((end) => end > since.number)
163
+ .map((end) => `${since.epoch}:${end}`);
164
+ }
165
+ // Why a position is not one of the file's commits, or undefined when it is.
166
+ export function expiry(numbered, { epoch, number }) {
167
+ const position = `${epoch}:${number}`;
168
+ if (numbered === null)
169
+ return `Position ${position} is of commits this file does not have`;
170
+ if (epoch !== numbered.epoch)
171
+ return `Position ${position} is of an earlier numbering: the commits of this file were numbered again since`;
172
+ if (number > numbered.newest)
173
+ return `Position ${position} is past the newest commit of this file, ${numbered.newest}`;
174
+ if (number < numbered.minValid)
175
+ 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`;
176
+ return undefined;
177
+ }
178
+ // The numbering of the file, as the snapshot of the open read transaction
179
+ // reads it, or null when the file has none. schema names the file where
180
+ // database attached it. sqlite-drift compares the tables of main only, so the
181
+ // numbering of an attached file is read as it is, and tables it lacks give
182
+ // null. A position is a commit that ends a closed load where no load
183
+ // straddles it, so a reader there sees no part of a pass. A sweep line finds
184
+ // them: taking the loads in order of their first commits, an end is a
185
+ // position when no load that began at or before it ends after it. An open
186
+ // load has no end yet, so it reaches past every later commit.
187
+ export function readNumbering(database, schema = 'main') {
188
+ if (schema === 'main' && numbering.drift(database) !== 'fits')
189
+ return null;
190
+ const file = `"${schema.replaceAll('"', '""')}"`;
191
+ let row;
192
+ try {
193
+ row = database
194
+ .prepare(`SELECT "epoch", "min_valid" FROM ${file}.${epochs}`)
195
+ .get();
196
+ }
197
+ catch (error) {
198
+ if (Error.isError(error) && /^no such (table|column): /.test(error.message))
199
+ return null;
200
+ throw error;
201
+ }
202
+ const epoch = row?.epoch;
203
+ if (typeof epoch !== 'string')
204
+ return null;
205
+ const minValid = Number(row?.min_valid);
206
+ const newest = Number(database
207
+ .prepare(`SELECT coalesce(max("number"), 0) AS "newest" FROM ${file}.${commits}`)
208
+ .get()?.newest);
209
+ const spans = database
210
+ .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"`)
211
+ .all()
212
+ .map((span) => ({
213
+ first: Number(span.first),
214
+ last: span.last === null ? Infinity : Number(span.last),
215
+ }));
216
+ const ends = [];
217
+ const pending = spans.values();
218
+ let span = pending.next();
219
+ let reach = 0;
220
+ for (const end of spans
221
+ .map(({ last }) => last)
222
+ .filter(Number.isFinite)
223
+ .sort((left, right) => left - right)) {
224
+ for (; !span.done && span.value.first <= end; span = pending.next())
225
+ reach = Math.max(reach, span.value.last);
226
+ if (reach === end)
227
+ ends.push(end);
228
+ }
229
+ return { epoch, newest, minValid, ends };
230
+ }
231
+ // When a commit of the file was made, or undefined when it has no commit of
232
+ // that number.
233
+ export function committedAt(database, number) {
234
+ const at = database
235
+ .prepare(`SELECT "committed_at" FROM ${commits} WHERE "number" = ?`)
236
+ .get(number)?.committed_at;
237
+ return typeof at === 'string' ? at : undefined;
238
+ }
239
+ export function parsePosition(position) {
240
+ const groups = /^(?<epoch>[^:]+):(?<number>0|[1-9]\d*)$/.exec(position)?.groups;
241
+ if (groups?.epoch === undefined || groups.number === undefined)
242
+ throw new TypeError(`${position} is not a position of a SQLite file`);
243
+ return { epoch: groups.epoch, number: Number(groups.number) };
244
+ }
245
+ function now(clock) {
246
+ return new Date(clock.Date.now()).toISOString();
247
+ }
@@ -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
  }