diff --git a/demos/MAUITodo/Data/Config.cs b/demos/MAUITodo/Data/Config.cs new file mode 100644 index 0000000..0fc8547 --- /dev/null +++ b/demos/MAUITodo/Data/Config.cs @@ -0,0 +1,55 @@ +using dotenv.net; + +namespace MAUITodo.Data; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +public class EnvConfig +{ + public string SupabaseUrl { get; set; } + public string SupabaseKey { get; set; } + public string SupabaseStorageUrl { get; set; } + public string PowerSyncUrl { get; set; } + public string BackendUrl { get; set; } + public string SupabaseUsername { get; set; } + public string SupabasePassword { get; set; } + public bool UseSupabase { get; set; } + + public EnvConfig() + { + DotEnv.Load(); + Console.WriteLine($"Current directory: {Directory.GetCurrentDirectory()}"); + + // Parse boolean value first + string useSupabaseStr = Environment.GetEnvironmentVariable("USE_SUPABASE") ?? "false"; + if (!bool.TryParse(useSupabaseStr, out bool useSupabase)) + { + throw new InvalidOperationException("USE_SUPABASE environment variable is not a valid boolean."); + } + UseSupabase = useSupabase; + + if (UseSupabase) + Console.WriteLine("Using Supabase"); + else + Console.WriteLine("Using Node"); + + PowerSyncUrl = GetRequiredEnv("POWERSYNC_URL"); + + if (UseSupabase) + { + SupabaseUrl = GetRequiredEnv("SUPABASE_URL"); + SupabaseKey = GetRequiredEnv("SUPABASE_KEY"); + SupabaseUsername = GetRequiredEnv("SUPABASE_USERNAME"); + SupabasePassword = GetRequiredEnv("SUPABASE_PASSWORD"); + } + else + { + BackendUrl = GetRequiredEnv("BACKEND_URL"); + } + } + + private static string GetRequiredEnv(string key) + { + return Environment.GetEnvironmentVariable(key) + ?? throw new InvalidOperationException($"{key} environment variable is not set."); + } +} diff --git a/demos/MAUITodo/Data/NodeConnector.cs b/demos/MAUITodo/Data/NodeConnector.cs deleted file mode 100644 index 089e28f..0000000 --- a/demos/MAUITodo/Data/NodeConnector.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Text; -using System.Text.Json; - -using PowerSync.Common.Client; -using PowerSync.Common.Client.Connection; -using PowerSync.Common.DB.Crud; - -namespace MAUITodo.Data; - -public class NodeConnector : IPowerSyncBackendConnector -{ - private string StorageFilePath => Path.Combine(FileSystem.AppDataDirectory, "user_id.txt"); - - private readonly HttpClient _httpClient; - - public string BackendUrl { get; } - public string PowerSyncUrl { get; } - public string UserId { get; private set; } - private string? clientId; - - public NodeConnector() - { - _httpClient = new HttpClient(); - - // Load or generate User ID - UserId = LoadOrGenerateUserId(); - - // Android emulator uses 10.0.2.2 to access host-ran processes -#if ANDROID - BackendUrl = "http://10.0.2.2:6060"; - PowerSyncUrl = "http://10.0.2.2:8080"; -#else - BackendUrl = "http://localhost:6060"; - PowerSyncUrl = "http://localhost:8080"; -#endif - - clientId = null; - } - - public string LoadOrGenerateUserId() - { - if (File.Exists(StorageFilePath)) - { - return File.ReadAllText(StorageFilePath); - } - - string newUserId = Guid.NewGuid().ToString(); - File.WriteAllText(StorageFilePath, newUserId); - return newUserId; - } - - public async Task FetchCredentials() - { - string tokenEndpoint = "api/auth/token"; - string url = $"{BackendUrl}/{tokenEndpoint}?user_id={UserId}"; - - HttpResponseMessage response = await _httpClient.GetAsync(url); - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Received {response.StatusCode} from {tokenEndpoint}: {await response.Content.ReadAsStringAsync()}"); - } - - string responseBody = await response.Content.ReadAsStringAsync(); - var jsonResponse = JsonSerializer.Deserialize>(responseBody); - - if (jsonResponse == null || !jsonResponse.ContainsKey("token")) - { - throw new Exception("Invalid response received from authentication endpoint."); - } - - return new PowerSyncCredentials(PowerSyncUrl, jsonResponse["token"]); - } - - public async Task UploadData(IPowerSyncDatabase database) - { - CrudTransaction? transaction; - try - { - transaction = await database.GetNextCrudTransaction(); - } - catch (Exception ex) - { - Console.WriteLine($"UploadData Error: {ex.Message}"); - return; - } - - if (transaction == null) - { - return; - } - - clientId ??= await database.GetClientId(); - - try - { - var batch = new List(); - - foreach (var operation in transaction.Crud) - { - batch.Add(new - { - op = operation.Op.ToString(), - table = operation.Table, - id = operation.Id, - data = operation.OpData - }); - } - - var payload = JsonSerializer.Serialize(new { batch }); - var content = new StringContent(payload, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _httpClient.PostAsync($"{BackendUrl}/api/data", content); - - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Received {response.StatusCode} from /api/data: {await response.Content.ReadAsStringAsync()}"); - } - - await transaction.Complete(); - } - catch (Exception ex) - { - Console.WriteLine($"UploadData Error: {ex.Message}"); - throw; - } - } -} diff --git a/demos/MAUITodo/Data/SupabaseConnector.cs b/demos/MAUITodo/Data/SupabaseConnector.cs new file mode 100644 index 0000000..13fb431 --- /dev/null +++ b/demos/MAUITodo/Data/SupabaseConnector.cs @@ -0,0 +1,178 @@ +namespace MAUITodo.Data; + +using MAUITodo.Models; +using MAUITodo.Config; +using MAUITodo.Helpers; + +using Newtonsoft.Json; + +using PowerSync.Common.Client; +using PowerSync.Common.Client.Connection; +using PowerSync.Common.DB.Crud; + +using Supabase; +using Supabase.Gotrue; +using Supabase.Postgrest.Exceptions; +using Supabase.Postgrest.Interfaces; + +public class SupabaseConnector : IPowerSyncBackendConnector +{ + private readonly Supabase.Client _supabase; + private readonly EnvConfig _envConfig; + private Session? _currentSession; + + public Session? CurrentSession + { + get => _currentSession; + set + { + _currentSession = value; + + if (_currentSession?.User?.Id != null) + { + UserID = _currentSession.User.Id; + } + } + } + + public string UserID { get; private set; } = ""; + + public bool Ready { get; private set; } + + public SupabaseConnector(EnvConfig envConfig) + { + _envConfig = envConfig; + _supabase = new Supabase.Client(envConfig.SupabaseUrl, envConfig.SupabaseKey, new SupabaseOptions + { + AutoConnectRealtime = true + }); + + _ = _supabase.InitializeAsync(); + } + + public async Task Login(string email, string password) + { + var response = await _supabase.Auth.SignInWithPassword(email, password); + if (response?.User == null || response.AccessToken == null) + { + throw new Exception("Login failed."); + } + + CurrentSession = response; + } + + public Task FetchCredentials() + { + PowerSyncCredentials? credentials = null; + + var sessionResponse = _supabase.Auth.CurrentSession; + if (sessionResponse?.AccessToken != null) + { + credentials = new PowerSyncCredentials(_envConfig.PowerSyncUrl, sessionResponse.AccessToken); + } + + return Task.FromResult(credentials); + } + + public async Task UploadData(IPowerSyncDatabase database) + { + var transaction = await database.GetNextCrudTransaction(); + if (transaction == null) return; + + try + { + foreach (var op in transaction.Crud) + { + switch (op.Op) + { + case UpdateType.PUT: + if (op.Table.ToLower().Trim() == "lists") + { + var model = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(op.OpData)) ?? throw new InvalidOperationException("Model is null."); + model.ID = op.Id; + + await _supabase.From().Upsert(model); + } + else if (op.Table.ToLower().Trim() == "todos") + { + var model = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(op.OpData)) ?? throw new InvalidOperationException("Model is null."); + model.ID = op.Id; + + await _supabase.From().Upsert(model); + } + break; + + case UpdateType.PATCH: + if (op.OpData is null || op.OpData.Count == 0) + { + Console.WriteLine("PATCH skipped: No data to update."); + break; + } + + if (op.Table.ToLower().Trim() == "lists") + { + // Create an update query for the 'TodoItem' table where the 'ID' matches 'op.Id' + IPostgrestTable updateQuery = _supabase + .From() + .Where(x => x.ID == op.Id); + + // Loop through each key-value pair in the operation data (op.OpData) to apply updates dynamically + foreach (var kvp in op.OpData) + { + // Apply the "SET" operation for each key-value pair. + // The key represents the JSON property name and the value is the new value to be set + updateQuery = SupabasePatchHelper.ApplySet(updateQuery, kvp.Key, kvp.Value); + } + + _ = await updateQuery.Update(); + } + else if (op.Table.ToLower().Trim() == "todos") + { + // Create an update query for the 'TodoItem' table where the 'ID' matches 'op.Id' + IPostgrestTable updateQuery = _supabase + .From() + .Where(x => x.ID == op.Id); + + // Loop through each key-value pair in the operation data (op.OpData) to apply updates dynamically + foreach (var kvp in op.OpData) + { + // Apply the "SET" operation for each key-value pair. + // The key represents the JSON property name and the value is the new value to be set + updateQuery = SupabasePatchHelper.ApplySet(updateQuery, kvp.Key, kvp.Value); + } + + _ = await updateQuery.Update(); + } + break; + + case UpdateType.DELETE: + if (op.Table.ToLower().Trim() == "lists") + { + await _supabase + .From() + .Where(x => x.ID == op.Id) + .Delete(); + } + else if (op.Table.ToLower().Trim() == "todos") + { + await _supabase + .From() + .Where(x => x.ID == op.Id) + .Delete(); + } + break; + + default: + throw new InvalidOperationException("Unknown operation type."); + } + } + + await transaction.Complete(); + } + catch (PostgrestException ex) + { + Console.WriteLine($"Error during upload: {ex.Message}"); + throw; + } + } +} diff --git a/demos/MAUITodo/Data/SupabaseRemoteStorageAdapter.cs b/demos/MAUITodo/Data/SupabaseRemoteStorageAdapter.cs new file mode 100644 index 0000000..77a84ba --- /dev/null +++ b/demos/MAUITodo/Data/SupabaseRemoteStorageAdapter.cs @@ -0,0 +1,46 @@ +namespace MAUITodo.Data; + +using PowerSync.Common.Attachments; +using Supabase.Storage; + +public class SupabaseRemoteStorageAdapter : IRemoteStorageAdapter +{ + private readonly Supabase.Client _client; + private readonly string _bucketId; + + public SupabaseRemoteStorageAdapter(Supabase.Client client, string bucketId) + { + _client = client; + _bucketId = bucketId; + } + + public async Task UploadFileAsync(Stream stream, Attachment attachment) + { + // Convert Stream into byte[] for Supabase + byte[] bytes; + using (var ms = new MemoryStream()) + { + stream.CopyTo(ms); + bytes = ms.ToArray(); + } + + string mediaType = attachment.MediaType ?? "application/octet-stream"; + await _client.Storage + .From(_bucketId) + .Upload(bytes, attachment.Filename, new FileOptions { ContentType = mediaType }); + } + + public async Task DownloadFileAsync(Attachment attachment) + { + var bytes = await _client.Storage + .From(_bucketId) + .Download(attachment.Filename, null); // Pass null manually to force specific overload to be used + + return new MemoryStream(bytes); + } + + public async Task DeleteFileAsync(Attachment attachment) + { + await _client.Storage.From(_bucketId).Remove(attachment.Filename); + } +} diff --git a/demos/MAUITodo/Helpers/SupabasePatchHelper.cs b/demos/MAUITodo/Helpers/SupabasePatchHelper.cs new file mode 100644 index 0000000..078d233 --- /dev/null +++ b/demos/MAUITodo/Helpers/SupabasePatchHelper.cs @@ -0,0 +1,40 @@ +namespace MAUITodo.Helpers; + +using System.Linq.Expressions; + +using Newtonsoft.Json; + +using Supabase.Postgrest.Interfaces; +using Supabase.Postgrest.Models; + +public static class SupabasePatchHelper +{ + // Applies a "SET" operation to the table, setting the value of a specific property. + public static IPostgrestTable ApplySet( + IPostgrestTable table, // The table to apply the operation to + string jsonPropertyName, // The name of the JSON property to update + object value // The new value to set for the property + ) where T : BaseModel, new() // Ensures T is a subclass of BaseModel with a parameterless constructor + { + // Find the property on the model that matches the JSON property name + var property = typeof(T) + .GetProperties() // Get all properties of the model type + .FirstOrDefault(p => + // Check if the property has a JsonPropertyAttribute + p.GetCustomAttributes(typeof(JsonPropertyAttribute), true) + .FirstOrDefault() is JsonPropertyAttribute attr && + attr.PropertyName == jsonPropertyName); // Check if the JSON property name matches + + if (property == null) + throw new ArgumentException($"'{jsonPropertyName}' is not a valid property on type '{typeof(T).Name}'"); + + // Create an expression to access the specified property on the model + var parameter = Expression.Parameter(typeof(T), "x"); // Define a parameter for the expression + var propertyAccess = Expression.Property(parameter, property.Name); // Access the property + var converted = Expression.Convert(propertyAccess, typeof(object)); // Convert the value to object type + var lambda = Expression.Lambda>(converted, parameter); // Create a lambda expression for the property + + // Apply the "SET" operation to the table using the lambda expression + return table.Set(lambda, value); + } +} diff --git a/demos/MAUITodo/MAUITodo.csproj b/demos/MAUITodo/MAUITodo.csproj index 56d224b..00b25d8 100644 --- a/demos/MAUITodo/MAUITodo.csproj +++ b/demos/MAUITodo/MAUITodo.csproj @@ -63,11 +63,13 @@ + + diff --git a/demos/MAUITodo/Models/TodoItem.cs b/demos/MAUITodo/Models/TodoItem.cs index e13ee8f..606a56d 100644 --- a/demos/MAUITodo/Models/TodoItem.cs +++ b/demos/MAUITodo/Models/TodoItem.cs @@ -1,34 +1,58 @@ namespace MAUITodo.Models; -using PowerSync.Common.DB.Schema.Attributes; +using Newtonsoft.Json; +using Supabase.Postgrest.Models; +using PowerSync = PowerSync.Common.DB.Schema.Attributes; +using Supabase = Supabase.Postgrest.Attributes; + +// TODO: We should probably be able to automatically infer the PowerSync +// model from the Supabase model or vice-versa. [ - Table("todos"), - Index("list", ["list_id"]) + PowerSync.Table("todos"), + PowerSync.Index("list", ["list_id"]), + Supabase.Table("todos") ] -public class TodoItem +public class TodoItem : BaseModel { - [Column("id")] + [PowerSync.Column("id")] + [Supabase.Column("id")] + [Supabase.PrimaryKey("id")] + [JsonProperty("id")] public string ID { get; set; } = ""; - [Column("list_id")] + [PowerSync.Column("list_id")] + [Supabase.Column("list_id")] + [JsonProperty("list_id")] public string ListId { get; set; } = null!; - [Column("created_at")] + [PowerSync.Column("created_at")] + [Supabase.Column("created_at")] + [JsonProperty("created_at")] public string CreatedAt { get; set; } = null!; - [Column("completed_at")] + [PowerSync.Column("completed_at")] + [Supabase.Column("completed_at")] + [JsonProperty("completed_at")] public string? CompletedAt { get; set; } - [Column("description")] + [PowerSync.Column("description")] + [Supabase.Column("description")] + [JsonProperty("description")] public string Description { get; set; } = null!; - [Column("created_by")] + [PowerSync.Column("created_by")] + [Supabase.Column("created_by")] + [JsonProperty("created_by")] public string CreatedBy { get; set; } = null!; - [Column("completed_by")] + [PowerSync.Column("completed_by")] + [Supabase.Column("completed_by")] + [JsonProperty("completed_by")] public string CompletedBy { get; set; } = null!; - [Column("completed")] + [PowerSync.Column("completed")] + [Supabase.Column("completed")] + [JsonProperty("completed")] public bool Completed { get; set; } } diff --git a/demos/MAUITodo/Models/TodoList.cs b/demos/MAUITodo/Models/TodoList.cs index 22c418a..175ad5a 100644 --- a/demos/MAUITodo/Models/TodoList.cs +++ b/demos/MAUITodo/Models/TodoList.cs @@ -1,19 +1,37 @@ namespace MAUITodo.Models; -using PowerSync.Common.DB.Schema.Attributes; +using Newtonsoft.Json; +using Supabase.Postgrest.Models; -[Table("lists")] -public class TodoList +using PowerSync = PowerSync.Common.DB.Schema.Attributes; +using Supabase = Supabase.Postgrest.Attributes; + +// TODO: We should probably be able to automatically infer the PowerSync +// model from the Supabase model or vice-versa. +[ + PowerSync.Table("lists"), + Supabase.Table("lists") +] +public class TodoList : BaseModel { - [Column("id")] + [PowerSync.Column("id")] + [Supabase.Column("id")] + [JsonProperty("id")] + [Supabase.PrimaryKey("id")] public string ID { get; set; } = ""; - [Column("created_at")] + [PowerSync.Column("created_at")] + [Supabase.Column("created_at")] + [JsonProperty("created_at")] public string CreatedAt { get; set; } = null!; - [Column("name")] + [PowerSync.Column("name")] + [Supabase.Column("name")] + [JsonProperty("name")] public string Name { get; set; } = null!; - [Column("owner_id")] + [PowerSync.Column("owner_id")] + [Supabase.Column("owner_id")] + [JsonProperty("owner_id")] public string OwnerId { get; set; } = null!; }