@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.
@@ -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
- path;
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, path, table) {
33
+ constructor(configuration, destination, table) {
27
34
  super(configuration.stream);
28
35
  this.configuration = configuration;
29
- this.path = path;
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 at its first commit, before its first merge.
39
- // replacing: that commit replaces the target, so its rows are moot. A mode
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.fields.join(', ')}) SELECT ${this.table.columns.map((column) => column.quotedName).join(', ')}, ? FROM ${stage} WHERE ${op} = 'R' ORDER BY ${seq}`)
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
- // Empties the target a replacing commit is about to fill.
70
- replace(database) {
71
- database.exec(`DELETE FROM ${this.table.quotedName}`);
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 in each of the tables, which while a reload is open
120
- // are the target and its hidden target: both rows still refer to files.
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 name of tables()) {
128
- // clear also accepts a target or a declared column that was never loaded.
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
- async clear(writer, committed) {
141
- using database = openDestination(this.path);
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, no stored chunk is referenced, including those a failed
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(this.#description.columns).map(([name, description]) => [
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 and the
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, and the triggers that delete them, before the table
260
- // is replaced: no row of the table that takes its place refers to them.
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 swaps that in at complete().
307
- prepare(database, { writer, reloading }, loadedAt) {
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
- // Every stored file a row of the tables refers to.
322
- // With no table yet, no row refers to a file: a query of no rows.
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
- if (this.replaces)
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
- data = {
424
- ...Object(data),
425
- [column.field]: await store.save(content),
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
- record.run(...this.encode(data));
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
- this.merge(database, stage, loadedAt, into());
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
- if (this.table.readerView !== undefined)
462
- database.exec(`DROP VIEW IF EXISTS ${quote(this.table.readerView)}`);
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
- database.exec(`DROP TABLE IF EXISTS ${this.table.quotedName}`);
465
- database.exec(`ALTER TABLE ${hidden} RENAME TO ${this.table.quotedName}`);
466
- this.adopt(database);
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.1.3",
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.1.3",
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",