From fa3b6ffa093ad78b34ae2ec319902d41b3fff1d2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 22:09:54 +1300 Subject: [PATCH 1/9] Fix partial failure recreate --- src/Database/Database.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 32682716a..b73f0b9e2 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1799,7 +1799,10 @@ public function createCollection(string $id, array $attributes = [], array $inde // 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. Skip creation and proceed to metadata creation. + // partial failure. Drop and recreate to ensure schema matches. + $this->adapter->deleteCollection($id); + $this->adapter->createCollection($id, $attributes, $indexes); + $created = true; } if ($id === self::METADATA) { From b9727f2a6c0d5f3795c31e64b6c508320429cbc4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 22:28:10 +1300 Subject: [PATCH 2/9] fix: address review comments and fix test failures - Wrap deleteCollection in try/catch for NotFoundException in duplicate recovery flow - Remove dead $created condition (always true, flagged by PHPStan) - Skip Sequence validation for $tenant attribute - Use loose comparison for tenant checks to handle type mismatches Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 32 +++++++++++++--------------- src/Database/Validator/Structure.php | 3 +++ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index b73f0b9e2..12ae1cb6f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1790,19 +1790,19 @@ public function createCollection(string $id, array $attributes = [], array $inde } } - $created = false; - try { $this->adapter->createCollection($id, $attributes, $indexes); - $created = true; } 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. - $this->adapter->deleteCollection($id); + try { + $this->adapter->deleteCollection($id); + } catch (NotFoundException) { + // Already removed by a concurrent reconciler. + } $this->adapter->createCollection($id, $attributes, $indexes); - $created = true; } if ($id === self::METADATA) { @@ -1812,12 +1812,10 @@ public function createCollection(string $id, array $attributes = [], array $inde try { $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); } catch (\Throwable $e) { - if ($created) { - try { - $this->cleanupCollection($id); - } catch (\Throwable $e) { - Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); - } + 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); } @@ -1859,7 +1857,7 @@ public function updateCollection(string $id, array $permissions, bool $documentS if ( $this->adapter->getSharedTables() - && $collection->getTenant() !== $this->adapter->getTenant() + && $collection->getTenant() != $this->adapter->getTenant() ) { throw new NotFoundException('Collection not found'); } @@ -1895,7 +1893,7 @@ public function getCollection(string $id): Document $id !== self::METADATA && $this->adapter->getSharedTables() && $collection->getTenant() !== null - && $collection->getTenant() !== $this->adapter->getTenant() + && $collection->getTenant() != $this->adapter->getTenant() ) { return new Document(); } @@ -1950,7 +1948,7 @@ public function getSizeOfCollection(string $collection): int throw new NotFoundException('Collection not found'); } - if ($this->adapter->getSharedTables() && $collection->getTenant() !== $this->adapter->getTenant()) { + if ($this->adapter->getSharedTables() && $collection->getTenant() != $this->adapter->getTenant()) { throw new NotFoundException('Collection not found'); } @@ -1976,7 +1974,7 @@ public function getSizeOfCollectionOnDisk(string $collection): int throw new NotFoundException('Collection not found'); } - if ($this->adapter->getSharedTables() && $collection->getTenant() !== $this->adapter->getTenant()) { + if ($this->adapter->getSharedTables() && $collection->getTenant() != $this->adapter->getTenant()) { throw new NotFoundException('Collection not found'); } @@ -2010,7 +2008,7 @@ public function deleteCollection(string $id): bool throw new NotFoundException('Collection not found'); } - if ($this->adapter->getSharedTables() && $collection->getTenant() !== $this->adapter->getTenant()) { + if ($this->adapter->getSharedTables() && $collection->getTenant() != $this->adapter->getTenant()) { throw new NotFoundException('Collection not found'); } @@ -7204,7 +7202,7 @@ public function upsertDocumentsWithIncrease( if ($document->getTenant() === null) { throw new DatabaseException('Missing tenant. Tenant must be set when tenant per document is enabled.'); } - if (!$old->isEmpty() && $old->getTenant() !== $document->getTenant()) { + if (!$old->isEmpty() && $old->getTenant() != $document->getTenant()) { throw new DatabaseException('Tenant cannot be changed.'); } } else { diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index a65734dbd..9644ac1d5 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -340,6 +340,9 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) switch ($type) { case Database::VAR_ID: + if ($attribute['$id'] === '$tenant') { + break; + } $validators[] = new Sequence($this->idAttributeType, $attribute['$id'] === '$sequence'); break; From 9aa420600a6b65ccc2eb42ad5ae77c4d9b26786e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 22:34:52 +1300 Subject: [PATCH 3/9] fix: use raw tenant attribute to avoid type mismatch in update comparison getTenant() casts numeric strings to int, but the no-change comparison uses strict equality. This caused false shouldUpdate=true, triggering authorization checks on documents with only read permissions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 12ae1cb6f..9d6783bd4 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6055,7 +6055,7 @@ public function updateDocument(string $collection, string $id, Document $documen $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; if ($this->adapter->getSharedTables()) { - $document['$tenant'] = $old->getTenant(); // Make sure user doesn't switch tenant + $document['$tenant'] = $old->getAttribute('$tenant'); // Make sure user doesn't switch tenant } $document = new Document($document); From e7edc9c675448f901f1579e965fdb849eb59b066 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 22:43:15 +1300 Subject: [PATCH 4/9] fix: allow int|string|null in Sequence validator and use loose comparison Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 5 ++--- src/Database/Validator/Sequence.php | 10 +++++++--- src/Database/Validator/Structure.php | 3 --- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 9d6783bd4..5e8e55f6e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6055,7 +6055,7 @@ public function updateDocument(string $collection, string $id, Document $documen $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; if ($this->adapter->getSharedTables()) { - $document['$tenant'] = $old->getAttribute('$tenant'); // Make sure user doesn't switch tenant + $document['$tenant'] = $old->getTenant(); // Make sure user doesn't switch tenant } $document = new Document($document); @@ -6160,8 +6160,7 @@ public function updateDocument(string $collection, string $id, Document $documen $oldValue = $old->getAttribute($key); - // If values are not equal we need to update document. - if ($value !== $oldValue) { + if ($value != $oldValue) { $shouldUpdate = true; break; } diff --git a/src/Database/Validator/Sequence.php b/src/Database/Validator/Sequence.php index d528cc4ea..c45be3642 100644 --- a/src/Database/Validator/Sequence.php +++ b/src/Database/Validator/Sequence.php @@ -41,13 +41,17 @@ public function isValid($value): bool return false; } - if (!\is_string($value)) { + if ($value === null) { + return true; + } + + if (!\is_string($value) && !\is_int($value)) { return false; } switch ($this->idAttributeType) { - case Database::VAR_UUID7: //UUID7 - return preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1; + case Database::VAR_UUID7: + return \is_string($value) && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1; case Database::VAR_INTEGER: $start = ($this->primary) ? 1 : 0; $validator = new Range($start, Database::MAX_BIG_INT, Database::VAR_INTEGER); diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index 9644ac1d5..a65734dbd 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -340,9 +340,6 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) switch ($type) { case Database::VAR_ID: - if ($attribute['$id'] === '$tenant') { - break; - } $validators[] = new Sequence($this->idAttributeType, $attribute['$id'] === '$sequence'); break; From e135e51f35023c6b1db9557159b6fb081ae63486 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 22:57:36 +1300 Subject: [PATCH 5/9] fix: use loose comparison only for VAR_ID type attributes in update check Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 5e8e55f6e..380827a32 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6059,10 +6059,19 @@ public function updateDocument(string $collection, string $id, Document $documen } $document = new Document($document); - $relationships = \array_filter($collection->getAttribute('attributes', []), function ($attribute) { + $attributes = $collection->getAttribute('attributes', []); + + $relationships = \array_filter($attributes, function ($attribute) { return $attribute['type'] === Database::VAR_RELATIONSHIP; }); + $idAttributes = []; + foreach (\array_merge(self::INTERNAL_ATTRIBUTES, $attributes) as $attribute) { + if ($attribute['type'] === Database::VAR_ID) { + $idAttributes[$attribute['$id']] = true; + } + } + $shouldUpdate = false; if ($collection->getId() !== self::METADATA) { @@ -6160,7 +6169,8 @@ public function updateDocument(string $collection, string $id, Document $documen $oldValue = $old->getAttribute($key); - if ($value != $oldValue) { + $isIdType = isset($idAttributes[$key]); + if ($isIdType ? $value != $oldValue : $value !== $oldValue) { $shouldUpdate = true; break; } From 05c5ff809f4c5ea3d53e6bf6e25ff015ea0523a0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 23:03:24 +1300 Subject: [PATCH 6/9] fix: skip format validation for non-primary VAR_ID attributes in Sequence Non-primary VAR_ID attributes like $tenant can be any int/string value and should not be forced through UUID7 or Integer range checks. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Validator/Sequence.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Database/Validator/Sequence.php b/src/Database/Validator/Sequence.php index c45be3642..6f1152150 100644 --- a/src/Database/Validator/Sequence.php +++ b/src/Database/Validator/Sequence.php @@ -49,6 +49,10 @@ public function isValid($value): bool return false; } + if (!$this->primary) { + return true; + } + switch ($this->idAttributeType) { case Database::VAR_UUID7: return \is_string($value) && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1; From f5a989f65aa006af0e956c36f65c11b4442f9c22 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 23:09:53 +1300 Subject: [PATCH 7/9] fix: resolve PHPStan error and update unit test for non-primary VAR_ID Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Validator/Sequence.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Database/Validator/Sequence.php b/src/Database/Validator/Sequence.php index 6f1152150..7e3ebca27 100644 --- a/src/Database/Validator/Sequence.php +++ b/src/Database/Validator/Sequence.php @@ -57,8 +57,7 @@ public function isValid($value): bool case Database::VAR_UUID7: return \is_string($value) && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1; case Database::VAR_INTEGER: - $start = ($this->primary) ? 1 : 0; - $validator = new Range($start, Database::MAX_BIG_INT, Database::VAR_INTEGER); + $validator = new Range(1, Database::MAX_BIG_INT, Database::VAR_INTEGER); return $validator->isValid($value); default: From 5fb39b6026e45ae5291e5e93ca0f170b34d0930b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 23:10:05 +1300 Subject: [PATCH 8/9] fix: update unit test for non-primary VAR_ID validation Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/Validator/StructureTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Validator/StructureTest.php b/tests/unit/Validator/StructureTest.php index ffc2b62ee..f3b49864d 100644 --- a/tests/unit/Validator/StructureTest.php +++ b/tests/unit/Validator/StructureTest.php @@ -773,7 +773,7 @@ public function testId(): void 'id' => $sqlId, ]))); - $this->assertEquals(false, $validator->isValid(new Document([ + $this->assertEquals(true, $validator->isValid(new Document([ '$collection' => ID::custom('posts'), 'title' => 'My Title', 'description' => null, From a67be9c874dbb7dffc80d9b1984a28fe97ce682b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Mar 2026 23:55:25 +1300 Subject: [PATCH 9/9] fix: revert to strict comparison in updateDocument, normalize tenant instead The loose comparison in the update-detection block could miss type-only changes, allowing writes to persist with only READ authorization checked instead of UPDATE. Normalize the old document's $tenant value so both sides match under strict comparison. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Database/Database.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 380827a32..b2e16af45 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6055,7 +6055,9 @@ public function updateDocument(string $collection, string $id, Document $documen $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; if ($this->adapter->getSharedTables()) { - $document['$tenant'] = $old->getTenant(); // Make sure user doesn't switch tenant + $tenant = $old->getTenant(); + $document['$tenant'] = $tenant; + $old->setAttribute('$tenant', $tenant); // Normalize for strict comparison } $document = new Document($document); @@ -6065,13 +6067,6 @@ public function updateDocument(string $collection, string $id, Document $documen return $attribute['type'] === Database::VAR_RELATIONSHIP; }); - $idAttributes = []; - foreach (\array_merge(self::INTERNAL_ATTRIBUTES, $attributes) as $attribute) { - if ($attribute['type'] === Database::VAR_ID) { - $idAttributes[$attribute['$id']] = true; - } - } - $shouldUpdate = false; if ($collection->getId() !== self::METADATA) { @@ -6169,8 +6164,7 @@ public function updateDocument(string $collection, string $id, Document $documen $oldValue = $old->getAttribute($key); - $isIdType = isset($idAttributes[$key]); - if ($isIdType ? $value != $oldValue : $value !== $oldValue) { + if ($value !== $oldValue) { $shouldUpdate = true; break; }