@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 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` keeps its rows.
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 asOfSelect: the file as of a position
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
@@ -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 { type SQLiteReading, asOfSelect } from './sqlite-as-of.ts';
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 { asOfSelect } from './sqlite-as-of.js';
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
+ }
@@ -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 {};
@@ -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
- const quote = (name) => `"${name.replaceAll('"', '""')}"`;
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
- shadowSyncHistory(database);
71
- const views = storedViews(database);
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
- const version = versioned(database, schema).find((version) => identifiers.key(version.target) === identifiers.key(target));
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
- function selectAsOf(database, { target, history }, number, schema) {
102
- const columns = columnsOf(database, quote(target), schema);
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
- return `SELECT ${current} FROM ${file}.${quote(target)} WHERE "committed_in" <= ${number} UNION ALL SELECT ${replaced} FROM ${file}.${quote(history)} WHERE "committed_in" <= ${number} AND "replaced_in" > ${number}`;
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
- // The sync history keeps no versions, so each of its tables reads as a view
114
- // that fails, and so does each view over them.
115
- function shadowSyncHistory(database) {
116
- database.function('_elt_unversioned', { deterministic: false }, () => {
117
- throw new Error('The sync history cannot be read as of a position: it keeps no row versions');
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 main.sqlite_schema WHERE "type" = 'view' AND "name" NOT LIKE 'sqlite\_%' ESCAPE '\'`)
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()
@@ -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 `"${this.name.replaceAll('"', '""')}"`;
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
@@ -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.
@@ -5,4 +5,5 @@ declare class SQLiteIdentifiers extends Identifiers {
5
5
  key(name: string): string;
6
6
  }
7
7
  export declare const identifiers: Readonly<SQLiteIdentifiers>;
8
+ export declare const quote: (name: string) => string;
8
9
  export {};
@@ -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('"', '""')}"`;
@@ -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 = `"${schema.replaceAll('"', '""')}"`;
200
+ const file = quote(schema);
191
201
  let row;
192
202
  try {
193
203
  row = database
@@ -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 `"${this.name.replaceAll('"', '""')}"`;
72
+ return quote(this.name);
73
73
  }
74
74
  get createTableSQL() {
75
75
  return `CREATE TABLE IF NOT EXISTS ${this.definition(this.quotedName)}`;
@@ -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
- const quote = (name) => `"${name.replaceAll('"', '""')}"`;
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
  }
@@ -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.0",
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": ">=24"
39
+ "node": ">=26"
40
40
  },
41
41
  "dependencies": {
42
- "@eltjs/elt": "^0.3.0",
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",