@alisio/core 0.1.0-alpha.21 → 0.1.0-alpha.22
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/dist/analysis/capabilities.d.ts +48 -0
- package/dist/analysis/capabilities.js +90 -0
- package/dist/analysis/data/csv.d.ts +46 -0
- package/dist/analysis/data/csv.js +240 -0
- package/dist/analysis/data/datasets.d.ts +132 -0
- package/dist/analysis/data/datasets.js +441 -0
- package/dist/analysis/data/describe.d.ts +27 -0
- package/dist/analysis/data/describe.js +103 -0
- package/dist/analysis/data/engine-client.d.ts +58 -0
- package/dist/analysis/data/engine-client.js +304 -0
- package/dist/analysis/data/engine-entry.d.ts +1 -0
- package/dist/analysis/data/engine-entry.js +59 -0
- package/dist/analysis/data/engine-source.d.ts +4 -0
- package/dist/analysis/data/engine-source.js +6 -0
- package/dist/analysis/data/engine-types.d.ts +86 -0
- package/dist/analysis/data/engine-types.js +7 -0
- package/dist/analysis/data/infer.d.ts +42 -0
- package/dist/analysis/data/infer.js +118 -0
- package/dist/analysis/data/ingest-worker.d.ts +17 -0
- package/dist/analysis/data/ingest-worker.js +365 -0
- package/dist/analysis/data/json.d.ts +19 -0
- package/dist/analysis/data/json.js +44 -0
- package/dist/analysis/data/query-worker.d.ts +12 -0
- package/dist/analysis/data/query-worker.js +75 -0
- package/dist/analysis/data/rows.d.ts +39 -0
- package/dist/analysis/data/rows.js +120 -0
- package/dist/analysis/data/sql-guard.d.ts +20 -0
- package/dist/analysis/data/sql-guard.js +129 -0
- package/dist/analysis/data/stats.d.ts +18 -0
- package/dist/analysis/data/stats.js +77 -0
- package/dist/analysis/install-hints.d.ts +33 -0
- package/dist/analysis/install-hints.js +144 -0
- package/dist/analysis/janitor.d.ts +60 -0
- package/dist/analysis/janitor.js +264 -0
- package/dist/analysis/jobs.d.ts +94 -0
- package/dist/analysis/jobs.js +165 -0
- package/dist/analysis/oci.d.ts +129 -0
- package/dist/analysis/oci.js +330 -0
- package/dist/analysis/python/sources.d.ts +2 -0
- package/dist/analysis/python/sources.js +10 -0
- package/dist/analysis/requirements.d.ts +32 -0
- package/dist/analysis/requirements.js +73 -0
- package/dist/analysis/rerun.d.ts +40 -0
- package/dist/analysis/rerun.js +136 -0
- package/dist/analysis/runtime-manager.d.ts +146 -0
- package/dist/analysis/runtime-manager.js +472 -0
- package/dist/analysis/status.d.ts +21 -0
- package/dist/analysis/status.js +52 -0
- package/dist/application.d.ts +41 -0
- package/dist/application.js +195 -0
- package/dist/artifacts/kinds.d.ts +35 -0
- package/dist/artifacts/kinds.js +177 -0
- package/dist/artifacts/publisher.d.ts +7 -0
- package/dist/artifacts/publisher.js +29 -0
- package/dist/artifacts/store.d.ts +119 -0
- package/dist/artifacts/store.js +581 -0
- package/dist/commands/builtins.js +13 -0
- package/dist/config.d.ts +102 -0
- package/dist/config.js +167 -6
- package/dist/core/contracts.d.ts +69 -1
- package/dist/core/runner.d.ts +34 -4
- package/dist/core/runner.js +388 -130
- package/dist/index.d.ts +23 -4
- package/dist/index.js +22 -3
- package/dist/mcp/rich.js +4 -0
- package/dist/plugins/host.js +14 -6
- package/dist/runtime/blobs.d.ts +22 -0
- package/dist/runtime/blobs.js +84 -1
- package/dist/runtime/ids.d.ts +8 -0
- package/dist/runtime/ids.js +32 -0
- package/dist/runtime/paths.d.ts +5 -0
- package/dist/runtime/paths.js +6 -0
- package/dist/runtime/process.d.ts +12 -0
- package/dist/runtime/process.js +64 -4
- package/dist/runtime/sqlite.d.ts +2 -0
- package/dist/runtime/sqlite.js +18 -0
- package/dist/runtime/store.d.ts +5 -0
- package/dist/runtime/store.js +75 -0
- package/dist/runtime/zip.d.ts +24 -0
- package/dist/runtime/zip.js +126 -0
- package/dist/sessions/children.js +4 -0
- package/dist/tools/analysis.d.ts +37 -0
- package/dist/tools/analysis.js +572 -0
- package/dist/tools/artifacts.d.ts +14 -0
- package/dist/tools/artifacts.js +143 -0
- package/dist/tools/data.d.ts +7 -0
- package/dist/tools/data.js +160 -0
- package/dist/tools/execute.js +6 -2
- package/package.json +2 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cell conversion, identifier sanitising and type hints of the dataset ingestion (spec §17.2).
|
|
3
|
+
* Pure functions with no imports, shared by the ingestion engine (a separate process) and the
|
|
4
|
+
* tests. Values are stored without loss: a cell becomes a number only when its text is a plain
|
|
5
|
+
* number (no leading zeros, no separators, safe range); otherwise the exact text is kept.
|
|
6
|
+
*/
|
|
7
|
+
/** Plain decimal number: optional minus, no leading zeros, optional fraction and exponent. */
|
|
8
|
+
const NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
9
|
+
const INTEGER = /^-?(?:0|[1-9]\d*)$/;
|
|
10
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
11
|
+
const BOOLEAN = /^(?:true|false)$/i;
|
|
12
|
+
/**
|
|
13
|
+
* Converts one CSV/XLSX text cell. Empty → `null`; plain integers in the safe range → INTEGER
|
|
14
|
+
* (`bigint`, because node:sqlite binds JS numbers as REAL); plain decimals → REAL; anything else
|
|
15
|
+
* (`007`, `1,234`, `$12`, `N/A`, `-0`, `1e999`, unsafe integers) keeps its exact text. The only
|
|
16
|
+
* documented normalisation is `1.50` → `1.5`.
|
|
17
|
+
*/
|
|
18
|
+
export function convertCell(raw) {
|
|
19
|
+
if (raw === "")
|
|
20
|
+
return null;
|
|
21
|
+
if (!NUMBER.test(raw))
|
|
22
|
+
return raw;
|
|
23
|
+
if (INTEGER.test(raw)) {
|
|
24
|
+
if (raw === "-0")
|
|
25
|
+
return raw;
|
|
26
|
+
const n = Number(raw);
|
|
27
|
+
return Number.isSafeInteger(n) ? BigInt(raw) : raw;
|
|
28
|
+
}
|
|
29
|
+
const n = Number(raw);
|
|
30
|
+
return Number.isFinite(n) ? n : raw;
|
|
31
|
+
}
|
|
32
|
+
/** A JSON value stored without loss: numbers stay numbers, nested values become JSON text. */
|
|
33
|
+
export function convertJson(value) {
|
|
34
|
+
if (value === null || value === undefined)
|
|
35
|
+
return null;
|
|
36
|
+
if (typeof value === "number") {
|
|
37
|
+
if (!Number.isFinite(value))
|
|
38
|
+
return String(value);
|
|
39
|
+
return Number.isSafeInteger(value) && !Object.is(value, -0) ? BigInt(value) : value;
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === "string")
|
|
42
|
+
return value;
|
|
43
|
+
if (typeof value === "boolean")
|
|
44
|
+
return value ? "true" : "false";
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
}
|
|
47
|
+
/** Whether every non-empty cell of a row is a plain number (then the row is data, not a header). */
|
|
48
|
+
export function looksLikeData(row) {
|
|
49
|
+
const cells = row.filter((cell) => cell !== "");
|
|
50
|
+
return cells.length > 0 && cells.every((cell) => NUMBER.test(cell));
|
|
51
|
+
}
|
|
52
|
+
/** `region`, `año` → `ano`, `Total ($)` → `total`; never empty, never starts with a digit. */
|
|
53
|
+
export function sanitizeIdentifier(label, fallback) {
|
|
54
|
+
let id = label
|
|
55
|
+
.normalize("NFKD")
|
|
56
|
+
.replace(/[̀-ͯ]/g, "")
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
59
|
+
.replace(/^_+|_+$/g, "")
|
|
60
|
+
.slice(0, 48);
|
|
61
|
+
if (!id)
|
|
62
|
+
return fallback;
|
|
63
|
+
if (/^[0-9]/.test(id))
|
|
64
|
+
id = `_${id}`;
|
|
65
|
+
return id;
|
|
66
|
+
}
|
|
67
|
+
/** Deduplicates names in order (`x`, `x_2`, `x_3`); `taken` carries names across calls. */
|
|
68
|
+
export function uniqueName(name, taken) {
|
|
69
|
+
let candidate = name;
|
|
70
|
+
for (let n = 2; taken.has(candidate); n++)
|
|
71
|
+
candidate = `${name}_${n}`;
|
|
72
|
+
taken.add(candidate);
|
|
73
|
+
return candidate;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Type hint of a column from the storage class and value of its first cells (`kind` is SQLite's
|
|
77
|
+
* `typeof`). All integers → `integer`; all numbers → `real`; all dates / booleans → those;
|
|
78
|
+
* anything mixed or empty → `text`.
|
|
79
|
+
*/
|
|
80
|
+
export function inferType(sample) {
|
|
81
|
+
let integers = 0, reals = 0, texts = 0, dates = 0, booleans = 0;
|
|
82
|
+
for (const { kind, value } of sample) {
|
|
83
|
+
if (kind === "null")
|
|
84
|
+
continue;
|
|
85
|
+
if (kind === "integer")
|
|
86
|
+
integers++;
|
|
87
|
+
else if (kind === "real")
|
|
88
|
+
reals++;
|
|
89
|
+
else if (kind === "text") {
|
|
90
|
+
texts++;
|
|
91
|
+
const text = String(value);
|
|
92
|
+
if (ISO_DATE.test(text))
|
|
93
|
+
dates++;
|
|
94
|
+
else if (BOOLEAN.test(text))
|
|
95
|
+
booleans++;
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
return "text";
|
|
99
|
+
}
|
|
100
|
+
const total = integers + reals + texts;
|
|
101
|
+
if (!total)
|
|
102
|
+
return "text";
|
|
103
|
+
if (texts === 0)
|
|
104
|
+
return reals === 0 ? "integer" : "real";
|
|
105
|
+
if (integers + reals > 0)
|
|
106
|
+
return "text";
|
|
107
|
+
if (dates === texts)
|
|
108
|
+
return "date";
|
|
109
|
+
if (booleans === texts)
|
|
110
|
+
return "boolean";
|
|
111
|
+
return "text";
|
|
112
|
+
}
|
|
113
|
+
/** Names SQLite reserves for the row id: a column named so would shadow it. */
|
|
114
|
+
export const RESERVED_NAMES = ["rowid", "oid", "_rowid_"];
|
|
115
|
+
/** A fresh set of taken column names, pre-seeded with the reserved ones. */
|
|
116
|
+
export const newTakenNames = () => new Set(RESERVED_NAMES);
|
|
117
|
+
/** Double-quotes an SQL identifier. */
|
|
118
|
+
export const quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IngestRequest, IngestResult } from "./engine-types.ts";
|
|
2
|
+
declare const sqlite: typeof import("node:sqlite");
|
|
3
|
+
/** Largest JSON document (not JSON Lines) parsed in memory. */
|
|
4
|
+
export declare const MAX_JSON_BYTES: number;
|
|
5
|
+
export declare class EngineError extends Error {
|
|
6
|
+
readonly code: string;
|
|
7
|
+
constructor(code: string, message: string);
|
|
8
|
+
}
|
|
9
|
+
/** Opens a writable database for ingestion (a temporary file, journal off). */
|
|
10
|
+
export declare function openWritable(path: string): InstanceType<typeof sqlite.DatabaseSync>;
|
|
11
|
+
/** UTF-8 (with or without BOM), UTF-16 by BOM, or windows-1252 when the bytes are not UTF-8. */
|
|
12
|
+
export declare function detectEncoding(path: string): Promise<string>;
|
|
13
|
+
/** Builds `req.target` from `req.source` and returns what was ingested. */
|
|
14
|
+
export declare function runIngest(req: IngestRequest, progress: (rows: number) => void): Promise<IngestResult>;
|
|
15
|
+
/** Statistics for a database the Python helper wrote (XLSX), plus the metadata keys. */
|
|
16
|
+
export declare function runFinalize(target: string, sourceName: string, sourceSha256: string): IngestResult;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingestion side of the data engine (spec §17.2): streams a CSV/TSV/JSON/JSONL file into one
|
|
3
|
+
* SQLite file with batched inserts in transactions, then computes the statistics. Runs inside the
|
|
4
|
+
* engine process, never in the host. Values are stored without loss (see `infer.ts`).
|
|
5
|
+
*/
|
|
6
|
+
import { closeSync, createReadStream, openSync, readFileSync, readSync, rmSync, statSync, } from "node:fs";
|
|
7
|
+
import { CsvError, CsvParser, detectDelimiter } from "./csv.js";
|
|
8
|
+
import { convertCell, convertJson, looksLikeData, newTakenNames, quoteIdent, sanitizeIdentifier, uniqueName, } from "./infer.js";
|
|
9
|
+
import { jsonDocumentRecords, LineSplitter, toRecord } from "./json.js";
|
|
10
|
+
import { finalizeDatabase, INGEST_VERSION, META_SCHEMA } from "./stats.js";
|
|
11
|
+
const sqlite = process.getBuiltinModule("node:sqlite");
|
|
12
|
+
/** Largest JSON document (not JSON Lines) parsed in memory. */
|
|
13
|
+
export const MAX_JSON_BYTES = 50 * 1024 * 1024;
|
|
14
|
+
const BATCH_ROWS = 500;
|
|
15
|
+
const MAX_PARAMS = 32_766;
|
|
16
|
+
const COMMIT_ROWS = 5_000;
|
|
17
|
+
export class EngineError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Opens a writable database for ingestion (a temporary file, journal off). */
|
|
25
|
+
export function openWritable(path) {
|
|
26
|
+
rmSync(path, { force: true });
|
|
27
|
+
const db = new sqlite.DatabaseSync(path);
|
|
28
|
+
db.exec("PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA cache_size=-65536; PRAGMA temp_store=FILE;");
|
|
29
|
+
return db;
|
|
30
|
+
}
|
|
31
|
+
function head(path, bytes) {
|
|
32
|
+
const fd = openSync(path, "r");
|
|
33
|
+
try {
|
|
34
|
+
const buffer = Buffer.alloc(bytes);
|
|
35
|
+
const read = readSync(fd, buffer, 0, bytes, 0);
|
|
36
|
+
return buffer.subarray(0, read);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function* chunks(path) {
|
|
43
|
+
for await (const chunk of createReadStream(path, { highWaterMark: 1024 * 1024 }))
|
|
44
|
+
yield chunk;
|
|
45
|
+
}
|
|
46
|
+
/** UTF-8 (with or without BOM), UTF-16 by BOM, or windows-1252 when the bytes are not UTF-8. */
|
|
47
|
+
export async function detectEncoding(path) {
|
|
48
|
+
const start = head(path, 3);
|
|
49
|
+
if (start[0] === 0xef && start[1] === 0xbb && start[2] === 0xbf)
|
|
50
|
+
return "utf-8";
|
|
51
|
+
if (start[0] === 0xff && start[1] === 0xfe)
|
|
52
|
+
return "utf-16le";
|
|
53
|
+
if (start[0] === 0xfe && start[1] === 0xff)
|
|
54
|
+
return "utf-16be";
|
|
55
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
56
|
+
try {
|
|
57
|
+
for await (const chunk of chunks(path))
|
|
58
|
+
decoder.decode(chunk, { stream: true });
|
|
59
|
+
decoder.decode();
|
|
60
|
+
return "utf-8";
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return "windows-1252";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function* textChunks(path, encoding) {
|
|
67
|
+
const decoder = new TextDecoder(encoding);
|
|
68
|
+
for await (const chunk of chunks(path)) {
|
|
69
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
70
|
+
if (text)
|
|
71
|
+
yield text;
|
|
72
|
+
}
|
|
73
|
+
const rest = decoder.decode();
|
|
74
|
+
if (rest)
|
|
75
|
+
yield rest;
|
|
76
|
+
}
|
|
77
|
+
/** Batched multi-row inserts into one table, committing every few thousand rows. */
|
|
78
|
+
class TableWriter {
|
|
79
|
+
db;
|
|
80
|
+
table;
|
|
81
|
+
columns;
|
|
82
|
+
maxRows;
|
|
83
|
+
onRows;
|
|
84
|
+
pending = [];
|
|
85
|
+
statements = new Map();
|
|
86
|
+
sinceCommit = 0;
|
|
87
|
+
rows = 0;
|
|
88
|
+
constructor(db, table, columns, maxRows, onRows) {
|
|
89
|
+
this.db = db;
|
|
90
|
+
this.table = table;
|
|
91
|
+
this.columns = columns;
|
|
92
|
+
this.maxRows = maxRows;
|
|
93
|
+
this.onRows = onRows;
|
|
94
|
+
db.exec("BEGIN");
|
|
95
|
+
db.exec(`CREATE TABLE ${quoteIdent(table)}(${columns.map(quoteIdent).join(", ")})`);
|
|
96
|
+
}
|
|
97
|
+
statement(count) {
|
|
98
|
+
let statement = this.statements.get(count);
|
|
99
|
+
if (!statement) {
|
|
100
|
+
const group = `(${this.columns.map(() => "?").join(",")})`;
|
|
101
|
+
statement = this.db.prepare(`INSERT INTO ${quoteIdent(this.table)} VALUES ${Array(count).fill(group).join(",")}`);
|
|
102
|
+
this.statements.set(count, statement);
|
|
103
|
+
}
|
|
104
|
+
return statement;
|
|
105
|
+
}
|
|
106
|
+
add(row) {
|
|
107
|
+
if (this.rows >= this.maxRows)
|
|
108
|
+
throw new EngineError("ingest_limit", `The file has more than ${this.maxRows} rows`);
|
|
109
|
+
this.pending.push(row);
|
|
110
|
+
this.rows++;
|
|
111
|
+
if (this.pending.length >= Math.min(BATCH_ROWS, Math.floor(MAX_PARAMS / this.columns.length)))
|
|
112
|
+
this.flush();
|
|
113
|
+
}
|
|
114
|
+
flush() {
|
|
115
|
+
if (!this.pending.length)
|
|
116
|
+
return;
|
|
117
|
+
const width = this.columns.length;
|
|
118
|
+
const params = [];
|
|
119
|
+
for (const row of this.pending) {
|
|
120
|
+
for (let i = 0; i < width; i++)
|
|
121
|
+
params.push(row[i] ?? null);
|
|
122
|
+
}
|
|
123
|
+
this.statement(this.pending.length).run(...params);
|
|
124
|
+
this.sinceCommit += this.pending.length;
|
|
125
|
+
this.pending = [];
|
|
126
|
+
if (this.sinceCommit >= COMMIT_ROWS) {
|
|
127
|
+
this.db.exec("COMMIT");
|
|
128
|
+
this.db.exec("BEGIN");
|
|
129
|
+
this.sinceCommit = 0;
|
|
130
|
+
this.onRows(this.rows);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Adds a column (JSON keys can appear late); queued rows are written first. */
|
|
134
|
+
addColumn(name) {
|
|
135
|
+
this.flush();
|
|
136
|
+
this.db.exec(`ALTER TABLE ${quoteIdent(this.table)} ADD COLUMN ${quoteIdent(name)}`);
|
|
137
|
+
this.columns = [...this.columns, name];
|
|
138
|
+
this.statements.clear();
|
|
139
|
+
}
|
|
140
|
+
finish() {
|
|
141
|
+
this.flush();
|
|
142
|
+
this.db.exec("COMMIT");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function register(db, sheetName, ordinal, table, names, labels) {
|
|
146
|
+
db.prepare("INSERT INTO _alisio_sheets(ordinal,name,table_name,rows,columns) VALUES(?,?,?,0,?)").run(ordinal, sheetName, table, names.length);
|
|
147
|
+
const insert = db.prepare("INSERT INTO _alisio_columns(table_name,ordinal,name,label,inferred_type) VALUES(?,?,?,?,'text')");
|
|
148
|
+
names.forEach((name, i) => {
|
|
149
|
+
insert.run(table, i + 1, name, labels[i] ?? name);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async function ingestDelimited(db, req, encoding, onRows) {
|
|
153
|
+
let sample = "";
|
|
154
|
+
for await (const text of textChunks(req.source, encoding)) {
|
|
155
|
+
sample = text.slice(0, 64 * 1024);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
const delimiter = req.format === "tsv" ? "\t" : detectDelimiter(sample);
|
|
159
|
+
let writer;
|
|
160
|
+
let names = [];
|
|
161
|
+
let labels = [];
|
|
162
|
+
let irregular = 0;
|
|
163
|
+
const addRow = (fields) => {
|
|
164
|
+
if (fields.length !== names.length)
|
|
165
|
+
irregular++;
|
|
166
|
+
const values = [];
|
|
167
|
+
for (let i = 0; i < names.length; i++)
|
|
168
|
+
values.push(convertCell(fields[i] ?? ""));
|
|
169
|
+
writer.add(values);
|
|
170
|
+
};
|
|
171
|
+
const parser = new CsvParser(delimiter, (fields) => {
|
|
172
|
+
if (!writer) {
|
|
173
|
+
if (fields.length > req.limits.maxColumns)
|
|
174
|
+
throw new EngineError("ingest_limit", `The file has ${fields.length} columns; the limit is ${req.limits.maxColumns}`);
|
|
175
|
+
const header = !looksLikeData(fields);
|
|
176
|
+
const taken = newTakenNames();
|
|
177
|
+
labels = fields.map((cell, i) => (header && cell !== "" ? cell : `column_${i + 1}`));
|
|
178
|
+
names = labels.map((label, i) => uniqueName(sanitizeIdentifier(label, `column_${i + 1}`), taken));
|
|
179
|
+
writer = new TableWriter(db, "data", names, req.limits.maxRows, onRows);
|
|
180
|
+
if (!header)
|
|
181
|
+
addRow(fields);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
addRow(fields);
|
|
185
|
+
}, req.limits.maxCellChars);
|
|
186
|
+
try {
|
|
187
|
+
for await (const text of textChunks(req.source, encoding))
|
|
188
|
+
parser.write(text);
|
|
189
|
+
parser.end();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error instanceof CsvError)
|
|
193
|
+
throw new EngineError("ingest_limit", error.message);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
if (!writer)
|
|
197
|
+
throw new EngineError("ingest_invalid", "The file has no rows");
|
|
198
|
+
writer.finish();
|
|
199
|
+
return {
|
|
200
|
+
registered: { table: "data", names, labels, rows: writer.rows },
|
|
201
|
+
delimiter,
|
|
202
|
+
irregular,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
async function ingestJson(db, req, encoding, onRows) {
|
|
206
|
+
const names = [];
|
|
207
|
+
const labels = [];
|
|
208
|
+
const taken = newTakenNames();
|
|
209
|
+
const index = new Map();
|
|
210
|
+
let writer;
|
|
211
|
+
const addRecord = (record) => {
|
|
212
|
+
const keys = Object.keys(record);
|
|
213
|
+
const fresh = keys.filter((key) => !index.has(key));
|
|
214
|
+
if (fresh.length && names.length + fresh.length > req.limits.maxColumns)
|
|
215
|
+
throw new EngineError("ingest_limit", `The file has more than ${req.limits.maxColumns} distinct keys`);
|
|
216
|
+
for (const key of fresh) {
|
|
217
|
+
const name = uniqueName(sanitizeIdentifier(key, `column_${names.length + 1}`), taken);
|
|
218
|
+
index.set(key, names.length);
|
|
219
|
+
names.push(name);
|
|
220
|
+
labels.push(key);
|
|
221
|
+
if (writer)
|
|
222
|
+
writer.addColumn(name);
|
|
223
|
+
}
|
|
224
|
+
if (!writer) {
|
|
225
|
+
if (!names.length) {
|
|
226
|
+
// An empty object is still a row: keep one placeholder-free table of zero columns? Not
|
|
227
|
+
// representable in SQLite, so skip it.
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
writer = new TableWriter(db, "data", [...names], req.limits.maxRows, onRows);
|
|
231
|
+
}
|
|
232
|
+
const values = Array(names.length).fill(null);
|
|
233
|
+
for (const key of keys)
|
|
234
|
+
values[index.get(key)] = convertJson(record[key]);
|
|
235
|
+
writer.add(values);
|
|
236
|
+
};
|
|
237
|
+
const lineHandler = () => {
|
|
238
|
+
let n = 0;
|
|
239
|
+
return (line) => {
|
|
240
|
+
n++;
|
|
241
|
+
if (!line.trim())
|
|
242
|
+
return;
|
|
243
|
+
let value;
|
|
244
|
+
try {
|
|
245
|
+
value = JSON.parse(line);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw new EngineError("ingest_invalid", `Line ${n} is not valid JSON`);
|
|
249
|
+
}
|
|
250
|
+
addRecord(toRecord(value));
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
if (req.format === "jsonl") {
|
|
254
|
+
const splitter = new LineSplitter(lineHandler());
|
|
255
|
+
for await (const text of textChunks(req.source, encoding))
|
|
256
|
+
splitter.write(text);
|
|
257
|
+
splitter.end();
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
if (statSync(req.source).size > MAX_JSON_BYTES)
|
|
261
|
+
throw new EngineError("ingest_limit", `A JSON document is read in memory up to ${MAX_JSON_BYTES / (1024 * 1024)} MiB; use JSON Lines for larger files`);
|
|
262
|
+
const text = new TextDecoder(encoding).decode(readFileSync(req.source));
|
|
263
|
+
let records;
|
|
264
|
+
try {
|
|
265
|
+
records = jsonDocumentRecords(text);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Several objects on separate lines: JSON Lines with a .json name.
|
|
269
|
+
const splitter = new LineSplitter(lineHandler());
|
|
270
|
+
splitter.write(text);
|
|
271
|
+
splitter.end();
|
|
272
|
+
records = [];
|
|
273
|
+
}
|
|
274
|
+
for (const record of records)
|
|
275
|
+
addRecord(record);
|
|
276
|
+
}
|
|
277
|
+
if (!writer)
|
|
278
|
+
throw new EngineError("ingest_invalid", "The file has no records");
|
|
279
|
+
writer.finish();
|
|
280
|
+
return {
|
|
281
|
+
registered: { table: "data", names, labels, rows: writer.rows },
|
|
282
|
+
irregular: 0,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/** Builds `req.target` from `req.source` and returns what was ingested. */
|
|
286
|
+
export async function runIngest(req, progress) {
|
|
287
|
+
const encoding = await detectEncoding(req.source);
|
|
288
|
+
const db = openWritable(req.target);
|
|
289
|
+
let lastProgress = 0;
|
|
290
|
+
const onRows = (rows) => {
|
|
291
|
+
const now = Date.now();
|
|
292
|
+
if (now - lastProgress >= 250) {
|
|
293
|
+
lastProgress = now;
|
|
294
|
+
progress(rows);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
try {
|
|
298
|
+
db.exec(META_SCHEMA);
|
|
299
|
+
let delimiter;
|
|
300
|
+
let irregular = 0;
|
|
301
|
+
let registered;
|
|
302
|
+
if (req.format === "csv" || req.format === "tsv") {
|
|
303
|
+
const done = await ingestDelimited(db, req, encoding, onRows);
|
|
304
|
+
registered = done.registered;
|
|
305
|
+
delimiter = done.delimiter;
|
|
306
|
+
irregular = done.irregular;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
const done = await ingestJson(db, req, encoding, onRows);
|
|
310
|
+
registered = done.registered;
|
|
311
|
+
}
|
|
312
|
+
register(db, "data", 1, registered.table, registered.names, registered.labels);
|
|
313
|
+
const meta = db.prepare("INSERT OR REPLACE INTO _alisio_meta(key,value) VALUES(?,?)");
|
|
314
|
+
for (const [key, value] of Object.entries({
|
|
315
|
+
ingest_version: String(INGEST_VERSION),
|
|
316
|
+
format: req.format,
|
|
317
|
+
source_name: req.sourceName,
|
|
318
|
+
source_sha256: req.sourceSha256,
|
|
319
|
+
encoding,
|
|
320
|
+
...(delimiter ? { delimiter } : {}),
|
|
321
|
+
irregular_rows: String(irregular),
|
|
322
|
+
created_at: new Date().toISOString(),
|
|
323
|
+
}))
|
|
324
|
+
meta.run(key, value);
|
|
325
|
+
finalizeDatabase(db);
|
|
326
|
+
const sheets = db
|
|
327
|
+
.prepare('SELECT name, table_name AS "table", rows, columns FROM _alisio_sheets ORDER BY ordinal')
|
|
328
|
+
.all();
|
|
329
|
+
return {
|
|
330
|
+
sheets: sheets.map((s) => ({ ...s })),
|
|
331
|
+
encoding,
|
|
332
|
+
...(delimiter ? { delimiter } : {}),
|
|
333
|
+
irregularRows: irregular,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
db.close();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Statistics for a database the Python helper wrote (XLSX), plus the metadata keys. */
|
|
341
|
+
export function runFinalize(target, sourceName, sourceSha256) {
|
|
342
|
+
const db = new sqlite.DatabaseSync(target);
|
|
343
|
+
try {
|
|
344
|
+
db.exec("PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF;");
|
|
345
|
+
db.exec(META_SCHEMA);
|
|
346
|
+
const meta = db.prepare("INSERT OR REPLACE INTO _alisio_meta(key,value) VALUES(?,?)");
|
|
347
|
+
for (const [key, value] of Object.entries({
|
|
348
|
+
ingest_version: String(INGEST_VERSION),
|
|
349
|
+
format: "xlsx",
|
|
350
|
+
source_name: sourceName,
|
|
351
|
+
source_sha256: sourceSha256,
|
|
352
|
+
encoding: "utf-8",
|
|
353
|
+
created_at: new Date().toISOString(),
|
|
354
|
+
}))
|
|
355
|
+
meta.run(key, value);
|
|
356
|
+
finalizeDatabase(db);
|
|
357
|
+
const sheets = db
|
|
358
|
+
.prepare('SELECT name, table_name AS "table", rows, columns FROM _alisio_sheets ORDER BY ordinal')
|
|
359
|
+
.all();
|
|
360
|
+
return { sheets: sheets.map((s) => ({ ...s })), encoding: "utf-8", irregularRows: 0 };
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
db.close();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON and JSON Lines helpers of the dataset ingestion (spec §17.2). Pure: the ingestion engine
|
|
3
|
+
* feeds decoded text and gets records back. Nested values are stored as JSON text by the caller.
|
|
4
|
+
*/
|
|
5
|
+
/** Splits streamed text into lines (`\n` or `\r\n`); the last partial line waits for more text. */
|
|
6
|
+
export declare class LineSplitter {
|
|
7
|
+
private readonly onLine;
|
|
8
|
+
private rest;
|
|
9
|
+
constructor(onLine: (line: string) => void);
|
|
10
|
+
write(chunk: string): void;
|
|
11
|
+
end(): void;
|
|
12
|
+
}
|
|
13
|
+
/** A plain object stays as it is; any other JSON value becomes `{ value }`. */
|
|
14
|
+
export declare function toRecord(value: unknown): Record<string, unknown>;
|
|
15
|
+
/**
|
|
16
|
+
* The records of a JSON document: an array (each element a record) or a single object (one
|
|
17
|
+
* record). Throws a `SyntaxError` for invalid JSON.
|
|
18
|
+
*/
|
|
19
|
+
export declare function jsonDocumentRecords(text: string): Array<Record<string, unknown>>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON and JSON Lines helpers of the dataset ingestion (spec §17.2). Pure: the ingestion engine
|
|
3
|
+
* feeds decoded text and gets records back. Nested values are stored as JSON text by the caller.
|
|
4
|
+
*/
|
|
5
|
+
/** Splits streamed text into lines (`\n` or `\r\n`); the last partial line waits for more text. */
|
|
6
|
+
export class LineSplitter {
|
|
7
|
+
onLine;
|
|
8
|
+
rest = "";
|
|
9
|
+
constructor(onLine) {
|
|
10
|
+
this.onLine = onLine;
|
|
11
|
+
}
|
|
12
|
+
write(chunk) {
|
|
13
|
+
const text = this.rest + chunk;
|
|
14
|
+
let start = 0;
|
|
15
|
+
for (;;) {
|
|
16
|
+
const end = text.indexOf("\n", start);
|
|
17
|
+
if (end < 0)
|
|
18
|
+
break;
|
|
19
|
+
const stop = end > start && text.charCodeAt(end - 1) === 13 ? end - 1 : end;
|
|
20
|
+
this.onLine(text.slice(start, stop));
|
|
21
|
+
start = end + 1;
|
|
22
|
+
}
|
|
23
|
+
this.rest = start === 0 ? text : text.slice(start);
|
|
24
|
+
}
|
|
25
|
+
end() {
|
|
26
|
+
if (this.rest)
|
|
27
|
+
this.onLine(this.rest.endsWith("\r") ? this.rest.slice(0, -1) : this.rest);
|
|
28
|
+
this.rest = "";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** A plain object stays as it is; any other JSON value becomes `{ value }`. */
|
|
32
|
+
export function toRecord(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
34
|
+
? value
|
|
35
|
+
: { value };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The records of a JSON document: an array (each element a record) or a single object (one
|
|
39
|
+
* record). Throws a `SyntaxError` for invalid JSON.
|
|
40
|
+
*/
|
|
41
|
+
export function jsonDocumentRecords(text) {
|
|
42
|
+
const parsed = JSON.parse(text);
|
|
43
|
+
return Array.isArray(parsed) ? parsed.map(toRecord) : [toRecord(parsed)];
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query side of the data engine (spec §17.3): runs one read-only statement on a dataset file.
|
|
3
|
+
* The connection is opened `readOnly`, then `query_only=ON` and `trusted_schema=OFF`; extension
|
|
4
|
+
* loading stays disabled (the `node:sqlite` default). The host kills the process when a statement
|
|
5
|
+
* exceeds its time limit: `node:sqlite` offers no interrupt, progress handler or authorizer.
|
|
6
|
+
*/
|
|
7
|
+
import type { QueryRequest, QueryResult } from "./engine-types.ts";
|
|
8
|
+
/** Closes one connection (or all of them): the host does this before deleting a file. */
|
|
9
|
+
export declare function closeConnections(path?: string): void;
|
|
10
|
+
export declare class QueryRejected extends Error {
|
|
11
|
+
}
|
|
12
|
+
export declare function runQuery(req: QueryRequest): QueryResult;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { guardSql } from "./sql-guard.js";
|
|
2
|
+
const sqlite = process.getBuiltinModule("node:sqlite");
|
|
3
|
+
const connections = new Map();
|
|
4
|
+
function connect(path, heapBytes) {
|
|
5
|
+
let db = connections.get(path);
|
|
6
|
+
if (!db) {
|
|
7
|
+
db = new sqlite.DatabaseSync(path, { readOnly: true });
|
|
8
|
+
db.exec("PRAGMA query_only=ON; PRAGMA trusted_schema=OFF;");
|
|
9
|
+
if (heapBytes)
|
|
10
|
+
db.exec(`PRAGMA hard_heap_limit=${Math.floor(heapBytes)};`);
|
|
11
|
+
connections.set(path, db);
|
|
12
|
+
}
|
|
13
|
+
return db;
|
|
14
|
+
}
|
|
15
|
+
/** Closes one connection (or all of them): the host does this before deleting a file. */
|
|
16
|
+
export function closeConnections(path) {
|
|
17
|
+
for (const [key, db] of connections) {
|
|
18
|
+
if (path && key !== path)
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
db.close();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* already closed */
|
|
25
|
+
}
|
|
26
|
+
connections.delete(key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class QueryRejected extends Error {
|
|
30
|
+
}
|
|
31
|
+
export function runQuery(req) {
|
|
32
|
+
let sql = req.sql;
|
|
33
|
+
if (req.guard) {
|
|
34
|
+
const guarded = guardSql(sql);
|
|
35
|
+
if (!guarded.ok)
|
|
36
|
+
throw new QueryRejected(guarded.reason);
|
|
37
|
+
sql = guarded.sql;
|
|
38
|
+
}
|
|
39
|
+
const db = connect(req.db, req.heapBytes);
|
|
40
|
+
const statement = db.prepare(sql);
|
|
41
|
+
statement.setReadBigInts(true);
|
|
42
|
+
statement.setReturnArrays(true);
|
|
43
|
+
const columns = statement.columns().map((c) => c.name);
|
|
44
|
+
const rows = [];
|
|
45
|
+
let truncated = false;
|
|
46
|
+
let clipped = false;
|
|
47
|
+
const cell = (value) => {
|
|
48
|
+
if (value === null || value === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
if (typeof value === "bigint") {
|
|
51
|
+
const n = Number(value);
|
|
52
|
+
return Number.isSafeInteger(n) ? n : value.toString();
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === "number")
|
|
55
|
+
return value;
|
|
56
|
+
if (value instanceof Uint8Array)
|
|
57
|
+
return `<blob ${value.byteLength} bytes>`;
|
|
58
|
+
const text = String(value);
|
|
59
|
+
if (text.length > req.cellChars) {
|
|
60
|
+
clipped = true;
|
|
61
|
+
return `${text.slice(0, req.cellChars)}…`;
|
|
62
|
+
}
|
|
63
|
+
return text;
|
|
64
|
+
};
|
|
65
|
+
// JS numbers bind as REAL, which `LIMIT`/`OFFSET` reject: integral values bind as INTEGER.
|
|
66
|
+
const bound = (req.params ?? []).map((value) => typeof value === "number" && Number.isSafeInteger(value) ? BigInt(value) : value);
|
|
67
|
+
for (const row of statement.iterate(...bound)) {
|
|
68
|
+
if (rows.length >= req.maxRows) {
|
|
69
|
+
truncated = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
rows.push(row.map(cell));
|
|
73
|
+
}
|
|
74
|
+
return { columns, rows, truncated, clipped };
|
|
75
|
+
}
|