@alisio/core 0.1.0-alpha.20 → 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/agents/active.d.ts +19 -0
- package/dist/agents/active.js +41 -0
- package/dist/agents/capabilities.d.ts +26 -0
- package/dist/agents/capabilities.js +120 -0
- package/dist/agents/create-agent-skill.d.ts +11 -0
- package/dist/agents/create-agent-skill.js +60 -0
- package/dist/agents/definitions.d.ts +88 -0
- package/dist/agents/definitions.js +404 -0
- package/dist/agents/draft.d.ts +41 -0
- package/dist/agents/draft.js +133 -0
- package/dist/agents/service.d.ts +57 -0
- package/dist/agents/service.js +86 -0
- package/dist/agents/templates.d.ts +8 -0
- package/dist/agents/templates.js +102 -0
- 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 +48 -0
- package/dist/application.js +228 -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/commands/catalog.js +13 -1
- 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 +30 -5
- package/dist/index.js +29 -4
- 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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages between the host and the data engine, a separate process (`node` or `bun`) that runs
|
|
3
|
+
* `node:sqlite` so that a stuck statement can be stopped with a kill (`worker.terminate()` cannot
|
|
4
|
+
* interrupt a native SQLite call; verified on Node 22.19 and Bun 1.4.2). Newline-delimited JSON
|
|
5
|
+
* over stdin/stdout. Types only: no runtime code, safe to import from anywhere.
|
|
6
|
+
*/
|
|
7
|
+
export interface DataLimits {
|
|
8
|
+
maxRows: number;
|
|
9
|
+
maxColumns: number;
|
|
10
|
+
/** Characters per cell. */
|
|
11
|
+
maxCellChars: number;
|
|
12
|
+
}
|
|
13
|
+
export interface IngestRequest {
|
|
14
|
+
op: "ingest";
|
|
15
|
+
id: number;
|
|
16
|
+
source: string;
|
|
17
|
+
format: "csv" | "tsv" | "json" | "jsonl";
|
|
18
|
+
/** Temporary database file to create (the host renames it). */
|
|
19
|
+
target: string;
|
|
20
|
+
sourceName: string;
|
|
21
|
+
sourceSha256: string;
|
|
22
|
+
limits: DataLimits;
|
|
23
|
+
}
|
|
24
|
+
/** Computes the statistics of a database the Python helper wrote. */
|
|
25
|
+
export interface FinalizeRequest {
|
|
26
|
+
op: "finalize";
|
|
27
|
+
id: number;
|
|
28
|
+
target: string;
|
|
29
|
+
sourceName: string;
|
|
30
|
+
sourceSha256: string;
|
|
31
|
+
}
|
|
32
|
+
export interface QueryRequest {
|
|
33
|
+
op: "query";
|
|
34
|
+
id: number;
|
|
35
|
+
db: string;
|
|
36
|
+
sql: string;
|
|
37
|
+
params?: Array<string | number | null>;
|
|
38
|
+
/** Rows returned; one more is read to report truncation. */
|
|
39
|
+
maxRows: number;
|
|
40
|
+
/** Cells longer than this many characters are cut (and marked). */
|
|
41
|
+
cellChars: number;
|
|
42
|
+
/** Apply the lexical guard in the engine too (model-written SQL). */
|
|
43
|
+
guard: boolean;
|
|
44
|
+
/** Soft cap of SQLite's heap, in bytes. */
|
|
45
|
+
heapBytes?: number;
|
|
46
|
+
}
|
|
47
|
+
export interface CloseRequest {
|
|
48
|
+
op: "close";
|
|
49
|
+
id: number;
|
|
50
|
+
/** A database file to close, or every open one when absent. */
|
|
51
|
+
db?: string;
|
|
52
|
+
}
|
|
53
|
+
export type EngineRequest = IngestRequest | FinalizeRequest | QueryRequest | CloseRequest;
|
|
54
|
+
export interface SheetSummary {
|
|
55
|
+
name: string;
|
|
56
|
+
table: string;
|
|
57
|
+
rows: number;
|
|
58
|
+
columns: number;
|
|
59
|
+
}
|
|
60
|
+
export interface IngestResult {
|
|
61
|
+
sheets: SheetSummary[];
|
|
62
|
+
encoding: string;
|
|
63
|
+
delimiter?: string;
|
|
64
|
+
irregularRows: number;
|
|
65
|
+
}
|
|
66
|
+
export interface QueryResult {
|
|
67
|
+
columns: string[];
|
|
68
|
+
rows: Array<Array<string | number | null>>;
|
|
69
|
+
truncated: boolean;
|
|
70
|
+
/** Some cell was cut at `cellChars`. */
|
|
71
|
+
clipped: boolean;
|
|
72
|
+
}
|
|
73
|
+
export type EngineResponse = {
|
|
74
|
+
id: number;
|
|
75
|
+
type: "result";
|
|
76
|
+
result: IngestResult | QueryResult | Record<string, never>;
|
|
77
|
+
} | {
|
|
78
|
+
id: number;
|
|
79
|
+
type: "progress";
|
|
80
|
+
rows: number;
|
|
81
|
+
} | {
|
|
82
|
+
id: number;
|
|
83
|
+
type: "error";
|
|
84
|
+
code: string;
|
|
85
|
+
message: string;
|
|
86
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages between the host and the data engine, a separate process (`node` or `bun`) that runs
|
|
3
|
+
* `node:sqlite` so that a stuck statement can be stopped with a kill (`worker.terminate()` cannot
|
|
4
|
+
* interrupt a native SQLite call; verified on Node 22.19 and Bun 1.4.2). Newline-delimited JSON
|
|
5
|
+
* over stdin/stdout. Types only: no runtime code, safe to import from anywhere.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
/** A value ready to bind: `bigint` (stored as INTEGER), `number` (REAL), text or `null`. */
|
|
8
|
+
export type StoredValue = bigint | number | string | null;
|
|
9
|
+
export interface ConvertedCell {
|
|
10
|
+
value: StoredValue;
|
|
11
|
+
}
|
|
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 declare function convertCell(raw: string): StoredValue;
|
|
19
|
+
/** A JSON value stored without loss: numbers stay numbers, nested values become JSON text. */
|
|
20
|
+
export declare function convertJson(value: unknown): StoredValue;
|
|
21
|
+
/** Whether every non-empty cell of a row is a plain number (then the row is data, not a header). */
|
|
22
|
+
export declare function looksLikeData(row: string[]): boolean;
|
|
23
|
+
/** `region`, `año` → `ano`, `Total ($)` → `total`; never empty, never starts with a digit. */
|
|
24
|
+
export declare function sanitizeIdentifier(label: string, fallback: string): string;
|
|
25
|
+
/** Deduplicates names in order (`x`, `x_2`, `x_3`); `taken` carries names across calls. */
|
|
26
|
+
export declare function uniqueName(name: string, taken: Set<string>): string;
|
|
27
|
+
export type InferredType = "integer" | "real" | "date" | "boolean" | "text";
|
|
28
|
+
/**
|
|
29
|
+
* Type hint of a column from the storage class and value of its first cells (`kind` is SQLite's
|
|
30
|
+
* `typeof`). All integers → `integer`; all numbers → `real`; all dates / booleans → those;
|
|
31
|
+
* anything mixed or empty → `text`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function inferType(sample: Array<{
|
|
34
|
+
kind: string;
|
|
35
|
+
value: unknown;
|
|
36
|
+
}>): InferredType;
|
|
37
|
+
/** Names SQLite reserves for the row id: a column named so would shadow it. */
|
|
38
|
+
export declare const RESERVED_NAMES: readonly ["rowid", "oid", "_rowid_"];
|
|
39
|
+
/** A fresh set of taken column names, pre-seeded with the reserved ones. */
|
|
40
|
+
export declare const newTakenNames: () => Set<string>;
|
|
41
|
+
/** Double-quotes an SQL identifier. */
|
|
42
|
+
export declare const quoteIdent: (name: string) => string;
|
|
@@ -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>>;
|