#2774·emdash

`export-seed` writes log lines to stdout, migrates the database it exports, and emits `$ref` values that `seed` cannot resolve

Author: yet2comeCreated Aug 28, 2026Updated Sep 16, 2026

Summary

Three independent problems in emdash export-seed, found while validating a documented backup procedure by round-tripping export-seedseed in an isolated environment. Each is reproducible on its own; together they make the round trip silently lossy.

  1. Log output goes to stdout, so redirecting the command to a file produces invalid JSON. Exit code is 0 and stderr is empty.
  2. The command runs migrations on the database it is exporting, so an "export" writes to its source.
  3. reference field values are exported as $ref:<source-row-id> while every other entity is exported by seed id, so seed cannot resolve them and the reference is silently dropped.

Affected version: [email protected]

Related: #2055 covers export-seed being local-only and wrangler d1 export --remote being blocked by FTS5. This report is about the local export-seedseed path itself, which is the fallback that issue leaves standing.

1. Log output on stdout corrupts a redirected export

src/cli/commands/export-seed.ts (dist/cli/index.mjs:1223) prints the resolved path with consola.info, which writes to stdout, and the export query triggers a kysely deprecation warning that also reaches stdout.

bash
$ npx emdash export-seed -d ./data.db --with-content > backup.json
$ head -3 backup.json
ℹ Database: /path/to/data.db
orderBy(array) is deprecated, use multiple orderBy calls instead.
{
$ echo $?
0

stderr is empty — 2>&1 >/dev/null produces no output. The JSON payload itself is written with console.log(output), so the log lines and the data share one stream.

The deprecation line comes from kysely/dist/parser/order-by-parser.js and is triggered by orderBy being called with an array inside the export path, so it appears on every run rather than only in unusual configurations.

The failure surfaces only at restore time:

bash
$ npx emdash seed backup.json -d ./restored.db
 ERROR  Failed to parse seed file: Unexpected token 'ℹ', "ℹ Database"... is not valid JSON

Expected: informational output goes to stderr (or is suppressed when stdout is not a TTY), so that > file yields parseable JSON. As a smaller point, the internal orderBy(array) call could be updated so the deprecation warning stops firing.

2. export-seed runs migrations on the database it exports

javascript
// dist/cli/index.mjs:1221-1231
async run({ args }) {
    const dbPath = resolve(resolve(args.cwd), args.database);
    consola.info(`Database: ${dbPath}`);
    const db = createDatabase({ url: `file:${dbPath}` });
    try {
        await runMigrations(db);      // <-- writes to the database being exported
    } catch (error) { … }

Demonstrated against an empty file:

bash
$ : > empty.sqlite
$ wc -c < empty.sqlite
0
$ npx emdash export-seed -d empty.sqlite --with-content > /dev/null
$ wc -c < empty.sqlite
917504
$ sqlite3 empty.sqlite "SELECT COUNT(*) FROM _emdash_migrations;"
52

A read-only export turned a zero-byte file into a fully migrated 47-table database.

This matters most in the case where an export is most likely to be taken: immediately before an upgrade. A user exporting to capture the pre-upgrade state instead migrates the database first and exports the post-migration state. There is no --no-migrate escape hatch.

Expected: export-seed reads. If the schema is older than the code expects, failing with an explanatory message is preferable to migrating in place; at minimum the migration should be opt-in.

3. $ref values are exported as source row ids, so seed cannot resolve them

Every entity in the exported file is keyed by a derived seed id — groups:soumu, events:setsumeikai-2026-09. Reference field values, however, keep the source database's row id:

json
{
  "content": {
    "groups": [
      { "id": "groups:soumu",   "slug": "soumu",   "data": {} },
      { "id": "groups:sasaeai", "slug": "sasaeai", "data": {} }
    ],
    "events": [
      {
        "id": "events:setsumeikai-2026-09",
        "slug": "setsumeikai-2026-09",
        "data": {
          "organizer": "$ref:01M0HFJZ9JJCJAQGZTT0V90VAH"
        }
      }
    ]
  }
}

01M0HFJZ9JJCJAQGZTT0V90VAH is the source row's ULID. seed assigns fresh ULIDs on insert, so nothing in the target database carries that id, and the reference resolves to nothing:

bash
# source
setsumeikai-2026-09 | 01M0HFJZ9JJCJAQGZTT0V90VAH | soumu

# after export-seed → seed
setsumeikai-2026-09 | $ref:01M0HFJZ9JJCJAQGZTT0V90VAH | (unresolved)

The literal string $ref:01M0… is stored in the column, so the field is neither resolved nor cleared — a consumer reading the field gets a value that looks like an id but matches no row.

A hand-written seed expresses the same reference against the seed id, and that form does resolve:

json
{ "id": "event-1", "data": { "organizer": "$ref:group-soumu" } }

Expected: export-seed emits $ref:<seed-id> ($ref:groups:soumu), matching the ids it assigns to the referenced entries in the same file. Failing that, seed should reject an unresolvable $ref rather than storing it verbatim.

Reproduction

bash
# 1. any site with a reference field and some content
npx emdash export-seed -d ./data.db --with-content > backup.json
npx emdash seed backup.json -d ./restored.db     # fails: invalid JSON  (problem 1)

# 2. work around problem 1 and retry
npx emdash export-seed -d ./data.db --with-content | sed -n '/^{/,$p' > backup.json
npx emdash seed backup.json -d ./restored.db     # succeeds
# the reference column now holds "$ref:<old-ulid>"  (problem 3)

Impact

The three combine into a backup path that appears to work. export-seed --with-content reads as the natural way to capture a site's content, the command exits 0, and the resulting file looks like a backup. The corruption is caught at restore time; the broken references are not caught at all, because the restore reports success and the missing data only shows up wherever the reference was rendered.

Separately from these three, note that the seed format does not carry entry ids or created_at / updated_at / published_at, so a round trip renumbers them to the restore time. That is reasonable for a seed, but it means export-seed should probably not be described as a backup mechanism — which is how we had been using it until this round trip was actually tested.