Skip to content

Fix ghost perms#813

Merged
abnegate merged 5 commits intomainfrom
fix-ghost-perms
Feb 16, 2026
Merged

Fix ghost perms#813
abnegate merged 5 commits intomainfrom
fix-ghost-perms

Conversation

@abnegate
Copy link
Member

@abnegate abnegate commented Feb 16, 2026

Summary by CodeRabbit

  • New Features

    • Transaction support that pins a single DB adapter for the full transaction lifecycle.
  • Bug Fixes

    • Improved duplicate-error detection to distinguish permission conflicts from unique-attribute violations.
    • Automatic cleanup and recovery of orphaned permission rows to allow document recreation.
  • Tests

    • New end-to-end tests for transaction atomicity, duplicate-error messages, and orphaned-permissions recovery.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 16, 2026

📝 Walkthrough

Walkthrough

Adds Pool::withTransaction to pin a single pooled adapter for the duration of a transaction, and updates MariaDB adapter to detect, delete, and retry on orphaned permission duplicate-key errors while refining duplicate-key error messages across adapters.

Changes

Cohort / File(s) Summary
Connection Pool / Transactions
src/Database/Adapter/Pool.php
Adds withTransaction(callable $callback): mixed and a pinned-adapter mechanism so a single underlying adapter instance services an entire transaction (re-applies adapter config, reuses pin for nested calls).
MariaDB adapter - permissions
src/Database/Adapter/MariaDB.php
Wraps permission insert in try/catch; on PDOException 23000/1062 with message containing _index1, selects and deletes orphaned permission rows (tenant-scoped if applicable) and retries the insert; enhances processException to map permission vs document duplicate cases.
Duplicate-error messaging (other adapters)
src/Database/Adapter/Mongo.php, src/Database/Adapter/Postgres.php, src/Database/Adapter/SQLite.php
Unifies/enhances duplicate-key handling: inspect exception message for _uid and return a distinct DuplicateException message when duplicates are due to other unique attributes vs the _uid case.
End-to-End tests: Pool & permissions
tests/e2e/Adapter/PoolTest.php
Adds execRawSQL helper (executes raw SQL via the pool) and testOrphanedPermissionsRecovery to simulate orphaned permission rows and verify MariaDB adapter cleanup + successful document recreation.
End-to-End tests: Duplicate messages
tests/e2e/Adapter/Scopes/DocumentTests.php
Adds testDuplicateExceptionMessages to assert DuplicateException messages distinguish between duplicate IDs and other unique-index violations; guarded by unique-index capability check.
End-to-End tests: Transaction atomicity
tests/e2e/Adapter/Scopes/GeneralTests.php
Adds testTransactionAtomicity to validate commit and rollback behavior via withTransaction (note: appears added twice in the diff).

Sequence Diagram(s)

sequenceDiagram
    rect rgba(200,200,255,0.5)
    participant Client
    participant Pool
    participant Adapter
    participant DB as Database (PDO)
    end

    Client->>Pool: withTransaction(callback)
    Pool->>Pool: Pin adapter instance from pool
    Pool->>Adapter: Reconfigure adapter (db, namespace, tenant, auth, timeout, debug, metadata)
    Adapter->>DB: beginTransaction()
    Adapter->>Adapter: Execute callback()
    alt Callback succeeds
        Adapter->>DB: commit()
        Adapter-->>Pool: result
    else Callback fails
        Adapter->>DB: rollback()
        Adapter-->>Pool: throw
    end
    Pool-->>Client: Return/Throw
Loading
sequenceDiagram
    rect rgba(255,220,200,0.5)
    participant Client
    participant MariaDB as Adapter (MariaDB)
    participant DB as Database (PDO)
    end

    Client->>MariaDB: createDocument(with permissions)
    MariaDB->>DB: INSERT permission
    alt PDOException (SQLSTATE 23000 / errorCode 1062) and message contains "_index1"
        DB-->>MariaDB: PDOException
        MariaDB->>DB: SELECT orphaned permission rows for document (tenant scoped if present)
        MariaDB->>DB: DELETE orphaned permission rows
        MariaDB->>DB: Retry INSERT permission
        DB-->>MariaDB: Success
    else Other duplicate / unique violation
        DB-->>MariaDB: PDOException
        MariaDB->>MariaDB: processException -> map to DuplicateException (permission vs document vs generic)
        MariaDB-->>Client: Throw DuplicateException
    end
    MariaDB-->>Client: Return created document (on success)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • fogelito

