Skip to content

feat: adapt migration schema calls to query-lib value objects - #222

Open
abnegate wants to merge 75 commits into
mainfrom
feat-query-lib
Open

abnegate wants to merge 75 commits into
mainfrom
feat-query-lib

Conversation

@abnegate

@abnegate abnegate commented Aug 21, 2026 •

Copy link
Copy Markdown
Member

Adapts the migration destination's schema calls to the query-lib value objects.

Why this approach

Database::createCollection() takes a Collection only, so destination Appwrite now wraps the collection id, attributes and indexes — along with the named permissions and documentSecurity — in new Collection(...) instead of passing positional arrays.

utopia-php/database is pinned to dev-feat-query-lib as 2.0.0, re-pinned to that branch's head whenever it moves.

Lock repairs that came with the re-pin

Two problems surfaced only when the lock was resolved again rather than reused:

  • utopia-php/query was locked to dev-feat-schema-order, a branch that no longer exists on the remote. The resolution survived only as long as nobody resolved it. database's branch requires query 0.6.*, which is released, so the lock now takes the release.
  • utopia-php/storage moved 4.0.3 → 4.0.4, because 4.0.3 capped utopia-php/validators at ^0.4 while database requires ^0.5. 4.0.4 dropped the validators dependency outright. database has required ^0.5 on main as well as on the branch, so this was already true before the query-lib work and only surfaced now.

A live bug the re-pin exposed

The Appwrite destination passed the 'ASC' / 'DESC' strings a source hands back straight into Utopia\Database\Index, which takes Order cases and rejects anything else. The resulting InvalidArgumentException is not a Migration Exception, so instead of recording a failed index the whole transfer aborted.

This was already true before this PR and simply could not be seen: the lock held query at the deleted branch, whose Index took plain strings, so CI had never built an index against the contract the released library actually has. AppwriteIndexLengthsTest covers it — two of its cases pass ['ASC', 'ASC'], and both went red on the first run against the re-pinned lock and green with the fix.

It is the same defect as the one in appwrite/appwrite#11649's Databases worker, from the same cause.

Also on this branch: provisioning ownership and recovery

The branch has grown well past its title. Alongside the value-object adaptation it carries a provisioning-owner / lifecycle-fencing subsystem added 2026-08-29..31, which does not depend on query-lib at all:

  • Destinations/Appwrite/ProvisioningOwner.php and owner persistence in Destinations/Appwrite.php
  • fencing for concurrent provisioning attempts, and recovery of terminal migration failures
  • explicit CLI flags for provisioning recovery in bin/MigrationCLI.php
  • the tests that come with them: AppwriteDatabaseConcurrencyTest (+400), AppwriteDatabaseStatusTest (+1026), MigrationCLITest (+342), ProvisioningOwnerTest (+36)

Total diff against main is +3450/-714 over 23 files. Recommendation: split those commits into a PR against main so they can ship now, and leave only the Collection/Attribute/Index/Order adaptation and the lock re-pin here, where it is blocked on database#823 being tagged.

Chain

Landing order, bottom up:

  1. utopia-php/database#823 — the query-lib migration itself
  2. utopia-php/abuse#124, utopia-php/migration#222, utopia-php/monorepo#206 — the schema call sites in the libraries (audit moved to the monorepo, so feat: adapt audit schema to query-lib Attribute and Index VOs audit#133 is closed in favour of monorepo#206)
  3. appwrite/appwrite#11649
  4. appwrite-labs/cloud#5410

The three framework PRs once stacked on #823 — #947 (ORM), #948 (repositories and seeding), #949 (migration runner and schema differ) — are closed and are not part of this train.

Verified

  • CI green on this head
  • Greptile 5/5, no unresolved threads
  • Pint on the Collection wrap

Not verified

  • Open PR Support duplicate handling for Appwrite imports #205 (duplicate handling for Appwrite imports) edits the same Destinations/Appwrite.php regions; whichever lands second will need a merge.
  • The utopia-php/database dependency is still a branch pin. It becomes a released tag only once #823 merges, and this PR should not land before that.

