1006 lines
33 KiB
JavaScript
1006 lines
33 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Build eazybusiness_minimal from a saved snapshot (no source DB required).
|
|
*
|
|
* Snapshot layout (scripts/minimal-db/):
|
|
* manifest.json metadata + table/type lists
|
|
* definitions.json schemas, CREATE TABLE/TYPE, indexes, triggers
|
|
* data/<schema>.<table>.json row payloads (binary as base64)
|
|
*
|
|
* Usage:
|
|
* node scripts/create-minimal-db.mjs extract # from source → snapshot
|
|
* node scripts/create-minimal-db.mjs # snapshot → target DB
|
|
* node scripts/create-minimal-db.mjs create --target eazybusiness_minimal
|
|
* node scripts/create-minimal-db.mjs --list
|
|
*
|
|
* Env: MSSQL_* from .env (create only needs a server; extract also needs --source).
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import dotenv from 'dotenv';
|
|
import sql from 'mssql';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
dotenv.config({ path: path.join(root, '.env') });
|
|
|
|
const SNAPSHOT_DIR = path.join(root, 'scripts/minimal-db');
|
|
const DEFINITIONS_PATH = path.join(SNAPSHOT_DIR, 'definitions.json');
|
|
const MANIFEST_PATH = path.join(SNAPSHOT_DIR, 'manifest.json');
|
|
const DATA_DIR = path.join(SNAPSHOT_DIR, 'data');
|
|
|
|
/** Extra table types used directly by src/queries (not only via triggers). */
|
|
const APP_TABLE_TYPES = [
|
|
['dbo', 'TYPE_spkundeInsert'],
|
|
['Verkauf', 'TYPE_spAuftragEckdatenBerechnen'],
|
|
];
|
|
|
|
/** Tables referenced by src/queries (dbo/Verkauf/Pos user tables only). */
|
|
const TABLES = [
|
|
['dbo', 'tArtikel'],
|
|
['dbo', 'tArtikelAttribut'],
|
|
['dbo', 'tArtikelAttributSprache'],
|
|
['dbo', 'tArtikelBeschreibung'],
|
|
['dbo', 'tArtikelbildPlattform'],
|
|
['dbo', 'tAttribut'],
|
|
['dbo', 'tAttributSprache'],
|
|
['dbo', 'tBild'],
|
|
['dbo', 'tFirmaHistory'],
|
|
['dbo', 'tKategorie'],
|
|
['dbo', 'tKategorieArtikel'],
|
|
['dbo', 'tKategorieShop'],
|
|
['dbo', 'tKategorieSprache'],
|
|
['dbo', 'tKategoriebildPlattform'],
|
|
['dbo', 'tKunde'],
|
|
['dbo', 'tKundenGruppe'],
|
|
['dbo', 'tLaufendeNummern'],
|
|
['dbo', 'tLieferschein'],
|
|
['dbo', 'tPickliste'],
|
|
['dbo', 'tPicklistePos'],
|
|
['dbo', 'tPlattform'],
|
|
['dbo', 'tPreis'],
|
|
['dbo', 'tPreisDetail'],
|
|
['dbo', 'tSessionId'],
|
|
['dbo', 'tShop'],
|
|
['dbo', 'tShopSubshop'],
|
|
['dbo', 'tSteuersatz'],
|
|
['dbo', 'tSteuerzone'],
|
|
['dbo', 'tStueckliste'],
|
|
['dbo', 'tVersand'],
|
|
['dbo', 'tVersandArt'],
|
|
['dbo', 'tWarenLager'],
|
|
['dbo', 'tWarenLagerPlatz'],
|
|
['dbo', 'tZahlung'],
|
|
['dbo', 'tZahlungsArtSprache'],
|
|
['dbo', 'tZahlungsart'],
|
|
['dbo', 'tpk'],
|
|
['Pos', 'tAuftragMapping'],
|
|
['Pos', 'tAuftragPositionMapping'],
|
|
['Verkauf', 'tAuftrag'],
|
|
['Verkauf', 'tAuftragAdresse'],
|
|
['Verkauf', 'tAuftragEckdaten'],
|
|
['Verkauf', 'tAuftragPosition'],
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
command: 'create',
|
|
source: null,
|
|
target: 'eazybusiness_minimal',
|
|
list: false,
|
|
help: false,
|
|
snapshotDir: SNAPSHOT_DIR,
|
|
};
|
|
const positionals = [];
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--list') args.list = true;
|
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
else if (a === '--source') args.source = argv[++i];
|
|
else if (a === '--target') args.target = argv[++i];
|
|
else if (a === '--snapshot') args.snapshotDir = path.resolve(argv[++i]);
|
|
else if (a.startsWith('-')) throw new Error(`Unknown argument: ${a}`);
|
|
else positionals.push(a);
|
|
}
|
|
if (positionals[0] === 'extract' || positionals[0] === 'create') {
|
|
args.command = positionals[0];
|
|
} else if (positionals[0]) {
|
|
throw new Error(`Unknown command: ${positionals[0]} (use extract|create)`);
|
|
}
|
|
args.source = args.source || process.env.MSSQL_DATABASE || 'eazybusiness';
|
|
return args;
|
|
}
|
|
|
|
function baseConfig(database) {
|
|
if (!process.env.MSSQL_USER) {
|
|
throw new Error('MSSQL_USER is not set (load .env or export it)');
|
|
}
|
|
return {
|
|
server: process.env.MSSQL_SERVER || 'localhost',
|
|
port: Number(process.env.MSSQL_PORT) || 1433,
|
|
database,
|
|
user: process.env.MSSQL_USER,
|
|
password: process.env.MSSQL_PASSWORD,
|
|
options: {
|
|
encrypt: process.env.MSSQL_ENCRYPT !== 'false',
|
|
trustServerCertificate: process.env.MSSQL_TRUST_SERVER_CERTIFICATE !== 'false',
|
|
},
|
|
requestTimeout: 600_000,
|
|
connectionTimeout: 30_000,
|
|
};
|
|
}
|
|
|
|
async function withPool(database, fn) {
|
|
const pool = new sql.ConnectionPool(baseConfig(database));
|
|
await pool.connect();
|
|
try {
|
|
return await fn(pool);
|
|
} finally {
|
|
await pool.close();
|
|
}
|
|
}
|
|
|
|
function qIdent(name) {
|
|
return `[${String(name).replace(/]/g, ']]')}]`;
|
|
}
|
|
|
|
function qName(schema, table) {
|
|
return `${qIdent(schema)}.${qIdent(table)}`;
|
|
}
|
|
|
|
function dataFileName(schema, table) {
|
|
return `${schema}.${table}.json`;
|
|
}
|
|
|
|
function log(msg) {
|
|
console.log(msg);
|
|
}
|
|
|
|
function writeJson(filePath, value) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function sqlTypeName(col) {
|
|
if (col.isRowversion) return 'binary(8)';
|
|
const t = col.typeName.toLowerCase();
|
|
if (['nvarchar', 'nchar', 'varchar', 'char', 'varbinary', 'binary'].includes(t)) {
|
|
if (col.maxLength === -1) return `${col.typeName}(MAX)`;
|
|
const len = ['nvarchar', 'nchar'].includes(t) ? col.maxLength / 2 : col.maxLength;
|
|
return `${col.typeName}(${len})`;
|
|
}
|
|
if (['decimal', 'numeric'].includes(t)) {
|
|
return `${col.typeName}(${col.precision},${col.scale})`;
|
|
}
|
|
if (['datetime2', 'datetimeoffset', 'time'].includes(t)) {
|
|
return `${col.typeName}(${col.scale})`;
|
|
}
|
|
if (t === 'float' && col.precision !== 53) {
|
|
return `${col.typeName}(${col.precision})`;
|
|
}
|
|
return col.typeName;
|
|
}
|
|
|
|
function columnListSql(columns, { allowDefaults = true } = {}) {
|
|
return columns
|
|
.map((c) => {
|
|
if (c.isComputed) {
|
|
const persisted = c.isPersisted ? ' PERSISTED' : '';
|
|
return ` ${qIdent(c.columnName)} AS ${c.computedDefinition}${persisted}`;
|
|
}
|
|
const nullability = c.isNullable ? 'NULL' : 'NOT NULL';
|
|
let identity = '';
|
|
if (c.isIdentity) {
|
|
const seed = c.identitySeed ?? 1;
|
|
const incr = c.identityIncrement ?? 1;
|
|
identity = ` IDENTITY(${seed},${incr})`;
|
|
}
|
|
const def =
|
|
allowDefaults && c.defaultDefinition
|
|
? ` DEFAULT ${c.defaultDefinition}`
|
|
: '';
|
|
const comment = c.isRowversion ? ' /* was rowversion; stored as binary(8) for restore */' : '';
|
|
return ` ${qIdent(c.columnName)} ${sqlTypeName(c)}${identity}${def} ${nullability}${comment}`;
|
|
})
|
|
.join(',\n');
|
|
}
|
|
|
|
function parseTypeRefsFromDefinitions(definitions) {
|
|
const refs = new Map();
|
|
const add = (schema, name) => {
|
|
const key = `${schema}.${name}`.toLowerCase();
|
|
if (!refs.has(key)) refs.set(key, [schema, name]);
|
|
};
|
|
for (const [schema, name] of APP_TABLE_TYPES) add(schema, name);
|
|
|
|
const reQualified = /(?:\[?([A-Za-z_][\w]*)\]?\.)\[?(TYPE_[\w]+)\]?/gi;
|
|
const reBare = /DECLARE\s+@\w+\s+(?:AS\s+)?\[?(TYPE_[\w]+)\]?/gi;
|
|
for (const def of definitions) {
|
|
if (!def) continue;
|
|
for (const m of def.matchAll(reQualified)) add(m[1], m[2]);
|
|
for (const m of def.matchAll(reBare)) add('dbo', m[1]);
|
|
}
|
|
return [...refs.values()];
|
|
}
|
|
|
|
function mssqlTypeForColumn(col) {
|
|
const t = col.typeName.toLowerCase();
|
|
if (col.isRowversion) return sql.Binary(8);
|
|
switch (t) {
|
|
case 'int':
|
|
return sql.Int;
|
|
case 'bigint':
|
|
return sql.BigInt;
|
|
case 'smallint':
|
|
return sql.SmallInt;
|
|
case 'tinyint':
|
|
return sql.TinyInt;
|
|
case 'bit':
|
|
return sql.Bit;
|
|
case 'decimal':
|
|
case 'numeric':
|
|
return sql.Decimal(col.precision, col.scale);
|
|
case 'money':
|
|
return sql.Money;
|
|
case 'smallmoney':
|
|
return sql.SmallMoney;
|
|
case 'float':
|
|
return sql.Float(col.precision);
|
|
case 'real':
|
|
return sql.Real;
|
|
case 'date':
|
|
return sql.Date;
|
|
case 'datetime':
|
|
return sql.DateTime;
|
|
case 'datetime2':
|
|
return sql.DateTime2(col.scale);
|
|
case 'datetimeoffset':
|
|
return sql.DateTimeOffset(col.scale);
|
|
case 'smalldatetime':
|
|
return sql.SmallDateTime;
|
|
case 'time':
|
|
return sql.Time(col.scale);
|
|
case 'uniqueidentifier':
|
|
return sql.UniqueIdentifier;
|
|
case 'xml':
|
|
return sql.Xml;
|
|
case 'nvarchar':
|
|
return col.maxLength === -1 ? sql.NVarChar(sql.MAX) : sql.NVarChar(col.maxLength / 2);
|
|
case 'nchar':
|
|
return sql.NChar(col.maxLength / 2);
|
|
case 'varchar':
|
|
return col.maxLength === -1 ? sql.VarChar(sql.MAX) : sql.VarChar(col.maxLength);
|
|
case 'char':
|
|
return sql.Char(col.maxLength);
|
|
case 'varbinary':
|
|
return col.maxLength === -1 ? sql.VarBinary(sql.MAX) : sql.VarBinary(col.maxLength);
|
|
case 'binary':
|
|
return sql.Binary(col.maxLength);
|
|
case 'text':
|
|
return sql.Text;
|
|
case 'ntext':
|
|
return sql.NText;
|
|
case 'image':
|
|
return sql.Image;
|
|
default:
|
|
throw new Error(`Unsupported column type ${col.typeName} for ${col.columnName}`);
|
|
}
|
|
}
|
|
|
|
function serializeCell(value) {
|
|
if (value === null || value === undefined) return null;
|
|
if (Buffer.isBuffer(value)) {
|
|
return { __type: 'Buffer', base64: value.toString('base64') };
|
|
}
|
|
if (value instanceof Date) {
|
|
return { __type: 'Date', iso: value.toISOString() };
|
|
}
|
|
if (typeof value === 'bigint') {
|
|
return { __type: 'BigInt', value: value.toString() };
|
|
}
|
|
if (typeof value === 'object' && value !== null && value.type === 'Buffer' && Array.isArray(value.data)) {
|
|
return { __type: 'Buffer', base64: Buffer.from(value.data).toString('base64') };
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function deserializeCell(value) {
|
|
if (value === null || value === undefined) return null;
|
|
if (typeof value === 'object' && value !== null && value.__type) {
|
|
if (value.__type === 'Buffer') return Buffer.from(value.base64, 'base64');
|
|
if (value.__type === 'Date') return new Date(value.iso);
|
|
if (value.__type === 'BigInt') return value.value;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function verifySourceTables(sourcePool) {
|
|
const missing = [];
|
|
for (const [schema, table] of TABLES) {
|
|
const r = await sourcePool
|
|
.request()
|
|
.input('schema', sql.NVarChar, schema)
|
|
.input('table', sql.NVarChar, table)
|
|
.query(`
|
|
SELECT 1 AS ok
|
|
FROM sys.tables t
|
|
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
WHERE s.name = @schema AND t.name = @table
|
|
`);
|
|
if (!r.recordset.length) missing.push(`${schema}.${table}`);
|
|
}
|
|
if (missing.length) {
|
|
throw new Error(`Missing source tables:\n ${missing.join('\n ')}`);
|
|
}
|
|
}
|
|
|
|
async function loadTableColumns(sourcePool, schema, table) {
|
|
const result = await sourcePool
|
|
.request()
|
|
.input('schema', sql.NVarChar, schema)
|
|
.input('table', sql.NVarChar, table)
|
|
.query(`
|
|
SELECT
|
|
c.name AS columnName,
|
|
ty.name AS typeName,
|
|
c.max_length AS maxLength,
|
|
c.[precision] AS [precision],
|
|
c.scale AS scale,
|
|
c.is_nullable AS isNullable,
|
|
c.is_identity AS isIdentity,
|
|
c.is_computed AS isComputed,
|
|
CASE WHEN ty.name IN (N'timestamp', N'rowversion') THEN 1 ELSE 0 END AS isRowversion,
|
|
cc.definition AS computedDefinition,
|
|
cc.is_persisted AS isPersisted,
|
|
dc.definition AS defaultDefinition,
|
|
CONVERT(bigint, ic.seed_value) AS identitySeed,
|
|
CONVERT(bigint, ic.increment_value) AS identityIncrement,
|
|
CONVERT(bigint, ic.last_value) AS identityLastValue,
|
|
c.column_id AS columnId
|
|
FROM sys.columns c
|
|
INNER JOIN sys.tables t ON t.object_id = c.object_id
|
|
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
INNER JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
|
LEFT JOIN sys.computed_columns cc
|
|
ON cc.object_id = c.object_id AND cc.column_id = c.column_id
|
|
LEFT JOIN sys.default_constraints dc ON dc.object_id = c.default_object_id
|
|
LEFT JOIN sys.identity_columns ic
|
|
ON ic.object_id = c.object_id AND ic.column_id = c.column_id
|
|
WHERE s.name = @schema AND t.name = @table
|
|
ORDER BY c.column_id
|
|
`);
|
|
return result.recordset.map((c) => ({
|
|
...c,
|
|
isNullable: !!c.isNullable,
|
|
isIdentity: !!c.isIdentity,
|
|
isComputed: !!c.isComputed,
|
|
isRowversion: !!c.isRowversion,
|
|
isPersisted: !!c.isPersisted,
|
|
}));
|
|
}
|
|
|
|
function buildCreateTableDdl(schema, table, columns) {
|
|
return `CREATE TABLE ${qName(schema, table)} (\n${columnListSql(columns)}\n)`;
|
|
}
|
|
|
|
async function loadTableTypeDdl(sourcePool, typeRefs) {
|
|
if (!typeRefs.length) return [];
|
|
|
|
const values = typeRefs
|
|
.map(([s, n]) => `(N'${s.replace(/'/g, "''")}', N'${n.replace(/'/g, "''")}')`)
|
|
.join(',');
|
|
|
|
const meta = await sourcePool.request().query(`
|
|
SELECT
|
|
SCHEMA_NAME(tt.schema_id) AS schemaName,
|
|
tt.name AS typeName,
|
|
tt.type_table_object_id AS typeTableObjectId
|
|
FROM sys.table_types tt
|
|
INNER JOIN (VALUES ${values}) AS wanted(schemaName, typeName)
|
|
ON wanted.schemaName = SCHEMA_NAME(tt.schema_id) AND wanted.typeName = tt.name
|
|
ORDER BY SCHEMA_NAME(tt.schema_id), tt.name
|
|
`);
|
|
|
|
const found = new Set(
|
|
meta.recordset.map((r) => `${r.schemaName}.${r.typeName}`.toLowerCase())
|
|
);
|
|
const missing = typeRefs.filter(([s, n]) => !found.has(`${s}.${n}`.toLowerCase()));
|
|
if (missing.length) {
|
|
throw new Error(
|
|
`Missing source table types:\n ${missing.map(([s, n]) => `${s}.${n}`).join('\n ')}`
|
|
);
|
|
}
|
|
|
|
const ddls = [];
|
|
for (const tt of meta.recordset) {
|
|
const cols = await sourcePool
|
|
.request()
|
|
.input('objectId', sql.Int, tt.typeTableObjectId)
|
|
.query(`
|
|
SELECT
|
|
c.name AS columnName,
|
|
ty.name AS typeName,
|
|
c.max_length AS maxLength,
|
|
c.[precision] AS [precision],
|
|
c.scale AS scale,
|
|
c.is_nullable AS isNullable,
|
|
c.is_identity AS isIdentity,
|
|
CONVERT(bit, 0) AS isComputed,
|
|
CONVERT(bit, 0) AS isRowversion,
|
|
CONVERT(nvarchar(max), NULL) AS computedDefinition,
|
|
CONVERT(bit, 0) AS isPersisted,
|
|
CONVERT(nvarchar(max), NULL) AS defaultDefinition,
|
|
CONVERT(bigint, NULL) AS identitySeed,
|
|
CONVERT(bigint, NULL) AS identityIncrement,
|
|
c.column_id AS columnId
|
|
FROM sys.columns c
|
|
INNER JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
|
WHERE c.object_id = @objectId
|
|
ORDER BY c.column_id
|
|
`);
|
|
|
|
const columns = cols.recordset.map((c) => ({
|
|
...c,
|
|
isNullable: !!c.isNullable,
|
|
isIdentity: !!c.isIdentity,
|
|
isComputed: false,
|
|
isRowversion: false,
|
|
isPersisted: false,
|
|
}));
|
|
|
|
const pkCols = await sourcePool
|
|
.request()
|
|
.input('objectId', sql.Int, tt.typeTableObjectId)
|
|
.query(`
|
|
SELECT c.name AS columnName, ic.is_descending_key AS isDescending, ic.key_ordinal AS keyOrdinal
|
|
FROM sys.indexes i
|
|
INNER JOIN sys.index_columns ic
|
|
ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
|
INNER JOIN sys.columns c
|
|
ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
|
WHERE i.object_id = @objectId AND i.is_primary_key = 1
|
|
ORDER BY ic.key_ordinal
|
|
`);
|
|
|
|
let ddl = `CREATE TYPE ${qName(tt.schemaName, tt.typeName)} AS TABLE (\n${columnListSql(columns, { allowDefaults: false })}`;
|
|
if (pkCols.recordset.length) {
|
|
const pk = pkCols.recordset
|
|
.map((c) => `${qIdent(c.columnName)} ${c.isDescending ? 'DESC' : 'ASC'}`)
|
|
.join(', ');
|
|
ddl += `,\n PRIMARY KEY CLUSTERED (${pk})`;
|
|
}
|
|
ddl += '\n)';
|
|
ddls.push({ schemaName: tt.schemaName, typeName: tt.typeName, ddl });
|
|
}
|
|
return ddls;
|
|
}
|
|
|
|
async function loadIndexes(sourcePool) {
|
|
const tableList = TABLES.map(([s, t]) => `N'${s}.${t}'`).join(',');
|
|
const result = await sourcePool.request().query(`
|
|
SELECT
|
|
s.name AS schemaName,
|
|
t.name AS tableName,
|
|
i.name AS indexName,
|
|
i.index_id AS indexId,
|
|
i.is_primary_key AS isPrimaryKey,
|
|
i.is_unique_constraint AS isUniqueConstraint,
|
|
i.is_unique AS isUnique,
|
|
i.type_desc AS typeDesc,
|
|
i.has_filter AS hasFilter,
|
|
i.filter_definition AS filterDefinition,
|
|
i.fill_factor AS [fillFactor],
|
|
i.is_padded AS isPadded,
|
|
i.ignore_dup_key AS ignoreDupKey,
|
|
i.allow_row_locks AS allowRowLocks,
|
|
i.allow_page_locks AS allowPageLocks
|
|
FROM sys.indexes i
|
|
INNER JOIN sys.tables t ON t.object_id = i.object_id
|
|
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
WHERE i.index_id > 0
|
|
AND i.is_hypothetical = 0
|
|
AND i.type IN (1, 2)
|
|
AND s.name + N'.' + t.name IN (${tableList})
|
|
ORDER BY s.name, t.name,
|
|
CASE WHEN i.is_primary_key = 1 THEN 0 WHEN i.is_unique_constraint = 1 THEN 1 ELSE 2 END,
|
|
i.index_id
|
|
`);
|
|
|
|
const columns = await sourcePool.request().query(`
|
|
SELECT
|
|
s.name AS schemaName,
|
|
t.name AS tableName,
|
|
i.index_id AS indexId,
|
|
c.name AS columnName,
|
|
ic.is_descending_key AS isDescending,
|
|
ic.is_included_column AS isIncluded,
|
|
ic.key_ordinal AS keyOrdinal,
|
|
ic.index_column_id AS indexColumnId
|
|
FROM sys.index_columns ic
|
|
INNER JOIN sys.indexes i ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
|
INNER JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
|
INNER JOIN sys.tables t ON t.object_id = i.object_id
|
|
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
WHERE i.index_id > 0
|
|
AND i.type IN (1, 2)
|
|
AND s.name + N'.' + t.name IN (${tableList})
|
|
ORDER BY s.name, t.name, i.index_id, ic.is_included_column, ic.key_ordinal, ic.index_column_id
|
|
`);
|
|
|
|
const colsByKey = new Map();
|
|
for (const row of columns.recordset) {
|
|
const key = `${row.schemaName}.${row.tableName}.${row.indexId}`;
|
|
if (!colsByKey.has(key)) colsByKey.set(key, []);
|
|
colsByKey.get(key).push(row);
|
|
}
|
|
|
|
return result.recordset.map((idx) => {
|
|
const full = {
|
|
...idx,
|
|
isPrimaryKey: !!idx.isPrimaryKey,
|
|
isUniqueConstraint: !!idx.isUniqueConstraint,
|
|
isUnique: !!idx.isUnique,
|
|
hasFilter: !!idx.hasFilter,
|
|
isPadded: !!idx.isPadded,
|
|
ignoreDupKey: !!idx.ignoreDupKey,
|
|
allowRowLocks: !!idx.allowRowLocks,
|
|
allowPageLocks: !!idx.allowPageLocks,
|
|
columns: colsByKey.get(`${idx.schemaName}.${idx.tableName}.${idx.indexId}`) || [],
|
|
};
|
|
return {
|
|
schemaName: full.schemaName,
|
|
tableName: full.tableName,
|
|
indexName: full.indexName,
|
|
ddl: buildIndexDdl(full),
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildIndexDdl(idx) {
|
|
const table = qName(idx.schemaName, idx.tableName);
|
|
const indexName = qIdent(idx.indexName);
|
|
const keyCols = idx.columns
|
|
.filter((c) => !c.isIncluded)
|
|
.map((c) => `${qIdent(c.columnName)} ${c.isDescending ? 'DESC' : 'ASC'}`);
|
|
const includeCols = idx.columns
|
|
.filter((c) => c.isIncluded)
|
|
.map((c) => qIdent(c.columnName));
|
|
|
|
if (!keyCols.length) {
|
|
throw new Error(`Index ${idx.schemaName}.${idx.tableName}.${idx.indexName} has no key columns`);
|
|
}
|
|
|
|
const withParts = [
|
|
`PAD_INDEX = ${idx.isPadded ? 'ON' : 'OFF'}`,
|
|
`IGNORE_DUP_KEY = ${idx.ignoreDupKey ? 'ON' : 'OFF'}`,
|
|
`ALLOW_ROW_LOCKS = ${idx.allowRowLocks ? 'ON' : 'OFF'}`,
|
|
`ALLOW_PAGE_LOCKS = ${idx.allowPageLocks ? 'ON' : 'OFF'}`,
|
|
];
|
|
if (idx.fillFactor > 0) withParts.push(`FILLFACTOR = ${idx.fillFactor}`);
|
|
|
|
const filter = idx.hasFilter && idx.filterDefinition
|
|
? ` WHERE ${idx.filterDefinition}`
|
|
: '';
|
|
const include = includeCols.length ? ` INCLUDE (${includeCols.join(', ')})` : '';
|
|
const unique = idx.isUnique || idx.isPrimaryKey || idx.isUniqueConstraint ? 'UNIQUE ' : '';
|
|
const clustered = idx.typeDesc === 'CLUSTERED' ? 'CLUSTERED' : 'NONCLUSTERED';
|
|
|
|
if (idx.isPrimaryKey) {
|
|
return `ALTER TABLE ${table} ADD CONSTRAINT ${indexName} PRIMARY KEY ${clustered} (${keyCols.join(', ')}) WITH (${withParts.join(', ')})`;
|
|
}
|
|
if (idx.isUniqueConstraint) {
|
|
return `ALTER TABLE ${table} ADD CONSTRAINT ${indexName} UNIQUE ${clustered} (${keyCols.join(', ')}) WITH (${withParts.join(', ')})`;
|
|
}
|
|
return `CREATE ${unique}${clustered} INDEX ${indexName} ON ${table} (${keyCols.join(', ')})${include}${filter} WITH (${withParts.join(', ')})`;
|
|
}
|
|
|
|
async function loadTriggers(sourcePool) {
|
|
const tableList = TABLES.map(([s, t]) => `N'${s}.${t}'`).join(',');
|
|
const result = await sourcePool.request().query(`
|
|
SELECT
|
|
s.name AS schemaName,
|
|
t.name AS tableName,
|
|
tr.name AS triggerName,
|
|
tr.is_disabled AS isDisabled,
|
|
OBJECTPROPERTY(tr.object_id, 'ExecIsQuotedIdentOn') AS quotedIdentOn,
|
|
OBJECTPROPERTY(tr.object_id, 'ExecIsAnsiNullsOn') AS ansiNullsOn,
|
|
OBJECT_DEFINITION(tr.object_id) AS definition
|
|
FROM sys.triggers tr
|
|
INNER JOIN sys.tables t ON t.object_id = tr.parent_id
|
|
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
WHERE tr.parent_class_desc = N'OBJECT_OR_COLUMN'
|
|
AND s.name + N'.' + t.name IN (${tableList})
|
|
ORDER BY s.name, t.name, tr.name
|
|
`);
|
|
return result.recordset.map((tr) => ({
|
|
schemaName: tr.schemaName,
|
|
tableName: tr.tableName,
|
|
triggerName: tr.triggerName,
|
|
isDisabled: !!tr.isDisabled,
|
|
quotedIdentOn: !!tr.quotedIdentOn,
|
|
ansiNullsOn: !!tr.ansiNullsOn,
|
|
definition: tr.definition,
|
|
}));
|
|
}
|
|
|
|
async function exportTableData(sourcePool, schema, table, columns, dataDir) {
|
|
const insertCols = columns.filter((c) => !c.isComputed);
|
|
const colList = insertCols.map((c) => qIdent(c.columnName)).join(', ');
|
|
const result = await sourcePool
|
|
.request()
|
|
.query(`SELECT ${colList} FROM ${qName(schema, table)}`);
|
|
|
|
const rows = result.recordset.map((row) => {
|
|
const out = {};
|
|
for (const col of insertCols) {
|
|
out[col.columnName] = serializeCell(row[col.columnName]);
|
|
}
|
|
return out;
|
|
});
|
|
|
|
const filePath = path.join(dataDir, dataFileName(schema, table));
|
|
writeJson(filePath, {
|
|
schema,
|
|
table,
|
|
columns: insertCols.map((c) => c.columnName),
|
|
rowCount: rows.length,
|
|
rows,
|
|
});
|
|
return rows.length;
|
|
}
|
|
|
|
async function extractSnapshot(source, snapshotDir) {
|
|
const definitionsPath = path.join(snapshotDir, 'definitions.json');
|
|
const manifestPath = path.join(snapshotDir, 'manifest.json');
|
|
const dataDir = path.join(snapshotDir, 'data');
|
|
|
|
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
|
|
log(`Extracting from ${source} → ${snapshotDir}`);
|
|
|
|
await withPool(source, async (sourcePool) => {
|
|
await verifySourceTables(sourcePool);
|
|
|
|
const tables = [];
|
|
for (const [schema, table] of TABLES) {
|
|
const columns = await loadTableColumns(sourcePool, schema, table);
|
|
const ddl = buildCreateTableDdl(schema, table, columns);
|
|
const identity = columns.find((c) => c.isIdentity);
|
|
tables.push({
|
|
schema,
|
|
table,
|
|
ddl,
|
|
columns,
|
|
identity: identity
|
|
? {
|
|
column: identity.columnName,
|
|
seed: identity.identitySeed ?? 1,
|
|
increment: identity.identityIncrement ?? 1,
|
|
lastValue: identity.identityLastValue,
|
|
}
|
|
: null,
|
|
});
|
|
log(` table ${schema}.${table} (${columns.length} cols)`);
|
|
}
|
|
|
|
const triggers = await loadTriggers(sourcePool);
|
|
const typeRefs = parseTypeRefsFromDefinitions(triggers.map((t) => t.definition));
|
|
const types = await loadTableTypeDdl(sourcePool, typeRefs);
|
|
const indexes = await loadIndexes(sourcePool);
|
|
|
|
const schemas = [
|
|
...new Set([
|
|
...TABLES.map(([s]) => s),
|
|
...typeRefs.map(([s]) => s),
|
|
]),
|
|
].sort();
|
|
|
|
log(`Exporting data for ${TABLES.length} tables…`);
|
|
let totalRows = 0;
|
|
for (const t of tables) {
|
|
const n = await exportTableData(sourcePool, t.schema, t.table, t.columns, dataDir);
|
|
totalRows += n;
|
|
log(` data ${t.schema}.${t.table}: ${n} rows`);
|
|
}
|
|
|
|
const definitions = {
|
|
version: 1,
|
|
extractedAt: new Date().toISOString(),
|
|
source,
|
|
schemas,
|
|
types,
|
|
tables: tables.map(({ schema, table, ddl, columns, identity }) => ({
|
|
schema,
|
|
table,
|
|
ddl,
|
|
identity,
|
|
columns,
|
|
})),
|
|
indexes,
|
|
triggers,
|
|
};
|
|
writeJson(definitionsPath, definitions);
|
|
writeJson(manifestPath, {
|
|
version: 1,
|
|
extractedAt: definitions.extractedAt,
|
|
source,
|
|
tableCount: tables.length,
|
|
indexCount: indexes.length,
|
|
triggerCount: triggers.length,
|
|
typeCount: types.length,
|
|
rowCount: totalRows,
|
|
tables: TABLES.map(([schema, table]) => ({ schema, table })),
|
|
});
|
|
|
|
log('');
|
|
log('Snapshot written.');
|
|
log(` tables: ${tables.length}`);
|
|
log(` types: ${types.length}`);
|
|
log(` indexes: ${indexes.length}`);
|
|
log(` triggers: ${triggers.length}`);
|
|
log(` rows: ${totalRows}`);
|
|
});
|
|
}
|
|
|
|
async function dropAndCreateDatabase(master, targetName) {
|
|
log(`Dropping ${targetName} if it exists…`);
|
|
await master.request().query(`
|
|
IF DB_ID(N'${targetName.replace(/'/g, "''")}') IS NOT NULL
|
|
BEGIN
|
|
ALTER DATABASE ${qIdent(targetName)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
|
DROP DATABASE ${qIdent(targetName)};
|
|
END
|
|
`);
|
|
log(`Creating ${targetName}…`);
|
|
await master.request().query(`CREATE DATABASE ${qIdent(targetName)}`);
|
|
}
|
|
|
|
async function ensureSchemas(targetPool, schemas) {
|
|
for (const schema of schemas) {
|
|
if (!schema || schema.toLowerCase() === 'dbo') continue;
|
|
await targetPool.request().query(`
|
|
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'${schema.replace(/'/g, "''")}')
|
|
EXEC(N'CREATE SCHEMA ${qIdent(schema)}');
|
|
`);
|
|
}
|
|
}
|
|
|
|
async function execDdl(targetPool, ddl, label) {
|
|
try {
|
|
await targetPool.request().batch(ddl);
|
|
return true;
|
|
} catch (err) {
|
|
console.error(` FAIL ${label}: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadTableData(targetPool, tableDef, dataDir) {
|
|
const filePath = path.join(dataDir, dataFileName(tableDef.schema, tableDef.table));
|
|
if (!fs.existsSync(filePath)) {
|
|
log(` skip data ${tableDef.schema}.${tableDef.table}: no file`);
|
|
return 0;
|
|
}
|
|
|
|
const payload = readJson(filePath);
|
|
if (!payload.rows?.length) {
|
|
log(` data ${tableDef.schema}.${tableDef.table}: 0 rows`);
|
|
return 0;
|
|
}
|
|
|
|
const colByName = new Map(tableDef.columns.map((c) => [c.columnName, c]));
|
|
const insertCols = payload.columns
|
|
.map((name) => colByName.get(name))
|
|
.filter((c) => c && !c.isComputed);
|
|
|
|
const table = new sql.Table(qName(tableDef.schema, tableDef.table));
|
|
table.create = false;
|
|
for (const col of insertCols) {
|
|
table.columns.add(col.columnName, mssqlTypeForColumn(col), {
|
|
nullable: col.isNullable,
|
|
primary: false,
|
|
});
|
|
}
|
|
|
|
for (const row of payload.rows) {
|
|
table.rows.add(...insertCols.map((c) => deserializeCell(row[c.columnName])));
|
|
}
|
|
|
|
const hasIdentity = insertCols.some((c) => c.isIdentity);
|
|
const started = Date.now();
|
|
await targetPool.request().bulk(table, { keepIdentity: hasIdentity, keepNulls: true });
|
|
log(` data ${tableDef.schema}.${tableDef.table}: ${payload.rows.length} rows (${Date.now() - started}ms)`);
|
|
return payload.rows.length;
|
|
}
|
|
|
|
async function reseedIdentities(targetPool, tables) {
|
|
for (const t of tables) {
|
|
if (t.identity == null || t.identity.lastValue == null) continue;
|
|
const next = Number(t.identity.lastValue);
|
|
if (!Number.isFinite(next)) continue;
|
|
await targetPool
|
|
.request()
|
|
.query(`DBCC CHECKIDENT ('${t.schema}.${t.table}', RESEED, ${next})`);
|
|
}
|
|
}
|
|
|
|
async function createTriggers(targetPool, triggers) {
|
|
let ok = 0;
|
|
let failed = 0;
|
|
for (const tr of triggers) {
|
|
if (!tr.definition) {
|
|
failed++;
|
|
console.error(` FAIL trigger ${tr.schemaName}.${tr.tableName}.${tr.triggerName}: no definition`);
|
|
continue;
|
|
}
|
|
try {
|
|
await targetPool.request().batch(`
|
|
SET ANSI_NULLS ${tr.ansiNullsOn ? 'ON' : 'OFF'};
|
|
SET QUOTED_IDENTIFIER ${tr.quotedIdentOn ? 'ON' : 'OFF'};
|
|
`);
|
|
await targetPool.request().batch(tr.definition);
|
|
if (tr.isDisabled) {
|
|
await targetPool
|
|
.request()
|
|
.query(
|
|
`DISABLE TRIGGER ${qIdent(tr.triggerName)} ON ${qName(tr.schemaName, tr.tableName)}`
|
|
);
|
|
}
|
|
ok++;
|
|
} catch (err) {
|
|
failed++;
|
|
console.error(
|
|
` FAIL trigger ${tr.schemaName}.${tr.tableName}.${tr.triggerName}: ${err.message}`
|
|
);
|
|
}
|
|
}
|
|
log(`Triggers: ${ok} created, ${failed} failed (of ${triggers.length})`);
|
|
return { ok, failed };
|
|
}
|
|
|
|
async function summarize(targetPool) {
|
|
const tables = await targetPool.request().query(`
|
|
SELECT COUNT(*) AS n FROM sys.tables
|
|
`);
|
|
const indexes = await targetPool.request().query(`
|
|
SELECT COUNT(*) AS n FROM sys.indexes WHERE index_id > 0 AND object_id IN (SELECT object_id FROM sys.tables)
|
|
`);
|
|
const triggers = await targetPool.request().query(`
|
|
SELECT COUNT(*) AS n FROM sys.triggers WHERE parent_class_desc = N'OBJECT_OR_COLUMN'
|
|
`);
|
|
const types = await targetPool.request().query(`
|
|
SELECT COUNT(*) AS n FROM sys.table_types
|
|
`);
|
|
return {
|
|
tables: tables.recordset[0].n,
|
|
indexes: indexes.recordset[0].n,
|
|
triggers: triggers.recordset[0].n,
|
|
types: types.recordset[0].n,
|
|
};
|
|
}
|
|
|
|
async function createFromSnapshot(target, snapshotDir) {
|
|
const definitionsPath = path.join(snapshotDir, 'definitions.json');
|
|
const dataDir = path.join(snapshotDir, 'data');
|
|
if (!fs.existsSync(definitionsPath)) {
|
|
throw new Error(
|
|
`Snapshot not found at ${definitionsPath}. Run: node scripts/create-minimal-db.mjs extract`
|
|
);
|
|
}
|
|
|
|
const definitions = readJson(definitionsPath);
|
|
log(`Target: ${target}`);
|
|
log(`Snapshot: ${snapshotDir} (from ${definitions.source} @ ${definitions.extractedAt})`);
|
|
|
|
await withPool('master', async (master) => {
|
|
await dropAndCreateDatabase(master, target);
|
|
});
|
|
|
|
await withPool(target, async (targetPool) => {
|
|
log('Creating schemas…');
|
|
await ensureSchemas(targetPool, definitions.schemas);
|
|
|
|
log(`Creating ${definitions.types.length} table types…`);
|
|
let typeOk = 0;
|
|
for (const t of definitions.types) {
|
|
if (await execDdl(targetPool, t.ddl, `type ${t.schemaName}.${t.typeName}`)) {
|
|
typeOk++;
|
|
log(` type ${t.schemaName}.${t.typeName}`);
|
|
}
|
|
}
|
|
log(`Table types: ${typeOk}/${definitions.types.length}`);
|
|
|
|
log(`Creating ${definitions.tables.length} tables…`);
|
|
let tableOk = 0;
|
|
for (const t of definitions.tables) {
|
|
if (await execDdl(targetPool, t.ddl, `table ${t.schema}.${t.table}`)) {
|
|
tableOk++;
|
|
log(` table ${t.schema}.${t.table}`);
|
|
}
|
|
}
|
|
log(`Tables: ${tableOk}/${definitions.tables.length}`);
|
|
|
|
log('Loading data…');
|
|
let totalRows = 0;
|
|
for (const t of definitions.tables) {
|
|
totalRows += await loadTableData(targetPool, t, dataDir);
|
|
}
|
|
log(`Loaded ${totalRows} rows`);
|
|
|
|
await reseedIdentities(targetPool, definitions.tables);
|
|
|
|
log(`Creating ${definitions.indexes.length} indexes…`);
|
|
let indexOk = 0;
|
|
let indexFail = 0;
|
|
for (const idx of definitions.indexes) {
|
|
if (await execDdl(targetPool, idx.ddl, `index ${idx.schemaName}.${idx.tableName}.${idx.indexName}`)) {
|
|
indexOk++;
|
|
} else {
|
|
indexFail++;
|
|
}
|
|
}
|
|
log(`Indexes: ${indexOk} created, ${indexFail} failed (of ${definitions.indexes.length})`);
|
|
|
|
log(`Creating ${definitions.triggers.length} triggers…`);
|
|
const triggerResult = await createTriggers(targetPool, definitions.triggers);
|
|
|
|
const summary = await summarize(targetPool);
|
|
log('');
|
|
log('Done.');
|
|
log(` tables: ${summary.tables}`);
|
|
log(` types: ${summary.types}`);
|
|
log(` indexes: ${summary.indexes} (${indexFail} failed)`);
|
|
log(` triggers: ${summary.triggers} (${triggerResult.failed} failed)`);
|
|
log(` rows: ${totalRows}`);
|
|
});
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: node scripts/create-minimal-db.mjs [command] [options]
|
|
|
|
Commands:
|
|
create Recreate target DB from saved snapshot (default; no source DB)
|
|
extract Pull definitions + data from source DB into scripts/minimal-db/
|
|
|
|
Options:
|
|
--source <db> Source database for extract (default: MSSQL_DATABASE)
|
|
--target <db> Target database for create (default: eazybusiness_minimal)
|
|
--snapshot <dir> Snapshot directory (default: scripts/minimal-db)
|
|
--list Print the table list and exit
|
|
--help Show this help
|
|
`);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
if (args.list) {
|
|
for (const [schema, table] of TABLES) {
|
|
console.log(`${schema}.${table}`);
|
|
}
|
|
console.log(`\n${TABLES.length} tables`);
|
|
return;
|
|
}
|
|
|
|
if (args.command === 'extract') {
|
|
await extractSnapshot(args.source, args.snapshotDir);
|
|
return;
|
|
}
|
|
|
|
await createFromSnapshot(args.target, args.snapshotDir);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err.message || err);
|
|
process.exit(1);
|
|
});
|