From 8feb70dcb1b5608df0776b4954776f0c5193dcfe Mon Sep 17 00:00:00 2001 From: Bilux Date: Sat, 23 Aug 2025 09:20:47 +0000 Subject: [PATCH 1/8] wip: add transaction support to clients Signed-off-by: Bilux --- .pre-commit-config.yaml | 34 ++-- client/dart/lib/src/client.dart | 13 +- client/dart/lib/src/transaction.dart | 164 +++++++++++++++ client/dart/lib/trailbase.dart | 1 + client/dart/test/trailbase_test.dart | 37 ++++ client/dotnet/test/ClientTest.cs | 67 +++++++ client/dotnet/trailbase/Client.cs | 5 + client/dotnet/trailbase/TransactionApi.cs | 185 +++++++++++++++++ client/go/trailbase/client.go | 9 + client/go/trailbase/client_test.go | 46 +++++ client/go/trailbase/transaction_api.go | 159 +++++++++++++++ client/python/tests/test_client.py | 34 ++++ client/python/trailbase/__init__.py | 121 +++++++++++- .../Sources/TrailBase/TrailBase.swift | 6 +- .../Sources/TrailBase/Transaction.swift | 187 ++++++++++++++++++ .../Tests/TrailBaseTests/TrailBaseTests.swift | 45 +++++ crates/assets/js/client/src/index.ts | 100 ++++++++++ .../integration/client_integration.test.ts | 52 +++++ crates/client/src/lib.rs | 111 +++++++++++ crates/client/tests/integration_test.rs | 44 +++++ 20 files changed, 1396 insertions(+), 24 deletions(-) create mode 100644 client/dart/lib/src/transaction.dart create mode 100644 client/dotnet/trailbase/TransactionApi.cs create mode 100644 client/go/trailbase/transaction_api.go create mode 100644 client/swift/trailbase/Sources/TrailBase/Transaction.swift diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5dd5b77e0..2e0831271 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -142,6 +142,23 @@ repos: types: [python] pass_filenames: false + ### Go client + - id: go_format + name: Go format + # gofmt always returns zero exit code :sigh: + entry: sh -c 'DIFF=$(gofmt -d -e client/go/trailbase/) && echo "${DIFF}" && test -z "${DIFF}"' + language: system + types: [go] + files: .*\.(go)$ + pass_filenames: false + + - id: go_test + name: Go test + entry: sh -c 'cd client/go/trailbase && go test -v' + language: system + types: [go] + pass_filenames: false + ### Swift client - id: swift_format name: Swift format @@ -160,20 +177,3 @@ repos: language: system types: [swift] pass_filenames: false - - ### Go client - - id: go_format - name: Go format - # gofmt always returns zero exit code :sigh: - entry: sh -c 'DIFF=$(gofmt -d -e client/go/trailbase/) && echo "${DIFF}" && test -z "${DIFF}"' - language: system - types: [go] - files: .*\.(go)$ - pass_filenames: false - - - id: go_test - name: Go test - entry: sh -c 'cd client/go/trailbase && go test -v' - language: system - types: [go] - pass_filenames: false diff --git a/client/dart/lib/src/client.dart b/client/dart/lib/src/client.dart index 441071573..ba3d752ad 100644 --- a/client/dart/lib/src/client.dart +++ b/client/dart/lib/src/client.dart @@ -6,6 +6,7 @@ import 'package:logging/logging.dart'; import 'package:dio/dio.dart' as dio; import 'sse.dart'; +import 'transaction.dart'; class User { final String id; @@ -135,12 +136,12 @@ abstract class RecordId { factory RecordId.uuid(String id) => _UuidRecordId(id); } -class _ResponseRecordIds { +class ResponseRecordIds { final List _ids; - const _ResponseRecordIds(this._ids); + const ResponseRecordIds(this._ids); - _ResponseRecordIds.fromJson(Map json) + ResponseRecordIds.fromJson(Map json) : _ids = (json['ids'] as List).cast(); List toRecordIds() { @@ -416,7 +417,7 @@ class RecordApi { if ((response.statusCode ?? 400) > 200) { throw Exception('${response.data} ${response.statusMessage}'); } - final responseIds = _ResponseRecordIds.fromJson(response.data); + final responseIds = ResponseRecordIds.fromJson(response.data); assert(responseIds._ids.length == 1); return responseIds.toRecordIds()[0]; } @@ -431,7 +432,7 @@ class RecordApi { if ((response.statusCode ?? 400) > 200) { throw Exception('${response.data} ${response.statusMessage}'); } - final responseIds = _ResponseRecordIds.fromJson(response.data); + final responseIds = ResponseRecordIds.fromJson(response.data); return responseIds.toRecordIds(); } @@ -587,6 +588,8 @@ class Client { RecordApi records(String name) => RecordApi(this, name); + TransactionBatch transaction() => TransactionBatch(this); + _TokenState _updateTokens(Tokens? tokens) { final state = _TokenState.build(tokens); diff --git a/client/dart/lib/src/transaction.dart b/client/dart/lib/src/transaction.dart new file mode 100644 index 000000000..7d4b7bce8 --- /dev/null +++ b/client/dart/lib/src/transaction.dart @@ -0,0 +1,164 @@ +import 'package:trailbase/src/client.dart'; + +class Operation { + CreateOperation? create; + UpdateOperation? update; + DeleteOperation? delete; + + Operation({this.create, this.update, this.delete}); + + Map toJson() { + final Map data = {}; + if (create != null) { + data['Create'] = create!.toJson(); + } + if (update != null) { + data['Update'] = update!.toJson(); + } + if (delete != null) { + data['Delete'] = delete!.toJson(); + } + return data; + } +} + +class CreateOperation { + String apiName; + Map value; + + CreateOperation({required this.apiName, required this.value}); + + Map toJson() { + final Map data = {}; + data['api_name'] = apiName; + data['value'] = value; + return data; + } +} + +class UpdateOperation { + String apiName; + RecordId recordId; + Map value; + + UpdateOperation({ + required this.apiName, + required this.recordId, + required this.value, + }); + + Map toJson() { + final Map data = {}; + data['api_name'] = apiName; + data['record_id'] = recordId.toString(); + data['value'] = value; + return data; + } +} + +class DeleteOperation { + String apiName; + RecordId recordId; + + DeleteOperation({required this.apiName, required this.recordId}); + + Map toJson() { + final Map data = {}; + data['api_name'] = apiName; + data['record_id'] = recordId.toString(); + return data; + } +} + +class TransactionRequest { + List operations; + + TransactionRequest({required this.operations}); + + Map toJson() { + final Map data = {}; + data['operations'] = operations.map((e) => e.toJson()).toList(); + return data; + } +} + +abstract class ITransactionBatch { + IApiBatch api(String apiName); + Future> send(); +} + +abstract class IApiBatch { + ITransactionBatch create(Map value); + ITransactionBatch update(RecordId recordId, Map value); + ITransactionBatch delete(RecordId recordId); +} + +class TransactionBatch implements ITransactionBatch { + final Client _client; + final List _operations = []; + + TransactionBatch(this._client); + + @override + IApiBatch api(String apiName) { + return ApiBatch(this, apiName); + } + + @override + Future> send() async { + final request = TransactionRequest(operations: _operations); + final response = await _client.fetch( + 'api/transaction/v1/execute', + method: 'POST', + data: request.toJson(), + ); + + if ((response.statusCode ?? 400) > 200) { + throw Exception('${response.data} ${response.statusMessage}'); + } + + final result = ResponseRecordIds.fromJson(response.data); + return result.toRecordIds(); + } + + void addOperation(Operation operation) { + _operations.add(operation); + } +} + +class ApiBatch implements IApiBatch { + final TransactionBatch _batch; + final String _apiName; + + ApiBatch(this._batch, this._apiName); + + @override + ITransactionBatch create(Map value) { + _batch.addOperation( + Operation(create: CreateOperation(apiName: _apiName, value: value)), + ); + return _batch; + } + + @override + ITransactionBatch update(RecordId recordId, Map value) { + _batch.addOperation( + Operation( + update: UpdateOperation( + apiName: _apiName, + recordId: recordId, + value: value, + ), + ), + ); + return _batch; + } + + @override + ITransactionBatch delete(RecordId recordId) { + _batch.addOperation( + Operation(delete: DeleteOperation(apiName: _apiName, recordId: recordId)), + ); + return _batch; + } +} diff --git a/client/dart/lib/trailbase.dart b/client/dart/lib/trailbase.dart index 1d840900f..59d96d5e4 100644 --- a/client/dart/lib/trailbase.dart +++ b/client/dart/lib/trailbase.dart @@ -3,3 +3,4 @@ library; export 'src/client.dart'; export 'src/pkce.dart'; export 'src/sse.dart'; +export 'src/transaction.dart'; diff --git a/client/dart/test/trailbase_test.dart b/client/dart/test/trailbase_test.dart index 7dffa0eeb..d26639b0d 100644 --- a/client/dart/test/trailbase_test.dart +++ b/client/dart/test/trailbase_test.dart @@ -446,5 +446,42 @@ Future main() async { textNotNull: createMessage, )); }); + + test('transaction', () async { + final client = await connect(); + final api = client.records('simple_strict_table'); + + var ids = []; + + { + final batch = client.transaction(); + final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final message = 'dart transaction test create: ${now}'; + batch.api('simple_strict_table').create({'text_not_null': message}); + ids = await batch.send(); + expect(ids.length, equals(1)); + final record = SimpleStrict.fromJson(await api.read(ids[0])); + expect(record.textNotNull, message); + } + + { + final batch = client.transaction(); + final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final message = 'dart transaction test update: ${now}'; + batch.api('simple_strict_table').update(ids[0], { + 'text_not_null': message, + }); + await batch.send(); + final record = SimpleStrict.fromJson(await api.read(ids[0])); + expect(record.textNotNull, message); + } + + { + final batch = client.transaction(); + batch.api('simple_strict_table').delete(ids[0]); + await batch.send(); + expect(() async => await api.read(ids[0]), throwsException); + } + }); }); } diff --git a/client/dotnet/test/ClientTest.cs b/client/dotnet/test/ClientTest.cs index 5adf2817c..b5cb6ce7a 100644 --- a/client/dotnet/test/ClientTest.cs +++ b/client/dotnet/test/ClientTest.cs @@ -435,4 +435,71 @@ await api.Update( Assert.True(tableEvents[2] is DeleteEvent); Assert.Equal(UpdatedMessage, tableEvents[2].Value!["text_not_null"]?.ToString()); } + + [Fact] + [RequiresDynamicCode("Testing dynamic code")] + [RequiresUnreferencedCode("testing dynamic code")] + public async Task TransactionCreateOperationTest() { + var client = await ClientTest.Connect(); + var batch = client.Transaction(); + var api = client.Records("simple_strict_table"); + + var now = DateTimeOffset.Now.ToUnixTimeSeconds(); + var suffix = $"{now} {System.Environment.Version} transaction"; + var record = new SimpleStrict(null, null, null, $"C# transaction create test: {suffix}"); + + batch.Api("simple_strict_table").Create(record, SerializeSimpleStrictContext.Default.SimpleStrict); + + // Test actual creation + var ids = await batch.Send(); + Assert.Single(ids); + + var createdRecord = await api.Read(ids[0]); + Assert.Equal(record.text_not_null, createdRecord!.text_not_null); + } + + [Fact] + [RequiresDynamicCode("Testing dynamic code")] + [RequiresUnreferencedCode("testing dynamic code")] + public async Task TransactionUpdateOperationTest() { + var client = await ClientTest.Connect(); + var batch = client.Transaction(); + var api = client.Records("simple_strict_table"); + + // First create a record to update + var now = DateTimeOffset.Now.ToUnixTimeSeconds(); + var suffix = $"{now} {System.Environment.Version} transaction"; + var createRecord = new SimpleStrict(null, null, null, $"C# transaction update test original: {suffix}"); + var id = await api.Create(createRecord, SerializeSimpleStrictContext.Default.SimpleStrict); + + // Update operation + var updateRecord = new SimpleStrict(null, null, null, $"C# transaction update test modified: {suffix}"); + batch.Api("simple_strict_table").Update(id, updateRecord, SerializeSimpleStrictContext.Default.SimpleStrict); + + // Test actual update + await batch.Send(); + var updatedRecord = await api.Read(id); + Assert.Equal(updateRecord.text_not_null, updatedRecord!.text_not_null); + } + + [Fact] + [RequiresDynamicCode("Testing dynamic code")] + [RequiresUnreferencedCode("testing dynamic code")] + public async Task TransactionDeleteOperationTest() { + var client = await ClientTest.Connect(); + var batch = client.Transaction(); + var api = client.Records("simple_strict_table"); + + // First create a record to delete + var now = DateTimeOffset.Now.ToUnixTimeSeconds(); + var suffix = $"{now} {System.Environment.Version} transaction"; + var createRecord = new SimpleStrict(null, null, null, $"C# transaction delete test: {suffix}"); + var id = await api.Create(createRecord, SerializeSimpleStrictContext.Default.SimpleStrict); + + batch.Api("simple_strict_table").Delete(id); + + // Test actual deletion + await batch.Send(); + await Assert.ThrowsAsync(() => api.Read(id)); + } } diff --git a/client/dotnet/trailbase/Client.cs b/client/dotnet/trailbase/Client.cs index a794f11dc..e4a1365aa 100644 --- a/client/dotnet/trailbase/Client.cs +++ b/client/dotnet/trailbase/Client.cs @@ -292,6 +292,11 @@ public RecordApi Records(string name) { return new RecordApi(this, name); } + /// Create a new transaction batch. + public ITransactionBatch Transaction() { + return new TransactionBatch(this); + } + /// Log in with the given credentials. public async Task Login(string email, string password) { var response = await Fetch( diff --git a/client/dotnet/trailbase/TransactionApi.cs b/client/dotnet/trailbase/TransactionApi.cs new file mode 100644 index 000000000..f84450cfd --- /dev/null +++ b/client/dotnet/trailbase/TransactionApi.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading.Tasks; + +namespace TrailBase; + +[JsonConverter(typeof(OperationJsonConverter))] +internal abstract class Operation { + [JsonPropertyName("api_name")] + public string ApiName { get; set; } = string.Empty; + + public static Operation Create(string apiName, JsonObject value) + => new CreateOperation { ApiName = apiName, Value = value }; + + public static Operation Update(string apiName, string recordId, JsonObject value) + => new UpdateOperation { ApiName = apiName, RecordId = recordId, Value = value }; + + public static Operation Delete(string apiName, string recordId) + => new DeleteOperation { ApiName = apiName, RecordId = recordId }; +} + +internal class CreateOperation : Operation { + [JsonPropertyName("value")] + public JsonObject Value { get; set; } = new(); +} + +internal class UpdateOperation : Operation { + [JsonPropertyName("record_id")] + public string RecordId { get; set; } = string.Empty; + + [JsonPropertyName("value")] + public JsonObject Value { get; set; } = new(); +} + +internal class DeleteOperation : Operation { + [JsonPropertyName("record_id")] + public string RecordId { get; set; } = string.Empty; +} + +[RequiresDynamicCode("JSON serialization may require dynamic code")] +[RequiresUnreferencedCode("JSON serialization may require unreferenced code")] +internal class OperationJsonConverter : JsonConverter { + public override Operation? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + using (var doc = JsonDocument.ParseValue(ref reader)) { + var root = doc.RootElement; + if (root.TryGetProperty("Create", out var createElem)) { return JsonSerializer.Deserialize(createElem.GetRawText(), options); } + if (root.TryGetProperty("Update", out var updateElem)) { return JsonSerializer.Deserialize(updateElem.GetRawText(), options); } + if (root.TryGetProperty("Delete", out var deleteElem)) { return JsonSerializer.Deserialize(deleteElem.GetRawText(), options); } + throw new JsonException("Unknown operation type"); + } + } + + public override void Write(Utf8JsonWriter writer, Operation value, JsonSerializerOptions options) { + writer.WriteStartObject(); + + switch (value) { + case CreateOperation create: + writer.WritePropertyName("Create"); + JsonSerializer.Serialize(writer, create, typeof(CreateOperation), options); + break; + case UpdateOperation update: + writer.WritePropertyName("Update"); + JsonSerializer.Serialize(writer, update, typeof(UpdateOperation), options); + break; + case DeleteOperation delete: + writer.WritePropertyName("Delete"); + JsonSerializer.Serialize(writer, delete, typeof(DeleteOperation), options); + break; + default: + throw new NotSupportedException($"Operation of type {value.GetType()} is not supported."); + } + + writer.WriteEndObject(); + } +} + +internal class TransactionRequest { + [JsonPropertyName("operations")] + public List Operations { get; set; } = new(); +} + +internal class TransactionResponse { + [JsonPropertyName("ids")] + public List Ids { get; set; } = new(); +} + +/// Transaction +public interface ITransactionBatch { + /// Api + IApiBatch Api(string apiName); + + /// Send + [RequiresDynamicCode("JSON serialization may require dynamic code")] + [RequiresUnreferencedCode("JSON serialization may require unreferenced code")] + Task> Send(); +} + +/// Api +public interface IApiBatch { + /// Create + ITransactionBatch Create(T record, JsonTypeInfo jsonTypeInfo); + /// Update + ITransactionBatch Update(RecordId recordId, T record, JsonTypeInfo jsonTypeInfo); + /// Delete + ITransactionBatch Delete(RecordId recordId); +} + +/// New transaction batch. +public class TransactionBatch : ITransactionBatch { + private readonly Client _client; + private readonly List _operations = new(); + + /// + public TransactionBatch(Client client) { + _client = client; + } + + /// Api. + public IApiBatch Api(string apiName) { + return new ApiBatch(this, apiName); + } + + /// Send transaction batch. + [RequiresDynamicCode("JSON serialization may require dynamic code")] + [RequiresUnreferencedCode("JSON serialization may require unreferenced code")] + public async Task> Send() { + var request = new TransactionRequest { Operations = _operations }; + var response = await _client.Fetch( + "api/transaction/v1/execute", + HttpMethod.Post, + JsonContent.Create(request), + null + ); + + string json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + + return result?.Ids ?? new List(); + } + + internal void AddOperation(Operation operation) { + _operations.Add(operation); + } +} + +internal class ApiBatch : IApiBatch { + private readonly TransactionBatch _batch; + private readonly string _apiName; + + public ApiBatch(TransactionBatch batch, string apiName) { + _batch = batch; + _apiName = apiName; + } + + public ITransactionBatch Create(T record, JsonTypeInfo jsonTypeInfo) { + var value = ToJsonObject(record, jsonTypeInfo); + _batch.AddOperation(Operation.Create(_apiName, value)); + return _batch; + } + + public ITransactionBatch Update(RecordId recordId, T record, JsonTypeInfo jsonTypeInfo) { + var value = ToJsonObject(record, jsonTypeInfo); + _batch.AddOperation(Operation.Update(_apiName, recordId.ToString(), value)); + return _batch; + } + + public ITransactionBatch Delete(RecordId recordId) { + _batch.AddOperation(Operation.Delete(_apiName, recordId.ToString())); + return _batch; + } + + private static JsonObject ToJsonObject(T record, JsonTypeInfo jsonTypeInfo) { + var node = JsonSerializer.SerializeToNode(record, jsonTypeInfo); + + return node as JsonObject ?? throw new InvalidOperationException("The provided record did not serialize to a JSON object."); + } +} diff --git a/client/go/trailbase/client.go b/client/go/trailbase/client.go index 6c9bb0130..f38201d99 100644 --- a/client/go/trailbase/client.go +++ b/client/go/trailbase/client.go @@ -132,6 +132,8 @@ type Client interface { // Internal do(method string, path string, body []byte, queryParams []QueryParam) (*http.Response, error) + + Transaction() *TransactionBatch } type ClientImpl struct { @@ -269,6 +271,13 @@ func (c *ClientImpl) do(method string, path string, body []byte, queryParams []Q return c.client.do(method, path, headers, body, queryParams) } +func (c *ClientImpl) Transaction() *TransactionBatch { + return &TransactionBatch{ + client: c, + operations: make([]Operation, 0), + } +} + func (c *ClientImpl) updateTokens(tokens *Tokens) (*Tokens, error) { state, err := NewTokenState(tokens) if err != nil { diff --git a/client/go/trailbase/client_test.go b/client/go/trailbase/client_test.go index 6de61c011..fa2c7eb22 100644 --- a/client/go/trailbase/client_test.go +++ b/client/go/trailbase/client_test.go @@ -251,6 +251,52 @@ func TestRecordApi(t *testing.T) { assert(t, r == nil, "expected nil value reading delete record") } +func TestTransaction(t *testing.T) { + client := connect(t) + batch := client.Transaction() + api := NewRecordApi[SimpleStrict](client, "simple_strict_table") + + now := time.Now().Unix() + + var ids []RecordId + + // Create + createdMessage := fmt.Sprint("go transaction create test: =?&", now) + batch.API("simple_strict_table").Create(SimpleStrict{ + TextNotNull: createdMessage, + }) + ids, err := batch.Send() + assertFine(t, err) + assert(t, len(ids) == 1, "Expected one ID from create operation") + + simpleStrict1, err := api.Read(ids[0]) + assertFine(t, err) + assertEqual(t, createdMessage, simpleStrict1.TextNotNull) + + // Update + { + updatedMessage := fmt.Sprint("go transaction update test: =?&", now) + batch.API("simple_strict_table").Update(ids[0], SimpleStrict{ + TextNotNull: updatedMessage, + }) + _, err := batch.Send() + assertFine(t, err) + + simpleStrict2, err := api.Read(ids[0]) + assertFine(t, err) + assertEqual(t, updatedMessage, simpleStrict2.TextNotNull) + } + // Delete + { + batch.API("simple_strict_table").Delete(ids[0]) + _, err := batch.Send() + assertFine(t, err) + r, err := api.Read(ids[0]) + assert(t, err != nil, "expected error reading delete record") + assert(t, r == nil, "expected nil value reading delete record") + } +} + func assertEqual[T comparable](t *testing.T, expected T, got T) { if expected != got { buf := make([]byte, 1<<16) diff --git a/client/go/trailbase/transaction_api.go b/client/go/trailbase/transaction_api.go new file mode 100644 index 000000000..eddc57057 --- /dev/null +++ b/client/go/trailbase/transaction_api.go @@ -0,0 +1,159 @@ +package trailbase + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type Operation struct { + Type string `json:"-"` + ApiName string `json:"api_name"` + RecordID string `json:"record_id,omitempty"` + Value interface{} `json:"value,omitempty"` +} + +func (o Operation) MarshalJSON() ([]byte, error) { + var wrapper struct { + Create *struct { + ApiName string `json:"api_name"` + Value interface{} `json:"value"` + } `json:"Create,omitempty"` + Update *struct { + ApiName string `json:"api_name"` + RecordID string `json:"record_id"` + Value interface{} `json:"value"` + } `json:"Update,omitempty"` + Delete *struct { + ApiName string `json:"api_name"` + RecordID string `json:"record_id"` + } `json:"Delete,omitempty"` + } + + switch o.Type { + case "Create": + wrapper.Create = &struct { + ApiName string `json:"api_name"` + Value interface{} `json:"value"` + }{ + ApiName: o.ApiName, + Value: o.Value, + } + case "Update": + wrapper.Update = &struct { + ApiName string `json:"api_name"` + RecordID string `json:"record_id"` + Value interface{} `json:"value"` + }{ + ApiName: o.ApiName, + RecordID: o.RecordID, + Value: o.Value, + } + case "Delete": + wrapper.Delete = &struct { + ApiName string `json:"api_name"` + RecordID string `json:"record_id"` + }{ + ApiName: o.ApiName, + RecordID: o.RecordID, + } + } + + return json.Marshal(wrapper) +} + +type TransactionRequest struct { + Operations []Operation `json:"operations"` +} + +type TransactionResponse struct { + Ids []string `json:"ids"` +} + +type TransactionBatch struct { + client Client + operations []Operation +} + +type ApiBatch struct { + batch *TransactionBatch + apiName string +} + +func (tb *TransactionBatch) API(apiName string) *ApiBatch { + return &ApiBatch{ + batch: tb, + apiName: apiName, + } +} + +func (tb *TransactionBatch) Send() ([]RecordId, error) { + reqBody := TransactionRequest{ + Operations: tb.operations, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := tb.client.do("POST", "api/transaction/v1/execute", jsonData, []QueryParam{}) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var response TransactionResponse + decoder := json.NewDecoder(resp.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if response.Ids == nil { + response.Ids = []string{} + } + + recordIDs := make([]RecordId, len(response.Ids)) + for i, idStr := range response.Ids { + recordIDs[i] = StringRecordId(idStr) + } + + return recordIDs, nil +} + +func (tb *TransactionBatch) addOperation(op Operation) { + tb.operations = append(tb.operations, op) +} + +func (ab *ApiBatch) Create(value interface{}) *TransactionBatch { + ab.batch.addOperation(Operation{ + Type: "Create", + ApiName: ab.apiName, + Value: value, + }) + return ab.batch +} + +func (ab *ApiBatch) Update(recordID RecordId, value interface{}) *TransactionBatch { + ab.batch.addOperation(Operation{ + Type: "Update", + ApiName: ab.apiName, + RecordID: recordID.ToString(), + Value: value, + }) + return ab.batch +} + +func (ab *ApiBatch) Delete(recordID RecordId) *TransactionBatch { + ab.batch.addOperation(Operation{ + Type: "Delete", + ApiName: ab.apiName, + RecordID: recordID.ToString(), + }) + return ab.batch +} diff --git a/client/python/tests/test_client.py b/client/python/tests/test_client.py index 1809ae819..261d0e6f3 100644 --- a/client/python/tests/test_client.py +++ b/client/python/tests/test_client.py @@ -269,4 +269,38 @@ def test_subscriptions(trailbase: TrailBaseFixture): assert "Insert" in events[0] +def test_transaction(trailbase: TrailBaseFixture): + assert trailbase.isUp() + + client = connect() + api = client.records("simple_strict_table") + ids: List[RecordId] = [] + + if True: + now = int(time()) + batch = client.transaction() + message = f"transaction test create: {now}" + batch.api("simple_strict_table").create({"text_not_null": message}) + ids = batch.send() + record = api.read(ids[0]) + assert record["text_not_null"] == message + + if True: + now = int(time()) + batch = client.transaction() + updatedMessage = f"transaction test update: {now}" + batch.api("simple_strict_table").update(ids[0], {"text_not_null": updatedMessage}) + batch.send() + record = api.read(ids[0]) + assert record["text_not_null"] == updatedMessage + + if True: + batch = client.transaction() + batch.api("simple_strict_table").delete(ids[0]) + batch.send() + + with pytest.raises(Exception): + api.read(ids[0]) + + logger = logging.getLogger(__name__) diff --git a/client/python/trailbase/__init__.py b/client/python/trailbase/__init__.py index 685a5f762..dabc5c5a0 100644 --- a/client/python/trailbase/__init__.py +++ b/client/python/trailbase/__init__.py @@ -2,6 +2,23 @@ __description__ = "TrailBase client SDK for python." __version__ = "0.1.0" +__all__ = [ + "Client", + "CompareOp", + "Filter", + "And", + "Or", + "RecordId", + "User", + "ListResponse", + "Tokens", + "JSON", + "JSON_OBJECT", + "JSON_ARRAY", + "TransactionBatch", + "ApiBatch", +] + import httpx import jwt import logging @@ -11,7 +28,7 @@ from enum import Enum from contextlib import contextmanager from time import time -from typing import TypeAlias, cast, final +from typing import TypeAlias, List, Protocol, cast, final JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None JSON_OBJECT: TypeAlias = dict[str, JSON] @@ -252,6 +269,105 @@ def impl(): return impl() +# Transaction related classes and protocols +class CreateOperation(typing.TypedDict): + api_name: str + value: JSON_OBJECT + + +class UpdateOperation(typing.TypedDict): + api_name: str + record_id: str + value: JSON_OBJECT + + +class DeleteOperation(typing.TypedDict): + api_name: str + record_id: str + + +class Operation(typing.TypedDict, total=False): + Create: CreateOperation + Update: UpdateOperation + Delete: DeleteOperation + + +class TransactionRequest(typing.TypedDict): + operations: List[Operation] + + +class TransactionResponse(typing.TypedDict): + ids: List[str] + + +class ITransactionBatch(Protocol): + def api(self, api_name: str) -> "IApiBatch": ... + + def send(self) -> List[RecordId]: ... + + +class IApiBatch(Protocol): + def create(self, value: JSON_OBJECT) -> ITransactionBatch: ... + + def update(self, recordId: RecordId | str | int, value: JSON_OBJECT) -> ITransactionBatch: ... + + def delete(self, recordId: RecordId | str | int) -> ITransactionBatch: ... + + +class TransactionBatch: + _client: "Client" + _operations: List[Operation] + + def __init__(self, client: "Client") -> None: + self._client = client + self._operations = [] + + def api(self, api_name: str) -> "ApiBatch": + return ApiBatch(self, api_name) + + def send(self) -> List[RecordId]: + ops_json = [dict(op) for op in self._operations] + + response = self._client.fetch( + "api/transaction/v1/execute", + method="POST", + data=cast(JSON, {"operations": ops_json}), + ) + if response.status_code != 200: + raise Exception(f"Transaction failed with status code {response.status_code}: {response.text}") + + return record_ids_from_json(response.json()) + + def add_operation(self, operation: Operation) -> None: + self._operations.append(operation) + + +class ApiBatch: + _batch: TransactionBatch + _api_name: str + + def __init__(self, batch: TransactionBatch, api_name: str) -> None: + self._batch = batch + self._api_name = api_name + + def create(self, value: JSON_OBJECT) -> ITransactionBatch: + operation: Operation = {"Create": {"api_name": self._api_name, "value": value}} + self._batch.add_operation(operation) + return self._batch + + def update(self, recordId: RecordId | str | int, value: JSON_OBJECT) -> ITransactionBatch: + id = repr(recordId) if isinstance(recordId, RecordId) else f"{recordId}" + operation: Operation = {"Update": {"api_name": self._api_name, "record_id": id, "value": value}} + self._batch.add_operation(operation) + return self._batch + + def delete(self, recordId: RecordId | str | int) -> ITransactionBatch: + id = repr(recordId) if isinstance(recordId, RecordId) else f"{recordId}" + operation: Operation = {"Delete": {"api_name": self._api_name, "record_id": id}} + self._batch.add_operation(operation) + return self._batch + + class Client: _authApi: str = "api/auth/v1" @@ -329,6 +445,9 @@ def logout(self) -> None: def records(self, name: str) -> "RecordApi": return RecordApi(name, self) + def transaction(self) -> TransactionBatch: + return TransactionBatch(self) + def _updateTokens(self, tokens: Tokens | None): state = TokenState.build(tokens) diff --git a/client/swift/trailbase/Sources/TrailBase/TrailBase.swift b/client/swift/trailbase/Sources/TrailBase/TrailBase.swift index 883eb126f..36abb705c 100644 --- a/client/swift/trailbase/Sources/TrailBase/TrailBase.swift +++ b/client/swift/trailbase/Sources/TrailBase/TrailBase.swift @@ -328,6 +328,10 @@ public class Client { return RecordApi(client: self, name: name) } + public func transaction() -> TransactionBatch { + return TransactionBatch(client: self) + } + public func refresh() async throws { guard let (headers, refreshToken) = getHeaderAndRefreshToken() else { throw ClientError.unauthenticated @@ -381,7 +385,7 @@ public class Client { return state } - fileprivate func fetch( + internal func fetch( path: String, method: String, body: Data? = nil, diff --git a/client/swift/trailbase/Sources/TrailBase/Transaction.swift b/client/swift/trailbase/Sources/TrailBase/Transaction.swift new file mode 100644 index 000000000..98e4a828d --- /dev/null +++ b/client/swift/trailbase/Sources/TrailBase/Transaction.swift @@ -0,0 +1,187 @@ +import Foundation + +public enum Operation: Codable { + case create(apiName: String, value: [String: AnyCodable]) + case update(apiName: String, recordId: RecordId, value: [String: AnyCodable]) + case delete(apiName: String, recordId: RecordId) + + private enum CodingKeys: String, CodingKey { + case create = "Create" + case update = "Update" + case delete = "Delete" + case apiName = "api_name" + case recordId = "record_id" + case value = "value" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case let .create(apiName, value): + var createContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .create) + try createContainer.encode(apiName, forKey: .apiName) + try createContainer.encode(value, forKey: .value) + + case let .update(apiName, recordId, value): + var updateContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .update) + try updateContainer.encode(apiName, forKey: .apiName) + try updateContainer.encode("\(recordId)", forKey: .recordId) + try updateContainer.encode(value, forKey: .value) + + case let .delete(apiName, recordId): + var deleteContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .delete) + try deleteContainer.encode(apiName, forKey: .apiName) + try deleteContainer.encode("\(recordId)", forKey: .recordId) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let createContainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .create) { + let apiName = try createContainer.decode(String.self, forKey: .apiName) + let value = try createContainer.decode([String: AnyCodable].self, forKey: .value) + self = .create(apiName: apiName, value: value) + + } else if let updateContainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .update) { + let apiName = try updateContainer.decode(String.self, forKey: .apiName) + let recordIdString = try updateContainer.decode(String.self, forKey: .recordId) + let value = try updateContainer.decode([String: AnyCodable].self, forKey: .value) + self = .update(apiName: apiName, recordId: RecordId.string(recordIdString), value: value) + + } else if let deleteContainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .delete) { + let apiName = try deleteContainer.decode(String.self, forKey: .apiName) + let recordIdString = try deleteContainer.decode(String.self, forKey: .recordId) + self = .delete(apiName: apiName, recordId: RecordId.string(recordIdString)) + + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: container.codingPath, debugDescription: "Invalid Operation type") + ) + } + } +} + +public struct TransactionRequest: Codable { + public var operations: [Operation] = [] + + private enum CodingKeys: String, CodingKey { + case operations = "operations" + } +} + +public struct TransactionResponse: Codable { + public var ids: [String] = [] + + private enum CodingKeys: String, CodingKey { + case ids = "ids" + } +} + +public class TransactionBatch { + private let client: Client + private var operations: [Operation] = [] + + init(client: Client) { + self.client = client + } + + public func api(_ apiName: String) -> ApiBatch { + return ApiBatch(batch: self, apiName: apiName) + } + + public func send() async throws -> [RecordId] { + let request = TransactionRequest(operations: operations) + let body = try JSONEncoder().encode(request) + + let (_, data) = try await client.fetch( + path: "api/transaction/v1/execute", + method: "POST", + body: body + ) + + let response = try JSONDecoder().decode(TransactionResponse.self, from: data) + return response.ids.map { RecordId.string($0) } + } + + internal func addOperation(_ operation: Operation) { + operations.append(operation) + } +} + +public class ApiBatch { + private let batch: TransactionBatch + private let apiName: String + + init(batch: TransactionBatch, apiName: String) { + self.batch = batch + self.apiName = apiName + } + + public func create(value: [String: AnyCodable]) -> TransactionBatch { + batch.addOperation(.create(apiName: apiName, value: value)) + return batch + } + + public func update(recordId: RecordId, value: [String: AnyCodable]) -> TransactionBatch { + batch.addOperation(.update(apiName: apiName, recordId: recordId, value: value)) + return batch + } + + public func delete(recordId: RecordId) -> TransactionBatch { + batch.addOperation(.delete(apiName: apiName, recordId: recordId)) + return batch + } +} + +public struct AnyCodable: Codable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self.value = () + } else if let bool = try? container.decode(Bool.self) { + self.value = bool + } else if let int = try? container.decode(Int.self) { + self.value = int + } else if let double = try? container.decode(Double.self) { + self.value = double + } else if let string = try? container.decode(String.self) { + self.value = string + } else if let array = try? container.decode([AnyCodable].self) { + self.value = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + self.value = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if value is () { + try container.encodeNil() + } else if let bool = value as? Bool { + try container.encode(bool) + } else if let int = value as? Int { + try container.encode(int) + } else if let double = value as? Double { + try container.encode(double) + } else if let string = value as? String { + try container.encode(string) + } else if let array = value as? [Any] { + try container.encode(array.map(AnyCodable.init)) + } else if let dictionary = value as? [String: Any] { + try container.encode(dictionary.mapValues(AnyCodable.init)) + } else { + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded") + throw EncodingError.invalidValue(value, context) + } + } +} diff --git a/client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift b/client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift index 0e2259527..36000e270 100644 --- a/client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift +++ b/client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift @@ -199,4 +199,49 @@ extension Trait where Self == SetupTrailBaseTrait { } catch { } } + + @Test("Test Transaction") func testTransaction() async throws { + let client = try await connect() + let now = NSDate().timeIntervalSince1970 + + // Test create operation + let batch = client.transaction() + let createRecord: [String: AnyCodable] = [ + "text_not_null": AnyCodable("swift transaction create test: =?&\(now)") + ] + batch.api("simple_strict_table").create(value: createRecord) + + // Test actual creation + let ids: [RecordId] = try await batch.send() + #expect(ids.count == 1) + + // Verify record was created + let api = client.records("simple_strict_table") + let createdRecord: SimpleStrict = try await api.read(recordId: ids[0]) + #expect(createdRecord.text_not_null == createRecord["text_not_null"]?.value as? String) + + // Test update operation + let updateBatch = client.transaction() + let updateRecord: [String: AnyCodable] = [ + "text_not_null": AnyCodable("swift transaction update test: =?&\(now)") + ] + updateBatch.api("simple_strict_table").update(recordId: ids[0], value: updateRecord) + + // Test actual update + let _ = try await updateBatch.send() + let updatedRecord: SimpleStrict = try await api.read(recordId: ids[0]) + #expect(updatedRecord.text_not_null == updateRecord["text_not_null"]?.value as? String) + + // Test delete operation + let deleteBatch = client.transaction() + deleteBatch.api("simple_strict_table").delete(recordId: ids[0]) + + // Test actual deletion + let _ = try await deleteBatch.send() + do { + let _: SimpleStrict = try await api.read(recordId: ids[0]) + #expect(Bool(false)) + } catch { + } + } } diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index 77fdc4b98..0b35ba1a1 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -428,6 +428,9 @@ export interface Client { /// /// Unlike native fetch, will throw in case !response.ok. fetch(path: string, init?: FetchOptions): Promise; + + /// Creates a new transaction batch. + transaction(): TransactionBatch; } /// Client for interacting with TrailBase auth and record APIs. @@ -485,6 +488,11 @@ class ClientImpl implements Client { return new RecordApiImpl(this, name); } + /// Creates a new transaction batch. + public transaction(): TransactionBatch { + return new TransactionBatch(this); + } + public avatarUrl(userId?: string): string | undefined { const id = userId ?? this.user()?.id; if (id) { @@ -776,3 +784,95 @@ export const exportedForTesting = isDev base64Encode, } : undefined; + +/// Batch Builder Class +export interface Operation { + Create?: { + api_name: string; + value: Record; + }; + Update?: { + api_name: string; + record_id: string; + value: Record; + }; + Delete?: { + api_name: string; + record_id: string; + }; +} + +export interface TransactionRequest { + operations: Operation[]; +} + +export interface TransactionResponse { + ids: string[]; +} + +/// Batch Builder Class +export class TransactionBatch { + private operations: Operation[] = []; + + constructor(private client: Client) {} + + api(apiName: string): ApiBatch { + return new ApiBatch(this, apiName); + } + + async send(): Promise { + const response = await this.client.fetch("/api/transaction/v1/execute", { + method: "POST", + body: JSON.stringify({ operations: this.operations }), + headers: jsonContentTypeHeader, + }); + + return (await response.json()).ids; + } + + addOperation(operation: Operation): void { + this.operations.push(operation); + } +} + +// Api-Specific Operations +export class ApiBatch { + constructor( + private batch: TransactionBatch, + private apiName: string, + ) {} + + create(value: Record): TransactionBatch { + this.batch.addOperation({ + Create: { + api_name: this.apiName, + value: value, + }, + }); + return this.batch; + } + + update( + recordId: string | number, + value: Record, + ): TransactionBatch { + this.batch.addOperation({ + Update: { + api_name: this.apiName, + record_id: `${recordId}`, + value: value, + }, + }); + return this.batch; + } + + delete(recordId: string | number): TransactionBatch { + this.batch.addOperation({ + Delete: { + api_name: this.apiName, + record_id: `${recordId}`, + }, + }); + return this.batch; + } +} diff --git a/crates/assets/js/client/tests/integration/client_integration.test.ts b/crates/assets/js/client/tests/integration/client_integration.test.ts index 7f4974934..87149d79f 100644 --- a/crates/assets/js/client/tests/integration/client_integration.test.ts +++ b/crates/assets/js/client/tests/integration/client_integration.test.ts @@ -331,6 +331,58 @@ test("realtime subscribe specific record tests", async () => { expect(events[1]["Delete"]["text_not_null"]).equals(updatedMessage); }); +test("transaction tests", async () => { + const client = await connect(); + const now = new Date().getTime(); + + // Test transaction with create operation + { + const batch = client.transaction(); + const record = { text_not_null: `ts transaction create test: =?&${now}` }; + batch.api("simple_strict_table").create(record); + + const ids = await batch.send(); + expect(ids).toHaveLength(1); + + // Verify record was created + const api = client.records("simple_strict_table"); + const createdRecord = await api.read(ids[0]); + expect(createdRecord.text_not_null).toBe(record.text_not_null); + } + + // Test transaction with update operation + { + const api = client.records("simple_strict_table"); + const record = { + text_not_null: `ts transaction update test original: =?&${now}`, + }; + const id = await api.create(record); + + const batch = client.transaction(); + const updatedRecord = { + text_not_null: `ts transaction update test modified: =?&${now}`, + }; + batch.api("simple_strict_table").update(id, updatedRecord); + + await batch.send(); + const readRecord = await api.read(id); + expect(readRecord.text_not_null).toBe(updatedRecord.text_not_null); + } + + // Test transaction with delete operation + { + const api = client.records("simple_strict_table"); + const record = { text_not_null: `ts transaction delete test: =?&${now}` }; + const id = await api.create(record); + + const batch = client.transaction(); + batch.api("simple_strict_table").delete(id); + + await batch.send(); + await expect(api.read(id)).rejects.toThrow(); + } +}); + test("realtime subscribe table tests", async () => { const client = await connect(); const api = client.records("simple_strict_table"); diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 63735b356..888743fa7 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -689,6 +689,113 @@ impl ClientState { } } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum Operation { + Create { + api_name: String, + value: serde_json::Value, + }, + Update { + api_name: String, + record_id: String, + value: serde_json::Value, + }, + Delete { + api_name: String, + record_id: String, + }, +} + +#[derive(Serialize)] +pub struct TransactionRequest { + pub operations: Vec, +} + +#[derive(Deserialize)] +pub struct TransactionResponse { + pub ids: Vec, +} + +pub struct TransactionBatch { + client: Arc, + operations: Vec, +} + +pub struct ApiBatch<'a> { + batch: &'a mut TransactionBatch, + api_name: String, +} + +impl TransactionBatch { + fn new(client: Arc) -> Self { + Self { + client, + operations: Vec::new(), + } + } + + pub fn api(&mut self, api_name: impl Into) -> ApiBatch { + ApiBatch { + batch: self, + api_name: api_name.into(), + } + } + + pub async fn send(self) -> Result, Error> { + let request = TransactionRequest { + operations: self.operations, + }; + + let response = self + .client + .fetch( + "api/transaction/v1/execute", + Method::POST, + Some(&request), + None, + ) + .await?; + + let result: TransactionResponse = json(response).await?; + Ok(result.ids) + } + + fn add_operation(&mut self, operation: Operation) { + self.operations.push(operation); + } +} + +impl<'a> ApiBatch<'a> { + pub fn create(self, value: impl Into) -> &'a mut TransactionBatch { + self.batch.add_operation(Operation::Create { + api_name: self.api_name, + value: value.into(), + }); + self.batch + } + + pub fn update( + self, + record_id: impl RecordId<'a>, + value: impl Into, + ) -> &'a mut TransactionBatch { + self.batch.add_operation(Operation::Update { + api_name: self.api_name, + record_id: record_id.serialized_id().to_string(), + value: value.into(), + }); + self.batch + } + + pub fn delete(self, record_id: impl RecordId<'a>) -> &'a mut TransactionBatch { + self.batch.add_operation(Operation::Delete { + api_name: self.api_name, + record_id: record_id.serialized_id().to_string(), + }); + self.batch + } +} + #[derive(Clone)] pub struct Client { state: Arc, @@ -818,6 +925,10 @@ impl Client { return state; } + + pub fn transaction(&self) -> TransactionBatch { + TransactionBatch::new(self.state.clone()) + } } fn build_headers(tokens: Option<&Tokens>) -> HeaderMap { diff --git a/crates/client/tests/integration_test.rs b/crates/client/tests/integration_test.rs index ecf3af047..ca6142072 100644 --- a/crates/client/tests/integration_test.rs +++ b/crates/client/tests/integration_test.rs @@ -410,6 +410,50 @@ fn integration_test() { runtime.block_on(subscription_test()); println!("Ran subscription tests"); + + runtime.block_on(transaction_tests()); + println!("Ran transaction tests"); +} + +async fn transaction_tests() { + let client = connect().await; + let api = client.records("simple_strict_table"); + let now = now(); + let ids; + + { + let mut batch = client.transaction(); + let message = format!("rust transaction create test: {now}"); + batch + .api("simple_strict_table") + .create(json!({"text_not_null": message})); + ids = batch.send().await.unwrap(); + assert_eq!(ids.len(), 1); + + let record: SimpleStrict = api.read(&ids[0]).await.unwrap(); + assert_eq!(record.text_not_null, message); + } + + { + let mut batch = client.transaction(); + let message = format!("rust transaction update test: {now}"); + batch + .api("simple_strict_table") + .update(&ids[0], json!({"text_not_null": message})); + batch.send().await.unwrap(); + + let record: SimpleStrict = api.read(&ids[0]).await.unwrap(); + assert_eq!(record.text_not_null, message); + } + + { + let mut batch = client.transaction(); + batch.api("simple_strict_table").delete(&ids[0]); + batch.send().await.unwrap(); + + let response = api.read::(&ids[0]).await; + assert!(response.is_err()); + } } fn now() -> u64 { From d076ca0fc53600bed9f58e59c953dcd5bd82fd91 Mon Sep 17 00:00:00 2001 From: Bilux Date: Sat, 23 Aug 2025 09:24:26 +0000 Subject: [PATCH 2/8] wip: enable record transactions for tests Signed-off-by: Bilux --- client/testfixture/config.textproto | 1 + 1 file changed, 1 insertion(+) diff --git a/client/testfixture/config.textproto b/client/testfixture/config.textproto index 9a99426e2..d59201374 100644 --- a/client/testfixture/config.textproto +++ b/client/testfixture/config.textproto @@ -3,6 +3,7 @@ email {} server { application_name: "TrailBase" logs_retention_sec: 604800 + enable_record_transactions: true } auth { oauth_providers: [{ From 8277c6f64684f0ec35e3524c47a83c5c8e1765d4 Mon Sep 17 00:00:00 2001 From: Bilux Date: Sat, 23 Aug 2025 09:28:27 +0000 Subject: [PATCH 3/8] wip: clean up some clients transaction support code Signed-off-by: Bilux --- client/dart/lib/src/transaction.dart | 37 ++++++++++--------- client/dotnet/trailbase/TransactionApi.cs | 3 +- client/go/trailbase/transaction_api.go | 4 +- client/python/trailbase/__init__.py | 4 +- .../Sources/TrailBase/Transaction.swift | 4 +- crates/assets/js/client/src/index.ts | 3 +- crates/client/src/lib.rs | 10 ++--- 7 files changed, 36 insertions(+), 29 deletions(-) diff --git a/client/dart/lib/src/transaction.dart b/client/dart/lib/src/transaction.dart index 7d4b7bce8..5d22c071e 100644 --- a/client/dart/lib/src/transaction.dart +++ b/client/dart/lib/src/transaction.dart @@ -1,9 +1,9 @@ import 'package:trailbase/src/client.dart'; class Operation { - CreateOperation? create; - UpdateOperation? update; - DeleteOperation? delete; + _CreateOperation? create; + _UpdateOperation? update; + _DeleteOperation? delete; Operation({this.create, this.update, this.delete}); @@ -22,11 +22,11 @@ class Operation { } } -class CreateOperation { +class _CreateOperation { String apiName; Map value; - CreateOperation({required this.apiName, required this.value}); + _CreateOperation({required this.apiName, required this.value}); Map toJson() { final Map data = {}; @@ -36,12 +36,12 @@ class CreateOperation { } } -class UpdateOperation { +class _UpdateOperation { String apiName; RecordId recordId; Map value; - UpdateOperation({ + _UpdateOperation({ required this.apiName, required this.recordId, required this.value, @@ -56,11 +56,11 @@ class UpdateOperation { } } -class DeleteOperation { +class _DeleteOperation { String apiName; RecordId recordId; - DeleteOperation({required this.apiName, required this.recordId}); + _DeleteOperation({required this.apiName, required this.recordId}); Map toJson() { final Map data = {}; @@ -94,6 +94,8 @@ abstract class IApiBatch { } class TransactionBatch implements ITransactionBatch { + static const String _transactionApi = 'api/transaction/v1/execute'; + final Client _client; final List _operations = []; @@ -108,7 +110,7 @@ class TransactionBatch implements ITransactionBatch { Future> send() async { final request = TransactionRequest(operations: _operations); final response = await _client.fetch( - 'api/transaction/v1/execute', + TransactionBatch._transactionApi, method: 'POST', data: request.toJson(), ); @@ -121,7 +123,7 @@ class TransactionBatch implements ITransactionBatch { return result.toRecordIds(); } - void addOperation(Operation operation) { + void _addOperation(Operation operation) { _operations.add(operation); } } @@ -134,17 +136,17 @@ class ApiBatch implements IApiBatch { @override ITransactionBatch create(Map value) { - _batch.addOperation( - Operation(create: CreateOperation(apiName: _apiName, value: value)), + _batch._addOperation( + Operation(create: _CreateOperation(apiName: _apiName, value: value)), ); return _batch; } @override ITransactionBatch update(RecordId recordId, Map value) { - _batch.addOperation( + _batch._addOperation( Operation( - update: UpdateOperation( + update: _UpdateOperation( apiName: _apiName, recordId: recordId, value: value, @@ -156,8 +158,9 @@ class ApiBatch implements IApiBatch { @override ITransactionBatch delete(RecordId recordId) { - _batch.addOperation( - Operation(delete: DeleteOperation(apiName: _apiName, recordId: recordId)), + _batch._addOperation( + Operation( + delete: _DeleteOperation(apiName: _apiName, recordId: recordId)), ); return _batch; } diff --git a/client/dotnet/trailbase/TransactionApi.cs b/client/dotnet/trailbase/TransactionApi.cs index f84450cfd..a93019a82 100644 --- a/client/dotnet/trailbase/TransactionApi.cs +++ b/client/dotnet/trailbase/TransactionApi.cs @@ -115,6 +115,7 @@ public interface IApiBatch { /// New transaction batch. public class TransactionBatch : ITransactionBatch { + static readonly string _transactionApi = "api/transaction/v1/execute"; private readonly Client _client; private readonly List _operations = new(); @@ -134,7 +135,7 @@ public IApiBatch Api(string apiName) { public async Task> Send() { var request = new TransactionRequest { Operations = _operations }; var response = await _client.Fetch( - "api/transaction/v1/execute", + TransactionBatch._transactionApi, HttpMethod.Post, JsonContent.Create(request), null diff --git a/client/go/trailbase/transaction_api.go b/client/go/trailbase/transaction_api.go index eddc57057..2b0e5f504 100644 --- a/client/go/trailbase/transaction_api.go +++ b/client/go/trailbase/transaction_api.go @@ -97,7 +97,7 @@ func (tb *TransactionBatch) Send() ([]RecordId, error) { return nil, fmt.Errorf("failed to marshal request: %w", err) } - resp, err := tb.client.do("POST", "api/transaction/v1/execute", jsonData, []QueryParam{}) + resp, err := tb.client.do("POST", transactionApi, jsonData, []QueryParam{}) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -157,3 +157,5 @@ func (ab *ApiBatch) Delete(recordID RecordId) *TransactionBatch { }) return ab.batch } + +const transactionApi string = "api/transaction/v1/execute" diff --git a/client/python/trailbase/__init__.py b/client/python/trailbase/__init__.py index dabc5c5a0..f02837a4e 100644 --- a/client/python/trailbase/__init__.py +++ b/client/python/trailbase/__init__.py @@ -315,6 +315,8 @@ def delete(self, recordId: RecordId | str | int) -> ITransactionBatch: ... class TransactionBatch: + _transactionApi: str = "api/transaction/v1/execute" + _client: "Client" _operations: List[Operation] @@ -329,7 +331,7 @@ def send(self) -> List[RecordId]: ops_json = [dict(op) for op in self._operations] response = self._client.fetch( - "api/transaction/v1/execute", + self._transactionApi, method="POST", data=cast(JSON, {"operations": ops_json}), ) diff --git a/client/swift/trailbase/Sources/TrailBase/Transaction.swift b/client/swift/trailbase/Sources/TrailBase/Transaction.swift index 98e4a828d..5f3d01950 100644 --- a/client/swift/trailbase/Sources/TrailBase/Transaction.swift +++ b/client/swift/trailbase/Sources/TrailBase/Transaction.swift @@ -96,7 +96,7 @@ public class TransactionBatch { let body = try JSONEncoder().encode(request) let (_, data) = try await client.fetch( - path: "api/transaction/v1/execute", + path: TRANSACTION_API, method: "POST", body: body ) @@ -185,3 +185,5 @@ public struct AnyCodable: Codable { } } } + +private let TRANSACTION_API = "api/transaction/v1/execute" diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index 0b35ba1a1..fe9c98876 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -689,6 +689,7 @@ export async function initClientFromCookies( const recordApiBasePath = "/api/records/v1"; const authApiBasePath = "/api/auth/v1"; +const transactionApiBasePath = "/api/transaction/v1/execute"; export function filePath( apiName: string, @@ -821,7 +822,7 @@ export class TransactionBatch { } async send(): Promise { - const response = await this.client.fetch("/api/transaction/v1/execute", { + const response = await this.client.fetch(transactionApiBasePath, { method: "POST", body: JSON.stringify({ operations: this.operations }), headers: jsonContentTypeHeader, diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 888743fa7..d74f8a888 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -734,7 +734,7 @@ impl TransactionBatch { } } - pub fn api(&mut self, api_name: impl Into) -> ApiBatch { + pub fn api(&mut self, api_name: impl Into) -> ApiBatch<'_> { ApiBatch { batch: self, api_name: api_name.into(), @@ -748,12 +748,7 @@ impl TransactionBatch { let response = self .client - .fetch( - "api/transaction/v1/execute", - Method::POST, - Some(&request), - None, - ) + .fetch(TRANSACTION_API, Method::POST, Some(&request), None) .await?; let result: TransactionResponse = json(response).await?; @@ -980,6 +975,7 @@ async fn json(resp: reqwest::Response) -> Result const AUTH_API: &str = "api/auth/v1"; const RECORD_API: &str = "api/records/v1"; +const TRANSACTION_API: &str = "/api/transaction/v1/execute"; #[cfg(test)] mod tests { From dfb919beeea946159f831fdbc568ad2fb9edc333 Mon Sep 17 00:00:00 2001 From: Bilux Date: Tue, 2 Sep 2025 03:15:00 +0000 Subject: [PATCH 4/8] Revert --- .pre-commit-config.yaml | 34 ++-- client/dart/lib/src/client.dart | 13 +- client/dart/lib/src/transaction.dart | 167 ---------------- client/dart/lib/trailbase.dart | 1 - client/dart/test/trailbase_test.dart | 37 ---- client/dotnet/test/ClientTest.cs | 67 ------- client/dotnet/trailbase/Client.cs | 5 - client/dotnet/trailbase/TransactionApi.cs | 186 ----------------- client/go/trailbase/client.go | 9 - client/go/trailbase/client_test.go | 46 ----- client/go/trailbase/transaction_api.go | 161 --------------- client/python/tests/test_client.py | 34 ---- client/python/trailbase/__init__.py | 123 +----------- .../Sources/TrailBase/TrailBase.swift | 6 +- .../Sources/TrailBase/Transaction.swift | 189 ------------------ .../Tests/TrailBaseTests/TrailBaseTests.swift | 45 ----- client/testfixture/config.textproto | 1 - crates/assets/js/client/src/index.ts | 101 ---------- .../integration/client_integration.test.ts | 52 ----- crates/client/src/lib.rs | 107 ---------- crates/client/tests/integration_test.rs | 44 ---- 21 files changed, 24 insertions(+), 1404 deletions(-) delete mode 100644 client/dart/lib/src/transaction.dart delete mode 100644 client/dotnet/trailbase/TransactionApi.cs delete mode 100644 client/go/trailbase/transaction_api.go delete mode 100644 client/swift/trailbase/Sources/TrailBase/Transaction.swift diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e0831271..5dd5b77e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -142,23 +142,6 @@ repos: types: [python] pass_filenames: false - ### Go client - - id: go_format - name: Go format - # gofmt always returns zero exit code :sigh: - entry: sh -c 'DIFF=$(gofmt -d -e client/go/trailbase/) && echo "${DIFF}" && test -z "${DIFF}"' - language: system - types: [go] - files: .*\.(go)$ - pass_filenames: false - - - id: go_test - name: Go test - entry: sh -c 'cd client/go/trailbase && go test -v' - language: system - types: [go] - pass_filenames: false - ### Swift client - id: swift_format name: Swift format @@ -177,3 +160,20 @@ repos: language: system types: [swift] pass_filenames: false + + ### Go client + - id: go_format + name: Go format + # gofmt always returns zero exit code :sigh: + entry: sh -c 'DIFF=$(gofmt -d -e client/go/trailbase/) && echo "${DIFF}" && test -z "${DIFF}"' + language: system + types: [go] + files: .*\.(go)$ + pass_filenames: false + + - id: go_test + name: Go test + entry: sh -c 'cd client/go/trailbase && go test -v' + language: system + types: [go] + pass_filenames: false diff --git a/client/dart/lib/src/client.dart b/client/dart/lib/src/client.dart index ba3d752ad..441071573 100644 --- a/client/dart/lib/src/client.dart +++ b/client/dart/lib/src/client.dart @@ -6,7 +6,6 @@ import 'package:logging/logging.dart'; import 'package:dio/dio.dart' as dio; import 'sse.dart'; -import 'transaction.dart'; class User { final String id; @@ -136,12 +135,12 @@ abstract class RecordId { factory RecordId.uuid(String id) => _UuidRecordId(id); } -class ResponseRecordIds { +class _ResponseRecordIds { final List _ids; - const ResponseRecordIds(this._ids); + const _ResponseRecordIds(this._ids); - ResponseRecordIds.fromJson(Map json) + _ResponseRecordIds.fromJson(Map json) : _ids = (json['ids'] as List).cast(); List toRecordIds() { @@ -417,7 +416,7 @@ class RecordApi { if ((response.statusCode ?? 400) > 200) { throw Exception('${response.data} ${response.statusMessage}'); } - final responseIds = ResponseRecordIds.fromJson(response.data); + final responseIds = _ResponseRecordIds.fromJson(response.data); assert(responseIds._ids.length == 1); return responseIds.toRecordIds()[0]; } @@ -432,7 +431,7 @@ class RecordApi { if ((response.statusCode ?? 400) > 200) { throw Exception('${response.data} ${response.statusMessage}'); } - final responseIds = ResponseRecordIds.fromJson(response.data); + final responseIds = _ResponseRecordIds.fromJson(response.data); return responseIds.toRecordIds(); } @@ -588,8 +587,6 @@ class Client { RecordApi records(String name) => RecordApi(this, name); - TransactionBatch transaction() => TransactionBatch(this); - _TokenState _updateTokens(Tokens? tokens) { final state = _TokenState.build(tokens); diff --git a/client/dart/lib/src/transaction.dart b/client/dart/lib/src/transaction.dart deleted file mode 100644 index 5d22c071e..000000000 --- a/client/dart/lib/src/transaction.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:trailbase/src/client.dart'; - -class Operation { - _CreateOperation? create; - _UpdateOperation? update; - _DeleteOperation? delete; - - Operation({this.create, this.update, this.delete}); - - Map toJson() { - final Map data = {}; - if (create != null) { - data['Create'] = create!.toJson(); - } - if (update != null) { - data['Update'] = update!.toJson(); - } - if (delete != null) { - data['Delete'] = delete!.toJson(); - } - return data; - } -} - -class _CreateOperation { - String apiName; - Map value; - - _CreateOperation({required this.apiName, required this.value}); - - Map toJson() { - final Map data = {}; - data['api_name'] = apiName; - data['value'] = value; - return data; - } -} - -class _UpdateOperation { - String apiName; - RecordId recordId; - Map value; - - _UpdateOperation({ - required this.apiName, - required this.recordId, - required this.value, - }); - - Map toJson() { - final Map data = {}; - data['api_name'] = apiName; - data['record_id'] = recordId.toString(); - data['value'] = value; - return data; - } -} - -class _DeleteOperation { - String apiName; - RecordId recordId; - - _DeleteOperation({required this.apiName, required this.recordId}); - - Map toJson() { - final Map data = {}; - data['api_name'] = apiName; - data['record_id'] = recordId.toString(); - return data; - } -} - -class TransactionRequest { - List operations; - - TransactionRequest({required this.operations}); - - Map toJson() { - final Map data = {}; - data['operations'] = operations.map((e) => e.toJson()).toList(); - return data; - } -} - -abstract class ITransactionBatch { - IApiBatch api(String apiName); - Future> send(); -} - -abstract class IApiBatch { - ITransactionBatch create(Map value); - ITransactionBatch update(RecordId recordId, Map value); - ITransactionBatch delete(RecordId recordId); -} - -class TransactionBatch implements ITransactionBatch { - static const String _transactionApi = 'api/transaction/v1/execute'; - - final Client _client; - final List _operations = []; - - TransactionBatch(this._client); - - @override - IApiBatch api(String apiName) { - return ApiBatch(this, apiName); - } - - @override - Future> send() async { - final request = TransactionRequest(operations: _operations); - final response = await _client.fetch( - TransactionBatch._transactionApi, - method: 'POST', - data: request.toJson(), - ); - - if ((response.statusCode ?? 400) > 200) { - throw Exception('${response.data} ${response.statusMessage}'); - } - - final result = ResponseRecordIds.fromJson(response.data); - return result.toRecordIds(); - } - - void _addOperation(Operation operation) { - _operations.add(operation); - } -} - -class ApiBatch implements IApiBatch { - final TransactionBatch _batch; - final String _apiName; - - ApiBatch(this._batch, this._apiName); - - @override - ITransactionBatch create(Map value) { - _batch._addOperation( - Operation(create: _CreateOperation(apiName: _apiName, value: value)), - ); - return _batch; - } - - @override - ITransactionBatch update(RecordId recordId, Map value) { - _batch._addOperation( - Operation( - update: _UpdateOperation( - apiName: _apiName, - recordId: recordId, - value: value, - ), - ), - ); - return _batch; - } - - @override - ITransactionBatch delete(RecordId recordId) { - _batch._addOperation( - Operation( - delete: _DeleteOperation(apiName: _apiName, recordId: recordId)), - ); - return _batch; - } -} diff --git a/client/dart/lib/trailbase.dart b/client/dart/lib/trailbase.dart index 59d96d5e4..1d840900f 100644 --- a/client/dart/lib/trailbase.dart +++ b/client/dart/lib/trailbase.dart @@ -3,4 +3,3 @@ library; export 'src/client.dart'; export 'src/pkce.dart'; export 'src/sse.dart'; -export 'src/transaction.dart'; diff --git a/client/dart/test/trailbase_test.dart b/client/dart/test/trailbase_test.dart index d26639b0d..7dffa0eeb 100644 --- a/client/dart/test/trailbase_test.dart +++ b/client/dart/test/trailbase_test.dart @@ -446,42 +446,5 @@ Future main() async { textNotNull: createMessage, )); }); - - test('transaction', () async { - final client = await connect(); - final api = client.records('simple_strict_table'); - - var ids = []; - - { - final batch = client.transaction(); - final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final message = 'dart transaction test create: ${now}'; - batch.api('simple_strict_table').create({'text_not_null': message}); - ids = await batch.send(); - expect(ids.length, equals(1)); - final record = SimpleStrict.fromJson(await api.read(ids[0])); - expect(record.textNotNull, message); - } - - { - final batch = client.transaction(); - final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final message = 'dart transaction test update: ${now}'; - batch.api('simple_strict_table').update(ids[0], { - 'text_not_null': message, - }); - await batch.send(); - final record = SimpleStrict.fromJson(await api.read(ids[0])); - expect(record.textNotNull, message); - } - - { - final batch = client.transaction(); - batch.api('simple_strict_table').delete(ids[0]); - await batch.send(); - expect(() async => await api.read(ids[0]), throwsException); - } - }); }); } diff --git a/client/dotnet/test/ClientTest.cs b/client/dotnet/test/ClientTest.cs index b5cb6ce7a..5adf2817c 100644 --- a/client/dotnet/test/ClientTest.cs +++ b/client/dotnet/test/ClientTest.cs @@ -435,71 +435,4 @@ await api.Update( Assert.True(tableEvents[2] is DeleteEvent); Assert.Equal(UpdatedMessage, tableEvents[2].Value!["text_not_null"]?.ToString()); } - - [Fact] - [RequiresDynamicCode("Testing dynamic code")] - [RequiresUnreferencedCode("testing dynamic code")] - public async Task TransactionCreateOperationTest() { - var client = await ClientTest.Connect(); - var batch = client.Transaction(); - var api = client.Records("simple_strict_table"); - - var now = DateTimeOffset.Now.ToUnixTimeSeconds(); - var suffix = $"{now} {System.Environment.Version} transaction"; - var record = new SimpleStrict(null, null, null, $"C# transaction create test: {suffix}"); - - batch.Api("simple_strict_table").Create(record, SerializeSimpleStrictContext.Default.SimpleStrict); - - // Test actual creation - var ids = await batch.Send(); - Assert.Single(ids); - - var createdRecord = await api.Read(ids[0]); - Assert.Equal(record.text_not_null, createdRecord!.text_not_null); - } - - [Fact] - [RequiresDynamicCode("Testing dynamic code")] - [RequiresUnreferencedCode("testing dynamic code")] - public async Task TransactionUpdateOperationTest() { - var client = await ClientTest.Connect(); - var batch = client.Transaction(); - var api = client.Records("simple_strict_table"); - - // First create a record to update - var now = DateTimeOffset.Now.ToUnixTimeSeconds(); - var suffix = $"{now} {System.Environment.Version} transaction"; - var createRecord = new SimpleStrict(null, null, null, $"C# transaction update test original: {suffix}"); - var id = await api.Create(createRecord, SerializeSimpleStrictContext.Default.SimpleStrict); - - // Update operation - var updateRecord = new SimpleStrict(null, null, null, $"C# transaction update test modified: {suffix}"); - batch.Api("simple_strict_table").Update(id, updateRecord, SerializeSimpleStrictContext.Default.SimpleStrict); - - // Test actual update - await batch.Send(); - var updatedRecord = await api.Read(id); - Assert.Equal(updateRecord.text_not_null, updatedRecord!.text_not_null); - } - - [Fact] - [RequiresDynamicCode("Testing dynamic code")] - [RequiresUnreferencedCode("testing dynamic code")] - public async Task TransactionDeleteOperationTest() { - var client = await ClientTest.Connect(); - var batch = client.Transaction(); - var api = client.Records("simple_strict_table"); - - // First create a record to delete - var now = DateTimeOffset.Now.ToUnixTimeSeconds(); - var suffix = $"{now} {System.Environment.Version} transaction"; - var createRecord = new SimpleStrict(null, null, null, $"C# transaction delete test: {suffix}"); - var id = await api.Create(createRecord, SerializeSimpleStrictContext.Default.SimpleStrict); - - batch.Api("simple_strict_table").Delete(id); - - // Test actual deletion - await batch.Send(); - await Assert.ThrowsAsync(() => api.Read(id)); - } } diff --git a/client/dotnet/trailbase/Client.cs b/client/dotnet/trailbase/Client.cs index e4a1365aa..a794f11dc 100644 --- a/client/dotnet/trailbase/Client.cs +++ b/client/dotnet/trailbase/Client.cs @@ -292,11 +292,6 @@ public RecordApi Records(string name) { return new RecordApi(this, name); } - /// Create a new transaction batch. - public ITransactionBatch Transaction() { - return new TransactionBatch(this); - } - /// Log in with the given credentials. public async Task Login(string email, string password) { var response = await Fetch( diff --git a/client/dotnet/trailbase/TransactionApi.cs b/client/dotnet/trailbase/TransactionApi.cs deleted file mode 100644 index a93019a82..000000000 --- a/client/dotnet/trailbase/TransactionApi.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using System.Threading.Tasks; - -namespace TrailBase; - -[JsonConverter(typeof(OperationJsonConverter))] -internal abstract class Operation { - [JsonPropertyName("api_name")] - public string ApiName { get; set; } = string.Empty; - - public static Operation Create(string apiName, JsonObject value) - => new CreateOperation { ApiName = apiName, Value = value }; - - public static Operation Update(string apiName, string recordId, JsonObject value) - => new UpdateOperation { ApiName = apiName, RecordId = recordId, Value = value }; - - public static Operation Delete(string apiName, string recordId) - => new DeleteOperation { ApiName = apiName, RecordId = recordId }; -} - -internal class CreateOperation : Operation { - [JsonPropertyName("value")] - public JsonObject Value { get; set; } = new(); -} - -internal class UpdateOperation : Operation { - [JsonPropertyName("record_id")] - public string RecordId { get; set; } = string.Empty; - - [JsonPropertyName("value")] - public JsonObject Value { get; set; } = new(); -} - -internal class DeleteOperation : Operation { - [JsonPropertyName("record_id")] - public string RecordId { get; set; } = string.Empty; -} - -[RequiresDynamicCode("JSON serialization may require dynamic code")] -[RequiresUnreferencedCode("JSON serialization may require unreferenced code")] -internal class OperationJsonConverter : JsonConverter { - public override Operation? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - using (var doc = JsonDocument.ParseValue(ref reader)) { - var root = doc.RootElement; - if (root.TryGetProperty("Create", out var createElem)) { return JsonSerializer.Deserialize(createElem.GetRawText(), options); } - if (root.TryGetProperty("Update", out var updateElem)) { return JsonSerializer.Deserialize(updateElem.GetRawText(), options); } - if (root.TryGetProperty("Delete", out var deleteElem)) { return JsonSerializer.Deserialize(deleteElem.GetRawText(), options); } - throw new JsonException("Unknown operation type"); - } - } - - public override void Write(Utf8JsonWriter writer, Operation value, JsonSerializerOptions options) { - writer.WriteStartObject(); - - switch (value) { - case CreateOperation create: - writer.WritePropertyName("Create"); - JsonSerializer.Serialize(writer, create, typeof(CreateOperation), options); - break; - case UpdateOperation update: - writer.WritePropertyName("Update"); - JsonSerializer.Serialize(writer, update, typeof(UpdateOperation), options); - break; - case DeleteOperation delete: - writer.WritePropertyName("Delete"); - JsonSerializer.Serialize(writer, delete, typeof(DeleteOperation), options); - break; - default: - throw new NotSupportedException($"Operation of type {value.GetType()} is not supported."); - } - - writer.WriteEndObject(); - } -} - -internal class TransactionRequest { - [JsonPropertyName("operations")] - public List Operations { get; set; } = new(); -} - -internal class TransactionResponse { - [JsonPropertyName("ids")] - public List Ids { get; set; } = new(); -} - -/// Transaction -public interface ITransactionBatch { - /// Api - IApiBatch Api(string apiName); - - /// Send - [RequiresDynamicCode("JSON serialization may require dynamic code")] - [RequiresUnreferencedCode("JSON serialization may require unreferenced code")] - Task> Send(); -} - -/// Api -public interface IApiBatch { - /// Create - ITransactionBatch Create(T record, JsonTypeInfo jsonTypeInfo); - /// Update - ITransactionBatch Update(RecordId recordId, T record, JsonTypeInfo jsonTypeInfo); - /// Delete - ITransactionBatch Delete(RecordId recordId); -} - -/// New transaction batch. -public class TransactionBatch : ITransactionBatch { - static readonly string _transactionApi = "api/transaction/v1/execute"; - private readonly Client _client; - private readonly List _operations = new(); - - /// - public TransactionBatch(Client client) { - _client = client; - } - - /// Api. - public IApiBatch Api(string apiName) { - return new ApiBatch(this, apiName); - } - - /// Send transaction batch. - [RequiresDynamicCode("JSON serialization may require dynamic code")] - [RequiresUnreferencedCode("JSON serialization may require unreferenced code")] - public async Task> Send() { - var request = new TransactionRequest { Operations = _operations }; - var response = await _client.Fetch( - TransactionBatch._transactionApi, - HttpMethod.Post, - JsonContent.Create(request), - null - ); - - string json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json); - - return result?.Ids ?? new List(); - } - - internal void AddOperation(Operation operation) { - _operations.Add(operation); - } -} - -internal class ApiBatch : IApiBatch { - private readonly TransactionBatch _batch; - private readonly string _apiName; - - public ApiBatch(TransactionBatch batch, string apiName) { - _batch = batch; - _apiName = apiName; - } - - public ITransactionBatch Create(T record, JsonTypeInfo jsonTypeInfo) { - var value = ToJsonObject(record, jsonTypeInfo); - _batch.AddOperation(Operation.Create(_apiName, value)); - return _batch; - } - - public ITransactionBatch Update(RecordId recordId, T record, JsonTypeInfo jsonTypeInfo) { - var value = ToJsonObject(record, jsonTypeInfo); - _batch.AddOperation(Operation.Update(_apiName, recordId.ToString(), value)); - return _batch; - } - - public ITransactionBatch Delete(RecordId recordId) { - _batch.AddOperation(Operation.Delete(_apiName, recordId.ToString())); - return _batch; - } - - private static JsonObject ToJsonObject(T record, JsonTypeInfo jsonTypeInfo) { - var node = JsonSerializer.SerializeToNode(record, jsonTypeInfo); - - return node as JsonObject ?? throw new InvalidOperationException("The provided record did not serialize to a JSON object."); - } -} diff --git a/client/go/trailbase/client.go b/client/go/trailbase/client.go index f38201d99..6c9bb0130 100644 --- a/client/go/trailbase/client.go +++ b/client/go/trailbase/client.go @@ -132,8 +132,6 @@ type Client interface { // Internal do(method string, path string, body []byte, queryParams []QueryParam) (*http.Response, error) - - Transaction() *TransactionBatch } type ClientImpl struct { @@ -271,13 +269,6 @@ func (c *ClientImpl) do(method string, path string, body []byte, queryParams []Q return c.client.do(method, path, headers, body, queryParams) } -func (c *ClientImpl) Transaction() *TransactionBatch { - return &TransactionBatch{ - client: c, - operations: make([]Operation, 0), - } -} - func (c *ClientImpl) updateTokens(tokens *Tokens) (*Tokens, error) { state, err := NewTokenState(tokens) if err != nil { diff --git a/client/go/trailbase/client_test.go b/client/go/trailbase/client_test.go index fa2c7eb22..6de61c011 100644 --- a/client/go/trailbase/client_test.go +++ b/client/go/trailbase/client_test.go @@ -251,52 +251,6 @@ func TestRecordApi(t *testing.T) { assert(t, r == nil, "expected nil value reading delete record") } -func TestTransaction(t *testing.T) { - client := connect(t) - batch := client.Transaction() - api := NewRecordApi[SimpleStrict](client, "simple_strict_table") - - now := time.Now().Unix() - - var ids []RecordId - - // Create - createdMessage := fmt.Sprint("go transaction create test: =?&", now) - batch.API("simple_strict_table").Create(SimpleStrict{ - TextNotNull: createdMessage, - }) - ids, err := batch.Send() - assertFine(t, err) - assert(t, len(ids) == 1, "Expected one ID from create operation") - - simpleStrict1, err := api.Read(ids[0]) - assertFine(t, err) - assertEqual(t, createdMessage, simpleStrict1.TextNotNull) - - // Update - { - updatedMessage := fmt.Sprint("go transaction update test: =?&", now) - batch.API("simple_strict_table").Update(ids[0], SimpleStrict{ - TextNotNull: updatedMessage, - }) - _, err := batch.Send() - assertFine(t, err) - - simpleStrict2, err := api.Read(ids[0]) - assertFine(t, err) - assertEqual(t, updatedMessage, simpleStrict2.TextNotNull) - } - // Delete - { - batch.API("simple_strict_table").Delete(ids[0]) - _, err := batch.Send() - assertFine(t, err) - r, err := api.Read(ids[0]) - assert(t, err != nil, "expected error reading delete record") - assert(t, r == nil, "expected nil value reading delete record") - } -} - func assertEqual[T comparable](t *testing.T, expected T, got T) { if expected != got { buf := make([]byte, 1<<16) diff --git a/client/go/trailbase/transaction_api.go b/client/go/trailbase/transaction_api.go deleted file mode 100644 index 2b0e5f504..000000000 --- a/client/go/trailbase/transaction_api.go +++ /dev/null @@ -1,161 +0,0 @@ -package trailbase - -import ( - "encoding/json" - "fmt" - "net/http" -) - -type Operation struct { - Type string `json:"-"` - ApiName string `json:"api_name"` - RecordID string `json:"record_id,omitempty"` - Value interface{} `json:"value,omitempty"` -} - -func (o Operation) MarshalJSON() ([]byte, error) { - var wrapper struct { - Create *struct { - ApiName string `json:"api_name"` - Value interface{} `json:"value"` - } `json:"Create,omitempty"` - Update *struct { - ApiName string `json:"api_name"` - RecordID string `json:"record_id"` - Value interface{} `json:"value"` - } `json:"Update,omitempty"` - Delete *struct { - ApiName string `json:"api_name"` - RecordID string `json:"record_id"` - } `json:"Delete,omitempty"` - } - - switch o.Type { - case "Create": - wrapper.Create = &struct { - ApiName string `json:"api_name"` - Value interface{} `json:"value"` - }{ - ApiName: o.ApiName, - Value: o.Value, - } - case "Update": - wrapper.Update = &struct { - ApiName string `json:"api_name"` - RecordID string `json:"record_id"` - Value interface{} `json:"value"` - }{ - ApiName: o.ApiName, - RecordID: o.RecordID, - Value: o.Value, - } - case "Delete": - wrapper.Delete = &struct { - ApiName string `json:"api_name"` - RecordID string `json:"record_id"` - }{ - ApiName: o.ApiName, - RecordID: o.RecordID, - } - } - - return json.Marshal(wrapper) -} - -type TransactionRequest struct { - Operations []Operation `json:"operations"` -} - -type TransactionResponse struct { - Ids []string `json:"ids"` -} - -type TransactionBatch struct { - client Client - operations []Operation -} - -type ApiBatch struct { - batch *TransactionBatch - apiName string -} - -func (tb *TransactionBatch) API(apiName string) *ApiBatch { - return &ApiBatch{ - batch: tb, - apiName: apiName, - } -} - -func (tb *TransactionBatch) Send() ([]RecordId, error) { - reqBody := TransactionRequest{ - Operations: tb.operations, - } - - jsonData, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - resp, err := tb.client.do("POST", transactionApi, jsonData, []QueryParam{}) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - var response TransactionResponse - decoder := json.NewDecoder(resp.Body) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&response); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - if response.Ids == nil { - response.Ids = []string{} - } - - recordIDs := make([]RecordId, len(response.Ids)) - for i, idStr := range response.Ids { - recordIDs[i] = StringRecordId(idStr) - } - - return recordIDs, nil -} - -func (tb *TransactionBatch) addOperation(op Operation) { - tb.operations = append(tb.operations, op) -} - -func (ab *ApiBatch) Create(value interface{}) *TransactionBatch { - ab.batch.addOperation(Operation{ - Type: "Create", - ApiName: ab.apiName, - Value: value, - }) - return ab.batch -} - -func (ab *ApiBatch) Update(recordID RecordId, value interface{}) *TransactionBatch { - ab.batch.addOperation(Operation{ - Type: "Update", - ApiName: ab.apiName, - RecordID: recordID.ToString(), - Value: value, - }) - return ab.batch -} - -func (ab *ApiBatch) Delete(recordID RecordId) *TransactionBatch { - ab.batch.addOperation(Operation{ - Type: "Delete", - ApiName: ab.apiName, - RecordID: recordID.ToString(), - }) - return ab.batch -} - -const transactionApi string = "api/transaction/v1/execute" diff --git a/client/python/tests/test_client.py b/client/python/tests/test_client.py index 261d0e6f3..1809ae819 100644 --- a/client/python/tests/test_client.py +++ b/client/python/tests/test_client.py @@ -269,38 +269,4 @@ def test_subscriptions(trailbase: TrailBaseFixture): assert "Insert" in events[0] -def test_transaction(trailbase: TrailBaseFixture): - assert trailbase.isUp() - - client = connect() - api = client.records("simple_strict_table") - ids: List[RecordId] = [] - - if True: - now = int(time()) - batch = client.transaction() - message = f"transaction test create: {now}" - batch.api("simple_strict_table").create({"text_not_null": message}) - ids = batch.send() - record = api.read(ids[0]) - assert record["text_not_null"] == message - - if True: - now = int(time()) - batch = client.transaction() - updatedMessage = f"transaction test update: {now}" - batch.api("simple_strict_table").update(ids[0], {"text_not_null": updatedMessage}) - batch.send() - record = api.read(ids[0]) - assert record["text_not_null"] == updatedMessage - - if True: - batch = client.transaction() - batch.api("simple_strict_table").delete(ids[0]) - batch.send() - - with pytest.raises(Exception): - api.read(ids[0]) - - logger = logging.getLogger(__name__) diff --git a/client/python/trailbase/__init__.py b/client/python/trailbase/__init__.py index f02837a4e..685a5f762 100644 --- a/client/python/trailbase/__init__.py +++ b/client/python/trailbase/__init__.py @@ -2,23 +2,6 @@ __description__ = "TrailBase client SDK for python." __version__ = "0.1.0" -__all__ = [ - "Client", - "CompareOp", - "Filter", - "And", - "Or", - "RecordId", - "User", - "ListResponse", - "Tokens", - "JSON", - "JSON_OBJECT", - "JSON_ARRAY", - "TransactionBatch", - "ApiBatch", -] - import httpx import jwt import logging @@ -28,7 +11,7 @@ from enum import Enum from contextlib import contextmanager from time import time -from typing import TypeAlias, List, Protocol, cast, final +from typing import TypeAlias, cast, final JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None JSON_OBJECT: TypeAlias = dict[str, JSON] @@ -269,107 +252,6 @@ def impl(): return impl() -# Transaction related classes and protocols -class CreateOperation(typing.TypedDict): - api_name: str - value: JSON_OBJECT - - -class UpdateOperation(typing.TypedDict): - api_name: str - record_id: str - value: JSON_OBJECT - - -class DeleteOperation(typing.TypedDict): - api_name: str - record_id: str - - -class Operation(typing.TypedDict, total=False): - Create: CreateOperation - Update: UpdateOperation - Delete: DeleteOperation - - -class TransactionRequest(typing.TypedDict): - operations: List[Operation] - - -class TransactionResponse(typing.TypedDict): - ids: List[str] - - -class ITransactionBatch(Protocol): - def api(self, api_name: str) -> "IApiBatch": ... - - def send(self) -> List[RecordId]: ... - - -class IApiBatch(Protocol): - def create(self, value: JSON_OBJECT) -> ITransactionBatch: ... - - def update(self, recordId: RecordId | str | int, value: JSON_OBJECT) -> ITransactionBatch: ... - - def delete(self, recordId: RecordId | str | int) -> ITransactionBatch: ... - - -class TransactionBatch: - _transactionApi: str = "api/transaction/v1/execute" - - _client: "Client" - _operations: List[Operation] - - def __init__(self, client: "Client") -> None: - self._client = client - self._operations = [] - - def api(self, api_name: str) -> "ApiBatch": - return ApiBatch(self, api_name) - - def send(self) -> List[RecordId]: - ops_json = [dict(op) for op in self._operations] - - response = self._client.fetch( - self._transactionApi, - method="POST", - data=cast(JSON, {"operations": ops_json}), - ) - if response.status_code != 200: - raise Exception(f"Transaction failed with status code {response.status_code}: {response.text}") - - return record_ids_from_json(response.json()) - - def add_operation(self, operation: Operation) -> None: - self._operations.append(operation) - - -class ApiBatch: - _batch: TransactionBatch - _api_name: str - - def __init__(self, batch: TransactionBatch, api_name: str) -> None: - self._batch = batch - self._api_name = api_name - - def create(self, value: JSON_OBJECT) -> ITransactionBatch: - operation: Operation = {"Create": {"api_name": self._api_name, "value": value}} - self._batch.add_operation(operation) - return self._batch - - def update(self, recordId: RecordId | str | int, value: JSON_OBJECT) -> ITransactionBatch: - id = repr(recordId) if isinstance(recordId, RecordId) else f"{recordId}" - operation: Operation = {"Update": {"api_name": self._api_name, "record_id": id, "value": value}} - self._batch.add_operation(operation) - return self._batch - - def delete(self, recordId: RecordId | str | int) -> ITransactionBatch: - id = repr(recordId) if isinstance(recordId, RecordId) else f"{recordId}" - operation: Operation = {"Delete": {"api_name": self._api_name, "record_id": id}} - self._batch.add_operation(operation) - return self._batch - - class Client: _authApi: str = "api/auth/v1" @@ -447,9 +329,6 @@ def logout(self) -> None: def records(self, name: str) -> "RecordApi": return RecordApi(name, self) - def transaction(self) -> TransactionBatch: - return TransactionBatch(self) - def _updateTokens(self, tokens: Tokens | None): state = TokenState.build(tokens) diff --git a/client/swift/trailbase/Sources/TrailBase/TrailBase.swift b/client/swift/trailbase/Sources/TrailBase/TrailBase.swift index 36abb705c..883eb126f 100644 --- a/client/swift/trailbase/Sources/TrailBase/TrailBase.swift +++ b/client/swift/trailbase/Sources/TrailBase/TrailBase.swift @@ -328,10 +328,6 @@ public class Client { return RecordApi(client: self, name: name) } - public func transaction() -> TransactionBatch { - return TransactionBatch(client: self) - } - public func refresh() async throws { guard let (headers, refreshToken) = getHeaderAndRefreshToken() else { throw ClientError.unauthenticated @@ -385,7 +381,7 @@ public class Client { return state } - internal func fetch( + fileprivate func fetch( path: String, method: String, body: Data? = nil, diff --git a/client/swift/trailbase/Sources/TrailBase/Transaction.swift b/client/swift/trailbase/Sources/TrailBase/Transaction.swift deleted file mode 100644 index 5f3d01950..000000000 --- a/client/swift/trailbase/Sources/TrailBase/Transaction.swift +++ /dev/null @@ -1,189 +0,0 @@ -import Foundation - -public enum Operation: Codable { - case create(apiName: String, value: [String: AnyCodable]) - case update(apiName: String, recordId: RecordId, value: [String: AnyCodable]) - case delete(apiName: String, recordId: RecordId) - - private enum CodingKeys: String, CodingKey { - case create = "Create" - case update = "Update" - case delete = "Delete" - case apiName = "api_name" - case recordId = "record_id" - case value = "value" - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case let .create(apiName, value): - var createContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .create) - try createContainer.encode(apiName, forKey: .apiName) - try createContainer.encode(value, forKey: .value) - - case let .update(apiName, recordId, value): - var updateContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .update) - try updateContainer.encode(apiName, forKey: .apiName) - try updateContainer.encode("\(recordId)", forKey: .recordId) - try updateContainer.encode(value, forKey: .value) - - case let .delete(apiName, recordId): - var deleteContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .delete) - try deleteContainer.encode(apiName, forKey: .apiName) - try deleteContainer.encode("\(recordId)", forKey: .recordId) - } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - if let createContainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .create) { - let apiName = try createContainer.decode(String.self, forKey: .apiName) - let value = try createContainer.decode([String: AnyCodable].self, forKey: .value) - self = .create(apiName: apiName, value: value) - - } else if let updateContainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .update) { - let apiName = try updateContainer.decode(String.self, forKey: .apiName) - let recordIdString = try updateContainer.decode(String.self, forKey: .recordId) - let value = try updateContainer.decode([String: AnyCodable].self, forKey: .value) - self = .update(apiName: apiName, recordId: RecordId.string(recordIdString), value: value) - - } else if let deleteContainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .delete) { - let apiName = try deleteContainer.decode(String.self, forKey: .apiName) - let recordIdString = try deleteContainer.decode(String.self, forKey: .recordId) - self = .delete(apiName: apiName, recordId: RecordId.string(recordIdString)) - - } else { - throw DecodingError.dataCorrupted( - DecodingError.Context(codingPath: container.codingPath, debugDescription: "Invalid Operation type") - ) - } - } -} - -public struct TransactionRequest: Codable { - public var operations: [Operation] = [] - - private enum CodingKeys: String, CodingKey { - case operations = "operations" - } -} - -public struct TransactionResponse: Codable { - public var ids: [String] = [] - - private enum CodingKeys: String, CodingKey { - case ids = "ids" - } -} - -public class TransactionBatch { - private let client: Client - private var operations: [Operation] = [] - - init(client: Client) { - self.client = client - } - - public func api(_ apiName: String) -> ApiBatch { - return ApiBatch(batch: self, apiName: apiName) - } - - public func send() async throws -> [RecordId] { - let request = TransactionRequest(operations: operations) - let body = try JSONEncoder().encode(request) - - let (_, data) = try await client.fetch( - path: TRANSACTION_API, - method: "POST", - body: body - ) - - let response = try JSONDecoder().decode(TransactionResponse.self, from: data) - return response.ids.map { RecordId.string($0) } - } - - internal func addOperation(_ operation: Operation) { - operations.append(operation) - } -} - -public class ApiBatch { - private let batch: TransactionBatch - private let apiName: String - - init(batch: TransactionBatch, apiName: String) { - self.batch = batch - self.apiName = apiName - } - - public func create(value: [String: AnyCodable]) -> TransactionBatch { - batch.addOperation(.create(apiName: apiName, value: value)) - return batch - } - - public func update(recordId: RecordId, value: [String: AnyCodable]) -> TransactionBatch { - batch.addOperation(.update(apiName: apiName, recordId: recordId, value: value)) - return batch - } - - public func delete(recordId: RecordId) -> TransactionBatch { - batch.addOperation(.delete(apiName: apiName, recordId: recordId)) - return batch - } -} - -public struct AnyCodable: Codable { - public let value: Any - - public init(_ value: T?) { - self.value = value ?? () - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self.value = () - } else if let bool = try? container.decode(Bool.self) { - self.value = bool - } else if let int = try? container.decode(Int.self) { - self.value = int - } else if let double = try? container.decode(Double.self) { - self.value = double - } else if let string = try? container.decode(String.self) { - self.value = string - } else if let array = try? container.decode([AnyCodable].self) { - self.value = array.map { $0.value } - } else if let dictionary = try? container.decode([String: AnyCodable].self) { - self.value = dictionary.mapValues { $0.value } - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - if value is () { - try container.encodeNil() - } else if let bool = value as? Bool { - try container.encode(bool) - } else if let int = value as? Int { - try container.encode(int) - } else if let double = value as? Double { - try container.encode(double) - } else if let string = value as? String { - try container.encode(string) - } else if let array = value as? [Any] { - try container.encode(array.map(AnyCodable.init)) - } else if let dictionary = value as? [String: Any] { - try container.encode(dictionary.mapValues(AnyCodable.init)) - } else { - let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded") - throw EncodingError.invalidValue(value, context) - } - } -} - -private let TRANSACTION_API = "api/transaction/v1/execute" diff --git a/client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift b/client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift index 36000e270..0e2259527 100644 --- a/client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift +++ b/client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift @@ -199,49 +199,4 @@ extension Trait where Self == SetupTrailBaseTrait { } catch { } } - - @Test("Test Transaction") func testTransaction() async throws { - let client = try await connect() - let now = NSDate().timeIntervalSince1970 - - // Test create operation - let batch = client.transaction() - let createRecord: [String: AnyCodable] = [ - "text_not_null": AnyCodable("swift transaction create test: =?&\(now)") - ] - batch.api("simple_strict_table").create(value: createRecord) - - // Test actual creation - let ids: [RecordId] = try await batch.send() - #expect(ids.count == 1) - - // Verify record was created - let api = client.records("simple_strict_table") - let createdRecord: SimpleStrict = try await api.read(recordId: ids[0]) - #expect(createdRecord.text_not_null == createRecord["text_not_null"]?.value as? String) - - // Test update operation - let updateBatch = client.transaction() - let updateRecord: [String: AnyCodable] = [ - "text_not_null": AnyCodable("swift transaction update test: =?&\(now)") - ] - updateBatch.api("simple_strict_table").update(recordId: ids[0], value: updateRecord) - - // Test actual update - let _ = try await updateBatch.send() - let updatedRecord: SimpleStrict = try await api.read(recordId: ids[0]) - #expect(updatedRecord.text_not_null == updateRecord["text_not_null"]?.value as? String) - - // Test delete operation - let deleteBatch = client.transaction() - deleteBatch.api("simple_strict_table").delete(recordId: ids[0]) - - // Test actual deletion - let _ = try await deleteBatch.send() - do { - let _: SimpleStrict = try await api.read(recordId: ids[0]) - #expect(Bool(false)) - } catch { - } - } } diff --git a/client/testfixture/config.textproto b/client/testfixture/config.textproto index d59201374..9a99426e2 100644 --- a/client/testfixture/config.textproto +++ b/client/testfixture/config.textproto @@ -3,7 +3,6 @@ email {} server { application_name: "TrailBase" logs_retention_sec: 604800 - enable_record_transactions: true } auth { oauth_providers: [{ diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index fe9c98876..77fdc4b98 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -428,9 +428,6 @@ export interface Client { /// /// Unlike native fetch, will throw in case !response.ok. fetch(path: string, init?: FetchOptions): Promise; - - /// Creates a new transaction batch. - transaction(): TransactionBatch; } /// Client for interacting with TrailBase auth and record APIs. @@ -488,11 +485,6 @@ class ClientImpl implements Client { return new RecordApiImpl(this, name); } - /// Creates a new transaction batch. - public transaction(): TransactionBatch { - return new TransactionBatch(this); - } - public avatarUrl(userId?: string): string | undefined { const id = userId ?? this.user()?.id; if (id) { @@ -689,7 +681,6 @@ export async function initClientFromCookies( const recordApiBasePath = "/api/records/v1"; const authApiBasePath = "/api/auth/v1"; -const transactionApiBasePath = "/api/transaction/v1/execute"; export function filePath( apiName: string, @@ -785,95 +776,3 @@ export const exportedForTesting = isDev base64Encode, } : undefined; - -/// Batch Builder Class -export interface Operation { - Create?: { - api_name: string; - value: Record; - }; - Update?: { - api_name: string; - record_id: string; - value: Record; - }; - Delete?: { - api_name: string; - record_id: string; - }; -} - -export interface TransactionRequest { - operations: Operation[]; -} - -export interface TransactionResponse { - ids: string[]; -} - -/// Batch Builder Class -export class TransactionBatch { - private operations: Operation[] = []; - - constructor(private client: Client) {} - - api(apiName: string): ApiBatch { - return new ApiBatch(this, apiName); - } - - async send(): Promise { - const response = await this.client.fetch(transactionApiBasePath, { - method: "POST", - body: JSON.stringify({ operations: this.operations }), - headers: jsonContentTypeHeader, - }); - - return (await response.json()).ids; - } - - addOperation(operation: Operation): void { - this.operations.push(operation); - } -} - -// Api-Specific Operations -export class ApiBatch { - constructor( - private batch: TransactionBatch, - private apiName: string, - ) {} - - create(value: Record): TransactionBatch { - this.batch.addOperation({ - Create: { - api_name: this.apiName, - value: value, - }, - }); - return this.batch; - } - - update( - recordId: string | number, - value: Record, - ): TransactionBatch { - this.batch.addOperation({ - Update: { - api_name: this.apiName, - record_id: `${recordId}`, - value: value, - }, - }); - return this.batch; - } - - delete(recordId: string | number): TransactionBatch { - this.batch.addOperation({ - Delete: { - api_name: this.apiName, - record_id: `${recordId}`, - }, - }); - return this.batch; - } -} diff --git a/crates/assets/js/client/tests/integration/client_integration.test.ts b/crates/assets/js/client/tests/integration/client_integration.test.ts index 87149d79f..7f4974934 100644 --- a/crates/assets/js/client/tests/integration/client_integration.test.ts +++ b/crates/assets/js/client/tests/integration/client_integration.test.ts @@ -331,58 +331,6 @@ test("realtime subscribe specific record tests", async () => { expect(events[1]["Delete"]["text_not_null"]).equals(updatedMessage); }); -test("transaction tests", async () => { - const client = await connect(); - const now = new Date().getTime(); - - // Test transaction with create operation - { - const batch = client.transaction(); - const record = { text_not_null: `ts transaction create test: =?&${now}` }; - batch.api("simple_strict_table").create(record); - - const ids = await batch.send(); - expect(ids).toHaveLength(1); - - // Verify record was created - const api = client.records("simple_strict_table"); - const createdRecord = await api.read(ids[0]); - expect(createdRecord.text_not_null).toBe(record.text_not_null); - } - - // Test transaction with update operation - { - const api = client.records("simple_strict_table"); - const record = { - text_not_null: `ts transaction update test original: =?&${now}`, - }; - const id = await api.create(record); - - const batch = client.transaction(); - const updatedRecord = { - text_not_null: `ts transaction update test modified: =?&${now}`, - }; - batch.api("simple_strict_table").update(id, updatedRecord); - - await batch.send(); - const readRecord = await api.read(id); - expect(readRecord.text_not_null).toBe(updatedRecord.text_not_null); - } - - // Test transaction with delete operation - { - const api = client.records("simple_strict_table"); - const record = { text_not_null: `ts transaction delete test: =?&${now}` }; - const id = await api.create(record); - - const batch = client.transaction(); - batch.api("simple_strict_table").delete(id); - - await batch.send(); - await expect(api.read(id)).rejects.toThrow(); - } -}); - test("realtime subscribe table tests", async () => { const client = await connect(); const api = client.records("simple_strict_table"); diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index d74f8a888..63735b356 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -689,108 +689,6 @@ impl ClientState { } } -#[derive(Clone, Debug, Deserialize, Serialize)] -pub enum Operation { - Create { - api_name: String, - value: serde_json::Value, - }, - Update { - api_name: String, - record_id: String, - value: serde_json::Value, - }, - Delete { - api_name: String, - record_id: String, - }, -} - -#[derive(Serialize)] -pub struct TransactionRequest { - pub operations: Vec, -} - -#[derive(Deserialize)] -pub struct TransactionResponse { - pub ids: Vec, -} - -pub struct TransactionBatch { - client: Arc, - operations: Vec, -} - -pub struct ApiBatch<'a> { - batch: &'a mut TransactionBatch, - api_name: String, -} - -impl TransactionBatch { - fn new(client: Arc) -> Self { - Self { - client, - operations: Vec::new(), - } - } - - pub fn api(&mut self, api_name: impl Into) -> ApiBatch<'_> { - ApiBatch { - batch: self, - api_name: api_name.into(), - } - } - - pub async fn send(self) -> Result, Error> { - let request = TransactionRequest { - operations: self.operations, - }; - - let response = self - .client - .fetch(TRANSACTION_API, Method::POST, Some(&request), None) - .await?; - - let result: TransactionResponse = json(response).await?; - Ok(result.ids) - } - - fn add_operation(&mut self, operation: Operation) { - self.operations.push(operation); - } -} - -impl<'a> ApiBatch<'a> { - pub fn create(self, value: impl Into) -> &'a mut TransactionBatch { - self.batch.add_operation(Operation::Create { - api_name: self.api_name, - value: value.into(), - }); - self.batch - } - - pub fn update( - self, - record_id: impl RecordId<'a>, - value: impl Into, - ) -> &'a mut TransactionBatch { - self.batch.add_operation(Operation::Update { - api_name: self.api_name, - record_id: record_id.serialized_id().to_string(), - value: value.into(), - }); - self.batch - } - - pub fn delete(self, record_id: impl RecordId<'a>) -> &'a mut TransactionBatch { - self.batch.add_operation(Operation::Delete { - api_name: self.api_name, - record_id: record_id.serialized_id().to_string(), - }); - self.batch - } -} - #[derive(Clone)] pub struct Client { state: Arc, @@ -920,10 +818,6 @@ impl Client { return state; } - - pub fn transaction(&self) -> TransactionBatch { - TransactionBatch::new(self.state.clone()) - } } fn build_headers(tokens: Option<&Tokens>) -> HeaderMap { @@ -975,7 +869,6 @@ async fn json(resp: reqwest::Response) -> Result const AUTH_API: &str = "api/auth/v1"; const RECORD_API: &str = "api/records/v1"; -const TRANSACTION_API: &str = "/api/transaction/v1/execute"; #[cfg(test)] mod tests { diff --git a/crates/client/tests/integration_test.rs b/crates/client/tests/integration_test.rs index ca6142072..ecf3af047 100644 --- a/crates/client/tests/integration_test.rs +++ b/crates/client/tests/integration_test.rs @@ -410,50 +410,6 @@ fn integration_test() { runtime.block_on(subscription_test()); println!("Ran subscription tests"); - - runtime.block_on(transaction_tests()); - println!("Ran transaction tests"); -} - -async fn transaction_tests() { - let client = connect().await; - let api = client.records("simple_strict_table"); - let now = now(); - let ids; - - { - let mut batch = client.transaction(); - let message = format!("rust transaction create test: {now}"); - batch - .api("simple_strict_table") - .create(json!({"text_not_null": message})); - ids = batch.send().await.unwrap(); - assert_eq!(ids.len(), 1); - - let record: SimpleStrict = api.read(&ids[0]).await.unwrap(); - assert_eq!(record.text_not_null, message); - } - - { - let mut batch = client.transaction(); - let message = format!("rust transaction update test: {now}"); - batch - .api("simple_strict_table") - .update(&ids[0], json!({"text_not_null": message})); - batch.send().await.unwrap(); - - let record: SimpleStrict = api.read(&ids[0]).await.unwrap(); - assert_eq!(record.text_not_null, message); - } - - { - let mut batch = client.transaction(); - batch.api("simple_strict_table").delete(&ids[0]); - batch.send().await.unwrap(); - - let response = api.read::(&ids[0]).await; - assert!(response.is_err()); - } } fn now() -> u64 { From fd6428a9e3fc296ccfa99f00813fc7c45732c85f Mon Sep 17 00:00:00 2001 From: Bilux Date: Tue, 2 Sep 2025 03:18:03 +0000 Subject: [PATCH 5/8] wip: add transaction support to `js` client Signed-off-by: Bilux --- .pre-commit-config.yaml | 34 +- client/testfixture/config.textproto | 1 + crates/assets/js/client/src/index.ts | 291 +++++++++++++----- .../integration/client_integration.test.ts | 232 +++++++++----- crates/core/src/records/transaction.rs | 52 +++- 5 files changed, 411 insertions(+), 199 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5dd5b77e0..2e0831271 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -142,6 +142,23 @@ repos: types: [python] pass_filenames: false + ### Go client + - id: go_format + name: Go format + # gofmt always returns zero exit code :sigh: + entry: sh -c 'DIFF=$(gofmt -d -e client/go/trailbase/) && echo "${DIFF}" && test -z "${DIFF}"' + language: system + types: [go] + files: .*\.(go)$ + pass_filenames: false + + - id: go_test + name: Go test + entry: sh -c 'cd client/go/trailbase && go test -v' + language: system + types: [go] + pass_filenames: false + ### Swift client - id: swift_format name: Swift format @@ -160,20 +177,3 @@ repos: language: system types: [swift] pass_filenames: false - - ### Go client - - id: go_format - name: Go format - # gofmt always returns zero exit code :sigh: - entry: sh -c 'DIFF=$(gofmt -d -e client/go/trailbase/) && echo "${DIFF}" && test -z "${DIFF}"' - language: system - types: [go] - files: .*\.(go)$ - pass_filenames: false - - - id: go_test - name: Go test - entry: sh -c 'cd client/go/trailbase && go test -v' - language: system - types: [go] - pass_filenames: false diff --git a/client/testfixture/config.textproto b/client/testfixture/config.textproto index 9a99426e2..d59201374 100644 --- a/client/testfixture/config.textproto +++ b/client/testfixture/config.textproto @@ -3,6 +3,7 @@ email {} server { application_name: "TrailBase" logs_retention_sec: 604800 + enable_record_transactions: true } auth { oauth_providers: [{ diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index 77fdc4b98..cf198e3f1 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -186,54 +186,146 @@ export type Or = { export type FilterOrComposite = Filter | And | Or; -export interface RecordApi> { - list(opts?: { - pagination?: Pagination; - order?: string[]; - filters?: FilterOrComposite[]; - count?: boolean; - expand?: string[]; - }): Promise>; - - read( - id: string | number, - opt?: { - expand?: string[]; - }, - ): Promise; +export interface Operation { + Create?: { + api_name: string; + value: Record; + }; + Update?: { + api_name: string; + record_id: string | number; + value: Record; + }; + Delete?: { + api_name: string; + record_id: string | number; + }; +} - create(record: T): Promise; - createBulk(records: T[]): Promise<(string | number)[]>; +export interface DeferredOperation { + query(): Promise; +} - update(id: string | number, record: Partial): Promise; +export interface DeferredMutation + extends DeferredOperation { + toJSON(): Operation; +} - delete(id: string | number): Promise; +export class CreateOperation> + implements DeferredMutation +{ + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly record: Partial, + ) {} + async query(): Promise { + const response = await this.client.fetch( + `${recordApiBasePath}/${this.apiName}`, + { + method: "POST", + body: JSON.stringify(this.record), + headers: jsonContentTypeHeader, + }, + ); - subscribe(id: string | number): Promise>; + return (await response.json()).ids[0]; + } + toJSON(): Operation { + return { + Create: { + api_name: this.apiName, + value: this.record, + }, + }; + } } -/// Provides CRUD access to records through TrailBase's record API. -export class RecordApiImpl> - implements RecordApi +export class UpdateOperation> + implements DeferredMutation { - private readonly _path: string; + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly id: string | number, + private readonly record: Partial, + ) {} + async query(): Promise { + await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, { + method: "PATCH", + body: JSON.stringify(this.record), + headers: jsonContentTypeHeader, + }); + } + toJSON(): Operation { + return { + Update: { + api_name: this.apiName, + record_id: this.id, + value: this.record, + }, + }; + } +} +export class DeleteOperation implements DeferredMutation { constructor( private readonly client: Client, - private readonly name: string, - ) { - this._path = `${recordApiBasePath}/${this.name}`; + private readonly apiName: string, + private readonly id: string | number, + ) {} + async query(): Promise { + await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, { + method: "DELETE", + }); } + toJSON(): Operation { + return { + Delete: { + api_name: this.apiName, + record_id: this.id, + }, + }; + } +} - public async list>(opts?: { - pagination?: Pagination; - order?: string[]; - filters?: FilterOrComposite[]; - count?: boolean; - expand?: string[]; - }): Promise> { +export class ReadOperation> + implements DeferredOperation +{ + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly id: string | number, + private readonly opt?: { expand?: string[] }, + ) {} + async query(): Promise { + const expand = this.opt?.expand; + const response = await this.client.fetch( + expand + ? `${recordApiBasePath}/${this.apiName}/${this.id}?expand=${expand.join(",")}` + : `${recordApiBasePath}/${this.apiName}/${this.id}`, + ); + return (await response.json()) as T; + } +} + +export class ListOperation> + implements DeferredOperation> +{ + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }, + ) {} + async query(): Promise> { const params = new URLSearchParams(); - const pagination = opts?.pagination; + const pagination = this.opts?.pagination; if (pagination) { const cursor = pagination.cursor; if (cursor) params.append("cursor", cursor); @@ -244,12 +336,12 @@ export class RecordApiImpl> const offset = pagination.offset; if (offset) params.append("offset", offset.toString()); } - const order = opts?.order; + const order = this.opts?.order; if (order) params.append("order", order.join(",")); - if (opts?.count) params.append("count", "true"); + if (this.opts?.count) params.append("count", "true"); - const expand = opts?.expand; + const expand = this.opts?.expand; if (expand) params.append("expand", expand.join(",")); function traverseFilters(path: string, filter: FilterOrComposite) { @@ -275,71 +367,87 @@ export class RecordApiImpl> } } - const filters = opts?.filters; + const filters = this.opts?.filters; if (filters) { for (const filter of filters) { traverseFilters("filter", filter); } } - const response = await this.client.fetch(`${this._path}?${params}`); + const response = await this.client.fetch( + `${recordApiBasePath}/${this.apiName}?${params}`, + ); return (await response.json()) as ListResponse; } +} - public async read>( +export interface RecordApi> { + list(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): ListOperation; + + read( id: string | number, opt?: { expand?: string[]; }, - ): Promise { - const expand = opt?.expand; - const response = await this.client.fetch( - expand - ? `${this._path}/${id}?expand=${expand.join(",")}` - : `${this._path}/${id}`, - ); - return (await response.json()) as T; - } + ): ReadOperation; - public async create>( - record: T, - ): Promise { - const response = await this.client.fetch(this._path, { - method: "POST", - body: JSON.stringify(record), - headers: jsonContentTypeHeader, - }); + create(record: T): CreateOperation; - return (await response.json()).ids[0]; - } + update(id: string | number, record: Partial): UpdateOperation; - public async createBulk>( - records: T[], - ): Promise<(string | number)[]> { - const response = await this.client.fetch(this._path, { - method: "POST", - body: JSON.stringify(records), - headers: jsonContentTypeHeader, - }); + delete(id: string | number): DeleteOperation; - return (await response.json()).ids; + subscribe(id: string | number): Promise>; +} + +/// Provides CRUD access to records through TrailBase's record API. +export class RecordApiImpl> + implements RecordApi +{ + private readonly _path: string; + + constructor( + private readonly client: Client, + private readonly name: string, + ) { + this._path = `${recordApiBasePath}/${this.name}`; } - public async update>( + public list(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): ListOperation { + return new ListOperation(this.client, this.name, opts); + } + + public read( id: string | number, - record: Partial, - ): Promise { - await this.client.fetch(`${this._path}/${id}`, { - method: "PATCH", - body: JSON.stringify(record), - headers: jsonContentTypeHeader, - }); + opt?: { + expand?: string[]; + }, + ): ReadOperation { + return new ReadOperation(this.client, this.name, id, opt); } - public async delete(id: string | number): Promise { - await this.client.fetch(`${this._path}/${id}`, { - method: "DELETE", - }); + public create(record: T): CreateOperation { + return new CreateOperation(this.client, this.name, record); + } + + public update(id: string | number, record: Partial): UpdateOperation { + return new UpdateOperation(this.client, this.name, id, record); + } + + public delete(id: string | number): DeleteOperation { + return new DeleteOperation(this.client, this.name, id); } public async subscribe(id: string | number): Promise> { @@ -428,6 +536,12 @@ export interface Client { /// /// Unlike native fetch, will throw in case !response.ok. fetch(path: string, init?: FetchOptions): Promise; + + /// Excute a batch query. + execute( + operations: (CreateOperation | UpdateOperation | DeleteOperation)[], + transaction?: boolean, + ): Promise<(string | number)[]>; } /// Client for interacting with TrailBase auth and record APIs. @@ -485,6 +599,20 @@ class ClientImpl implements Client { return new RecordApiImpl(this, name); } + /// Excute a batch query. + async execute( + operations: (CreateOperation | UpdateOperation | DeleteOperation)[], + transaction: boolean = true, + ): Promise<(string | number)[]> { + const response = await this.fetch(transactionApiBasePath, { + method: "POST", + body: JSON.stringify({ operations, transaction }), + headers: jsonContentTypeHeader, + }); + + return (await response.json()).ids; + } + public avatarUrl(userId?: string): string | undefined { const id = userId ?? this.user()?.id; if (id) { @@ -681,6 +809,7 @@ export async function initClientFromCookies( const recordApiBasePath = "/api/records/v1"; const authApiBasePath = "/api/auth/v1"; +const transactionApiBasePath = "/api/transaction/v1/execute"; export function filePath( apiName: string, diff --git a/crates/assets/js/client/tests/integration/client_integration.test.ts b/crates/assets/js/client/tests/integration/client_integration.test.ts index 7f4974934..af644d47f 100644 --- a/crates/assets/js/client/tests/integration/client_integration.test.ts +++ b/crates/assets/js/client/tests/integration/client_integration.test.ts @@ -75,26 +75,31 @@ test("Record integration tests", async () => { const ids: string[] = []; for (const msg of messages) { - ids.push((await api.create({ text_not_null: msg })) as string); + ids.push((await api.create({ text_not_null: msg }).query()) as string); } { - const bulkIds = await api.createBulk([ - { text_not_null: "ts bulk create 0" }, - { text_not_null: "ts bulk create 1" }, - ]); + const bulkIds = await client.execute( + [ + api.create({ text_not_null: "ts bulk create 0" }), + api.create({ text_not_null: "ts bulk create 1" }), + ], + false, + ); expect(bulkIds.length).toBe(2); } { - const response = await api.list({ - filters: [ - { - column: "text_not_null", - value: messages[0], - }, - ], - }); + const response = await api + .list({ + filters: [ + { + column: "text_not_null", + value: messages[0], + }, + ], + }) + .query(); expect(response.total_count).toBeUndefined(); expect(response.cursor).not.undefined.and.not.toBe(""); const records = response.records; @@ -103,17 +108,19 @@ test("Record integration tests", async () => { } { - const response = await api.list({ - filters: [ - { - column: "text_not_null", - op: "like", - value: `% =?&${now}`, - }, - ], - order: ["+text_not_null"], - count: true, - }); + const response = await api + .list({ + filters: [ + { + column: "text_not_null", + op: "like", + value: `% =?&${now}`, + }, + ], + order: ["+text_not_null"], + count: true, + }) + .query(); expect(response.total_count).toBe(2); expect(response.records.map((el) => el.text_not_null)).toStrictEqual( messages, @@ -121,36 +128,40 @@ test("Record integration tests", async () => { } { - const response = await api.list({ - filters: [ - { - column: "text_not_null", - op: "like", - value: `%${now}`, - }, - ], - order: ["-text_not_null"], - }); + const response = await api + .list({ + filters: [ + { + column: "text_not_null", + op: "like", + value: `%${now}`, + }, + ], + order: ["-text_not_null"], + }) + .query(); expect( response.records.map((el) => el.text_not_null).reverse(), ).toStrictEqual(messages); } - const record: SimpleStrict = await api.read(ids[0]); + const record = await api.read(ids[0]).query(); expect(record.id).toStrictEqual(ids[0]); expect(record.text_not_null).toStrictEqual(messages[0]); // Test 1:1 view-bases record API. const view_record: SimpleCompleteView = await client - .records("simple_complete_view") - .read(ids[0]); + .records("simple_complete_view") + .read(ids[0]) + .query(); expect(view_record.id).toStrictEqual(ids[0]); expect(view_record.text_not_null).toStrictEqual(messages[0]); // Test view-based record API with column renames. const subset_view_record: SimpleSubsetView = await client - .records("simple_subset_view") - .read(ids[0]); + .records("simple_subset_view") + .read(ids[0]) + .query(); expect(subset_view_record.id).toStrictEqual(ids[0]); expect(subset_view_record.t_not_null).toStrictEqual(messages[0]); @@ -159,8 +170,8 @@ test("Record integration tests", async () => { text_default: "updated default", text_null: "updated null", }; - await api.update(ids[1], updated_value); - const updated_record: SimpleStrict = await api.read(ids[1]); + await api.update(ids[1], updated_value).query(); + const updated_record = await api.read(ids[1]).query(); expect(updated_record).toEqual( expect.objectContaining({ id: ids[1], @@ -168,14 +179,12 @@ test("Record integration tests", async () => { }), ); - await api.delete(ids[1]); + await api.delete(ids[1]).query(); expect(await client.logout()).toBe(true); expect(client.user()).toBe(undefined); - await expect( - async () => await api.read(ids[0]), - ).rejects.toThrowError( + await expect(async () => await api.read(ids[0]).query()).rejects.toThrowError( expect.objectContaining({ status: status.FORBIDDEN, }), @@ -206,10 +215,10 @@ type Comment = { test("expand foreign records", async () => { const client = await connect(); - const api = client.records("comment"); + const api = client.records("comment"); { - const comment = await api.read(1); + const comment = await api.read(1).query(); expect(comment.id).toBe(1); expect(comment.body).toBe("first comment"); expect(comment.author.data).toBeUndefined(); @@ -217,7 +226,7 @@ test("expand foreign records", async () => { } { - const comment = await api.read(1, { expand: ["post"] }); + const comment = await api.read(1, { expand: ["post"] }).query(); expect(comment.id).toBe(1); expect(comment.body).toBe("first comment"); expect(comment.author.data).toBeUndefined(); @@ -225,13 +234,15 @@ test("expand foreign records", async () => { } { - const response = await api.list({ - expand: ["author", "post"], - order: ["-id"], - pagination: { - limit: 1, - }, - }); + const response = await api + .list({ + expand: ["author", "post"], + order: ["-id"], + pagination: { + limit: 1, + }, + }) + .query(); expect(response.records.length).toBe(1); const comment = response.records[0]; @@ -243,25 +254,29 @@ test("expand foreign records", async () => { } { - const response = await api.list({ - expand: ["author", "post"], - order: ["-id"], - pagination: { - limit: 2, - }, - }); + const response = await api + .list({ + expand: ["author", "post"], + order: ["-id"], + pagination: { + limit: 2, + }, + }) + .query(); expect(response.records.length).toBe(2); const second = response.records[1]; - const offsetResponse = await api.list({ - expand: ["author", "post"], - order: ["-id"], - pagination: { - limit: 1, - offset: 1, - }, - }); + const offsetResponse = await api + .list({ + expand: ["author", "post"], + order: ["-id"], + pagination: { + limit: 1, + offset: 1, + }, + }) + .query(); expect(offsetResponse.records.length).toBe(1); const offsetFirst = offsetResponse.records[0]; @@ -278,7 +293,7 @@ test("record error tests", async () => { ); const nonExistantApi = client.records("non-existant"); await expect( - async () => await nonExistantApi.read(nonExistantId), + async () => await nonExistantApi.read(nonExistantId).query(), ).rejects.toThrowError( expect.objectContaining({ status: status.METHOD_NOT_ALLOWED, @@ -287,14 +302,14 @@ test("record error tests", async () => { const api = client.records("simple_strict_table"); await expect( - async () => await api.read("invalid id"), + async () => await api.read("invalid id").query(), ).rejects.toThrowError( expect.objectContaining({ status: status.BAD_REQUEST, }), ); await expect( - async () => await api.read(nonExistantId), + async () => await api.read(nonExistantId).query(), ).rejects.toThrowError( expect.objectContaining({ status: status.NOT_FOUND, @@ -304,13 +319,15 @@ test("record error tests", async () => { test("realtime subscribe specific record tests", async () => { const client = await connect(); - const api = client.records("simple_strict_table"); + const api = client.records("simple_strict_table"); const now = new Date().getTime(); const createMessage = `ts client realtime test 0: =?&${now}`; - const id = (await api.create({ - text_not_null: createMessage, - })) as string; + const id = (await api + .create({ + text_not_null: createMessage, + }) + .query()) as string; const eventStream = await api.subscribe(id); @@ -318,8 +335,8 @@ test("realtime subscribe specific record tests", async () => { const updatedValue: Partial = { text_not_null: updatedMessage, }; - await api.update(id, updatedValue); - await api.delete(id); + await api.update(id, updatedValue).query(); + await api.delete(id).query(); const events: Event[] = []; for await (const event of eventStream) { @@ -331,23 +348,68 @@ test("realtime subscribe specific record tests", async () => { expect(events[1]["Delete"]["text_not_null"]).equals(updatedMessage); }); +test("transaction tests", async () => { + const client = await connect(); + const api = client.records("simple_strict_table"); + const now = new Date().getTime(); + + // Test transaction with create operation + { + const record = { text_not_null: `ts transaction create test: =?&${now}` }; + const ids = await client.execute([api.create(record)]); + + expect(ids).toHaveLength(1); + + // Verify record was created + const createdRecord = await api.read(ids[0]).query(); + expect(createdRecord.text_not_null).toBe(record.text_not_null); + } + + // Test transaction with update operation + { + const record = { + text_not_null: `ts transaction update test original: =?&${now}`, + }; + const id = await api.create(record).query(); + const updatedRecord = { + text_not_null: `ts transaction update test modified: =?&${now}`, + }; + await client.execute([api.update(id, updatedRecord)]); + + const readRecord = await api.read(id).query(); + expect(readRecord.text_not_null).toBe(updatedRecord.text_not_null); + } + + // Test transaction with delete operation + { + const record = { text_not_null: `ts transaction delete test: =?&${now}` }; + const id = await api.create(record).query(); + + await client.execute([api.delete(id)]); + + await expect(api.read(id).query()).rejects.toThrow(); + } +}); + test("realtime subscribe table tests", async () => { const client = await connect(); - const api = client.records("simple_strict_table"); + const api = client.records("simple_strict_table"); const eventStream = await api.subscribe("*"); const now = new Date().getTime(); const createMessage = `ts client realtime test 0: =?&${now}`; - const id = (await api.create({ - text_not_null: createMessage, - })) as string; + const id = (await api + .create({ + text_not_null: createMessage, + }) + .query()) as string; const updatedMessage = `ts client updated realtime test 0: ${now}`; const updatedValue: Partial = { text_not_null: updatedMessage, }; - await api.update(id, updatedValue); - await api.delete(id); + await api.update(id, updatedValue).query(); + await api.delete(id).query(); const events: Event[] = []; for await (const event of eventStream) { diff --git a/crates/core/src/records/transaction.rs b/crates/core/src/records/transaction.rs index 332083993..835051e38 100644 --- a/crates/core/src/records/transaction.rs +++ b/crates/core/src/records/transaction.rs @@ -31,6 +31,7 @@ pub enum Operation { #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct TransactionRequest { operations: Vec, + transaction: Option, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] @@ -187,25 +188,42 @@ pub async fn record_transactions_handler( }) .collect::, _>>()?; - let ids = state - .conn() - .call( - move |conn: &mut rusqlite::Connection| -> Result, trailbase_sqlite::Error> { - let tx = conn.transaction()?; - - let mut ids: Vec = vec![]; - for op in operations { - if let Some(id) = op(&tx).map_err(|err| trailbase_sqlite::Error::Other(err.into()))? { - ids.push(id); + let ids = if request.transaction.unwrap_or(true) { + state + .conn() + .call( + move |conn: &mut rusqlite::Connection| -> Result, trailbase_sqlite::Error> { + let tx = conn.transaction()?; + + let mut ids: Vec = vec![]; + for op in operations { + if let Some(id) = op(&tx).map_err(|err| trailbase_sqlite::Error::Other(err.into()))? { + ids.push(id); + } } - } - tx.commit()?; + tx.commit()?; - return Ok(ids); - }, - ) - .await?; + return Ok(ids); + }, + ) + .await? + } else { + state + .conn() + .call( + move |conn: &mut rusqlite::Connection| -> Result, trailbase_sqlite::Error> { + let mut ids: Vec = vec![]; + for op in operations { + if let Some(id) = op(conn).map_err(|err| trailbase_sqlite::Error::Other(err.into()))? { + ids.push(id); + } + } + return Ok(ids); + }, + ) + .await? + }; return Ok(Json(TransactionResponse { ids })); } @@ -304,6 +322,7 @@ mod tests { value: json!({"value": 2}), }, ], + transaction: None, }), ) .await @@ -325,6 +344,7 @@ mod tests { value: json!({"value": 3}), }, ], + transaction: None, }), ) .await From 48e15d250a035f6886d51cf5e64c727ad7ac962a Mon Sep 17 00:00:00 2001 From: Bilux Date: Thu, 4 Sep 2025 16:02:06 +0100 Subject: [PATCH 6/8] wip: update `js` client api --- crates/assets/js/client/src/index.ts | 110 +++++++--- .../integration/client_integration.test.ts | 190 ++++++++---------- 2 files changed, 170 insertions(+), 130 deletions(-) diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index cf198e3f1..f935e0518 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -186,22 +186,30 @@ export type Or = { export type FilterOrComposite = Filter | And | Or; -export interface Operation { - Create?: { +export interface CreateOp { + Create: { api_name: string; value: Record; }; - Update?: { +} + +export interface UpdateOp { + Update: { api_name: string; record_id: string | number; value: Record; }; - Delete?: { +} + +export interface DeleteOp { + Delete: { api_name: string; record_id: string | number; }; } +export type Operation = CreateOp | UpdateOp | DeleteOp; + export interface DeferredOperation { query(): Promise; } @@ -231,7 +239,7 @@ export class CreateOperation> return (await response.json()).ids[0]; } - toJSON(): Operation { + toJSON(): CreateOp { return { Create: { api_name: this.apiName, @@ -257,7 +265,7 @@ export class UpdateOperation> headers: jsonContentTypeHeader, }); } - toJSON(): Operation { + toJSON(): UpdateOp { return { Update: { api_name: this.apiName, @@ -279,7 +287,7 @@ export class DeleteOperation implements DeferredMutation { method: "DELETE", }); } - toJSON(): Operation { + toJSON(): DeleteOp { return { Delete: { api_name: this.apiName, @@ -382,28 +390,51 @@ export class ListOperation> } export interface RecordApi> { + // Immediate operations list(opts?: { pagination?: Pagination; order?: string[]; filters?: FilterOrComposite[]; count?: boolean; expand?: string[]; - }): ListOperation; + }): Promise>; read( id: string | number, opt?: { expand?: string[]; }, - ): ReadOperation; + ): Promise; - create(record: T): CreateOperation; + create(record: T): Promise; - update(id: string | number, record: Partial): UpdateOperation; + update(id: string | number, record: Partial): Promise; - delete(id: string | number): DeleteOperation; + delete(id: string | number): Promise; subscribe(id: string | number): Promise>; + + // Deferred operations + listOp(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): ListOperation; + + readOp( + id: string | number, + opt?: { + expand?: string[]; + }, + ): ReadOperation; + + createOp(record: T): CreateOperation; + + updateOp(id: string | number, record: Partial): UpdateOperation; + + deleteOp(id: string | number): DeleteOperation; } /// Provides CRUD access to records through TrailBase's record API. @@ -419,35 +450,35 @@ export class RecordApiImpl> this._path = `${recordApiBasePath}/${this.name}`; } - public list(opts?: { + public async list(opts?: { pagination?: Pagination; order?: string[]; filters?: FilterOrComposite[]; count?: boolean; expand?: string[]; - }): ListOperation { - return new ListOperation(this.client, this.name, opts); + }): Promise> { + return new ListOperation(this.client, this.name, opts).query(); } - public read( + public async read>( id: string | number, opt?: { expand?: string[]; }, - ): ReadOperation { - return new ReadOperation(this.client, this.name, id, opt); + ): Promise { + return new ReadOperation(this.client, this.name, id, opt).query(); } - public create(record: T): CreateOperation { - return new CreateOperation(this.client, this.name, record); + public async create(record: T): Promise { + return new CreateOperation(this.client, this.name, record).query(); } - public update(id: string | number, record: Partial): UpdateOperation { - return new UpdateOperation(this.client, this.name, id, record); + public async update(id: string | number, record: Partial): Promise { + return new UpdateOperation(this.client, this.name, id, record).query(); } - public delete(id: string | number): DeleteOperation { - return new DeleteOperation(this.client, this.name, id); + public async delete(id: string | number): Promise { + return new DeleteOperation(this.client, this.name, id).query(); } public async subscribe(id: string | number): Promise> { @@ -474,6 +505,37 @@ export class RecordApiImpl> return body.pipeThrough(transformStream); } + + public listOp(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): ListOperation { + return new ListOperation(this.client, this.name, opts); + } + + public readOp( + id: string | number, + opt?: { + expand?: string[]; + }, + ): ReadOperation { + return new ReadOperation(this.client, this.name, id, opt); + } + + public createOp(record: T): CreateOperation { + return new CreateOperation(this.client, this.name, record); + } + + public updateOp(id: string | number, record: Partial): UpdateOperation { + return new UpdateOperation(this.client, this.name, id, record); + } + + public deleteOp(id: string | number): DeleteOperation { + return new DeleteOperation(this.client, this.name, id); + } } class ThinClient { diff --git a/crates/assets/js/client/tests/integration/client_integration.test.ts b/crates/assets/js/client/tests/integration/client_integration.test.ts index af644d47f..6baa8b321 100644 --- a/crates/assets/js/client/tests/integration/client_integration.test.ts +++ b/crates/assets/js/client/tests/integration/client_integration.test.ts @@ -75,14 +75,14 @@ test("Record integration tests", async () => { const ids: string[] = []; for (const msg of messages) { - ids.push((await api.create({ text_not_null: msg }).query()) as string); + ids.push((await api.create({ text_not_null: msg })) as string); } { const bulkIds = await client.execute( [ - api.create({ text_not_null: "ts bulk create 0" }), - api.create({ text_not_null: "ts bulk create 1" }), + api.createOp({ text_not_null: "ts bulk create 0" }), + api.createOp({ text_not_null: "ts bulk create 1" }), ], false, ); @@ -90,16 +90,14 @@ test("Record integration tests", async () => { } { - const response = await api - .list({ - filters: [ - { - column: "text_not_null", - value: messages[0], - }, - ], - }) - .query(); + const response = await api.list({ + filters: [ + { + column: "text_not_null", + value: messages[0], + }, + ], + }); expect(response.total_count).toBeUndefined(); expect(response.cursor).not.undefined.and.not.toBe(""); const records = response.records; @@ -108,19 +106,17 @@ test("Record integration tests", async () => { } { - const response = await api - .list({ - filters: [ - { - column: "text_not_null", - op: "like", - value: `% =?&${now}`, - }, - ], - order: ["+text_not_null"], - count: true, - }) - .query(); + const response = await api.list({ + filters: [ + { + column: "text_not_null", + op: "like", + value: `% =?&${now}`, + }, + ], + order: ["+text_not_null"], + count: true, + }); expect(response.total_count).toBe(2); expect(response.records.map((el) => el.text_not_null)).toStrictEqual( messages, @@ -128,40 +124,36 @@ test("Record integration tests", async () => { } { - const response = await api - .list({ - filters: [ - { - column: "text_not_null", - op: "like", - value: `%${now}`, - }, - ], - order: ["-text_not_null"], - }) - .query(); + const response = await api.list({ + filters: [ + { + column: "text_not_null", + op: "like", + value: `%${now}`, + }, + ], + order: ["-text_not_null"], + }); expect( response.records.map((el) => el.text_not_null).reverse(), ).toStrictEqual(messages); } - const record = await api.read(ids[0]).query(); + const record = await api.read(ids[0]); expect(record.id).toStrictEqual(ids[0]); expect(record.text_not_null).toStrictEqual(messages[0]); // Test 1:1 view-bases record API. const view_record: SimpleCompleteView = await client .records("simple_complete_view") - .read(ids[0]) - .query(); + .read(ids[0]); expect(view_record.id).toStrictEqual(ids[0]); expect(view_record.text_not_null).toStrictEqual(messages[0]); // Test view-based record API with column renames. const subset_view_record: SimpleSubsetView = await client .records("simple_subset_view") - .read(ids[0]) - .query(); + .read(ids[0]); expect(subset_view_record.id).toStrictEqual(ids[0]); expect(subset_view_record.t_not_null).toStrictEqual(messages[0]); @@ -170,8 +162,8 @@ test("Record integration tests", async () => { text_default: "updated default", text_null: "updated null", }; - await api.update(ids[1], updated_value).query(); - const updated_record = await api.read(ids[1]).query(); + await api.update(ids[1], updated_value); + const updated_record = await api.read(ids[1]); expect(updated_record).toEqual( expect.objectContaining({ id: ids[1], @@ -179,12 +171,12 @@ test("Record integration tests", async () => { }), ); - await api.delete(ids[1]).query(); + await api.delete(ids[1]); expect(await client.logout()).toBe(true); expect(client.user()).toBe(undefined); - await expect(async () => await api.read(ids[0]).query()).rejects.toThrowError( + await expect(async () => await api.read(ids[0])).rejects.toThrowError( expect.objectContaining({ status: status.FORBIDDEN, }), @@ -218,7 +210,7 @@ test("expand foreign records", async () => { const api = client.records("comment"); { - const comment = await api.read(1).query(); + const comment = await api.read(1); expect(comment.id).toBe(1); expect(comment.body).toBe("first comment"); expect(comment.author.data).toBeUndefined(); @@ -226,7 +218,7 @@ test("expand foreign records", async () => { } { - const comment = await api.read(1, { expand: ["post"] }).query(); + const comment = await api.read(1, { expand: ["post"] }); expect(comment.id).toBe(1); expect(comment.body).toBe("first comment"); expect(comment.author.data).toBeUndefined(); @@ -234,15 +226,13 @@ test("expand foreign records", async () => { } { - const response = await api - .list({ - expand: ["author", "post"], - order: ["-id"], - pagination: { - limit: 1, - }, - }) - .query(); + const response = await api.list({ + expand: ["author", "post"], + order: ["-id"], + pagination: { + limit: 1, + }, + }); expect(response.records.length).toBe(1); const comment = response.records[0]; @@ -254,29 +244,25 @@ test("expand foreign records", async () => { } { - const response = await api - .list({ - expand: ["author", "post"], - order: ["-id"], - pagination: { - limit: 2, - }, - }) - .query(); + const response = await api.list({ + expand: ["author", "post"], + order: ["-id"], + pagination: { + limit: 2, + }, + }); expect(response.records.length).toBe(2); const second = response.records[1]; - const offsetResponse = await api - .list({ - expand: ["author", "post"], - order: ["-id"], - pagination: { - limit: 1, - offset: 1, - }, - }) - .query(); + const offsetResponse = await api.list({ + expand: ["author", "post"], + order: ["-id"], + pagination: { + limit: 1, + offset: 1, + }, + }); expect(offsetResponse.records.length).toBe(1); const offsetFirst = offsetResponse.records[0]; @@ -293,7 +279,7 @@ test("record error tests", async () => { ); const nonExistantApi = client.records("non-existant"); await expect( - async () => await nonExistantApi.read(nonExistantId).query(), + async () => await nonExistantApi.read(nonExistantId), ).rejects.toThrowError( expect.objectContaining({ status: status.METHOD_NOT_ALLOWED, @@ -301,16 +287,12 @@ test("record error tests", async () => { ); const api = client.records("simple_strict_table"); - await expect( - async () => await api.read("invalid id").query(), - ).rejects.toThrowError( + await expect(async () => await api.read("invalid id")).rejects.toThrowError( expect.objectContaining({ status: status.BAD_REQUEST, }), ); - await expect( - async () => await api.read(nonExistantId).query(), - ).rejects.toThrowError( + await expect(async () => await api.read(nonExistantId)).rejects.toThrowError( expect.objectContaining({ status: status.NOT_FOUND, }), @@ -323,11 +305,9 @@ test("realtime subscribe specific record tests", async () => { const now = new Date().getTime(); const createMessage = `ts client realtime test 0: =?&${now}`; - const id = (await api - .create({ - text_not_null: createMessage, - }) - .query()) as string; + const id = (await api.create({ + text_not_null: createMessage, + })) as string; const eventStream = await api.subscribe(id); @@ -335,8 +315,8 @@ test("realtime subscribe specific record tests", async () => { const updatedValue: Partial = { text_not_null: updatedMessage, }; - await api.update(id, updatedValue).query(); - await api.delete(id).query(); + await api.update(id, updatedValue); + await api.delete(id); const events: Event[] = []; for await (const event of eventStream) { @@ -356,12 +336,12 @@ test("transaction tests", async () => { // Test transaction with create operation { const record = { text_not_null: `ts transaction create test: =?&${now}` }; - const ids = await client.execute([api.create(record)]); + const ids = await client.execute([api.createOp(record)]); expect(ids).toHaveLength(1); // Verify record was created - const createdRecord = await api.read(ids[0]).query(); + const createdRecord = await api.read(ids[0]); expect(createdRecord.text_not_null).toBe(record.text_not_null); } @@ -370,24 +350,24 @@ test("transaction tests", async () => { const record = { text_not_null: `ts transaction update test original: =?&${now}`, }; - const id = await api.create(record).query(); + const id = await api.create(record); const updatedRecord = { text_not_null: `ts transaction update test modified: =?&${now}`, }; - await client.execute([api.update(id, updatedRecord)]); + await client.execute([api.updateOp(id, updatedRecord)]); - const readRecord = await api.read(id).query(); + const readRecord = await api.read(id); expect(readRecord.text_not_null).toBe(updatedRecord.text_not_null); } // Test transaction with delete operation { const record = { text_not_null: `ts transaction delete test: =?&${now}` }; - const id = await api.create(record).query(); + const id = await api.create(record); - await client.execute([api.delete(id)]); + await client.execute([api.deleteOp(id)]); - await expect(api.read(id).query()).rejects.toThrow(); + await expect(api.read(id)).rejects.toThrow(); } }); @@ -398,18 +378,16 @@ test("realtime subscribe table tests", async () => { const now = new Date().getTime(); const createMessage = `ts client realtime test 0: =?&${now}`; - const id = (await api - .create({ - text_not_null: createMessage, - }) - .query()) as string; + const id = (await api.create({ + text_not_null: createMessage, + })) as string; const updatedMessage = `ts client updated realtime test 0: ${now}`; const updatedValue: Partial = { text_not_null: updatedMessage, }; - await api.update(id, updatedValue).query(); - await api.delete(id).query(); + await api.update(id, updatedValue); + await api.delete(id); const events: Event[] = []; for await (const event of eventStream) { From fb339b48c7dc63e43f6d775aba259d410be2b258 Mon Sep 17 00:00:00 2001 From: Bilux Date: Fri, 5 Sep 2025 12:19:36 +0100 Subject: [PATCH 7/8] wip: update js client api --- crates/assets/js/client/src/index.ts | 114 +++++++++++++-------------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index f935e0518..f3deb9a1e 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -208,15 +208,13 @@ export interface DeleteOp { }; } -export type Operation = CreateOp | UpdateOp | DeleteOp; - -export interface DeferredOperation { +interface DeferredOperation { query(): Promise; } -export interface DeferredMutation +interface DeferredMutation extends DeferredOperation { - toJSON(): Operation; + toJSON(): CreateOp | UpdateOp | DeleteOp; } export class CreateOperation> @@ -227,7 +225,7 @@ export class CreateOperation> private readonly apiName: string, private readonly record: Partial, ) {} - async query(): Promise { + async query(): Promise { const response = await this.client.fetch( `${recordApiBasePath}/${this.apiName}`, { @@ -390,7 +388,6 @@ export class ListOperation> } export interface RecordApi> { - // Immediate operations list(opts?: { pagination?: Pagination; order?: string[]; @@ -399,22 +396,6 @@ export interface RecordApi> { expand?: string[]; }): Promise>; - read( - id: string | number, - opt?: { - expand?: string[]; - }, - ): Promise; - - create(record: T): Promise; - - update(id: string | number, record: Partial): Promise; - - delete(id: string | number): Promise; - - subscribe(id: string | number): Promise>; - - // Deferred operations listOp(opts?: { pagination?: Pagination; order?: string[]; @@ -423,6 +404,13 @@ export interface RecordApi> { expand?: string[]; }): ListOperation; + read( + id: string | number, + opt?: { + expand?: string[]; + }, + ): Promise; + readOp( id: string | number, opt?: { @@ -430,25 +418,29 @@ export interface RecordApi> { }, ): ReadOperation; + create(record: T): Promise; + createOp(record: T): CreateOperation; + update(id: string | number, record: Partial): Promise; + updateOp(id: string | number, record: Partial): UpdateOperation; + delete(id: string | number): Promise; + deleteOp(id: string | number): DeleteOperation; + + subscribe(id: string | number): Promise>; } /// Provides CRUD access to records through TrailBase's record API. export class RecordApiImpl> implements RecordApi { - private readonly _path: string; - constructor( private readonly client: Client, private readonly name: string, - ) { - this._path = `${recordApiBasePath}/${this.name}`; - } + ) {} public async list(opts?: { pagination?: Pagination; @@ -460,6 +452,16 @@ export class RecordApiImpl> return new ListOperation(this.client, this.name, opts).query(); } + public listOp(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): ListOperation { + return new ListOperation(this.client, this.name, opts); + } + public async read>( id: string | number, opt?: { @@ -469,20 +471,43 @@ export class RecordApiImpl> return new ReadOperation(this.client, this.name, id, opt).query(); } + public readOp( + id: string | number, + opt?: { + expand?: string[]; + }, + ): ReadOperation { + return new ReadOperation(this.client, this.name, id, opt); + } + public async create(record: T): Promise { return new CreateOperation(this.client, this.name, record).query(); } + public createOp(record: T): CreateOperation { + return new CreateOperation(this.client, this.name, record); + } + public async update(id: string | number, record: Partial): Promise { return new UpdateOperation(this.client, this.name, id, record).query(); } + public updateOp(id: string | number, record: Partial): UpdateOperation { + return new UpdateOperation(this.client, this.name, id, record); + } + public async delete(id: string | number): Promise { return new DeleteOperation(this.client, this.name, id).query(); } + public deleteOp(id: string | number): DeleteOperation { + return new DeleteOperation(this.client, this.name, id); + } + public async subscribe(id: string | number): Promise> { - const response = await this.client.fetch(`${this._path}/subscribe/${id}`); + const response = await this.client.fetch( + `${recordApiBasePath}/${this.name}/subscribe/${id}`, + ); const body = response.body; if (!body) { throw Error("Subscription reader is null."); @@ -505,37 +530,6 @@ export class RecordApiImpl> return body.pipeThrough(transformStream); } - - public listOp(opts?: { - pagination?: Pagination; - order?: string[]; - filters?: FilterOrComposite[]; - count?: boolean; - expand?: string[]; - }): ListOperation { - return new ListOperation(this.client, this.name, opts); - } - - public readOp( - id: string | number, - opt?: { - expand?: string[]; - }, - ): ReadOperation { - return new ReadOperation(this.client, this.name, id, opt); - } - - public createOp(record: T): CreateOperation { - return new CreateOperation(this.client, this.name, record); - } - - public updateOp(id: string | number, record: Partial): UpdateOperation { - return new UpdateOperation(this.client, this.name, id, record); - } - - public deleteOp(id: string | number): DeleteOperation { - return new DeleteOperation(this.client, this.name, id); - } } class ThinClient { From 3cd3aad5ed3344f743336eaa4de2006cc9b4891a Mon Sep 17 00:00:00 2001 From: Bilux Date: Sat, 6 Sep 2025 07:57:53 +0100 Subject: [PATCH 8/8] Finale clean up --- .pre-commit-config.yaml | 34 ++++++++++++++-------------- crates/assets/js/client/src/index.ts | 1 + 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e0831271..5dd5b77e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -142,23 +142,6 @@ repos: types: [python] pass_filenames: false - ### Go client - - id: go_format - name: Go format - # gofmt always returns zero exit code :sigh: - entry: sh -c 'DIFF=$(gofmt -d -e client/go/trailbase/) && echo "${DIFF}" && test -z "${DIFF}"' - language: system - types: [go] - files: .*\.(go)$ - pass_filenames: false - - - id: go_test - name: Go test - entry: sh -c 'cd client/go/trailbase && go test -v' - language: system - types: [go] - pass_filenames: false - ### Swift client - id: swift_format name: Swift format @@ -177,3 +160,20 @@ repos: language: system types: [swift] pass_filenames: false + + ### Go client + - id: go_format + name: Go format + # gofmt always returns zero exit code :sigh: + entry: sh -c 'DIFF=$(gofmt -d -e client/go/trailbase/) && echo "${DIFF}" && test -z "${DIFF}"' + language: system + types: [go] + files: .*\.(go)$ + pass_filenames: false + + - id: go_test + name: Go test + entry: sh -c 'cd client/go/trailbase && go test -v' + language: system + types: [go] + pass_filenames: false diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index f3deb9a1e..61b87adcd 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -186,6 +186,7 @@ export type Or = { export type FilterOrComposite = Filter | And | Or; +// TODO: consider generating these types with ts-rs to reduce manual duplication. export interface CreateOp { Create: { api_name: string;