Data migration
One-shot backfill scripts per domain. SQL Server → Postgres. Idempotent (run anytime; upsert by stable id mapping). Mapping tables for entities that change shape.
Approach
- Pull from SQL Server with a TS script using
mssql(Tedious) driver. - Transform in TS — collapse enums, generate UUID v7 from legacy composite key (deterministic via UUID v5 namespace) so re-runs land same UUIDs.
- Upsert into Postgres via Drizzle's
onConflictDoUpdate.
ID mapping
v1 uses BaseObj composite keys (or int identity). v2 uses UUID v7. Deterministic mapping:
// scripts/migrate/id.ts
import { v5 as uuidv5 } from "uuid";
const NS = "spacehub-v1-mapping-namespace-uuid"; // any fixed UUID
export function mapId(table: string, legacyId: string | number): string {
return uuidv5(`${table}:${legacyId}`, NS);
}
// Same legacy id always maps to same UUID. Re-runs idempotent.
Per-domain scripts
scripts/migrate/
├── 01-users.ts # User + UserMore + Customer → users + profiles + property_owners
├── 02-properties.ts # Property + Floor + Room
├── 03-contracts.ts # Contract + ContractItem + ContractRoom + ContractPaymentChart
├── 04-invoices.ts # Bill + InvoiceBill + Sale + Discount → invoices + invoice_items
├── 05-payments.ts # Payment + StatementLog → payments + payment_events + bank_*
├── 06-ebarimt.ts # SaleEbarimt + EbarimtRecord → ebarimt_* columns on invoices
├── 07-files.ts # SPImage blobs → R2 + files table
├── _lib.ts # shared: id mapping, status enum collapsers, money parsers
└── run.ts # orchestrator: runs in order with checkpointing
Example script — properties
// scripts/migrate/02-properties.ts
import sql from "mssql";
import { db, properties, floors, rooms } from "@spacehub/db";
import { mapId } from "./_lib";
async function migrateProperties() {
const mssqlPool = await sql.connect(process.env.MSSQL_URL!);
const result = await mssqlPool.request().query(`
SELECT p.Oid as legacyId, p.Name, p.Address, p.City, p.PropertyOwnerOid as poId,
p.TotalArea, p.FloorCount, p.BuiltYear
FROM Property p
WHERE p.IsDeleted = 0
`);
for (const row of result.recordset) {
await db.insert(properties).values({
id: mapId("property", row.legacyId),
ownerId: mapId("propertyOwner", row.poId),
name: row.Name,
address: row.Address,
city: row.City ?? "Улаанбаатар",
totalArea: row.TotalArea ?? null,
floorCount: row.FloorCount ?? null,
builtYear: row.BuiltYear ?? null,
status: "active",
}).onConflictDoUpdate({
target: properties.id,
set: {
name: sql`excluded.name`,
address: sql`excluded.address`,
updatedAt: new Date(),
},
});
}
console.log(`Migrated ${result.recordset.length} properties`);
await mssqlPool.close();
}
Enum collapsing
v1 has many 2-enum drift cases (KNOWN_ISSUES #3). Migration is when we resolve them — pick canonical value per row.
// _lib.ts
export function collapseInvoiceStatus(old: number, oldType: number): InvoiceStatus {
// If new enum (oldType) is set, prefer it; fall back to legacy
if (oldType != null) return mapNew[oldType];
return mapOld[old];
}
Money parsing
v1 stores decimal as decimal(18,2). v2 uses BigInt minor units. Multiply by 100:
export function toMinor(decimal: string | number): bigint {
const s = typeof decimal === "number" ? decimal.toFixed(2) : decimal;
const [whole, frac = ""] = s.split(".");
const paddedFrac = (frac + "00").slice(0, 2);
return BigInt(whole.replace("-", "") + paddedFrac) * (s.startsWith("-") ? -1n : 1n);
}
Orchestrator + checkpointing
// scripts/migrate/run.ts
const stages = [
{ name: "users", fn: migrateUsers },
{ name: "properties", fn: migrateProperties },
{ name: "contracts", fn: migrateContracts },
{ name: "invoices", fn: migrateInvoices },
{ name: "payments", fn: migratePayments },
{ name: "ebarimt", fn: migrateEbarimt },
{ name: "files", fn: migrateFiles },
];
for (const s of stages) {
console.log(`▶ ${s.name}...`);
const started = Date.now();
await s.fn();
console.log(`✓ ${s.name} done in ${(Date.now() - started)/1000}s`);
await db.insert(migrationCheckpoints).values({ stage: s.name, completedAt: new Date() })
.onConflictDoUpdate({ target: migrationCheckpoints.stage, set: { completedAt: new Date() } });
}
Validation
Nightly parity check while in dual-write:
// scripts/parity/check.ts
const v1Count = await mssql.request().query("SELECT COUNT(*) c FROM Contract WHERE IsDeleted = 0");
const v2Count = await db.select({ c: count() }).from(contracts);
if (Math.abs(v1Count.recordset[0].c - v2Count[0].c) > 5) {
await alertSlack(`Contract count drift: v1=${v1Count.recordset[0].c} v2=${v2Count[0].c}`);
}
// also sample 10 random contracts, compare field-by-field
File migration (images)
v1's SPImage stored blobs in SQL Server. Migration:
- Stream
SPImageblobs in batches of 100. - Upload each to R2 at
owners/{ownerId}/legacy/{spImageId}.{ext}. - Insert
filesrow. - Update referencing entity (Property, Profile) with new
url. - Run inside BullMQ
migrate-imagequeue, concurrency 4, exp backoff on R2 errors.
Run modes
- Initial: full backfill from prod SQL Server snapshot (offline copy — don't hit prod directly).
- Incremental: re-run nightly during dual-write to catch v1-only writes.
- Final: last run before cutover to v2 writes-only.
Build steps
scripts/migrate/dir +_lib.tswith mapId + enum collapsers + money parser.- One script per domain, ordered by dependency.
- Orchestrator with checkpointing in
migration_checkpointstable. - Parity check script + scheduled GitHub Action.
- File migration via BullMQ worker (R2 uploads).
- Dry-run mode (
--dry-runflag) for stage testing.
Open questions
- Production cutover window: weekend? Off-hours? Confirm with biggest owners.
- Audit trail preservation: v1 has
CreatedBy/ModifiedByon BaseObj. Map to v2 users; for legacy entries by deleted users, attribute to a system user. - Soft-deleted rows: v1 marks
IsDeleted = 1. Skip in backfill? Recommend skip (truly delete v2-side). - Reports & XtraReport blobs: don't migrate — rebuild in React-PDF.