Fixes from the 2026-09-23 review

A progress callback can stop a transfer: Exception\Aborted (11.7)

  • New Utopia\Migration\Exception\Aborted (extends RuntimeException).
    • Throw it from the callback given to Transfer::run() or Transfer::runWithResourceSelector() to stop the transfer.
    • Every library source (Appwrite, Firebase, Supabase, NHost, CSV, JSON) rethrows it from the catch that otherwise
      records a resource type's failure and moves on. Nothing further is exported or imported.
    • It leaves run()/runWithResourceSelector() as the same instance.
    • Every other failure is still recorded in getErrors() as before.
    • The batch whose callback threw has already been imported, because the callback runs after the destination imports
      a batch.
  • Why: each source wraps every resource type's export in a catch-all.
    • An exception from the progress callback was recorded as that type's error, and the source went on to the next type
      and group. It imported the first batch of each before the callback threw again.
    • A consumer had no way to stop a run, and appwrite's migration worker relies on exactly that when its attempt is
      superseded.
  • The nested per-file and per-deployment catches in the Appwrite source's storage, functions and sites exports rethrow
    it too. An abort no longer skips to the next file or deployment.
  • Transfer keeps the first abort for the rest of the run. A source outside this library that still catches it and
    carries on cannot turn it into a normal return: later batches rethrow it without calling the callback again, and the
    run ends with it. A later run() on the same Transfer is unaffected.
  • Aborted is deliberately not a Utopia\Migration\Exception. That type is the per-resource error record that sources
    append to errors, and an abort is not a resource error.

Provisioning ownership (06.1, 06.2, 06.7, 06.9)

  • Owners are written only where the schema has room for them.

    • The Appwrite destination checks separately for status (Appwrite V25) and for the
      migrationId/migrationAttemptId owner pair (V26).
    • On a project that has reached V25 but not V26, databases get status tracking with no owner, as they did before
      ownership existed.
    • Before this change, every database import into such a project failed. Schemas that reject unknown attributes
      returned Unknown attribute: "migrationId". Schemas that drop them, like Appwrite's project database, left every
      database stuck in provisioning with "owner changed before finalization".
  • Databases left incomplete before ownership existed can be recovered again. A provisioning or failed database
    that names no owner was left by an earlier library version or by a status-only schema. It is recovered without
    getRecoverableOwner, as it was before ownership existed:

    • A failed database is overwritten.
    • A provisioning database is resolved under the OnDuplicate policy like any existing database, and fail keeps it
      instead of colliding with it. It is marked ready when the run succeeds.

    Either way, the current attempt claims the database under the row lock when the schema can record an owner. A missing
    backing collection is recreated before the ready flip. A database that names an owner, even half of one, still needs
    attestation, and a refusal still fails closed.

  • Provisioning writes are fenced without borrowed timestamps.

    • The overwrite claim no longer writes the source archive's $updatedAt into the column the optimistic fence
      compares. So $updatedAt always moves forward, whatever the preserveDates setting.
    • Every guarded write re-reads the row. If the stored timestamp did not move forward, the write fails
      (Database <id> provisioning write did not move its update timestamp forward). Inside the claim transaction it is
      rolled back.
  • A relationship overwrite no longer changes an onDelete the source didn't specify.

    • If the source has no onDelete, or an empty or unknown one, the value reaches updateRelationship() as null
      ("unchanged").
    • Before, it became restrict, or an empty or unknown value threw a ValueError.
    • Both sides of the Appwrite attribute metadata keep the destination's action.

