From 480f8c97ca414113f7d2fa3d766edf4f794ee286 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 11 Aug 2025 08:25:58 +0000 Subject: [PATCH] Add transaction support for databases with staging and commit/rollback Co-authored-by: jakeb994 --- .../Documents/Attribute/Decrement.php | 3 +- .../Documents/Attribute/Increment.php | 3 +- .../Collections/Documents/Bulk/Delete.php | 3 +- .../Collections/Documents/Bulk/Update.php | 3 +- .../Collections/Documents/Bulk/Upsert.php | 3 +- .../Collections/Documents/Create.php | 3 +- .../Collections/Documents/Delete.php | 3 +- .../Collections/Documents/Update.php | 3 +- .../Collections/Documents/Upsert.php | 3 +- .../Http/Grids/Tables/Rows/Bulk/Delete.php | 1 + .../Http/Grids/Tables/Rows/Bulk/Update.php | 1 + .../Http/Grids/Tables/Rows/Bulk/Upsert.php | 1 + .../Grids/Tables/Rows/Column/Decrement.php | 1 + .../Grids/Tables/Rows/Column/Increment.php | 1 + .../Http/Grids/Tables/Rows/Create.php | 1 + .../Http/Grids/Tables/Rows/Delete.php | 1 + .../Http/Grids/Tables/Rows/Update.php | 1 + .../Http/Grids/Tables/Rows/Upsert.php | 1 + .../Http/Transactions/AddOperations.php | 117 ++++++++ .../Databases/Http/Transactions/Create.php | 73 +++++ .../Databases/Http/Transactions/Delete.php | 76 ++++++ .../Databases/Http/Transactions/Get.php | 69 +++++ .../Databases/Http/Transactions/Update.php | 249 ++++++++++++++++++ .../Databases/Http/Transactions/XList.php | 73 +++++ 24 files changed, 684 insertions(+), 9 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Transactions/AddOperations.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Transactions/Create.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Transactions/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Transactions/Get.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Transactions/Update.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Transactions/XList.php diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index 49a408e64c..fa6bc93845 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -74,6 +74,7 @@ class Decrement extends Action ->param('attribute', '', new Key(), 'Attribute key.') ->param('value', 1, new Numeric(), 'Value to increment the attribute by. The value must be a number.', true) ->param('min', null, new Numeric(), 'Minimum value for the attribute. If the current value is lesser than this value, an exception will be thrown.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') @@ -81,7 +82,7 @@ class Decrement extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index 5eadc96b9e..5bfbc96c7b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -74,6 +74,7 @@ class Increment extends Action ->param('attribute', '', new Key(), 'Attribute key.') ->param('value', 1, new Numeric(), 'Value to increment the attribute by. The value must be a number.', true) ->param('max', null, new Numeric(), 'Maximum value for the attribute. If the current value is greater than this value, an error will be thrown.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') @@ -81,7 +82,7 @@ class Increment extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php index f44e54f2b4..42206983bd 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php @@ -70,6 +70,7 @@ class Delete extends Action ->param('databaseId', '', new UID(), 'Database ID.') ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForStatsUsage') @@ -81,7 +82,7 @@ class Delete extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan): void { $database = $dbForProject->getDocument('databases', $databaseId); if ($database->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php index 82b39ef178..0a199c6eb7 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php @@ -74,6 +74,7 @@ class Update extends Action ->param('collectionId', '', new UID(), 'Collection ID.') ->param('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', true) ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForStatsUsage') @@ -85,7 +86,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string|array $data, array $queries, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan): void + public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan): void { $data = \is_string($data) ? \json_decode($data, true) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php index 23e6453138..01844b52f4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php @@ -71,6 +71,7 @@ class Upsert extends Action ->param('databaseId', '', new UID(), 'Database ID.') ->param('collectionId', '', new UID(), 'Collection ID.') ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of document data as JSON objects. May contain partial documents.', false, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForStatsUsage') @@ -78,7 +79,7 @@ class Upsert extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $documents, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, array $plan): void + public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, array $plan): void { $database = $dbForProject->getDocument('databases', $databaseId); if ($database->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 0691249943..5d4629e7a5 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -117,6 +117,7 @@ class Create extends Action ->param('data', [], new JSON(), 'Document data as JSON object.', true) ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('user') @@ -127,7 +128,7 @@ class Create extends Action ->inject('queueForWebhooks') ->callback($this->action(...)); } - public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks): void + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks): void { $data = \is_string($data) ? \json_decode($data, true) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index 7bc81ab7db..75377fe42b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -71,6 +71,7 @@ class Delete extends Action ->param('databaseId', '', new UID(), 'Database ID.') ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') ->param('documentId', '', new UID(), 'Document ID.') + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') @@ -79,7 +80,7 @@ class Delete extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + public function action(string $databaseId, string $collectionId, string $documentId, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index 1d06e6d0da..cda4563a4c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -77,6 +77,7 @@ class Update extends Action ->param('documentId', '', new UID(), 'Document ID.') ->param('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', true) ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') @@ -85,7 +86,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index e862896a40..bdd5c1de0a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -80,6 +80,7 @@ class Upsert extends Action ->param('documentId', '', new CustomId(), 'Document ID.') ->param('data', [], new JSON(), 'Document data as JSON object. Include all required attributes of the document to be created or updated.') ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('requestTimestamp') ->inject('response') ->inject('user') @@ -89,7 +90,7 @@ class Upsert extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Delete.php index 6816fc3b13..e040757b7a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Delete.php @@ -56,6 +56,7 @@ class Delete extends DocumentsDelete ->param('databaseId', '', new UID(), 'Database ID.') ->param('tableId', '', new UID(), 'Table ID. You can create a new table using the Database service [server integration](https://appwrite.io/docs/server/tables#tablesCreate).') ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForStatsUsage') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Update.php index 8c640f00f8..72381ee38f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Update.php @@ -58,6 +58,7 @@ class Update extends DocumentsUpdate ->param('tableId', '', new UID(), 'Table ID.') ->param('data', [], new JSON(), 'Row data as JSON object. Include only column and value pairs to be updated.', true) ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForStatsUsage') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Upsert.php index 35f0a99bdc..230f81676a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Bulk/Upsert.php @@ -58,6 +58,7 @@ class Upsert extends DocumentsUpsert ->param('databaseId', '', new UID(), 'Database ID.') ->param('tableId', '', new UID(), 'Table ID.') ->param('rows', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of row data as JSON objects. May contain partial rows.', false, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForStatsUsage') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Column/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Column/Decrement.php index 272510335f..cb04e118ca 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Column/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Column/Decrement.php @@ -60,6 +60,7 @@ class Decrement extends DecrementDocumentAttribute ->param('column', '', new Key(), 'Column key.') ->param('value', 1, new Numeric(), 'Value to increment the column by. The value must be a number.', true) ->param('min', null, new Numeric(), 'Minimum value for the column. If the current value is lesser than this value, an exception will be thrown.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Column/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Column/Increment.php index 2a28418a6e..4e36a2b912 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Column/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Column/Increment.php @@ -60,6 +60,7 @@ class Increment extends IncrementDocumentAttribute ->param('column', '', new Key(), 'Column key.') ->param('value', 1, new Numeric(), 'Value to increment the column by. The value must be a number.', true) ->param('max', null, new Numeric(), 'Maximum value for the column. If the current value is greater than this value, an error will be thrown.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Create.php index d5a84bbb7c..dfbb9a61d2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Create.php @@ -96,6 +96,7 @@ class Create extends DocumentCreate ->param('data', [], new JSON(), 'Row data as JSON object.', true) ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('rows', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('response') ->inject('dbForProject') ->inject('user') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Delete.php index f821aabe6e..b13b17b8cd 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Delete.php @@ -61,6 +61,7 @@ class Delete extends DocumentDelete ->param('databaseId', '', new UID(), 'Database ID.') ->param('tableId', '', new UID(), 'Table ID. You can create a new table using the Database service [server integration](https://appwrite.io/docs/server/tables#tablesCreate).') ->param('rowId', '', new UID(), 'Row ID.') + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Update.php index 7731e10e9d..c6ce1a9795 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Update.php @@ -60,6 +60,7 @@ class Update extends DocumentUpdate ->param('rowId', '', new UID(), 'Row ID.') ->param('data', [], new JSON(), 'Row data as JSON object. Include only columns and value pairs to be updated.', true) ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Upsert.php index d9756eab87..157956ca18 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Grids/Tables/Rows/Upsert.php @@ -62,6 +62,7 @@ class Upsert extends DocumentUpsert ->param('rowId', '', new UID(), 'Row ID.') ->param('data', [], new JSON(), 'Row data as JSON object. Include all required columns of the row to be created or updated.', true) ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) ->inject('requestTimestamp') ->inject('response') ->inject('user') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Transactions/AddOperations.php b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/AddOperations.php new file mode 100644 index 0000000000..197148a18a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/AddOperations.php @@ -0,0 +1,117 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/databases/transactions/:transactionId/operations') + ->desc('Add operations to transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'transactions.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'transactions', + name: 'createOperations', + description: '/docs/references/databases/create-operations.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('operations', [], new ArrayList(new Operation()), 'Array of staged operations.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('plan') + ->callback($this->action(...)); + } + + public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, array $plan): void + { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + $maxBatch = $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH; + $existing = $transaction->getAttribute('operations', 0); + + if (($existing + \count($operations)) > $maxBatch) { + throw new Exception( + Exception::TRANSACTION_LIMIT_EXCEEDED, + 'Transaction already has ' . $existing . ' operations, adding ' . \count($operations) . ' would exceed the maximum of ' . $maxBatch + ); + } + + $databases = $collections = $staged = []; + foreach ($operations as $operation) { + $database = $databases[$operation['databaseId']] ??= $dbForProject->getDocument('databases', $operation['databaseId']); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $collections[$operation['collectionId']] ??= $dbForProject->getDocument('database_' . $database->getSequence(), $operation['collectionId']); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $staged[] = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => $operation['documentId'] ?? ID::unique(), + 'action' => $operation['action'], + 'data' => $operation['data'] ?? new \stdClass(), + ]); + } + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged, $existing, $operations) { + $dbForProject->createDocuments('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + \count($operations) + ); + }); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) + ->dynamic($transaction, UtopiaResponse::MODEL_TRANSACTION); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Create.php new file mode 100644 index 0000000000..9c4577c4c6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Create.php @@ -0,0 +1,73 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/databases/transactions') + ->desc('Create transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'transactions.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'transactions', + name: 'createTransaction', + description: '/docs/references/databases/create-transaction.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(int $ttl, UtopiaResponse $response, Database $dbForProject): void + { + $transaction = $dbForProject->createDocument('transactions', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'operations' => 0, + 'expiresAt' => DateTime::addSeconds(new \DateTime(), $ttl), + ])); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) + ->dynamic($transaction, UtopiaResponse::MODEL_TRANSACTION); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Delete.php new file mode 100644 index 0000000000..ae6f41fb2d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Delete.php @@ -0,0 +1,76 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/databases/transactions/:transactionId') + ->desc('Delete transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'transactions.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'transactions', + name: 'deleteTransaction', + description: '/docs/references/databases/delete-transaction.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $transactionId, UtopiaResponse $response, Database $dbForProject): void + { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + + if ($transaction->isEmpty()) { + throw new Exception(Exception::TRANSACTION_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('transactions', $transactionId)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove transaction from DB'); + } + + $dbForProject->deleteDocuments('transactionLogs', [ + Query::equal('transactionInternalId', [$transaction->getSequence()]), + ]); + + $response->noContent(); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Get.php new file mode 100644 index 0000000000..1a8286e658 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Get.php @@ -0,0 +1,69 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/transactions/:transactionId') + ->desc('Get transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'transactions.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'transactions', + name: 'getTransaction', + description: '/docs/references/databases/get-transaction.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $transactionId, UtopiaResponse $response, Database $dbForProject): void + { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + + if ($transaction->isEmpty()) { + throw new Exception(Exception::TRANSACTION_NOT_FOUND); + } + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($transaction, UtopiaResponse::MODEL_TRANSACTION); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Update.php new file mode 100644 index 0000000000..d32e8d4c20 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/Update.php @@ -0,0 +1,249 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/databases/transactions/:transactionId') + ->desc('Update transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'transactions', + name: 'updateTransaction', + description: '/docs/references/databases/update-transaction.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('commit', false, new Boolean(), 'Commit transaction?', true) + ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->callback($this->action(...)); + } + + public function action(string $transactionId, bool $commit, bool $rollback, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Document $project): void + { + if (!$commit && !$rollback) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true'); + } + if ($commit && $rollback) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time'); + } + + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty()) { + throw new Exception(Exception::TRANSACTION_NOT_FOUND); + } + if ($transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::TRANSACTION_NOT_READY); + } + + $now = new \DateTime(); + $expiresAt = new \DateTime($transaction->getAttribute('expiresAt', 'now')); + if ($now > $expiresAt) { + throw new Exception(Exception::TRANSACTION_EXPIRED); + } + + if ($commit) { + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $transaction, $requestTimestamp) { + $dbForProject->updateDocument('transactions', $transactionId, new Document([ + 'status' => 'committing', + ])); + + $operations = $dbForProject->find('transactionLogs', [ + Query::equal('transactionInternalId', [$transaction->getSequence()]), + ]); + + $creates + = $updates + = $deletes + = $increments + = $decrements + = $bulkUpdates + = $bulkDeletes + = []; + + foreach ($operations as $operation) { + $databaseInternalId = $operation['databaseInternalId']; + $collectionInternalId = $operation['collectionInternalId']; + $documentId = $operation['documentId']; + + switch ($operation['action']) { + case 'create': + $creates[$databaseInternalId][$collectionInternalId][] = new Document([ + '$id' => $documentId ?? ID::unique(), + ...$operation['data'] + ]); + break; + case 'update': + case 'upsert': + $updates[$databaseInternalId][$collectionInternalId][] = new Document([ + '$id' => $documentId, + ...$operation['data'], + ]); + break; + case 'delete': + $deletes[$databaseInternalId][$collectionInternalId][] = $documentId; + break; + case 'increment': + $increments[$databaseInternalId][$collectionInternalId][] = [ + 'attribute' => $operation['data']['attribute'], + 'value' => $operation['data']['value'] ?? 1, + 'max' => $operation['data']['max'] ?? null, + ]; + break; + case 'decrement': + $decrements[$databaseInternalId][$collectionInternalId][] = [ + 'attribute' => $operation['data']['attribute'], + 'value' => $operation['data']['value'] ?? 1, + 'min' => $operation['data']['min'] ?? null, + ]; + break; + case 'bulkUpdate': + $bulkUpdates[$databaseInternalId][$collectionInternalId][] = [ + 'data' => $operation['data']['data'] ?? null, + 'queries' => $operation['data']['queries'] ?? [], + ]; + break; + case 'bulkDelete': + $bulkDeletes[$databaseInternalId][$collectionInternalId][] = [ + 'queries' => $operation['data']['queries'] ?? [], + ]; + break; + } + } + + try { + foreach ($creates as $dbId => $cols) { + foreach ($cols as $colId => $docs) { + $dbForProject->createDocuments("database_{$dbId}_collection_{$colId}", $docs); + } + } + foreach ($updates as $dbId => $cols) { + foreach ($cols as $colId => $docs) { + $dbForProject->createOrUpdateDocuments("database_{$dbId}_collection_{$colId}", $docs); + } + } + foreach ($deletes as $dbId => $cols) { + foreach ($cols as $colId => $ids) { + $dbForProject->deleteDocuments("database_{$dbId}_collection_{$colId}", [ + Query::equal('$id', $ids), + ]); + } + } + foreach ($increments as $dbId => $cols) { + foreach ($cols as $colId => $increments) { + foreach ($increments as $increment) { + $dbForProject->increaseDocumentAttribute( + "database_{$dbId}_collection_{$colId}", + $increment['attribute'], + $increment['value'], + $increment['max'] + ); + } + } + } + foreach ($decrements as $dbId => $cols) { + foreach ($cols as $colId => $decrements) { + foreach ($decrements as $decrement) { + $dbForProject->decreaseDocumentAttribute( + "database_{$dbId}_collection_{$colId}", + $decrement['attribute'], + $decrement['value'], + $decrement['min'] + ); + } + } + } + foreach ($bulkUpdates as $dbId => $cols) { + foreach ($cols as $colId => $updates) { + foreach ($updates as $update) { + $dbForProject->updateDocuments("database_{$dbId}_collection_{$colId}", $update['data'], $update['queries']); + } + } + } + foreach ($bulkDeletes as $dbId => $cols) { + foreach ($cols as $colId => $deletes) { + foreach ($deletes as $delete) { + $dbForProject->deleteDocuments("database_{$dbId}_collection_{$colId}", $delete['queries']); + } + } + } + + $dbForProject->updateDocument('transactions', $transactionId, new Document([ + 'status' => 'committed', + ])); + + $dbForProject->deleteDocuments('transactionLogs', [ + Query::equal('transactionInternalId', [$transaction->getSequence()]), + ]); + } catch (DuplicateException|ConflictException) { + $dbForProject->updateDocument('transactions', $transactionId, new Document([ + 'status' => 'failed', + ])); + + throw new Exception(Exception::TRANSACTION_CONFLICT); + } + }); + + $transaction = $dbForProject->getDocument('transactions', $transactionId); + } + + if ($rollback) { + $dbForProject->deleteDocuments('transactionLogs', [ + Query::equal('transactionInternalId', [$transaction->getSequence()]), + ]); + + $transaction = $dbForProject->updateDocument('transactions', $transactionId, new Document([ + 'status' => 'rolledBack', + ])); + } + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($transaction, UtopiaResponse::MODEL_TRANSACTION); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Transactions/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/XList.php new file mode 100644 index 0000000000..ee58807d6f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Transactions/XList.php @@ -0,0 +1,73 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/transactions') + ->desc('List transactions') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'transactions.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'transactions', + name: 'listTransactions', + description: '/docs/references/databases/list-transactions.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Transactions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries).', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(array $queries, UtopiaResponse $response, Database $dbForProject): void + { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $response->dynamic(new Document([ + 'transactions' => $dbForProject->find('transactions', $queries), + 'total' => $dbForProject->count('transactions', $queries), + ]), UtopiaResponse::MODEL_TRANSACTION_LIST); + } +} \ No newline at end of file