@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,306 @@
1
+ import { FileRead, isCalendarDate, isTimestamp } from '@eltjs/elt';
2
+ // STRICT tables accept only these types, so dates and times are their
3
+ // canonical ISO text, which sorts in time order, and exact decimals are text.
4
+ const storageTypes = {
5
+ text: 'TEXT',
6
+ integer: 'INTEGER',
7
+ int64: 'INTEGER',
8
+ real: 'REAL',
9
+ decimal: 'TEXT',
10
+ blob: 'BLOB',
11
+ boolean: 'INTEGER',
12
+ date: 'TEXT',
13
+ timestamp: 'TEXT',
14
+ local_timestamp: 'TEXT',
15
+ local_time: 'TEXT',
16
+ // An object of scalar fields, only as an item of an array column.
17
+ object: 'TEXT',
18
+ };
19
+ // The kind that stores each string format; base64 text loads as its bytes.
20
+ const formatKinds = {
21
+ 'date-time': 'timestamp',
22
+ date: 'date',
23
+ 'date-time-local': 'local_timestamp',
24
+ 'time-local': 'local_time',
25
+ int64: 'int64',
26
+ decimal: 'decimal',
27
+ base64: 'blob',
28
+ };
29
+ const plainKinds = new Set([
30
+ 'text',
31
+ 'integer',
32
+ 'real',
33
+ 'blob',
34
+ 'boolean',
35
+ 'date',
36
+ 'timestamp',
37
+ 'object',
38
+ ]);
39
+ // GLOB for a fraction of a second with exactly this many digits.
40
+ const fraction = (precision) => precision === 0 ? '' : `.${'[0-9]'.repeat(precision)}`;
41
+ // Whether the first 19 characters name a real second of the calendar.
42
+ const realSecond = (name) => `strftime('%Y-%m-%dT%H:%M:%S', substr(${name}, 1, 19)) IS substr(${name}, 1, 19)`;
43
+ // A CHECK that keeps a value of the kind in its canonical form.
44
+ export function canonical(kind, name, format) {
45
+ switch (kind) {
46
+ case 'boolean':
47
+ return ` CHECK (${name} IN (0, 1))`;
48
+ case 'date':
49
+ return ` CHECK (date(${name}) IS ${name})`;
50
+ case 'timestamp': {
51
+ const precision = format?.name === 'date-time' ? format.precision : 3;
52
+ return precision === 3
53
+ ? ` CHECK (strftime('%Y-%m-%dT%H:%M:%fZ', ${name}) IS ${name})`
54
+ : ` CHECK (${realSecond(name)} AND substr(${name}, 20) GLOB '${fraction(precision)}Z')`;
55
+ }
56
+ case 'local_timestamp':
57
+ return format?.name === 'date-time-local'
58
+ ? ` CHECK (${realSecond(name)} AND substr(${name}, 20) GLOB '${fraction(format.precision)}')`
59
+ : '';
60
+ case 'local_time':
61
+ return format?.name === 'time-local'
62
+ ? ` CHECK (time(substr(${name}, 1, 8)) IS substr(${name}, 1, 8) AND substr(${name}, 9) GLOB '${fraction(format.precision)}')`
63
+ : '';
64
+ default:
65
+ return '';
66
+ }
67
+ }
68
+ export class SQLiteColumn {
69
+ // The record field the column holds.
70
+ field;
71
+ // What SQLite calls the column, which the table it belongs to decides.
72
+ name;
73
+ type;
74
+ kind;
75
+ isPrimaryKey;
76
+ nullable;
77
+ optional;
78
+ // An array of kind, stored as a JSON array in TEXT.
79
+ array;
80
+ fileRead;
81
+ constructor(field, type, options) {
82
+ if (typeof field !== 'string')
83
+ throw new TypeError('A column holds a field named by a string');
84
+ if (typeof type === 'string' && !plainKinds.has(type))
85
+ throw new TypeError('Unsupported SQLite column type');
86
+ this.type = type;
87
+ const kind = typeof type === 'string' ? type : formatKinds[type.name];
88
+ this.array = options.array ?? false;
89
+ if (this.array && (kind === 'blob' || options.primaryKey))
90
+ throw new TypeError('Array columns hold scalar values or objects and cannot be keys');
91
+ this.fileRead = options.fileRead;
92
+ if (this.fileRead !== undefined &&
93
+ (!(this.fileRead instanceof FileRead) || this.fileRead.name !== field))
94
+ throw new TypeError('Column file read must match its field');
95
+ this.field = field;
96
+ this.name = options.name ?? field;
97
+ this.kind = kind;
98
+ this.isPrimaryKey = options.primaryKey;
99
+ this.nullable = !options.primaryKey && options.nullable;
100
+ this.optional = !options.primaryKey && options.optional;
101
+ Object.freeze(this);
102
+ }
103
+ primaryKey() {
104
+ return new SQLiteColumn(this.field, this.type, {
105
+ nullable: false,
106
+ optional: false,
107
+ primaryKey: true,
108
+ array: this.array,
109
+ fileRead: this.fileRead,
110
+ name: this.name,
111
+ });
112
+ }
113
+ notNull() {
114
+ return new SQLiteColumn(this.field, this.type, {
115
+ nullable: false,
116
+ optional: false,
117
+ primaryKey: this.isPrimaryKey,
118
+ array: this.array,
119
+ fileRead: this.fileRead,
120
+ name: this.name,
121
+ });
122
+ }
123
+ from(file) {
124
+ if (this.array || (this.kind !== 'blob' && this.kind !== 'text'))
125
+ throw new TypeError('Files require a BLOB column, parsed TEXT or a stored TEXT reference');
126
+ return new SQLiteColumn(this.field, this.kind, {
127
+ nullable: this.nullable,
128
+ optional: this.optional,
129
+ primaryKey: this.isPrimaryKey,
130
+ fileRead: new FileRead(this.field, file, this.fileRead?.parser),
131
+ name: this.name,
132
+ });
133
+ }
134
+ parse(parser) {
135
+ if (this.kind !== 'text')
136
+ throw new TypeError('Document parsing requires a TEXT column');
137
+ if (this.fileRead === undefined)
138
+ throw new TypeError('Select a source file before selecting a parser');
139
+ return new SQLiteColumn(this.field, this.kind, {
140
+ nullable: this.nullable,
141
+ optional: this.optional,
142
+ primaryKey: this.isPrimaryKey,
143
+ fileRead: new FileRead(this.field, this.fileRead.file, parser),
144
+ name: this.name,
145
+ });
146
+ }
147
+ // This column as the table names it.
148
+ named(name) {
149
+ return new SQLiteColumn(this.field, this.type, {
150
+ nullable: this.nullable,
151
+ optional: this.optional,
152
+ primaryKey: this.isPrimaryKey,
153
+ array: this.array,
154
+ fileRead: this.fileRead,
155
+ name,
156
+ });
157
+ }
158
+ get quotedName() {
159
+ return `"${this.name.replaceAll('"', '""')}"`;
160
+ }
161
+ // Original file bytes live in a chunk table; the column keeps the file's id.
162
+ get storesFile() {
163
+ return this.kind === 'blob' && this.fileRead !== undefined;
164
+ }
165
+ get storageType() {
166
+ if (this.array)
167
+ return 'TEXT';
168
+ return this.storesFile ? 'INTEGER' : storageTypes[this.kind];
169
+ }
170
+ // The column's type as readers see it in the catalog.
171
+ get dataType() {
172
+ if (this.storesFile)
173
+ return 'integer';
174
+ return this.array ? `${this.#typeName}[]` : this.#typeName;
175
+ }
176
+ // The kind with the precision and scale its format declares, as SQL writes
177
+ // them; a timestamp of milliseconds is the plain kind.
178
+ get #typeName() {
179
+ const { type } = this;
180
+ if (typeof type === 'string')
181
+ return type;
182
+ switch (type.name) {
183
+ case 'date-time':
184
+ return type.precision === 3
185
+ ? this.kind
186
+ : `${this.kind}(${type.precision})`;
187
+ case 'date-time-local':
188
+ case 'time-local':
189
+ return `${this.kind}(${type.precision})`;
190
+ case 'decimal':
191
+ return type.precision === undefined
192
+ ? this.kind
193
+ : `${this.kind}(${[type.precision, type.scale ?? []].join(',')})`;
194
+ default:
195
+ return this.kind;
196
+ }
197
+ }
198
+ get definition() {
199
+ const check = this.array
200
+ ? ` CHECK (json_valid(${this.quotedName}) AND json_type(${this.quotedName}) = 'array')`
201
+ : canonical(this.kind, this.quotedName, typeof this.type === 'string' ? undefined : this.type);
202
+ // A field the stream requires stays nullable in storage: rows loaded
203
+ // before it was added have no value for it. Records are still checked for
204
+ // it when they are written (encode).
205
+ return `${this.quotedName} ${this.storageType}${this.isPrimaryKey ? ' PRIMARY KEY NOT NULL' : ''}${check}`;
206
+ }
207
+ encode(record) {
208
+ if (record === null || typeof record !== 'object' || Array.isArray(record))
209
+ throw new TypeError(`Record is missing field ${JSON.stringify(this.field)}`);
210
+ if (!Object.hasOwn(record, this.field)) {
211
+ if (this.optional)
212
+ return null;
213
+ throw new TypeError(`Record is missing field ${JSON.stringify(this.field)}`);
214
+ }
215
+ const value = Reflect.get(record, this.field);
216
+ if (value === null && this.nullable)
217
+ return null;
218
+ const { type } = this;
219
+ if (this.array) {
220
+ if (Array.isArray(value) &&
221
+ value.every((element) => this.#element(element)))
222
+ return JSON.stringify(value);
223
+ throw new TypeError(`Field ${JSON.stringify(this.field)} requires an array of ${this.kind}${this.nullable ? ' or null' : ' (not null)'}`);
224
+ }
225
+ if (typeof type !== 'string') {
226
+ if (typeof value === 'string' && type.accepts(value)) {
227
+ if (type.name === 'int64')
228
+ return BigInt(value);
229
+ if (type.name === 'base64')
230
+ return Buffer.from(value, 'base64');
231
+ return value;
232
+ }
233
+ throw new TypeError(`Field ${JSON.stringify(this.field)} requires a canonical ${type.name} value${this.nullable ? ' or null' : ' (not null)'}`);
234
+ }
235
+ switch (type) {
236
+ case 'text':
237
+ if (typeof value === 'string')
238
+ return value;
239
+ break;
240
+ case 'boolean':
241
+ if (typeof value === 'boolean')
242
+ return Number(value);
243
+ break;
244
+ case 'integer':
245
+ if ((typeof value === 'bigint' &&
246
+ value >= -(2n ** 63n) &&
247
+ value < 2n ** 63n) ||
248
+ (typeof value === 'number' && Number.isSafeInteger(value)))
249
+ return value;
250
+ break;
251
+ case 'date':
252
+ if (isCalendarDate(value))
253
+ return value;
254
+ break;
255
+ case 'timestamp':
256
+ if (isTimestamp(value))
257
+ return value;
258
+ break;
259
+ case 'real':
260
+ if (typeof value === 'number' && Number.isFinite(value))
261
+ return value;
262
+ break;
263
+ case 'blob':
264
+ if (this.storesFile) {
265
+ if (typeof value === 'number' && Number.isSafeInteger(value))
266
+ return value;
267
+ }
268
+ else if (value instanceof Uint8Array)
269
+ return value;
270
+ break;
271
+ // An object is only an array's item, which the array branch encodes.
272
+ case 'object':
273
+ }
274
+ throw new TypeError(`Field ${JSON.stringify(this.field)} requires ${this.kind}${this.nullable ? ' or null' : ' (not null)'}`);
275
+ }
276
+ // Whether a JSON array element keeps this kind's value exactly.
277
+ #element(value) {
278
+ const { type } = this;
279
+ if (typeof type !== 'string')
280
+ return typeof value === 'string' && type.accepts(value);
281
+ switch (type) {
282
+ case 'text':
283
+ return typeof value === 'string';
284
+ case 'boolean':
285
+ return typeof value === 'boolean';
286
+ case 'integer':
287
+ return Number.isSafeInteger(value);
288
+ case 'real':
289
+ return typeof value === 'number' && Number.isFinite(value);
290
+ case 'date':
291
+ return isCalendarDate(value);
292
+ case 'timestamp':
293
+ return isTimestamp(value);
294
+ case 'object':
295
+ return (value !== null &&
296
+ typeof value === 'object' &&
297
+ !Array.isArray(value) &&
298
+ Object.values(value).every((field) => field === null ||
299
+ typeof field === 'string' ||
300
+ typeof field === 'boolean' ||
301
+ (typeof field === 'number' && Number.isFinite(field))));
302
+ default:
303
+ return false;
304
+ }
305
+ }
306
+ }
@@ -0,0 +1,14 @@
1
+ import { type Stream } from '@eltjs/elt';
2
+ import { SQLiteColumn } from './sqlite-column.ts';
3
+ export declare class SQLiteColumns {
4
+ static fromSchema({ properties, required, }: Stream['jsonSchema']): readonly SQLiteColumn[];
5
+ text(field: string): SQLiteColumn;
6
+ integer(field: string): SQLiteColumn;
7
+ int64(field: string): SQLiteColumn;
8
+ real(field: string): SQLiteColumn;
9
+ date(field: string): SQLiteColumn;
10
+ timestamp(field: string): SQLiteColumn;
11
+ localTimestamp(field: string, precision: number): SQLiteColumn;
12
+ blob(field: string): SQLiteColumn;
13
+ boolean(field: string): SQLiteColumn;
14
+ }
@@ -0,0 +1,118 @@
1
+ import { Int64Format, LocalTimestampFormat, declaredFormat, } from '@eltjs/elt';
2
+ import { SQLiteColumn } from './sqlite-column.js';
3
+ function scalarType(name, type, format) {
4
+ switch (type) {
5
+ case 'string':
6
+ return format ?? 'text';
7
+ case 'integer':
8
+ return 'integer';
9
+ case 'number':
10
+ return 'real';
11
+ case 'boolean':
12
+ return 'boolean';
13
+ case 'object':
14
+ return 'object';
15
+ default:
16
+ throw new TypeError(`Unsupported JSON Schema type for field ${name}: ${type}`);
17
+ }
18
+ }
19
+ // SQLite column declarations are independent of a source or connection.
20
+ export class SQLiteColumns {
21
+ // Scalars, and arrays of scalars or of objects as JSON arrays in TEXT.
22
+ // String formats keep their kind, as in Postgres.
23
+ static fromSchema({ properties, required = [], }) {
24
+ if (properties === undefined)
25
+ throw new TypeError('SQLite requires an object schema with explicit properties');
26
+ const requiredFields = new Set(required);
27
+ for (const name of requiredFields)
28
+ if (!Object.hasOwn(properties, name))
29
+ throw new TypeError(`Schema does not describe field ${name}`);
30
+ return Object.entries(properties).map(([name, field]) => {
31
+ const types = typeof field.type === 'string' ? [field.type] : field.type;
32
+ if (new Set(types).size !== types.length)
33
+ throw new TypeError(`Unsupported JSON Schema type for field ${name}`);
34
+ const valueTypes = types.filter((type) => type !== 'null');
35
+ const valueType = valueTypes[0];
36
+ if (valueType === undefined || valueTypes.length !== 1)
37
+ throw new TypeError(`SQLite requires one scalar type for field ${name}`);
38
+ const array = valueType === 'array';
39
+ const items = array ? field.items : undefined;
40
+ if (array && items === undefined)
41
+ throw new TypeError(`Unsupported JSON Schema items for field ${name}`);
42
+ const format = declaredFormat(items ?? field);
43
+ return new SQLiteColumn(name, scalarType(name, items?.type ?? valueType, format), {
44
+ nullable: types.includes('null'),
45
+ optional: !requiredFields.has(name),
46
+ primaryKey: false,
47
+ array,
48
+ });
49
+ });
50
+ }
51
+ text(field) {
52
+ return new SQLiteColumn(field, 'text', {
53
+ nullable: true,
54
+ optional: false,
55
+ primaryKey: false,
56
+ });
57
+ }
58
+ integer(field) {
59
+ return new SQLiteColumn(field, 'integer', {
60
+ nullable: true,
61
+ optional: false,
62
+ primaryKey: false,
63
+ });
64
+ }
65
+ // A signed 64-bit integer, which a stream spells as decimal text (format
66
+ // int64), so a value past Number.MAX_SAFE_INTEGER keeps every digit.
67
+ int64(field) {
68
+ return new SQLiteColumn(field, new Int64Format(), {
69
+ nullable: true,
70
+ optional: false,
71
+ primaryKey: false,
72
+ });
73
+ }
74
+ real(field) {
75
+ return new SQLiteColumn(field, 'real', {
76
+ nullable: true,
77
+ optional: false,
78
+ primaryKey: false,
79
+ });
80
+ }
81
+ date(field) {
82
+ return new SQLiteColumn(field, 'date', {
83
+ nullable: true,
84
+ optional: false,
85
+ primaryKey: false,
86
+ });
87
+ }
88
+ timestamp(field) {
89
+ return new SQLiteColumn(field, 'timestamp', {
90
+ nullable: true,
91
+ optional: false,
92
+ primaryKey: false,
93
+ });
94
+ }
95
+ // A wall-clock date and time with no zone and precision digits of a second
96
+ // (format date-time-local), as SQL Server's datetime2 holds it.
97
+ localTimestamp(field, precision) {
98
+ return new SQLiteColumn(field, new LocalTimestampFormat(precision), {
99
+ nullable: true,
100
+ optional: false,
101
+ primaryKey: false,
102
+ });
103
+ }
104
+ blob(field) {
105
+ return new SQLiteColumn(field, 'blob', {
106
+ nullable: true,
107
+ optional: false,
108
+ primaryKey: false,
109
+ });
110
+ }
111
+ boolean(field) {
112
+ return new SQLiteColumn(field, 'boolean', {
113
+ nullable: true,
114
+ optional: false,
115
+ primaryKey: false,
116
+ });
117
+ }
118
+ }
@@ -0,0 +1,21 @@
1
+ import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
2
+ import type { CopyConfiguration, Deduplication, KeyValue } from '@eltjs/elt';
3
+ import type { SQLiteColumn } from './sqlite-column.ts';
4
+ import type { SQLiteTable } from './sqlite-table.ts';
5
+ import { SQLiteWriter } from './sqlite-writer.ts';
6
+ export declare class SQLiteDeduplicatingWriter extends SQLiteWriter {
7
+ #private;
8
+ readonly deduplication: Deduplication;
9
+ readonly keys: readonly SQLiteColumn[];
10
+ readonly cursor?: SQLiteColumn;
11
+ constructor(configuration: CopyConfiguration, path: string, table: SQLiteTable);
12
+ protected get replaces(): boolean;
13
+ protected inspect(database: DatabaseSync): void;
14
+ protected initialize(database: DatabaseSync, replacing: boolean): void;
15
+ protected replace(database: DatabaseSync): void;
16
+ protected build(database: DatabaseSync, into: string): void;
17
+ protected adopt(database: DatabaseSync): void;
18
+ protected merge(database: DatabaseSync, stage: string, loadedAt: string, into: string): void;
19
+ protected encode(record: unknown): SQLInputValue[];
20
+ protected deletionKeys(key: Readonly<Record<string, KeyValue>>): readonly [readonly SQLiteColumn[], SQLInputValue[]];
21
+ }
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { SQLiteColumns } from './sqlite-columns.js';
3
+ import { SQLiteWriter, op, seq } from './sqlite-writer.js';
4
+ export class SQLiteDeduplicatingWriter extends SQLiteWriter {
5
+ deduplication;
6
+ keys;
7
+ cursor;
8
+ constructor(configuration, path, table) {
9
+ super(configuration, path, table);
10
+ this.deduplication = configuration.deduplication();
11
+ const inferred = SQLiteColumns.fromSchema(configuration.stream.jsonSchema);
12
+ const column = (field) => {
13
+ const selected = table.columns.find((column) => column.field === field);
14
+ if (selected === undefined)
15
+ throw new TypeError(`Deduplication requires destination column ${field}`);
16
+ if (selected.dataType !==
17
+ inferred.find((column) => column.field === field)?.dataType)
18
+ throw new TypeError(`Deduplication column ${field} must preserve the source scalar type`);
19
+ return selected;
20
+ };
21
+ this.keys = Object.freeze(this.deduplication.primaryKey.map(column));
22
+ const { cursorField } = this.deduplication;
23
+ this.cursor = cursorField === undefined ? undefined : column(cursorField);
24
+ Object.freeze(this);
25
+ }
26
+ get replaces() {
27
+ return this.configuration.destinationSyncMode === 'overwrite_dedup';
28
+ }
29
+ // Named after the table and its key, so a changed key builds a new index
30
+ // and an unchanged one is never rebuilt.
31
+ get #dedupIndex() {
32
+ return `_elt_dedup_${createHash('sha256')
33
+ .update(JSON.stringify([this.table.name, this.keys.map((key) => key.name)]))
34
+ .digest('hex')}`;
35
+ }
36
+ // A stored table the load merges into must hold keys and cursors of the
37
+ // stream's types, none null, and no key twice, which its index forbids once
38
+ // it exists.
39
+ inspect(database) {
40
+ const existing = database
41
+ .prepare(`PRAGMA table_info(${this.table.quotedName})`)
42
+ .all();
43
+ const tracked = this.cursor === undefined ? this.keys : [...this.keys, this.cursor];
44
+ for (const column of tracked) {
45
+ if (!existing.some((field) => field.name === column.name && field.type === column.storageType))
46
+ throw new TypeError(`Existing deduplication column ${column.name} has an incompatible storage type`);
47
+ }
48
+ if (database
49
+ .prepare(`SELECT 1 FROM ${this.table.quotedName} WHERE ${tracked.map((column) => `${column.quotedName} IS NULL`).join(' OR ')} LIMIT 1`)
50
+ .get())
51
+ throw new TypeError('Existing deduplication keys and cursors must be non-null');
52
+ if (database
53
+ .prepare(`SELECT 1 FROM sqlite_schema WHERE "type" = 'index' AND "name" = ?`)
54
+ .get(this.#dedupIndex) === undefined &&
55
+ database
56
+ .prepare(`SELECT 1 FROM ${this.table.quotedName} GROUP BY ${this.keys.map((column) => `${column.quotedName} COLLATE BINARY`).join(', ')} HAVING count(*) > 1 LIMIT 1`)
57
+ .get())
58
+ throw new TypeError('Existing rows repeat a deduplication key; replace them with overwrite_dedup before deduplicating incrementally');
59
+ }
60
+ // A replacing load keeps none of the stored rows: its index is built once
61
+ // the commit has emptied the table.
62
+ initialize(database, replacing) {
63
+ if (replacing)
64
+ this.dropDedupIndexes(database, this.#dedupIndex);
65
+ else
66
+ this.#index(database);
67
+ }
68
+ replace(database) {
69
+ super.replace(database);
70
+ this.#index(database);
71
+ }
72
+ // The target's key index, built only when it does not exist yet.
73
+ #index(database) {
74
+ this.dropDedupIndexes(database, this.#dedupIndex);
75
+ this.#unique(database, `"${this.#dedupIndex}"`, this.table.quotedName);
76
+ }
77
+ #unique(database, name, on) {
78
+ database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${name} ON ${on} (${this.keys.map((column) => `${column.quotedName} COLLATE BINARY`).join(', ')})`);
79
+ }
80
+ // A reload's hidden target merges on its key from its first commit; SQLite
81
+ // cannot rename an index, so the target's own is built once it is swapped in.
82
+ get #provisionalIndex() {
83
+ return `"${this.#dedupIndex}_next"`;
84
+ }
85
+ build(database, into) {
86
+ this.#unique(database, this.#provisionalIndex, into);
87
+ }
88
+ adopt(database) {
89
+ database.exec(`DROP INDEX IF EXISTS ${this.#provisionalIndex}`);
90
+ this.#index(database);
91
+ }
92
+ // The result of applying the staged operations one at a time: a staged
93
+ // DELETE removes its key, and only records after a key's last DELETE count.
94
+ // replace keeps the newest extraction, so a restated fact overwrites the
95
+ // loaded one; cursor_newer keeps the greatest cursor (the first on ties) and
96
+ // the guard that rejects out-of-order replay.
97
+ merge(database, stage, loadedAt, into) {
98
+ const keys = this.keys.map((column) => column.quotedName);
99
+ const same = (left, right) => keys.map((key) => `${left}.${key} = ${right}.${key}`).join(' AND ');
100
+ database.exec(`DELETE FROM ${into} WHERE (${keys.join(', ')}) IN (SELECT ${keys.join(', ')} FROM ${stage} WHERE ${op} = 'D')`);
101
+ const { cursor } = this;
102
+ const guarded = this.configuration.dedupPolicy !== 'replace' && cursor !== undefined;
103
+ const order = guarded
104
+ ? `"staged".${cursor.quotedName} COLLATE BINARY DESC, "staged".${seq}`
105
+ : `"staged".${seq} DESC`;
106
+ // Rank identifiers only: sorting whole mail bodies/files multiplies temporary disk usage.
107
+ const columns = this.table.columns.map((column) => column.quotedName);
108
+ database
109
+ .prepare(`WITH "deleted" AS (SELECT ${keys.join(', ')}, max(${seq}) AS "_elt_last" FROM ${stage} WHERE ${op} = 'D' GROUP BY ${keys.join(', ')}), "ranked" AS (SELECT "staged".${seq}, row_number() OVER (PARTITION BY ${keys.map((key) => `"staged".${key}`).join(', ')} ORDER BY ${order}) AS "_elt_rank" FROM ${stage} AS "staged" LEFT JOIN "deleted" ON ${same('"deleted"', '"staged"')} WHERE "staged".${op} = 'R' AND ("deleted"."_elt_last" IS NULL OR "staged".${seq} > "deleted"."_elt_last")) ` +
110
+ `INSERT INTO ${into} AS "_elt_target" (${this.fields.join(', ')}) SELECT ${columns.join(', ')}, ? FROM ${stage} WHERE ${seq} IN (SELECT ${seq} FROM "ranked" WHERE "_elt_rank" = 1) ORDER BY ${seq} ` +
111
+ `ON CONFLICT (${keys.map((key) => `${key} COLLATE BINARY`).join(', ')}) DO UPDATE SET ${this.fields.map((field) => `${field} = excluded.${field}`).join(', ')}${guarded ? ` WHERE excluded.${cursor.quotedName} COLLATE BINARY > "_elt_target".${cursor.quotedName}` : ''}`)
112
+ .run(loadedAt);
113
+ }
114
+ encode(record) {
115
+ this.deduplication.key(record);
116
+ if (this.cursor !== undefined)
117
+ this.deduplication.cursor(record);
118
+ return super.encode(record);
119
+ }
120
+ deletionKeys(key) {
121
+ this.deduplication.key(key);
122
+ return [this.keys, this.keys.map((column) => column.encode(key))];
123
+ }
124
+ }
@@ -0,0 +1,8 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ export declare const descriptions = "\"_elt_descriptions\"";
3
+ export type ColumnDescription = {
4
+ readonly description: string | null;
5
+ readonly dataType?: string;
6
+ };
7
+ export declare function createDescriptions(database: DatabaseSync): void;
8
+ export declare function describe(database: DatabaseSync, relation: string, description: string, columns: Readonly<Record<string, ColumnDescription>>): void;
@@ -0,0 +1,37 @@
1
+ import { Declaration } from '@zukhruf/sqlite-drift';
2
+ import { identifiers } from './sqlite-identifiers.js';
3
+ // SQLite has no COMMENT ON. Relation and column meanings live here instead,
4
+ // beside what they describe, and the catalog view shows them to readers.
5
+ export const descriptions = '"_elt_descriptions"';
6
+ const descriptionsTable = new Declaration([
7
+ `CREATE TABLE ${descriptions} ("relation" TEXT NOT NULL COLLATE NOCASE, "column" TEXT NOT NULL COLLATE NOCASE, "data_type" TEXT, "description" TEXT NOT NULL, PRIMARY KEY ("relation", "column")) STRICT`,
8
+ ], { rows: 'replace' });
9
+ // A table another version declared otherwise is replaced; the next commit or
10
+ // publication of each relation describes it again.
11
+ export function createDescriptions(database) {
12
+ descriptionsTable.apply(database);
13
+ }
14
+ // Replaces what a relation says about itself and its columns; a column
15
+ // without a description says nothing, as a Postgres comment set to NULL. A
16
+ // column without a stated type keeps the type SQLite declares for it, read
17
+ // here because the catalog view cannot: shells keep a schema untrusted, which
18
+ // refuses pragma functions inside views.
19
+ export function describe(database, relation, description, columns) {
20
+ createDescriptions(database);
21
+ database.exec(`DELETE FROM ${descriptions} WHERE "relation" NOT IN (SELECT "name" FROM sqlite_schema)`);
22
+ database
23
+ .prepare(`DELETE FROM ${descriptions} WHERE "relation" = ?`)
24
+ .run(relation);
25
+ const declared = new Map(database
26
+ .prepare('SELECT "name", "type" FROM pragma_table_info(?)')
27
+ .all(relation)
28
+ .map(({ name, type }) => [
29
+ identifiers.key(String(name)),
30
+ String(type).toLowerCase() || null,
31
+ ]));
32
+ const insert = database.prepare(`INSERT INTO ${descriptions} ("relation", "column", "data_type", "description") VALUES (?, ?, ?, ?)`);
33
+ insert.run(relation, '', null, description);
34
+ for (const [column, { description, dataType }] of Object.entries(columns))
35
+ if (description !== null)
36
+ insert.run(relation, column, dataType ?? declared.get(identifiers.key(column)) ?? null, description);
37
+ }
@@ -0,0 +1,19 @@
1
+ import type { CopyConfiguration, Load, PipelineClock } from '@eltjs/elt';
2
+ import { Destination } from '@eltjs/elt';
3
+ import type { SQLiteColumn } from './sqlite-column.ts';
4
+ import { SQLiteColumns } from './sqlite-columns.ts';
5
+ import { SQLiteTable } from './sqlite-table.ts';
6
+ import type { SQLiteWriter } from './sqlite-writer.ts';
7
+ export declare class SQLiteDestination extends Destination<SQLiteTable> {
8
+ readonly supportedDestinationSyncModes: readonly ["overwrite", "append", "append_dedup", "overwrite_dedup"];
9
+ readonly path: string;
10
+ constructor({ path }: {
11
+ path: string;
12
+ });
13
+ location(target: SQLiteTable): string;
14
+ load({ clock, }: {
15
+ clock: PipelineClock;
16
+ }): Promise<Load<SQLiteTable>>;
17
+ table(name: string, configure?: (columns: SQLiteColumns) => readonly SQLiteColumn[]): SQLiteTable;
18
+ createWriter(configuration: CopyConfiguration, target: SQLiteTable): SQLiteWriter;
19
+ }