From 0b6c76a84ec9eb9568cb03183704eb26382733b2 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 14 Jun 2026 09:28:39 +0300 Subject: [PATCH 01/16] uid change --- src/Database/Adapter/MariaDB.php | 44 ++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 599da878e..f6b05b43b 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -980,11 +980,22 @@ public function updateDocument(Document $collection, string $id, Document $docum $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); + $attributes['_uid'] = $document->getId(); $name = $this->filter($collection); $columns = ''; if (!$skipPermissions) { + /** + * Permission rows in the _perms table are keyed by the document's + * UID (the _document column). When the UID changes, the existing + * rows must be re-pointed to the new UID before the diff below is + * applied, otherwise the old rows are orphaned and unchanged + * permissions are lost for the new UID. + */ + $newUid = $document->offsetExists('$id') ? $document->getId() : $id; + $uidChanged = $newUid !== $id; + $sql = " SELECT _type, _permission FROM {$this->getSQLTable($name . '_perms')} @@ -998,7 +1009,8 @@ public function updateDocument(Document $collection, string $id, Document $docum * Get current permissions from the database */ $sqlPermissions = $this->getPDO()->prepare($sql); - $sqlPermissions->bindValue(':_uid', $document->getId()); + + $sqlPermissions->bindValue(':_uid', $id); if ($this->sharedTables) { $sqlPermissions->bindValue(':_tenant', $this->tenant); @@ -1041,6 +1053,26 @@ public function updateDocument(Document $collection, string $id, Document $docum } } + /** + * Query to re-point existing permissions to the new UID + */ + if ($uidChanged) { + $sql = " + UPDATE {$this->getSQLTable($name . '_perms')} + SET _document = :_newUid + WHERE _document = :_uid + {$this->getTenantQuery($collection)} + "; + + $stmtRepointPermissions = $this->getPDO()->prepare($sql); + $stmtRepointPermissions->bindValue(':_uid', $id); + $stmtRepointPermissions->bindValue(':_newUid', $newUid); + + if ($this->sharedTables) { + $stmtRepointPermissions->bindValue(':_tenant', $this->tenant); + } + } + /** * Query to remove permissions */ @@ -1071,7 +1103,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); - $stmtRemovePermissions->bindValue(':_uid', $document->getId()); + $stmtRemovePermissions->bindValue(':_uid', $newUid); if ($this->sharedTables) { $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); @@ -1119,7 +1151,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $stmtAddPermissions->bindValue(":_uid", $newUid); if ($this->sharedTables) { $stmtAddPermissions->bindValue(":_tenant", $this->tenant); @@ -1168,7 +1200,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = " UPDATE {$this->getSQLTable($name)} - SET {$columns} _uid = :_newUid + SET " . \rtrim($columns, ',') . " WHERE _id=:_sequence {$this->getTenantQuery($collection)} "; @@ -1178,7 +1210,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_sequence', $document->getSequence()); - $stmt->bindValue(':_newUid', $document->getId()); if ($this->sharedTables) { $stmt->bindValue(':_tenant', $this->tenant); @@ -1209,6 +1240,9 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt->execute(); + if (isset($stmtRepointPermissions)) { + $stmtRepointPermissions->execute(); + } if (isset($stmtRemovePermissions)) { $stmtRemovePermissions->execute(); } From 20dd6e4f371febc3fb0bf372da399b8425da42df Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 14 Jun 2026 09:30:01 +0300 Subject: [PATCH 02/16] Database.php --- src/Database/Database.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 37b903d13..80bbb9dc6 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6183,6 +6183,14 @@ public function updateDocument(string $collection, string $id, Document $documen } $document = new Document($document); + // A UID change re-keys the permission rows (_perms._document) to the + // new UID, so the permission block must run even when the permission + // set itself is unchanged. Otherwise the old rows are orphaned and the + // new UID is left with no permissions. + if ($document->getId() !== $id) { + $skipPermissionsUpdate = false; + } + $attributes = $collection->getAttribute('attributes', []); $relationships = \array_filter($attributes, function ($attribute) { From 9898ed1293e9c42ff44b561abd9f75255bb6597d Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 14 Jun 2026 10:01:14 +0300 Subject: [PATCH 03/16] Add tests --- tests/e2e/Adapter/Scopes/DocumentTests.php | 104 +++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 23cc3b623..5b935e4bc 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1572,6 +1572,110 @@ public function testCreateDocumentDefaults(): void $database->deleteCollection('defaults'); } + /** + * When a document's UID changes on update, its permission rows in the + * collection's _perms table must follow the new UID. Otherwise the old + * rows are orphaned and the renamed document is left with no permissions, + * even when the permission set itself was not changed. + */ + public function testUpdateDocumentChangeIdMigratesPermissions(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $auth = $database->getAuthorization(); + + $collection = 'update_change_id_perms'; + + // documentSecurity with no collection-level permissions: reads are + // governed purely by the document's rows in the _perms table. + $database->createCollection($collection, permissions: [], documentSecurity: true); + $this->assertEquals(true, $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, false)); + + // Create a document whose read permission is scoped to a single role, + // so that find() must consult the _perms table to return it. + $document = $auth->skip(fn () => $database->createDocument($collection, new Document([ + '$id' => 'old_id', + 'name' => 'test', + '$permissions' => [ + Permission::read(Role::user('alice')), + Permission::update(Role::user('alice')), + Permission::delete(Role::user('alice')), + ], + ]))); + $this->assertEquals('old_id', $document->getId()); + + // Sanity: as alice the document is visible via the _perms table. + $auth->addRole(Role::user('alice')->toString()); + $this->assertCount(1, $database->find($collection)); + + // Rename the document WITHOUT changing its permission set. + $renamed = $auth->skip(fn () => $database->updateDocument($collection, 'old_id', new Document(\array_merge( + $document->getArrayCopy(), + ['$id' => 'new_id'], + )))); + $this->assertEquals('new_id', $renamed->getId()); + + // The old UID must no longer resolve to a document. + $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'old_id'))->isEmpty()); + + // The new UID must exist and keep its permissions on the main row. + $newDoc = $auth->skip(fn () => $database->getDocument($collection, 'new_id')); + $this->assertFalse($newDoc->isEmpty()); + $this->assertContains(Permission::read(Role::user('alice')), $newDoc->getPermissions()); + + // The crucial check: the permission rows must have migrated to the new + // UID in the _perms table. As alice, find() (which joins _perms) must + // still return exactly the renamed document. With orphaned rows under + // the old UID this returns 0. + $found = $database->find($collection); + $this->assertCount(1, $found); + $this->assertEquals('new_id', $found[0]->getId()); + + /** + * Second scenario: change the UID AND the permission set in the same + * update. Drop alice's access and grant bob instead. The removed rows + * must be gone, the added rows must land under the new UID, and nothing + * may be left orphaned under the old UID. + */ + $rekeyed = $auth->skip(fn () => $database->updateDocument($collection, 'new_id', new Document(\array_merge( + $newDoc->getArrayCopy(), + [ + '$id' => 'final_id', + '$permissions' => [ + Permission::read(Role::user('bob')), + Permission::update(Role::user('bob')), + Permission::delete(Role::user('bob')), + ], + ], + )))); + $this->assertEquals('final_id', $rekeyed->getId()); + + // The old UID must no longer resolve to a document. + $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'new_id'))->isEmpty()); + + // The main row must reflect the new permission set. + $finalDoc = $auth->skip(fn () => $database->getDocument($collection, 'final_id')); + $this->assertFalse($finalDoc->isEmpty()); + $this->assertContains(Permission::read(Role::user('bob')), $finalDoc->getPermissions()); + $this->assertNotContains(Permission::read(Role::user('alice')), $finalDoc->getPermissions()); + + // alice's permission rows were removed: as alice nothing is returned. + $this->assertCount(0, $database->find($collection)); + + // bob's permission rows landed under the new UID: as bob the renamed + // document is returned via the _perms join. + $auth->addRole(Role::user('bob')->toString()); + $foundAsBob = $database->find($collection); + $this->assertCount(1, $foundAsBob); + $this->assertEquals('final_id', $foundAsBob[0]->getId()); + + $auth->removeRole(Role::user('alice')->toString()); + $auth->removeRole(Role::user('bob')->toString()); + + $auth->skip(fn () => $database->deleteCollection($collection)); + } + public function testIncreaseDecrease(): Document { /** @var Database $database */ From b9d2a24d20ae4e9a8df0499b760723da58705249 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 14 Jun 2026 10:08:07 +0300 Subject: [PATCH 04/16] Uid change --- src/Database/Database.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 80bbb9dc6..b12cd6434 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6170,6 +6170,12 @@ public function updateDocument(string $collection, string $id, Document $documen $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); } + + // UID change + if ($document->offsetExists('$id') && $document->getId() !== $id) { + $skipPermissionsUpdate = false; + } + $createdAt = $document->getCreatedAt(); $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); @@ -6183,14 +6189,6 @@ public function updateDocument(string $collection, string $id, Document $documen } $document = new Document($document); - // A UID change re-keys the permission rows (_perms._document) to the - // new UID, so the permission block must run even when the permission - // set itself is unchanged. Otherwise the old rows are orphaned and the - // new UID is left with no permissions. - if ($document->getId() !== $id) { - $skipPermissionsUpdate = false; - } - $attributes = $collection->getAttribute('attributes', []); $relationships = \array_filter($attributes, function ($attribute) { From f8a17ad965b0345ab1cf0e7e6c62fbf04d842283 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 14 Jun 2026 10:40:55 +0300 Subject: [PATCH 05/16] Delete old --- src/Database/Adapter/MariaDB.php | 164 +++++-------------------------- 1 file changed, 22 insertions(+), 142 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index f6b05b43b..75d7f3ada 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -988,164 +988,47 @@ public function updateDocument(Document $collection, string $id, Document $docum if (!$skipPermissions) { /** * Permission rows in the _perms table are keyed by the document's - * UID (the _document column). When the UID changes, the existing - * rows must be re-pointed to the new UID before the diff below is - * applied, otherwise the old rows are orphaned and unchanged - * permissions are lost for the new UID. + * UID (the _document column). Replace the permissions with a full + * rewrite: delete every existing row for the (old) UID, then + * re-insert the document's current permission set under the (new) + * UID. This keeps the logic simple and correctly re-keys the + * permission rows when the UID changes. */ $newUid = $document->offsetExists('$id') ? $document->getId() : $id; - $uidChanged = $newUid !== $id; $sql = " - SELECT _type, _permission - FROM {$this->getSQLTable($name . '_perms')} + DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)} "; - $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); - /** - * Get current permissions from the database - */ - $sqlPermissions = $this->getPDO()->prepare($sql); - - $sqlPermissions->bindValue(':_uid', $id); + $stmtRemovePermissions = $this->getPDO()->prepare($sql); + $stmtRemovePermissions->bindValue(':_uid', $id); if ($this->sharedTables) { - $sqlPermissions->bindValue(':_tenant', $this->tenant); - } - - $sqlPermissions->execute(); - $permissions = $sqlPermissions->fetchAll(); - $sqlPermissions->closeCursor(); - - $initial = []; - foreach (Database::PERMISSIONS as $type) { - $initial[$type] = []; + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); } - $permissions = array_reduce($permissions, function (array $carry, array $item) { - $carry[$item['_type']][] = $item['_permission']; - - return $carry; - }, $initial); - /** - * Get removed Permissions + * Insert the document's full current permission set under the + * (new) UID. */ - $removals = []; + $values = []; foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($permissions[$type], $document->getPermissionsByType($type)); - if (!empty($diff)) { - $removals[$type] = $diff; - } - } - - /** - * Get added Permissions - */ - $additions = []; - foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($document->getPermissionsByType($type), $permissions[$type]); - if (!empty($diff)) { - $additions[$type] = $diff; - } - } - - /** - * Query to re-point existing permissions to the new UID - */ - if ($uidChanged) { - $sql = " - UPDATE {$this->getSQLTable($name . '_perms')} - SET _document = :_newUid - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; - - $stmtRepointPermissions = $this->getPDO()->prepare($sql); - $stmtRepointPermissions->bindValue(':_uid', $id); - $stmtRepointPermissions->bindValue(':_newUid', $newUid); - - if ($this->sharedTables) { - $stmtRepointPermissions->bindValue(':_tenant', $this->tenant); - } - } - - /** - * Query to remove permissions - */ - $removeQuery = ''; - if (!empty($removals)) { - $removeQuery = ' AND ('; - foreach ($removals as $type => $permissions) { - $removeQuery .= "( - _type = '{$type}' - AND _permission IN (" . implode(', ', \array_map(fn (string $i) => ":_remove_{$type}_{$i}", \array_keys($permissions))) . ") - )"; - if ($type !== \array_key_last($removals)) { - $removeQuery .= ' OR '; - } + foreach ($document->getPermissionsByType($type) as $i => $_) { + $tenantPlaceholder = $this->sharedTables ? ', :_tenant' : ''; + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}{$tenantPlaceholder})"; } } - if (!empty($removeQuery)) { - $removeQuery .= ')'; - $sql = " - DELETE - FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; - $removeQuery = $sql . $removeQuery; - - $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); - - $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); - $stmtRemovePermissions->bindValue(':_uid', $newUid); - - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); - } - - foreach ($removals as $type => $permissions) { - foreach ($permissions as $i => $permission) { - $stmtRemovePermissions->bindValue(":_remove_{$type}_{$i}", $permission); - } - } - } - - /** - * Query to add permissions - */ - if (!empty($additions)) { - $values = []; - foreach ($additions as $type => $permissions) { - foreach ($permissions as $i => $_) { - $value = "( :_uid, '{$type}', :_add_{$type}_{$i}"; - - if ($this->sharedTables) { - $value .= ", :_tenant)"; - } else { - $value .= ")"; - } - - $values[] = $value; - } - } + if (!empty($values)) { + $tenantColumn = $this->sharedTables ? ', _tenant' : ''; $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission - "; - - if ($this->sharedTables) { - $sql .= ', _tenant)'; - } else { - $sql .= ')'; - } - - $sql .= " VALUES " . \implode(', ', $values); + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$tenantColumn}) + VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); @@ -1157,8 +1040,8 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmtAddPermissions->bindValue(":_tenant", $this->tenant); } - foreach ($additions as $type => $permissions) { - foreach ($permissions as $i => $permission) { + foreach (Database::PERMISSIONS as $type) { + foreach ($document->getPermissionsByType($type) as $i => $permission) { $stmtAddPermissions->bindValue(":_add_{$type}_{$i}", $permission); } } @@ -1240,9 +1123,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt->execute(); - if (isset($stmtRepointPermissions)) { - $stmtRepointPermissions->execute(); - } if (isset($stmtRemovePermissions)) { $stmtRemovePermissions->execute(); } From 49826b6a896018660e7c5317671a1884aeda42ec Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 14 Jun 2026 14:45:52 +0300 Subject: [PATCH 06/16] try finally --- tests/e2e/Adapter/Scopes/DocumentTests.php | 168 +++++++++++---------- 1 file changed, 85 insertions(+), 83 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 5b935e4bc..6f0d78da9 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1587,93 +1587,95 @@ public function testUpdateDocumentChangeIdMigratesPermissions(): void $collection = 'update_change_id_perms'; - // documentSecurity with no collection-level permissions: reads are - // governed purely by the document's rows in the _perms table. - $database->createCollection($collection, permissions: [], documentSecurity: true); - $this->assertEquals(true, $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, false)); - - // Create a document whose read permission is scoped to a single role, - // so that find() must consult the _perms table to return it. - $document = $auth->skip(fn () => $database->createDocument($collection, new Document([ - '$id' => 'old_id', - 'name' => 'test', - '$permissions' => [ - Permission::read(Role::user('alice')), - Permission::update(Role::user('alice')), - Permission::delete(Role::user('alice')), - ], - ]))); - $this->assertEquals('old_id', $document->getId()); - - // Sanity: as alice the document is visible via the _perms table. - $auth->addRole(Role::user('alice')->toString()); - $this->assertCount(1, $database->find($collection)); - - // Rename the document WITHOUT changing its permission set. - $renamed = $auth->skip(fn () => $database->updateDocument($collection, 'old_id', new Document(\array_merge( - $document->getArrayCopy(), - ['$id' => 'new_id'], - )))); - $this->assertEquals('new_id', $renamed->getId()); - - // The old UID must no longer resolve to a document. - $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'old_id'))->isEmpty()); - - // The new UID must exist and keep its permissions on the main row. - $newDoc = $auth->skip(fn () => $database->getDocument($collection, 'new_id')); - $this->assertFalse($newDoc->isEmpty()); - $this->assertContains(Permission::read(Role::user('alice')), $newDoc->getPermissions()); - - // The crucial check: the permission rows must have migrated to the new - // UID in the _perms table. As alice, find() (which joins _perms) must - // still return exactly the renamed document. With orphaned rows under - // the old UID this returns 0. - $found = $database->find($collection); - $this->assertCount(1, $found); - $this->assertEquals('new_id', $found[0]->getId()); - - /** - * Second scenario: change the UID AND the permission set in the same - * update. Drop alice's access and grant bob instead. The removed rows - * must be gone, the added rows must land under the new UID, and nothing - * may be left orphaned under the old UID. - */ - $rekeyed = $auth->skip(fn () => $database->updateDocument($collection, 'new_id', new Document(\array_merge( - $newDoc->getArrayCopy(), - [ - '$id' => 'final_id', + try { + // documentSecurity with no collection-level permissions: reads are + // governed purely by the document's rows in the _perms table. + $database->createCollection($collection, permissions: [], documentSecurity: true); + $this->assertEquals(true, $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, false)); + + // Create a document whose read permission is scoped to a single role, + // so that find() must consult the _perms table to return it. + $document = $auth->skip(fn () => $database->createDocument($collection, new Document([ + '$id' => 'old_id', + 'name' => 'test', '$permissions' => [ - Permission::read(Role::user('bob')), - Permission::update(Role::user('bob')), - Permission::delete(Role::user('bob')), + Permission::read(Role::user('alice')), + Permission::update(Role::user('alice')), + Permission::delete(Role::user('alice')), ], - ], - )))); - $this->assertEquals('final_id', $rekeyed->getId()); - - // The old UID must no longer resolve to a document. - $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'new_id'))->isEmpty()); + ]))); + $this->assertEquals('old_id', $document->getId()); + + // Sanity: as alice the document is visible via the _perms table. + $auth->addRole(Role::user('alice')->toString()); + $this->assertCount(1, $database->find($collection)); + + // Rename the document WITHOUT changing its permission set. + $renamed = $auth->skip(fn () => $database->updateDocument($collection, 'old_id', new Document(\array_merge( + $document->getArrayCopy(), + ['$id' => 'new_id'], + )))); + $this->assertEquals('new_id', $renamed->getId()); + + // The old UID must no longer resolve to a document. + $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'old_id'))->isEmpty()); + + // The new UID must exist and keep its permissions on the main row. + $newDoc = $auth->skip(fn () => $database->getDocument($collection, 'new_id')); + $this->assertFalse($newDoc->isEmpty()); + $this->assertContains(Permission::read(Role::user('alice')), $newDoc->getPermissions()); + + // The crucial check: the permission rows must have migrated to the new + // UID in the _perms table. As alice, find() (which joins _perms) must + // still return exactly the renamed document. With orphaned rows under + // the old UID this returns 0. + $found = $database->find($collection); + $this->assertCount(1, $found); + $this->assertEquals('new_id', $found[0]->getId()); - // The main row must reflect the new permission set. - $finalDoc = $auth->skip(fn () => $database->getDocument($collection, 'final_id')); - $this->assertFalse($finalDoc->isEmpty()); - $this->assertContains(Permission::read(Role::user('bob')), $finalDoc->getPermissions()); - $this->assertNotContains(Permission::read(Role::user('alice')), $finalDoc->getPermissions()); - - // alice's permission rows were removed: as alice nothing is returned. - $this->assertCount(0, $database->find($collection)); - - // bob's permission rows landed under the new UID: as bob the renamed - // document is returned via the _perms join. - $auth->addRole(Role::user('bob')->toString()); - $foundAsBob = $database->find($collection); - $this->assertCount(1, $foundAsBob); - $this->assertEquals('final_id', $foundAsBob[0]->getId()); - - $auth->removeRole(Role::user('alice')->toString()); - $auth->removeRole(Role::user('bob')->toString()); + /** + * Second scenario: change the UID AND the permission set in the same + * update. Drop alice's access and grant bob instead. The removed rows + * must be gone, the added rows must land under the new UID, and nothing + * may be left orphaned under the old UID. + */ + $rekeyed = $auth->skip(fn () => $database->updateDocument($collection, 'new_id', new Document(\array_merge( + $newDoc->getArrayCopy(), + [ + '$id' => 'final_id', + '$permissions' => [ + Permission::read(Role::user('bob')), + Permission::update(Role::user('bob')), + Permission::delete(Role::user('bob')), + ], + ], + )))); + $this->assertEquals('final_id', $rekeyed->getId()); + + // The old UID must no longer resolve to a document. + $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'new_id'))->isEmpty()); + + // The main row must reflect the new permission set. + $finalDoc = $auth->skip(fn () => $database->getDocument($collection, 'final_id')); + $this->assertFalse($finalDoc->isEmpty()); + $this->assertContains(Permission::read(Role::user('bob')), $finalDoc->getPermissions()); + $this->assertNotContains(Permission::read(Role::user('alice')), $finalDoc->getPermissions()); + + // alice's permission rows were removed: as alice nothing is returned. + $this->assertCount(0, $database->find($collection)); + + // bob's permission rows landed under the new UID: as bob the renamed + // document is returned via the _perms join. + $auth->addRole(Role::user('bob')->toString()); + $foundAsBob = $database->find($collection); + $this->assertCount(1, $foundAsBob); + $this->assertEquals('final_id', $foundAsBob[0]->getId()); + } finally { + $auth->removeRole(Role::user('alice')->toString()); + $auth->removeRole(Role::user('bob')->toString()); - $auth->skip(fn () => $database->deleteCollection($collection)); + $auth->skip(fn () => $database->deleteCollection($collection)); + } } public function testIncreaseDecrease(): Document From ef6a7ddf34650d5b6513146bfa82cc8a4b81a9cf Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 14 Jun 2026 14:56:18 +0300 Subject: [PATCH 07/16] Get current permissions from the database --- src/Database/Adapter/MariaDB.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 75d7f3ada..4e5d580c3 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -986,14 +986,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $columns = ''; if (!$skipPermissions) { - /** - * Permission rows in the _perms table are keyed by the document's - * UID (the _document column). Replace the permissions with a full - * rewrite: delete every existing row for the (old) UID, then - * re-insert the document's current permission set under the (new) - * UID. This keeps the logic simple and correctly re-keys the - * permission rows when the UID changes. - */ $newUid = $document->offsetExists('$id') ? $document->getId() : $id; $sql = " @@ -1011,10 +1003,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); } - /** - * Insert the document's full current permission set under the - * (new) UID. - */ $values = []; foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByType($type) as $i => $_) { From e5a494633f65bb1ee7b1fbf25e7f6473706ba1d1 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 09:48:59 +0300 Subject: [PATCH 08/16] line --- src/Database/Adapter/MariaDB.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 4e5d580c3..cbe042955 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -998,7 +998,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmtRemovePermissions = $this->getPDO()->prepare($sql); $stmtRemovePermissions->bindValue(':_uid', $id); - if ($this->sharedTables) { $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); } From a77ed3e6d5018cee25ab439d46fcb639e23da4e6 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 11:19:15 +0300 Subject: [PATCH 09/16] Use binds --- src/Database/Adapter/MariaDB.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index cbe042955..9c9e24f26 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -992,7 +992,7 @@ public function updateDocument(Document $collection, string $id, Document $docum DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)} - "; + "; $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); @@ -1003,10 +1003,12 @@ public function updateDocument(Document $collection, string $id, Document $docum } $values = []; + $binds = []; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $i => $_) { + foreach ($document->getPermissionsByType($type) as $i => $permission) { $tenantPlaceholder = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}{$tenantPlaceholder})"; + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$tenantPlaceholder})"; + $binds[":_add_{$type}_{$i}"] = $permission; } } @@ -1020,17 +1022,13 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $newUid); - if ($this->sharedTables) { $stmtAddPermissions->bindValue(":_tenant", $this->tenant); } - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $i => $permission) { - $stmtAddPermissions->bindValue(":_add_{$type}_{$i}", $permission); - } + foreach ($binds as $key => $permission) { + $stmtAddPermissions->bindValue($key, $permission); } } } From ea369c62a45ab0df2df706443b43c4584236ea97 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 11:42:42 +0300 Subject: [PATCH 10/16] space --- src/Database/Adapter/MariaDB.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 9c9e24f26..64e9681a2 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1016,7 +1016,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $tenantColumn = $this->sharedTables ? ', _tenant' : ''; $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$tenantColumn}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission {$tenantColumn}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); From dd5c81c9da4225fc04bc3d5778bb19ec1ef4a48b Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 12:22:42 +0300 Subject: [PATCH 11/16] Postgres adapter: --- src/Database/Adapter/Postgres.php | 126 +++++------------------------- 1 file changed, 20 insertions(+), 106 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index d81cdec0b..211761089 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1139,140 +1139,55 @@ public function updateDocument(Document $collection, string $id, Document $docum $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); + $attributes['_uid'] = $document->getId(); $name = $this->filter($collection); $columns = ''; if (!$skipPermissions) { + $newUid = $document->offsetExists('$id') ? $document->getId() : $id; + $sql = " - SELECT _type, _permission - FROM {$this->getSQLTable($name . '_perms')} + DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)} "; - $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); - - /** - * Get current permissions from the database - */ - $permissionsStmt = $this->getPDO()->prepare($sql); - $permissionsStmt->bindValue(':_uid', $document->getId()); + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + $stmtRemovePermissions = $this->getPDO()->prepare($sql); + $stmtRemovePermissions->bindValue(':_uid', $id); if ($this->sharedTables) { - $permissionsStmt->bindValue(':_tenant', $this->tenant); + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); } - $this->execute($permissionsStmt); - $permissions = $permissionsStmt->fetchAll(); - $permissionsStmt->closeCursor(); - - $initial = []; + $values = []; + $binds = []; foreach (Database::PERMISSIONS as $type) { - $initial[$type] = []; - } - - $permissions = array_reduce($permissions, function (array $carry, array $item) { - $carry[$item['_type']][] = $item['_permission']; - - return $carry; - }, $initial); - - /** - * Get removed Permissions - */ - $removals = []; - foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($permissions[$type], $document->getPermissionsByType($type)); - if (!empty($diff)) { - $removals[$type] = $diff; - } - } - - /** - * Get added Permissions - */ - $additions = []; - foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($document->getPermissionsByType($type), $permissions[$type]); - if (!empty($diff)) { - $additions[$type] = $diff; - } - } - - /** - * Query to remove permissions - */ - $removeQuery = ''; - if (!empty($removals)) { - $removeQuery = ' AND ('; - foreach ($removals as $type => $permissions) { - $removeQuery .= "( - _type = '{$type}' - AND _permission IN (" . implode(', ', \array_map(fn (string $i) => ":_remove_{$type}_{$i}", \array_keys($permissions))) . ") - )"; - if ($type !== \array_key_last($removals)) { - $removeQuery .= ' OR '; - } + foreach ($document->getPermissionsByType($type) as $i => $permission) { + $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$sqlTenant})"; + $binds[":_add_{$type}_{$i}"] = $permission; } } - if (!empty($removeQuery)) { - $removeQuery .= ')'; - - $sql = " - DELETE - FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; - - $removeQuery = $sql . $removeQuery; - - $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); - $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); - $stmtRemovePermissions->bindValue(':_uid', $document->getId()); - - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); - } - - foreach ($removals as $type => $permissions) { - foreach ($permissions as $i => $permission) { - $stmtRemovePermissions->bindValue(":_remove_{$type}_{$i}", $permission); - } - } - } - - /** - * Query to add permissions - */ - if (!empty($additions)) { - $values = []; - foreach ($additions as $type => $permissions) { - foreach ($permissions as $i => $_) { - $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$sqlTenant})"; - } - } + if (!empty($values)) { $sqlTenant = $this->sharedTables ? ', _tenant' : ''; $sql = " INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission {$sqlTenant}) - VALUES" . \implode(', ', $values); + VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $stmtAddPermissions->bindValue(":_uid", $newUid); if ($this->sharedTables) { $stmtAddPermissions->bindValue(':_tenant', $this->tenant); } - foreach ($additions as $type => $permissions) { - foreach ($permissions as $i => $permission) { - $stmtAddPermissions->bindValue(":_add_{$type}_{$i}", $permission); - } + foreach ($binds as $key => $permission) { + $stmtAddPermissions->bindValue($key, $permission); } } } @@ -1312,7 +1227,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = " UPDATE {$this->getSQLTable($name)} - SET {$columns} _uid = :_newUid + SET " . \rtrim($columns, ',') . " WHERE _id=:_sequence {$this->getTenantQuery($collection)} "; @@ -1322,7 +1237,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_sequence', $document->getSequence()); - $stmt->bindValue(':_newUid', $document->getId()); if ($this->sharedTables) { $stmt->bindValue(':_tenant', $this->tenant); From 2cb0fa6a34a6258b0f30221f9b990239c6b53d6c Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 12:31:39 +0300 Subject: [PATCH 12/16] SQLite.php --- src/Database/Adapter/SQLite.php | 124 +++++--------------------------- 1 file changed, 19 insertions(+), 105 deletions(-) diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 5b07ce3b7..34ae724c2 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1275,6 +1275,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); + $attributes['_uid'] = $document->getId(); if ($this->sharedTables) { $attributes['_tenant'] = $this->tenant; @@ -1284,116 +1285,33 @@ public function updateDocument(Document $collection, string $id, Document $docum $columns = ''; if (!$skipPermissions) { + $newUid = $document->offsetExists('$id') ? $document->getId() : $id; + $sql = " - SELECT _type, _permission - FROM `{$this->getNamespace()}_{$name}_perms` + DELETE FROM `{$this->getNamespace()}_{$name}_perms` WHERE _document = :_uid {$this->getTenantQuery($collection)} "; - $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); - - /** - * Get current permissions from the database - */ - $permissionsStmt = $this->getPDO()->prepare($sql); - $permissionsStmt->bindValue(':_uid', $document->getId()); + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + $stmtRemovePermissions = $this->getPDO()->prepare($sql); + $stmtRemovePermissions->bindValue(':_uid', $id); if ($this->sharedTables) { - $permissionsStmt->bindValue(':_tenant', $this->tenant); - } - - $permissionsStmt->execute(); - $permissions = $permissionsStmt->fetchAll(); - $permissionsStmt->closeCursor(); - - $initial = []; - foreach (Database::PERMISSIONS as $type) { - $initial[$type] = []; - } - - $permissions = array_reduce($permissions, function (array $carry, array $item) { - $carry[$item['_type']][] = $item['_permission']; - - return $carry; - }, $initial); - - /** - * Get removed Permissions - */ - $removals = []; - foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($permissions[$type], $document->getPermissionsByType($type)); - if (!empty($diff)) { - $removals[$type] = $diff; - } + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); } - /** - * Get added Permissions - */ - $additions = []; + $values = []; + $binds = []; foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($document->getPermissionsByType($type), $permissions[$type]); - if (!empty($diff)) { - $additions[$type] = $diff; - } - } - - /** - * Query to remove permissions - */ - $removeQuery = ''; - if (!empty($removals)) { - $removeQuery = ' AND ('; - foreach ($removals as $type => $permissions) { - $removeQuery .= "( - _type = '{$type}' - AND _permission IN (" . implode(', ', \array_map(fn (string $i) => ":_remove_{$type}_{$i}", \array_keys($permissions))) . ") - )"; - if ($type !== \array_key_last($removals)) { - $removeQuery .= ' OR '; - } - } - } - if (!empty($removeQuery)) { - $removeQuery .= ')'; - $sql = " - DELETE - FROM `{$this->getNamespace()}_{$name}_perms` - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; - - $removeQuery = $sql . $removeQuery; - $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); - - $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); - $stmtRemovePermissions->bindValue(':_uid', $document->getId()); - - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); - } - - foreach ($removals as $type => $permissions) { - foreach ($permissions as $i => $permission) { - $stmtRemovePermissions->bindValue(":_remove_{$type}_{$i}", $permission); - } + foreach ($document->getPermissionsByType($type) as $i => $permission) { + $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; + $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i} {$tenantQuery})"; + $binds[":_add_{$type}_{$i}"] = $permission; } } - /** - * Query to add permissions - */ - if (!empty($additions)) { - $values = []; - foreach ($additions as $type => $permissions) { - foreach ($permissions as $i => $_) { - $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i} {$tenantQuery})"; - } - } - + if (!empty($values)) { $tenantQuery = $this->sharedTables ? ', _tenant' : ''; $sql = " @@ -1403,16 +1321,13 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); $stmtAddPermissions = $this->getPDO()->prepare($sql); - - $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $stmtAddPermissions->bindValue(":_uid", $newUid); if ($this->sharedTables) { $stmtAddPermissions->bindValue(":_tenant", $this->tenant); } - foreach ($additions as $type => $permissions) { - foreach ($permissions as $i => $permission) { - $stmtAddPermissions->bindValue(":_add_{$type}_{$i}", $permission); - } + foreach ($binds as $key => $permission) { + $stmtAddPermissions->bindValue($key, $permission); } } } @@ -1456,7 +1371,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = " UPDATE `{$this->getNamespace()}_{$name}` - SET {$columns}, _uid = :_newUid + SET {$columns} WHERE _uid = :_existingUid {$this->getTenantQuery($collection)} "; @@ -1466,7 +1381,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_existingUid', $id); - $stmt->bindValue(':_newUid', $document->getId()); if ($this->sharedTables) { $stmt->bindValue(':_tenant', $this->tenant); From 76658317ba8a3206acd31d8cfd4db83019dde4cf Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 12:50:34 +0300 Subject: [PATCH 13/16] stan --- src/Database/Adapter/SQLite.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 34ae724c2..94ffbe8fc 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2844,14 +2844,7 @@ public function getSchemaIndexes(string $collection): array * Schema-index entries for FTS5 fulltext tables on `$collection`. * Maps each back to a metadata index id when possible. * - * @return array, - * lengths: array, - * }> + * @return array, lengths: array}> */ protected function getFulltextSchemaIndexes(string $collection): array { From 903107a1029ae3f1958358cf5e015e9333de5d1b Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 12:51:00 +0300 Subject: [PATCH 14/16] duplicate test --- tests/e2e/Adapter/Scopes/DocumentTests.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 6f0d78da9..4f998372d 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1645,6 +1645,7 @@ public function testUpdateDocumentChangeIdMigratesPermissions(): void '$id' => 'final_id', '$permissions' => [ Permission::read(Role::user('bob')), + Permission::read(Role::user('bob')), // Duplication check Permission::update(Role::user('bob')), Permission::delete(Role::user('bob')), ], From 26d93bc9ab13e17355eb85ca2c1361a46e57d1a9 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 13:04:39 +0300 Subject: [PATCH 15/16] code QL --- src/Database/Adapter/SQLite.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 94ffbe8fc..eb7e92b5b 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2844,7 +2844,15 @@ public function getSchemaIndexes(string $collection): array * Schema-index entries for FTS5 fulltext tables on `$collection`. * Maps each back to a metadata index id when possible. * - * @return array, lengths: array}> + * Each entry has keys: + * - `$id`: string + * - `indexName`: string + * - `indexType`: string + * - `nonUnique`: int + * - `columns`: array + * - `lengths`: array + * + * @return array> */ protected function getFulltextSchemaIndexes(string $collection): array { From e6cb341e81a49e8014d70e406e5f133485c079c7 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 15 Jun 2026 13:20:43 +0300 Subject: [PATCH 16/16] Sequence is immutable --- src/Database/Database.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index b12cd6434..5a6cd97d7 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6180,6 +6180,7 @@ public function updateDocument(string $collection, string $id, Document $documen $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); $document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collection ID + $document['$sequence'] = $old->getSequence(); // Sequence is immutable $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; if ($this->adapter->getSharedTables()) {