From 9d54d366fdf0aa42876ac485fb95a02934576f73 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Mar 2026 00:46:42 +1300 Subject: [PATCH 01/14] fix: skip orphan reconciliation for shared tables in createCollection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In shared-tables mode, physical tables are reused across tenants. When a second tenant calls createCollection and the physical table already exists, the DuplicateException should not trigger the drop-and-recreate orphan reconciliation logic — that would destroy all other tenants' data. For _metadata, re-throw the DuplicateException so Database::create() properly signals that the database already exists. For other collections, skip the physical table recreation and proceed to create the per-tenant metadata document. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 41 ++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 72480760e..4b16d873a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1793,16 +1793,29 @@ public function createCollection(string $id, array $attributes = [], array $inde try { $this->adapter->createCollection($id, $attributes, $indexes); } catch (DuplicateException $e) { - // Metadata check (above) already verified collection is absent - // from metadata. A DuplicateException from the adapter means the - // collection exists only in physical schema — an orphan from a prior - // partial failure. Drop and recreate to ensure schema matches. - try { - $this->adapter->deleteCollection($id); - } catch (NotFoundException) { - // Already removed by a concurrent reconciler. + if ($this->adapter->getSharedTables()) { + // In shared-tables mode the physical table is reused across + // tenants. A DuplicateException simply means the table already + // exists for another tenant — not an orphan. Re-throw for + // _metadata so Database::create() properly signals duplicate; + // for other collections, skip and proceed to create per-tenant + // metadata document below. + if ($id === self::METADATA) { + throw $e; + } + } else { + // Metadata check (above) already verified collection is absent + // from metadata. A DuplicateException from the adapter means + // the collection exists only in physical schema — an orphan + // from a prior partial failure. Drop and recreate to ensure + // schema matches. + try { + $this->adapter->deleteCollection($id); + } catch (NotFoundException) { + // Already removed by a concurrent reconciler. + } + $this->adapter->createCollection($id, $attributes, $indexes); } - $this->adapter->createCollection($id, $attributes, $indexes); } if ($id === self::METADATA) { @@ -1812,10 +1825,12 @@ public function createCollection(string $id, array $attributes = [], array $inde try { $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); } catch (\Throwable $e) { - try { - $this->cleanupCollection($id); - } catch (\Throwable $e) { - Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); + if (!$this->adapter->getSharedTables()) { + try { + $this->cleanupCollection($id); + } catch (\Throwable $e) { + Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); + } } throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); } From 9b86f0713e40910c20b22967e79651345d86b99c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 14:43:50 +1300 Subject: [PATCH 02/14] fix: make shared-tables createCollection idempotent for _metadata and add rollback observability - Remove _metadata DuplicateException re-throw in shared-tables mode so Database::create() works for every tenant, not just the first - Add Console::warning when metadata document creation fails in shared-tables mode to make partial state visible in logs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 4b16d873a..586ee1a60 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1796,13 +1796,7 @@ public function createCollection(string $id, array $attributes = [], array $inde if ($this->adapter->getSharedTables()) { // In shared-tables mode the physical table is reused across // tenants. A DuplicateException simply means the table already - // exists for another tenant — not an orphan. Re-throw for - // _metadata so Database::create() properly signals duplicate; - // for other collections, skip and proceed to create per-tenant - // metadata document below. - if ($id === self::METADATA) { - throw $e; - } + // exists for another tenant — not an orphan. Skip and proceed. } else { // Metadata check (above) already verified collection is absent // from metadata. A DuplicateException from the adapter means @@ -1831,6 +1825,8 @@ public function createCollection(string $id, array $attributes = [], array $inde } catch (\Throwable $e) { Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); } + } else { + Console::warning("createCollection '{$id}' metadata failed in shared-tables mode; physical table retained. Metadata document must be re-created."); } throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); } From cb46d781e019ed87d96512a31e290666335d9e09 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 15:05:06 +1300 Subject: [PATCH 03/14] fix: validate reused shared tables and add multi-tenant test coverage - Verify physical table exists before writing tenant metadata when skipping DuplicateException in shared-tables mode - Add test for multi-tenant createCollection in shared-tables v1 mode (per-tenant metadata docs) - Add test for multi-tenant Database::create() idempotency Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 6 +- tests/e2e/Adapter/Scopes/CollectionTests.php | 104 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 586ee1a60..d3331554f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1796,7 +1796,11 @@ public function createCollection(string $id, array $attributes = [], array $inde if ($this->adapter->getSharedTables()) { // In shared-tables mode the physical table is reused across // tenants. A DuplicateException simply means the table already - // exists for another tenant — not an orphan. Skip and proceed. + // exists for another tenant — not an orphan. Verify the table + // is actually present before writing tenant metadata. + if ($id !== self::METADATA && !$this->adapter->exists($this->adapter->getDatabase(), $id)) { + throw $e; + } } else { // Metadata check (above) already verified collection is absent // from metadata. A DuplicateException from the adapter means diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index ccf884f5c..5a8548f55 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1326,6 +1326,110 @@ public function testSharedTablesDuplicates(): void ->setDatabase($schema); } + public function testSharedTablesMultiTenantCreateCollection(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $sharedTables = $database->getSharedTables(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + + if (!$database->getAdapter()->getSupportForSchemas()) { + $this->expectNotToPerformAssertions(); + return; + } + + $dbName = 'stMultiTenant'; + if ($database->exists($dbName)) { + $database->setDatabase($dbName)->delete(); + } + + $tenant1 = 10; + $tenant2 = 20; + + $database + ->setDatabase($dbName) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant($tenant1) + ->create(); + + $database->createCollection('multiTenantCol', [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + ]), + ]); + + $col1 = $database->getCollection('multiTenantCol'); + $this->assertFalse($col1->isEmpty()); + $this->assertEquals(1, \count($col1->getAttribute('attributes'))); + + $database->setTenant($tenant2); + + $database->createCollection('multiTenantCol', [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + ]), + ]); + + $col2 = $database->getCollection('multiTenantCol'); + $this->assertFalse($col2->isEmpty()); + $this->assertEquals(1, \count($col2->getAttribute('attributes'))); + + $database->setTenant($tenant1); + $col1Again = $database->getCollection('multiTenantCol'); + $this->assertFalse($col1Again->isEmpty()); + + $database + ->setSharedTables($sharedTables) + ->setNamespace($namespace) + ->setDatabase($schema); + } + + public function testSharedTablesMultiTenantCreate(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $sharedTables = $database->getSharedTables(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + + if (!$database->getAdapter()->getSupportForSchemas()) { + $this->expectNotToPerformAssertions(); + return; + } + + $dbName = 'stMultiCreate'; + if ($database->exists($dbName)) { + $database->setDatabase($dbName)->delete(); + } + + $database + ->setDatabase($dbName) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(100) + ->create(); + + $this->assertTrue($database->exists($dbName)); + + $database->setTenant(200); + $database->create(); + + $this->assertTrue($database->exists($dbName)); + + $database + ->setSharedTables($sharedTables) + ->setNamespace($namespace) + ->setDatabase($schema); + } + public function testEvents(): void { $this->getDatabase()->getAuthorization()->skip(function () { From be7a66c09be2c974ac495aaba477b5f75d60fe12 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 15:13:05 +1300 Subject: [PATCH 04/14] fix: make shared-tables tests cross-adapter compatible - Use getIdAttributeType() to pick string vs int tenant values for Mongo vs SQL adapters - Run multi-tenant tests within existing shared-tables database when available (SharedTables/* test classes) - Add cleanup to prevent test pollution Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/e2e/Adapter/Scopes/CollectionTests.php | 115 ++++++++++++------- 1 file changed, 72 insertions(+), 43 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 5a8548f55..d3c754d8d 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1333,28 +1333,35 @@ public function testSharedTablesMultiTenantCreateCollection(): void $sharedTables = $database->getSharedTables(); $namespace = $database->getNamespace(); $schema = $database->getDatabase(); - - if (!$database->getAdapter()->getSupportForSchemas()) { + $originalTenant = $database->getTenant(); + $createdDb = false; + + if ($sharedTables) { + // Already in shared-tables mode (SharedTables/* test classes) + } elseif ($database->getAdapter()->getSupportForSchemas()) { + $dbName = 'stMultiTenant'; + if ($database->exists($dbName)) { + $database->setDatabase($dbName)->delete(); + } + $database + ->setDatabase($dbName) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(10) + ->create(); + $createdDb = true; + } else { $this->expectNotToPerformAssertions(); return; } - $dbName = 'stMultiTenant'; - if ($database->exists($dbName)) { - $database->setDatabase($dbName)->delete(); - } - - $tenant1 = 10; - $tenant2 = 20; + $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 10 : 'tenant_10'; + $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 20 : 'tenant_20'; + $colName = 'multiTenantCol'; - $database - ->setDatabase($dbName) - ->setNamespace('') - ->setSharedTables(true) - ->setTenant($tenant1) - ->create(); + $database->setTenant($tenant1); - $database->createCollection('multiTenantCol', [ + $database->createCollection($colName, [ new Document([ '$id' => 'name', 'type' => Database::VAR_STRING, @@ -1363,13 +1370,13 @@ public function testSharedTablesMultiTenantCreateCollection(): void ]), ]); - $col1 = $database->getCollection('multiTenantCol'); + $col1 = $database->getCollection($colName); $this->assertFalse($col1->isEmpty()); $this->assertEquals(1, \count($col1->getAttribute('attributes'))); $database->setTenant($tenant2); - $database->createCollection('multiTenantCol', [ + $database->createCollection($colName, [ new Document([ '$id' => 'name', 'type' => Database::VAR_STRING, @@ -1378,18 +1385,29 @@ public function testSharedTablesMultiTenantCreateCollection(): void ]), ]); - $col2 = $database->getCollection('multiTenantCol'); + $col2 = $database->getCollection($colName); $this->assertFalse($col2->isEmpty()); $this->assertEquals(1, \count($col2->getAttribute('attributes'))); $database->setTenant($tenant1); - $col1Again = $database->getCollection('multiTenantCol'); + $col1Again = $database->getCollection($colName); $this->assertFalse($col1Again->isEmpty()); + // Cleanup: delete per-tenant metadata docs + $database->setTenant($tenant1); + $database->deleteCollection($colName); + $database->setTenant($tenant2); + $database->deleteCollection($colName); + + if ($createdDb) { + $database->delete(); + } + $database ->setSharedTables($sharedTables) ->setNamespace($namespace) - ->setDatabase($schema); + ->setDatabase($schema) + ->setTenant($originalTenant); } public function testSharedTablesMultiTenantCreate(): void @@ -1399,35 +1417,46 @@ public function testSharedTablesMultiTenantCreate(): void $sharedTables = $database->getSharedTables(); $namespace = $database->getNamespace(); $schema = $database->getDatabase(); - - if (!$database->getAdapter()->getSupportForSchemas()) { + $originalTenant = $database->getTenant(); + $createdDb = false; + + $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 100 : 'tenant_100'; + $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 200 : 'tenant_200'; + + if ($sharedTables) { + // Already in shared-tables mode; create() should be idempotent + $database->setTenant($tenant1); + $database->create(); + $database->setTenant($tenant2); + $database->create(); + $this->assertTrue($database->exists()); + } elseif ($database->getAdapter()->getSupportForSchemas()) { + $dbName = 'stMultiCreate'; + if ($database->exists($dbName)) { + $database->setDatabase($dbName)->delete(); + } + $database + ->setDatabase($dbName) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant($tenant1) + ->create(); + $this->assertTrue($database->exists($dbName)); + $database->setTenant($tenant2); + $database->create(); + $this->assertTrue($database->exists($dbName)); + $database->delete(); + $createdDb = true; + } else { $this->expectNotToPerformAssertions(); return; } - $dbName = 'stMultiCreate'; - if ($database->exists($dbName)) { - $database->setDatabase($dbName)->delete(); - } - - $database - ->setDatabase($dbName) - ->setNamespace('') - ->setSharedTables(true) - ->setTenant(100) - ->create(); - - $this->assertTrue($database->exists($dbName)); - - $database->setTenant(200); - $database->create(); - - $this->assertTrue($database->exists($dbName)); - $database ->setSharedTables($sharedTables) ->setNamespace($namespace) - ->setDatabase($schema); + ->setDatabase($schema) + ->setTenant($originalTenant); } public function testEvents(): void From c80db70142cfdc536cfaf894b4b20b4f2c678048 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 15:26:20 +1300 Subject: [PATCH 05/14] fix: handle missing exception transforms in Mongo and Postgres adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mongo: extend createCollection pre-check to all collections in shared-tables mode, not just _metadata - Postgres: add 42P01 (Undefined table) → NotFoundException in processException - Postgres: wrap deleteCollection in try-catch with processException to match MariaDB behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Adapter/Mongo.php | 6 ++++-- src/Database/Adapter/Postgres.php | 11 ++++++++++- tests/e2e/Adapter/Scopes/CollectionTests.php | 11 +++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 789aa691a..aee1bfd47 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -414,8 +414,10 @@ public function createCollection(string $name, array $attributes = [], array $in { $id = $this->getNamespace() . '_' . $this->filter($name); - // For metadata collections outside transactions, check if exists first - if (!$this->inTransaction && $name === Database::METADATA && $this->exists($this->getNamespace(), $name)) { + // In shared-tables mode or for metadata, the physical collection may + // already exist for another tenant. Return early to avoid a + // "Collection Exists" exception from the client. + if (!$this->inTransaction && ($this->getSharedTables() || $name === Database::METADATA) && $this->exists($this->getNamespace(), $name)) { return true; } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 2af11aea3..0415e82a4 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -439,7 +439,11 @@ public function deleteCollection(string $id): bool $sql = "DROP TABLE {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')}"; $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); - return $this->getPDO()->prepare($sql)->execute(); + try { + return $this->getPDO()->prepare($sql)->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } } /** @@ -2229,6 +2233,11 @@ protected function processException(PDOException $e): \Exception return new LimitException('Datetime field overflow', $e->getCode(), $e); } + // Unknown table + if ($e->getCode() === '42P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new NotFoundException('Collection not found', $e->getCode(), $e); + } + // Unknown column if ($e->getCode() === "42703" && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { return new NotFoundException('Attribute not found', $e->getCode(), $e); diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index d3c754d8d..c12d0bf78 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1393,14 +1393,13 @@ public function testSharedTablesMultiTenantCreateCollection(): void $col1Again = $database->getCollection($colName); $this->assertFalse($col1Again->isEmpty()); - // Cleanup: delete per-tenant metadata docs - $database->setTenant($tenant1); - $database->deleteCollection($colName); - $database->setTenant($tenant2); - $database->deleteCollection($colName); - if ($createdDb) { $database->delete(); + } else { + $database->setTenant($tenant1); + $database->deleteCollection($colName); + $database->setTenant($tenant2); + $database->deleteCollection($colName); } $database From 3e4aef14a95ab610ae99f9f8bbf3cb053e3391bb Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 15:34:44 +1300 Subject: [PATCH 06/14] fix: handle table already exists and no such table in SQLite processException Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Adapter/SQLite.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 12f2406f4..161aab17a 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1312,6 +1312,16 @@ protected function processException(PDOException $e): \Exception return new TimeoutException('Query timed out', $e->getCode(), $e); } + // Table/index already exists (SQLITE_ERROR with "already exists" message) + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'already exists') !== false) { + return new DuplicateException('Collection already exists', $e->getCode(), $e); + } + + // Table not found (SQLITE_ERROR with "no such table" message) + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'no such table') !== false) { + return new NotFoundException('Collection not found', $e->getCode(), $e); + } + // Duplicate - SQLite uses various error codes for constraint violations: // - Error code 19 is SQLITE_CONSTRAINT (includes UNIQUE violations) // - Error code 1 is also used for some duplicate cases @@ -1320,7 +1330,6 @@ protected function processException(PDOException $e): \Exception ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1 || $e->errorInfo[1] === 19)) || $e->getCode() === '23000' ) { - // Check if it's actually a duplicate/unique constraint violation $message = $e->getMessage(); if ( (isset($e->errorInfo[1]) && $e->errorInfo[1] === 19) || From 602f8deef7c07e224573eb7e7ef22d9cfd9588b2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 15:40:25 +1300 Subject: [PATCH 07/14] fix: handle SQLite exists() returning false for database-level check Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/e2e/Adapter/Scopes/CollectionTests.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index c12d0bf78..2a383b6e1 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1423,12 +1423,14 @@ public function testSharedTablesMultiTenantCreate(): void $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 200 : 'tenant_200'; if ($sharedTables) { - // Already in shared-tables mode; create() should be idempotent + // Already in shared-tables mode; create() should be idempotent. + // No assertion on exists() since SQLite always returns false for + // database-level exists. The test verifies create() doesn't throw. $database->setTenant($tenant1); $database->create(); $database->setTenant($tenant2); $database->create(); - $this->assertTrue($database->exists()); + $this->assertTrue(true); } elseif ($database->getAdapter()->getSupportForSchemas()) { $dbName = 'stMultiCreate'; if ($database->exists($dbName)) { From 10cf13d0dcfcd2aee13880945e5bc04fbbdbfcf1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 17:40:36 +1300 Subject: [PATCH 08/14] fix: handle client-level Collection Exists exception in Mongo processException The Mongo client throws Exception('Collection Exists') with code 0 rather than using MongoDB's native error code 48. This was only caught by the pre-check, leaving the in-transaction path unhandled. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Adapter/Mongo.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index aee1bfd47..8db98a950 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3520,8 +3520,8 @@ protected function processException(\Throwable $e): \Throwable return new DuplicateException('Document already exists', $e->getCode(), $e); } - // Collection already exists - if ($e->getCode() === 48) { + // Collection already exists (code 48 from MongoDB, code 0 from client pre-check) + if ($e->getCode() === 48 || ($e->getCode() === 0 && stripos($e->getMessage(), 'Collection Exists') !== false)) { return new DuplicateException('Collection already exists', $e->getCode(), $e); } From 20ba2531ad2129a05c3f3cf116b4cedb5fd58a2e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 17:52:52 +1300 Subject: [PATCH 09/14] fix: uniform orphan recovery and rollback across all table modes - DuplicateException with table missing: do orphan recovery (drop + recreate) in all modes, not just per-tenant - Track whether we created the physical table; only rollback on metadata failure if we own the table, regardless of shared-tables mode - Removes mode-specific branching in the rollback path Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index d3331554f..5ffc9e894 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1790,17 +1790,17 @@ public function createCollection(string $id, array $attributes = [], array $inde } } + $createdPhysicalTable = false; + try { $this->adapter->createCollection($id, $attributes, $indexes); + $createdPhysicalTable = true; } catch (DuplicateException $e) { - if ($this->adapter->getSharedTables()) { + if ($this->adapter->getSharedTables() + && ($id === self::METADATA || $this->adapter->exists($this->adapter->getDatabase(), $id))) { // In shared-tables mode the physical table is reused across // tenants. A DuplicateException simply means the table already - // exists for another tenant — not an orphan. Verify the table - // is actually present before writing tenant metadata. - if ($id !== self::METADATA && !$this->adapter->exists($this->adapter->getDatabase(), $id)) { - throw $e; - } + // exists for another tenant — not an orphan. } else { // Metadata check (above) already verified collection is absent // from metadata. A DuplicateException from the adapter means @@ -1813,6 +1813,7 @@ public function createCollection(string $id, array $attributes = [], array $inde // Already removed by a concurrent reconciler. } $this->adapter->createCollection($id, $attributes, $indexes); + $createdPhysicalTable = true; } } @@ -1823,14 +1824,12 @@ public function createCollection(string $id, array $attributes = [], array $inde try { $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); } catch (\Throwable $e) { - if (!$this->adapter->getSharedTables()) { + if ($createdPhysicalTable) { try { $this->cleanupCollection($id); } catch (\Throwable $e) { Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); } - } else { - Console::warning("createCollection '{$id}' metadata failed in shared-tables mode; physical table retained. Metadata document must be re-created."); } throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); } From 038cd5663849b641c87240ed4b8baaa26ac8c42b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 18:22:18 +1300 Subject: [PATCH 10/14] fix: add getSchemaIndexes and validate orphan indexes in createIndex Adds getSchemaIndexes() to match the existing getSchemaAttributes() pattern. When an index exists in physical schema but not metadata (orphan), createIndex now validates the physical definition matches. Mismatched orphans are dropped and recreated. - Adapter: abstract getSchemaIndexes() + getSupportForSchemaIndexes() - MariaDB: queries INFORMATION_SCHEMA.STATISTICS - All others: default stubs (empty array / false) - Database::createIndex: validates orphan index shape before reuse Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Adapter.php | 18 +++++++++++ src/Database/Adapter/MariaDB.php | 52 ++++++++++++++++++++++++++++++ src/Database/Adapter/Mongo.php | 10 ++++++ src/Database/Adapter/Pool.php | 10 ++++++ src/Database/Adapter/Postgres.php | 5 +++ src/Database/Adapter/SQL.php | 10 ++++++ src/Database/Adapter/SQLite.php | 5 +++ src/Database/Database.php | 53 +++++++++++++++++++++++++------ 8 files changed, 154 insertions(+), 9 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 4e25c8f81..a0c1c238a 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -958,6 +958,13 @@ abstract public function getSupportForAttributes(): bool; */ abstract public function getSupportForSchemaAttributes(): bool; + /** + * Are schema indexes supported? + * + * @return bool + */ + abstract public function getSupportForSchemaIndexes(): bool; + /** * Is index supported? * @@ -1365,6 +1372,17 @@ abstract public function getInternalIndexesKeys(): array; */ abstract public function getSchemaAttributes(string $collection): array; + /** + * Get Schema Indexes + * + * Returns physical index definitions from the database schema. + * + * @param string $collection + * @return array + * @throws DatabaseException + */ + abstract public function getSchemaIndexes(string $collection): array; + /** * Get the expected column type for a given attribute type. * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 1bd8797c9..223f91e71 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1845,6 +1845,58 @@ public function getSupportForSchemaAttributes(): bool return true; } + public function getSupportForSchemaIndexes(): bool + { + return true; + } + + public function getSchemaIndexes(string $collection): array + { + $schema = $this->getDatabase(); + $collection = $this->getNamespace() . '_' . $this->filter($collection); + + try { + $stmt = $this->getPDO()->prepare(' + SELECT + INDEX_NAME as indexName, + COLUMN_NAME as columnName, + NON_UNIQUE as nonUnique, + SEQ_IN_INDEX as seqInIndex, + INDEX_TYPE as indexType, + SUB_PART as subPart + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table + ORDER BY INDEX_NAME, SEQ_IN_INDEX + '); + $stmt->bindParam(':schema', $schema); + $stmt->bindParam(':table', $collection); + $stmt->execute(); + $rows = $stmt->fetchAll(); + $stmt->closeCursor(); + + $grouped = []; + foreach ($rows as $row) { + $name = $row['indexName']; + if (!isset($grouped[$name])) { + $grouped[$name] = [ + '$id' => $name, + 'indexName' => $name, + 'indexType' => $row['indexType'], + 'nonUnique' => (int)$row['nonUnique'], + 'columns' => [], + 'lengths' => [], + ]; + } + $grouped[$name]['columns'][] = $row['columnName']; + $grouped[$name]['lengths'][] = $row['subPart'] !== null ? (int)$row['subPart'] : null; + } + + return \array_map(fn ($idx) => new Document($idx), \array_values($grouped)); + } catch (PDOException $e) { + throw new DatabaseException('Failed to get schema indexes', $e->getCode(), $e); + } + } + /** * Set max execution time * @param int $milliseconds diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 8db98a950..7663da585 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3601,6 +3601,16 @@ public function getSchemaAttributes(string $collection): array return []; } + public function getSupportForSchemaIndexes(): bool + { + return false; + } + + public function getSchemaIndexes(string $collection): array + { + return []; + } + /** * @param string $collection * @param array $tenants diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 3128d97ed..e89be89ac 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -563,6 +563,16 @@ public function getSchemaAttributes(string $collection): array return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForSchemaIndexes(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSchemaIndexes(string $collection): array + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getTenantQuery(string $collection, string $alias = ''): string { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 0415e82a4..8dcf72025 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2146,6 +2146,11 @@ public function getSupportForSchemaAttributes(): bool return false; } + public function getSupportForSchemaIndexes(): bool + { + return false; + } + public function getSupportForUpserts(): bool { return true; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index fb949dfa4..8f0bd2db2 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2328,6 +2328,16 @@ public function getSchemaAttributes(string $collection): array return []; } + public function getSchemaIndexes(string $collection): array + { + return []; + } + + public function getSupportForSchemaIndexes(): bool + { + return false; + } + public function getTenantQuery( string $collection, string $alias = '', diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 161aab17a..3c25987eb 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -974,6 +974,11 @@ public function getSupportForSchemaAttributes(): bool return false; } + public function getSupportForSchemaIndexes(): bool + { + return false; + } + /** * Is upsert supported? * diff --git a/src/Database/Database.php b/src/Database/Database.php index 5ffc9e894..75df52a7a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4574,18 +4574,48 @@ public function createIndex(string $collection, string $id, string $type, array } $created = false; + $existsInSchema = false; - try { - $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes, [], $ttl); + if ($this->adapter->getSupportForSchemaIndexes()) { + $schemaIndexes = $this->getSchemaIndexes($collection->getId()); + $filteredId = $this->adapter->filter($id); - if (!$created) { - throw new DatabaseException('Failed to create index'); + foreach ($schemaIndexes as $schemaIndex) { + if (\strtolower($schemaIndex->getId()) === \strtolower($filteredId)) { + $schemaColumns = $schemaIndex->getAttribute('columns', []); + $schemaLengths = $schemaIndex->getAttribute('lengths', []); + + $filteredAttributes = \array_map(fn ($a) => $this->adapter->filter($a), $attributes); + $match = ($schemaColumns === $filteredAttributes && $schemaLengths === $lengths); + + if ($match) { + $existsInSchema = true; + } else { + // Orphan index with wrong definition — drop so it + // gets recreated with the correct shape. + try { + $this->adapter->deleteIndex($collection->getId(), $id); + } catch (NotFoundException) { + } + } + break; + } + } + } + + if (!$existsInSchema) { + try { + $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes, [], $ttl); + + if (!$created) { + throw new DatabaseException('Failed to create index'); + } + } catch (DuplicateException) { + // Metadata check (lines above) already verified index is absent + // from metadata. A DuplicateException from the adapter means the + // index exists only in physical schema — an orphan from a prior + // partial failure. Skip creation and proceed to metadata update. } - } catch (DuplicateException $e) { - // Metadata check (lines above) already verified index is absent - // from metadata. A DuplicateException from the adapter means the - // index exists only in physical schema — an orphan from a prior - // partial failure. Skip creation and proceed to metadata update. } $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); @@ -9220,6 +9250,11 @@ public function getSchemaAttributes(string $collection): array return $this->adapter->getSchemaAttributes($collection); } + public function getSchemaIndexes(string $collection): array + { + return $this->adapter->getSchemaIndexes($collection); + } + /** * @param string $collectionId * @param string|null $documentId From 3760d542e64cec03978f08ab7708f4c34510c1c1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 18:29:14 +1300 Subject: [PATCH 11/14] fix: add phpstan return type annotation to getSchemaIndexes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 75df52a7a..52085ba70 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9250,6 +9250,10 @@ public function getSchemaAttributes(string $collection): array return $this->adapter->getSchemaAttributes($collection); } + /** + * @param string $collection + * @return array + */ public function getSchemaIndexes(string $collection): array { return $this->adapter->getSchemaIndexes($collection); From 76d46b81b7066b4c4ee0794ef5e11f60476db4f7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 19:04:00 +1300 Subject: [PATCH 12/14] fix: only swallow client-level Collection Exists for shared-table/metadata paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code-0 "Collection Exists" from the Mongo client was being converted to DuplicateException in processException, which the local catch in createCollection silently returned true for — bypassing Database::createCollection()'s orphan reconciliation in non-shared mode. Move the handling into createCollection itself where context is available: return true for shared-tables/metadata, throw DuplicateException otherwise so the caller can reconcile. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Adapter/Mongo.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 7663da585..a61a59c3a 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -430,6 +430,16 @@ public function createCollection(string $name, array $attributes = [], array $in if ($e instanceof DuplicateException) { return true; } + // Client throws code-0 "Collection Exists" when its pre-check + // finds the collection. In shared-tables/metadata context this + // is a no-op; otherwise re-throw as DuplicateException so + // Database::createCollection() can run orphan reconciliation. + if ($e->getCode() === 0 && stripos($e->getMessage(), 'Collection Exists') !== false) { + if ($this->getSharedTables() || $name === Database::METADATA) { + return true; + } + throw new DuplicateException('Collection already exists', $e->getCode(), $e); + } throw $e; } @@ -3520,8 +3530,8 @@ protected function processException(\Throwable $e): \Throwable return new DuplicateException('Document already exists', $e->getCode(), $e); } - // Collection already exists (code 48 from MongoDB, code 0 from client pre-check) - if ($e->getCode() === 48 || ($e->getCode() === 0 && stripos($e->getMessage(), 'Collection Exists') !== false)) { + // Collection already exists + if ($e->getCode() === 48) { return new DuplicateException('Collection already exists', $e->getCode(), $e); } From 93567b681c86c21d0f9708b92976a2d845c49036 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 19:10:04 +1300 Subject: [PATCH 13/14] fix: wrap shared-tables tests in try/finally to prevent state leaks Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/e2e/Adapter/Scopes/CollectionTests.php | 162 ++++++++++--------- 1 file changed, 82 insertions(+), 80 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 2a383b6e1..f2487c197 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1355,58 +1355,60 @@ public function testSharedTablesMultiTenantCreateCollection(): void return; } - $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 10 : 'tenant_10'; - $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 20 : 'tenant_20'; - $colName = 'multiTenantCol'; - - $database->setTenant($tenant1); + try { + $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 10 : 'tenant_10'; + $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 20 : 'tenant_20'; + $colName = 'multiTenantCol'; - $database->createCollection($colName, [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => true, - ]), - ]); + $database->setTenant($tenant1); - $col1 = $database->getCollection($colName); - $this->assertFalse($col1->isEmpty()); - $this->assertEquals(1, \count($col1->getAttribute('attributes'))); + $database->createCollection($colName, [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + ]), + ]); - $database->setTenant($tenant2); + $col1 = $database->getCollection($colName); + $this->assertFalse($col1->isEmpty()); + $this->assertEquals(1, \count($col1->getAttribute('attributes'))); - $database->createCollection($colName, [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => true, - ]), - ]); + $database->setTenant($tenant2); - $col2 = $database->getCollection($colName); - $this->assertFalse($col2->isEmpty()); - $this->assertEquals(1, \count($col2->getAttribute('attributes'))); + $database->createCollection($colName, [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + ]), + ]); - $database->setTenant($tenant1); - $col1Again = $database->getCollection($colName); - $this->assertFalse($col1Again->isEmpty()); + $col2 = $database->getCollection($colName); + $this->assertFalse($col2->isEmpty()); + $this->assertEquals(1, \count($col2->getAttribute('attributes'))); - if ($createdDb) { - $database->delete(); - } else { $database->setTenant($tenant1); - $database->deleteCollection($colName); - $database->setTenant($tenant2); - $database->deleteCollection($colName); - } + $col1Again = $database->getCollection($colName); + $this->assertFalse($col1Again->isEmpty()); - $database - ->setSharedTables($sharedTables) - ->setNamespace($namespace) - ->setDatabase($schema) - ->setTenant($originalTenant); + if ($createdDb) { + $database->delete(); + } else { + $database->setTenant($tenant1); + $database->deleteCollection($colName); + $database->setTenant($tenant2); + $database->deleteCollection($colName); + } + } finally { + $database + ->setSharedTables($sharedTables) + ->setNamespace($namespace) + ->setDatabase($schema) + ->setTenant($originalTenant); + } } public function testSharedTablesMultiTenantCreate(): void @@ -1417,47 +1419,47 @@ public function testSharedTablesMultiTenantCreate(): void $namespace = $database->getNamespace(); $schema = $database->getDatabase(); $originalTenant = $database->getTenant(); - $createdDb = false; - $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 100 : 'tenant_100'; - $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 200 : 'tenant_200'; - - if ($sharedTables) { - // Already in shared-tables mode; create() should be idempotent. - // No assertion on exists() since SQLite always returns false for - // database-level exists. The test verifies create() doesn't throw. - $database->setTenant($tenant1); - $database->create(); - $database->setTenant($tenant2); - $database->create(); - $this->assertTrue(true); - } elseif ($database->getAdapter()->getSupportForSchemas()) { - $dbName = 'stMultiCreate'; - if ($database->exists($dbName)) { - $database->setDatabase($dbName)->delete(); + try { + $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 100 : 'tenant_100'; + $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 200 : 'tenant_200'; + + if ($sharedTables) { + // Already in shared-tables mode; create() should be idempotent. + // No assertion on exists() since SQLite always returns false for + // database-level exists. The test verifies create() doesn't throw. + $database->setTenant($tenant1); + $database->create(); + $database->setTenant($tenant2); + $database->create(); + $this->assertTrue(true); + } elseif ($database->getAdapter()->getSupportForSchemas()) { + $dbName = 'stMultiCreate'; + if ($database->exists($dbName)) { + $database->setDatabase($dbName)->delete(); + } + $database + ->setDatabase($dbName) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant($tenant1) + ->create(); + $this->assertTrue($database->exists($dbName)); + $database->setTenant($tenant2); + $database->create(); + $this->assertTrue($database->exists($dbName)); + $database->delete(); + } else { + $this->expectNotToPerformAssertions(); + return; } + } finally { $database - ->setDatabase($dbName) - ->setNamespace('') - ->setSharedTables(true) - ->setTenant($tenant1) - ->create(); - $this->assertTrue($database->exists($dbName)); - $database->setTenant($tenant2); - $database->create(); - $this->assertTrue($database->exists($dbName)); - $database->delete(); - $createdDb = true; - } else { - $this->expectNotToPerformAssertions(); - return; + ->setSharedTables($sharedTables) + ->setNamespace($namespace) + ->setDatabase($schema) + ->setTenant($originalTenant); } - - $database - ->setSharedTables($sharedTables) - ->setNamespace($namespace) - ->setDatabase($schema) - ->setTenant($originalTenant); } public function testEvents(): void From 3be72b7b6fa25743b53ec41a037b672bee22bad9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 19 Mar 2026 19:49:33 +1300 Subject: [PATCH 14/14] fix: skip schema index validation during shared-tables migration Match the attribute validator's behavior: when isMigrating is true in shared-tables mode, skip the schema index check so indexes are always created fresh during migration rather than being matched against or dropping existing physical indexes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 52085ba70..ffb0ff4da 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4576,7 +4576,8 @@ public function createIndex(string $collection, string $id, string $type, array $created = false; $existsInSchema = false; - if ($this->adapter->getSupportForSchemaIndexes()) { + if ($this->adapter->getSupportForSchemaIndexes() + && !($this->adapter->getSharedTables() && $this->isMigrating())) { $schemaIndexes = $this->getSchemaIndexes($collection->getId()); $filteredId = $this->adapter->filter($id);