@eltjs/elt-sqlite 0.3.0 → 0.3.2
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 +10 -2
- package/dist/computed-dataset.js +15 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/sqlite-as-of-definitions.d.ts +19 -0
- package/dist/sqlite-as-of-definitions.js +67 -0
- package/dist/sqlite-as-of.d.ts +31 -1
- package/dist/sqlite-as-of.js +74 -28
- package/dist/sqlite-column.js +2 -1
- package/dist/sqlite-file-store.js +1 -2
- package/dist/sqlite-history.js +1 -2
- package/dist/sqlite-identifiers.d.ts +1 -0
- package/dist/sqlite-identifiers.js +2 -0
- package/dist/sqlite-loads.js +12 -2
- package/dist/sqlite-table.js +2 -2
- package/dist/sqlite-views.js +1 -1
- package/dist/sqlite-writer.js +1 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -120,6 +120,12 @@ Each commit in the SQLite file has a number. A reader that compares the file at
|
|
|
120
120
|
- 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.
|
|
121
121
|
|
|
122
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).
|
|
123
|
+
- `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`.
|
|
125
|
+
- 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
|
+
- A table of the sync history fails when it is read, and so does each view over it.
|
|
127
|
+
- 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.
|
|
128
|
+
- The views fail through the SQL functions of `asOfFunctions`. Register each one on the connection: `database.function(name, implementation)`.
|
|
123
129
|
|
|
124
130
|
### Retention
|
|
125
131
|
|
|
@@ -136,7 +142,7 @@ new SQLiteDestination({
|
|
|
136
142
|
- Min valid is a position, and it never passes the newest position. Thus a pass that stays open longer than the retention does not expire the position before it.
|
|
137
143
|
- A position older than min valid is expired: `reading`, `positions` and `asOfSelect` give `{ expired }` with a reason that names the retention.
|
|
138
144
|
- A file in a [file storage](../../elt/README.md#local-files) that only the deleted history rows refer to goes when the stream of its table next commits.
|
|
139
|
-
- `_elt_commits`
|
|
145
|
+
- The same commit deletes each row of `_elt_commits` with a number less than W, and each closed load of `_elt_loads` that has no commit left. W is a position, so each load with a commit before W ends at or before W. The row of W stays, because a reading as of W gives its `committed_at`. A `committed_in` of a row is only compared with numbers, so it needs no row in `_elt_commits`. An open load stays, also when it has no commit yet.
|
|
140
146
|
- A retention that is less than zero throws a `RangeError`. A retention in months or years throws a `RangeError` of `Temporal`, because their length depends on the date.
|
|
141
147
|
|
|
142
148
|
## Checkpoints
|
|
@@ -248,6 +254,7 @@ A refresh is a load in `_elt_loads`, so a [reading](#commit-numbers-and-position
|
|
|
248
254
|
|
|
249
255
|
- 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.
|
|
250
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.
|
|
257
|
+
- 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.
|
|
251
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`.
|
|
252
259
|
- 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.
|
|
253
260
|
|
|
@@ -296,7 +303,8 @@ src/
|
|
|
296
303
|
sqlite-writer.ts the stage, the commit, evolve, reload and the difference of a table
|
|
297
304
|
sqlite-loads.ts SQLiteLoad: the loads and commit numbers of a file, its positions and its retention
|
|
298
305
|
sqlite-history.ts SQLiteHistory: the history of a table, and the rows that the retention deletes
|
|
299
|
-
sqlite-as-of.ts readings and
|
|
306
|
+
sqlite-as-of.ts readings, asOfSelect and asOfFunctions: the file as of a position
|
|
307
|
+
sqlite-as-of-definitions.ts asOfDefinitions: the views of a position of an attached file
|
|
300
308
|
sqlite-checkpoint-store.ts SQLiteCheckpointStore
|
|
301
309
|
sqlite-sync-history.ts SQLiteSyncHistory
|
|
302
310
|
sqlite-catalog.ts installSQLiteCatalog
|
package/dist/computed-dataset.js
CHANGED
|
@@ -6,11 +6,10 @@ import { DerivedDataset, } from '@eltjs/elt';
|
|
|
6
6
|
import { busyTimeout } from './open-destination.js';
|
|
7
7
|
import { SQLiteDestination } from './sqlite-destination.js';
|
|
8
8
|
import { SQLiteHistory, rowHash } from './sqlite-history.js';
|
|
9
|
-
import { identifiers } from './sqlite-identifiers.js';
|
|
9
|
+
import { identifiers, quote } from './sqlite-identifiers.js';
|
|
10
10
|
import { SQLiteLoad } from './sqlite-loads.js';
|
|
11
11
|
import { publishSQLiteViews } from './sqlite-views.js';
|
|
12
12
|
import { View } from './view.js';
|
|
13
|
-
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
14
13
|
// The columns the dataset keeps on every output row beside the rule's own.
|
|
15
14
|
const engineColumns = {
|
|
16
15
|
input_hash: 'SHA-256 of the input the row was made from; the row is made again when its input changes.',
|
|
@@ -55,6 +54,20 @@ export class ComputedDataset extends DerivedDataset {
|
|
|
55
54
|
name: this.#table,
|
|
56
55
|
location: identifiers.key(this.#table),
|
|
57
56
|
});
|
|
57
|
+
// SQLite takes two names that differ only in ASCII case for one column,
|
|
58
|
+
// and would refuse the table at the first refresh.
|
|
59
|
+
const taken = new Map([
|
|
60
|
+
compute.key.name,
|
|
61
|
+
...Object.keys(engineColumns),
|
|
62
|
+
'committed_in',
|
|
63
|
+
'_elt_row_hash',
|
|
64
|
+
].map((column) => [identifiers.key(column), column]));
|
|
65
|
+
for (const column of Object.keys(compute.output)) {
|
|
66
|
+
const other = taken.get(identifiers.key(column));
|
|
67
|
+
if (other !== undefined)
|
|
68
|
+
throw new TypeError(`Computed dataset ${name} cannot have the column ${column}: the dataset already has the column ${other}`);
|
|
69
|
+
taken.set(identifiers.key(column), column);
|
|
70
|
+
}
|
|
58
71
|
this.#columns = Object.freeze([
|
|
59
72
|
compute.key.name,
|
|
60
73
|
...Object.keys(engineColumns),
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ export { SQLiteCheckpointStore } from './sqlite-checkpoint-store.ts';
|
|
|
5
5
|
export { SQLiteColumn } from './sqlite-column.ts';
|
|
6
6
|
export { SQLiteColumns } from './sqlite-columns.ts';
|
|
7
7
|
export { SQLiteDestination } from './sqlite-destination.ts';
|
|
8
|
-
export {
|
|
8
|
+
export { asOfDefinitions } from './sqlite-as-of-definitions.ts';
|
|
9
|
+
export { type SQLiteReading, asOfFunctions, asOfSelect, } from './sqlite-as-of.ts';
|
|
9
10
|
export { type PassState, type PassStatus, readPassStatus, type StreamPassStatus, } from './sqlite-passes.ts';
|
|
10
11
|
export { SQLiteSyncHistory } from './sqlite-sync-history.ts';
|
|
11
12
|
export { SQLiteTable } from './sqlite-table.ts';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,8 @@ export { SQLiteCheckpointStore } from './sqlite-checkpoint-store.js';
|
|
|
5
5
|
export { SQLiteColumn } from './sqlite-column.js';
|
|
6
6
|
export { SQLiteColumns } from './sqlite-columns.js';
|
|
7
7
|
export { SQLiteDestination } from './sqlite-destination.js';
|
|
8
|
-
export {
|
|
8
|
+
export { asOfDefinitions } from './sqlite-as-of-definitions.js';
|
|
9
|
+
export { asOfFunctions, asOfSelect, } from './sqlite-as-of.js';
|
|
9
10
|
export { readPassStatus, } from './sqlite-passes.js';
|
|
10
11
|
export { SQLiteSyncHistory } from './sqlite-sync-history.js';
|
|
11
12
|
export { SQLiteTable } from './sqlite-table.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
/**
|
|
3
|
+
* The `CREATE TEMP VIEW` statements that show the file attached as `schema`
|
|
4
|
+
* as of a position, each relation as `${prefix}_${name}`, for a host that
|
|
5
|
+
* reads several files in one connection: each target, each stored view with
|
|
6
|
+
* its names mapped to these views, and the sync history and each view that
|
|
7
|
+
* reads what keeps no row versions, which fail. Each view reads the file
|
|
8
|
+
* when it is read, so it stays the same across later commits, and fails
|
|
9
|
+
* through `asOfFunctions` once the file no longer reads the position exactly:
|
|
10
|
+
* the retention deleted its history, or the file was made again. A position
|
|
11
|
+
* the file cannot read now is expired.
|
|
12
|
+
*/
|
|
13
|
+
export declare function asOfDefinitions(database: DatabaseSync, { schema, position, prefix, }: {
|
|
14
|
+
readonly schema: string;
|
|
15
|
+
readonly position: string;
|
|
16
|
+
readonly prefix: string;
|
|
17
|
+
}): readonly string[] | {
|
|
18
|
+
readonly expired: string;
|
|
19
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { open } from '@zukhruf/sqlite-open';
|
|
2
|
+
import { columnsOf, literal, readable, selectAsOf, storedViews, syncHistoryTables, syncHistoryViews, versioned, } from './sqlite-as-of.js';
|
|
3
|
+
import { quote } from './sqlite-identifiers.js';
|
|
4
|
+
/**
|
|
5
|
+
* The `CREATE TEMP VIEW` statements that show the file attached as `schema`
|
|
6
|
+
* as of a position, each relation as `${prefix}_${name}`, for a host that
|
|
7
|
+
* reads several files in one connection: each target, each stored view with
|
|
8
|
+
* its names mapped to these views, and the sync history and each view that
|
|
9
|
+
* reads what keeps no row versions, which fail. Each view reads the file
|
|
10
|
+
* when it is read, so it stays the same across later commits, and fails
|
|
11
|
+
* through `asOfFunctions` once the file no longer reads the position exactly:
|
|
12
|
+
* the retention deleted its history, or the file was made again. A position
|
|
13
|
+
* the file cannot read now is expired.
|
|
14
|
+
*/
|
|
15
|
+
export function asOfDefinitions(database, { schema, position, prefix, }) {
|
|
16
|
+
const asked = readable(database, position, schema);
|
|
17
|
+
if ('expired' in asked)
|
|
18
|
+
return asked;
|
|
19
|
+
const named = (name) => `${prefix}_${name}`;
|
|
20
|
+
const targets = versioned(database, schema);
|
|
21
|
+
const views = storedViews(database, schema);
|
|
22
|
+
const columns = new Map([
|
|
23
|
+
...targets.map(({ target }) => target),
|
|
24
|
+
...views.map(({ name }) => name),
|
|
25
|
+
...syncHistoryTables,
|
|
26
|
+
]
|
|
27
|
+
.map((name) => [
|
|
28
|
+
name,
|
|
29
|
+
columnsOf(database, quote(name), schema),
|
|
30
|
+
])
|
|
31
|
+
.filter(([, names]) => names.length > 0));
|
|
32
|
+
return [
|
|
33
|
+
...targets.map((version) => `CREATE TEMP VIEW ${quote(named(version.target))} AS ${selectAsOf(database, version, asked.number, schema, asked)}`),
|
|
34
|
+
...syncHistoryViews(database, schema, named),
|
|
35
|
+
...views.map((view) => `CREATE TEMP VIEW ${quote(named(view.name))} AS ${mapped(view, columns, named)}`),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
// The query of a stored view with each relation it names renamed, as
|
|
39
|
+
// SQLite's own ALTER TABLE ... RENAME rewrites the views that name a table:
|
|
40
|
+
// the view is made in a database of its own beside a table of the columns of
|
|
41
|
+
// each other relation, and each table is renamed. A view that reads a table
|
|
42
|
+
// with no view of the position reads as a view that fails.
|
|
43
|
+
function mapped({ name, sql }, columns, named) {
|
|
44
|
+
using scratch = open(':memory:');
|
|
45
|
+
scratch.exec('PRAGMA legacy_alter_table = OFF');
|
|
46
|
+
const others = [...columns].filter(([relation]) => relation !== name);
|
|
47
|
+
for (const [relation, names] of others)
|
|
48
|
+
scratch.exec(`CREATE TABLE ${quote(relation)} (${names.map(quote).join(', ')})`);
|
|
49
|
+
scratch.exec(sql);
|
|
50
|
+
try {
|
|
51
|
+
scratch.prepare(`SELECT * FROM ${quote(name)}`);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (Error.isError(error) && /no such table: /.test(error.message))
|
|
55
|
+
return `SELECT NULL WHERE "_elt_unversioned"(${literal(`View ${quote(name)} cannot be read as of a position: it reads a table that keeps no row versions (${error.message})`)})`;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
for (const [relation] of others)
|
|
59
|
+
scratch.exec(`ALTER TABLE ${quote(relation)} RENAME TO ${quote(named(relation))}`);
|
|
60
|
+
const renamed = String(scratch
|
|
61
|
+
.prepare(`SELECT "sql" FROM sqlite_schema WHERE "name" = ?`)
|
|
62
|
+
.get(name)?.sql);
|
|
63
|
+
const head = `CREATE VIEW ${quote(name)} AS `;
|
|
64
|
+
if (!renamed.startsWith(head))
|
|
65
|
+
throw new TypeError(`View ${quote(name)} is not one that elt made: it does not begin with ${head}`);
|
|
66
|
+
return renamed.slice(head.length);
|
|
67
|
+
}
|
package/dist/sqlite-as-of.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
import { type DatabaseSync } from 'node:sqlite';
|
|
1
|
+
import { type DatabaseSync, type SQLInputValue } from 'node:sqlite';
|
|
2
|
+
import { type Position } from './sqlite-loads.ts';
|
|
3
|
+
type Versioned = {
|
|
4
|
+
readonly target: string;
|
|
5
|
+
readonly history: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The SQL functions that views of a position call to fail, by name: a host
|
|
9
|
+
* registers each one on the connection that reads the views, as
|
|
10
|
+
* `database.function(name, implementation)`. `_elt_unversioned` refuses a
|
|
11
|
+
* read of what keeps no row versions; `_elt_readable` refuses a read of a
|
|
12
|
+
* position that the attached file no longer reads exactly.
|
|
13
|
+
*/
|
|
14
|
+
export declare const asOfFunctions: {
|
|
15
|
+
_elt_unversioned(reason: SQLInputValue): never;
|
|
16
|
+
_elt_readable(schema: SQLInputValue, position: SQLInputValue, epoch: SQLInputValue, minValid: SQLInputValue): 1;
|
|
17
|
+
};
|
|
2
18
|
/**
|
|
3
19
|
* The SQLite file as of one position: `sql` reads in one read transaction,
|
|
4
20
|
* and sees each target as it was at that position. Dispose it to close it.
|
|
@@ -27,3 +43,17 @@ export declare function asOfSelect(database: DatabaseSync, target: string, posit
|
|
|
27
43
|
}): string | {
|
|
28
44
|
readonly expired: string;
|
|
29
45
|
};
|
|
46
|
+
export declare function readable(database: DatabaseSync, position: string, schema: string): Position | {
|
|
47
|
+
readonly expired: string;
|
|
48
|
+
};
|
|
49
|
+
export declare function selectAsOf(database: DatabaseSync, { target, history }: Versioned, number: number, schema: string, guard?: Position): string;
|
|
50
|
+
export declare const literal: (text: string) => string;
|
|
51
|
+
export declare const syncHistoryTables: string[];
|
|
52
|
+
export declare function syncHistoryViews(database: DatabaseSync, schema: string, name: (table: string) => string): string[];
|
|
53
|
+
export declare function storedViews(database: DatabaseSync, schema: string): {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly sql: string;
|
|
56
|
+
}[];
|
|
57
|
+
export declare function versioned(database: DatabaseSync, schema: string): Versioned[];
|
|
58
|
+
export declare function columnsOf(database: DatabaseSync, table: string, schema?: string): string[];
|
|
59
|
+
export {};
|
package/dist/sqlite-as-of.js
CHANGED
|
@@ -2,10 +2,36 @@ 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 } from './sqlite-identifiers.js';
|
|
5
|
+
import { identifiers, 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
|
-
|
|
8
|
+
import { version as rowHash } from './sqlite-writer.js';
|
|
9
|
+
/**
|
|
10
|
+
* The SQL functions that views of a position call to fail, by name: a host
|
|
11
|
+
* registers each one on the connection that reads the views, as
|
|
12
|
+
* `database.function(name, implementation)`. `_elt_unversioned` refuses a
|
|
13
|
+
* read of what keeps no row versions; `_elt_readable` refuses a read of a
|
|
14
|
+
* position that the attached file no longer reads exactly.
|
|
15
|
+
*/
|
|
16
|
+
export const asOfFunctions = {
|
|
17
|
+
_elt_unversioned(reason) {
|
|
18
|
+
throw new Error(String(reason));
|
|
19
|
+
},
|
|
20
|
+
// The guard knows the epoch and min_valid of the file only, so it gives
|
|
21
|
+
// the position as the newest commit: a file made again has another epoch.
|
|
22
|
+
_elt_readable(schema, position, epoch, minValid) {
|
|
23
|
+
const asked = parsePosition(String(position));
|
|
24
|
+
const expired = expiry({
|
|
25
|
+
epoch: String(epoch),
|
|
26
|
+
minValid: Number(minValid),
|
|
27
|
+
newest: asked.number,
|
|
28
|
+
ends: [],
|
|
29
|
+
}, asked);
|
|
30
|
+
if (expired === undefined)
|
|
31
|
+
return 1;
|
|
32
|
+
throw new Error(`${expired} (the file attached as ${String(schema)})`);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
9
35
|
// A reader of the file, or null while no file is at the path.
|
|
10
36
|
export function reader(path) {
|
|
11
37
|
try {
|
|
@@ -67,8 +93,10 @@ function shadow(database, number) {
|
|
|
67
93
|
const targets = versioned(database, 'main');
|
|
68
94
|
for (const version of targets)
|
|
69
95
|
database.exec(`CREATE TEMP VIEW ${quote(version.target)} AS ${selectAsOf(database, version, number, 'main')}`);
|
|
70
|
-
|
|
71
|
-
const
|
|
96
|
+
database.function('_elt_unversioned', asOfFunctions._elt_unversioned);
|
|
97
|
+
for (const view of syncHistoryViews(database, 'main', (table) => table))
|
|
98
|
+
database.exec(view);
|
|
99
|
+
const views = storedViews(database, 'main');
|
|
72
100
|
for (const { sql } of views)
|
|
73
101
|
database.exec(sql.replace(/^CREATE VIEW /, 'CREATE TEMP VIEW '));
|
|
74
102
|
refuseQualified(database, views.map(({ name }) => name), new Set(targets.map(({ target }) => identifiers.key(target))));
|
|
@@ -84,47 +112,65 @@ function shadow(database, number) {
|
|
|
84
112
|
* or older than the retention, is expired.
|
|
85
113
|
*/
|
|
86
114
|
export function asOfSelect(database, target, position, { schema = 'main' } = {}) {
|
|
115
|
+
const asked = readable(database, position, schema);
|
|
116
|
+
if ('expired' in asked)
|
|
117
|
+
return asked;
|
|
118
|
+
const version = versioned(database, schema).find((version) => identifiers.key(version.target) === identifiers.key(target));
|
|
119
|
+
if (version === undefined)
|
|
120
|
+
throw new TypeError(`${quote(target)} is not a target of ${schema}`);
|
|
121
|
+
return selectAsOf(database, version, asked.number, schema);
|
|
122
|
+
}
|
|
123
|
+
// The position, when the file attached as schema reads it now.
|
|
124
|
+
export function readable(database, position, schema) {
|
|
87
125
|
const asked = parsePosition(position);
|
|
88
126
|
const numbered = readNumbering(database, schema);
|
|
89
127
|
const expired = expiry(numbered, asked);
|
|
90
128
|
if (expired !== undefined)
|
|
91
129
|
return { expired };
|
|
92
130
|
refuseInsidePass(numbered, asked, schema === 'main' ? 'the file' : `the file attached as ${schema}`);
|
|
93
|
-
|
|
94
|
-
if (version === undefined)
|
|
95
|
-
throw new TypeError(`${quote(target)} is not a target of ${schema}`);
|
|
96
|
-
return selectAsOf(database, version, asked.number, schema);
|
|
131
|
+
return asked;
|
|
97
132
|
}
|
|
98
133
|
// A target as of commit number: its current rows that the commit had made,
|
|
99
134
|
// and the history rows that the commit had made and a later commit replaced.
|
|
100
|
-
// A column that the history lacks reads as NULL there.
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
139
|
+
export function selectAsOf(database, { target, history }, number, schema, guard) {
|
|
140
|
+
const columns = columnsOf(database, quote(target), schema).filter((column) => guard === undefined || quote(column) !== rowHash);
|
|
103
141
|
const kept = new Set(columnsOf(database, quote(history), schema).map((column) => identifiers.key(column)));
|
|
104
|
-
const current = columns.map(quote).join(', ');
|
|
142
|
+
const current = columns.map((column) => `"r".${quote(column)}`).join(', ');
|
|
105
143
|
const replaced = columns
|
|
106
144
|
.map((column) => kept.has(identifiers.key(column))
|
|
107
|
-
? quote(column)
|
|
145
|
+
? `"r".${quote(column)}`
|
|
108
146
|
: `NULL AS ${quote(column)}`)
|
|
109
147
|
.join(', ');
|
|
110
148
|
const file = quote(schema);
|
|
111
|
-
|
|
149
|
+
// The guard is the outer loop of a CROSS JOIN, which SQLite keeps in
|
|
150
|
+
// place, so it runs once and also when the target holds no row.
|
|
151
|
+
const from = guard === undefined
|
|
152
|
+
? (table) => `FROM ${file}.${quote(table)} AS "r" WHERE`
|
|
153
|
+
: (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`;
|
|
154
|
+
return `SELECT ${current} ${from(target)} "r"."committed_in" <= ${number} UNION ALL SELECT ${replaced} ${from(history)} "r"."committed_in" <= ${number} AND "r"."replaced_in" > ${number}`;
|
|
112
155
|
}
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
156
|
+
export const literal = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
157
|
+
// The tables of the sync history, unquoted.
|
|
158
|
+
export const syncHistoryTables = [attempts, coverage, checks].map((table) => table.slice(1, -1));
|
|
159
|
+
// The sync history keeps no versions, so each of its tables of schema reads
|
|
160
|
+
// as a TEMP view, named by name, that fails, and so does each view over them.
|
|
161
|
+
export function syncHistoryViews(database, schema, name) {
|
|
162
|
+
return syncHistoryTables.flatMap((table) => {
|
|
163
|
+
const columns = columnsOf(database, quote(table), schema);
|
|
164
|
+
return columns.length === 0
|
|
165
|
+
? []
|
|
166
|
+
: [
|
|
167
|
+
`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')`,
|
|
168
|
+
];
|
|
118
169
|
});
|
|
119
|
-
for (const table of [attempts, coverage, checks]) {
|
|
120
|
-
const columns = columnsOf(database, table);
|
|
121
|
-
if (columns.length > 0)
|
|
122
|
-
database.exec(`CREATE TEMP VIEW ${table} AS SELECT ${columns.map((column) => `NULL AS ${quote(column)}`).join(', ')} WHERE "_elt_unversioned"()`);
|
|
123
|
-
}
|
|
124
170
|
}
|
|
125
|
-
function storedViews(database) {
|
|
171
|
+
export function storedViews(database, schema) {
|
|
126
172
|
return database
|
|
127
|
-
.prepare(String.raw `SELECT "name", "sql" FROM
|
|
173
|
+
.prepare(String.raw `SELECT "name", "sql" FROM ${quote(schema)}.sqlite_schema WHERE "type" = 'view' AND "name" NOT LIKE 'sqlite\_%' ESCAPE '\'`)
|
|
128
174
|
.all()
|
|
129
175
|
.map(({ name, sql }) => ({ name: String(name), sql: String(sql) }));
|
|
130
176
|
}
|
|
@@ -160,7 +206,7 @@ function refuseQualified(database, views, targets) {
|
|
|
160
206
|
}
|
|
161
207
|
}
|
|
162
208
|
// Each target with its history, as the file attached as schema records them.
|
|
163
|
-
function versioned(database, schema) {
|
|
209
|
+
export function versioned(database, schema) {
|
|
164
210
|
const file = quote(schema);
|
|
165
211
|
if (database
|
|
166
212
|
.prepare(`SELECT 1 FROM ${file}.sqlite_schema WHERE "type" = 'table' AND "name" = ?`)
|
|
@@ -175,7 +221,7 @@ function versioned(database, schema) {
|
|
|
175
221
|
}));
|
|
176
222
|
}
|
|
177
223
|
// The columns of a table of schema, named as SQL quotes it.
|
|
178
|
-
function columnsOf(database, table, schema = 'main') {
|
|
224
|
+
export function columnsOf(database, table, schema = 'main') {
|
|
179
225
|
return database
|
|
180
226
|
.prepare(`PRAGMA ${quote(schema)}.table_info(${table})`)
|
|
181
227
|
.all()
|
package/dist/sqlite-column.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FileRead, isCalendarDate, isTimestamp } from '@eltjs/elt';
|
|
2
|
+
import { quote } from './sqlite-identifiers.js';
|
|
2
3
|
// STRICT tables accept only these types, so dates and times are their
|
|
3
4
|
// canonical ISO text, which sorts in time order, and exact decimals are text.
|
|
4
5
|
const storageTypes = {
|
|
@@ -156,7 +157,7 @@ export class SQLiteColumn {
|
|
|
156
157
|
});
|
|
157
158
|
}
|
|
158
159
|
get quotedName() {
|
|
159
|
-
return
|
|
160
|
+
return quote(this.name);
|
|
160
161
|
}
|
|
161
162
|
// Original file bytes live in a chunk table; the column keeps the file's id.
|
|
162
163
|
get storesFile() {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createHash, hash } from 'node:crypto';
|
|
2
2
|
import { Declaration } from '@zukhruf/sqlite-drift';
|
|
3
|
-
import { identifiers } from './sqlite-identifiers.js';
|
|
4
|
-
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
3
|
+
import { identifiers, quote } from './sqlite-identifiers.js';
|
|
5
4
|
const tablePrefix = '_elt_files_';
|
|
6
5
|
const chunks = '("file" INTEGER NOT NULL, "n" INTEGER NOT NULL, "bytes" BLOB NOT NULL, PRIMARY KEY ("file", "n")) STRICT';
|
|
7
6
|
// The highest id that each store gave a file it published. Ids only go up: a
|
package/dist/sqlite-history.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { createHash, hash } from 'node:crypto';
|
|
2
2
|
import { Declaration } from '@zukhruf/sqlite-drift';
|
|
3
3
|
import { SQLiteFileStore } from './sqlite-file-store.js';
|
|
4
|
-
import { identifiers } from './sqlite-identifiers.js';
|
|
5
|
-
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
4
|
+
import { identifiers, quote } from './sqlite-identifiers.js';
|
|
6
5
|
export const histories = '_elt_histories';
|
|
7
6
|
// Which table keeps the replaced rows of which target, by the target's
|
|
8
7
|
// location, so a reader finds the history of each target.
|
|
@@ -17,3 +17,5 @@ class SQLiteIdentifiers extends Identifiers {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
export const identifiers = Object.freeze(new SQLiteIdentifiers());
|
|
20
|
+
// SQLite quotes an identifier in double quotes and doubles each one inside it.
|
|
21
|
+
export const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
package/dist/sqlite-loads.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Declaration } from '@zukhruf/sqlite-drift';
|
|
|
3
3
|
import { transaction } from '@zukhruf/sqlite-transaction';
|
|
4
4
|
import { openDestination } from './open-destination.js';
|
|
5
5
|
import { expireHistory } from './sqlite-history.js';
|
|
6
|
+
import { quote } from './sqlite-identifiers.js';
|
|
6
7
|
const epochs = '"_elt_epoch"';
|
|
7
8
|
const loads = '"_elt_loads"';
|
|
8
9
|
const commits = '"_elt_commits"';
|
|
@@ -106,7 +107,12 @@ export class SQLiteLoad {
|
|
|
106
107
|
// window becomes min_valid, and the history keeps no row that only readings
|
|
107
108
|
// before it read. min_valid is a position, never past the newest one, so the
|
|
108
109
|
// newest position stays readable while a pass stays open longer than the
|
|
109
|
-
// window.
|
|
110
|
+
// window. The numbering then keeps no commit before min_valid and no closed
|
|
111
|
+
// load left with none, so it holds only what a reading still reads: min_valid
|
|
112
|
+
// is a position, so each load that has a commit before it ends at or before
|
|
113
|
+
// it, and keeps min_valid's own commit, whose committed_at a reading there
|
|
114
|
+
// reads. A committed_in of a row only compares with numbers, and needs no
|
|
115
|
+
// commit row. An open load keeps its row with or without commits.
|
|
110
116
|
function retain(database, { clock, retention }) {
|
|
111
117
|
const cutoff = new Date(clock.Date.now() - retention.total('milliseconds')).toISOString();
|
|
112
118
|
const minValid = Number(database.prepare(`SELECT "min_valid" FROM ${epochs}`).get()?.min_valid);
|
|
@@ -120,6 +126,10 @@ function retain(database, { clock, retention }) {
|
|
|
120
126
|
return;
|
|
121
127
|
expireHistory(database, expired);
|
|
122
128
|
database.prepare(`UPDATE ${epochs} SET "min_valid" = ?`).run(expired);
|
|
129
|
+
database.prepare(`DELETE FROM ${commits} WHERE "number" < ?`).run(expired);
|
|
130
|
+
database
|
|
131
|
+
.prepare(`DELETE FROM ${loads} WHERE "closed_at" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${commits} AS c WHERE c."load" = ${loads}."id")`)
|
|
132
|
+
.run();
|
|
123
133
|
}
|
|
124
134
|
// Records a load of owner that opens at openedAt, with the numbering and
|
|
125
135
|
// its epoch if the file has none yet, and gives its id.
|
|
@@ -187,7 +197,7 @@ export function expiry(numbered, { epoch, number }) {
|
|
|
187
197
|
export function readNumbering(database, schema = 'main') {
|
|
188
198
|
if (schema === 'main' && numbering.drift(database) !== 'fits')
|
|
189
199
|
return null;
|
|
190
|
-
const file =
|
|
200
|
+
const file = quote(schema);
|
|
191
201
|
let row;
|
|
192
202
|
try {
|
|
193
203
|
row = database
|
package/dist/sqlite-table.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Target } from '@eltjs/elt';
|
|
2
2
|
import { SQLiteColumn, canonical } from './sqlite-column.js';
|
|
3
3
|
import { SQLiteColumns } from './sqlite-columns.js';
|
|
4
|
-
import { identifiers } from './sqlite-identifiers.js';
|
|
4
|
+
import { identifiers, quote } from './sqlite-identifiers.js';
|
|
5
5
|
// A reusable target definition. Empty columns mean infer from the Copy's
|
|
6
6
|
// stream. Its own name, its view's and its columns' follow the destination's
|
|
7
7
|
// naming rule, so a field SQLite would take for another still loads.
|
|
@@ -69,7 +69,7 @@ export class SQLiteTable extends Target {
|
|
|
69
69
|
return identifiers.key(this.name);
|
|
70
70
|
}
|
|
71
71
|
get quotedName() {
|
|
72
|
-
return
|
|
72
|
+
return quote(this.name);
|
|
73
73
|
}
|
|
74
74
|
get createTableSQL() {
|
|
75
75
|
return `CREATE TABLE IF NOT EXISTS ${this.definition(this.quotedName)}`;
|
package/dist/sqlite-views.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Declaration } from '@zukhruf/sqlite-drift';
|
|
|
2
2
|
import { transaction } from '@zukhruf/sqlite-transaction';
|
|
3
3
|
import { isReserved } from './reserved.js';
|
|
4
4
|
import { describe } from './sqlite-descriptions.js';
|
|
5
|
-
|
|
5
|
+
import { quote } from './sqlite-identifiers.js';
|
|
6
6
|
export function viewDefinition(view) {
|
|
7
7
|
return `CREATE VIEW ${quote(view.name)} AS ${view.query}`;
|
|
8
8
|
}
|
package/dist/sqlite-writer.js
CHANGED
|
@@ -6,9 +6,8 @@ import { openDestination } from './open-destination.js';
|
|
|
6
6
|
import { describe } from './sqlite-descriptions.js';
|
|
7
7
|
import { SQLiteFileStore } from './sqlite-file-store.js';
|
|
8
8
|
import { SQLiteHistory, rowHash } from './sqlite-history.js';
|
|
9
|
-
import { identifiers } from './sqlite-identifiers.js';
|
|
9
|
+
import { identifiers, quote } from './sqlite-identifiers.js';
|
|
10
10
|
import { SQLiteLoad } from './sqlite-loads.js';
|
|
11
|
-
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
12
11
|
const writersTable = new Declaration([
|
|
13
12
|
'CREATE TABLE "_elt_writers" ("target" TEXT PRIMARY KEY, "writer" TEXT NOT NULL) STRICT',
|
|
14
13
|
], { rows: 'replace' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eltjs/elt-sqlite",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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": {
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
|
-
"node": ">=
|
|
39
|
+
"node": ">=26"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@eltjs/elt": "^0.3.
|
|
42
|
+
"@eltjs/elt": "^0.3.2",
|
|
43
43
|
"@zukhruf/mutex": "^0.8.10",
|
|
44
44
|
"@zukhruf/sqlite-drift": "^0.8.10",
|
|
45
45
|
"@zukhruf/sqlite-open": "^0.8.10",
|