autosql
v2.0.0
Published
An auto-parser of JSON into SQL.
Maintainers
Readme
AutoSQL - Automated SQL Insertions for Modern Data Workflows

🚀 AutoSQL — A Smarter Way to Insert Data
AutoSQL is a TypeScript-powered zero-config ingest layer for SQL databases. It helps engineers and analysts insert structured or semi-structured JSON into MySQL or PostgreSQL with no manual schema prep, modelling, or migrations.
Built for modern ELT workflows, AutoSQL automatically infers the right schema — types, keys, indexes — and creates or updates tables on the fly. It’s ideal for:
- API responses and flat files used in data warehousing
- No-code/low-code tool exports
- Rapid ingestion pipelines where structure evolves frequently
Unlike traditional ORMs, AutoSQL doesn’t require boilerplate models or migration scripts. Just connect, pass in your data, and let AutoSQL handle the rest.
See CHANGELOG.md for release history.
📦 Installation
npm install autosql📚 Table of Contents
- Supported SQL Dialects
- Quick Start
- Configuration
- Insert Options
- Core Interfaces
- Convenience Utilities
- Streaming Inserts
- Schema History & Drift Detection
- Multi-writer Safety
- Large-dataset Support
🧬 Supported SQL Dialects
AutoSQL supports:
- MySQL (via
mysql2) - PostgreSQL (via
pg) - SQL Server / Azure SQL (via
mssql) — core ETL path: create, insert, idempotent re-ingest,MERGEupsert, add-column evolution, and multilingual/emoji viaNVARCHAR. A few advanced features (streaming, row-level history, split tables, bulk-copy) are not yet implemented for SQL Server — use MySQL/Postgres for those.
The dialect drivers (mysql2 / pg / mssql) and pg-copy-streams (for bulkLoad) are optional
peer dependencies — install only the driver for the dialect you use:
npm install autosql mysql2 # MySQL
npm install autosql pg # PostgreSQL (+ pg-copy-streams for bulkLoad)
npm install autosql mssql # SQL Server / Azure SQLOptional support for SSH tunneling is available via:
⚡ Quick Start
import { Database } from 'autosql';
const config = {
sqlDialect: 'mysql',
host: 'localhost',
user: 'root',
password: 'root',
database: 'mysql',
port: 3306
};
const data = [
{ id: 1, name: 'Alice', created_at: '2024-01-01' },
{ id: 2, name: 'Bob', created_at: '2024-01-02' }
];
let db: Database;
db = Database.create(config);
await db.establishConnection();
// Option 1: Direct insert if schema already exists or is managed externally
await db.autoInsertData({ table: 'target_table', data });
// Option 2: Fully automated schema + insert workflow
await db.autoSQL('target_table', data);
await db.closeConnection();AutoSQL will:
- Infer metadata and key structure
- Create or alter the target table
- Batch insert rows
- Handle dialect-specific quirks automatically
- Automatically manage timestamps and optional history tracking (if configured)
⚙️ Configuration
export interface DatabaseConfig {
// Required connection settings
sqlDialect: 'mysql' | 'pgsql' | 'sqlserver';
host?: string;
user?: string;
password?: string;
database?: string;
port?: number;
connectionLimit?: number; // Max pooled connections (governs insert/introspection concurrency) — defaults to 5
// Optional table target
// ALL SETTINGS BELOW HERE ARE OPTIONAL
schema?: string;
table?: string;
// Metadata control
metaData?: { [tableName: string]: MetadataHeader };
existingMetaData?: { [tableName: string]: MetadataHeader };
updatePrimaryKey?: boolean;
primaryKey?: string[];
// Table creation and charset settings.
// These also pin the connection encoding: MySQL connects with `charset` (defaults to the
// dialect's utf8mb4) and Postgres with `client_encoding` from `encoding` (defaults to UTF8),
// so 4-byte characters (emoji, some CJK) are transferred intact rather than failing with a
// MySQL `Incorrect string value` error against an otherwise-utf8mb4 table.
engine?: string;
charset?: string;
collate?: string;
encoding?: string;
// Type inference controls
pseudoUnique?: number; // The % of values that must be unique to be considered pseudoUnique — defaults to 0.9 (90%)
categorical?: number; // The % of values that must be repeated to be considered categorical — defaults to 0.20 (20%)
autoIndexing?: boolean; // Automatically identify and add indexes to tables when altering / creating — defaults to TRUE
// Max fractional-digit scale for inferred decimals. NO hard default (v2.0.0): a decimal keeps the
// full scale the data needs, up to the dialect limit (MySQL 30, SQL Server 38, Postgres 16383), so
// precision is never silently lost. Set a value (e.g. 2 for currency) to deliberately cap scale —
// values beyond the cap are rounded WITH A WARNING, or (see decimalToVarchar) stored as text.
decimalMaxLength?: number;
decimalToVarchar?: boolean; // When a decimal exceeds the scale cap, store the column as varchar (exact text) instead of rounding — defaults to false
maxKeyLength?: number; // Limits indexes / primary keys from using columns that are longer than this length — defaults to 255
maxCompositeKeyColumns?: number; // Cap the auto-detected composite primary key at this many columns (bounds the O(2^N) key search) — defaults to 4
maxVarcharLength?: number; // Prevents varchar columns from exceeding this length, autoconverts to text — defaults to 1024
// Add an auto-increment surrogate key (BIGINT AUTO_INCREMENT / BIGSERIAL) when no natural
// key is found, so a table can still be created. A natural key always wins. Off by default.
surrogateKey?: boolean;
surrogateKeyColumn?: string; // Column name for the surrogate — defaults to "autosql_id"
// Force specific columns to always be stored as varchar regardless of their content.
// Use this for string-encoded identifiers that would otherwise be inferred as numeric
// types: phone numbers, zip codes, padded codes (e.g. "007"), account numbers, etc.
forceStringColumns?: string[];
// Force specific columns to be typed boolean. By default a bare 0/1 infers as an INTEGER (v2.0.0):
// boolean is only inferred from real true/false. Use this for flags stored as 0/1. An out-of-domain
// value (e.g. 2, "yes") in a hinted column throws rather than being silently coerced.
booleanColumns?: string[];
// Opt-in (MySQL only): when configuring a PRE-EXISTING table, convert its text columns to the
// target charset (utf8mb4) so externally-created 3-byte utf8/utf8mb3 columns accept 4-byte
// characters. Convergent + best-effort (a failed CONVERT is logged, not fatal). Defaults to false.
upgradeCharset?: boolean;
// Locale number parsing. Provide BOTH together to disambiguate single-separator values
// (e.g. thousandsSeparator: "." + decimalSeparator: "," parses "1.000" as 1000, not 1).
// Omit both to use the default heuristic.
thousandsSeparator?: string;
decimalSeparator?: string;
// Sampling controls
sampling?: number; // If provided data exceeds samplingMinimum rows, we sample this % of values for identifying uniques and column types — defaults to 0, allows values between 0 and 1
samplingMinimum?: number; // If provided data exceeds this row count, sampling kicks in — defaults to 100
// Insert strategy
insertType?: 'UPDATE' | 'INSERT'; // UPDATE upserts (replaces non-primary-key values); INSERT appends and errors on a duplicate key. Defaults to UPDATE.
insertStack?: number; // Maximum number of rows to insert in one query - defaults to 100
bulkLoad?: boolean; // Populate staging with the dialect's bulk-copy (Postgres COPY / MySQL LOAD DATA LOCAL INFILE) instead of parameterised INSERT — much faster for large loads; falls back to INSERT on failure. MySQL/Postgres only. Defaults to false.
safeMode?: boolean; // Prevent the altering of tables if needed - defaults to false
deleteColumns?: boolean; // Drop columns if needed - defaults to false
// Timestamp columns
addTimestamps?: boolean; // If TRUE, runs function ensureTimestamps as part of AutoSQL function. Which adds a dwh_created_at, dwh_modified_at and dwh_loaded_at timestamp columns that are automatically filled. -- defaults to TRUE
// Optional advanced insert modes
useStagingInsert?: boolean; // Enable temporary staging table insert pattern (if supported) -- defaults to TRUE
addHistory?: boolean; // Automatically duplicate rows into history tables before overwrites -- defaults to FALSE
historyTables?: string[]; // Names of the tables to have history tracked -- pairs with addHistory above
autoSplit?: boolean; // Automatically split large datasets (columns) across multiple tables if needed
addNested?: boolean; // Extracts nested JSON values into separate tables with composite primary keys -- defaults to FALSE
nestedTables?: string[]; // Nested Table names to apply nested extraction on -- if nesting `columnA` on `tableB`, this would be [`tableB_columnA`]
excludeBlankColumns?: boolean; // Exclude columns from insert queries if all their values are null or undefined -- defaults to TRUE
sanitizeInvalidChars?: boolean; // Strip NUL bytes and unpaired UTF-16 surrogates from string values before insert -- defaults to FALSE
// Performance scaling
useWorkers?: boolean; // Enables parallel worker threads — defaults to true
maxWorkers?: number; // Maximum concurrent workers — defaults to 8
workerTaskTimeout?: number; // Seconds before a wedged worker task fails instead of hanging the load (0 = disabled). A dead worker is always caught regardless; this only guards an alive-but-hung one. Defaults to 0.
// Table naming
// Change these if your schema already has tables that use the default prefixes/suffixes.
stagingPrefix?: string; // Prefix for auto-created staging tables — defaults to "temp_staging__"
historyTableSuffix?: string; // Suffix for auto-created history tables — defaults to "__history"
// Logging — omit to suppress all output, pass `console` to restore default behaviour,
// or supply a structured logger ({ log, warn, error }).
logger?: {
log?: (msg: string) => void;
warn?: (msg: string) => void;
error?: (msg: string) => void;
};
// Multi-writer safety (v1.0.5+)
useSchemaLock?: boolean; // Acquire a per-table advisory lock during schema inference + DDL — defaults to false
schemaLockTimeout?: number; // Seconds to wait for the advisory lock before throwing SchemaLockTimeoutError — defaults to 30
// Schema history & drift detection (v1.1.0+)
schemaHistory?: boolean; // Record every DDL operation to an audit log table — defaults to false
schemaHistoryTable?: string; // Name of the audit log table — defaults to "autosql_schema_history"
schemaHistorySchema?: string; // Schema/database to place the audit log table in — defaults to the current schema
detectDrift?: boolean; // Check for out-of-band schema changes on every autoSQL call — defaults to true (when schemaHistory is enabled)
strictDriftDetection?: boolean; // Throw SchemaDriftError instead of warning when drift is detected — defaults to false
// Streaming (v1.1.0+)
streamingStagingPrefix?: string; // Prefix for per-run stream staging tables — defaults to "autosql_stream__"
streamMaxRetries?: number; // Max per-row retry rounds after a bulk merge failure — defaults to 3
rejectedRowsTable?: string; // If set, unrecoverable rows are written here instead of throwing
rejectedRowsSchema?: string; // Schema to place the rejected rows table in — defaults to current schema
keepOrphanedStagingTables?: boolean; // Skip orphaned stream staging table cleanup on openStream — defaults to false
// SSH tunneling support
sshConfig?: SSHKeys;
sshStream?: ClientChannel | null;
sshClient?: SSHClient;
}🧠 Metadata Format
AutoSQL can infer metadata from your data, or you can specify it manually:
meta_data: [
{
created_at: {
type: 'datetime',
length: 0,
allowNull: true,
default: 'CURRENT_TIMESTAMP',
index: true
}
},
{
name: {
type: 'varchar',
length: 50,
allowNull: false,
unique: true,
primary: true
}
}
]🔐 SSH Support
AutoSQL supports SSH tunneling for connecting to remote MySQL or PostgreSQL servers via an intermediate gateway.
Include the SSH configuration inside your DatabaseConfig object under the sshConfig key. AutoSQL will automatically establish the tunnel when establishConnection() is called.
const config: DatabaseConfig = {
...
sshConfig: {
username: 'ssh_user',
host: 'remote_host',
port: 22,
password: 'password',
private_key: 'PRIVATE_KEY_STRING',
private_key_path: '/path/to/key.pem',
source_address: 'localhost',
source_port: 3306,
destination_address: 'remote_sql_host',
destination_port: 3306
}
}
const db = Database.create(config);
await db.establishConnection();
// Tunnel is now active and DB connection is routed through it📑 Insert Options
These control how data is batched, inserted, and optionally how schema alterations are handled.
Basic Insert Options
insertType:'UPDATE' | 'INSERT'
Determines behaviour on duplicate keys.UPDATEreplaces non-primary key values with new ones. Defaults to'INSERT'.insertStack:number
Maximum number of rows to insert in a single query. Defaults to100.safeMode:boolean
Iftrue, prevents any table alterations during runtime. Defaults tofalse.deleteColumns:boolean
Allows dropping of existing columns when altering tables. Defaults tofalse.
⏱ Timestamp Columns
addTimestamps:boolean
Iftrue, automatically adds and manages the following timestamp columns:dwh_created_at,dwh_modified_at,dwh_loaded_at
These are injected and updated during insert operations. Defaults totrue. This will also check a variety of common timestamp columns and will only add the equivalent if they do not exist in the existing data. As an example, modified timestamps will check modified_at, modify_at, modified_date, update_date etc.
🧪 Advanced Insert Modes
useStagingInsert:boolean
Enables a staging table strategy where data is first inserted into a temporary table before being merged into the target. Useful for large or high-concurrency environments. Defaults totrue.addHistory:boolean
If enabled, before overwriting rows (inUPDATEmode), AutoSQL writes the previous version into a corresponding history table. RequiresuseStagingInsert. Defaults tofalse. (Not available on SQL Server.)historyTables:string[]
List of table names to track with history inserts. Used in conjunction withaddHistory.bulkLoad:boolean
Populate staging tables with the dialect's native bulk-copy — PostgresCOPY FROM STDIN(via the optionalpg-copy-streams) or MySQLLOAD DATA LOCAL INFILE— instead of parameterised multi-rowINSERT. Much faster and cheaper for large loads; the merge (staging → real) and upsert semantics are unchanged. Falls back toINSERT(with a warning) if bulk load fails for a table. MySQL/Postgres only. Defaults tofalse.rejectedRowsTable:string— graceful degradation (opt-in)
By default a load is all-or-nothing: a row the database rejects fails the whole batch. SetrejectedRowsTableand AutoSQL instead retries the failed batch row-by-row, lands the good rows, and diverts the unrecoverable ones (with their error and raw data) to this table — so one bad row no longer sinks the load. Works on the streaming path, the direct path (useStagingInsert: false), and the default staging path. When combined withaddHistory, each row's before-image and its merge commit in a single transaction, so a diverted row leaves no data change and no spurious history entry. WithoutrejectedRowsTablethe fail-loud all-or-nothing default is unchanged.Schema fast paths (5th arg to
autoSQL/autoSQLChunked:options)
For repeated loads you can skip inference and/or introspection:assumeSchema: pass aMetadataHeaderthe caller already knows — AutoSQL skips per-value type inference for the covered columns (any not covered are still inferred).existingSchema: pass the CURRENT table's resolved schema — AutoSQL skips the introspection round-trip.autoSQLreturns its resolvedmetaDatain theQueryResult, so cache that and pass it back asexistingSchemaon the next load.
const first = await db.autoSQL('events', batch1); // reuse the resolved schema to skip re-introspection next time await db.autoSQL('events', batch2, undefined, undefined, { existingSchema: first.metaData });autoSplit:boolean
Automatically splits datasets across multiple tables when the row size or column count exceeds allowed limits. Prevents failed inserts due to row size limits. Defaults tofalseaddNested:boolean
If enabled, AutoSQL will extract nested objects or arrays from a field and insert them into a separate table.
Defaults tofalse.excludeBlankColumns:boolean
When enabled, columns that contain only null or undefined values across all rows are excluded from the generated insert queries and parameter lists. This helps to avoid inserting empty data unnecessarily. Defaults totrue.sanitizeInvalidChars:boolean
When enabled, string values are cleaned of characters a SQL text column cannot store before insert: NUL bytes (U+0000) are removed, and unpaired UTF-16 surrogates are replaced with the Unicode replacement character (U+FFFD). These otherwise hard-fail Postgres (invalid byte sequence for encoding UTF8,unsupported Unicode escape sequence) and can corrupt MySQL. Well-formed text — including emoji and non-ASCII scripts (日本語, café, Привет) — is left untouched. Enable this when ingesting free-text that may contain pasted or malformed bytes. Note this is a separate concern from connection charset: emoji/CJK that fail with a MySQLIncorrect string valueerror are fixed by the pinned connection charset (seecharset/encoding), not by this option.
Defaults tofalse(it mutates data, so it is opt-in).surrogateKey:boolean/surrogateKeyColumn:string
When a dataset has no natural primary key, enablingsurrogateKeyadds an auto-increment surrogate column (BIGINT AUTO_INCREMENTon MySQL,BIGSERIALon Postgres) so the table can still be created and Postgres upserts have a conflict target. The column is namedautosql_idunless you override it withsurrogateKeyColumn.- A natural key always wins — the surrogate is only a fallback used when no single-column or composite key is found.
- Sticky / idempotent — the surrogate is anchored to the existing table: re-ingestion never thrashes the primary key, a later batch that happens to be unique cannot introduce a competing key, and an existing table without a surrogate never gains one.
- Database-generated — auto-increment columns are omitted from generated
INSERTcolumn lists so the database assigns the value. - Append semantics — because the surrogate is unique per physical insert, every ingest appends; upsert (
insertType: "UPDATE") never matches an existing row. Provide a naturalprimaryKeyif you need upserts. - Not compatible with
addHistory,addNested, orautoSplit(config validation throws). Applies toautoSQL/autoSQLChunked. Defaults tofalse.
nestedTables:string[]
Used in conjunction withaddNested. Specifies which nested structures should be extracted and written into their own relational tables.Format: Each entry should follow the pattern:
"<tableName>_<columnName>"For each entry:
- If the dataset includes a table that matches
<tableName>, - And that table contains a column named
<columnName>, - And the column contains a JSON object or an array of JSON objects,
- AutoSQL will extract the nested structure into a new table named
<tableName>_<columnName>
Behavior:
- The new nested table will include the parent row’s primary key (e.g.,
row1_id) to maintain relationships - The nested object will define the child table’s schema
- Arrays will be flattened—each item becomes a separate row in the nested table
- If the dataset includes a table that matches
🏷 Table Naming
stagingPrefix:stringPrefix applied to auto-created staging tables. Change this if your schema already has tables starting with the default prefix. Defaults to"temp_staging__".Note: autosql identifies its throwaway staging tables by this prefix (they skip primary-key reconciliation, are dropped after each run, etc.). Do not name a real target table with the
stagingPrefix, or it will be treated as a staging table.historyTableSuffix:stringSuffix applied to auto-created history tables. Change this if your schema already has tables ending with the default suffix. Defaults to"__history".
🔬 Type Inference Overrides
forceStringColumns:string[]Column names that should always be stored asvarcharregardless of their content. Use this for string-encoded identifiers that would otherwise be inferred as numeric types:forceStringColumns: ['phone', 'zip_code', 'account_number', 'product_code']Without this, a column containing
"14155550100"would be inferred asbigint. With it, the column staysvarcharand leading zeros, formatting, and string semantics are preserved.booleanColumns:string[]Column names that should be typedboolean. By default (v2.0.0) a bare0/1infers as an integer — boolean is only inferred from realtrue/false— so keys/counts/coded categories aren't mis-typed. Use this hint for flags genuinely stored as0/1. An out-of-domain value (2,"yes") in a hinted column throws rather than being silently coerced (forcing a value to boolean is lossy).decimalToVarchar:booleanBy default a decimal keeps the full scale the data needs, up to the dialect's numeric limit — precision is never silently lost. If you setdecimalMaxLengthto cap scale, values beyond the cap are rounded with a warning; enabledecimalToVarcharto instead store the whole column asvarchar(exact text) so no value is rounded. Defaults tofalse.thousandsSeparator:string/decimalSeparator:stringDisambiguate locale number formats. By default a value with a single separator like"1,000"is treated as a decimal (1.0). Provide both (they must be set together) to parse explicitly — e.g. withthousandsSeparator: "."anddecimalSeparator: ",","1.000"parses as1000and"1,5"as1.5. Omit both to use the default heuristic.// European-formatted input thousandsSeparator: '.', decimalSeparator: ','
🛡 DDL Safety
AutoSQL automatically attempts to compensate for failed ALTER TABLE operations to keep your schema in a consistent state.
PostgreSQL: DDL is fully transactional. If an ALTER TABLE fails, the database rolls it back automatically as part of the transaction. No additional action is needed.
MySQL: DDL is non-transactional. If an ALTER TABLE fails, AutoSQL runs a best-effort compensating pass:
- Newly added columns are dropped (
DROP COLUMN IF EXISTS— safe to run even if the column was never created) - Modified columns are restored to their previous type
- Renamed columns are renamed back
- Dropped columns cannot be recovered — a warning is logged and no compensation is attempted
Warnings about irrecoverable changes (dropped columns, nullable changes) are always emitted via the configured logger.
🧵 Scaling & Workers
useWorkers:booleanEnables parallel worker threads for inserting batches. Improves performance with large datasets. Defaults totrue. Note: Workers require a compiledworker.jsfile. When running viats-nodeor from TypeScript source, the compiled file may not exist — AutoSQL detects this automatically and falls back to direct execution with a warning.maxWorkers:numberMaximum number of concurrent workers to use during insertion. Must be used withuseWorkers. Defaults to8
🏁 Core Classes: Database (with AutoSQL Utilities)
The Database class is the primary entry point into AutoSQL's workflow. It handles connection management and exposes high-level autoSQL methods for automated insertions, table creation, and metadata handling.
import { Database } from 'autosql';
const db = Database.create(config);
await db.establishConnection();
await db.autoConfigureTable(
'target_table', // table name
sampleData, // raw input data
null, // optional existing metadata
initialMeta // optional manually defined metadata
);This is the core interface for managing connections, generating queries, and executing inserts.
⚙️ Database Class
🔸 Static Method
Database.create(config)– Returns an instance of eitherMySQLDatabaseorPostgresDatabasebased on config.
🔹 Core Methods
getConfig()– Returns the fullDatabaseConfigused to initialise this instance.updateSchema(schema: string)– Sets the instance's default schema (mutates config). For a per-operation schema that won't interfere with concurrent operations, prefer passingschematoautoSQL/autoSQLChunked/openStream, or userunWithSchemabelow.runWithSchema(schema: string, fn: () => T)– Runsfnwithschemaas the effective schema for the duration of the async operation, without mutating instance config — concurrent operations with different schemas stay isolated. (This is what the per-callschemaargument uses internally.)getDialect()– Returns the SQL dialect (mysqlorpgsql).establishConnection()– Creates and stores a live database connection.testConnection()– Attempts to connect and returns success as a boolean.runQuery(queryOrParams: QueryInput | QueryInput[])– Executes a SQL query or list of queries.runTransaction(queries: QueryInput[])– Runs the queries atomically on a single pinned connection (BEGIN→ … →COMMIT, with automaticROLLBACKon failure and transient-error retry). Use this for transactional work.startTransaction(client)/commit(client)/rollback(client)– Low-level transaction control; each requires a pinned connection (managed internally byrunTransaction). PreferrunTransaction().runTransactionsWithConcurrency(queryGroups: QueryInput[][])– Runs multiple query batches in parallel.closeConnection()– Safely closes the active DB connection.
🔹 Table and Schema Methods
checkSchemaExists(schemaName: string)– Returns whether the given schema exists.createSchema(schemaName: string)– Creates the schema if it doesn't exist already.createTableQuery(table: string, headers: MetadataHeader)– ReturnsQueryInput[]to create a table.alterTableQuery(table: string, oldHeaders: MetadataHeader, newHeaders: MetadataHeader)– ReturnsQueryInput[]to alter an existing table.dropTableQuery(table: string)– Returns aQueryInputto drop a table.getTableMetaData(schema: string, table: string)– Fetches current metadata from the DB for a given table.
🔹 AutoSQL Methods (Exposed on db)
autoSQL(table: string, data: Record<string, any>[], schema?: string, primaryKey?: string[], options?: { assumeSchema?: MetadataHeader })
The simplest way to handle everything — metadata inference, schema changes, batching, inserting, history, workers, and nested structures — in one call.
Designed for production-ready automation and one-liner ingestion.
Passoptions.assumeSchemawhen you already know the schema (e.g. a mapped column spec) to skip type inference: columns it declares are authoritative (which also avoids inference footguns like small integers being read as boolean), and any undeclared columns are inferred as a fallback. Skipping inference is the main compute saving on recurring pipelines.autoInsertData(inputOrTable: InsertInput | string, inputData?: Record<string, any>[], inputMetaData?: MetadataHeader, inputPreviousMetaData?: AlterTableChanges | MetadataHeader | null, inputComparedMetaData?: { changes: AlterTableChanges, updatedMetaData: MetadataHeader }, inputRunQuery = true, inputInsertType?: 'UPDATE' | 'INSERT')
Executes a full insert using the dialect-aware batching engine.
IfinputRunQueryistrue, queries are executed viarunTransactionsWithConcurrency().
Iffalse, a list of insert queries (QueryInput[]) is returned without running them.autoConfigureTable(inputOrTable: InsertInput | string, data?: Record<string, any>[], currentMeta?: MetadataHeader, newMeta?: MetadataHeader, runQuery = true)
Determines whether a table should be created or altered based on metadata comparison.
IfrunQueryistrue, schema changes are applied immediately viarunTransactionsWithConcurrency().
Iffalse, queries are returned for inspection.autoCreateTable(table: string, newMetaData: MetadataHeader, tableExists?: boolean, runQuery = true)
Creates a new table with the provided metadata.
IfrunQueryisfalse, returns theCREATE TABLEqueries without executing them.autoAlterTable(table: string, tableChanges: AlterTableChanges, tableExists?: boolean, runQuery = true)
Alters an existing table using a computed diff.
Like above,runQuerycontrols whether to return or execute the queries.fetchTableMetadata(table: string)
Looks up metadata for the given table and returns{ currentMetaData, tableExists }.
Used internally for decisions about schema creation or alteration.splitTableData(table: string, data: Record<string, any>[], metaData: MetadataHeader)
IfautoSplitis enabled, splits a wide dataset across multiple smaller tables.
Returns an array ofInsertInputinstructions for multi-table insert execution.handleMetadata(table: string, data: Record<string, any>[], primaryKey?: string[])Combines metadata inference and comparison into one call. Returns an object with:currentMetaData: existing table metadata from the DBnewMetaData: metadata inferred from new datamergedMetaData: result of merging existing and new metadatainitialComparedMetaData: diff result, if anychanges: schema changes needed for alignment
getMetaData(config: DatabaseConfig, data: Record<string, any>[], primaryKey?: string[])Analyses sample data and returns a metadata map with type, length, nullability, uniqueness, and key suggestions.compareMetaData(oldMeta: MetadataHeader, newMeta: MetadataHeader)Compares two metadata structures and returns:changes: anAlterTableChangesdiff objectupdatedMetaData: the merged metadata structure
autoSQLChunked(table: string, iterable: AsyncIterable<Record<string, any>[]>, schema?: string, primaryKey?: string[])(v1.0.5+) Streaming-friendly variant ofautoSQLthat accepts anAsyncIterableof row chunks. Schema inference and DDL run once on the first non-empty chunk; subsequent chunks skip straight to insert. Compatible withuseSchemaLockanduseStagingInsert.openStream(table: string, schema?: string, primaryKey?: string[])(v1.1.0+) Opens a streaming session and returns anAutoSQLStreamHandle. See Streaming Inserts for full details.
Each method is designed to work with the same Database instance.
🧰 Convenience Utilities
AutoSQL exposes utilities that power autoSQL and can be used independently. These include metadata analysis, SQL formatting, batching, config validation, and more.
🔍 Type Inference & Normalisation
predictType(value)– Predicts SQL-compatible type (varchar,datetime,int, etc.) based on a single input value.collateTypes(typeSetOrArray)– Accepts aSetorArrayof types and returns a single compatible SQL type.normalizeNumber(input, thousands, decimal)– Standardises numeric values to SQL-safe format with optional locale indicators.calculateColumnLength(column, value, sqlLookup)– Dynamically computes and updates column length and decimal precision based on input data.shuffleArray(array)– Randomly reorders an array (used for sampling).isObject(val)– Type-safe check to determine if a value is a non-null object.
⚙️ Config & Metadata Tools
validateConfig(config)– Validates and merges the providedDatabaseConfigwith default settings.mergeColumnLengths(lengthA, lengthB)– Chooses the greater length definition between two metadata column states.setToArray(set)– Converts a Set to a regular array.normalizeKeysArray(keys)– Flattens and sanitizes arrays of key strings (e.g., for primary keys).isValidDataFormat(data)– Checks if the input is a valid array of plain objects suitable for inserts.
🧠 Metadata Inference & Preparation
initializeMetaData(headers)– Constructs a default metadata object from column headers with default flags and null types.getDataHeaders(data, config)– Scans sample data to derive column names and infer initial metadata.predictIndexes(metaData, maxKeyLength?, primaryKey?, sampleData?)– Suggests primary keys, unique constraints, and indexes based on uniqueness, length limits, or configured priorities.updateColumnType(existingMeta, newValue)– Adjusts the type and attributes of a column based on new sample input.
📦 Insert Planning & Execution
splitInsertData(data, config)– Splits large datasets into batches that meet size and row count constraints.getInsertValues(metaData, row, dialectConfig)– Extracts a single row's values as a SQL-safe array, accounting for dialect-specific formatting.organizeSplitData(data, splitMetaData)– Partitions the dataset by metadata groups for multiple table insert strategies.organizeSplitTable(table, newMetaData, currentMetaData, dialectConfig)– Generates split metadata configurations based on structural divergence.estimateRowSize(metaData, dialect)– Estimates the byte size of a row using provided metadata and flags potential overflows.parseDatabaseMetaData(rows, dialectConfig?)– Transforms SQL column descriptions into AutoSQL-compatible metadata.tableChangesExist(alterTableChanges)– Returnstrueif the proposed table changes indicate schema modification is needed.isMetaDataHeader(obj)– Type guard to check if an object qualifies as a metadata header.isValidDataFormat(data)– Validates that the input is an array of row objects suitable for processing.
🌊 Streaming Inserts
For large or incremental datasets, use openStream to avoid loading everything into memory at once. Each stream session uses its own isolated staging table so concurrent writes never interfere.
const stream = await db.openStream('events', 'my_schema', ['id']);
// Write data in as many chunks as you like
await stream.write(chunk1);
await stream.write(chunk2);
// Merge staged data into the target table, then clean up
const result = await stream.end();
console.log(result.affectedRows);
// Or abandon without merging
await stream.abort();How it works
openStream(table, schema?, primaryKey?)— runs a connectivity check and cleans up any orphaned staging tables from previous crashed runs (configurable withkeepOrphanedStagingTables).write(chunk)— on the first call, creates an all-text (LONGTEXT/TEXT) staging table unique to this run. Each subsequent call appends rows to it.end()— reads all staged rows, infers the schema withgetMetaData, applies any necessary DDL viaconfigureTables, then issues a bulkINSERT … SELECTwith dialect-specific type casts. If the bulk merge fails, a per-row fallback fires — failed rows trigger a schema widening pass before each retry round (up tostreamMaxRetries). The staging table is always dropped in thefinallyblock.abort()— drops the staging table without merging. Safe to call even ifwrite()was never called.
Error handling & the async contract
openStream(), write(), end() and abort() each return a promise that rejects on failure, so you must await them (or attach a .catch). They are not fire-and-forget: an un-awaited write() that fails becomes an unhandled promise rejection and its error is lost.
A rejected write() leaves this run's staging table in an indeterminate state (the chunk may be partly applied or absent). When a write() rejects, take one of two safe paths:
- retry the same chunk —
write()is append-only, so re-sending a chunk after a transient failure is fine; then continue andend()as usual; or abort()— drop the staging table and discard the whole run.
Do not call end() after a failed/un-awaited write() expecting the gap to be ignored: end() merges whatever is staged, so a lost chunk becomes missing rows.
Rejected rows
If rejectedRowsTable is configured, rows that cannot be merged after all retries are written to that table instead of throwing:
const db = Database.create({
...,
rejectedRowsTable: 'autosql_rejected_rows',
streamMaxRetries: 5,
});This same graceful degradation also applies to the non-streaming direct-insert path (useStagingInsert: false): when a batch insert fails, autosql retries the batch's rows one at a time (widening the schema between rounds) and diverts any that still fail to rejectedRowsTable. Without rejectedRowsTable set, a failed batch throws (fail-loud is the default). The default staging path (useStagingInsert: true) is unaffected — it stays atomic (all-or-nothing) by design, so a bad row fails the whole load there.
Works with advisory locks and schema history
const db = Database.create({
...,
useSchemaLock: true, // holds lock only during DDL phase
schemaHistory: true, // records a migration entry for any DDL at merge time
});📜 Schema History & Drift Detection
Enable schemaHistory to keep a full audit trail of every DDL operation AutoSQL applies.
const db = Database.create({
...,
schemaHistory: true,
schemaHistoryTable: 'autosql_schema_history', // default
detectDrift: true, // warn if the live schema diverges from the recorded one
strictDriftDetection: false, // set true to throw SchemaDriftError instead of warning
});AutoSQL creates the history table automatically on first use. Each migration writes a pending record, then updates to applied, failed, or rolled_back.
Exported functions
import { detectSchemaDrift, getSchemaAt, computeChecksum } from 'autosql';
import { SchemaDriftError } from 'autosql';
// Check whether the live schema matches the last recorded checksum
const { drifted, expected, actual } = await detectSchemaDrift(db, 'users');
// Reconstruct what the schema looked like at a point in time
const historicSchema = await getSchemaAt(db, 'users', new Date('2025-06-01'));
// Compute the sha256 checksum used internally for drift comparison
const checksum = computeChecksum(metaData);Error types
SchemaLockTimeoutError— thrown whenuseSchemaLock: trueand the advisory lock could not be acquired withinschemaLockTimeoutseconds.SchemaDriftError— thrown whenstrictDriftDetection: trueand the live schema checksum does not match the last recorded checksum.
Both are exported from the package root:
import { SchemaLockTimeoutError, SchemaDriftError } from 'autosql';🔒 Multi-writer Safety
When multiple processes call autoSQL on the same table simultaneously, schema inference and DDL can race. Enable advisory locks to serialize the DDL phase:
const db = Database.create({
...,
useSchemaLock: true,
schemaLockTimeout: 30, // seconds
});- MySQL — uses
GET_LOCK('autosql_schema__<table>', timeout)on a dedicated pool connection. - PostgreSQL — uses
pg_try_advisory_lock(hash(<table>))polled every 500 ms on a dedicated pool client.
The lock is held only during schema inference and DDL, then released before any inserts begin — concurrent inserts are never blocked. If the lock cannot be acquired within the timeout, SchemaLockTimeoutError is thrown.
📦 Large-dataset Support
autoSQLChunked
For datasets too large to hold in memory, use autoSQLChunked with any AsyncIterable:
async function* pageRows() {
let page = 0;
while (true) {
const rows = await fetchPage(page++);
if (rows.length === 0) break;
yield rows;
}
}
const result = await db.autoSQLChunked('events', pageRows());The first non-empty chunk runs the full inference + DDL pipeline. All subsequent chunks skip directly to insert — no repeated schema work. Compatible with useSchemaLock: true and useStagingInsert: true.
🐳 Docker & Local Configuration
The tests/docker-init folder contains a prebuilt Docker Compose setup to run AutoSQL tools locally. This is especially useful for integration testing or working with supported databases in a consistent environment.
📁 Folder Structure
/tests
├── utils/
│ └── config.local.json ← Configuration file used by tests and docker
└── docker-init/
├── docker-compose.yml ← Starts all test containers
└── .env ← (Optional) Environment variables for overrides⚙️ Running Docker Containers
Navigate to the docker-init directory and run:
cd tests/docker-init
docker-compose upThis will spin up the configured containers (e.g., Postgres, MySQL, etc.) defined in the docker-compose.yml file.
📝 Configuration Matching
Make sure the contents of config.local.json in tests/utils/ match the credentials and ports defined in docker-compose.yml. This ensures AutoSQL tests can connect to the correct database containers.
For example, if docker-compose.yml sets the MySQL container like this:
mysql:
image: mysql:8
ports:
- "3307:3306"
environment:
MYSQL_USER: testuser
MYSQL_PASSWORD: testpass
MYSQL_DATABASE: testdbThen your config.local.json should include:
{
"mysql": {
"host": "localhost",
"port": 3307,
"username": "testuser",
"password": "testpass",
"database": "testdb"
}
}This setup helps avoid mismatched credentials or ports during testing.
📬 Feedback
This library is under active development. Suggestions, issues, and contributions are welcome.
Contact: [email protected]
