@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 +75 -9
- package/dist/computed-dataset.d.ts +3 -2
- package/dist/computed-dataset.js +92 -16
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sqlite-append-writer.d.ts +4 -3
- package/dist/sqlite-append-writer.js +4 -4
- package/dist/sqlite-as-of.d.ts +29 -0
- package/dist/sqlite-as-of.js +183 -0
- package/dist/sqlite-deduplicating-writer.d.ts +6 -4
- package/dist/sqlite-deduplicating-writer.js +50 -26
- package/dist/sqlite-destination.d.ts +12 -2
- package/dist/sqlite-destination.js +37 -18
- package/dist/sqlite-file-store.d.ts +5 -2
- package/dist/sqlite-file-store.js +41 -25
- package/dist/sqlite-history.d.ts +14 -0
- package/dist/sqlite-history.js +130 -0
- package/dist/sqlite-identifiers.js +9 -1
- package/dist/sqlite-loads.d.ts +38 -0
- package/dist/sqlite-loads.js +247 -0
- package/dist/sqlite-overwrite-writer.d.ts +4 -3
- package/dist/sqlite-overwrite-writer.js +4 -4
- package/dist/sqlite-table.js +1 -1
- package/dist/sqlite-writer.d.ts +19 -9
- package/dist/sqlite-writer.js +220 -72
- package/package.json +2 -2
package/dist/sqlite-writer.js
CHANGED
|
@@ -5,13 +5,18 @@ import { FileContent, TargetOwnedError, Writer, describeTarget, errorMessage, re
|
|
|
5
5
|
import { openDestination } from './open-destination.js';
|
|
6
6
|
import { describe } from './sqlite-descriptions.js';
|
|
7
7
|
import { SQLiteFileStore } from './sqlite-file-store.js';
|
|
8
|
+
import { SQLiteHistory, rowHash } from './sqlite-history.js';
|
|
8
9
|
import { identifiers } from './sqlite-identifiers.js';
|
|
10
|
+
import { SQLiteLoad } from './sqlite-loads.js';
|
|
9
11
|
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
10
12
|
const writersTable = new Declaration([
|
|
11
13
|
'CREATE TABLE "_elt_writers" ("target" TEXT PRIMARY KEY, "writer" TEXT NOT NULL) STRICT',
|
|
12
14
|
], { rows: 'replace' });
|
|
13
15
|
export const seq = '"_elt_seq"';
|
|
14
16
|
export const op = '"_elt_op"';
|
|
17
|
+
export const version = '"_elt_row_hash"';
|
|
18
|
+
// The id SQLite gives each row of a table; no column takes the name.
|
|
19
|
+
const rowid = '_rowid_';
|
|
15
20
|
// One stream's load. Operations, and the chunks of their files, wait in
|
|
16
21
|
// connection-private TEMP tables, so another stream's commit never publishes
|
|
17
22
|
// them and a crash leaves nothing behind. As in Postgres, no transaction stays
|
|
@@ -20,14 +25,17 @@ export const op = '"_elt_op"';
|
|
|
20
25
|
// operations one at a time, the first also creating the target.
|
|
21
26
|
export class SQLiteWriter extends Writer {
|
|
22
27
|
configuration;
|
|
23
|
-
|
|
28
|
+
destination;
|
|
24
29
|
table;
|
|
30
|
+
// Where the target keeps the rows that its commits replace or remove.
|
|
31
|
+
history;
|
|
25
32
|
#description;
|
|
26
|
-
constructor(configuration,
|
|
33
|
+
constructor(configuration, destination, table) {
|
|
27
34
|
super(configuration.stream);
|
|
28
35
|
this.configuration = configuration;
|
|
29
|
-
this.
|
|
36
|
+
this.destination = destination;
|
|
30
37
|
this.table = table;
|
|
38
|
+
this.history = new SQLiteHistory(table);
|
|
31
39
|
this.#description = describeTarget(configuration, table.columns, (column) => `Integer reference to the source file's original bytes. Join ${quote(SQLiteFileStore.tableName(table, column))} on file = this value and concatenate bytes in order of n. NULL when the source file is unavailable.`);
|
|
32
40
|
if (table.readerView === undefined)
|
|
33
41
|
return;
|
|
@@ -35,8 +43,10 @@ export class SQLiteWriter extends Writer {
|
|
|
35
43
|
if (missing.length > 0)
|
|
36
44
|
throw new TypeError(`Reader view ${table.readerView} needs JSON Schema descriptions for ${missing.join(', ')} of stream ${this.stream.name}`);
|
|
37
45
|
}
|
|
38
|
-
// What the target needs
|
|
39
|
-
//
|
|
46
|
+
// What the target needs before rows of its mode enter it: at its first
|
|
47
|
+
// commit, once it evolves, and once a difference removed the rows that
|
|
48
|
+
// leave. replacing: the first commit replaces the rows by difference, so
|
|
49
|
+
// the index of the mode waits until the rows that leave are gone. A mode
|
|
40
50
|
// without a key releases the index a deduplicating mode made.
|
|
41
51
|
initialize(database, _replacing) {
|
|
42
52
|
this.dropDedupIndexes(database, null);
|
|
@@ -57,28 +67,72 @@ export class SQLiteWriter extends Writer {
|
|
|
57
67
|
build(_database, _into) { }
|
|
58
68
|
adopt(_database) { }
|
|
59
69
|
// Inserts each staged record, in order.
|
|
60
|
-
append(database, stage, loadedAt, into) {
|
|
70
|
+
append(database, stage, { loadedAt, committedIn }, into) {
|
|
61
71
|
database
|
|
62
|
-
.prepare(`INSERT INTO ${into} (${this.
|
|
63
|
-
.run(loadedAt);
|
|
72
|
+
.prepare(`INSERT INTO ${into} (${this.written.join(', ')}) SELECT ${this.table.columns.map((column) => column.quotedName).join(', ')}, ?, ?, ${version} FROM ${stage} WHERE ${op} = 'R' ORDER BY ${seq}`)
|
|
73
|
+
.run(loadedAt, committedIn);
|
|
64
74
|
}
|
|
65
|
-
// An overwrite replaces the target at its first commit.
|
|
75
|
+
// An overwrite replaces the rows of the target at its first commit.
|
|
66
76
|
get replaces() {
|
|
67
77
|
return false;
|
|
68
78
|
}
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
// Which rows of the stage a merge into an empty table keeps, as a
|
|
80
|
+
// condition on them.
|
|
81
|
+
kept(_stage) {
|
|
82
|
+
return `${op} = 'R'`;
|
|
83
|
+
}
|
|
84
|
+
// The ids of the rows of left that no row of right matches, as multisets:
|
|
85
|
+
// the k-th copy of some values on one side matches the k-th copy of the
|
|
86
|
+
// same values on the other, so no key is needed.
|
|
87
|
+
#unmatched(left, right) {
|
|
88
|
+
const numbered = ({ table, id, rows }) => `SELECT ${id} AS "row", ${version} AS "hash", row_number() OVER (PARTITION BY ${version} ORDER BY ${id}) AS "copy" FROM ${table}${rows === undefined ? '' : ` WHERE ${rows}`}`;
|
|
89
|
+
return `SELECT "mine"."row" FROM (${numbered(left)}) AS "mine" LEFT JOIN (${numbered(right)}) AS "theirs" ON "theirs"."hash" = "mine"."hash" AND "theirs"."copy" = "mine"."copy" WHERE "theirs"."row" IS NULL`;
|
|
90
|
+
}
|
|
91
|
+
get #target() {
|
|
92
|
+
return { table: this.table.quotedName, id: rowid };
|
|
93
|
+
}
|
|
94
|
+
// The first half of replacing the rows of the target with the rows of
|
|
95
|
+
// next: the history keeps each row of the target that no row of next
|
|
96
|
+
// matches, and the row goes. A row with a match stays as it is, with its
|
|
97
|
+
// commit and its loaded_at. What remains matches rows of next, so it fits
|
|
98
|
+
// the index of the mode, which the rows that enter then fit too.
|
|
99
|
+
#removeUnmatched(database, next, committedIn) {
|
|
100
|
+
const { table, id } = this.#target;
|
|
101
|
+
const leaving = this.#unmatched(this.#target, next);
|
|
102
|
+
this.history.keep(database, committedIn, `FROM ${table} AS "_elt_old" WHERE "_elt_old".${id} IN (${leaving})`);
|
|
103
|
+
database.exec(`DELETE FROM ${table} WHERE ${id} IN (${leaving})`);
|
|
104
|
+
this.initialize(database, false);
|
|
105
|
+
}
|
|
106
|
+
// Replaces the rows of the target with the records that the stage keeps,
|
|
107
|
+
// by difference: each record that no row matches enters with this commit.
|
|
108
|
+
#replace(database, stage, { loadedAt, committedIn }) {
|
|
109
|
+
const staged = { table: stage, id: seq, rows: this.kept(stage) };
|
|
110
|
+
this.#removeUnmatched(database, staged, committedIn);
|
|
111
|
+
database
|
|
112
|
+
.prepare(`INSERT INTO ${this.table.quotedName} (${this.written.join(', ')}) SELECT ${this.table.columns.map((column) => column.quotedName).join(', ')}, ?, ?, ${version} FROM ${stage} WHERE ${seq} IN (${this.#unmatched(staged, this.#target)}) ORDER BY ${seq}`)
|
|
113
|
+
.run(loadedAt, committedIn);
|
|
114
|
+
}
|
|
115
|
+
// Copies each row of the target into its history, as replaced in commit
|
|
116
|
+
// replacedIn.
|
|
117
|
+
#keepRows(database, replacedIn) {
|
|
118
|
+
this.history.keep(database, replacedIn, `FROM ${this.table.quotedName} AS "_elt_old"`);
|
|
72
119
|
}
|
|
73
120
|
get hash() {
|
|
74
121
|
return hash('sha256', this.table.location);
|
|
75
122
|
}
|
|
123
|
+
// What a reader reads of each row.
|
|
76
124
|
get fields() {
|
|
77
125
|
return [
|
|
78
126
|
...this.table.columns.map((column) => column.quotedName),
|
|
79
127
|
'"loaded_at"',
|
|
128
|
+
'"committed_in"',
|
|
80
129
|
];
|
|
81
130
|
}
|
|
131
|
+
// What a commit writes into each row: its fields, and the hash of its
|
|
132
|
+
// values.
|
|
133
|
+
get written() {
|
|
134
|
+
return [...this.fields, version];
|
|
135
|
+
}
|
|
82
136
|
encode(record) {
|
|
83
137
|
return this.table.columns.map((column) => column.encode(record));
|
|
84
138
|
}
|
|
@@ -116,29 +170,24 @@ export class SQLiteWriter extends Writer {
|
|
|
116
170
|
.prepare(`SELECT 1 FROM sqlite_schema WHERE "type" = 'table' AND lower("name") = ?`)
|
|
117
171
|
.get(location) !== undefined);
|
|
118
172
|
}
|
|
119
|
-
// The field's values
|
|
120
|
-
//
|
|
173
|
+
// The field's values that a row of the tables or of the history holds:
|
|
174
|
+
// while a reload is open, the rows of the target and of its hidden target
|
|
175
|
+
// both still refer to files.
|
|
121
176
|
values(database, tables) {
|
|
122
177
|
const { table } = this;
|
|
178
|
+
const referenced = (column) => this.#referenced(database, column, tables());
|
|
123
179
|
return async function* (field) {
|
|
124
180
|
const column = table.columns.find((column) => column.field === field);
|
|
125
181
|
if (column === undefined)
|
|
126
182
|
throw new TypeError(`Unknown target field: ${field}`);
|
|
127
|
-
for (const
|
|
128
|
-
|
|
129
|
-
if (!database
|
|
130
|
-
.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ? COLLATE NOCASE')
|
|
131
|
-
.get(name, column.name))
|
|
132
|
-
continue;
|
|
133
|
-
for (const row of database
|
|
134
|
-
.prepare(`SELECT ${column.quotedName} AS value FROM ${quote(name)}`)
|
|
135
|
-
.iterate())
|
|
136
|
-
yield row.value;
|
|
137
|
-
}
|
|
183
|
+
for (const row of database.prepare(referenced(column)).iterate())
|
|
184
|
+
yield row.value;
|
|
138
185
|
};
|
|
139
186
|
}
|
|
140
|
-
|
|
141
|
-
|
|
187
|
+
// A clear that empties a table is a change readers see, so it is a load of
|
|
188
|
+
// one numbered commit, and the history keeps the rows it removes.
|
|
189
|
+
async clear({ writer, clock, connection }, committed) {
|
|
190
|
+
using database = openDestination(this.destination.path);
|
|
142
191
|
database.exec('BEGIN IMMEDIATE');
|
|
143
192
|
try {
|
|
144
193
|
this.writers(database);
|
|
@@ -148,9 +197,13 @@ export class SQLiteWriter extends Writer {
|
|
|
148
197
|
if (owner !== undefined && owner !== writer)
|
|
149
198
|
throw new TargetOwnedError(this.table.name, String(owner), writer);
|
|
150
199
|
// Emptied, not dropped: views built on the table keep working. With no
|
|
151
|
-
// rows left,
|
|
152
|
-
// stream left behind that the file triggers never see.
|
|
200
|
+
// rows left, only the history refers to stored files.
|
|
153
201
|
if (this.exists(database, this.table.location)) {
|
|
202
|
+
this.history.create(database, this.written);
|
|
203
|
+
this.#keepRows(database, SQLiteLoad.once(database, connection, {
|
|
204
|
+
clock,
|
|
205
|
+
retention: this.destination.retention,
|
|
206
|
+
}));
|
|
154
207
|
database.exec(`DELETE FROM ${this.table.quotedName}`);
|
|
155
208
|
// Clearing a copy also abandons the reload it left open.
|
|
156
209
|
database.exec(`DROP TABLE IF EXISTS ${quote(this.#hiddenName)}`);
|
|
@@ -158,7 +211,7 @@ export class SQLiteWriter extends Writer {
|
|
|
158
211
|
for (const column of this.table.columns) {
|
|
159
212
|
const chunks = SQLiteFileStore.tableName(this.table, column);
|
|
160
213
|
if (column.storesFile && this.exists(database, chunks))
|
|
161
|
-
database.exec(`DELETE FROM ${quote(chunks)}`);
|
|
214
|
+
database.exec(`DELETE FROM ${quote(chunks)} WHERE "file" NOT IN (${this.#referenced(database, column, [])})`);
|
|
162
215
|
}
|
|
163
216
|
}
|
|
164
217
|
database
|
|
@@ -181,8 +234,13 @@ export class SQLiteWriter extends Writer {
|
|
|
181
234
|
const dataTypes = new Map([
|
|
182
235
|
...this.table.columns.map(({ name, dataType }) => [name, dataType]),
|
|
183
236
|
['loaded_at', 'timestamp'],
|
|
237
|
+
['committed_in', 'integer'],
|
|
184
238
|
]);
|
|
185
|
-
const columns = Object.fromEntries(Object.entries(
|
|
239
|
+
const columns = Object.fromEntries(Object.entries({
|
|
240
|
+
...this.#description.columns,
|
|
241
|
+
loaded_at: 'Start time of the load that wrote these values of the row. A deduplicating load, an overwrite or a reload that brings the same values leaves the row, and its loaded_at, as they are. Not the source modification time or the most recent successful sync.',
|
|
242
|
+
committed_in: 'Number of the commit that last wrote this row: the row of _elt_commits with this number tells when, and in which load.',
|
|
243
|
+
}).map(([name, description]) => [
|
|
186
244
|
name,
|
|
187
245
|
{ description, dataType: dataTypes.get(name) },
|
|
188
246
|
]));
|
|
@@ -219,11 +277,12 @@ export class SQLiteWriter extends Writer {
|
|
|
219
277
|
.get(view) === undefined)
|
|
220
278
|
database.exec(this.#viewDefinition(view));
|
|
221
279
|
}
|
|
222
|
-
// The target as its first commit makes it: its owner, the table
|
|
223
|
-
// index its mode owns, its reader view and its descriptions.
|
|
280
|
+
// The target as its first commit makes it: its owner, the table, its
|
|
281
|
+
// history, the index its mode owns, its reader view and its descriptions.
|
|
224
282
|
#create(database, writer, replacing) {
|
|
225
283
|
this.own(database, writer);
|
|
226
284
|
database.exec(this.table.createTableSQL);
|
|
285
|
+
this.history.create(database, this.written);
|
|
227
286
|
this.initialize(database, replacing);
|
|
228
287
|
if (this.table.readerView !== undefined)
|
|
229
288
|
this.installReaderView(database, this.table.readerView);
|
|
@@ -255,9 +314,27 @@ export class SQLiteWriter extends Writer {
|
|
|
255
314
|
#declaration(name) {
|
|
256
315
|
return new Declaration([`CREATE TABLE ${this.table.definition(quote(name))}`], { rows: 'keep' });
|
|
257
316
|
}
|
|
317
|
+
// Every stored file of the column that a row of the tables or of the
|
|
318
|
+
// history refers to, as a SELECT of "value". A row of the history keeps its
|
|
319
|
+
// file for a reading as of a position before the commit that replaced the
|
|
320
|
+
// row. A table without the column refers to none of its files: the target
|
|
321
|
+
// while a reload adds the column, a history that has kept no row since, or
|
|
322
|
+
// a target that a clear finds never loaded.
|
|
323
|
+
#referenced(database, column, tables) {
|
|
324
|
+
const selects = [...tables, this.history.name]
|
|
325
|
+
.filter((table) => database
|
|
326
|
+
.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ? COLLATE NOCASE')
|
|
327
|
+
.get(table, column.name) !== undefined)
|
|
328
|
+
.map((table) => `SELECT ${column.quotedName} AS "value" FROM ${quote(table)} WHERE ${column.quotedName} IS NOT NULL`);
|
|
329
|
+
// With no such table yet, no row refers to a file: a query of no rows.
|
|
330
|
+
return selects.length === 0
|
|
331
|
+
? 'SELECT NULL AS "value" WHERE 0'
|
|
332
|
+
: selects.join(' UNION ');
|
|
333
|
+
}
|
|
258
334
|
// Drops the chunks of every stored column that is no longer one of the
|
|
259
|
-
// target's file columns,
|
|
260
|
-
//
|
|
335
|
+
// target's file columns, before the table is replaced: no row of the table
|
|
336
|
+
// that takes its place refers to them, and a reading as of a position reads
|
|
337
|
+
// only the target's columns.
|
|
261
338
|
#dropRetiredChunks(database) {
|
|
262
339
|
const files = new Set(this.table.columns
|
|
263
340
|
.filter(({ storesFile }) => storesFile)
|
|
@@ -270,10 +347,37 @@ export class SQLiteWriter extends Writer {
|
|
|
270
347
|
name: String(name),
|
|
271
348
|
});
|
|
272
349
|
database.exec(`DROP TABLE IF EXISTS ${quote(chunks)}`);
|
|
273
|
-
for (const trigger of ['delete', 'update'])
|
|
274
|
-
database.exec(`DROP TRIGGER IF EXISTS ${quote(`${chunks}_${trigger}`)}`);
|
|
275
350
|
}
|
|
276
351
|
}
|
|
352
|
+
// Keeps in the history, in a commit of its own, each row with a value in a
|
|
353
|
+
// stored column that the new shape drops or stores as another type: the
|
|
354
|
+
// row loses or changes the value, so it is a new version. Its row hash
|
|
355
|
+
// counted the old value, so evolve sets the hash to NULL, which matches no
|
|
356
|
+
// record until a commit writes the row again. A column that the shape adds
|
|
357
|
+
// is NULL in each row, which a row hash does not count, so it makes no new
|
|
358
|
+
// version. Gives the number of the commit, if any.
|
|
359
|
+
#keepChanged(database, load) {
|
|
360
|
+
const declared = new Map([
|
|
361
|
+
...this.table.columns.map((column) => [identifiers.key(column.name), column.storageType]),
|
|
362
|
+
['loaded_at', 'TEXT'],
|
|
363
|
+
['committed_in', 'INTEGER'],
|
|
364
|
+
['_elt_row_hash', 'TEXT'],
|
|
365
|
+
]);
|
|
366
|
+
const changed = database
|
|
367
|
+
.prepare('SELECT "name", "type" FROM pragma_table_info(?)')
|
|
368
|
+
.all(this.table.name)
|
|
369
|
+
.filter(({ name, type }) => declared.get(identifiers.key(String(name))) !== String(type))
|
|
370
|
+
.map(({ name }) => `${quote(String(name))} IS NOT NULL`);
|
|
371
|
+
if (changed.length === 0)
|
|
372
|
+
return undefined;
|
|
373
|
+
const rows = changed.join(' OR ');
|
|
374
|
+
const committedIn = load.commit();
|
|
375
|
+
this.history.keep(database, committedIn, `FROM ${this.table.quotedName} AS "_elt_old" WHERE ${rows}`);
|
|
376
|
+
database
|
|
377
|
+
.prepare(`UPDATE ${this.table.quotedName} SET "committed_in" = ?, "loaded_at" = ? WHERE ${rows}`)
|
|
378
|
+
.run(committedIn, load.loadedAt);
|
|
379
|
+
return committedIn;
|
|
380
|
+
}
|
|
277
381
|
// Brings a stored table the stream no longer fits to its shape, keeping
|
|
278
382
|
// every row: drift rebuilds it from the new definition and copies the
|
|
279
383
|
// columns both have, by name, a column the stream added taking its default
|
|
@@ -281,10 +385,11 @@ export class SQLiteWriter extends Writer {
|
|
|
281
385
|
// fails the load, naming the column, and the transaction leaves the table as
|
|
282
386
|
// it was. The reader view selects the stored columns, and the mode index
|
|
283
387
|
// can cover a dropped key, so both are made again for the new shape.
|
|
284
|
-
#evolve(database) {
|
|
388
|
+
#evolve(database, load) {
|
|
285
389
|
if (this.table.readerView !== undefined)
|
|
286
390
|
database.exec(`DROP VIEW IF EXISTS ${quote(this.table.readerView)}`);
|
|
287
391
|
this.dropDedupIndexes(database, null);
|
|
392
|
+
const changedIn = this.#keepChanged(database, load);
|
|
288
393
|
this.#dropRetiredChunks(database);
|
|
289
394
|
try {
|
|
290
395
|
this.#declaration(this.table.name).apply(database);
|
|
@@ -292,19 +397,50 @@ export class SQLiteWriter extends Writer {
|
|
|
292
397
|
catch (cause) {
|
|
293
398
|
throw new TypeError(`The rows stored in ${this.table.quotedName} do not fit the new shape of stream ${this.stream.name}: ${errorMessage(cause)}. Clear the copy to load it again, or change the stream so they fit.`, { cause });
|
|
294
399
|
}
|
|
400
|
+
if (changedIn !== undefined)
|
|
401
|
+
database
|
|
402
|
+
.prepare(`UPDATE ${this.table.quotedName} SET ${version} = NULL WHERE "committed_in" = ?`)
|
|
403
|
+
.run(changedIn);
|
|
295
404
|
this.initialize(database, false);
|
|
296
405
|
if (this.table.readerView !== undefined)
|
|
297
406
|
this.installReaderView(database, this.table.readerView);
|
|
298
407
|
this.describe(database);
|
|
299
408
|
}
|
|
409
|
+
// Replaces the rows of the target with the rows of the hidden target, by
|
|
410
|
+
// difference, in place: the table keeps its indexes, triggers and reader
|
|
411
|
+
// view. Each row of the hidden target that no row matches enters with this
|
|
412
|
+
// commit, and the loaded_at of the load that wrote it.
|
|
413
|
+
#reloadInPlace(database, hidden, committedIn) {
|
|
414
|
+
const reloaded = { table: hidden, id: rowid };
|
|
415
|
+
this.#removeUnmatched(database, reloaded, committedIn);
|
|
416
|
+
database
|
|
417
|
+
.prepare(`INSERT INTO ${this.table.quotedName} (${this.written.join(', ')}) SELECT ${this.table.columns.map((column) => column.quotedName).join(', ')}, "loaded_at", ?, ${version} FROM ${hidden} WHERE ${rowid} IN (${this.#unmatched(reloaded, this.#target)}) ORDER BY ${rowid}`)
|
|
418
|
+
.run(committedIn);
|
|
419
|
+
database.exec(`DROP TABLE ${hidden}`);
|
|
420
|
+
}
|
|
421
|
+
// Replaces the target with the hidden target, whose rows readers see from
|
|
422
|
+
// this commit on, once the history keeps every row of the target.
|
|
423
|
+
#swap(database, hidden, committedIn) {
|
|
424
|
+
database
|
|
425
|
+
.prepare(`UPDATE ${hidden} SET "committed_in" = ?`)
|
|
426
|
+
.run(committedIn);
|
|
427
|
+
if (this.table.readerView !== undefined)
|
|
428
|
+
database.exec(`DROP VIEW IF EXISTS ${quote(this.table.readerView)}`);
|
|
429
|
+
if (this.exists(database, this.table.location))
|
|
430
|
+
this.#keepRows(database, committedIn);
|
|
431
|
+
database.exec(`DROP TABLE IF EXISTS ${this.table.quotedName}`);
|
|
432
|
+
database.exec(`ALTER TABLE ${hidden} RENAME TO ${this.table.quotedName}`);
|
|
433
|
+
this.adopt(database);
|
|
434
|
+
}
|
|
300
435
|
// Refuses, in one short transaction, a target another writer owns, a
|
|
301
436
|
// reader view not its own and stored rows it cannot keep, evolves a stored
|
|
302
437
|
// table the stream no longer fits unless the load overwrites it, and stages
|
|
303
438
|
// in TEMP. Nothing else reaches the database until a commit. A load into the
|
|
304
439
|
// target creates it, or adopts the stored one, at its first commit; a reload
|
|
305
440
|
// leaves it to readers as it is, merges into a hidden target from its first
|
|
306
|
-
// commit, and
|
|
307
|
-
prepare(
|
|
441
|
+
// commit, and gives its rows to the target at complete().
|
|
442
|
+
prepare(load, { writer, reloading }) {
|
|
443
|
+
const { database, loadedAt } = load;
|
|
308
444
|
const name = quote(`_elt_stage_${this.hash}`);
|
|
309
445
|
const stage = `temp.${name}`;
|
|
310
446
|
const hidden = quote(this.#hiddenName);
|
|
@@ -318,19 +454,12 @@ export class SQLiteWriter extends Writer {
|
|
|
318
454
|
const open = (at) => at === 'reload' || at === 'continue';
|
|
319
455
|
const into = () => (open(mode) ? hidden : this.table.quotedName);
|
|
320
456
|
const tables = () => [this.table.name, ...(open(mode) ? [this.#hiddenName] : [])].filter((table) => this.exists(database, identifiers.key(table)));
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
const referenced = (column) => {
|
|
324
|
-
const selects = tables().map((table) => `SELECT ${column.quotedName} FROM ${quote(table)} WHERE ${column.quotedName} IS NOT NULL`);
|
|
325
|
-
return selects.length === 0
|
|
326
|
-
? 'SELECT NULL WHERE 0'
|
|
327
|
-
: selects.join(' UNION ');
|
|
328
|
-
};
|
|
329
|
-
// Drops the chunks of files no row of the tables refers to.
|
|
457
|
+
// Drops the chunks of files that no row of the tables or of the history
|
|
458
|
+
// refers to.
|
|
330
459
|
const prune = () => {
|
|
331
460
|
for (const { column, store } of stores)
|
|
332
461
|
if (store.published)
|
|
333
|
-
database.exec(`DELETE FROM ${quote(store.name)} WHERE "file" NOT IN (${referenced(column)})`);
|
|
462
|
+
database.exec(`DELETE FROM ${quote(store.name)} WHERE "file" NOT IN (${this.#referenced(database, column, tables())})`);
|
|
334
463
|
};
|
|
335
464
|
mode = transaction(database, () => {
|
|
336
465
|
this.#refuse(database, writer);
|
|
@@ -347,20 +476,20 @@ export class SQLiteWriter extends Writer {
|
|
|
347
476
|
if (!open(next) && this.table.readerView !== undefined)
|
|
348
477
|
this.#refuseReaderView(database, this.table.readerView, target === 'stale');
|
|
349
478
|
if (next === 'evolve') {
|
|
350
|
-
this.#evolve(database);
|
|
479
|
+
this.#evolve(database, load);
|
|
351
480
|
next = 'load';
|
|
352
481
|
}
|
|
353
482
|
if (next === 'load' && !this.replaces)
|
|
354
483
|
this.inspect(database);
|
|
355
484
|
database.exec(`DROP TABLE IF EXISTS ${stage}`);
|
|
356
|
-
database.exec(`CREATE TEMP TABLE ${name} (${seq} INTEGER PRIMARY KEY, ${op} TEXT NOT NULL, ${this.table.columns.map((column) => `${column.quotedName} ${column.storageType}`).join(', ')})`);
|
|
485
|
+
database.exec(`CREATE TEMP TABLE ${name} (${seq} INTEGER PRIMARY KEY, ${op} TEXT NOT NULL, ${this.table.columns.map((column) => `${column.quotedName} ${column.storageType}`).join(', ')}, ${version} TEXT)`);
|
|
357
486
|
for (const { store } of stores)
|
|
358
487
|
store.stage();
|
|
359
488
|
return next;
|
|
360
489
|
}, { mode: 'immediate' });
|
|
361
490
|
const fresh = mode === 'create' || mode === 'reload';
|
|
362
491
|
const columns = this.table.columns.map((column) => column.quotedName);
|
|
363
|
-
const record = database.prepare(`INSERT INTO ${stage} (${op}, ${columns.join(', ')}) VALUES ('R', ${columns.map(() => '?').join(', ')})`);
|
|
492
|
+
const record = database.prepare(`INSERT INTO ${stage} (${op}, ${columns.join(', ')}, ${version}) VALUES ('R', ${columns.map(() => '?').join(', ')}, ?)`);
|
|
364
493
|
const staged = (column) => `SELECT ${column.quotedName} FROM ${stage} WHERE ${column.quotedName} IS NOT NULL`;
|
|
365
494
|
// Partitions the stage dropped, which the next commit empties in the
|
|
366
495
|
// table it merges into.
|
|
@@ -369,7 +498,7 @@ export class SQLiteWriter extends Writer {
|
|
|
369
498
|
const drop = () => {
|
|
370
499
|
for (const { column, store } of stores) {
|
|
371
500
|
if (store.published)
|
|
372
|
-
database.exec(`DELETE FROM ${quote(store.name)} WHERE "file" IN (${staged(column)}) AND "file" NOT IN (${referenced(column)})`);
|
|
501
|
+
database.exec(`DELETE FROM ${quote(store.name)} WHERE "file" IN (${staged(column)}) AND "file" NOT IN (${this.#referenced(database, column, tables())})`);
|
|
373
502
|
store.discard();
|
|
374
503
|
}
|
|
375
504
|
database.exec(`DELETE FROM ${stage}`);
|
|
@@ -377,16 +506,17 @@ export class SQLiteWriter extends Writer {
|
|
|
377
506
|
};
|
|
378
507
|
// The first commit claims the target: it owns the hidden target of a
|
|
379
508
|
// reload, or creates or adopts the table. Later commits find it claimed.
|
|
509
|
+
// Returns whether the commit replaces the rows of the target with the
|
|
510
|
+
// stage's.
|
|
380
511
|
const claimTarget = () => {
|
|
381
512
|
if (open(mode)) {
|
|
382
513
|
this.own(database, writer);
|
|
383
|
-
return;
|
|
514
|
+
return false;
|
|
384
515
|
}
|
|
385
516
|
this.#create(database, writer, this.replaces);
|
|
386
|
-
|
|
387
|
-
this.replace(database);
|
|
517
|
+
return this.replaces;
|
|
388
518
|
};
|
|
389
|
-
const claimed = () =>
|
|
519
|
+
const claimed = () => false;
|
|
390
520
|
let claim = claimTarget;
|
|
391
521
|
return {
|
|
392
522
|
fresh,
|
|
@@ -415,35 +545,48 @@ export class SQLiteWriter extends Writer {
|
|
|
415
545
|
.run(...values);
|
|
416
546
|
return;
|
|
417
547
|
}
|
|
418
|
-
// Each file is read here, before the source advances past it.
|
|
548
|
+
// Each file is read here, before the source advances past it. The
|
|
549
|
+
// row keeps its id, and the row hash its content: each load gives a
|
|
550
|
+
// file a new id, so a row whose file is the same matches.
|
|
419
551
|
let data = operation.data;
|
|
552
|
+
const contents = new Map();
|
|
420
553
|
for (const { column, store } of stores) {
|
|
421
554
|
const content = Reflect.get(Object(data), column.field);
|
|
422
|
-
if (content instanceof FileContent)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
555
|
+
if (content instanceof FileContent) {
|
|
556
|
+
const { id, sha256 } = await store.save(content);
|
|
557
|
+
data = { ...Object(data), [column.field]: id };
|
|
558
|
+
contents.set(column, sha256);
|
|
559
|
+
}
|
|
427
560
|
}
|
|
428
|
-
|
|
561
|
+
const values = this.encode(data);
|
|
562
|
+
const hashed = this.table.columns.map((column, index) => contents.get(column) ?? values[index] ?? null);
|
|
563
|
+
record.run(...values, rowHash(this.table.columns, hashed));
|
|
429
564
|
},
|
|
430
565
|
// Each commit is one transaction; a failed one leaves the database as
|
|
431
566
|
// the last commit left it.
|
|
432
567
|
commit: async () => {
|
|
433
568
|
transaction(database, () => {
|
|
569
|
+
const committedIn = load.commit();
|
|
434
570
|
if (mode === 'reload') {
|
|
435
571
|
database.exec(`DROP TABLE IF EXISTS ${hidden}`);
|
|
436
572
|
database.exec(`CREATE TABLE ${this.table.definition(hidden)}`);
|
|
437
573
|
this.build(database, hidden);
|
|
438
574
|
}
|
|
439
|
-
claim();
|
|
575
|
+
const replacing = claim();
|
|
576
|
+
// The rows of a hidden target were never shown, so only the
|
|
577
|
+
// target keeps the rows a commit replaces.
|
|
578
|
+
const history = open(mode) ? null : this.history;
|
|
440
579
|
for (const partition of resets) {
|
|
441
580
|
const [rows, values] = this.#scope(partition);
|
|
581
|
+
history?.keep(database, committedIn, `FROM ${into()} AS "_elt_old"${rows}`, values);
|
|
442
582
|
database.prepare(`DELETE FROM ${into()}${rows}`).run(...values);
|
|
443
583
|
}
|
|
444
584
|
for (const { store } of stores)
|
|
445
585
|
store.publish();
|
|
446
|
-
|
|
586
|
+
if (replacing)
|
|
587
|
+
this.#replace(database, stage, { loadedAt, committedIn });
|
|
588
|
+
else
|
|
589
|
+
this.merge(database, stage, { loadedAt, committedIn }, into(), history);
|
|
447
590
|
// Files of the rows a reset emptied, and of staged rows it dropped.
|
|
448
591
|
if (resets.length > 0)
|
|
449
592
|
prune();
|
|
@@ -458,12 +601,17 @@ export class SQLiteWriter extends Writer {
|
|
|
458
601
|
if (!open(mode))
|
|
459
602
|
return;
|
|
460
603
|
transaction(database, () => {
|
|
461
|
-
|
|
462
|
-
|
|
604
|
+
// Readers see the rows from this commit on, and the rows of the
|
|
605
|
+
// target it replaces up to it.
|
|
606
|
+
const committedIn = load.commit();
|
|
463
607
|
this.#dropRetiredChunks(database);
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
608
|
+
this.history.create(database, this.written);
|
|
609
|
+
// A target of another shape cannot take the rows of the hidden
|
|
610
|
+
// target in place.
|
|
611
|
+
if (this.#declaration(this.table.name).drift(database) === 'fits')
|
|
612
|
+
this.#reloadInPlace(database, hidden, committedIn);
|
|
613
|
+
else
|
|
614
|
+
this.#swap(database, hidden, committedIn);
|
|
467
615
|
if (this.table.readerView !== undefined)
|
|
468
616
|
this.installReaderView(database, this.table.readerView);
|
|
469
617
|
this.describe(database);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eltjs/elt-sqlite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The SQLite destination of elt: each stream is a raw table in one SQLite file, with a reader view over it and a catalog of each table, view and column.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"node": ">=24"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@eltjs/elt": "^0.
|
|
42
|
+
"@eltjs/elt": "^0.3.0",
|
|
43
43
|
"@zukhruf/mutex": "^0.8.10",
|
|
44
44
|
"@zukhruf/sqlite-drift": "^0.8.10",
|
|
45
45
|
"@zukhruf/sqlite-open": "^0.8.10",
|