Finalization contract (06.3, 06.4, 06.8)

  • A failed finalization throws.
    • Destinations\Appwrite::success() used to report a database it could not mark ready only by appending to
      getErrors(). That came after the point where most callers check for errors, so a caller that checked once before
      success() marked the migration completed while its databases stayed provisioning.
    • success() now attempts every step, records each failure with addError() as before, and then throws
      Utopia\Migration\Exception\Finalization.
    • The exception extends Utopia\Migration\Exception, and its failures property lists the recorded errors.
  • One failed step no longer skips the others.
    • A single database that failed its ready write made success() return before the OnDuplicate::Overwrite orphan
      sweep. That left the unswept columns and indexes of every table behind.
    • The flip still runs first, so a sweep failure can't strand databases in provisioning. The sweep now always
      follows, as it did before finalization moved to success().
    • A table whose sweep fails no longer stops the sweep of the tables after it.
  • Skipping success() is reported.
    • Finalization stays in success(), because callers such as Appwrite must persist their finalizing claim before it
      runs. Neither Transfer nor Target calls success(), and Transfer calls no lifecycle hook at all.
    • So the destination records an error for each database that a returned run left unfinalized. It does this when
      cleanUp() ends the lifecycle, or when run() starts again on the same instance.
    • A caller that calls success(), or error() for a run it won't finalize, gets no report.
    • The error can't appear before success() is due, so the error check a caller makes between run() and success()
      still passes.
  • Only a run that returned is finalized. success() after a destination run() that threw now does nothing. The
    databases stay provisioning for a later attempt to recover, as they did when run() finalized itself. Before this
    change they were marked ready with only part of their contents.
  • The lifecycle contract is documented on Target::success(), Target::error() and Target::cleanUp().

Upgrade notes

  • Custom sources. A Source subclass that catches \Throwable around its exports should rethrow
    Utopia\Migration\Exception\Aborted before recording other errors:
    } catch (Aborted $abort) { throw $abort; } catch (\Throwable $error) { ... }. Transfer::run() still ends with the
    abort if it does not, but the source keeps exporting until it returns.
  • Callers of Destinations\Appwrite:
    1. After run() returns, persist any claim that must come first, then call success().
    2. Catch Exception\Finalization from success(). Each failure is also in getErrors().
    3. For a run you won't finalize, call error() instead.
    4. After catching Aborted from Transfer::run(), call error() (or nothing), never success().
  • getRecoverableOwner is unchanged. It is now called only for rows that name an owner, so existing callbacks need
    no change.

CHANGELOG highlights (3.0.0, unreleased)

mg-05 writes CHANGELOG.md in wave 2; these are the entries it should carry.

  • Breaking: requires utopia-php/database 8 (main requires ^7.0.0). The PHP floor is unchanged: >=8.5, as
    on main.
  • Breaking: the Appwrite destination records which migration attempt provisions each database.
    • Its constructor takes a ProvisioningOwner $owner, made from a stable logical migration id and a fresh attempt id
      for every execution.
    • It also takes a getRecoverableOwner callback that attests the terminal owner of an incomplete database.
    • The standalone CLI requires --migration-id and a fresh --migration-attempt-id. Recovering an incomplete
      database that names an owner also requires --recover-migration-id and --recover-migration-attempt-id.
    • Owners are written and enforced only where the destination's databases metadata declares both owner attributes.
      Databases that name no owner are recovered as before.
  • Breaking: Destinations\Appwrite::run() no longer finalizes.
    • Call success() once run() has returned (after persisting any claim that must come first), or error() for a
      run you won't finalize.
    • success() throws Utopia\Migration\Exception\Finalization when a step fails. Each failure is also recorded with
      addError().
    • cleanUp() records an error for each database left unfinalized when neither was called.
  • Added: Utopia\Migration\Exception\Aborted. Throw it from a transfer's progress callback to stop the transfer.
    Every library source rethrows it, and Transfer ends the run with it.
  • Fixed:
    • Imports into a destination whose database metadata has status but no owner attributes.
    • Recovery of databases left provisioning or failed without an owner.
    • The provisioning fence no longer relies on source timestamps.
    • A relationship overwrite keeps the destination's onDelete when the source has none.
    • One failed ready flip no longer skips the overwrite orphan sweep.

