@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,230 @@
1
+ import { constants } from 'node:sqlite';
2
+ import { UnavailableError, open } from '@zukhruf/sqlite-open';
3
+ import { busyTimeout } from './open-destination.js';
4
+ import { histories } from './sqlite-history.js';
5
+ import { identifiers } from './sqlite-identifiers.js';
6
+ import { committedAt, expiry, parsePosition, readNumbering, } from './sqlite-loads.js';
7
+ import { attempts, checks, coverage } from './sqlite-sync-history-schema.js';
8
+ import { version as rowHash } from './sqlite-writer.js';
9
+ export const quote = (name) => `"${name.replaceAll('"', '""')}"`;
10
+ /**
11
+ * The SQL functions that views of a position call to fail, by name: a host
12
+ * registers each one on the connection that reads the views, as
13
+ * `database.function(name, implementation)`. `_elt_unversioned` refuses a
14
+ * read of what keeps no row versions; `_elt_readable` refuses a read of a
15
+ * position that the attached file no longer reads exactly.
16
+ */
17
+ export const asOfFunctions = {
18
+ _elt_unversioned(reason) {
19
+ throw new Error(String(reason));
20
+ },
21
+ // The guard knows the epoch and min_valid of the file only, so it gives
22
+ // the position as the newest commit: a file made again has another epoch.
23
+ _elt_readable(schema, position, epoch, minValid) {
24
+ const asked = parsePosition(String(position));
25
+ const expired = expiry({
26
+ epoch: String(epoch),
27
+ minValid: Number(minValid),
28
+ newest: asked.number,
29
+ ends: [],
30
+ }, asked);
31
+ if (expired === undefined)
32
+ return 1;
33
+ throw new Error(`${expired} (the file attached as ${String(schema)})`);
34
+ },
35
+ };
36
+ // A reader of the file, or null while no file is at the path.
37
+ export function reader(path) {
38
+ try {
39
+ return open(path, { readOnly: true, timeout: busyTimeout });
40
+ }
41
+ catch (error) {
42
+ if (error instanceof UnavailableError)
43
+ return null;
44
+ throw error;
45
+ }
46
+ }
47
+ // Reads the file as of the position, or as of its newest position. One read
48
+ // transaction holds the snapshot that the numbering and every later read
49
+ // see, so a commit after the reading begins shows in none of them.
50
+ export function readAsOf(path, position) {
51
+ const asked = position === undefined ? undefined : parsePosition(position);
52
+ const database = reader(path);
53
+ if (database === null)
54
+ return { expired: `No load has committed to ${path}` };
55
+ using stack = new DisposableStack();
56
+ stack.use(database);
57
+ database.exec('BEGIN');
58
+ const numbered = readNumbering(database);
59
+ if (asked !== undefined) {
60
+ const expired = expiry(numbered, asked);
61
+ if (expired !== undefined)
62
+ return { expired };
63
+ refuseInsidePass(numbered, asked, path);
64
+ }
65
+ const number = asked?.number ?? numbered?.ends.at(-1);
66
+ const at = number === undefined ? undefined : committedAt(database, number);
67
+ if (numbered === null || number === undefined || at === undefined)
68
+ return {
69
+ expired: position === undefined
70
+ ? `${path} has no position yet`
71
+ : `Position ${position} names no commit of ${path}`,
72
+ };
73
+ if (number < numbered.newest)
74
+ shadow(database, number);
75
+ stack.move();
76
+ return {
77
+ sql: database,
78
+ position: `${numbered.epoch}:${number}`,
79
+ at: Temporal.Instant.from(at),
80
+ [Symbol.dispose]: () => database.close(),
81
+ };
82
+ }
83
+ // No later load can straddle a position, so one that a reader was given
84
+ // stays one; a commit that is not a position stays inside its pass.
85
+ function refuseInsidePass(numbered, { epoch, number }, file) {
86
+ if (!numbered?.ends.includes(number))
87
+ throw new TypeError(`${epoch}:${number} is not a position of ${file}: a reading of a commit inside a pass would show a part of that pass. Read a position that positions() lists.`);
88
+ }
89
+ // Shadows, for this connection, each name a reader queries with a TEMP view
90
+ // of what it showed as of commit number. A TEMP view takes the place of a
91
+ // table or view of main for an unqualified name, and SQLite resolves the
92
+ // names of a view when it is read, so the views can be made in any order.
93
+ function shadow(database, number) {
94
+ const targets = versioned(database, 'main');
95
+ for (const version of targets)
96
+ database.exec(`CREATE TEMP VIEW ${quote(version.target)} AS ${selectAsOf(database, version, number, 'main')}`);
97
+ database.function('_elt_unversioned', asOfFunctions._elt_unversioned);
98
+ for (const view of syncHistoryViews(database, 'main', (table) => table))
99
+ database.exec(view);
100
+ const views = storedViews(database, 'main');
101
+ for (const { sql } of views)
102
+ database.exec(sql.replace(/^CREATE VIEW /, 'CREATE TEMP VIEW '));
103
+ refuseQualified(database, views.map(({ name }) => name), new Set(targets.map(({ target }) => identifiers.key(target))));
104
+ }
105
+ /**
106
+ * The SELECT that reads one target of the file as of a position, for a reader
107
+ * that copies the target, such as into a database of its own: the current
108
+ * rows that the commit of the position had made, and the history rows that it
109
+ * had made and a later commit replaced. `schema` names the file where
110
+ * `database` attached it, main by default. Run the SELECT in the read
111
+ * transaction that this checks the position in, since a later commit can
112
+ * expire the position. A position of another epoch, past the newest commit,
113
+ * or older than the retention, is expired.
114
+ */
115
+ export function asOfSelect(database, target, position, { schema = 'main' } = {}) {
116
+ const asked = readable(database, position, schema);
117
+ if ('expired' in asked)
118
+ return asked;
119
+ const version = versioned(database, schema).find((version) => identifiers.key(version.target) === identifiers.key(target));
120
+ if (version === undefined)
121
+ throw new TypeError(`${quote(target)} is not a target of ${schema}`);
122
+ return selectAsOf(database, version, asked.number, schema);
123
+ }
124
+ // The position, when the file attached as schema reads it now.
125
+ export function readable(database, position, schema) {
126
+ const asked = parsePosition(position);
127
+ const numbered = readNumbering(database, schema);
128
+ const expired = expiry(numbered, asked);
129
+ if (expired !== undefined)
130
+ return { expired };
131
+ refuseInsidePass(numbered, asked, schema === 'main' ? 'the file' : `the file attached as ${schema}`);
132
+ return asked;
133
+ }
134
+ // A target as of commit number: its current rows that the commit had made,
135
+ // and the history rows that the commit had made and a later commit replaced.
136
+ // A column that the history lacks reads as NULL there. A guarded select is
137
+ // for a view that outlives the read transaction: each read first checks, in
138
+ // the same statement, that the file still reads the position exactly, and
139
+ // it leaves out the row hash of the writers.
140
+ export function selectAsOf(database, { target, history }, number, schema, guard) {
141
+ const columns = columnsOf(database, quote(target), schema).filter((column) => guard === undefined || quote(column) !== rowHash);
142
+ const kept = new Set(columnsOf(database, quote(history), schema).map((column) => identifiers.key(column)));
143
+ const current = columns.map((column) => `"r".${quote(column)}`).join(', ');
144
+ const replaced = columns
145
+ .map((column) => kept.has(identifiers.key(column))
146
+ ? `"r".${quote(column)}`
147
+ : `NULL AS ${quote(column)}`)
148
+ .join(', ');
149
+ const file = quote(schema);
150
+ // The guard is the outer loop of a CROSS JOIN, which SQLite keeps in
151
+ // place, so it runs once and also when the target holds no row.
152
+ const from = guard === undefined
153
+ ? (table) => `FROM ${file}.${quote(table)} AS "r" WHERE`
154
+ : (table) => `FROM ${file}."_elt_epoch" AS "e" CROSS JOIN ${file}.${quote(table)} AS "r" WHERE "_elt_readable"(${literal(schema)}, ${literal(`${guard.epoch}:${guard.number}`)}, "e"."epoch", "e"."min_valid") AND`;
155
+ return `SELECT ${current} ${from(target)} "r"."committed_in" <= ${number} UNION ALL SELECT ${replaced} ${from(history)} "r"."committed_in" <= ${number} AND "r"."replaced_in" > ${number}`;
156
+ }
157
+ export const literal = (text) => `'${text.replaceAll("'", "''")}'`;
158
+ // The tables of the sync history, unquoted.
159
+ export const syncHistoryTables = [attempts, coverage, checks].map((table) => table.slice(1, -1));
160
+ // The sync history keeps no versions, so each of its tables of schema reads
161
+ // as a TEMP view, named by name, that fails, and so does each view over them.
162
+ export function syncHistoryViews(database, schema, name) {
163
+ return syncHistoryTables.flatMap((table) => {
164
+ const columns = columnsOf(database, quote(table), schema);
165
+ return columns.length === 0
166
+ ? []
167
+ : [
168
+ `CREATE TEMP VIEW ${quote(name(table))} AS SELECT ${columns.map((column) => `NULL AS ${quote(column)}`).join(', ')} WHERE "_elt_unversioned"('The sync history cannot be read as of a position: it keeps no row versions')`,
169
+ ];
170
+ });
171
+ }
172
+ export function storedViews(database, schema) {
173
+ return database
174
+ .prepare(String.raw `SELECT "name", "sql" FROM ${quote(schema)}.sqlite_schema WHERE "type" = 'view' AND "name" NOT LIKE 'sqlite\_%' ESCAPE '\'`)
175
+ .all()
176
+ .map(({ name, sql }) => ({ name: String(name), sql: String(sql) }));
177
+ }
178
+ // Refuses a stored view that reads a versioned target as main."target": the
179
+ // name skips the TEMP view of the target, so the view would show the target
180
+ // as it is now. SQLite tells which view reads each table while it prepares a
181
+ // statement, so each view is prepared once with an authorizer that watches.
182
+ function refuseQualified(database, views, targets) {
183
+ let found;
184
+ database.setAuthorizer((action, table, _column, schema, view) => {
185
+ if (action === constants.SQLITE_READ &&
186
+ schema === 'main' &&
187
+ table !== null &&
188
+ targets.has(identifiers.key(table)) &&
189
+ identifiers.key(view ?? '') !== identifiers.key(table))
190
+ found ??= { view: view ?? '', target: table };
191
+ return constants.SQLITE_OK;
192
+ });
193
+ try {
194
+ for (const view of views) {
195
+ try {
196
+ database.prepare(`SELECT * FROM temp.${quote(view)}`);
197
+ }
198
+ catch {
199
+ // A view that fails to prepare fails when it is read, too.
200
+ }
201
+ if (found !== undefined)
202
+ throw new TypeError(`View ${quote(found.view)} reads ${quote(found.target)} as main.${quote(found.target)}, which shows it as it is now, not as of a position; name the table without main.`);
203
+ }
204
+ }
205
+ finally {
206
+ database.setAuthorizer(null);
207
+ }
208
+ }
209
+ // Each target with its history, as the file attached as schema records them.
210
+ export function versioned(database, schema) {
211
+ const file = quote(schema);
212
+ if (database
213
+ .prepare(`SELECT 1 FROM ${file}.sqlite_schema WHERE "type" = 'table' AND "name" = ?`)
214
+ .get(histories) === undefined)
215
+ return [];
216
+ return database
217
+ .prepare(`SELECT t."name" AS "target", h."history" FROM ${file}.${quote(histories)} AS h JOIN ${file}.sqlite_schema AS t ON t."type" = 'table' AND lower(t."name") = h."target" JOIN ${file}.sqlite_schema AS k ON k."type" = 'table' AND k."name" = h."history"`)
218
+ .all()
219
+ .map(({ target, history }) => ({
220
+ target: String(target),
221
+ history: String(history),
222
+ }));
223
+ }
224
+ // The columns of a table of schema, named as SQL quotes it.
225
+ export function columnsOf(database, table, schema = 'main') {
226
+ return database
227
+ .prepare(`PRAGMA ${quote(schema)}.table_info(${table})`)
228
+ .all()
229
+ .map(({ name }) => String(name));
230
+ }
@@ -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;