@objectstack/service-storage@17.0.0-rc.1
Pre-releaseMinor Changes
-
b1863a5: feat(storage): released field files enter collection on deployments that verified their file migration — ADR-0104 D3 wave 2 PR-5b (#3459)
The gated, final step of the file-as-reference sequence. On a deployment whose
adr-0104-file-referencesflag is verified (os migrate files-to-references --apply, #3617), releasing a field file's ownership — clearing the field, or
deleting the owning record — now also tombstones the file
(status='deleted'+deleted_at), which starts thesys_filelifecycle's
declared 30-day grace window and, at its end, hands the row to the reap sweep.
Re-referencing the id inside the window revives it, exactly like re-attaching
an attachment.The two halves ship together, deliberately. The same change extends the
reap guard's sweep-time re-verify beyondsys_attachmentjoin rows to the
ownership columns: a tombstoned file whoseref_*columns name a current
owner (re-claimed in the window, or a release/claim race) is un-tombstoned and
vetoed. Tombstoning released files without that re-verify would have turned
every release into a guaranteed byte delete — the guard's old check consults
a table that is always empty for field files. This pairing was the standing
hard constraint on #3459, locked by regression tests on both halves.Nothing changes for a deployment that has not migrated. Release keeps
clearing the ownership columns only, and released files are retained forever.
Every way of not knowing — no flag row, an unreadable table, an engine that
cannot be asked — reads as "not verified": the gate fails closed, toward
retention. And the guard re-reads the flag fresh at sweep time (not the
release path's memoized read), so a later failing migration run — a database
that has drifted — closes the gate for already-written tombstones too, without
a restart. Attachments-scope collection is unchanged and needs no flag.The irreversible moment is therefore per deployment: day 30 after that
deployment verified its migration and released a file — never the upgrade
itself. -
270650f: feat(migrate): a datastore created from empty attests its data migrations at creation (#3438, ADR-0104 2026-07-30 addendum)
Deployment-level migration flags could only be recorded by running
os migrate. That left a hole at the other end of a deployment's life: a
database created on a version that already ships the migrations started lax
and stayed lax until someone thought to run a command that, for them, converts
nothing and finds nothing. Every new deployment re-entered the warn regime, so
the warn regime would never die out — and, since #3459, every new deployment
also kept every released file forever.A store the platform creates from empty now records
adr-0104-file-referencesandadr-0104-value-shapesat that moment. Nothing
to run; enforcement and collection are live from the first boot.This is not version-gating in disguise. The fact recorded — no legacy value
is stored here — is observed: the store had no history at all. The platform
attests only what it watched itself create, and the test is deliberately
strict: every table made by this boot and none found already present. One
pre-existing table anywhere, one datasource that was already there, one driver
that cannot account for its schema sync — any of those and the deployment
attests nothing and produces its evidence by scan, exactly as before. "Found
empty" and "created empty" are not the same claim, and only the second is an
observation.New surfaces.
IDataDriver.getSchemaSyncStats?()(optional, purely
observational: tables created vs found since connect — implemented by the SQL
and in-memory drivers),engine.wasDatastoreCreatedFromEmpty(),
attestFreshDatastore()in@objectstack/platform-objects/system, and
VALUE_SHAPES_MIGRATION_ID/CREATION_ATTESTED_MIGRATION_IDSin
@objectstack/spec/system. Attestation never overwrites an existing flag row
and never throws into a boot: a failure leaves the deployment lax, which a
migration run can still fix.Upgrading changes nothing for an existing database. It is non-empty when
the platform reaches it, so it is never attested — run
os migrate files-to-references --applyas before. Importing legacy values
into an attested deployment is rejected loudly at the write path;
OS_ALLOW_LAX_MEDIA_VALUES=1re-opens leniency while you diagnose.
Patch Changes
-
2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
The AGENTS.md post-task checklist requires breaking changesets to carry their
FROM → TO migration because "this text ships to consumers asCHANGELOG.md
inside the npm package and is what an upgrading agent greps after the tombstone
error." That delivery path was severed for 68 of the 69 publishable packages:
npm packspackage.json/README*/LICENSE*unconditionally but — unlike
older npm versions — notCHANGELOG.md, and the canonical
"files": ["dist", "README.md"]whitelist never named it. Measured on npm
10.9.7:npm pack --dry-runon@objectstack/typesshipped 3 files while its
70KBCHANGELOG.mdstayed behind. Only@objectstack/speclisted it
explicitly.The tombstone-error scenario is precisely the one where the repo is out of
reach — the upgrading agent hasnode_modulesand nothing else — so the
migration text has to ride in the tarball. Every publishable package now
declaresCHANGELOG.mdinfiles, and the canonical whitelist is
["dist", "README.md", "CHANGELOG.md"].The other half is the gate:
check:published-filesgains a fifth invariant,
COMPLETE — a whitelist that fails to coverCHANGELOG.mdfails the
always-required lint job, so the next package cannot silently sever the path
again.@objectstack/spec's per-package EXTRA_ENTRIES exemption dissolves
into the canonical set.Consumer-visible change: one more file per install (the package's changelog,
e.g. 70.8KB for@objectstack/types), andgrep -r "removed key" node_modules/@objectstack/*/CHANGELOG.mdnow finds the migration it was
promised. -
9881074: fix(batch): the background walks seek instead of counting, so they stop skipping rows (#4363)
#4363 made a single paged read a partition of its result set. It could not make
a walk one: seven background scans paged with a growingoffsetwhile writing
to the very rows they were reading, and an offset counts into a set those writes
are changing. Rows slide past the cursor and are never visited.That is not a slow page in any of these — it is a wrong answer wearing the shape
of a clean run:rebuildApproverIndexbuilt its desired state by walking
sys_approval_request WHERE status = 'pending'with noorderByat all, then
deleted every index row that state did not explain. A skipped request
meant an approver silently dropped from someone's queue. (The loop beside it
ordered bycreated_at— not unique, so its pages were never a partition
either.)verifyFileReferencesdecides which files nothing references. A record it
never visits is reported as an unreferenced file.backfillFileReferencesand the pinyin companion backfill rewrite
each row they read, so their own writes were shifting the set out from under
the cursor. Records were left unconverted and unsearchable by a run that
reported success.scanValueShapesexists to vouch that no stored value is off-shape, and
it opens a migration gate on that evidence.
All of them now go through
keysetWalk(@objectstack/types): order by a
unique key, and seek past the last one instead of counting from the start. A
row's key does not move when the row is updated, and cannot be shifted when
another is deleted, so the walk is stable under exactly the mutation these
functions perform. It is also O(n) rather than O(n²/page) — measured on
Postgres over 2M rows, deep pages cost ~1.1 s by offset against ~0.09 s by seek.One deliberate non-conversion: the REST export stream keeps its offset. It
honors a caller-chosen sort, and a keyset walk would have to re-order the export
byidto seek — changing what the user asked for to fix a cost. Its pages are
already a partition since #4363; only the depth cost remains.keysetWalkmerges the cursor with$andrather than spreading it into the
caller's filter, so a walk whose ownwhereconstrains the key column
({ id: { $in: [...] } }) keeps that constraint instead of having it silently
overwritten. When amaxcap is set it reads one row beyond the cap to tell
"the cap stopped us" from "the source ended exactly there" — without that, a
walk that read everything still reportstruncated, and a caller acting on it
goes looking for rows that were never withheld.The storage suites' fake engines now throw on an
offsetinstead of serving
one, so the conversion is pinned rather than merely passing. -
be7360c: chore(plugins,services): declare
providesServiceson the 20 remaining init-time service providers (ADR-0116 follow-up, #4131)ADR-0116 gave the kernel a declared ordering contract, but only
ObjectQLPluginandMetadataPluginhad declared what theirinit()
registers. The pre-Phase-1 ordering check can only name a provider for
services someone declared, so its coverage was two plugins wide.An audit of every plugin's
init()body (brace-matched, comments stripped,
each call classified by whether it sits inside atry/if) found 20 plugins
that register a service on every path without declaring it. All 20 now
declareprovidesServices. Purely additive: no ordering changes, no new
failure modes — aprovidesServicesentry only lets the kernel say who
provides a service when it reports a misordering, and enriches the Phase-1
getServicemiss diagnostic.Three needed a closer read before declaring, because they register the same
service from several branches (cache,queue,job): each early-return
branch plus the fallback registers it, so every path does — the declaration
is honest. ADR-0116's rule that a conditionally registered service must
never be declared is unchanged and was applied throughout.The same audit found 12 plugins that hard-resolve a service during
init()
(11 of themmanifest) without declaringrequiresServices. None is a live
exposure — every one already declares a harddependenciesentry on the
provider, so the kernel orders them correctly today. Those are tracked
separately: with a hard dependency in place,requiresServicesmostly
restates what the kernel already enforces, and its real value is on
soft-dependency consumers, of whichAppPluginis currently the only one. -
d5749d7: refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and
error.codeis enforced at compile time (#3973)BaseResponseSchemadeclares one envelope for every REST body the platform
emits. It declared it once; the code that wrote it was copied per route
module. After #3843 and #3983 converted the last drifting one, seven modules
each carried their own two-linesendOk/sendErrorpair — so the envelope's
shape lived in fourteen places rather than one.pnpm check:route-envelopeproved those seven copies agreed, which is why this
is a cleanup rather than a bug fix. But a guard proves agreement; it does not
create it. An eighth module starts by copying the pair again — not
hypothetically:share-link-routes.tswas found already drifting by the
repo-wide scan, and its drift had brokenclient.shareLinks.create()and
.list()throughunwrapResponse(#3983).What moved
sendOk/sendErrornow live once, in@objectstack/types
(response-envelope.ts), and all seven modules import them:Module service-storage/storage-routes.tsservice-settings/settings-routes.tsservice-datasource/admin-routes.tsrest/external-datasource-routes.tsrest/package-routes.tsservice-i18n/i18n-service-plugin.tsplugin-sharing/share-link-routes.tsPlacement was the open question in #3973, not design.
packages/specis
schemas-only (Prime Directive #2), and the callers spanrest, four
services/*and oneplugins/*, which rules out anything depending on them.
@objectstack/typesdepends on nothing but@objectstack/spec, so every caller
can reach it, and it is already where the repo puts a helper the HTTP boundaries
share —looksLikeInternalErrorLeak(#3867) sits one file over and made the
same argument first.The builders take a structural
{ status(n), json(body) }, so the package
imports no HTTP contract at all:IHttpResponsesatisfies it, and so does the
any-typedresthe older modules carry.error.codeis now checked by the compilerAll seven copies typed the parameter
code: string. ADR-0112 (#3841) closed the
vocabulary —ErrorCodeisStandardErrorCode ∪ ERROR_CODE_LEDGER— but an
invented code was still caught only at runtime, by a conformance suite parsing a
driven body, i.e. only on routes some test happened to drive.The shared
sendErrortypescodeasErrorCode, so an unregistered code now
fails to compile, at every call site at once:sendError(res, 400, "NOT_A_REGISTERED_CODE", "invented"); // Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'.
This cost no call-site churn: every code the seven modules emit was already
registered.extrais closed at the same placesendError's last parameter isPick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>— exactly whatApiErrorSchemadeclares besidecode
andmessage.It was
Record<string, unknown>whilesettings-routesstill hungnamespace/
key/reason/fieldsbesidecode. Those bodies passed every gate anyway:
ApiErrorSchemais a plainz.object, so unknown keys were STRIPPED rather than
rejected, andenvelopeViolationsinspects only the body's top level —
conformant by stripping rather than by declaration. #4224 moved that module
ontodetails, which is what lets the parameter close here. Closing it at the
shared builder is the part that lasts: an undeclared sibling is now a compile
error in every module at once, rather than a key that quietly evaporates in
whichever module reintroduces it.Nothing changes on the wire
The seven pairs were identical modulo the optional
statusandextra
parameters this one unions, and each module's driven conformance suite still
parses its real bodies against the real spec schemas. One internal call site was
rewritten:package-routespasseddetailspositionally and now passes
{ details }, producing the sameerror.detailsit always did.The guard got stronger
scripts/check-route-envelope.mjscounts response write sites per module. A
module that routes everything through the shared pair builds none itself, so
the seven now declare0 / 0 / 0where they used to declare2 / 1 / 1, and the
shared pair is pinned separately at2 / 1 / 1so the invariant stays total for
the surface rather than per-module. What the count asserts is no longer "your two
builders are the enveloped ones" but "you have no builders" — and a new route
that hand-rolls a body still moves it off zero and fails. -
efcd68c: The storage adapter stops being rebuilt and re-pointed on every boot, and the
"files may be unreachable" warning stops firing at a healthy server (#4096).Every
os dev/os serveboot printed:WARN StorageServicePlugin: storage adapter swapped (LocalStorageAdapter → LocalStorageAdapter). Existing files were NOT migrated and may be unreachable through the new adapter.The warning was telling the truth.
serveconstructed the plugin with
{ driver: 'local', root }— andStorageServicePluginOptionsdeclares
neither key. Both were dropped silently, so the plugin applied its own
./storagedefault,OS_STORAGE_ROOTchanged nothing, and uploads landed in a
directory nobody named. Thestoragesettings namespace then corrected the root
on its first read (its manifest default is./.objectstack/data/uploads),
genuinely moving the backing store — every boot, forever.Three fixes, because there were three defects:
servenow passes options the plugin reads —{ adapter: 'local', local: { rootDir } }.OS_STORAGE_ROOTtakes effect, and local uploads land
under.objectstack/data/uploadsfrom the first byte instead of./storage.
Extracted asresolveStorageCapabilityArgso the option SHAPE is pinned by
tests: a mismatch like this type-checks fine and does nothing at runtime.- A swap is skipped when nothing changed. The plugin records what the
running adapter points at and compares resolved configurations, instead of
rebuilding whenever the settings namespace held any value at all — which is
every boot once that namespace has persisted its own defaults. - The warning now means what it says. It fires when the BACKING STORE moved
(kind change, different root, different bucket/region/endpoint), not merely
when the adapter object was replaced. A credential rotation swaps the adapter
so the new key takes effect and logs at info: same bucket, nothing stranded.
A swap from a caller that resolved no target still warns — ignorance must not
silence it.
Path spellings are normalised, so the platform writing the same default two ways
(./.objectstack/data/uploadsin the settings manifest,
.objectstack/data/uploadsin the CLI) is no longer read as a migration between
a directory and itself.Verified on
examples/app-todo: the boot-diagnostics block went from four
warnings to three, with the storage line gone and./storageno longer created.
19 unit cases cover the target resolver and the swap/warn split (including the
refusals), 4 plugin-level cases pin what a boot does and says, and 7 pin the CLI
option shape.config.storageauthored with thedriver/rootdialect is still forwarded
verbatim and still not read by the plugin — the same mismatch one layer up.
Correcting it means deciding whether the plugin accepts that dialect or the
config schema is wrong, so it is filed rather than papered over with a lenient
alias here (AGENTS.md Prime Directive #12). -
68dea0b: feat(platform-objects,service-storage,cli):
sys_migrationis platform infrastructure — registered byPlatformObjectsPlugin, not by the storage service (#4243)The deployment-level data-migration flag ledger (
sys_migration, #3617) was
registered by@objectstack/service-storageas its first consumer. That was
deliberate while the file migration was the only consumer, but the ledger now
gates storage-independent behaviour too —os migrate value-shapes(#4235)
and the fresh-datastore attestation (#4215) — and a non-file migration had to
boot the whole storage plugin just so the kernel carried the table. Any kernel
assembled without storage silently had no ledger at all, which read exactly
like "migration not run" (both answer false) while actually meaning "ledger
not installed".The registration now lives in
PlatformObjectsPlugin
(@objectstack/platform-objects/plugin) — the pluginos servealready
auto-injects into every served kernel — so the ledger exists with the
platform, independent of which optional services are composed. The
fresh-datastore attestation (#3438, ADR-0104) moves with it: it is ledger
bookkeeping, and its old home justified itself as "the service that registers
sys_migration". Definition ownership is unchanged (sys_migrationstays in
@objectstack/platform-objectsand inPLATFORM_OBJECTS_BY_PACKAGE); the
flag helpers and readers are untouched.Consequences:
@objectstack/service-storageno longer contributessys_migrationto the
manifest and no longer performs the fresh-datastore attestation. An embedder
composingStorageServicePluginon a hand-built kernel that relied on it
for the ledger must composePlatformObjectsPlugin(the plugin every
supported assembly path already includes).- The CLI's
buildDataMigrationPlugins()no longer boots storage for every
gated migration — it registersPlatformObjectsPluginalways, and settings- storage only for
os migrate files-to-references({ storage: true }),
the one migration that actually reconciles against the storage adapter.
- storage only for
-
Updated dependencies [6a67d7a]
-
Updated dependencies [0ecc656]
-
Updated dependencies [06772eb]
-
Updated dependencies [270650f]
-
Updated dependencies [3aef718]
-
Updated dependencies [1ea6bce]
-
Updated dependencies [c1dcacd]
-
Updated dependencies [ad303ed]
-
Updated dependencies [32ccb23]
-
Updated dependencies [f5a4ef0]
-
Updated dependencies [2d3e255]
-
Updated dependencies [7d7521f]
-
Updated dependencies [5dc4d02]
-
Updated dependencies [05154a1]
-
Updated dependencies [9b6fe7c]
-
Updated dependencies [8c711fb]
-
Updated dependencies [09e4547]
-
Updated dependencies [91f4c78]
-
Updated dependencies [820eff9]
-
Updated dependencies [8d895ff]
-
Updated dependencies [f6472d7]
-
Updated dependencies [78caf51]
-
Updated dependencies [62a789b]
-
Updated dependencies [789ad63]
-
Updated dependencies [2af1988]
-
Updated dependencies [0af50a3]
-
Updated dependencies [2e836de]
-
Updated dependencies [12a19a8]
-
Updated dependencies [41dcda3]
-
Updated dependencies [c8124e5]
-
Updated dependencies [a1a4140]
-
Updated dependencies [c20b875]
-
Updated dependencies [2a37694]
-
Updated dependencies [217e2e6]
-
Updated dependencies [86a71d1]
-
Updated dependencies [d5c75e2]
-
Updated dependencies [03d26f7]
-
Updated dependencies [4384921]
-
Updated dependencies [3c628ce]
-
Updated dependencies [7cb922e]
-
Updated dependencies [1d22114]
-
Updated dependencies [b5f9397]
-
Updated dependencies [ed77493]
-
Updated dependencies [58a03d2]
-
Updated dependencies [dc530b4]
-
Updated dependencies [e59786e]
-
Updated dependencies [bcf1112]
-
Updated dependencies [9774b78]
-
Updated dependencies [b07d829]
-
Updated dependencies [a648e96]
-
Updated dependencies [a47ac06]
-
Updated dependencies [e4c61a7]
-
Updated dependencies [cc60165]
-
Updated dependencies [081aa6f]
-
Updated dependencies [91f4c78]
-
Updated dependencies [e8d0c21]
-
Updated dependencies [45dc446]
-
Updated dependencies [c1d44f7]
-
Updated dependencies [ab9fb5c]
-
Updated dependencies [f985b3f]
-
Updated dependencies [9a4932a]
-
Updated dependencies [f9fc874]
-
Updated dependencies [011b386]
-
Updated dependencies [9881074]
-
Updated dependencies [7777e8f]
-
Updated dependencies [507b92a]
-
Updated dependencies [7309c81]
-
Updated dependencies [20bc1ec]
-
Updated dependencies [90c2b15]
-
Updated dependencies [39eb01b]
-
Updated dependencies [42eeb7d]
-
Updated dependencies [01e124d]
-
Updated dependencies [7ce02eb]
-
Updated dependencies [a13827e]
-
Updated dependencies [7733604]
-
Updated dependencies [40e420f]
-
Updated dependencies [d13004a]
-
Updated dependencies [be7360c]
-
Updated dependencies [5b47ab5]
-
Updated dependencies [b09d8d9]
-
Updated dependencies [b09d8d9]
-
Updated dependencies [8675db6]
-
Updated dependencies [b09d8d9]
-
Updated dependencies [3eb1b2b]
-
Updated dependencies [59b85c0]
-
Updated dependencies [6e357ed]
-
Updated dependencies [d6938bf]
-
Updated dependencies [31e0be9]
-
Updated dependencies [4bfd455]
-
Updated dependencies [ffd2ce2]
-
Updated dependencies [62f8017]
-
Updated dependencies [a831df1]
-
Updated dependencies [f752ee3]
-
Updated dependencies [a1b61e0]
-
Updated dependencies [cd6b9f2]
-
Updated dependencies [2cb6d3c]
-
Updated dependencies [af2a095]
-
Updated dependencies [ec796d5]
-
Updated dependencies [e87fea1]
-
Updated dependencies [c65e529]
-
Updated dependencies [3ca34c1]
-
Updated dependencies [239c3a3]
-
Updated dependencies [94a0bbc]
-
Updated dependencies [d6bfb3d]
-
Updated dependencies [a2266a6]
-
Updated dependencies [d25a0ec]
-
Updated dependencies [667b83e]
-
Updated dependencies [627b188]
-
Updated dependencies [8d4eae7]
-
Updated dependencies [857a6cf]
-
Updated dependencies [65a3a84]
-
Updated dependencies [d5749d7]
-
Updated dependencies [ccd9397]
-
Updated dependencies [bca935b]
-
Updated dependencies [d92c72d]
-
Updated dependencies [c54c822]
-
Updated dependencies [8dcc0f5]
-
Updated dependencies [75b9e51]
-
Updated dependencies [0a2f233]
-
Updated dependencies [8621cdd]
-
Updated dependencies [6f23667]
-
Updated dependencies [5d21a48]
-
Updated dependencies [19365b7]
-
Updated dependencies [b7ed26d]
-
Updated dependencies [68dea0b]
-
Updated dependencies [64f8cbe]
-
Updated dependencies [b3a3d83]
-
Updated dependencies [7a55913]
-
Updated dependencies [35accbf]
-
Updated dependencies [6038de7]
-
Updated dependencies [eb95d97]
-
Updated dependencies [e4c2dc8]
-
Updated dependencies [1bd2795]
-
Updated dependencies [8186a70]
-
Updated dependencies [a329cca]
-
Updated dependencies [6eec18c]
-
Updated dependencies [4d7bebf]
-
Updated dependencies [821ac7a]
-
Updated dependencies [8f81731]
-
Updated dependencies [8b50cb3]
-
Updated dependencies [8c2db68]
-
Updated dependencies [22b5e54]
-
Updated dependencies [0166bd5]
-
Updated dependencies [9b702dc]
-
Updated dependencies [ab16331]
- @objectstack/spec@17.0.0-rc.1
- @objectstack/platform-objects@17.0.0-rc.1
- @objectstack/core@17.0.0-rc.1
- @objectstack/observability@17.0.0-rc.1
- @objectstack/types@17.0.0-rc.1