@eltjs/elt-sqlite 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,7 @@ Tables:
35
35
  - SQLite makes strict tables. SQL constraints that exist stay in force.
36
36
  - Each copy adds `loaded_at`, the UTC start time of the load that wrote the values of the row. A field with that name gets a different column name.
37
37
  - Each copy also adds `committed_in`, the number of the [commit](#commit-numbers-and-positions) that last wrote the row, and `_elt_row_hash`, the SHA-256 of the values of its columns. A [file in the SQLite file](#file-bytes-in-the-database) counts by the SHA-256 of its bytes, not by its id. A field with one of these names also gets a different column name. So does a field with the name `_rowid_`: SQLite reads that name as the id of each row, which an overwrite and a reload use.
38
+ - The external schema of a stream, which a reader sees, is its columns and `loaded_at`. A reader view, each view of a [reading](#commit-numbers-and-positions), `asOfSelect` and `asOfDefinitions` show exactly these columns. `committed_in`, `_elt_row_hash`, `replaced_in` and `_rowid_` are internal: only the SQL of the destination reads them, for the history, the retention, the readings and the difference. Thus a column that the destination adds for itself changes nothing that a reader sees, as SQL Server hides the period columns of a temporal table that it marks `HIDDEN`. SQLite cannot hide a column of a table, so a raw table itself shows `committed_in` and `_elt_row_hash`. Readers query the reader view.
38
39
  - When a deduplicating load, an overwrite or a reload brings a row with the same values as a stored row, it leaves the stored row as it is, with its `loaded_at` and its `committed_in`. A deduplicating load finds the stored row by its key. An overwrite and a reload find it by [difference](#commit-numbers-and-positions), also in a table with no key.
39
40
  - Deduplication upserts on a unique index that is named after the table and the key (`_elt_dedup_<hash>`). SQLite makes the index one time, and makes it again only when the key changes. It uses the native [UPSERT with a cursor comparison](https://www.sqlite.org/lang_upsert.html).
40
41
  - Deduplication also checks the stored types of the key and cursor columns, and refuses null keys and cursors.
@@ -104,7 +105,7 @@ Each commit in the SQLite file has a number. A reader that compares the file at
104
105
  - A connection runs one pass at a time, and a computed dataset runs one refresh at a time. Thus when a load opens, it closes each load of the same connection or dataset that is still open: the process of that pass or refresh ended first. A closed load cannot commit. A load with no commit changes no position.
105
106
  - A commit into a [hidden table](./CONTEXT.md) shows nothing to readers. When the stream ends, one more commit gives its rows to the table, and readers see them from that commit on.
106
107
  - An overwrite and a reload replace the rows of the table by [difference](./CONTEXT.md). The difference compares the rows as multisets, so it needs no key. On each side, each row gets a number k among the rows with the same `_elt_row_hash`, and the k-th copy of some values matches the k-th copy of the same values on the other side:
107
- - A row with a match stays as it is, with its `committed_in` and its `loaded_at`.
108
+ - A row with a match stays as it is, with its commit and its `loaded_at`.
108
109
  - A row of the table with no match goes into the history and leaves the table.
109
110
  - A new row with no match enters the table with the number of the commit.
110
111
  - An overwrite applies the difference in its one commit, to the records that it staged. A reload applies it, to the rows of its hidden table, in the commit at the end of the stream. The table keeps its indexes, its triggers and its reader view. When the table has a different shape than the hidden table, the reload puts the hidden table in its place, and every row of the table goes into the history.
@@ -118,10 +119,11 @@ Each commit in the SQLite file has a number. A reader that compares the file at
118
119
  - The reset of a partition copies each row of the partition into the history. The rows that the source sends again after the reset can come in later commits, for example after a checkpoint of each page. Thus no commit can know which rows of the partition left.
119
120
  - `destination.reading(position?)` gives a [reading](./CONTEXT.md) of the file as of the position, or as of the newest position: `{ sql, position, at }`, where `sql` is a read-only `DatabaseSync` in one read transaction and `at` is the time of the commit. Dispose the reading to close it. A position of a different epoch, after the newest commit, or older than [min valid](#retention), gives `{ expired }`. A commit number that `positions` does not list, for example a commit inside a pass, makes `reading` throw a `TypeError`, as a string that is not a position does: a reading there would show a part of a pass. A position that `positions` listed stays a position, because no later load can have commits before and after it.
120
121
  - As of a position before the newest commit, each table, its reader view and each view of the file show the rows that the commit of the position left. A column that a later commit added is `NULL`. The sync history keeps no versions, and a read of it fails. A stored view that names a table as `main.<table>` makes the reading fail, because that name shows the table as it is now.
122
+ - At each position, also the newest, each table of a reading shows the external schema of its stream: its columns and `loaded_at`, or the columns of a computed dataset. Thus a reading shows the same columns at each position. A stored view that reads `committed_in` or `_elt_row_hash` of a table fails when it is read as of a position before the newest commit.
121
123
 
122
- - `asOfSelect(database, table, position, { schema })` gives the `SELECT` that reads one table as of a position: the rows of the table with `committed_in ≤ P`, and the rows of its history with `committed_in ≤ P < replaced_in`. A reading uses the same `SELECT` for its `TEMP` views. `schema` names the SQLite file where `database` attached it, `main` by default. Thus a reader can copy a table as of a position into a database of its own. It checks the position as `reading` does, so run the `SELECT` in the same read transaction. See [Read the file as of a position](./docs/recipes/read-the-file-as-of-a-position.md).
124
+ - `asOfSelect(database, table, position, { schema })` gives the `SELECT` that reads one table as of a position: the rows of the table with `committed_in ≤ P`, and the rows of its history with `committed_in ≤ P < replaced_in`, with the columns of the external schema. A reading uses the same `SELECT` for its `TEMP` views. `schema` names the SQLite file where `database` attached it, `main` by default. Thus a reader can copy a table as of a position into a database of its own. It checks the position as `reading` does, so run the `SELECT` in the same read transaction. See [Read the file as of a position](./docs/recipes/read-the-file-as-of-a-position.md).
123
125
  - `asOfDefinitions(database, { schema, position, prefix })` gives the `CREATE TEMP VIEW` statements of the [views of a position](./CONTEXT.md) of the file that `database` attached as `schema`, or `{ expired }` as `asOfSelect` does. Thus one connection reads several files as of a position each, with no copy. Each table, reader view, view and table of the sync history is the view `<prefix>_<name>`, for example `mail_messages`:
124
- - A table reads as `asOfSelect` reads it, without `_elt_row_hash`.
126
+ - A table reads as `asOfSelect` reads it.
125
127
  - A stored view names the views of the position in place of its tables and views. SQLite's own `ALTER TABLE … RENAME` maps the names, in a database of its own with a table of the same columns for each name. A stored view that reads a table with no view of the position, for example `catalog`, fails when it is read.
126
128
  - A table of the sync history fails when it is read, and so does each view over it.
127
129
  - A view reads the file when it is read, so a later commit changes nothing in it. When the retention deletes history that the position reads, or the file is made again, each read of a table fails with the reason: in the same statement, the view compares the epoch and min valid of the file with the position.
@@ -253,9 +255,9 @@ At each refresh, the computed dataset compares the inputs with the output rows.
253
255
  A refresh is a load in `_elt_loads`, so a [reading](#commit-numbers-and-positions) shows the computed dataset as of a position, as it shows a table that a copy loads:
254
256
 
255
257
  - Each transaction of the refresh that changes a row takes one commit number. A transaction that changes no row takes none. No commit inside a refresh that is open is a position.
256
- - The raw table has the columns `committed_in` and `_elt_row_hash` after the columns of the rule. The view `<name>` does not show them. `_elt_row_hash` is the SHA-256 of the values of the other columns, as for a copy.
258
+ - The raw table has the columns `committed_in` and `_elt_row_hash` after the columns of the rule. A computed dataset has no `loaded_at`. Its external schema is the key, the engine columns and the columns of the rule: the view `<name>`, the views of a reading, `asOfSelect` and `asOfDefinitions` show only these. `_elt_row_hash` is the SHA-256 of the values of the other columns, as for a copy.
257
259
  - A rule column cannot have the name of the key, of an engine column, of `committed_in` or of `_elt_row_hash`, in any ASCII case, as SQLite compares column names. The constructor of `ComputedDataset` refuses such a rule with a `TypeError` that names the column and the dataset.
258
- - When the rule makes a row again with the same values, for example a `failed` row that fails again with the same error, the row stays as it is, with its `committed_in`.
260
+ - When the rule makes a row again with the same values, for example a `failed` row that fails again with the same error, the row stays as it is, with its commit.
259
261
  - Before a commit changes or deletes an output row, it copies the row into the [history](./CONTEXT.md) of the raw table. When the declaration changes, the commit that makes the table again copies each row of the old table into the history. A column that the old table did not have is `NULL` as of a position before.
260
262
 
261
263
  ## When something fails
@@ -32,7 +32,9 @@ export declare function readAsOf(path: string, position: string | undefined): SQ
32
32
  * The SELECT that reads one target of the file as of a position, for a reader
33
33
  * that copies the target, such as into a database of its own: the current
34
34
  * rows that the commit of the position had made, and the history rows that it
35
- * had made and a later commit replaced. `schema` names the file where
35
+ * had made and a later commit replaced, with the columns of the stream and
36
+ * `loaded_at`, or of a computed dataset, and none of the internal columns
37
+ * that only the destination reads. `schema` names the file where
36
38
  * `database` attached it, main by default. Run the SELECT in the read
37
39
  * transaction that this checks the position in, since a later commit can
38
40
  * expire the position. A position of another epoch, past the newest commit,
@@ -2,10 +2,9 @@ import { constants } from 'node:sqlite';
2
2
  import { UnavailableError, open } from '@zukhruf/sqlite-open';
3
3
  import { busyTimeout } from './open-destination.js';
4
4
  import { histories } from './sqlite-history.js';
5
- import { identifiers, quote } from './sqlite-identifiers.js';
5
+ import { identifiers, internalColumns, quote } from './sqlite-identifiers.js';
6
6
  import { committedAt, expiry, parsePosition, readNumbering, } from './sqlite-loads.js';
7
7
  import { attempts, checks, coverage } from './sqlite-sync-history-schema.js';
8
- import { version as rowHash } from './sqlite-writer.js';
9
8
  /**
10
9
  * The SQL functions that views of a position call to fail, by name: a host
11
10
  * registers each one on the connection that reads the views, as
@@ -69,8 +68,7 @@ export function readAsOf(path, position) {
69
68
  ? `${path} has no position yet`
70
69
  : `Position ${position} names no commit of ${path}`,
71
70
  };
72
- if (number < numbered.newest)
73
- shadow(database, number);
71
+ shadow(database, number, number < numbered.newest);
74
72
  stack.move();
75
73
  return {
76
74
  sql: database,
@@ -85,14 +83,20 @@ function refuseInsidePass(numbered, { epoch, number }, file) {
85
83
  if (!numbered?.ends.includes(number))
86
84
  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.`);
87
85
  }
88
- // Shadows, for this connection, each name a reader queries with a TEMP view
89
- // of what it showed as of commit number. A TEMP view takes the place of a
90
- // table or view of main for an unqualified name, and SQLite resolves the
86
+ // Shadows, for this connection, each target with a TEMP view of what it
87
+ // showed as of commit number, with the columns a reader sees, so a reading
88
+ // shows the same columns at each position. Before the newest commit, each
89
+ // other name a reader queries is shadowed too. A TEMP view takes the place of
90
+ // a table or view of main for an unqualified name, and SQLite resolves the
91
91
  // names of a view when it is read, so the views can be made in any order.
92
- function shadow(database, number) {
92
+ function shadow(database, number, past) {
93
93
  const targets = versioned(database, 'main');
94
94
  for (const version of targets)
95
95
  database.exec(`CREATE TEMP VIEW ${quote(version.target)} AS ${selectAsOf(database, version, number, 'main')}`);
96
+ // At the newest commit, the views and the sync history read the file as
97
+ // it is.
98
+ if (!past)
99
+ return;
96
100
  database.function('_elt_unversioned', asOfFunctions._elt_unversioned);
97
101
  for (const view of syncHistoryViews(database, 'main', (table) => table))
98
102
  database.exec(view);
@@ -105,7 +109,9 @@ function shadow(database, number) {
105
109
  * The SELECT that reads one target of the file as of a position, for a reader
106
110
  * that copies the target, such as into a database of its own: the current
107
111
  * rows that the commit of the position had made, and the history rows that it
108
- * had made and a later commit replaced. `schema` names the file where
112
+ * had made and a later commit replaced, with the columns of the stream and
113
+ * `loaded_at`, or of a computed dataset, and none of the internal columns
114
+ * that only the destination reads. `schema` names the file where
109
115
  * `database` attached it, main by default. Run the SELECT in the read
110
116
  * transaction that this checks the position in, since a later commit can
111
117
  * expire the position. A position of another epoch, past the newest commit,
@@ -131,13 +137,13 @@ export function readable(database, position, schema) {
131
137
  return asked;
132
138
  }
133
139
  // A target as of commit number: its current rows that the commit had made,
134
- // and the history rows that the commit had made and a later commit replaced.
135
- // A column that the history lacks reads as NULL there. A guarded select is
136
- // for a view that outlives the read transaction: each read first checks, in
137
- // the same statement, that the file still reads the position exactly, and
138
- // it leaves out the row hash of the writers.
140
+ // and the history rows that the commit had made and a later commit replaced,
141
+ // with the columns a reader sees, none of the internal ones. A column
142
+ // that the history lacks reads as NULL there. A guarded select is for a view
143
+ // that outlives the read transaction: each read first checks, in the same
144
+ // statement, that the file still reads the position exactly.
139
145
  export function selectAsOf(database, { target, history }, number, schema, guard) {
140
- const columns = columnsOf(database, quote(target), schema).filter((column) => guard === undefined || quote(column) !== rowHash);
146
+ const columns = columnsOf(database, quote(target), schema).filter((column) => !internalColumns.includes(identifiers.key(column)));
141
147
  const kept = new Set(columnsOf(database, quote(history), schema).map((column) => identifiers.key(column)));
142
148
  const current = columns.map((column) => `"r".${quote(column)}`).join(', ');
143
149
  const replaced = columns
@@ -1,4 +1,5 @@
1
1
  import { Identifiers } from '@eltjs/elt';
2
+ export declare const internalColumns: readonly string[];
2
3
  declare class SQLiteIdentifiers extends Identifiers {
3
4
  protected readonly maxBytes: number;
4
5
  protected readonly ownColumns: string[];
@@ -1,17 +1,20 @@
1
1
  import { Identifiers } from '@eltjs/elt';
2
+ // The columns that only the destination's own SQL reads: the commit that last
3
+ // wrote a row and the hash of its values in each table, the commit that
4
+ // replaced a row in each history, and _rowid_, the id SQLite gives each row,
5
+ // which a difference needs, as long as no column takes the name. No reader
6
+ // view, view of a position or SELECT as of a position shows them.
7
+ export const internalColumns = [
8
+ 'committed_in',
9
+ '_elt_row_hash',
10
+ 'replaced_in',
11
+ '_rowid_',
12
+ ];
2
13
  // SQLite holds a name of any length and compares two without ASCII case: it
3
14
  // folds A-Z only, so `É` and `é` stay two names.
4
15
  class SQLiteIdentifiers extends Identifiers {
5
16
  maxBytes = Infinity;
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
- ];
17
+ ownColumns = ['loaded_at', ...internalColumns];
15
18
  key(name) {
16
19
  return name.replaceAll(/[A-Z]/g, (letter) => letter.toLowerCase());
17
20
  }
@@ -30,7 +30,7 @@ export declare abstract class SQLiteWriter extends Writer {
30
30
  protected get replaces(): boolean;
31
31
  protected kept(_stage: string): string;
32
32
  private get hash();
33
- protected get fields(): string[];
33
+ protected get shown(): string[];
34
34
  protected get written(): string[];
35
35
  protected encode(record: unknown): SQLInputValue[];
36
36
  protected deletionKeys(_key: Readonly<Record<string, KeyValue>>): readonly [readonly SQLiteColumn[], SQLInputValue[]];
@@ -119,18 +119,18 @@ export class SQLiteWriter extends Writer {
119
119
  get hash() {
120
120
  return hash('sha256', this.table.location);
121
121
  }
122
- // What a reader reads of each row.
123
- get fields() {
122
+ // What a reader sees of each row, its reader view's columns: the stream's
123
+ // columns and loaded_at, none of the internal ones.
124
+ get shown() {
124
125
  return [
125
126
  ...this.table.columns.map((column) => column.quotedName),
126
127
  '"loaded_at"',
127
- '"committed_in"',
128
128
  ];
129
129
  }
130
- // What a commit writes into each row: its fields, and the hash of its
131
- // values.
130
+ // What a commit writes into each row: what a reader sees, the commit's
131
+ // number, and the hash of its values.
132
132
  get written() {
133
- return [...this.fields, version];
133
+ return [...this.shown, '"committed_in"', version];
134
134
  }
135
135
  encode(record) {
136
136
  return this.table.columns.map((column) => column.encode(record));
@@ -228,27 +228,31 @@ export class SQLiteWriter extends Writer {
228
228
  }
229
229
  }
230
230
  // What the table, its reader view and their columns say to readers,
231
- // rewritten with every load so they follow the stream's schema.
231
+ // rewritten with every load so they follow the stream's schema. The reader
232
+ // view describes only the columns it shows.
232
233
  describe(database) {
233
234
  const dataTypes = new Map([
234
235
  ...this.table.columns.map(({ name, dataType }) => [name, dataType]),
235
236
  ['loaded_at', 'timestamp'],
236
237
  ['committed_in', 'integer'],
237
238
  ]);
238
- const columns = Object.fromEntries(Object.entries({
239
- ...this.#description.columns,
240
- loaded_at: 'Start time of the load that wrote these values of the row. A deduplicating load, an overwrite or a reload that brings the same values leaves the row, and its loaded_at, as they are. Not the source modification time or the most recent successful sync.',
241
- committed_in: 'Number of the commit that last wrote this row: the row of _elt_commits with this number tells when, and in which load.',
242
- }).map(([name, description]) => [
239
+ const described = (descriptions) => Object.fromEntries(Object.entries(descriptions).map(([name, description]) => [
243
240
  name,
244
241
  { description, dataType: dataTypes.get(name) },
245
242
  ]));
246
- describe(database, this.table.name, this.#description.table, columns);
243
+ const shown = {
244
+ ...this.#description.columns,
245
+ loaded_at: 'Start time of the load that wrote these values of the row. A deduplicating load, an overwrite or a reload that brings the same values leaves the row, and its loaded_at, as they are. Not the source modification time or the most recent successful sync.',
246
+ };
247
+ describe(database, this.table.name, this.#description.table, described({
248
+ ...shown,
249
+ committed_in: 'Number of the commit that last wrote this row: the row of _elt_commits with this number tells when, and in which load.',
250
+ }));
247
251
  if (this.table.readerView !== undefined)
248
- describe(database, this.table.readerView, this.#description.table, columns);
252
+ describe(database, this.table.readerView, this.#description.table, described(shown));
249
253
  }
250
254
  #viewDefinition(view) {
251
- return `CREATE VIEW ${quote(view)} AS SELECT ${this.fields.join(', ')} FROM ${this.table.quotedName}`;
255
+ return `CREATE VIEW ${quote(view)} AS SELECT ${this.shown.join(', ')} FROM ${this.table.quotedName}`;
252
256
  }
253
257
  // Refuses, before anything is read, a view of other columns or another
254
258
  // table, rather than adopting it. A stale table's view still selects the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eltjs/elt-sqlite",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "The SQLite destination of elt: each stream is a raw table in one SQLite file, with a reader view over it and a catalog of each table, view and column.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
  "node": ">=26"
40
40
  },
41
41
  "dependencies": {
42
- "@eltjs/elt": "^0.3.2",
42
+ "@eltjs/elt": "^0.3.3",
43
43
  "@zukhruf/mutex": "^0.8.10",
44
44
  "@zukhruf/sqlite-drift": "^0.8.10",
45
45
  "@zukhruf/sqlite-open": "^0.8.10",