@eltjs/elt-sqlite 0.3.2 → 0.3.4
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 +7 -5
- package/dist/sqlite-as-of.d.ts +3 -1
- package/dist/sqlite-as-of.js +21 -15
- package/dist/sqlite-identifiers.d.ts +1 -0
- package/dist/sqlite-identifiers.js +12 -9
- package/dist/sqlite-writer.d.ts +1 -1
- package/dist/sqlite-writer.js +19 -15
- package/package.json +2 -2
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
package/dist/sqlite-as-of.d.ts
CHANGED
|
@@ -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
|
|
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,
|
package/dist/sqlite-as-of.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// the
|
|
138
|
-
//
|
|
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) =>
|
|
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,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
|
-
|
|
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
|
}
|
package/dist/sqlite-writer.d.ts
CHANGED
|
@@ -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
|
|
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[]];
|
package/dist/sqlite-writer.js
CHANGED
|
@@ -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
|
|
123
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.3.4",
|
|
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.
|
|
42
|
+
"@eltjs/elt": "^0.3.4",
|
|
43
43
|
"@zukhruf/mutex": "^0.8.10",
|
|
44
44
|
"@zukhruf/sqlite-drift": "^0.8.10",
|
|
45
45
|
"@zukhruf/sqlite-open": "^0.8.10",
|