Poem

🐰 I dug a tidy tunnel, found a stray perm in the ground,
I nibbled out the duplicates, then retried without a sound.
I pinned a steady pathway so transactions never hop,
Now docs and perms are tidy — hop, commit, and never stop! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Fix ghost perms' is vague and uses non-descriptive shorthand terminology that doesn't clearly convey the specific technical changes in the PR. Use a more descriptive title that clarifies what 'ghost perms' means and the core fix, such as 'Fix orphaned permissions handling in document recreation' or similar.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-ghost-perms

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/Database/Adapter/MariaDB.php`:
- Around line 1908-1914: The current handling of SQL error messages incorrectly
constructs DuplicateException and uses inverted logic: in the block around
$message = $e->getMessage() check the condition for _uid should be positive (if
(str_contains($message, '_uid')) ...) and when instantiating DuplicateException
pass the exception code and the exception object as the second and third args
(i.e. $e->getCode(), $e) instead of passing $e as the code; update the branch
that checks for '_uid' to throw new DuplicateException('Document with the
requested unique attributes already exists', $e->getCode(), $e) and ensure the
other branch that checks '_index1' still returns DuplicateException('Duplicate
permissions for document', $e->getCode(), $e).

In `@tests/e2e/Adapter/Scopes/DocumentTests.php`:
- Around line 5052-5064: The test currently only accepts DuplicateException for
unique-index collisions; update the assertion in DocumentTests::(the test around
createDocument('duplicateMessages', ...)) to accept either DuplicateException or
UniqueException (or branch by adapter using the adapter class name/identifier)
and still assert the error message contains "unique attributes"; specifically
modify the try/catch to catch UniqueException as well (or inspect the adapter
via the MariaDB adapter symbol to decide expected exception) while keeping the
assertStringContainsString on $e->getMessage() unchanged so both MariaDB and
other adapters pass.
- Around line 5025-5067: Wrap the test's setup, actions, and assertions that
operate on the 'duplicateMessages' collection in a try/finally so
deleteCollection('duplicateMessages') is always executed; specifically, after
calling createCollection('duplicateMessages') (and creating attributes/index),
run the two createDocument(...) blocks and their DuplicateException assertions
inside a try block and move the existing deleteCollection('duplicateMessages')
into the finally block to guarantee cleanup even if an assertion or exception
(from createDocument, DuplicateException checks, or Document construction)
fails.

In `@tests/e2e/Adapter/Scopes/GeneralTests.php`:
- Around line 825-840: The call to $this->fail('Expected exception was not
thrown') is unreachable because Database::withTransaction(...) (used here)
re-throws the exception, so remove that dead statement; leave the try/catch that
calls $database->withTransaction(...) and keep the catch asserting the exception
message from the thrown \Exception, or alternatively replace the try/catch with
$this->expectExceptionMessage('Intentional failure to trigger rollback') before
invoking withTransaction() — locate the invocation of withTransaction and the
surrounding try/catch block to remove the unreachable $this->fail(...) call.
🧹 Nitpick comments (1)
src/Database/Adapter/Pool.php (1)

107-127: Consider extracting the shared configuration logic to reduce duplication.

The adapter configuration block (lines 108–124) is an exact copy of the one in delegate() (lines 41–57). If either changes independently, they'll drift apart.

♻️ Extract a helper method
+    private function configureAdapter(Adapter $adapter): void
+    {
+        $adapter->setDatabase($this->getDatabase());
+        $adapter->setNamespace($this->getNamespace());
+        $adapter->setSharedTables($this->getSharedTables());
+        $adapter->setTenant($this->getTenant());
+        $adapter->setAuthorization($this->authorization);
+
+        if ($this->getTimeout() > 0) {
+            $adapter->setTimeout($this->getTimeout());
+        }
+        $adapter->resetDebug();
+        foreach ($this->getDebug() as $key => $value) {
+            $adapter->setDebug($key, $value);
+        }
+        $adapter->resetMetadata();
+        foreach ($this->getMetadata() as $key => $value) {
+            $adapter->setMetadata($key, $value);
+        }
+    }
+
     public function delegate(string $method, array $args): mixed
     {
-        return $this->pool->use(function (Adapter $adapter) use ($method, $args) {
-            $adapter->setDatabase($this->getDatabase());
-            $adapter->setNamespace($this->getNamespace());
-            $adapter->setSharedTables($this->getSharedTables());
-            $adapter->setTenant($this->getTenant());
-            $adapter->setAuthorization($this->authorization);
-
-            if ($this->getTimeout() > 0) {
-                $adapter->setTimeout($this->getTimeout());
-            }
-            $adapter->resetDebug();
-            foreach ($this->getDebug() as $key => $value) {
-                $adapter->setDebug($key, $value);
-            }
-            $adapter->resetMetadata();
-            foreach ($this->getMetadata() as $key => $value) {
-                $adapter->setMetadata($key, $value);
-            }
-
+        return $this->pool->use(function (Adapter $adapter) use ($method, $args) {
+            $this->configureAdapter($adapter);
             return $adapter->{$method}(...$args);
         });
     }

Then use the same helper in withTransaction:

     public function withTransaction(callable $callback): mixed
     {
         return $this->pool->use(function (Adapter $adapter) use ($callback) {
-            $adapter->setDatabase($this->getDatabase());
-            ...
+            $this->configureAdapter($adapter);
             return $adapter->withTransaction($callback);
         });
     }

abnegate and others added 3 commits February 16, 2026 23:29
…transaction pinning

- Mongo, Postgres, SQLite adapters now return distinct messages for duplicate document ID
  ("Document already exists") vs unique index violations ("Document with the requested
  unique attributes already exists"), matching MariaDB behavior
- Pool adapter now pins a single connection for the entire withTransaction lifecycle,
  ensuring all delegate calls within a transaction use the same underlying connection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When Database::createDocument (and other methods) internally call
withTransaction, the Pool adapter now reuses the already-pinned
adapter instead of checking out a new one from the pool. This ensures
all nested transactions share the same connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/Database/Adapter/Mongo.php`:
- Around line 3486-3491: The duplicate-key handling in Mongo.php currently
returns DuplicateException for unique constraint errors (codes 11000/11001)
unless the message contains '_uid'; change it so that only collisions involving
the internal document identity (i.e., messages containing '_uid') return
DuplicateException, while all other unique-key violations (including
PRIMARY/_id) return UniqueException instead; update the branch that checks
$e->getCode() === 11000 || $e->getCode() === 11001 to return a new
UniqueException('Document with the requested unique attributes already exists',
$e->getCode(), $e) for non-'_uid' messages and keep DuplicateException for
'_uid' cases, referencing the same $e, str_contains check, DuplicateException
and UniqueException symbols.

