Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a259e29
feat: adapt migration schema calls to query-lib value objects
abnegate Aug 14, 2026
d32d196
fix: accept query-lib biginteger as API bigint
abnegate Aug 14, 2026
47e2597
fix: create collection metadata from the persisted database sequence
abnegate Aug 14, 2026
cce7c66
(fix): allow migrating HuggingFace OAuth2 providers
abnegate Aug 21, 2026
340e6c6
chore: merge origin/main into feat-query-lib
abnegate Aug 21, 2026
0144e33
chore: pin utopia-php/database to Appwrite's feat-query-lib SHA
abnegate Aug 21, 2026
a11db20
test: stub Swoole Coroutine when the extension is missing
abnegate Aug 21, 2026
55b92c7
fix: mark databases failed when metadata reload misses
abnegate Aug 21, 2026
b67ace1
(chore): use caret ranges for Utopia dependencies
abnegate Aug 21, 2026
7a3a60e
(chore): refresh lockfile after caret Utopia constraints
abnegate Aug 21, 2026
f2de906
(feat): pass Collection to createCollection
abnegate Aug 21, 2026
3fccb0c
(fix): pass Attribute models to checkAttribute
abnegate Aug 21, 2026
3a5fb77
(fix): pass Attribute models into checkAttribute
abnegate Aug 21, 2026
18c09d4
(fix): recover failed databases on OnDuplicate::Fail retries
abnegate Aug 21, 2026
04ab893
(refactor): use Schema\Order and wrap Collection constructors
abnegate Aug 21, 2026
bf52c2c
chore(deps): drop the deleted query branch from the lock and re-pin d…
abnegate Aug 27, 2026
24e0f03
fix(destination): map index orders onto Order before building the index
abnegate Aug 27, 2026
ff6f1d5
fix(destination): let a database stranded in provisioning be retried
abnegate Aug 27, 2026
296a469
Merge branch 'main' into feat-query-lib
abnegate Aug 28, 2026
a40d6a3
(chore): align database dependency with query-lib head
abnegate Aug 29, 2026
b38b3c7
(chore): align database dependency with ready query-lib head
abnegate Aug 29, 2026
4c3a89a
(fix): prevent concurrent provisioning recovery
abnegate Aug 29, 2026
519e573
(fix): require explicit CLI provisioning recovery
abnegate Aug 29, 2026
8c2776d
(fix): require provisioning lifecycle policy
abnegate Aug 29, 2026
9d94e93
(fix): persist database provisioning owner
abnegate Aug 29, 2026
06f41d0
(fix): fence database provisioning attempts
abnegate Aug 31, 2026
dbcc441
(fix): recover terminal migration failures
abnegate Aug 31, 2026
e7d88de
(fix): require terminal migration attestation
abnegate Aug 31, 2026
3f42d20
(fix): surface database finalizer failures
abnegate Aug 31, 2026
ec7db1f
(fix): fence database migration finalization
abnegate Aug 31, 2026
97863f3
(fix): reject empty guarded database writes
abnegate Aug 31, 2026
e56b964
(fix): finalize standalone migrations
abnegate Aug 31, 2026
60b7a52
(chore): repin guarded database writes
abnegate Aug 31, 2026
1724bf3
(fix): finalize partial standalone migrations
abnegate Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ RUN set -ex \

COPY ./src /app/src
COPY ./tests /app/src/tests
COPY ./bin /app/bin

