tests: pin two-way recreate partner-side dedup + bump migration

testAppwriteMigrationUpsertTwoWayRecreateSkipsPartnerSide exercises
the DropAndRecreate path on a two-way relationship that the partner-
side pair-key dedup guards. Source recreates the relationship between
runs, forcing parent-side createdAt diff. Test asserts the migration
completes cleanly and partner-table rows survive — without dedup, the
partner pass re-fires DropAndRecreate and destroys those rows.

composer.lock: utopia-php/migration c76de9a -> c13e77d (partner-side
pair-key dedup restored).
This commit is contained in:
Prem Palanisamy
2026-04-27 14:45:39 +01:00
parent dfde1be035
commit c80c747e48
2 changed files with 158 additions and 4 deletions
Generated
+4 -4
View File
@@ -4532,12 +4532,12 @@
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "c76de9acffa7fddac7cb1e969ebde63c053abcd3"
"reference": "c13e77d562e775b66ab8c03ee5c8163e53c3067e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/c76de9acffa7fddac7cb1e969ebde63c053abcd3",
"reference": "c76de9acffa7fddac7cb1e969ebde63c053abcd3",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/c13e77d562e775b66ab8c03ee5c8163e53c3067e",
"reference": "c13e77d562e775b66ab8c03ee5c8163e53c3067e",
"shasum": ""
},
"require": {
@@ -4579,7 +4579,7 @@
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates"
},
"time": "2026-04-27T12:57:33+00:00"
"time": "2026-04-27T13:42:58+00:00"
},
{
"name": "utopia-php/mongo",
@@ -1621,6 +1621,160 @@ trait MigrationsBase
self::$cachedTableData = [];
}
/**
* Two-way DropAndRecreate path: source recreates the relationship between
* runs (createdAt diff). Without partner-side pair-key dedup, the second
* createField pass for the partner can re-fire DropAndRecreate after the
* first side already reconciled both physical columns, destroying rows
* already migrated to the partner table this run.
*
* Asserts: migration completes, both sides exist on dest, child rows
* referencing the parent are preserved end-to-end.
*/
public function testAppwriteMigrationUpsertTwoWayRecreateSkipsPartnerSide(): void
{
$sourceHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
];
$destHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
];
$databaseId = ID::unique();
$createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
'databaseId' => $databaseId,
'name' => 'Two-Way Recreate DB',
]);
$this->assertEquals(201, $createDb['headers']['status-code']);
foreach (['parents', 'children'] as $tbl) {
$createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
'tableId' => $tbl,
'name' => $tbl,
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $createTable['headers']['status-code']);
}
$createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
'relatedTableId' => 'children',
'type' => Database::RELATION_ONE_TO_MANY,
'twoWay' => true,
'key' => 'kids',
'twoWayKey' => 'parent',
'onDelete' => Database::RELATION_MUTATE_CASCADE,
]);
$this->assertEquals(202, $createRel['headers']['status-code']);
$this->assertEventually(function () use ($databaseId, $sourceHeaders) {
$r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
$this->assertEquals(200, $r['headers']['status-code']);
$this->assertEquals('available', $r['body']['status']);
}, 10000, 500);
$parentRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/rows', $sourceHeaders, [
'rowId' => 'parent-1',
'data' => [],
]);
$this->assertEquals(201, $parentRow['headers']['status-code']);
$childRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/children/rows', $sourceHeaders, [
'rowId' => 'child-1',
'data' => ['parent' => 'parent-1'],
]);
$this->assertEquals(201, $childRow['headers']['status-code']);
$resources = [
Resource::TYPE_DATABASE,
Resource::TYPE_TABLE,
Resource::TYPE_COLUMN,
Resource::TYPE_ROW,
];
$first = $this->performMigrationSync([
'resources' => $resources,
'endpoint' => $this->webEndpoint,
'projectId' => $this->getProject()['$id'],
'apiKey' => $this->getProject()['apiKey'],
]);
$this->assertEquals('completed', $first['status']);
// Recreate the relationship on source so its createdAt advances past
// dest's stored value — forces SchemaAction::DropAndRecreate on the
// parent side, which is the path the partner-side dedup guards.
sleep(1);
$deleteRel = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
$this->assertEquals(204, $deleteRel['headers']['status-code']);
$this->assertEventually(function () use ($databaseId, $sourceHeaders) {
$r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
$this->assertEquals(404, $r['headers']['status-code']);
}, 10000, 500);
sleep(1);
$recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
'relatedTableId' => 'children',
'type' => Database::RELATION_ONE_TO_MANY,
'twoWay' => true,
'key' => 'kids',
'twoWayKey' => 'parent',
'onDelete' => Database::RELATION_MUTATE_CASCADE,
]);
$this->assertEquals(202, $recreate['headers']['status-code']);
$this->assertEventually(function () use ($databaseId, $sourceHeaders) {
$r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
$this->assertEquals(200, $r['headers']['status-code']);
$this->assertEquals('available', $r['body']['status']);
}, 10000, 500);
// Child-row's relationship was wiped by the source-side delete. Re-link.
$relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $sourceHeaders, [
'data' => ['parent' => 'parent-1'],
]);
$this->assertEquals(200, $relink['headers']['status-code']);
$upsertResult = $this->performMigrationSync([
'resources' => $resources,
'endpoint' => $this->webEndpoint,
'projectId' => $this->getProject()['$id'],
'apiKey' => $this->getProject()['apiKey'],
'onDuplicate' => 'upsert',
]);
$this->assertEquals('completed', $upsertResult['status']);
$this->assertEventually(function () use ($databaseId, $destHeaders) {
$parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
$this->assertEquals(200, $parent['headers']['status-code']);
$this->assertEquals('available', $parent['body']['status']);
$child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
$this->assertEquals(200, $child['headers']['status-code']);
$this->assertEquals('available', $child['body']['status']);
}, 10000, 500);
// Both rows survive the re-migration. If the partner-side dedup were
// missing and the partner pass re-fired DropAndRecreate, the partner
// (children) table's row would have been wiped before the row pass.
$destChild = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $destHeaders);
$this->assertEquals(200, $destChild['headers']['status-code'], 'partner-table row must survive two-way recreate re-migration');
$this->assertEquals('parent-1', $destChild['body']['parent']['$id'] ?? $destChild['body']['parent'], 'partner-table row relationship must point to the migrated parent');
$this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
$this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
self::$cachedDatabaseData = [];
self::$cachedTableData = [];
}
/**
* Storage
*/