Summary
Three independent problems in emdash export-seed, found while validating a documented backup procedure by round-tripping export-seed → seed in an isolated environment. Each is reproducible on its own; together they make the round trip silently lossy.
- Log output goes to stdout, so redirecting the command to a file produces invalid JSON. Exit code is
0 and stderr is empty.
- The command runs migrations on the database it is exporting, so an "export" writes to its source.
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: emdash@0.30.0
Related: #2055 covers export-seed being local-only and wrangler d1 export --remote being blocked by FTS5. This report is about the local export-seed → seed 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.
$ 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:
$ 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
// 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:
$ : > 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:
{
"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:
# 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:
{ "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
# 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.
Summary
Three independent problems in
emdash export-seed, found while validating a documented backup procedure by round-trippingexport-seed→seedin an isolated environment. Each is reproducible on its own; together they make the round trip silently lossy.0and stderr is empty.referencefield values are exported as$ref:<source-row-id>while every other entity is exported by seed id, soseedcannot resolve them and the reference is silently dropped.Affected version:
emdash@0.30.0Related: #2055 covers
export-seedbeing local-only andwrangler d1 export --remotebeing blocked by FTS5. This report is about the localexport-seed→seedpath 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 withconsola.info, which writes to stdout, and the export query triggers a kysely deprecation warning that also reaches stdout.stderr is empty —
2>&1 >/dev/nullproduces no output. The JSON payload itself is written withconsole.log(output), so the log lines and the data share one stream.The deprecation line comes from
kysely/dist/parser/order-by-parser.jsand is triggered byorderBybeing 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:
Expected: informational output goes to stderr (or is suppressed when stdout is not a TTY), so that
> fileyields parseable JSON. As a smaller point, the internalorderBy(array)call could be updated so the deprecation warning stops firing.2.
export-seedruns migrations on the database it exportsDemonstrated against an empty file:
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-migrateescape hatch.Expected:
export-seedreads. 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.
$refvalues are exported as source row ids, soseedcannot resolve themEvery 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:{ "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" } } ] } }01M0HFJZ9JJCJAQGZTT0V90VAHis the source row's ULID.seedassigns fresh ULIDs on insert, so nothing in the target database carries that id, and the reference resolves to nothing: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:
{ "id": "event-1", "data": { "organizer": "$ref:group-soumu" } }Expected:
export-seedemits$ref:<seed-id>($ref:groups:soumu), matching the ids it assigns to the referenced entries in the same file. Failing that,seedshould reject an unresolvable$refrather than storing it verbatim.Reproduction
Impact
The three combine into a backup path that appears to work.
export-seed --with-contentreads as the natural way to capture a site's content, the command exits0, 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 meansexport-seedshould probably not be described as a backup mechanism — which is how we had been using it until this round trip was actually tested.