Commits

  • fbe35ad fix: stop a transfer when its progress callback aborts
  • 346f16b fix: end a transfer with the abort even when a source swallows it
  • c417528 fix(destination): write provisioning owners only where the schema declares them
  • d86a306 fix(destination): recover databases left incomplete before ownership existed
  • 5880597 fix(destination): fence provisioning writes without source timestamps
  • 0ecd10c fix(destination): leave a relationship's onDelete alone when the source has none
  • c033754 (fix): run the overwrite sweep when a database fails to flip ready
  • e73646f (fix): throw Finalization when success() cannot finalize
  • 2e89529 (fix): report a skipped success() when the destination lifecycle ends
  • 8429254 chore: re-pin utopia-php/database to the feat-query-lib head

🤖 Generated with Claude Code

Published 2.0 still used Database::VAR_* and positional createAttribute/createIndex, which feat-query-lib removed. Rebase onto main and pass Attribute/Index/Relationship VOs plus ColumnType/IndexType so Appwrite can pin this branch as 2.0.0.
Appwrite stores ColumnType::BigInteger as biginteger. CSV export
resolved that as an unsupported column type and wrote no rows.
createDocument can return an empty Mongo sequence while a subsequent
getDocument has the ObjectId. Creating database_{seq} from the create
return left table import looking up a collection that did not exist.
Appwrite main added huggingface as a project OAuth2 provider. Without
an allow-list entry, Appwrite-to-Appwrite migrations fail on that
provider even when the rest of the transfer succeeded.
Keep query-lib APIs and the huggingface PROVIDERS allow-list already on main.
Appwrite #11649 locks database at 5719edd. Staying on e593b78 would
only prove the schema VO calls against an older query-lib surface.
@greptile-apps

greptile-apps Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Upgrades database library and refactors migration schema calls.

No outstanding review finding blocks merging; the documented database release dependency remains a landing prerequisite.

Summary

This PR adapts Appwrite migration schema calls to query-lib value objects and adds provisioning ownership, recovery, abort handling, and explicit finalization. The only change since the previous review came from merging main and is already in the PR base. No new findings were accepted.

Reviews (47) · Last reviewed commit: "chore(merge): merge main into feat-query..."

utopia-php/database feat-query-lib keys silenced events with
Coroutine::getCid(). The CI image is vanilla PHP, so Memory-adapter
tests fatalled before they could run.
createDocument can persist a row whose subsequent getDocument is empty.
That throw sat outside the failed-status handler, so a later skip could
flip the unusable database to ready without a backing collection.
@abnegate

Copy link
Copy Markdown
Member Author

Addressed the reload-failure finding.

createDocument can persist a _databases row whose immediately following getDocument is empty (the Mongo sequence miss this branch already reloads for). That throw sat outside the markDatabaseFailed catch, so the document stayed provisioning and a later spec-matching skip could flip it to ready with no backing collection.

Reload + createCollection now share that catch. testReloadFailureMarksTheDatabaseFailed fails without the wrap (provisioning) and passes with it (failed).

Also stubbed Swoole\Coroutine::getCid() in the PHPUnit bootstrap so the Memory-adapter suite can run on the CI image, which has no Swoole extension. utopia-php/database feat-query-lib keys silenced events with it.

@greptile-apps review

Asterisk wildcards on utopia-php packages are replaced with
equivalent caret constraints so Composer ranges stay consistent.
Keep composer.json and composer.lock in sync so `composer validate`
passes, and pin utopia-php/database to the current query-lib HEAD.
@abnegate

Copy link
Copy Markdown
Member Author

@greptileai review

@abnegate

Copy link
Copy Markdown
Member Author

@greptile-apps review

Force re-review of HEAD 7a3a60e. Description updated for factories and caret lock refresh.

Database::createCollection no longer accepts a string id.
Database::checkAttribute now requires Attribute. Build schema models from the resource key so metadata document IDs are not used as attribute keys.
Appwrite E2E migrations failed because checkAttribute now requires
Attribute, and the destination still handed it a metadata Document.
Comment thread src/Migration/Destinations/Appwrite.php
A reload failure leaves a metadata document in `failed` with no backing
collection. Recovery only ran when onDuplicate was not Fail, so the
default policy retried createDocument against the existing ID and
stranded the database.
@abnegate

