dbgate-mssql-dumper is a standalone, client-agnostic SQL Server dump/restore
library for Node.js. It is architecturally modeled on dbgate-pg-dumper
(connection abstraction, normalized model, archive planning, plain-SQL
rendering as separate layers) but every layer is redesigned for SQL Server's
own catalog, object model, and restore semantics rather than adapted from
PostgreSQL concepts that don't apply. It does not import any DbGate runtime
package; the optional dbgate-mssql-dumper/tedious entry point is the only
place a Node.js SQL Server driver is referenced.
MssqlConnection(Input)
│
▼
introspectMssql() → MssqlIntrospectionResult { database, version, capabilities, diagnostics }
│
▼
inspectDumpArchive() → DumpArchiveInspection { entries (topo-sorted), diagnostics, cycles, droppedPreferenceEdges }
│
▼
renderPlainSql() → PlainSqlRenderResult { bytesWritten, renderedDumpIds, skippedDumpIds, warnings }
│
▼
DumpWriter (Stream/String)
dumpMssql() in src/api/ composes exactly this pipeline, acquiring one
physical connection up front and passing it to both introspectMssql and
data export (see "Why one physical session" below) rather than letting each
stage acquire its own. Each stage is still independently usable and
independently testable: renderPlainSql is a pure function of a
MssqlDatabase model and a DumpArchiveInspection, and never touches the
network; inspectDumpArchive is a pure function of a MssqlDatabase and a
selection; only introspectMssql (and the data-export and restore
functions) require a live MssqlConnection.
Table row data is planned by the archive (tableData entries) but streamed
by a separate function, exportTableDataAsInserts (in src/data/), which
requires a live connection. Calling renderPlainSql directly still only
renders schema objects from the static model and reports selected
tableData/sequenceState entries as warnings, so plain-SQL rendering
never implicitly requires a database round-trip mid-render; dumpMssql
bridges the two via PlainSqlRenderRequest.onDataEntry (see "Plain SQL
rendering" below), so its own output still interleaves real row data at
the correct position in the archive's topological order, not as a
separately-ordered second pass.
The core package depends on MssqlConnection (src/connection/types.ts),
not on a particular driver:
query(query, signal?)— buffered, parameterized query execution.query.timeoutMs, when set, applies a statement-level timeout; adapters that cannot honor it per-statement may ignore it.stream(query, options?)— async-iterable row streaming, for data export and any future large-result introspection query.options.batchSizeis a backpressure high-water mark: adapters that support it (Tedious does) suspend the underlying result-set flow once this many rows are buffered ahead of the consumer and resume it once the buffer drains, so an unconsumed multi-million-row result never accumulates unboundedly in memory.cancel()— best-effort cancellation of the in-flight statement.getTransactionStatus?(signal?)— optional; SQL Server has no public session-level transaction-status API comparable to PostgreSQL's, so adapters that can approximate it (e.g. via@@TRANCOUNT/XACT_STATE()) implement it and callers must treat it as best-effort.execBatch?(sql, signal?)— optional; executessqlas one genuine T-SQL batch (no parameter binding), distinct fromquery(). On the Tedious adapter this is the difference betweenexecSqlBatch(whatexecBatchcalls) andexecSql(whatquery()calls, routed throughsp_executesql) — see "Execution" under "Restore" below for whyrestoreSqlDumpspecifically needs the former. Adapters that cannot distinguish the two may omit it; callers fall back toquery().
MssqlConnectionSource represents anything that must be acquired to get one
physical connection (a pool). acquireMssqlConnection() normalizes a direct
connection or a source into one { connection, release } pair; a direct
connection's release is a no-op, matching the rule that this library never
closes a connection it did not create.
SQL Server settings (SET options), @@TRANCOUNT, temp tables, and
SESSION_CONTEXT are all connection-scoped. Introspection and any future
snapshot-consistent dump must run every statement against the same physical
session; routing through a generic pool query() could silently hand
different statements to different backend connections. This is the same
reasoning dbgate-pg-dumper documents for PostgreSQL, and it applies
identically here.
beginMssqlSession() (src/connection/session.ts) is the SQL Server
analogue of dbgate-pg-dumper's DumpSessionManager, built entirely on
plain connection.query() calls rather than dedicated interface methods —
MssqlConnection itself stays at four members. It supports the same three
transactionModes as dbgate-pg-dumper (managed/existing/none), with
SQL Server-appropriate mechanics rather than assumed PostgreSQL semantics:
managed(default) requires the connection to reportidle(viagetTransactionStatus, orunknownwhen unimplemented — refused for the same reason as an actually-unknown status, since a nestedBEGIN TRANSACTIONwould be silently accepted by SQL Server and only the outermostCOMMIT/ROLLBACKwould actually end it), then issuesSET TRANSACTION ISOLATION LEVEL <level>;followed byBEGIN TRANSACTION;, and owns the resultingcommit()/rollback().existingrequires the connection to already reportin-transactionand never issuesBEGIN/COMMIT/ROLLBACKitself — ownership stays with the caller.noneissues no transaction statements at all and provides no cross-query consistency guarantee.
SQL Server has no equivalent of PostgreSQL's REPEATABLE READ READ ONLY
transaction modifier or exported snapshot identifiers, so the isolation
level held for the session's duration is the only consistency lever
available. The default is REPEATABLE READ, which is always available and
takes shared read locks for the duration of the transaction; SNAPSHOT
(non-blocking, versioned reads — the closer analogue of PostgreSQL's
REPEATABLE READ) can be requested explicitly via isolationLevel, but
requires ALLOW_SNAPSHOT_ISOLATION ON at the database level, which this
library cannot assume or set on the caller's behalf. commit()/rollback()
are idempotent (a second call is a no-op) and take their own optional
AbortSignal, deliberately independent of the signal that triggered the
rollback in the first place — issuing ROLLBACK TRANSACTION through a
signal that is already aborted would make cleanup itself throw before the
rollback statement is ever sent.
dbgate-mssql-dumper/tedious (src/tedious.ts) adapts a caller-owned,
already-connected tedious.Connection. It is the only file in the package
that imports tedious, and tedious is an optional peer dependency — nothing
in dbgate-mssql-dumper's main entry point pulls it in. The adapter:
- maps
MssqlQueryParametervalues totedious.TYPES(using an explicitsqlTypename when given, otherwise inferring one from the JS value's type); - turns tedious's event-based
Request(row,columnMetadata,error, the completion callback) into a bufferedquery()promise or astream()async generator, using an internal wake/queue pattern sostream()can be consumed with ordinaryfor await; - applies
query.timeoutMsviarequest.setTimeout(...); - wires
AbortSignaltoconnection.cancel(); - declares its own minimal structural types for
tedious's row/column- metadata shapes rather than importing them, becausetedious's own public.d.tsdoes not export aColumnMetadata/row-column type — itsrowandcolumnMetadatalisteners are typed effectively asany/internal at the package boundary.
Streaming backpressure uses tedious.Request's own pause()/resume()
(a per-request control tedious exposes precisely for this purpose — pausing
stops the TDS row flow itself, tedious emits no further row events at all
while paused, rather than merely a hint) instead of buffering every row into
an array and instead of the PassThrough-stream approach
dbgate-plugin-mssql's tediousReadQuery uses (a plain Node object-mode
stream gets Node's own backpressure via Readable/Writable water marks,
but tedious does not write into one in tediousReadQuery; it push- writes
every row into the PassThrough as fast as rows arrive and relies on the
stream's internal buffer, which is unbounded for object-mode streams unless
the reader is also draining it promptly). This adapter instead tracks its
own small in-memory queue directly against tedious's request-level flow
control: once the queue reaches options.batchSize (default 50) it calls
request.pause(); once the consumer has drained the queue back down to half
that, it calls request.resume(). If the consumer stops iterating early
(break, or an error unwinding the for await), the generator's finally
cancels the request outright and resumes it first if still paused, so
nothing is left in a stuck-paused state.
A pool-source adapter (comparable to dbgate-pg-dumper's PgPoolConnectionSource)
is deliberately not included yet: tedious has no first-party pool, and
third-party pools (tedious-connection-pool, generic-pool) vary enough in
shape that wrapping one prematurely would bake in an unvalidated interface.
A minimal connectTedious(config) convenience creator is included instead —
it establishes one new tedious.Connection, adapts it, and returns a
close() alongside it. It lives entirely in the optional adapter module, so
it does not add a tedious-shaped concept to the core package's own API;
fromTediousConnection() (wrapping a connection the caller already
connected, and never closing it) remains the primary, dependency-light entry
point.
detectMssqlVersion() reads SERVERPROPERTY('ProductVersion'|'ProductLevel'|'EngineEdition')
only — never @@VERSION string parsing, which is locale-dependent and not
machine-parseable across editions. EngineEdition is mapped to a closed
MssqlEngineEdition union (standard, enterprise, azure-sql-database,
...) per Microsoft's documented numeric codes.
detectSourceCapabilities() derives a SourceCapabilities flag set once from
the numeric version: sequences (2012+), memory-optimized tables (2014+),
temporal tables/JSON functions/Always Encrypted (2016+), graph tables
(2017+), native JSON type (2025+). Azure SQL Database/Managed
Instance/Synapse/Edge are treated as always current, since Azure tracks the
latest on-premises feature set continuously rather than versioning by major
release.
src/compatibility/ keeps target capability checking (checkTargetCompatibility)
as a distinct concept from source capability detection, even though both
are today pure functions of the same MssqlVersion shape: a source's
capabilities describe what its catalog may contain; a target's describe what
it can accept on restore. Keeping them separate avoids conflating "what did
we read" with "what can we write" as the two inevitably diverge (e.g. once
downgrade/compatibility-level transformations are added).
src/model/ defines the catalog shape independent of how it was obtained:
MssqlDatabase is a set of independent, flat collections (schemas,
tables, views, routines, triggers, sequences, primaryKeys,
uniqueConstraints, foreignKeys, checkConstraints, defaultConstraints,
indexes) rather than a nested tree. This mirrors SQL Server's own catalog
views (sys.tables, sys.columns, sys.foreign_keys, ...), which are
themselves flat and joined by object_id/schema_id, and keeps archive
planning free of implicit parent/child traversal — exactly the design
dbgate-pg-dumper uses for the same reason.
Columns are not independent archive entries (unlike dbgate-pg-dumper,
where PostgreSQL's ADD COLUMN-based catalog encourages it): a T-SQL
CREATE TABLE always carries its full column list inline, so there is no
restore-ordering question for individual columns to answer. Constraints,
indexes, triggers, and table data are independent entries because SQL Server
restore scripts conventionally add them via separate ALTER TABLE/CREATE INDEX/CREATE TRIGGER statements after the table exists.
introspectMssql() (src/introspection/introspect.ts) loads schemas,
tables (with columns), primary/unique/check/default constraints, foreign
keys, independent indexes, sequences, views, routines, and triggers. Each
catalog concern lives in its own module under src/introspection/catalog/
(tables.ts, columns.ts, foreignKeys.ts, ...); introspect.ts only
orchestrates them. Each module targets independent SQL Server system
catalogs (sys.tables, sys.columns, sys.types, sys.identity_columns,
sys.computed_columns, sys.default_constraints, sys.key_constraints,
sys.check_constraints, sys.foreign_keys, sys.foreign_key_columns,
sys.indexes, sys.index_columns, sys.sequences, sys.views,
sys.sql_modules, sys.triggers, plus a handful of OBJECTPROPERTY/
SERVERPROPERTY calls) written independently for this package — no DbGate
runtime code is imported or reused, though dbgate-plugin-mssql's
MsSqlAnalyser.js and its SQL files were read as prior art for which
catalogs answer which questions.
Every catalog module issues a small, fixed number of queries regardless of
how many tables/objects exist: one (or two, for a header-plus-detail shape
like foreign keys or indexes) bulk SELECT, never one query per table. Row
relationships — which column belongs to which table, which index row
belongs to which index — are assembled afterward in memory by matching
object_ids (and index_ids, for index columns) that a previous query
already returned, the same "bounded bulk queries, assembled in memory"
approach dbgate-pg-dumper uses for PostgreSQL.
Schemas, tables, views, routines, and sequences are always read from their
catalog view in full first (every schema, every table, regardless of
selection), and then filtered in memory with the exact same
isSchemaSelected/isTableSelected functions the archive planner uses.
A caller-supplied schema or table name is therefore never interpolated into
catalog SQL at all — selection never reaches SQL text. The only values that
do scope a query are object_id integers the package already read back
from a previous catalog query in the same run; even those are never
string-formatted into the query. They travel as one bound parameter (a
JSON-encoded array), unpacked server-side with OPENJSON and cast to
int, via objectIdFilter() in catalog/objectIdFilter.ts:
WHERE t.object_id IN (SELECT CAST(value AS int) FROM OPENJSON(@tableIds))This is what keeps column/constraint/index/trigger loading a single bulk query scoped to "the tables that matter" instead of either a per-table loop or an always-unfiltered full-database scan.
A selected table's foreign key can reference a table outside the selection
entirely (a different, excluded schema, for instance). introspectMssql()
resolves refSchemaName/refTableName from an unfiltered table
object_id map (built before selection is applied), so the reference is
always named correctly; it then adds that referenced table's object_id to
the set of tables whose columns get loaded, so the returned
database.tables includes it — with columns, but not its own constraints or
indexes, which stay scoped to the originally-selected set. This is
required for, and mirrors, inspectDumpArchive()'s own
includedAsDependency handling: the planner looks up a foreign key's target
by name in database.tables, and that lookup needs to succeed even when the
target was never independently selected.
Views, routines (procedures and functions), and triggers all share one
loadModules() call (catalog/modules.ts) against sys.sql_modules,
scoped to the combined object_ids of whichever objects are being loaded.
Besides definition, it reads uses_ansi_nulls and uses_quoted_identifier
directly from sys.sql_modules — the exact session settings SQL Server
itself recorded at creation time — onto the model
(MssqlModuleMetadata, shared by MssqlView/MssqlRoutine/MssqlTrigger),
rather than assuming a fixed SET ANSI_NULLS ON preamble for every object as
a renderer-side default.
OBJECTPROPERTY(object_id, 'IsEncrypted') is read explicitly and is the only
signal treated as encryption. A null definition can also mean the object
has no sys.sql_modules row at all (a CLR-backed routine, for example) —
that is a different, non-encryption reason to have no SQL text. Whichever
it is, introspectMssql() never invents replacement SQL: moduleUnavailableDiagnostic()
reports one of two distinct diagnostic codes —
encrypted-module-definition-unavailable or module-definition-not-found
— and the model's definition stays null.
loadProgrammableDependencies() (catalog/programmableDependencies.ts)
reads sys.sql_expression_dependencies — restricted to
referenced_class_desc = 'OBJECT_OR_COLUMN' — for every selected view,
routine, and trigger in one bulk query, and resolves each row's target
against a combined table/view/routine lookup built earlier in the same
introspection run: first by referenced_id (SQL Server's own resolution),
then, since that catalog view is documented to leave referenced_id NULL
whenever it could not bind the reference (dynamic SQL, some forward
references), by an exact (referenced_schema_name, referenced_entity_name)
match against the same lookup. Either resolution path produces one
MssqlObjectDependency on MssqlDatabase.objectDependencies, carrying
is_schema_bound_reference through unchanged — the archive planner (see
"Hard dependencies vs. ordering preferences" above) is what turns that flag
into a hard edge or a mere ordering preference, not this loader. A
reference that resolves to neither path — a system function, something
outside the current selection, or genuinely dynamic SQL — produces an
unresolved-programmable-dependency info diagnostic instead of a guessed
edge.
Procedure/function parameters (sys.parameters) are not loaded yet —
MssqlRoutine.parameters is always []. Temporal/memory-optimized
table-level flags that are cheap to read alongside the main table query
(is_memory_optimized, durability_desc, temporal_type_desc, the history
table reference) are populated, but there is no separate introspection of
period columns, retention policy, or memory-optimized-specific index/bucket
metadata. Always Encrypted column metadata is not read. These are narrower
gaps than "full catalog introspection is not implemented" (the previous
phase's honest limitation) — the normalized model and every object family
listed above are now real, catalog-backed data.
src/selection/ matches exact, case-sensitive SQL Server identifiers —
never lowercased, never treated as wildcard patterns — because SQL Server
identifier casing is collation-dependent and this library must not guess at
it. By default, sys, INFORMATION_SCHEMA, guest, and the fixed
database-role schemas (db_owner, db_datareader, ...) are excluded, since
none of them are ever meaningful dump targets. includeSystemSchemas: true
opts back in.
inspectDumpArchive() (src/archive/) converts a MssqlDatabase into
immutable, ordered ArchiveEntry objects, independent of SQL text and output
streams — the same separation of concerns dbgate-pg-dumper uses. Every
entry carries a deterministic identity and stable dumpId, its object type
and section, its dependency edges, its selectionState ('selected' or
'dependency'), and — once a valid order exists — a sequenceNumber
recording where it landed. Diagnostics are reported at the inspection level
rather than embedded per-entry, since several diagnostic kinds (a cycle, a
duplicate identity) legitimately concern more than one entry, or none in
particular; each still names the specific entry it concerns through its
objectReference where one exists.
Every entry has a canonical identity built from length-prefixed
(objectType, schemaName, name, parentName, ...extraParts)
(createArchiveIdentity/createCanonicalIdentity); a truncated SHA-256
digest of that identity is the entry's stable dumpId
(createDumpId). Duplicate canonical identities are reported as a
structured duplicate-archive-identity diagnostic rather than silently
overwriting an entry.
Three sections, in emission order — pre-data, data, post-data — with a
centralized assignDumpSection/dumpSectionPriority/archiveObjectPriority
mapping in sectionRules.ts. This is a planning abstraction specific to this
package; SQL Server itself has no archive-section concept.
- pre-data: schemas, sequences, tables (with columns inline). Nothing here needs anything outside pre-data.
- data:
tableData, andsequenceState(a sequence's current value, kept separate from itssequencepre-data definition — the same splitdbgate-pg-dumperuses between a PostgreSQL sequence's definition and itsOWNED BYownership, applied here to definition vs. runtime state). - post-data: primary/unique constraints, functions, default/check constraints, independent indexes, foreign keys, views, procedures, triggers.
A source tool's own object listing order is not treated as authoritative
here, and two placements deliberately depart from the "obvious" pre-data/
post-data split a naive port of pg_dump's section names might suggest:
- Views, functions, procedures, and triggers all live in post-data, not
pre-data. Two independent reasons converge on this: an
AFTER INSERTtrigger firing per row while table data is being bulk-loaded is exactly the hazardpg_dumpavoids by keeping triggers in post-data, and the same reasoning applies verbatim to SQL Server; and grouping all four "programmable object" kinds into one band ordered after data gives one consistent rule ("behavior after structure and data") instead of an arbitrary split with no functional basis. - Independent indexes sort before foreign keys within post-data. SQL
Server accepts a
PRIMARY KEY, aUNIQUEconstraint, or a standalone unique index as a validREFERENCEStarget — not only a named constraint. Ordering a foreign key ahead of the standalone unique index its target might actually rely on would make the restore fail outright, so this package does not mirror a source ordering that lists constraints, then foreign keys, then indexes. - Functions sort before default/check constraints within post-data. A
CHECK/DEFAULTexpression may call a scalar UDF, and SQL Server validates that the function already exists at the moment the constraint is added — unlike stored procedures and triggers, which get deferred name resolution. Procedures have no such requirement and stay later, next to views and triggers.
These three points, and the full priority table, are documented at the
SECTION_BY_OBJECT_TYPE/OBJECT_PRIORITY_WITHIN_SECTION declarations in
sectionRules.ts — read there for the exact numbers. They matter only as
the tie-break among entries with no discovered dependency relationship;
a real edge (see below) always takes priority over this default order.
Every foreign-key entry depends only on its own table and the referenced
table (both pre-data), never on another foreign-key entry — so no foreign
key can ever end up depending, even transitively, on another post-data
entry. Combined with data sorting before post-data, this guarantees
every foreign key is created only after all table data has already loaded,
and that mutually referencing foreign keys (Orders → Customers and
Customers → Orders) are trivially safe: each depends only on tables, so
there is no cycle to resolve in the first place. A single self-referencing
foreign key (Employees.ManagerId → Employees.Id) is likewise never a
cycle: both its "own table" and "referenced table" edges resolve to the same
table entry, and addDependency only ever records that pair once.
ArchiveDependency.strength is 'hard' (required for a valid restore) or
'preference' (a nicer default order, never a correctness requirement).
Structural edges — table→schema, constraint/index/trigger→table, foreign
key→both tables, tableData→table, sequenceState→sequence,
view/routine/sequence→schema — are always hard.
Cross-references discovered between views, procedures, functions, and
triggers (MssqlDatabase.objectDependencies, from
sys.sql_expression_dependencies — see "Catalog introspection") get their
strength from SQL Server's own guarantee about the reference:
is_schema_bound_reference = 1(WITH SCHEMABINDING, or an otherwise-enforced reference): SQL Server itself blocks aDROPor incompatibleALTERof the referenced object while the dependent exists. Trustworthy enough to be a hard edge.is_schema_bound_reference = 0(an ordinary reference): not enforced or validated after creation — the target could be renamed, dropped, or the reference could be to dynamic SQL that this catalog view mis-resolves. Used only as an ordering preference.
This is the "deterministic fallback" for incomplete SQL Server dependency
metadata the archive layer is responsible for: sys.sql_expression_dependencies
is documented to leave referenced_id NULL when it cannot bind a
reference (dynamic SQL, certain forward references). The introspection
loader (catalog/programmableDependencies.ts) retries by exact
schema-qualified name against every table/view/routine/trigger it already
knows about before giving up; anything still unresolved becomes an
unresolved-programmable-dependency diagnostic there. The archive planner
adds a second, independent check for the same failure mode at its own
layer: a discovered dependency whose target was resolved by
introspection but excluded from this particular archive (by selection, or
because the source database simply has no matching entry in a hand-built
test model) also produces an unresolved-programmable-dependency
diagnostic and no edge — it is never invented at either layer.
Cycles are resolved before any topological sort runs, so the sort itself never has to guess which edge "caused" a cycle:
- Find every strongly connected component (Tarjan's algorithm) of size greater than one, or a self-loop, in the current edge set.
- For each such component, if any of its internal edges are preference-strength, drop all of them — removing an edge that carries no correctness requirement can never make a restore invalid — and recompute from step 1.
- Repeat until either no non-trivial component remains (success) or an iteration finds only hard-only components left (failure).
A hard-only component is reported as an ArchiveCycle (member dumpIds)
and an archive-dependency-cycle error diagnostic; the inspection is
valid: false, and — matching dbgate-pg-dumper's "no executable order for
an invalid archive" rule — entries still lists every would-be entry, but
in a deterministic fallback order (by section, then object priority, then
name) with no sequenceNumber, never a claim of a valid restore order.
Each preference edge actually dropped is reported as a
BrokenPreferenceEdge and a preference-cycle-broken info diagnostic, even
when the overall result is valid — dropping one is always safe, but it is
never silent.
Once the graph is acyclic, Kahn's algorithm produces the final order; ties
among simultaneously ready entries are broken by (section, objectPriority, schemaName, name, dumpId), so array/object iteration order in the input
MssqlDatabase never affects the output — the same determinism guarantee
dbgate-pg-dumper provides, verified here by a test that runs the same
model through twice with several of its arrays reversed and checks for an
identical dumpId sequence.
A selection can exclude a table that a selected object still hard-depends on
(most commonly: an included table's foreign key references an excluded
table). By default, the planner pulls the missing schema/table entry in
anyway, sets its selectionState to 'dependency', and emits an
included-as-dependency info diagnostic — so restoring the selected subset
never produces a dangling ALTER TABLE ... ADD CONSTRAINT FOREIGN KEY
against an object that was never created. Passing strictSelection: true
changes this from an automatic inclusion into a rejection: the same
situation instead produces a strict-selection-violation error
diagnostic and valid: false, matching dbgate-pg-dumper's strict
selection mode — the entry is still present (so the diagnostic can point at
something concrete), but the caller must resolve the conflict rather than
have it silently resolved for them.
full (default), schema-only, and data-only filter which sections are
included after the full dependency graph is built, then drop any dependency
edge pointing outside the retained set (a data-only render assumes its
tables and sequences already exist on the target, so it does not need
pre-data in the graph at all). This mirrors dbgate-pg-dumper's
explicit-section-boundary rule: mode boundaries are hard, so a data-only
archive never silently pulls in schema definitions.
renderPlainSql() (src/renderer/) is a pure function: (database, archive inspection, writer, options) → result. It never queries the database. For
each entry in archive.entries (already topologically sorted), it looks the
matching model object up by (schemaName, name[, parentName]) via
buildRenderLookups, and dispatches to a per-object-type renderer
(renderTableCreate, renderForeignKeyCreate, renderIndexCreate, ...).
Column types are formatted with formatColumnDataType: character types
render (n)/(max), decimal/numeric render (precision,scale),
datetime2/datetimeoffset/time render (precision). Views, procedures,
functions, and triggers already carry a full stored definition text (the
introspection equivalent of PostgreSQL's pg_get_viewdef/pg_get_functiondef)
and are emitted verbatim after a SET ANSI_NULLS .../SET QUOTED_IDENTIFIER ... preamble built from that specific object's uses_ansi_nulls/
uses_quoted_identifier flags (MssqlModuleMetadata, read from
sys.sql_modules at introspection time — see "Session settings and
encrypted/unavailable modules" above), not a hardcoded ON/ON assumed for
every object. A null flag (no sys.sql_modules row was found) still
defaults to ON, matching every supported SQL Server version's own default
and what SSMS itself scripts.
Every statement is followed by a GO batch separator — required because
CREATE VIEW/CREATE PROCEDURE/CREATE FUNCTION/CREATE TRIGGER must be
the only statement in their batch in T-SQL.
includeDropStatements emits DROP ... IF EXISTS in reverse archive
order before creation. Every drop form used (DROP TABLE/VIEW/PROCEDURE/ FUNCTION/TRIGGER/SEQUENCE/SCHEMA/INDEX IF EXISTS, ALTER TABLE ... DROP CONSTRAINT IF EXISTS) requires SQL Server 2016 or later; this is the
renderer's implicit minimum target today. CREATE SCHEMA has no native
IF NOT EXISTS form at all (unlike DROP SCHEMA IF EXISTS), so schema
creation is wrapped in an explicit IF NOT EXISTS (SELECT ... FROM sys.schemas ...) BEGIN EXEC('CREATE SCHEMA ...') END guard instead — CREATE SCHEMA cannot be the non-first statement of a conditional block directly, so
it must run through EXEC of a string.
unsupportedFeaturePolicy (error default, or warn-omit) governs both a
render function throwing (e.g. a view with no stored definition) and a
renderer returning null for a structurally out-of-scope object — today,
that is any index whose indexType is not CLUSTERED/NONCLUSTERED
(columnstore, XML, spatial). warn-omit skips the entry and records a
MssqlDiagnostic; error throws immediately.
renderPlainSql itself is still a pure, connection-free function of the
static model, but PlainSqlRenderRequest.onDataEntry lets a caller that
does have a live connection stream a data-section entry's real content
at exactly the position the archive's topological order already put it —
after the owning table's definition, before any post-data object that
depends on the table's data being present. When onDataEntry is absent, or
returns false for an entry it does not handle, that entry falls back to a
data-not-rendered warning and is skipped, exactly as before this hook
existed; dumpMssql is the only built-in caller that supplies it today (see
"Data export" below), so calling renderPlainSql directly and standalone
(as most of this package's own tests do) is unaffected. This keeps
renderPlainSql's "pure function of the model" character intact — it never
imports connection/ or data/ types — while still letting the full
dumpMssql pipeline produce one continuous, correctly ordered plain-SQL
output instead of two separately-ordered passes (schema, then all data, which
would violate the archive's own post-data-after-data ordering guarantee).
Every dump starts with the literal marker line isDumperSqlDump() checks
for (never changes), followed by whatever source metadata is available and
safe to disclose: database name, collation, compatibility level, source
product version/engine edition (from PlainSqlRenderRequest.sourceVersion),
and dump mode (PlainSqlRenderRequest.mode) — each line omitted individually
when its value is unavailable, so calling renderPlainSql without version/
mode info (as most unit tests do) still produces a valid, if shorter,
header. A timestamp line is added only when includeTimestamp: true, for
determinism. Nothing here can leak a secret: MssqlDatabase and
MssqlVersion never carry connection strings or credentials in the first
place, so there is nothing sensitive reachable from this header's inputs.
Determinism: no timestamps are written unless includeTimestamp: true;
identifier quoting, keyword-case, indentation, and line endings are all
explicit renderer options, never environment/locale-dependent.
SQL Server identifiers are bracket-quoted ([name], with ] doubled to
escape it), not double-quoted as in PostgreSQL, and quoting does not affect
case-folding the way it does in PostgreSQL (SQL Server never folds unquoted
identifiers to a canonical case; case sensitivity is purely a collation
property). quoteIdentifier leaves an identifier unquoted only when it
matches the plain regular-identifier grammar and is not one of the T-SQL
reserved keywords; quoteAllIdentifiers forces bracketing everywhere.
String literals use ordinary '...' or Unicode N'...' (doubling embedded
'); renderSqlLiteral always emits the N'...' form for JS strings, since
it is unconditionally valid regardless of the target column's Unicode-ness
and safe for any non-ASCII content irrespective of the database's code page
— unlike PostgreSQL, SQL Server has no single "always correct" string
literal form independent of column type, so this package picks the one that
is always accepted rather than trying to infer per-column Unicode-ness at
literal-rendering time. Binary values render as 0x-prefixed hex; dates
render as ISO-8601 '...' strings, which SQL Server parses unambiguously
regardless of session LANGUAGE/DATEFORMAT settings.
src/security/sensitive.ts provides isSensitiveOptionName/
redactSensitiveText/redactSensitiveSubstring for keeping connection
secrets (passwords, tokens, connection strings) out of diagnostics and error
messages; this is deliberately simpler than dbgate-pg-dumper's
async protectSensitiveValue — no encryption/hashing is performed today,
only masking.
exportTableDataAsInserts() (src/data/insertExport.ts) requires a live
connection and streams one table's rows through connection.stream(),
never buffering the full table — at most one batch's worth of rendered row
tuples is held in memory at a time (see "Batching" below). dumpMssql
supplies it (via PlainSqlRenderRequest.onDataEntry) for every tableData
archive entry when mode is 'full' or 'data-only'; sequenceState
entries are not yet handled this way and still fall back to the renderer's
default warning (see "What is intentionally out of scope" below).
When request.table: MssqlTable is supplied (as dumpMssql always does,
since it already has one from introspection), classifyColumnForExport()
(src/data/columnValueRenderer.ts) partitions every column before the
first row is even fetched:
computed— never a validINSERTtarget; SQL Server derives the value itself.generated(rowversion/timestamp) — server-maintained;INSERTrejects an explicit value for these outright.unsupported(sql_variant,xml,geography,geometry,hierarchyid) — excluded defensively rather than risking a corrupted or misleading literal:sql_variant's runtime type is not known without an extra query per value, and the others need type-specific constructor syntax (geography::STGeomFromText(...), etc.) this package does not generate. Each exclusion produces anunsupported-column-typewarning diagnostic naming the column — never silent data loss.- everything else is
insertable.
Only insertable columns are selected from the source table at all
(SELECT [col1], [col2], ... FROM ..., not SELECT *) and only they appear
in the generated INSERT's column list. If a table has no insertable
columns (every column computed/generated/unsupported), there is nothing to
select or bind — a plain SELECT COUNT(*) supplies the row count instead,
and the table's data becomes that many INSERT INTO ... DEFAULT VALUES;
statements, which is valid T-SQL for exactly this case and still recreates
the right number of rows.
Without a table model, exportTableDataAsInserts falls back to a plain
SELECT * with column order taken from the first streamed row's own keys
(relying on MssqlConnection's contract that every selected column is
present as a key on every row) and the JS-runtime-type-only
renderSqlLiteral for every value — no column is excluded and no
type-specific formatting applies. This fallback exists so data export never
strictly requires introspection to have run, but callers that have a
table model should always supply it.
renderColumnValue() renders each insertable column's value using its
actual SQL Server type rather than only the JS runtime type, so it can make
distinctions renderSqlLiteral structurally cannot:
char/varchar/textuse plain'...'quoting;nchar/nvarchar/ntextuseN'...'— both correctly handle embedded', embedded CR/LF, and Unicode content; only the unicode-prefix choice differs.- Exact numeric types that can exceed IEEE-754 precision (
bigint,decimal,numeric,moneyandsmallmoney) are selected through a server-sideCONVERT(varchar(64), …)before a JavaScript driver can narrow them to an IEEE-754 number. The exact numeric text is emitted verbatim and unquoted. Approximate numeric types (float/real) render throughformatFiniteNumber(), which expands JS's exponential notation (used for magnitudes>= 1e21or< 1e-6) into a plain digit string — T-SQL's exact-numeric literal grammar does not accept exponential notation at all, so this is a correctness fix, not cosmetic. A driver-supplied numeric string (safe, digit-only text) is emitted verbatim and unquoted rather than parsed into a JS number first — the only representation that can carry more precision than an IEEE 754 double without this package reintroducing loss of its own; a string that is not safe numeric text falls back to being quoted, defensively. dateuses only the ISO date portion;datetime/smalldatetimeuse the existing millisecond-precision ISO literal;datetime2/time/datetimeoffsetusequoteHighPrecisionDateTimeLiteral(), which recovers sub-millisecond precision from a non-enumerablenanosecondsDeltaproperty the Tedious adapter's value parser attaches toDateobjects for these types (up to 7 fractional digits total), instead of silently truncating to JSDate's native millisecond precision.binary/varbinary/imagerender as0x-prefixed hex;uniqueidentifieras a quoted GUID string.- Any value/type combination this function does not specifically recognize
falls back to the generic
renderSqlLiteralrather than throwing — a classification gap degrades gracefully instead of aborting an export partway through a table.
Known driver limitation, reported rather than hidden
One SQL Server type behavior cannot be fully recovered through the Tedious
adapter, and columnExportDiagnostics() reports it explicitly instead of
exporting a value that looks correct but is not:
datetimeoffsetalways loses its original UTC offset. Tedious'sreadDateTimeOffset()reads the offset bytes off the wire but discards the parsed value, constructing the returnedDateviaDate.UTC(...)unconditionally. The point in time is preserved exactly; the originally stored display offset is not recoverable at all through this adapter. Exported values are rendered with an explicit+00:00suffix — honest about being that UTC instant, not a claim about the source row's original offset — and adatetimeoffset-normalized-to-utcwarning is emitted once per such column.
Data export batches at two nested levels, because a statement and a batch cost different things when the dump is restored.
Rows are first accumulated into one multi-row INSERT INTO ... (cols) VALUES (...), (...), ...; statement (TableDataExportOptions.maxRowsPerStatement,
default 100) rather than one INSERT per row, subject to two independent
caps: row count, clamped to SQL Server's own hard limit of 1000 rows per
VALUES table-value-constructor regardless of what is requested (exceeding
it would produce a statement that fails at restore time), and rendered
byte size (maxStatementBytes, default 4,000,000), so a run of unusually
wide rows cannot produce one unreasonably large statement even while under
the row-count cap. Only the current statement's tuples are held in memory —
the underlying connection.stream() call is never collected into an array
first.
Those statements are then packed into GO-terminated batches
(maxRowsPerBatch, default 10,000 rows; maxBatchBytes, default 8,000,000
bytes of batch text), rather than one GO per statement. The two levels map
onto the two costs a restore pays:
- A statement is an implicit transaction. Its size sets how many
transaction-log commits the table costs — a million rows is 10,000 commits
at the default, a million at
maxRowsPerStatement: 1. Counter-intuitively, raising it does not pay: measured against SQL Server 2022 (200k narrow rows, restore timed end to end, repeated and interleaved), 1000-row statements restored consistently slower than 100-row ones — packed into batches or not — because a largeVALUEStable-value-constructor costs more to compile and materialize than the commits it saves. Hence a default well below the 1000-row ceiling. - A batch is a client/server round trip.
restoreSqlDumpexecutes batches strictly sequentially (see "Restore" below), so batch count is a latency multiplier, and on a remote server it dominates restore time. This is where the savings actually were: packing statements into batches cut a 50k-row restore's median wall time by about a third even against a local server (500 batches down to 5), with no change to the statements themselves.
Both batch caps are checked before a statement is written rather than
after, so they are genuine upper bounds and not limits a batch may overshoot
by one statement; a batch always holds at least one statement, however large
that single statement is on its own. maxBatchBytes is exact (the byte
accounting includes the separators and terminators actually written, and is
computed from parts already measured — never by re-scanning assembled
statement text) and defaults to an eighth of the 64 MiB
SqlBatchParserOptions.maxBatchBytes a restore accepts. SET IDENTITY_INSERT
statements ride along in whatever batch is open without counting toward its
row cap, and the final batch is closed once, after the closing OFF — a
table that emitted no statements at all produces no stray GO.
An identity column present among the insertable set wraps the whole
export in SET IDENTITY_INSERT [schema].[table] ON; / ... OFF;. If an
error occurs mid-export (a dropped connection, a cancelled signal), the
OFF statement is still emitted best-effort before the error propagates —
otherwise the generated dump file itself would be left with an unbalanced
ON with no matching OFF, which would affect every unrelated INSERT
statement executed later in the same restore session, not just this table's.
A secondary failure while emitting that best-effort OFF (e.g. the output
stream itself is broken) is swallowed rather than masking the original
error.
onProgress receives an exporting-data event after every row (message:
"schema.table", objectsProcessed: the running row count for that table,
bytesWritten: the writer's cumulative byte count) — not only per batch —
so a caller can report "rows written" and "bytes so far" at row
granularity even though the underlying SQL is batched.
COPY/bulk-protocol-based data loading (SQL Server's BULK INSERT/bcp
equivalent of PostgreSQL's COPY) is intentionally not implemented: the
project requirements call for plain .sql output only at this stage, and
INSERT statements are portable across any SQL Server client, whereas BULK INSERT requires server-side file access or a TDS bulk-load session tedious
itself would need to originate.
restoreSqlDump({ connection, source, options?, signal?, progress? })
(src/restore/) restores a plain-SQL dump using only the MssqlConnection
abstraction — no sqlcmd, SMO, bcp, or external process is ever invoked.
It is a real lexer over GO-separated batches followed by sequential
execution, not a naive line-splitter: GO is only ever recognized as a
batch separator when it is genuinely standalone, outside every string,
bracketed/double-quoted identifier, and comment. PRINT 'GO' and a GO
inside a comment never split a batch, and neither does a GO inside a
multi-line string/bracket/comment — a case the previous, line-only
implementation could get wrong.
SqlBatchParser is an incremental state machine: feed it text via push()
(any number of times, in any chunk size) and call finish() once at end of
input. It tracks one lexer mode — normal, inside a '...' string, inside
a "..." string/identifier, inside a [...] bracketed identifier, or
inside a (possibly nested — SQL Server, unlike ANSI SQL, allows /* /* */ */) block comment — across line and chunk boundaries, correctly handling
doubled-quote ('', ]]) escapes. Line comments (--) never need a
persisted mode since they always end at that physical line's own end. A
GO line is recognized only when the lexer is in normal mode at the
start of that line and the line matches ^\s*GO(?:\s+(\d+))?\s*(?:--.*)?$
(case-insensitive) — the same convention sqlcmd/SSMS use, including the
optional GO <n> repeat-count form.
parseSqlBatches(sql, options?) is a convenience wrapper over one
in-memory string; streamSqlBatches(source, options?, signal?) is the
streaming form restoreSqlDump actually uses, accepting a string, a
Readable, or any AsyncIterable<string | Buffer | Uint8Array> (Buffer/
Uint8Array chunks are decoded as UTF-8 through a persistent
node:string_decoder StringDecoder, so a multi-byte character split
across two chunks is never corrupted). At most one in-progress batch's
accumulated text, plus one pending partial line, is held in memory at a
time — the whole input is never buffered. A single batch must still be sent
to the server whole, so options.maxBatchBytes (default 64 MiB) bounds how
much of a pathological input with no GO lines at all the parser will
accumulate before giving up (BatchTooLargeError) rather than growing
unboundedly.
Every error restoreSqlDump/the parser throws intentionally extends
RestoreError:
SqlBatchParseError(parser errors) and its subclasses —MalformedSqlDumpError(a string/bracket/comment left open at end of input),InvalidGoRepeatCountError(GO 0,GO abc, or a count aboveoptions.maxGoRepeatCount, default 100,000),BatchTooLargeError, andUnsupportedSqlcmdDirectiveError— are always fatal and thrown out ofrestoreSqlDump: a parse failure means the batch boundaries themselves are not trustworthy, so nothing after the failure point can be safely executed either, regardless ofoptions.stopOnError.RestoreExecutionErroris scoped to one batch that parsed correctly but failed on the server. Unlike the parser errors, this is not thrown fromrestoreSqlDump— its data (batchIndex,location, a redactedsqlPreview, and a redactedmessage) is what populatesSqlDumpRestoreResult.errors, and — unlessoptions.stopOnError(the default) — restoration continues with the next batch. This mirrors every other stage in this package (dumpMssql,exportTableDataAsInserts,renderPlainSql): a recoverable, per-item problem is returned as structured data, not thrown.- Cancellation reuses the existing
OperationCancelledError(src/utils/errors.ts): an abortedAbortSignalmakesrestoreSqlDumpreturn{ cancelled: true, ... }with whatever partial counts had already accumulated, the same convention every long-running function in this package follows, rather than surfacing a thrown exception to distinguish from a crash.
sqlcmd/SSMS preprocess a script — expanding :setvar/$(Variable)
substitutions, running :r file includes, executing !!/:!! shell escapes —
before any batch ever reaches SQL Server. restoreSqlDump executes
batches directly against the connection and implements no such
preprocessor. Rather than silently forwarding an unresolved
$(Variable) token or a :setvar line to the server (which would fail
with a confusing native syntax error, or in principle execute as
unintended T-SQL), the parser detects these constructs explicitly and
throws UnsupportedSqlcmdDirectiveError:
- A colon-prefixed directive (
:r,:setvar,:connect,:on error,:!!, ...) or standard!!shell escape is recognized only when it starts in column 1 (no leading whitespace) — matchingsqlcmditself, and avoiding a false positive on indented T-SQL that happens to contain a colon (aCASElabel, a::static method reference likegeography::STGeomFromText). - A
$(Variable)token is recognized outside any string/comment/bracket —$is never a valid leading character of an unquoted T-SQL identifier, so this is unambiguous in practice, and a$(...)-shaped substring inside a string literal (where it is just ordinary data) is correctly left alone.
restoreSqlDump targets plain T-SQL batches, GO-separated: the exact
shape renderPlainSql produces, so a package-generated dump round-trips
correctly by construction (verified by the end-to-end test in
dumpMssql.test.ts/restore.test.ts). Ordinary hand-written SQL Server
scripts restore too, as long as they do not rely on sqlcmd scripting
(directives/variable substitution, detected and rejected above) or depend
on a GO-alone-on-its-line appearing legitimately inside an unterminated
multi-line string that was never meant to close before end of input (a
lexically pathological case this package has no way to distinguish from a
genuine mistake). This is a documented subset, not a claim of full
sqlcmd compatibility.
Each batch is executed via connection.execBatch() when the adapter
provides it, falling back to connection.query() otherwise.
execBatch is deliberately distinct from query(): on the Tedious
adapter, execBatch calls connection.execSqlBatch() (a real TDS SQL
batch, matching sqlcmd/SSMS exactly), while query() calls
connection.execSql() (routed through sp_executesql). This matters for
restore specifically — CREATE PROCEDURE/CREATE VIEW/CREATE FUNCTION/
CREATE TRIGGER must be the only statement in their batch and can behave
differently (or be rejected) inside an sp_executesql wrapper, and
batch-scoped constructs (local temp tables, SET options meant to persist
for later batches on the same connection) rely on not being sandboxed
inside a nested execution context. execBatch also supports no parameter
binding, matching execSqlBatch's own contract — batches from a restored
script are never parameterized. A GO <n> batch is executed n times in
sequence, each execution counted and tallied independently.
SqlDumpRestoreResult.rowsRestored sums rowsAffected across every
successfully executed batch. Tedious's own Request.rowCount already
accumulates across every DONE/DONE_IN_PROC token a batch produces (
confirmed by reading tedious/lib/token/handler.js), so a data batch
containing several INSERT statements — exactly what dumpMssql's own
data batches look like — reports their combined row count without this
package needing to parse or count statements itself. This is a
straightforward sum, not a data-batch-specific heuristic: ordinary DDL
reports 0, so in practice the total reflects rows inserted, but a script
containing its own UPDATE/DELETE statements would contribute those rows
too.
RestoreBatchError.sqlPreview (via safeSqlPreview) is always truncated
(200 characters by default) and never a full, potentially large statement.
Both sqlPreview and the execution error's message are passed through
redactSecrets() before being recorded, which blanks the value side of
PASSWORD =/PWD =/IDENTIFIED BY clauses ('...' or N'...') — a
failing CREATE LOGIN/ALTER LOGIN/CREATE USER statement, or a driver
error message that happens to echo the failing statement text back, can
therefore never leak the literal credential value into a diagnostic or
error. This is pattern-based, not a full parser, and protects the specific
clauses SQL Server itself uses for credentials — not arbitrary
"looks sensitive" text (see src/security/sensitive.ts for the equivalent
guard over connection options, as opposed to SQL text).
progress receives parsing events (one per batch as it is parsed, with
batchIndex) interleaved with executing events around each batch execution,
including each repetition of a GO <n> batch, with a running
rowsRestored total. Recognized generated INSERT batches additionally report
their executionMode (bulk-insert or sql-fallback), lifecycle state, schema,
and table so callers can aggregate concise table-level start/finish messages.
A final finalizing event closes the stream. AbortSignal is
checked before every batch execution and at every chunk boundary while
reading source; on cancellation, restoreSqlDump returns
{ cancelled: true, ... } with whatever had already executed, and the
acquired connection is always released via a finally block, regardless of
how the function returns.
preflightRestore() (src/preflight/) currently only detects the restore
target's version/capabilities ahead of running any statement. Existing-
object conflict detection, schema/role remapping, and dependency validation
against a specific archive (all present in dbgate-pg-dumper's restore
pipeline) are explicitly future work — see below.
Every stage that can produce a non-fatal problem returns it as a structured
MssqlDiagnostic { severity, code, message, objectReference? }
(src/model/diagnostics.ts) rather than throwing or logging text — callers
can filter/aggregate/display them without string-parsing. Progress is
reported through typed callbacks (DumpProgressCallback/
RestoreProgressCallback, src/utils/progress.ts) with a closed phase
union per operation, not free-form strings.
Every long-running function accepts an AbortSignal and, on cancellation,
returns its result shape with cancelled: true and whatever partial output/
counts had already been produced, rather than throwing — so a caller
cancelling a large dump/restore gets a well-formed, inspectable result
instead of having to distinguish "cancelled" from "crashed" by exception
type.
This phase is scaffolding: public interfaces, the connection abstraction, the Tedious adapter, and working (but intentionally partial) dump/restore/ introspection pipelines. Explicitly deferred:
- Procedure/function parameter introspection.
sys.parametersis not read yet;MssqlRoutine.parametersis always[](see "Catalog introspection" above). - Non-
CLUSTERED/NONCLUSTEREDindexes. Columnstore, XML, and spatial indexes are detected as unsupported and handled perunsupportedFeaturePolicy; they are not rendered (thoughloadIndexes()does report theirtype_descverbatim in the model). - Deep temporal/memory-optimized/graph-table metadata, Always Encrypted
column metadata. The cheap table-level flags (
isSystemVersioned,historyTable*,isMemoryOptimized,durability) are populated fromsys.tables, but period columns, retention policy, memory-optimized index/bucket metadata, graphNODE/EDGEspecifics, and Always Encrypted key metadata are not introspected. - Restore conflict detection and role/schema remapping.
preflightRestoredoes not yet check for existing objects on the target or offer schema/ login remapping. - Bulk/native data loading. Only
INSERT-statement data export/restore is implemented, by design for this phase (see Data export, above). - Sequence current-value export.
sequenceStatearchive entries exist and are correctly ordered (see "Archive planning"), butdumpMssqldoes not yet stream a sequence's current value the way it doestableData; those entries still fall back torenderPlainSql's default "not rendered" warning. sql_variant,xml,geography,geometry,hierarchyidcolumn data. Detected and excluded fromINSERToutput with anunsupported-column-typediagnostic per column (see "Data export" above); never guessed at or serialized incorrectly.- A
tedious-based connection pool source. Only direct-connection wrapping (fromTediousConnection) exists today. sqlcmdscripting.:setvar/$(Variable)substitution,:rfile includes,:connect, and shell escapes (!!/:!!) are detected and rejected withUnsupportedSqlcmdDirectiveError, never executed or expanded (see "Restore" above).