#!/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/..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 Source database for extract (default: MSSQL_DATABASE) --target Target database for create (default: eazybusiness_minimal) --snapshot 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); });