Copy link
Copy Markdown
Member Author

@greptileai review

abnegate and others added 2 commits August 21, 2026 22:53
Index types already used IndexType; column direction was still a raw
ASC string. Collection constructors with multiple named params were
also jammed on one line.
…atabase

The lock held utopia-php/query at dev-feat-schema-order, a branch that no longer
exists on the remote, so the resolution only survived as long as nobody resolved
it again. database's branch requires query 0.6.*, which is released, so this
takes the release.

storage 4.0.4 comes along because 4.0.3 capped utopia-php/validators at ^0.4
while database requires ^0.5; 4.0.4 dropped the validators dependency outright.
database has required ^0.5 on main as well as on the branch, so this was already
true before the query-lib work and only surfaced now that the lock moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/Migration/Destinations/Appwrite.php
abnegate and others added 13 commits September 21, 2026 23:22
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The database library no longer carries a per-document version, so the
compare-and-set on `expectedVersion` has nothing to compare. The
ownership writes now run under withRequestTimestamp with the timestamp
the row was read with: every update moves `$updatedAt` strictly
forward, so a row another writer touched between the locked read and
the write is refused as a conflict exactly as before, and the rows are
still locked with forUpdate inside the claim transaction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…head

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every source wraps each resource type's export in a catch-all that records the
failure and moves on to the next type. An exception thrown by the progress
callback was recorded the same way, so a consumer could not stop a transfer:
appwrite's worker throws Superseded from the callback once its migration
attempt is superseded (a zombie worker whose row was expired and retried, or a
DELETE on a running migration), and the worker kept importing the first batch
of every remaining type into databases the new attempt had already claimed.

