@eltjs/elt-sqlite 0.1.3 → 0.3.0

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
@@ -33,12 +33,15 @@ Inferred columns map flat JSON Schema fields:
33
33
  Tables:
34
34
 
35
35
  - SQLite makes strict tables. SQL constraints that exist stay in force.
36
- - Each copy adds `loaded_at`, the UTC start time of the load. A field with that name gets a different column name.
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
+ - 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
+ - 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.
37
39
  - 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).
38
40
  - Deduplication also checks the stored types of the key and cursor columns, and refuses null keys and cursors.
39
41
  - A change to a normal `append` or `overwrite` removes the index of the mode. Explicit constraints stay.
40
42
  - A table of append history with repeated keys must be replaced with `overwrite_dedup` before incremental deduplication can start.
41
43
  - When the stored definition of a table does not agree with the columns of the stream, the load [evolves it](../../elt/README.md#shape-changes) before it reads. SQLite cannot change the type or the constraints of a column in place. Thus, in one transaction, the rows move, column by name, into a table of the new definition. That table takes the name of the table, with its index and its reader view. A value that its type or a `CHECK` refuses fails the load, and the table stays as it was.
44
+ - A row that has a value in a column that the table drops, or that it keeps as a different type, loses or changes that value. Thus the evolve takes a commit number of the load, the [history](#commit-numbers-and-positions) keeps each such row as it was, and the row gets the number in `committed_in`. Its `_elt_row_hash` counted the old value, so it becomes null: a null hash matches no record, and the next commit that writes the row gives it a new version. A column that the table adds is null in each row, and a null value does not count in `_elt_row_hash`. Thus it changes no row.
42
45
  - An overwrite reloads such a table into a hidden table `_elt_next_<hash>`. The hidden table replaces the table and its reader view when the stream ends ([resets](../../elt/README.md#resets)).
43
46
  - The writer of each target is in the reserved table `_elt_writers` ([target ownership](../../elt/README.md#target-ownership)).
44
47
 
@@ -54,12 +57,15 @@ Counts:
54
57
 
55
58
  SQLite stores an original file in chunks, because one BLOB has a limit of 1,000,000,000 bytes (`SQLITE_MAX_LENGTH` in the build of Node), and a value of the full file would stay in memory.
56
59
 
57
- - The `bytes` column holds an `INTEGER` file id.
60
+ - The `bytes` column holds an `INTEGER` file id. Each load gives each file a new id.
58
61
  - A table `_elt_files_<hash>` for each column holds `(file, n, bytes)` rows of up to 4 MiB. `n` counts from 0.
62
+ - The ids of a chunk table only go up. The reserved table `_elt_file_ids` keeps the highest id that each chunk table gave, as `sqlite_sequence` keeps the highest rowid of a table with `AUTOINCREMENT`. Thus a new file never takes the id of a file that a row of the [history](#commit-numbers-and-positions) refers to, also when the chunk table was dropped and made again.
59
63
  - The hash is the first 40 hex characters of the SHA-256 of `JSON.stringify([table, column])`, with the two names in ASCII lower case. Thus table `a_b` with column `c` and table `a` with column `b_c` keep their chunks apart.
60
64
  - The description of the column names its chunk table.
61
65
  - An empty file has one empty chunk. A file that is not available gives a null value.
62
- - Triggers remove the chunks of a row in the same transaction when the row is deleted, overwritten or replaced. A record that a deduplication guard refuses stores no chunks.
66
+ - The chunks of a file stay while a row of the table or of its history refers to the file. Thus a [reading](#commit-numbers-and-positions) as of a position reads the bytes of each file that the position shows. When the [retention](#retention) deletes the last history row that refers to a file, the same commit deletes its chunks.
67
+ - A commit deletes the chunks of each file that it staged and that no row of the table or of its history refers to. Thus a record that a deduplication guard refuses stores no chunks. The commit that resets a partition, the commit at the end of a reload, and a clear delete the chunks of each file that no row of the table or of its history refers to.
68
+ - When a row with a file has the same values as a stored row, also the same file bytes, the stored row stays with its file id. The commit deletes the chunks of the new copy of the file.
63
69
 
64
70
  Read a file back in order, with the chunk table that the description names:
65
71
 
@@ -81,14 +87,58 @@ Checked on 2026-09-24 (macOS 26.6.2, Node.js 26.8.1): a file of 1.5 GB loaded as
81
87
  - SQLite takes no lock other than its transactions. Loads into one file run side by side. A [checkpoint store](#checkpoints) keeps two runs of one copy apart.
82
88
  - No transaction stays open between commits, as in Postgres.
83
89
  - `prepare` only refuses and stages. It refuses the target of a different writer, a reader view that is not its own, and stored rows that a deduplicating load cannot keep.
84
- - Each commit is one write transaction. The first commit also makes the table, the index of its mode, its reader view, its descriptions and its writer.
90
+ - Each commit is one write transaction. The first commit also makes the table, the index of its mode, its reader view, its descriptions and its writer. A full refresh commits one time, when its stream ends. Thus an overwrite replaces the rows of the table in that one commit, and a reader never sees a part of it.
85
91
  - Other writers of the file wait only for a commit. A stream that fails before its first commit leaves nothing.
86
92
  - Each stream stages its operations in a `TEMP` table that only its connection sees. The commit of a different stream never publishes them, and a crash leaves nothing.
87
93
  - A commit merges the staged operations of one stream into its table. The result is the same as when the operations apply one at a time: a staged `DELETE` removes its key, only the records after the last `DELETE` of a key count, `replace` keeps the last record, and `cursor_newer` keeps the first record with the greatest cursor.
88
- - Original files stream into a `TEMP` chunk table that only the connection sees, and they get to the store in the commit of their rows. After each merge, SQLite deletes the chunks that no row refers to. A clear empties the store.
89
- - A file column that the target drops takes its chunk table with it when the table evolves or a reload replaces it.
94
+ - Original files stream into a `TEMP` chunk table that only the connection sees, and they get to the store in the commit of their rows.
95
+ - A file column that the target drops takes its chunk table with it when the table evolves or a reload replaces it. A reading as of a position reads only the columns of the table, so it does not read that column.
90
96
  - `:memory:` is possible only for a full refresh. The database of the run is gone when its handle closes, so it cannot keep incremental progress.
91
97
 
98
+ ## Commit numbers and positions
99
+
100
+ Each commit in the SQLite file has a number. A reader that compares the file at two times can find the positions where no pass is half done, as a Kafka consumer reads up to the last stable offset.
101
+
102
+ - The table `_elt_commits` has one row for each commit: its `number`, its `load` and its `committed_at`. SQLite writes one transaction at a time, so the numbers follow the order of the commits, also across connections.
103
+ - The table `_elt_loads` has one row for each load: its `connector` (the name of the connection) or its `dataset` (the name of a [computed dataset](#declare-a-computed-dataset)), its `opened_at`, and its `closed_at`, which is null while the load is open. A load of a connection opens when a pass starts to load, and closes when the pass ends. A load of a computed dataset opens when a refresh starts, and closes when the refresh ends, also when a stop or a failure ends it.
104
+ - 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
+ - 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
+ - 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 of the table with no match goes into the history and leaves the table.
109
+ - A new row with no match enters the table with the number of the commit.
110
+ - 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.
111
+ - A file in the SQLite file counts in `_elt_row_hash` by the SHA-256 of its bytes, which the load calculates while it stages the chunks. Each load gives the file a new id, but a row whose file has the same bytes matches. The row keeps its file id, and the commit deletes the chunks of the new copy.
112
+ - The table `_elt_epoch` keeps the epoch, an id that names the numbering. A file that is made again, or tables that a different version declared in a different way, start a new epoch.
113
+ - `destination.positions(after?)` gives the positions after the position `after`, in ascending order, or all positions. A position is `<epoch>:<number>`. It names the last commit of a closed load, when no other load has commits before and after it. With no `after`, it gives each position from [min valid](#retention) on. When `after` is of a different epoch, is after the newest commit, or is older than min valid, `positions` gives `{ expired }` with the reason. It opens the file read-only.
114
+ - A clear that empties a table is a load of one commit. It does not close the open loads of its connection.
115
+ - Each table has a [history](./CONTEXT.md) `_elt_history_<hash>`, which the table `_elt_histories` names. Before a commit changes or removes a row, it copies the row into the history, with `replaced_in`, its own number. This occurs for a deduplicated row with different values, a `DELETE`, the reset of a partition, a clear, a row that an overwrite or a reload does not bring again, the rows of a table that a reload of a different shape replaces, a row that loses or changes a value when its table evolves, and an output row of a computed dataset that a refresh changes or deletes. A commit into a hidden table copies nothing. The history has each column that the table had, and it does not drop a column. A field with the name `replaced_in` gets a different column name.
116
+ - A file that a row of the history refers to stays, in the SQLite file and in a [file storage](../../elt/README.md#local-files): the values that the destination gives the file storage to keep include the values of the history.
117
+ - The history gets the columns of the table when it is made. SQLite cannot add a column with the name `rowid` or `oid` to a table. Thus when a stream adds a field with one of these names later, a commit that keeps a row in the history fails. Delete the SQLite file to load it again.
118
+ - 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
+ - `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
+ - 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
+
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
+
124
+ ### Retention
125
+
126
+ The history keeps each position readable for the retention of the destination, as Delta Lake keeps its log:
127
+
128
+ ```ts
129
+ new SQLiteDestination({
130
+ path: 'import.sqlite',
131
+ retention: Temporal.Duration.from({ days: 30 }), // the default
132
+ });
133
+ ```
134
+
135
+ - In each commit, let W be the newest position whose commit is older than now minus the retention. The commit deletes each history row with `replaced_in ≤ W`, and the chunks of each file that only those rows refer to. It sets min valid, the column `min_valid` of `_elt_epoch`, to W. This is the `MIN_VALID_VERSION` of SQL Server change tracking.
136
+ - 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
+ - A position older than min valid is expired: `reading`, `positions` and `asOfSelect` give `{ expired }` with a reason that names the retention.
138
+ - 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.
140
+ - 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
+
92
142
  ## Checkpoints
93
143
 
94
144
  `SQLiteCheckpointStore({ path })` keeps the checkpoints of incremental copies in a table `checkpoints` (`id`, `binding`, `reloading`, `state`) in its own file. Use it with SQLite destinations.
@@ -112,7 +162,7 @@ Checked on 2026-09-24 (macOS 26.6.2, Node.js 26.8.1): a file of 1.5 GB loaded as
112
162
 
113
163
  ## Reserved names
114
164
 
115
- The destination and its sync history own each table, index and view whose name starts with `_elt_`, in any case. Examples are `_elt_writers`, `_elt_descriptions`, the chunk tables `_elt_files_<hash>` and the sync history's `_elt_sync_attempts`. A stream, a view or a computed dataset never has such a name, and `publishSQLiteViews` refuses one. A reader that follows the data of a file, such as a changefeed, skips these tables with `isReserved(name)`:
165
+ The destination and its sync history own each table, index and view whose name starts with `_elt_`, in any case. Examples are `_elt_writers`, `_elt_descriptions`, `_elt_commits`, the chunk tables `_elt_files_<hash>` and the sync history's `_elt_sync_attempts`. A stream, a view or a computed dataset never has such a name, and `publishSQLiteViews` refuses one. A reader that follows the data of a file, such as a changefeed, skips these tables with `isReserved(name)`:
116
166
 
117
167
  ```ts
118
168
  import { isReserved } from '@eltjs/elt-sqlite';
@@ -194,10 +244,18 @@ At each refresh, the computed dataset compares the inputs with the output rows.
194
244
  - Each call of the rule commits as it settles, so readers see the output rows as they land. See [ADR 0003](./docs/adr/0003-each-rule-call-commits-as-it-settles.md).
195
245
  - When the declaration of the raw table changes, the refresh makes the table again. See [ADR 0004](./docs/adr/0004-a-changed-declaration-makes-the-table-again.md).
196
246
 
247
+ 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:
248
+
249
+ - 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
+ - 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.
251
+ - 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
+ - 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
+
197
254
  ## When something fails
198
255
 
199
256
  - **A rule failure** gives an output row with the status `failed` and the reason in `error`. The run continues, and the refresh after the next pass tries the input again.
200
- - **A dataset failure**, for example a query that SQLite refuses, rejects the run, or it stops the watch.
257
+ - **A dataset failure**, for example a query that SQLite refuses, rejects the run, or it stops the watch, with a `DerivedDatasetError` that names the dataset.
258
+ - **A table that is gone**, for example one that a person dropped by hand, is no failure. A computed dataset that reads it waits and keeps its output rows. When the copy loads the table again, the dataset asks the rule only for inputs that changed.
201
259
  - **A copy that failed** keeps its last committed rows. The derived datasets read those rows.
202
260
 
203
261
  ## Recipes
@@ -205,6 +263,7 @@ At each refresh, the computed dataset compares the inputs with the output rows.
205
263
  One use case each, with a full program that you can run:
206
264
 
207
265
  - [Search the text of stored files with SQL](./docs/recipes/search-the-text-of-stored-files.md)
266
+ - [Read the file as of a position](./docs/recipes/read-the-file-as-of-a-position.md)
208
267
 
209
268
  ## Decisions
210
269
 
@@ -214,6 +273,9 @@ One use case each, with a full program that you can run:
214
273
  - [ADR 0004](./docs/adr/0004-a-changed-declaration-makes-the-table-again.md): a changed declaration makes the table again.
215
274
  - [ADR 0005](./docs/adr/0005-each-checkpointed-copy-has-its-own-lock.md): each checkpointed copy has its own lock.
216
275
  - [ADR 0006](./docs/adr/0006-a-sole-writer-fails-the-passes-it-finds-unfinished.md): a sole writer fails the passes that it finds unfinished.
276
+ - [ADR 0007](./docs/adr/0007-each-pass-tries-an-input-at-most-once.md): each pass tries an input at most once.
277
+ - [ADR 0008](./docs/adr/0008-a-file-that-several-connections-load-shows-one-pass.md): a file that several connections load shows one pass.
278
+ - [ADR 0009](./docs/adr/0009-the-sqlite-file-numbers-its-commits-and-keeps-a-history-of-each-row.md): the SQLite file numbers its commits and keeps a history of each row.
217
279
 
218
280
  ## Development
219
281
 
@@ -231,7 +293,10 @@ The files of the destination:
231
293
  src/
232
294
  sqlite-destination.ts SQLiteDestination: tables and the load
233
295
  open-destination.ts openDestination: the open of each connection that writes the file
234
- sqlite-writer.ts the stage, the commit, evolve and reload of a table
296
+ sqlite-writer.ts the stage, the commit, evolve, reload and the difference of a table
297
+ sqlite-loads.ts SQLiteLoad: the loads and commit numbers of a file, its positions and its retention
298
+ 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
235
300
  sqlite-checkpoint-store.ts SQLiteCheckpointStore
236
301
  sqlite-sync-history.ts SQLiteSyncHistory
237
302
  sqlite-catalog.ts installSQLiteCatalog
@@ -246,4 +311,5 @@ src/
246
311
  sqlite-views.ts publishSQLiteViews: makes described views and checks their columns
247
312
  view.test.ts the tests of View
248
313
  computed-dataset.test.ts the tests of ComputedDataset, and of the refreshes of a run and a watch
314
+ computed-as-of.test.ts the tests of the commits, the positions and the history of a refresh
249
315
  ```
@@ -1,5 +1,5 @@
1
1
  import type { SQLInputValue } from 'node:sqlite';
2
- import { type Connection, DerivedDataset, type RefreshTrigger, type Rule } from '@eltjs/elt';
2
+ import { type Connection, DerivedDataset, type PipelineClock, type RefreshTrigger, type Rule } from '@eltjs/elt';
3
3
  import { SQLiteDestination } from './sqlite-destination.ts';
4
4
  import type { SQLiteTable } from './sqlite-table.ts';
5
5
  import { View } from './view.ts';
@@ -14,8 +14,9 @@ export declare class ComputedDataset<Input, Output extends SQLInputValue> extend
14
14
  compute: Rule<Input, Output>;
15
15
  });
16
16
  validate(connections: readonly Connection<SQLiteTable>[]): void;
17
- refresh({ connections, signal, trigger, }: {
17
+ refresh({ connections, clock, signal, trigger, }: {
18
18
  readonly connections: readonly Connection<SQLiteTable>[];
19
+ readonly clock: PipelineClock;
19
20
  readonly signal?: AbortSignal;
20
21
  readonly trigger: RefreshTrigger;
21
22
  }): Promise<void>;
@@ -3,8 +3,11 @@ import { Declaration } from '@zukhruf/sqlite-drift';
3
3
  import { open } from '@zukhruf/sqlite-open';
4
4
  import { transaction } from '@zukhruf/sqlite-transaction';
5
5
  import { DerivedDataset, } from '@eltjs/elt';
6
- import { busyTimeout, openDestination } from './open-destination.js';
6
+ import { busyTimeout } from './open-destination.js';
7
7
  import { SQLiteDestination } from './sqlite-destination.js';
8
+ import { SQLiteHistory, rowHash } from './sqlite-history.js';
9
+ import { identifiers } from './sqlite-identifiers.js';
10
+ import { SQLiteLoad } from './sqlite-loads.js';
8
11
  import { publishSQLiteViews } from './sqlite-views.js';
9
12
  import { View } from './view.js';
10
13
  const quote = (name) => `"${name.replaceAll('"', '""')}"`;
@@ -25,6 +28,10 @@ const engineColumns = {
25
28
  // A rule's failure on one input is that row's; it never fails the load.
26
29
  // Readers query the view name, over the table raw_<name>, which a refresh
27
30
  // replaces when its declaration changes, since it is rebuilt from the inputs.
31
+ // A refresh is a load of the file: each of its transactions that changes a
32
+ // row is a numbered commit, each row names the commit that last wrote it,
33
+ // and the history keeps each row a commit replaced or removed, so the view
34
+ // reads as of a position as the copies' tables do.
28
35
  export class ComputedDataset extends DerivedDataset {
29
36
  name;
30
37
  #into;
@@ -33,6 +40,7 @@ export class ComputedDataset extends DerivedDataset {
33
40
  #rule;
34
41
  #table;
35
42
  #declaration;
43
+ #history;
36
44
  // Every column of an output row, in table order.
37
45
  #columns;
38
46
  constructor({ into, name, description, from, compute, }) {
@@ -43,6 +51,10 @@ export class ComputedDataset extends DerivedDataset {
43
51
  this.#from = Object.freeze([...from]);
44
52
  this.#rule = compute;
45
53
  this.#table = `raw_${name}`;
54
+ this.#history = new SQLiteHistory({
55
+ name: this.#table,
56
+ location: identifiers.key(this.#table),
57
+ });
46
58
  this.#columns = Object.freeze([
47
59
  compute.key.name,
48
60
  ...Object.keys(engineColumns),
@@ -57,6 +69,8 @@ export class ComputedDataset extends DerivedDataset {
57
69
  `"status" TEXT NOT NULL CHECK ("status" IN ('computed', 'empty', 'failed'))`,
58
70
  '"error" TEXT',
59
71
  ...ruleColumns,
72
+ '"committed_in" INTEGER NOT NULL',
73
+ '"_elt_row_hash" TEXT NOT NULL',
60
74
  ].join(', ')}) STRICT`,
61
75
  ], { rows: 'replace' });
62
76
  Object.freeze(this);
@@ -76,12 +90,35 @@ export class ComputedDataset extends DerivedDataset {
76
90
  if (made.includes(name.toLowerCase()))
77
91
  throw new TypeError(`Computed dataset ${this.name} needs ${name}, which a copy of the pipeline makes`);
78
92
  }
79
- async refresh({ connections, signal, trigger, }) {
93
+ async refresh({ connections, clock, signal, trigger, }) {
80
94
  const inputs = this.#inputs(connections);
81
- using database = openDestination(this.#into.path);
95
+ if (inputs === null)
96
+ return;
97
+ // Open from the start, so it closes a refresh that a process ended; with
98
+ // no commit it is no span, and leaves the positions as they are.
99
+ using load = SQLiteLoad.open(this.#into.path, { dataset: this.name }, { clock, retention: this.#into.retention });
100
+ const { database } = load;
82
101
  const key = this.#rule.key.name;
102
+ const table = quote(this.#table);
103
+ const byKey = `FROM ${table} AS "_elt_old" WHERE "_elt_old".${quote(key)} = ?`;
104
+ // The number of a transaction's commit, taken at its first change, so a
105
+ // transaction that changes no row takes none.
106
+ const commits = () => {
107
+ let number;
108
+ return () => (number ??= load.commit());
109
+ };
110
+ const written = [...this.#columns, 'committed_in', '_elt_row_hash'].map(quote);
83
111
  const stored = transaction(database, () => {
112
+ const commit = commits();
113
+ // A new declaration replaces the table, and the history keeps the
114
+ // rows it drops.
115
+ if (this.#declaration.drift(database) === 'stale' &&
116
+ database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() !== undefined) {
117
+ this.#history.create(database, written);
118
+ this.#history.keep(database, commit(), `FROM ${table} AS "_elt_old"`);
119
+ }
84
120
  this.#declaration.apply(database);
121
+ this.#history.create(database, written);
85
122
  publishSQLiteViews(database, {
86
123
  views: [
87
124
  {
@@ -92,18 +129,20 @@ export class ComputedDataset extends DerivedDataset {
92
129
  ...engineColumns,
93
130
  ...Object.fromEntries(Object.entries(this.#rule.output).map(([column, { description }]) => [column, description])),
94
131
  },
95
- query: `SELECT ${this.#columns.map(quote).join(', ')} FROM ${quote(this.#table)}`,
132
+ query: `SELECT ${this.#columns.map(quote).join(', ')} FROM ${table}`,
96
133
  },
97
134
  ],
98
135
  });
99
136
  const rows = new Map();
100
- const remove = database.prepare(`DELETE FROM ${quote(this.#table)} WHERE ${quote(key)} = ?`);
137
+ const remove = database.prepare(`DELETE FROM ${table} WHERE ${quote(key)} = ?`);
101
138
  for (const row of database
102
- .prepare(`SELECT ${quote(key)} AS "key", "input_hash", "rule", "status" FROM ${quote(this.#table)}`)
139
+ .prepare(`SELECT ${quote(key)} AS "key", "input_hash", "rule", "status" FROM ${table}`)
103
140
  .all()) {
104
141
  const id = String(row.key);
105
- if (!inputs.has(id))
142
+ if (!inputs.has(id)) {
143
+ this.#history.keep(database, commit(), byKey, [id]);
106
144
  remove.run(id);
145
+ }
107
146
  else
108
147
  rows.set(id, {
109
148
  inputHash: String(row.input_hash),
@@ -113,8 +152,33 @@ export class ComputedDataset extends DerivedDataset {
113
152
  }
114
153
  return rows;
115
154
  }, { mode: 'immediate' });
116
- const write = database.prepare(`INSERT OR REPLACE INTO ${quote(this.#table)} (${this.#columns.map(quote).join(', ')}) VALUES (${this.#columns.map(() => '?').join(', ')})`);
117
- const store = (id, inputHash, rule, status, error, values) => write.run(id, inputHash, rule, status, error, ...Object.keys(this.#rule.output).map((column) => values[column] ?? null));
155
+ const version = database.prepare(`SELECT "_elt_row_hash" AS "hash" FROM ${table} WHERE ${quote(key)} = ?`);
156
+ const write = database.prepare(`INSERT INTO ${table} (${written.join(', ')}) VALUES (${written.map(() => '?').join(', ')}) ON CONFLICT (${quote(key)}) DO UPDATE SET ${written
157
+ .slice(1)
158
+ .map((column) => `${column} = excluded.${column}`)
159
+ .join(', ')}`);
160
+ const hashed = this.#columns.map((name) => ({ name }));
161
+ // Writes a row in the commit that commit numbers, unless the stored row
162
+ // has the same values: then it keeps its commit. The history keeps the
163
+ // row it replaces.
164
+ const store = (commit, id, inputHash, rule, status, error, values) => {
165
+ const row = [
166
+ id,
167
+ inputHash,
168
+ rule,
169
+ status,
170
+ error,
171
+ ...Object.keys(this.#rule.output).map((column) => values[column] ?? null),
172
+ ];
173
+ const hash = rowHash(hashed, row);
174
+ const before = version.get(id)?.hash;
175
+ if (before === hash)
176
+ return;
177
+ const number = commit();
178
+ if (before !== undefined)
179
+ this.#history.keep(database, number, byKey, [id]);
180
+ write.run(...row, number, hash);
181
+ };
118
182
  const due = [];
119
183
  for (const [id, input] of inputs) {
120
184
  if (signal?.aborted)
@@ -128,7 +192,7 @@ export class ComputedDataset extends DerivedDataset {
128
192
  continue;
129
193
  if (before?.rule === 'unsupported' && before.inputHash === inputHash)
130
194
  continue;
131
- store(id, inputHash, 'unsupported', 'empty', null, {});
195
+ transaction(database, () => store(commits(), id, inputHash, 'unsupported', 'empty', null, {}), { mode: 'immediate' });
132
196
  continue;
133
197
  }
134
198
  if (before !== undefined &&
@@ -168,16 +232,17 @@ export class ComputedDataset extends DerivedDataset {
168
232
  }
169
233
  // Each call commits on its own, so readers see rows as they land.
170
234
  transaction(database, () => {
235
+ const commit = commits();
171
236
  batch.forEach(({ id, inputHash, rule }, index) => {
172
237
  const result = results[index];
173
238
  if (result === undefined || 'gone' in result)
174
239
  return;
175
240
  if ('value' in result)
176
- store(id, inputHash, rule, 'computed', null, result.value);
241
+ store(commit, id, inputHash, rule, 'computed', null, result.value);
177
242
  else if ('error' in result)
178
- store(id, inputHash, rule, 'failed', result.error, {});
243
+ store(commit, id, inputHash, rule, 'failed', result.error, {});
179
244
  else
180
- store(id, inputHash, rule, 'empty', null, {});
245
+ store(commit, id, inputHash, rule, 'empty', null, {});
181
246
  });
182
247
  }, { mode: 'immediate' });
183
248
  }
@@ -188,6 +253,10 @@ export class ComputedDataset extends DerivedDataset {
188
253
  // Each input by key, from the rows of each relation of from that exists
189
254
  // yet: what the copies loaded, or what a view before this dataset gives. A
190
255
  // key read twice keeps its first input.
256
+ // null while a relation reads a table that is gone, such as one a person
257
+ // dropped by hand: its copy loads it again, and the dataset waits with what it
258
+ // made, as a view waits. Read as no rows, it would delete every output, and the
259
+ // reload would pay the rule for each again.
191
260
  #inputs(connections) {
192
261
  const inputs = new Map();
193
262
  for (const target of this.#from) {
@@ -204,9 +273,16 @@ export class ComputedDataset extends DerivedDataset {
204
273
  .prepare('SELECT 1 FROM sqlite_schema WHERE lower("name") = lower(?)')
205
274
  .get(relation) === undefined)
206
275
  continue;
207
- for (const row of database
208
- .prepare(`SELECT * FROM ${quote(relation)}`)
209
- .all()) {
276
+ let rows;
277
+ try {
278
+ rows = database.prepare(`SELECT * FROM ${quote(relation)}`).all();
279
+ }
280
+ catch (error) {
281
+ if (Error.isError(error) && error.message.startsWith('no such table: '))
282
+ return null;
283
+ throw error;
284
+ }
285
+ for (const row of rows) {
210
286
  const read = this.#rule.read(row);
211
287
  if (read !== null && !inputs.has(read.key))
212
288
  inputs.set(read.key, read.input);
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ 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
9
  export { type PassState, type PassStatus, readPassStatus, type StreamPassStatus, } from './sqlite-passes.ts';
9
10
  export { SQLiteSyncHistory } from './sqlite-sync-history.ts';
10
11
  export { SQLiteTable } from './sqlite-table.ts';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ 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
9
  export { readPassStatus, } from './sqlite-passes.js';
9
10
  export { SQLiteSyncHistory } from './sqlite-sync-history.js';
10
11
  export { SQLiteTable } from './sqlite-table.js';
@@ -1,8 +1,9 @@
1
1
  import type { DatabaseSync } from 'node:sqlite';
2
2
  import type { CopyConfiguration } from '@eltjs/elt';
3
+ import type { SQLiteDestination } from './sqlite-destination.ts';
3
4
  import type { SQLiteTable } from './sqlite-table.ts';
4
- import { SQLiteWriter } from './sqlite-writer.ts';
5
+ import { SQLiteWriter, type Stamp } from './sqlite-writer.ts';
5
6
  export declare class SQLiteAppendWriter extends SQLiteWriter {
6
- constructor(configuration: CopyConfiguration, path: string, table: SQLiteTable);
7
- protected merge(database: DatabaseSync, stage: string, loadedAt: string, into: string): void;
7
+ constructor(configuration: CopyConfiguration, destination: SQLiteDestination, table: SQLiteTable);
8
+ protected merge(database: DatabaseSync, stage: string, stamp: Stamp, into: string): void;
8
9
  }
@@ -1,10 +1,10 @@
1
1
  import { SQLiteWriter } from './sqlite-writer.js';
2
2
  export class SQLiteAppendWriter extends SQLiteWriter {
3
- constructor(configuration, path, table) {
4
- super(configuration, path, table);
3
+ constructor(configuration, destination, table) {
4
+ super(configuration, destination, table);
5
5
  Object.freeze(this);
6
6
  }
7
- merge(database, stage, loadedAt, into) {
8
- this.append(database, stage, loadedAt, into);
7
+ merge(database, stage, stamp, into) {
8
+ this.append(database, stage, stamp, into);
9
9
  }
10
10
  }
@@ -0,0 +1,29 @@
1
+ import { type DatabaseSync } from 'node:sqlite';
2
+ /**
3
+ * The SQLite file as of one position: `sql` reads in one read transaction,
4
+ * and sees each target as it was at that position. Dispose it to close it.
5
+ */
6
+ export type SQLiteReading = {
7
+ readonly sql: DatabaseSync;
8
+ readonly position: string;
9
+ readonly at: Temporal.Instant;
10
+ } & Disposable;
11
+ export declare function reader(path: string): DatabaseSync | null;
12
+ export declare function readAsOf(path: string, position: string | undefined): SQLiteReading | {
13
+ readonly expired: string;
14
+ };
15
+ /**
16
+ * The SELECT that reads one target of the file as of a position, for a reader
17
+ * that copies the target, such as into a database of its own: the current
18
+ * rows that the commit of the position had made, and the history rows that it
19
+ * had made and a later commit replaced. `schema` names the file where
20
+ * `database` attached it, main by default. Run the SELECT in the read
21
+ * transaction that this checks the position in, since a later commit can
22
+ * expire the position. A position of another epoch, past the newest commit,
23
+ * or older than the retention, is expired.
24
+ */
25
+ export declare function asOfSelect(database: DatabaseSync, target: string, position: string, { schema }?: {
26
+ readonly schema?: string;
27
+ }): string | {
28
+ readonly expired: string;
29
+ };