@eltjs/elt-sqlite 0.0.0-stage → 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +248 -2
  3. package/dist/computed-dataset.d.ts +22 -0
  4. package/dist/computed-dataset.js +230 -0
  5. package/dist/index.d.ts +13 -0
  6. package/dist/index.js +13 -0
  7. package/dist/open-destination.d.ts +3 -0
  8. package/dist/open-destination.js +14 -0
  9. package/dist/reserved.d.ts +7 -0
  10. package/dist/reserved.js +9 -0
  11. package/dist/sqlite-append-writer.d.ts +8 -0
  12. package/dist/sqlite-append-writer.js +10 -0
  13. package/dist/sqlite-catalog.d.ts +3 -0
  14. package/dist/sqlite-catalog.js +33 -0
  15. package/dist/sqlite-checkpoint-store.d.ts +9 -0
  16. package/dist/sqlite-checkpoint-store.js +84 -0
  17. package/dist/sqlite-column.d.ts +53 -0
  18. package/dist/sqlite-column.js +306 -0
  19. package/dist/sqlite-columns.d.ts +14 -0
  20. package/dist/sqlite-columns.js +118 -0
  21. package/dist/sqlite-deduplicating-writer.d.ts +21 -0
  22. package/dist/sqlite-deduplicating-writer.js +124 -0
  23. package/dist/sqlite-descriptions.d.ts +8 -0
  24. package/dist/sqlite-descriptions.js +37 -0
  25. package/dist/sqlite-destination.d.ts +19 -0
  26. package/dist/sqlite-destination.js +67 -0
  27. package/dist/sqlite-file-store.d.ts +17 -0
  28. package/dist/sqlite-file-store.js +82 -0
  29. package/dist/sqlite-identifiers.d.ts +8 -0
  30. package/dist/sqlite-identifiers.js +11 -0
  31. package/dist/sqlite-overwrite-writer.d.ts +9 -0
  32. package/dist/sqlite-overwrite-writer.js +13 -0
  33. package/dist/sqlite-passes.d.ts +39 -0
  34. package/dist/sqlite-passes.js +174 -0
  35. package/dist/sqlite-sync-history-schema.d.ts +8 -0
  36. package/dist/sqlite-sync-history-schema.js +94 -0
  37. package/dist/sqlite-sync-history.d.ts +12 -0
  38. package/dist/sqlite-sync-history.js +119 -0
  39. package/dist/sqlite-table.d.ts +15 -0
  40. package/dist/sqlite-table.js +83 -0
  41. package/dist/sqlite-views.d.ts +16 -0
  42. package/dist/sqlite-views.js +62 -0
  43. package/dist/sqlite-writer.d.ts +38 -0
  44. package/dist/sqlite-writer.js +483 -0
  45. package/dist/view.d.ts +20 -0
  46. package/dist/view.js +59 -0
  47. package/package.json +52 -4