Add Utopia\Migration\Exception\Aborted and rethrow it from each of those
catches, including the per-file and per-deployment catches nested inside the
storage, functions and sites exports, so nothing further is exported or
imported and the abort leaves Transfer::run() unchanged. It extends
RuntimeException rather than Utopia\Migration\Exception, which is the
per-resource error record the sources append to; consumers extend it
(appwrite's Superseded) to keep their own type. Every other failure is still
recorded as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The library's sources now rethrow Aborted, but a source outside the library,
or a catch added later, can still record it and carry on, and Transfer::run()
would then return as if the transfer had finished. Transfer now keeps the
first abort its callback throws: later batches rethrow it without calling the
callback again, and the run ends with it, so the caller always sees the abort.
The state is per run, so a later run on the same Transfer is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lares them

A destination whose `databases` metadata has `status` but not yet
`migrationId`/`migrationAttemptId` (a project Appwrite's V25 reached and
V26 has not, the normal state of a rolling upgrade) passed the only probe,
which looked for `status`, so every database write carried the owner too.
Where unknown attributes are rejected the create failed with
`Unknown attribute: "migrationId"`; where they are dropped, as Appwrite's
project database does, the owner vanished and the ready flip refused
every database as "owner changed before finalization". Either way no
database, and nothing under it, arrived.

Ownership now has its own probe that requires both owner attributes.
Owners are written, and the ready/failed flips check them, only when the
schema declares them; a status-only schema gets status tracking exactly
as before ownership existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…existed

A run on the released library that died mid-migration leaves its
database `provisioning` or `failed` with no owner, because nothing wrote
one then. Every later attempt refused that row before any recovery
logic ran: no attestation can vouch for an owner that was never
recorded, so the database stayed unrecoverable until someone edited the
metadata by hand. On main this was the supported recovery path.

A row that names no owner at all is recovered as it was before
ownership existed: a failed database is overwritten, and a provisioning
one is resolved like any existing database under the OnDuplicate policy
(Fail skips rather than colliding with it) and re-enrolled so the ready
flip completes it. Either way the current attempt claims it under the
row lock where the schema can record an owner, so a concurrent attempt
sees it owned and needs attestation. A missing backing collection is
recreated first in both cases, so a database stranded before its
collection existed is never flipped to ready without one.

The attestation callback is still consulted, and still fails closed,
for every row that names an owner, including one naming only half of
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Status flips run outside any transaction, so what keeps a stale writer
out is the pin to the `$updatedAt` it read, which holds only while every
write moves that timestamp strictly forward. The overwrite claim wrote
the source archive's `$updatedAt` into that column, usually older than
the row it replaces; it stayed harmless only because createDatabase()
happens to run with preserveDates off. Under the run-wide preserveDates
the claim moved the timestamp backwards and a writer pinned to the
pre-claim read passed the fence.

The claim no longer supplies `$updatedAt`, so the library always
derives a strictly later one, and every guarded write re-reads the row
and fails when the stored timestamp did not move past the one it was
pinned to. Inside the claim transaction that failure rolls the claim
back, so the invariant is enforced here rather than inherited from a
setting several frames up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce has none

`updateRelationship()` reads a null action as "leave it unchanged", but
an overwrite of a two-way relationship whose source carried no
`onDelete` passed `restrict` instead, silently rewriting the
destination's `cascade` or `setNull` and with it the delete semantics of
the migrated data. An empty or unknown value from a hand-edited archive
threw a ValueError, failing the column.

The source action is parsed with tryFrom(), so a missing or unusable
value reaches the library as null, and both sides of the Appwrite
attribute metadata keep the destination's action instead of recording
a value the library never applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
success() returned before cleanupOverwriteOrphans() as soon as one
provisioned database failed its ready write, so a single transient
failure in a multi-database OnDuplicate::Overwrite migration left the
unswept columns and indexes of every table behind, not just those of
the database that failed.

The two steps are independent. The flip still runs first, so a sweep
failure can't strand databases in provisioning, and each flip failure
is still recorded with addError(); the sweep now always follows, as it
did on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
success() reported a database stuck in provisioning only by appending
to getErrors(), after the point where most callers gate on errors: a
caller that checks once, before success(), marked the migration
completed while its databases stayed unusable. A thrown exception
cannot be missed that way, and the callers already wrap the lifecycle
in a try/catch.

success() still attempts every step first: each flip failure and each
table whose overwrite sweep fails is recorded with addError(), so the
report keeps per-resource detail, and a failing table no longer stops
the sweep of the tables after it. It then throws
Utopia\Migration\Exception\Finalization carrying those failures. The
exception extends Utopia\Migration\Exception so callers that bubble
library errors keep treating it as one. The contract is documented on
Target::success().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finalization moved from run() into success(), which neither Transfer
nor Target calls. A consumer that kept $transfer->run() as its last
call left every imported database in provisioning and every overwrite
sweep undone with no error and no failed resource, and a reused
destination silently discarded the previous run's pending work on its
next run().

Finalization stays in success(), because callers such as appwrite must
persist their finalizing claim before it runs, so the skip is reported
where it can no longer be a false alarm: Transfer calls no lifecycle
hook, and shutdown() precedes success() in the caller's sequence, so
the destination tracks whether a returned run is still pending and
records an error for each database left unfinalized when cleanUp()
ends the lifecycle, or when run() starts over. error() acknowledges a
run that will not be finalized and success() settles it, so neither
path reports anything.

success() now finalizes only a run that returned, as run() did on
main: an interrupted run keeps its databases in provisioning for a
later attempt to recover instead of exposing them as ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lock now points at d82a7ec54, the head carrying the review fixes
this branch's own changes were written against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/Migration/Destinations/Appwrite.php
abnegate and others added 12 commits September 24, 2026 21:43
success() skipped finalization only when the destination's own run() threw.
A source that catches Aborted and carries on lets run() return, which sets
the pending finalization, and only Transfer::run() then rethrows the latched
abort. A caller that called success() after that would flip the partially
imported databases to ready and sweep their overwrite orphans.

Transfer now tells the destination the run was aborted, through a new no-op
Destination::markAborted(), just before it rethrows. The Appwrite destination
drops its pending finalization there, so a later success() is a no-op exactly
as it already is after a run that threw, and the databases stay in
provisioning for a later attempt.

error() is not used for this: a custom destination's error() may do more than
skip finalization, and the caller still chooses between error() and a retry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…repositories

The inline alias `dev-feat-query-lib as 7.0.0` only ever worked in a root
package: in a dependency's require, Composer strips the ` as 7.0.0` half and
the requirement becomes the literal `== dev-feat-query-lib`. Together with the
dependency-level `repositories` block, which Composer reads only from the root
package, a published utopia-php/migration was installable only by a consumer
that replicated the same three VCS entries in its own composer.json, and it
would have kept demanding the branch itself once appwrite moved to a tag.

database now comes from Packagist as `8.*`, served by its `8.0.x-dev` branch
alias, so the three VCS entries are gone and query and async resolve from
Packagist at the same commits the lock already carried. The remaining caret
constraints with a `.0` lower bound become wildcards; `halaxa/json-machine`
keeps `^1.2` because no wildcard says the same. `minimum-stability: dev` and
`prefer-stable: true` stay until database 8.0.0 is tagged, and the release
sequence restores `stable`.

The `branch-alias` extra makes the branch resolvable as the next release, 3.0,
for anyone who tracks it before the tag exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
migration has never carried a changelog, and 3.0 is the first release that
breaks its consumers: the database 8 requirement, the Appwrite destination's
owner arguments and provisioning lease, the standalone CLI's new required
options, the success()/error() finalization contract and Exception\Aborted.
Collecting them with the upgrade notes gives consumers one place to read
before they move off 2.x.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Appwrite destination wrote the schema enum value into the project's
`attributes` metadata, so a migrated big integer landed as `biginteger`
while every other writer in Appwrite stores `bigint`. Appwrite's
attribute-update endpoints compare that raw string, so such a column
could only be changed by dropping and recreating it, losing its data.

`schemaColumnType()` now returns `Attribute::persistedType()` of the
matching `ColumnType` case: only the big-integer case changes value,
every other type keeps the spelling it had. The two sites in
`createField()` that turn that string back into a `ColumnType` go
through `Attribute::normalizeType()`, which accepts both spellings, so
the table's physical column is still `ColumnType::BigInteger`. The raw
comparisons against stored metadata now line up on both sides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it is stale

On destination metadata that declares `status` but not `migrationId` and
`migrationAttemptId`, every database row a migration writes names no owner,
so `getRecoverableOwner` has nothing to judge and the ownerless branch
recovered the row straight away. Two migrations of the same database then
race: the second re-enrolled a `provisioning` row and flipped it `ready` in
its own `success()` while the first was still writing the schema, and
overwrote a `failed` row an active attempt had just recorded. The owned path
refuses both without terminal attestation.

With no owner to consult, the row's update timestamp stands in for one: a
`provisioning` or `failed` row that names no owner is recoverable only once
it has gone a whole provisioning lease without a write, and a fresher one
fails closed with an error that names the database and says when it becomes
recoverable. Staleness is checked twice, like the owner: on the row read
before the claim and again on the row locked inside the claim transaction,
so a recovery that lands in between is not taken over. Timestamps are
compared as absolute epoch seconds against the destination's own clock,
never against a source-supplied one.

The lease is a trailing `provisioningLease` constructor argument defaulting
to 86400 seconds -- Appwrite's `Claim::FINALIZING_LEASE`, the longest a live
attempt holds its lease. An ownerless row is not refreshed while its
migration imports, so a shorter default could hand a live row over. `0`
restores the immediate recovery and a negative lease is rejected. Rows
written before ownership existed are older than any lease, so they recover
as before, and rows that name an owner are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hangelog

The 3.0.0 entry described the provisioning lease from mg-05's side before
mg-07 landed the implementation, so two clauses of contract C10 were weaker
than the code and than README's recovery section: a negative lease is
rejected with an InvalidArgumentException, and a row still inside the lease
makes createDatabase() fail closed with an error that names the database.
Both matter to a caller deciding what to catch and what the message will
say, so the changelog now matches the code rather than the other way round.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…w is claimed

On destination metadata that declares `status` but no owner attributes, an
ownerless `provisioning` row whose spec already matches takes `SchemaAction::Skip`,
so the claim transaction had nothing to write and left `$updatedAt` where it was.
The lease is measured from that timestamp, so a migration arriving a second after
a recovery read the same stale row, passed both staleness checks and recovered the
database a live migration was already importing into.

The claim now always touches an ownerless row. Repeating the stored status is not
a change, so the write carries the refreshed `$updatedAt` itself; `updateOwned()`
then asserts the timestamp moved forward, which is what refuses the second claim.

README and CHANGELOG already state the rule as a lease measured from the last
write; this makes that true of the `provisioning` recovery path as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…column

Two comparisons against Appwrite's stored attribute metadata still matched the
type as a raw string. A row written as `biginteger` by this branch's dedicated
big-integer endpoint therefore never matched a desired `bigint`: the spec check
refused to skip, the in-place update refused an immutable-field change, and the
caller fell through to dropping and recreating the column, taking its data with
it. Appwrite itself accepts both spellings for the same column type, so the
destination now compares them the same way and leaves the column alone.

The type moves out of ATTRIBUTE_IMMUTABLE_FIELDS into its own normalised check;
it stays immutable — only its comparison changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The live row was seeded one second short of the 60-second lease it is checked
against, so a runner that paused between seeding and the claim would see it
lapse and the refusal it asserts would disappear. A second-old row exercises the
same boundary with the whole lease as cushion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Transfer calls it only on the latched path, when a source swallowed the abort
and run() returned; an abort that propagates straight out of run() never reaches
it. The docblock read as unconditional, which would invite a custom destination
that writes terminal state during run() to rely on a call it will not get.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 24-hour default appeared as a bare literal in the constructor, in the test
helper that mirrors it and in every seed that has to sit past it, so the four
could drift apart silently. DEFAULT_PROVISIONING_LEASE names it once and the
seeds derive from it. The literal inside the asserted error messages stays: that
half is the observable contract and should be pinned independently.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
isStale() carries the same explanation as the commit that introduced it and the
constructor's public @PARAM; a private helper does not need it a third time.
The @throws stays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/Migration/Destinations/Appwrite.php
abnegate and others added 3 commits September 25, 2026 14:46
The 3.0.0 upgrade note for custom destinations told implementers what to
override but not when the hook fires. A destination author reading only the
upgrade notes could reasonably assume markAborted() runs for every abort,
including one that propagates straight out of run(), and build cleanup that
silently never happens on that path. State the latched-abort precondition
there, matching the markAborted() docblock in src/Migration/Destination.php
and the "Added" entry earlier in the same release.

The ATTRIBUTE_IMMUTABLE_FIELDS note was a single 217-column line, unreadable
without horizontal scrolling and inconsistent with every other multi-line
docblock in the file. Wrap it under 120 columns; the wording is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The train wrote its Composer constraints as wildcards under a house rule
that has since been withdrawn, so they are carets again.

appwrite/appwrite takes main's and 2.0.7's form, ^27.0, again.
utopia-php/database, which the train moved from main's ^7.0.0 to 8, takes
the caret of that range, ^8.0. With minimum-stability dev it is still
served by the database branch's 8.0.x-dev alias until 8.0.0 is tagged.
Each caret parses to exactly the range of the wildcard it replaces.

utopia-php/storage 4.* and utopia-php/dsn 0.2.* stay as they are: they are
main's own constraints, already in 2.0.7, and the train never changed them.

The lock changes only its content-hash because every locked version
satisfies the carets (appwrite/appwrite 27.1.0, utopia-php/database
dev-feat-query-lib at 8b716e3c1c); no package moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Appwrite main now registers a Webflow OAuth2 provider, and migrating
projects that use it requires the 'webflow' allow-list entry released in
2.0.8 (#228). Merging main keeps the query-lib train compatible with
appwrite main instead of rejecting Webflow providers during migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants