A minor release that changes what a soft delete means. Deleting a document now
releases its path back to the collection's live namespace while retaining the
path value for history, and stops deleting uploaded files from object storage.
The path change requires a one-time schema upgrade on existing installations. The
storage change requires no migration, but it does mean deleted media now
accumulates — see Migrations for both.
Highlights
-
monorepo — a soft-deleted document no longer holds its path hostage. Until
now,byline_document_pathsenforced uniqueness over
(collection_id, locale, path)regardless of whether the owning document was
deleted, so a deleted document kept its path reserved forever while
findByPath— which reads the deleted-filtering current-documents view —
reported nothing at that address. Re-creating a document at the same path
failed withERR_PATH_CONFLICTand no supported way to recover it. The table
now carries a nullabledeleted_atand a stored generated
alivecolumn (truewhendeleted_at IS NULL, otherwiseNULL), and the
unique key spans(collection_id, locale, path, alive). Because both
PostgreSQL and MySQL permit manyNULLs in a unique index, two live
documents still cannot share a path, while any number of deleted documents may
retain one. Path lookup filters toalive = true, so a deleted document at a
higher-priority locale is skipped in favour of a live fallback. Callers never
writealivethemselves. -
@byline/db-postgres,@byline/db-mysql— a new storage primitive,
restoreSoftDeletedDocument({ document_id }), reactivates every path row and
every version of a fully soft-deleted document in one transaction under the
same collection-then-document locks that soft delete takes. The live unique
key revalidates the retained path on the way back in: if another document has
claimed it in the meantime, the whole restoration rolls back rather than
half-reviving the document. It returns0— a no-op, not an error — for a
document that is missing, versionless, already live, or in a legacy
partially-live state, so it never implicitly "repairs" inconsistent data. This
is a storage primitive for trusted tooling: it does not rebuild tree
placement or search and cache projections, and no editorial trash-and-restore
surface is wired to it yet. -
@byline/core— path-conflict errors now say which operation failed and
that a live document owns the requested path, rather than reporting a bare
"path already in use". Because the unique key is now live-only, a violation
can only mean a live occupant, so the wording is derived rather than guessed —
no extra lookup is issued and the occupant's document id is never exposed. The
ERR_PATH_CONFLICTcode and the publicdetailsshape (path,locale,
constraint) are unchanged. -
monorepo — duplicating a document onto a path that only deleted rows
retain now keeps the requested path instead of appending a short-UUID suffix.
A live occupant still triggers the single suffixed retry, so the two cases are
finally distinguishable. -
monorepo — writing a new version of an existing document now takes a
row-scoped lock on that document before checking its liveness. Concurrent saves
to the same document serialise with each other and with soft delete and
un-delete; writes to unrelated documents remain fully concurrent. A fully
deleted document can no longer gain a live version by any route other than
whole-document un-delete. No action is required — this happens automatically —
but it is a behavioural change worth knowing about if you drive concurrent
writes at the same document.
Bug Fixes
@byline/core— a path conflict raised while updating a document reported
the collection's configured default content locale instead of the document's
ownsource_locale, which is the locale the conflicting path row is actually
keyed by. Any document whose source locale differs from the collection default
produced a misleading error. Create and duplicate were already correct — a
create is constrained to the default locale, and a duplicate writes a new
document — so the fix is confined to the two update paths.
Chores
-
monorepo — both database adapters' Drizzle migration streams were squashed
to a single fresh-install baseline for this release, and the@byline/cli
bundled templates were re-synchronised from them. Two guards now protect this:
a drift contract asserting the CLI templates match the adapter sources
byte-for-byte, and an append-only history check asserting every previously
released hand-writtensql/script is still present and unmodified. -
monorepo — the reference documentation importer
(apps/webapp/byline/scripts/import-docs.ts) lost its--forceflag, which
existed only to work around the reserved-path problem this release removes.
With it went a PostgreSQL advisory lock, version-tombstone mutation, staging
snapshots and manual hook replay — and the importer's only dependency on
PostgreSQL adapter internals. It is now dialect-neutral and runs against
MySQL, having previously been this repository's own counter-example to the
adapter abstraction. The flag is rejected as unknown rather than ignored.
Delete followed by re-import now starts a new logical document at the same
path; the old document and its audit trail stay deleted.
Migrations
-
@byline/db-postgres,@byline/db-mysql— existing installations must
apply the numbered native upgrade script for their provider:# PostgreSQL psql "$DATABASE_URL" -f packages/db-postgres/sql/0006_soft_delete_path_liveness.sql # MySQL mysql -u "$DB_USER" -p "$DB_NAME" < packages/db-mysql/sql/0001_soft_delete_path_liveness.sql
Each script adds the columns, backfills
deleted_atfor path rows whose
document has no live version, and rebuilds the unique key without renaming it.
A path row whose document has no versions at all is deliberately left live,
because nothing was ever deleted there. Both scripts are idempotent and safe
to rerun. The PostgreSQL script runs the backfill and constraint replacement
in one transaction and raises on a failed post-condition; MySQL DDL
auto-commits, so its script guards each step throughinformation_schema,
asserts both directions of path/version liveness afterwards, and fails loudly
rather than printing a diagnostic you might miss.Do not apply this release's development Drizzle migration or the squashed
fresh-install baseline to an occupied database — those describe the schema from
scratch and are for new installations only. -
monorepo — soft delete no longer removes uploaded sources or generated
variants from object storage. Source and variant paths are immutable
historical references that immutable versions and duplicated documents can
share, so deleting one document is not evidence that nobody else references
the object. The practical consequence is that storage now grows monotonically:
there is no purge or reference-safe reclamation operation yet, and no orphan
sweeper. Issue #72
owns the design — variant identity, persisted generation recipes,
provider-neutral source reads, shared-reference analysis, and eventual
cleanup. Plan capacity accordingly if you delete media at volume.
Breaking Changes
-
@byline/core— breaking for out-of-tree adapters only:
IDocumentCommandsnow requiresrestoreSoftDeletedDocument({ document_id }).
Both built-in adapters ship it, so applications using@byline/db-postgresor
@byline/db-mysqlneed no change. If you maintain your ownIDbAdapter, add
the command: it must atomically reactivate every version and every path row of
a fully deleted document, return0for missing, versionless, already-live and
legacy partially-live documents, and let a live-path unique violation roll the
whole operation back. -
@byline/core,@byline/host-tanstack-start— breaking for code
that narrows on the delete side-effect phase union:storageCleanupwas
removed fromDeleteDocumentSideEffectPhase, leavingafterTreeChangeand
afterDelete. Soft delete performs no object-storage cleanup, so the phase can
no longer occur and was not reserved. The transport layer still sanitises an
unrecognised phase to'unknown', so a stale value arriving from an older
client degrades rather than throwing.
All other @byline/* packages bumped to 4.11.0 in lockstep with no behavioural changes this cycle.