@@ -0,0 +1,119 @@
1
+ import { transaction } from '@zukhruf/sqlite-transaction';
2
+ import { SyncHistory, copyStatus, errorMessage, passError, passFailureType, passStatus, recordedFailure, } from '@eltjs/elt';
3
+ import { openDestination } from './open-destination.js';
4
+ import { installSQLiteCatalog } from './sqlite-catalog.js';
5
+ import { SQLiteDestination } from './sqlite-destination.js';
6
+ import { attempts, checks, coverage, syncHistoryTables, syncHistoryViews, } from './sqlite-sync-history-schema.js';
7
+ import { publishSQLiteViews } from './sqlite-views.js';
8
+ // Records every pass in the file its connection loads, beside the data, and
9
+ // shows readers that history, and the catalog, through views there.
10
+ export class SQLiteSyncHistory extends SyncHistory {
11
+ #soleWriter;
12
+ // soleWriter: the caller knows that no other process runs a pass of its
13
+ // connections while this history is in use, such as the leader of a
14
+ // connection's flight.
15
+ constructor(options) {
16
+ super();
17
+ this.#soleWriter = options?.soleWriter === true;
18
+ Object.freeze(this);
19
+ }
20
+ validate(connection) {
21
+ this.#path(connection);
22
+ }
23
+ async recordChecks(connection, streamChecks) {
24
+ write(this.#path(connection), (database) => {
25
+ const check = database.prepare(`INSERT INTO ${checks} ("connector", "stream", "checked_at") VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET "checked_at" = excluded."checked_at"`);
26
+ for (const { stream, at } of streamChecks)
27
+ check.run(connection.name, stream, at.toISOString());
28
+ });
29
+ }
30
+ // Every selected stream is declared before reading, even if it produces no
31
+ // rows or the process dies: an unfinished attempt stays running, never success.
32
+ async begin(connection, copies, at) {
33
+ const path = this.#path(connection);
34
+ const id = write(path, (database) => {
35
+ const attempt = database
36
+ .prepare(`INSERT INTO ${attempts} ("connector", "source", "started_at") VALUES (?, ?, ?) RETURNING "id"`)
37
+ .get(connection.name, connection.source.identity, at.toISOString());
38
+ if (attempt === undefined)
39
+ throw new Error('Sync attempt was not recorded');
40
+ const id = Number(attempt.id);
41
+ const declare = database.prepare(`INSERT INTO ${coverage} ("attempt_id", "stream", "target_schema", "target_table", "sync_mode", "destination_sync_mode", "description", "selection") VALUES (?, ?, 'main', ?, ?, ?, ?, ?)`);
42
+ for (const { copy, coverage } of copies)
43
+ declare.run(id, copy.from.name, copy.to.name, copy.configuration.syncMode, copy.configuration.destinationSyncMode, coverage.description, JSON.stringify(coverage.selection));
44
+ return id;
45
+ });
46
+ return {
47
+ finish: async (outcomes, upstream, ended) => this.#finish(path, id, outcomes, upstream, ended),
48
+ fail: async (error, failureType, ended) => write(path, (database) => fail(database, id, error, failureType, ended)),
49
+ };
50
+ }
51
+ // Brings the history's tables to their declaration and publishes its views
52
+ // and the catalog in each connection's file. Tables another version
53
+ // declared otherwise are replaced, and their history with them. A sole
54
+ // writer then records every pass of its connections that began and never
55
+ // ended as failed: the process running it ended first, so no outcome will
56
+ // come, as Airbyte fails a connection's unfinished jobs before it starts the
57
+ // next. Any other caller leaves them running, since another process may
58
+ // still be running them.
59
+ async install(connections, at) {
60
+ for (const path of new Set(connections.map((each) => this.#path(each)))) {
61
+ write(path, (database) => {
62
+ syncHistoryTables.apply(database);
63
+ publishSQLiteViews(database, { views: syncHistoryViews });
64
+ });
65
+ installSQLiteCatalog({ path });
66
+ }
67
+ if (this.#soleWriter)
68
+ for (const connection of connections)
69
+ this.#failUnfinished(connection, at);
70
+ }
71
+ #failUnfinished(connection, at) {
72
+ const error = new Error('The process running this pass ended before the pass did');
73
+ write(this.#path(connection), (database) => {
74
+ const unfinished = database
75
+ .prepare(`SELECT "id" FROM ${attempts} WHERE "connector" = ? AND "status" = 'running'`)
76
+ .all(connection.name);
77
+ for (const { id } of unfinished)
78
+ fail(database, Number(id), error, 'system', at);
79
+ });
80
+ }
81
+ #path({ name, destination }) {
82
+ if (!(destination instanceof SQLiteDestination))
83
+ throw new TypeError(`Connection ${name}: SQLite sync history records SQLite destinations only`);
84
+ return persistent(destination);
85
+ }
86
+ #finish(path, id, outcomes, upstream, ended) {
87
+ write(path, (database) => {
88
+ const record = database.prepare(`UPDATE ${coverage} SET "status" = ?, "written_count" = ?, "deleted_count" = ?, "failures" = ? WHERE "attempt_id" = ? AND "stream" = ?`);
89
+ for (const outcome of outcomes)
90
+ record.run(copyStatus(outcome), outcome.count, outcome.deleted, JSON.stringify(outcome.failures.map(recordedFailure)), id, outcome.copy.from.name);
91
+ const bound = database.prepare(`UPDATE ${coverage} SET "upstream_current_as_of" = ? WHERE "attempt_id" = ? AND "stream" = ?`);
92
+ for (const [stream, at] of upstream)
93
+ bound.run(at.toISOString(), id, stream);
94
+ database
95
+ .prepare(`UPDATE ${attempts} SET "completed_at" = ?, "status" = ?, "error" = ?, "failure_type" = ? WHERE "id" = ?`)
96
+ .run(ended.toISOString(), passStatus(outcomes), passError(outcomes), passFailureType(outcomes), id);
97
+ });
98
+ }
99
+ }
100
+ // A pass that produced no outcomes, so what it committed is unknown.
101
+ function fail(database, id, error, failureType, at) {
102
+ database
103
+ .prepare(`UPDATE ${coverage} SET "status" = 'failed', "failures" = ? WHERE "attempt_id" = ?`)
104
+ .run(JSON.stringify([
105
+ recordedFailure({ partition: null, error, failureType }),
106
+ ]), id);
107
+ database
108
+ .prepare(`UPDATE ${attempts} SET "status" = 'failed', "completed_at" = ?, "error" = ?, "failure_type" = ? WHERE "id" = ?`)
109
+ .run(at.toISOString(), errorMessage(error), failureType, id);
110
+ }
111
+ function persistent({ path }) {
112
+ if (path === ':memory:')
113
+ throw new TypeError('SQLite sync history requires a destination file');
114
+ return path;
115
+ }
116
+ function write(path, work) {
117
+ using database = openDestination(path);
118
+ return transaction(database, () => work(database), { mode: 'immediate' });
119
+ }
@@ -0,0 +1,15 @@
1
+ import type { Stream } from '@eltjs/elt';
2
+ import { Target } from '@eltjs/elt';
3
+ import { SQLiteColumn } from './sqlite-column.ts';
4
+ export declare class SQLiteTable extends Target {
5
+ readonly name: string;
6
+ readonly columns: readonly SQLiteColumn[];
7
+ readonly readerView: string | undefined;
8
+ constructor(name: string, columns?: readonly SQLiteColumn[], readerView?: string);
9
+ withReaderView(name: string): SQLiteTable;
10
+ resolve(stream: Stream): SQLiteTable;
11
+ get location(): string;
12
+ get quotedName(): string;
13
+ get createTableSQL(): string;
14
+ definition(name: string): string;
15
+ }
@@ -0,0 +1,83 @@
1
+ import { Target } from '@eltjs/elt';
2
+ import { SQLiteColumn, canonical } from './sqlite-column.js';
3
+ import { SQLiteColumns } from './sqlite-columns.js';
4
+ import { identifiers } from './sqlite-identifiers.js';
5
+ // A reusable target definition. Empty columns mean infer from the Copy's
6
+ // stream. Its own name, its view's and its columns' follow the destination's
7
+ // naming rule, so a field SQLite would take for another still loads.
8
+ export class SQLiteTable extends Target {
9
+ name;
10
+ columns;
11
+ // The name readers query: a documented view of exactly this table.
12
+ readerView;
13
+ constructor(name, columns, readerView) {
14
+ if (typeof name !== 'string' ||
15
+ (readerView !== undefined && typeof readerView !== 'string'))
16
+ throw new TypeError('A table and its view are named by strings');
17
+ const table = identifiers.relation(name);
18
+ const view = readerView === undefined ? undefined : identifiers.relation(readerView);
19
+ if (view !== undefined && identifiers.key(view) === identifiers.key(table))
20
+ throw new TypeError('A reader view needs a name of its own');
21
+ if (columns !== undefined &&
22
+ (!Array.isArray(columns) ||
23
+ columns.length === 0 ||
24
+ !columns.every((column) => column instanceof SQLiteColumn)))
25
+ throw new TypeError('A table requires at least one SQLite column');
26
+ const fields = (columns ?? []).map((column) => column.field);
27
+ if (new Set(fields).size !== fields.length)
28
+ throw new TypeError('Two columns hold one field');
29
+ if ((columns ?? []).filter((column) => column.isPrimaryKey).length > 1)
30
+ throw new TypeError('Only one primary-key column is supported');
31
+ const fileReads = (columns ?? []).flatMap((column) => column.fileRead === undefined ? [] : [column.fileRead]);
32
+ for (const column of columns ?? []) {
33
+ if (column.fileRead === undefined)
34
+ continue;
35
+ if (column.fileRead.outputType === 'bytes'
36
+ ? column.kind !== 'blob'
37
+ : column.kind !== 'text')
38
+ throw new TypeError('Original files require a BLOB column; parsed text and stored references require a TEXT column');
39
+ }
40
+ super(fileReads);
41
+ this.name = table;
42
+ this.columns = Object.freeze(identifiers
43
+ .columns(columns ?? [])
44
+ .map(([column, stored]) => column.named(stored)));
45
+ this.readerView = view;
46
+ Object.freeze(this);
47
+ }
48
+ // Loads also keep a view of this table under another name, created with the
49
+ // table and described by the same descriptions, which every column then needs.
50
+ withReaderView(name) {
51
+ return new SQLiteTable(this.name, this.columns.length === 0 ? undefined : this.columns, name);
52
+ }
53
+ resolve(stream) {
54
+ if (this.columns.length === 0)
55
+ return new SQLiteTable(this.name, SQLiteColumns.fromSchema(stream.jsonSchema), this.readerView);
56
+ const { properties } = stream.jsonSchema;
57
+ // Explicit columns of a stream that declares no properties stand alone.
58
+ if (properties === undefined)
59
+ return this;
60
+ for (const column of this.columns)
61
+ if (column.fileRead === undefined &&
62
+ !Object.hasOwn(properties, column.field))
63
+ throw new TypeError(`Stream ${stream.name} does not describe field ${column.field}`);
64
+ return this;
65
+ }
66
+ // SQLite takes names that differ only in ASCII case for one, so one table
67
+ // has one location.
68
+ get location() {
69
+ return identifiers.key(this.name);
70
+ }
71
+ get quotedName() {
72
+ return `"${this.name.replaceAll('"', '""')}"`;
73
+ }
74
+ get createTableSQL() {
75
+ return `CREATE TABLE IF NOT EXISTS ${this.definition(this.quotedName)}`;
76
+ }
77
+ // A table named name with these columns, as CREATE TABLE spells it.
78
+ definition(name) {
79
+ if (this.columns.length === 0)
80
+ throw new TypeError('Resolve inferred columns before creating a table');
81
+ return `${name} (${this.columns.map((column) => column.definition).join(', ')}, "loaded_at" TEXT NOT NULL${canonical('timestamp', '"loaded_at"')}) STRICT`;
82
+ }
83
+ }
@@ -0,0 +1,16 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { ReaderRelation } from '@eltjs/elt';
3
+ export type SQLiteView = ReaderRelation & {
4
+ readonly query: string;
5
+ };
6
+ export declare function viewDefinition(view: SQLiteView): string;
7
+ /**
8
+ * Publish ordinary views and their descriptions on a caller-owned connection,
9
+ * inside its transaction when one is open. Supply trusted SQL and list
10
+ * dependencies before dependents. Only a named view whose stored definition
11
+ * differs is replaced, so a publication that changes nothing leaves the schema
12
+ * as it was.
13
+ */
14
+ export declare function publishSQLiteViews(database: DatabaseSync, { views }: {
15
+ views: readonly SQLiteView[];
16
+ }): void;
@@ -0,0 +1,62 @@
1
+ import { Declaration } from '@zukhruf/sqlite-drift';
2
+ import { transaction } from '@zukhruf/sqlite-transaction';
3
+ import { isReserved } from './reserved.js';
4
+ import { describe } from './sqlite-descriptions.js';
5
+ const quote = (name) => `"${name.replaceAll('"', '""')}"`;
6
+ export function viewDefinition(view) {
7
+ return `CREATE VIEW ${quote(view.name)} AS ${view.query}`;
8
+ }
9
+ function text(value, what) {
10
+ if (typeof value !== 'string' ||
11
+ !value.trim() ||
12
+ value.includes('\0') ||
13
+ !value.isWellFormed())
14
+ throw new TypeError(`Invalid ${what}`);
15
+ }
16
+ /**
17
+ * Publish ordinary views and their descriptions on a caller-owned connection,
18
+ * inside its transaction when one is open. Supply trusted SQL and list
19
+ * dependencies before dependents. Only a named view whose stored definition
20
+ * differs is replaced, so a publication that changes nothing leaves the schema
21
+ * as it was.
22
+ */
23
+ export function publishSQLiteViews(database, { views }) {
24
+ const names = new Set();
25
+ for (const view of views) {
26
+ text(view.name, 'view name');
27
+ if (isReserved(view.name))
28
+ throw new TypeError('View names starting with _elt_ are reserved');
29
+ if (names.has(view.name.toLowerCase()))
30
+ throw new TypeError('Duplicate view names');
31
+ names.add(view.name.toLowerCase());
32
+ text(view.query, 'view query');
33
+ text(view.description, 'view description');
34
+ for (const [column, description] of Object.entries(view.columns)) {
35
+ text(column, 'column name');
36
+ text(description, 'column description');
37
+ }
38
+ }
39
+ if (views.length === 0)
40
+ return;
41
+ // A failed definition restores every replaced view, even if the caller
42
+ // catches the error and continues its transaction. With no transaction of
43
+ // the caller, it begins deferred.
44
+ transaction(database, () => {
45
+ // A declaration refuses an element of more than one statement, so a
46
+ // query cannot append DDL.
47
+ new Declaration(views.map(viewDefinition), { rows: 'replace' }).apply(database);
48
+ for (const view of views) {
49
+ const columns = database
50
+ .prepare('SELECT "name" FROM pragma_table_info(?)')
51
+ .all(view.name)
52
+ .map(({ name }) => String(name));
53
+ if (columns.length !== Object.keys(view.columns).length ||
54
+ columns.some((name) => !Object.hasOwn(view.columns, name)))
55
+ throw new TypeError(`View ${quote(view.name)} must describe exactly its output columns: ${columns.join(', ')}`);
56
+ describe(database, view.name, view.description, Object.fromEntries(Object.entries(view.columns).map(([column, description]) => [
57
+ column,
58
+ { description },
59
+ ])));
60
+ }
61
+ }, { mode: 'deferred' });
62
+ }
@@ -0,0 +1,38 @@
1
+ import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
2
+ import type { CopyConfiguration, FieldValues, KeyValue, Stage } from '@eltjs/elt';
3
+ import { Writer } from '@eltjs/elt';
4
+ import type { SQLiteColumn } from './sqlite-column.ts';
5
+ import type { SQLiteTable } from './sqlite-table.ts';
6
+ export declare const seq = "\"_elt_seq\"";
7
+ export declare const op = "\"_elt_op\"";
8
+ export declare abstract class SQLiteWriter extends Writer {
9
+ #private;
10
+ readonly configuration: CopyConfiguration;
11
+ readonly path: string;
12
+ readonly table: SQLiteTable;
13
+ constructor(configuration: CopyConfiguration, path: string, table: SQLiteTable);
14
+ protected initialize(database: DatabaseSync, _replacing: boolean): void;
15
+ protected dropDedupIndexes(database: DatabaseSync, kept: string | null): void;
16
+ protected inspect(_database: DatabaseSync): void;
17
+ protected abstract merge(database: DatabaseSync, stage: string, loadedAt: string, into: string): void;
18
+ protected build(_database: DatabaseSync, _into: string): void;
19
+ protected adopt(_database: DatabaseSync): void;
20
+ protected append(database: DatabaseSync, stage: string, loadedAt: string, into: string): void;
21
+ protected get replaces(): boolean;
22
+ protected replace(database: DatabaseSync): void;
23
+ private get hash();
24
+ protected get fields(): string[];
25
+ protected encode(record: unknown): SQLInputValue[];
26
+ protected deletionKeys(_key: Readonly<Record<string, KeyValue>>): readonly [readonly SQLiteColumn[], SQLInputValue[]];
27
+ private writers;
28
+ private own;
29
+ private exists;
30
+ private values;
31
+ clear(writer: string, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
32
+ private describe;
33
+ private installReaderView;
34
+ prepare(database: DatabaseSync, { writer, reloading }: {
35
+ writer: string;
36
+ reloading: boolean;
37
+ }, loadedAt: string): Stage;
38
+ }