COPY --from=composer /app/vendor /app/vendor

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ $transfer->run(
);
```

## Appwrite database recovery

Appwrite database destinations use a `ProvisioningOwner` made from a stable logical migration identifier and a fresh attempt identifier for every execution. The required `getRecoverableOwner` callback is the recovery authority for an existing database whose status is `provisioning` or `failed`.

A database status is local to that resource. It does not prove that the migration attempt which owns it has stopped, because an import can continue with other resources after recording a database failure. The callback must therefore consult the caller's authoritative operation lifecycle and return the exact stored owner only after that attempt is terminal. Return `null` while it is active or unknown; recovery then fails closed. This rule also applies when the retry uses the same logical migration identifier.

The standalone CLI requires `--migration-id` and a fresh `--migration-attempt-id`. Recovering an incomplete database additionally requires both `--recover-migration-id` and `--recover-migration-attempt-id` for the exact terminal prior attempt.

## Supported Resources Chart

Sources:
Expand Down
175 changes: 145 additions & 30 deletions bin/MigrationCLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@
use Utopia\Database\Adapter\MariaDB;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Migration\Destination;
use Utopia\Migration\Destinations\Appwrite as DestinationsAppwrite;
use Utopia\Migration\Destinations\Appwrite\ProvisioningOwner;
use Utopia\Migration\Destinations\Local;
use Utopia\Migration\Source;
use Utopia\Migration\Sources\Appwrite;
use Utopia\Migration\Sources\Firebase;
use Utopia\Migration\Sources\NHost;
use Utopia\Migration\Sources\Supabase;
use Utopia\Migration\Transfer;
use Utopia\Query\Schema\ColumnType;
use Utopia\Query\Schema\IndexType;
use Utopia\Query\Schema\Order;

/**
* Migrations CLI Tool
Expand All @@ -31,14 +34,17 @@ class MigrationCLI

protected mixed $destination;

/** @var list<string> */
private readonly array $arguments;

protected const STRUCTURE = [
'$collection' => 'databases',
'$id' => 'collections',
'name' => 'Collections',
'attributes' => [
[
'$id' => 'databaseInternalId',
'type' => Database::VAR_STRING,
'type' => ColumnType::String->value,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
Expand All @@ -49,7 +55,7 @@ class MigrationCLI
],
[
'$id' => 'databaseId',
'type' => Database::VAR_STRING,
'type' => ColumnType::String->value,
'signed' => true,
'size' => Database::LENGTH_KEY,
'format' => '',
Expand All @@ -60,7 +66,7 @@ class MigrationCLI
],
[
'$id' => 'name',
'type' => Database::VAR_STRING,
'type' => ColumnType::String->value,
'size' => Database::LENGTH_KEY,
'required' => true,
'signed' => true,
Expand All @@ -69,7 +75,7 @@ class MigrationCLI
],
[
'$id' => 'enabled',
'type' => Database::VAR_BOOLEAN,
'type' => ColumnType::Boolean->value,
'signed' => true,
'size' => 0,
'format' => '',
Expand All @@ -80,7 +86,7 @@ class MigrationCLI
],
[
'$id' => 'documentSecurity',
'type' => Database::VAR_BOOLEAN,
'type' => ColumnType::Boolean->value,
'signed' => true,
'size' => 0,
'format' => '',
Expand All @@ -91,7 +97,7 @@ class MigrationCLI
],
[
'$id' => 'attributes',
'type' => Database::VAR_STRING,
'type' => ColumnType::String->value,
'size' => 1000000,
'required' => false,
'signed' => true,
Expand All @@ -100,7 +106,7 @@ class MigrationCLI
],
[
'$id' => 'indexes',
'type' => Database::VAR_STRING,
'type' => ColumnType::String->value,
'size' => 1000000,
'required' => false,
'signed' => true,
Expand All @@ -109,7 +115,7 @@ class MigrationCLI
],
[
'$id' => 'search',
'type' => Database::VAR_STRING,
'type' => ColumnType::String->value,
'format' => '',
'size' => 16384,
'signed' => true,
Expand All @@ -122,35 +128,63 @@ class MigrationCLI
'indexes' => [
[
'$id' => '_fulltext_search',
'type' => Database::INDEX_FULLTEXT,
'type' => IndexType::Fulltext->value,
'attributes' => ['search'],
'lengths' => [],
'orders' => [],
],
[
'$id' => '_key_name',
'type' => Database::INDEX_KEY,
'type' => IndexType::Key->value,
'attributes' => ['name'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
'orders' => [Order::Asc->value],
],
[
'$id' => '_key_enabled',
'type' => Database::INDEX_KEY,
'type' => IndexType::Key->value,
'attributes' => ['enabled'],
'lengths' => [],
'orders' => [Database::ORDER_ASC],
'orders' => [Order::Asc->value],
],
[
'$id' => '_key_documentSecurity',
'type' => Database::INDEX_KEY,
'type' => IndexType::Key->value,
'attributes' => ['documentSecurity'],
'lengths' => [],
'orders' => [Database::ORDER_ASC],
'orders' => [Order::Asc->value],
],
],
];

/** @param list<string> $arguments */
public function __construct(array $arguments = [])
{
$this->arguments = $arguments;
}

public static function getHelp(): string
{
return <<<'HELP'
Usage: php bin/MigrationCLI.php [options]

Options:
-h, --help Show this help.
--migration-id=<identifier> Stable logical owner identifier for this migration.
Required for Appwrite; reuse it for retries.
--migration-attempt-id=<identifier>
Required for Appwrite; use a fresh attempt for every
execution or retry.
--recover-migration-id=<prior-migration-id>
--recover-migration-attempt-id=<prior-attempt-id>
Together attest that the exact prior migration attempt is terminal
and allow recovery of its provisioning or failed databases.
Recovery is refused by default.
Resource status alone never proves an attempt terminal.

HELP;
}

/**
* Prints the current status of migrations as a table after wiping the screen
*/
Expand Down Expand Up @@ -248,12 +282,22 @@ public function getDestination(): Destination
{
switch ($_ENV['DESTINATION_PROVIDER']) {
case 'appwrite':
$database = $this->getDatabase('destination');
$recoverableOwner = $this->getRecoverableOwner();

return new DestinationsAppwrite(
$_ENV['DESTINATION_APPWRITE_TEST_PROJECT'],
$_ENV['DESTINATION_APPWRITE_TEST_ENDPOINT'],
$_ENV['DESTINATION_APPWRITE_TEST_KEY'],
$this->getDatabase('destination'),
self::STRUCTURE
project: $_ENV['DESTINATION_APPWRITE_TEST_PROJECT'],
endpoint: $_ENV['DESTINATION_APPWRITE_TEST_ENDPOINT'],
key: $_ENV['DESTINATION_APPWRITE_TEST_KEY'],
dbForProject: $database,
getDatabasesDB: static fn (Document $document): Database => $database,
collectionStructure: self::STRUCTURE,
dbForPlatform: $database,
projectInternalId: $_ENV['DESTINATION_APPWRITE_TEST_PROJECT_INTERNAL_ID'],
owner: new ProvisioningOwner($this->getMigrationId(), $this->getMigrationAttemptId()),
// The standalone operator supplies the fixed pair only after confirming the prior
// lifecycle owner is terminal. The destination independently compares it with the row.
getRecoverableOwner: static fn (Document $document): ?ProvisioningOwner => $recoverableOwner,
);
case 'local':
return new Local('./localBackup');
Expand All @@ -262,6 +306,62 @@ public function getDestination(): Destination
}
}

private function getMigrationId(): string
{
foreach ($this->arguments as $argument) {
if (! \str_starts_with($argument, '--migration-id=')) {
continue;
}

$migrationId = \substr($argument, \strlen('--migration-id='));
if ($migrationId !== '') {
return $migrationId;
}
}

throw new \InvalidArgumentException('--migration-id is required for an Appwrite destination');
}

private function getMigrationAttemptId(): string
{
foreach ($this->arguments as $argument) {
if (! \str_starts_with($argument, '--migration-attempt-id=')) {
continue;
}

$attemptId = \substr($argument, \strlen('--migration-attempt-id='));
if ($attemptId !== '') {
return $attemptId;
}
}

throw new \InvalidArgumentException('--migration-attempt-id is required for an Appwrite destination');
}

private function getRecoverableOwner(): ?ProvisioningOwner
{
$migrationId = null;
$attemptId = null;

foreach ($this->arguments as $argument) {
if (\str_starts_with($argument, '--recover-migration-id=')) {
$value = \substr($argument, \strlen('--recover-migration-id='));
$migrationId = $value !== '' ? $value : null;
}

if (\str_starts_with($argument, '--recover-migration-attempt-id=')) {
$value = \substr($argument, \strlen('--recover-migration-attempt-id='));
$attemptId = $value !== '' ? $value : null;
}
}

if ($migrationId === null || $attemptId === null) {
return null;
}

return new ProvisioningOwner($migrationId, $attemptId);
}

public function getDatabase(string $type): Database
{
Database::addFilter(
Expand All @@ -280,15 +380,15 @@ function (mixed $value, Document $document, Database $database) {
$attributeType = $attribute->getAttribute('type');

switch ($attributeType) {
case Database::VAR_RELATIONSHIP:
case ColumnType::Relationship->value:
$options = $attribute->getAttribute('options');
foreach ($options as $key => $value) {
$attribute->setAttribute($key, $value);
}
$attribute->removeAttribute('options');
break;

case Database::VAR_STRING:
case ColumnType::String->value:
$filters = $attribute->getAttribute('filters', []);
$attribute->setAttribute('encrypt', in_array('encrypt', $filters));
break;
Expand Down Expand Up @@ -394,14 +494,14 @@ function (mixed $value, Document $attribute) {
$database
->setDatabase('appwrite')
->setNamespace('_' . $_ENV[$prefix . 'NAMESPACE']);
$database->getAuthorization()->disable();

return $database;
}

public function start(): void
{
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();
$this->loadEnvironment();

/**
* Initialise All Source Adapters
Expand All @@ -423,15 +523,30 @@ public function start(): void
/**
* Run Transfer
*/
Authorization::skip(fn () => $this->transfer->run(
$this->transfer->run(
$this->source->getSupportedResources(),
function () {
$this->drawFrame();
}
));
);
Comment thread
abnegate marked this conversation as resolved.

$this->destination->success();
}

protected function loadEnvironment(): void
{
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();
}
}

$instance = new MigrationCLI();
$instance->start();
$instance->drawFrame();
if (\realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$arguments = $_SERVER['argv'] ?? [];
if (\in_array('-h', $arguments, true) || \in_array('--help', $arguments, true)) {
echo MigrationCLI::getHelp();
} else {
$instance = new MigrationCLI($arguments);
$instance->start();
$instance->drawFrame();
}
}
Loading
Loading