bcdb restore: Write a Cloud Export Into a Container

bcdb could already open a .bak or a .bacpac and read anything in it, no SQL Server involved. That was genuinely useful for AL Runner , which is why I built it in the first place, but it didn’t do much for a developer’s normal day. For that, you want the data sitting in a database you can query, click through, and run BC against.

Since v0.2.0, bcdb restore is what changes that: it writes a source’s rows straight into a container that’s already running, and it doesn’t ask the container’s schema to match the source first. You set the container’s extensions up however you already have them, run the restore, and it loads whatever matches, table by table and column by column, and quietly skips whatever doesn’t: a missing table gets reported and left alone, a missing column just doesn’t come across, and the rest of that table loads anyway.

None of the usual schema-alignment work a restore normally demands. And it’s fast: I restored my own production environment’s export into a local container in about 10 seconds, fast enough that noticing a table’s missing and rerunning after installing the extension costs nothing.

bcdb restore MyEnvironment.bacpac --replace \
    --to "Server=localhost;Database=CRONUS;User ID=sa;Password=…;TrustServerCertificate=True"

--replace is there because a container almost always already has data in it (a demo company, at least). Leave it off in an interactive terminal and bcdb restore just asks before it overwrites anything; leave it off in a script and it refuses. That command took several iterations to get right.

First pass: match by name, refuse anything uncertain

The first version I wrote treated the target as fixed and untouchable. Installing extensions is what creates a container’s tables in the first place, so bcdb restore doesn’t build a database. It matches the source’s tables and columns to the target’s by name and carries rows across. Anything it wasn’t sure about, it refused: a table the target lacked, a column that would arrive narrower or differently typed than the target declared, a non-empty target without an explicit --replace. I checked the type conversions against a fixture covering every supported type at its extremes, and shipped it against that.

Then I pointed it at a real, running BC 28.4 container, and the design changed.

What a real container actually tolerates, and what it doesn’t

BC tolerates a database holding more than its extensions declare: a table nothing owns is ignored, and a table that already exists when its extension gets installed is adopted, not rejected. That’s the same path an uninstall/reinstall takes to keep a table’s data across the gap. That made refusing a missing table look like the wrong default, so I tried building it instead: derive column nullability from a bit in syscolpars.status, generate the table from the source’s own schema, and write into it.

The narrow version of that works. I dropped the demo Customer table outright (the state an uninstalled extension leaves), had bcdb restore rebuild it from a backup of the same database, and got back all 107 columns identical to what BC itself had built: same types, same widths, same nullability, same clustered primary key, same collation. BC’s own API served all five customers out of it without even a service-tier restart.

What a generic CREATE TABLE can’t reproduce is what BC’s own schema sync builds alongside a table — in particular the SumIndexField (VSIFT) indexed views a List page’s totals depend on. My API-only test never touched one, so it never caught the gap; the first List page that needs one throws Invalid object name '...$VSIFT$Key2'. Reproducing BC’s own schema generation isn’t really this tool’s job, so table creation only happens with --create, which is opt-in and off by default, useful mainly against a scratch database in a test. Against a real container, bcdb restore only ever writes into tables that are already there, created the way installing the extension actually creates them.

Locking myself out of my own admin login

Next test was bigger: the same BC 28.4 container, a real ~52 MB production export this time. I ran bcdb restore --replace and it did exactly what I’d told it to do: overwrite every ordinary table on the target with the source’s own. User, Access Control, and User Personalization are ordinary tables as far as a restore is concerned, so it overwrote those too, with the source tenant’s users. That locked the container’s own admin login out of the web client, in three separate failure modes, each one traced back to a specific table.

$ndo$… platform tables were already excluded by default: they’re the service tier’s own record of which apps are installed and which tenant this is, and overwriting them breaks the container. The login and session tables needed the same treatment, so User, Access Control, User Personalization, User Property, Company, the Tenant Profile family, and the NAV App family are now left alone by default too, unless --include-identity says otherwise. --exclude-table covers anything else by name.

One more thing came out of that same run: on a fresh, never-restored container, those tables start empty and BC repopulates them automatically on first real sign-in (confirmed by driving an actual login with Playwright and watching the row counts change). It takes 30–45 seconds and doesn’t run again once rows already exist for a login, which is exactly why a restored container with stale rows in those tables fails loudly, not silently. With the identity tables excluded, the same production export came up end to end: full role center, real navigation including a third-party extension entry, real customer data, no manual steps after the command finished.

The two-argument command that actually needed four flags

At that point the “restore that actually works” recipe was real, but getting it meant knowing to pass --no-create, --allow-column-loss, --replace, and --rename-company by hand: a checklist nobody would discover on their own, and one reader pointed that out directly . So the defaults changed one more time: --no-create and --allow-column-loss are now what happens without saying anything, --replace’s effect is the default too but confirmed interactively, not assumed (refused outright if it’s not attached to a terminal), and --rename-company triggers on its own whenever exactly one company has data on each side, only asking when it’s genuinely ambiguous.

Which is how a SaaS tenant named “Fabrikam Inc.” ends up in a container’s “CRONUS International Ltd_” without anyone having to state that mapping explicitly: bcdb restore sees the one company with data on each side, maps it, and logs the mapping so it’s never a surprise. With two companies holding data on either side, it names exactly what it found and refuses to guess.

Where it ended up

Rowversion columns are never written, since SQL Server stamps its own. Identity values are preserved. Amounts cross as SqlDecimal, not System.Decimal, since System.Decimal silently rounds values BC actually stores. --dry-run prints the whole plan (matched tables, mapped columns, every skip and why) and writes nothing, which is worth running before the real thing. Rows are streamed and bulk-copied, so a table bigger than memory costs no more than a small one.

bcdb restore ships as part of v0.2.0, the latest release , with the same binaries and the same dotnet tool install -g bcdb. Reading also picked up a real fix since the launch post : BC’s own demo backup started exceeding a bound the reader trusted too literally, starting at version 28.2, and bcdb refused to open it as a result. That’s fixed as of 0.1.2. Every flag mentioned above, plus the ones this post skipped, is in the README .

This is what makes AL Runner against real customer data practical, too: bcdb restore gets the data into a container whose extensions I already control, mismatches and all. A plain sqlpackage restore needs the exact extension versions to line up first.