In `@src/Database/Adapter/Pool.php`:
- Around line 115-149: The Pool class currently stores pinnedAdapter as instance
state which races when a Pool is shared across concurrent coroutines/fibers;
update the codebase to either (A) clearly document the ownership model so
callers know Pool must be instantiated per-request/per-coroutine (add a
class-level docblock on Pool and notes in constructor and DI config) or (B) make
pinnedAdapter context-aware by replacing the single property with a context map
keyed by the current execution context (e.g., Fiber::getCurrent() or the
runtime's coroutine id) so withTransaction and the pinnedAdapter lookup
store/retrieve adapter using that key; refer to the Pool::withTransaction method
and the pinnedAdapter property when making the change.

In `@src/Database/Adapter/Postgres.php`:
- Around line 2191-2196: The current Postgres exception handling block in the
code that inspects $e (the caught PDOException) returns DuplicateException for
non-_uid unique violations; change it so that when the error message does NOT
contain '_uid' you throw UniqueException('Document with the requested unique
attributes already exists', $e->getCode(), $e) instead of DuplicateException,
and keep DuplicateException only for cases where the message contains '_uid'.
Update the branch that currently returns new DuplicateException when
!\str_contains($message, '_uid') to return new UniqueException with the same
message and constructor args; leave the existing DuplicateException return for
the '_uid' path unchanged so behavior matches MariaDB adapter (see the block
handling $e->getCode() === '23505' and $e->errorInfo[1] === 7).

In `@src/Database/Adapter/SQLite.php`:
- Around line 1331-1334: The current SQLite adapter returns DuplicateException
for all unique constraint violations; change the non-_uid branch to throw
UniqueException instead so only violations whose message contains '_uid' return
DuplicateException; update the conditional in the code around where
DuplicateException is created (the block referencing $message and $e) to return
new DuplicateException('Document with the requested unique attributes already
exists', $e->getCode(), $e) when str_contains($message, '_uid') is true, and
return new UniqueException('Unique constraint violation', $e->getCode(), $e) for
all other unique constraint messages (mirror the behavior used in MariaDB.php).
🧹 Nitpick comments (1)
src/Database/Adapter/Pool.php (1)

123-140: Extract adapter configuration into a reusable private method.

The configuration block (lines 124–140) is a near-exact copy of lines 50–67 in delegate(). Extracting it avoids the risk of the two copies diverging when a new config setter is added.

♻️ Proposed refactor
+    private function configureAdapter(Adapter $adapter): void
+    {
+        $adapter->setDatabase($this->getDatabase());
+        $adapter->setNamespace($this->getNamespace());
+        $adapter->setSharedTables($this->getSharedTables());
+        $adapter->setTenant($this->getTenant());
+        $adapter->setAuthorization($this->authorization);
+
+        if ($this->getTimeout() > 0) {
+            $adapter->setTimeout($this->getTimeout());
+        }
+        $adapter->resetDebug();
+        foreach ($this->getDebug() as $key => $value) {
+            $adapter->setDebug($key, $value);
+        }
+        $adapter->resetMetadata();
+        foreach ($this->getMetadata() as $key => $value) {
+            $adapter->setMetadata($key, $value);
+        }
+    }

Then in both delegate() and withTransaction():

     return $this->pool->use(function (Adapter $adapter) use ($method, $args) {
-        // Run setters in case config changed since this connection was last used
-        $adapter->setDatabase($this->getDatabase());
-        $adapter->setNamespace($this->getNamespace());
-        ...
+        $this->configureAdapter($adapter);
         return $adapter->{$method}(...$args);
     });

Comment on lines +115 to +149
public function withTransaction(callable $callback): mixed
{
// If already inside a transaction, reuse the pinned adapter
// so nested withTransaction calls use the same connection
if ($this->pinnedAdapter !== null) {
return $this->pinnedAdapter->withTransaction($callback);
}

return $this->pool->use(function (Adapter $adapter) use ($callback) {
$adapter->setDatabase($this->getDatabase());
$adapter->setNamespace($this->getNamespace());
$adapter->setSharedTables($this->getSharedTables());
$adapter->setTenant($this->getTenant());
$adapter->setAuthorization($this->authorization);

if ($this->getTimeout() > 0) {
$adapter->setTimeout($this->getTimeout());
}
$adapter->resetDebug();
foreach ($this->getDebug() as $key => $value) {
$adapter->setDebug($key, $value);
}
$adapter->resetMetadata();
foreach ($this->getMetadata() as $key => $value) {
$adapter->setMetadata($key, $value);
}

$this->pinnedAdapter = $adapter;
try {
return $adapter->withTransaction($callback);
} finally {
$this->pinnedAdapter = null;
}
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how Pool adapter instances are created/shared in the codebase
echo "=== Pool instantiation patterns ==="
rg -n 'new Pool\(' --type php -g '!vendor/**' -g '!tests/**' | head -100

echo ""
echo "=== Pool::class bindings in DI containers ==="
rg -n 'Pool::class' --type php -g '!vendor/**' -g '!tests/**' | head -100

echo ""
echo "=== Check for singleton/shared container bindings ==="
rg -n -B2 -A2 '(singleton|share|shared).*[Pp]ool' --type php -g '!vendor/**' -g '!tests/**' | head -100

Repository: utopia-php/database

Length of output: 200


🏁 Script executed:

#!/bin/bash
# Check for Pool usage patterns (imports, instanceof, etc.)
echo "=== Import/use Pool patterns ==="
rg -n '(use.*Pool|instanceof Pool)' --type php -g '!vendor/**' | head -100

echo ""
echo "=== Pool class definition and interface ==="
rg -n 'class Pool\|interface.*Pool' --type php -g '!vendor/**' | head -50

echo ""
echo "=== Check test files for Pool usage ==="
rg -n 'new Pool\|Pool::class' --type php tests/ 2>/dev/null | head -100

echo ""
echo "=== Check if Pool is created through a factory or builder ==="
rg -n -B3 -A3 'function.*[Pp]ool|Pool.*factory' --type php -g '!vendor/**' | head -150

Repository: utopia-php/database

Length of output: 2067


🏁 Script executed:

#!/bin/bash
# Read the Pool test file to understand usage patterns
echo "=== Pool Test File ==="
cat -n tests/e2e/Adapter/PoolTest.php

echo ""
echo "=== Search for pool->use() patterns ==="
rg -n -B2 -A5 'pool->use\(' --type php -g '!vendor/**' | head -150

echo ""
echo "=== Check for Fiber/async documentation or comments ==="
rg -n -i '(fiber|coroutine|concurrent|async|swoole|thread)' --type php src/Database/Adapter/Pool.php

Repository: utopia-php/database

Length of output: 11805


🏁 Script executed:

#!/bin/bash
# Check for Database instantiation patterns in production code
echo "=== Database instantiation patterns ==="
rg -n 'new Database\(' --type php src/ | head -100

echo ""
echo "=== Check Pool class structure and properties ==="
head -100 src/Database/Adapter/Pool.php

echo ""
echo "=== Look for README or documentation ==="
find . -maxdepth 2 -name 'README*' -o -name '*.md' | head -10

Repository: utopia-php/database

Length of output: 3362


🏁 Script executed:

#!/bin/bash
# Check README for Pool usage documentation
echo "=== README Pool usage section ==="
grep -n -i -A10 -B2 'pool' README.md | head -100

echo ""
echo "=== Check SPEC for Pool design ==="
grep -n -i -A10 'pool\|transaction\|concurrent\|coroutine' SPEC.md | head -150

Repository: utopia-php/database

Length of output: 132


🏁 Script executed:

#!/bin/bash
# Read full README
echo "=== Full README.md ==="
cat README.md

echo ""
echo "=== Search for UtopiaPool usage documentation ==="
rg -n 'UtopiaPool\|Pools\\Pool' --type php -B5 -A5 src/ | head -200

Repository: utopia-php/database

Length of output: 26019


🏁 Script executed:

#!/bin/bash
# Check if there are any concurrency-related comments or patterns
echo "=== Search for concurrency/thread safety patterns in Pool ==="
cat -n src/Database/Adapter/Pool.php | grep -i -A5 -B5 'thread\|concurrent\|coroutine\|fiber\|async\|race\|mutex\|lock'

echo ""
echo "=== Check Pool's usage of delegate() with pinnedAdapter ==="
cat -n src/Database/Adapter/Pool.php | head -150

echo ""
echo "=== Search for any container/DI configuration files ==="
find . -maxdepth 3 -name 'config*' -o -name 'container*' -o -name 'services*' | grep -v vendor | grep -v node_modules

echo ""
echo "=== Check if there are any examples or integration tests showing shared Pool usage ==="
find . -path ./vendor -prune -o -name '*example*' -o -name '*integration*' -type f -print 2>/dev/null | head -20

Repository: utopia-php/database

Length of output: 7023


Document Pool ownership model regarding shared instances in async runtimes.

pinnedAdapter stored as mutable instance state could race if a single Pool instance were shared across concurrent coroutines (e.g., Swoole, Fibers). Coroutine B entering withTransaction while A's transaction is active would see A's pinnedAdapter and incorrectly route to A's connection, silently corrupting both transactions.

While Pool is dependency-injected (suggesting per-request instantiation), the codebase lacks documentation or configuration showing the intended ownership model. Add documentation clarifying whether Pool instances must be instantiated per-request/per-coroutine, or refactor to use context-aware storage (e.g., Fiber::getCurrent() key or similar) instead of instance state to make the design explicitly safe for shared instances.

🤖 Prompt for AI Agents
In `@src/Database/Adapter/Pool.php` around lines 115 - 149, The Pool class
currently stores pinnedAdapter as instance state which races when a Pool is
shared across concurrent coroutines/fibers; update the codebase to either (A)
clearly document the ownership model so callers know Pool must be instantiated
per-request/per-coroutine (add a class-level docblock on Pool and notes in
constructor and DI config) or (B) make pinnedAdapter context-aware by replacing
the single property with a context map keyed by the current execution context
(e.g., Fiber::getCurrent() or the runtime's coroutine id) so withTransaction and
the pinnedAdapter lookup store/retrieve adapter using that key; refer to the
Pool::withTransaction method and the pinnedAdapter property when making the
change.

@abnegate abnegate merged commit adfdf20 into main Feb 16, 2026
18 checks passed
@abnegate abnegate deleted the fix-ghost-perms branch February 16, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant