diff --git a/.gitignore b/.gitignore index 20bf598..9bb7c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,8 @@ TestResults/ *.dylib *.dll *.so + +.env + +# Ignore user id file +user_id.txt diff --git a/demos/CommandLine/.env.template b/demos/CommandLine/.env.template new file mode 100644 index 0000000..bfbfab4 --- /dev/null +++ b/demos/CommandLine/.env.template @@ -0,0 +1,25 @@ +# PowerSync server URL +POWERSYNC_URL=http://localhost:8080 + +# Set to true if you want to use Supabase as the backend +# Set to false if you want to use the PowerSync self-hosted backend +USE_SUPABASE=false + +# --- Supabase Connector Settings --- +# These values are used only if USE_SUPABASE=true + +# Supabase project URL +SUPABASE_URL=http://localhost:54321 + +# Supabase anon key (public client access) +SUPABASE_ANON_KEY=your_anon_key_here + +# Supabase credentials for an already existing user (used for login) +SUPABASE_USERNAME=your_supabase_email@example.com +SUPABASE_PASSWORD=your_supabase_password + +# --- PowerSync Backend Settings --- +# These values are used only if USE_SUPABASE=false + +# URL of your PowerSync self-hosted backend +BACKEND_URL=http://localhost:6060 diff --git a/demos/CommandLine/CommandLine.csproj b/demos/CommandLine/CommandLine.csproj index f33a0ab..920769d 100644 --- a/demos/CommandLine/CommandLine.csproj +++ b/demos/CommandLine/CommandLine.csproj @@ -13,8 +13,10 @@ + + diff --git a/demos/CommandLine/CommandLine.sln b/demos/CommandLine/CommandLine.sln new file mode 100644 index 0000000..a24ff89 --- /dev/null +++ b/demos/CommandLine/CommandLine.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommandLine", "CommandLine.csproj", "{6BB9F16E-3825-DE76-1286-9E5E2406710D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6BB9F16E-3825-DE76-1286-9E5E2406710D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BB9F16E-3825-DE76-1286-9E5E2406710D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BB9F16E-3825-DE76-1286-9E5E2406710D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BB9F16E-3825-DE76-1286-9E5E2406710D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A5588511-5909-4F05-80EB-09A56805607C} + EndGlobalSection +EndGlobal diff --git a/demos/CommandLine/Demo.cs b/demos/CommandLine/Demo.cs index dacb958..347edd3 100644 --- a/demos/CommandLine/Demo.cs +++ b/demos/CommandLine/Demo.cs @@ -1,11 +1,12 @@ namespace CommandLine; +using CommandLine.Utils; using PowerSync.Common.Client; +using PowerSync.Common.Client.Connection; using Spectre.Console; class Demo { - private record ListResult(string id, string name, string owner_id, string created_at); static async Task Main() { @@ -16,7 +17,31 @@ static async Task Main() }); await db.Init(); - var connector = new NodeConnector(); + var config = new Config(); + + IPowerSyncBackendConnector connector; + + string connectorUserId = ""; + + if (config.UseSupabase) + { + var supabaseConnector = new SupabaseConnector(config); + + // Ensure this user already exists + await supabaseConnector.Login(config.SupabaseUsername, config.SupabasePassword); + + connectorUserId = supabaseConnector.UserId; + + connector = supabaseConnector; + } + else + { + var nodeConnector = new NodeConnector(config); + + connectorUserId = nodeConnector.UserId; + + connector = nodeConnector; + } var table = new Table() .AddColumn("id") @@ -60,7 +85,7 @@ static async Task Main() } else if (key.Key == ConsoleKey.Enter) { - await db.Execute("insert into lists (id, name, owner_id, created_at) values (uuid(), 'New User', ?, datetime())", [connector.UserId]); + await db.Execute("insert into lists (id, name, owner_id, created_at) values (uuid(), 'New User', ?, datetime())", [connectorUserId]); } else if (key.Key == ConsoleKey.Backspace) { @@ -88,7 +113,6 @@ static async Task Main() } }); - // Start live updating table await AnsiConsole.Live(panel) .StartAsync(async ctx => diff --git a/demos/CommandLine/Helpers/SupabasePatchHelper.cs b/demos/CommandLine/Helpers/SupabasePatchHelper.cs new file mode 100644 index 0000000..47b46a8 --- /dev/null +++ b/demos/CommandLine/Helpers/SupabasePatchHelper.cs @@ -0,0 +1,38 @@ +namespace CommandLine.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/CommandLine/Models/Supabase/List.cs b/demos/CommandLine/Models/Supabase/List.cs new file mode 100644 index 0000000..7839231 --- /dev/null +++ b/demos/CommandLine/Models/Supabase/List.cs @@ -0,0 +1,27 @@ + +using Newtonsoft.Json; +using Supabase.Postgrest.Attributes; +using Supabase.Postgrest.Models; + +namespace CommandLine.Models.Supabase; + +#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. +[Table("lists")] +class List : BaseModel +{ + [PrimaryKey("id")] + [JsonProperty("id")] + public string Id { get; set; } + + [Column("created_at")] + [JsonProperty("created_at")] + public string CreatedAt { get; set; } + + [Column("name")] + [JsonProperty("name")] + public string Name { get; set; } + + [Column("owner_id")] + [JsonProperty("owner_id")] + public string OwnerId { get; set; } +} diff --git a/demos/CommandLine/Models/Supabase/Todos.cs b/demos/CommandLine/Models/Supabase/Todos.cs new file mode 100644 index 0000000..a871c86 --- /dev/null +++ b/demos/CommandLine/Models/Supabase/Todos.cs @@ -0,0 +1,44 @@ + +using Microsoft.VisualBasic; +using Newtonsoft.Json; +using Supabase.Postgrest.Attributes; +using Supabase.Postgrest.Models; + +namespace CommandLine.Models.Supabase; + +#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. +[Table("todos")] +class Todo : BaseModel +{ + [PrimaryKey("id")] + [JsonProperty("id")] + public string Id { get; set; } + + [Column("list_id")] + [JsonProperty("list_id")] + public string ListId { get; set; } + + [Column("created_at")] + [JsonProperty("created_at")] + public string CreatedAt { get; set; } + + [Column("completed_at")] + [JsonProperty("completed_at")] + public string CompletedAt { get; set; } + + [Column("description")] + [JsonProperty("description")] + public string Description { get; set; } + + [Column("created_by")] + [JsonProperty("created_by")] + public string CreatedBy { get; set; } + + [Column("completed_by")] + [JsonProperty("completed_by")] + public string CompletedBy { get; set; } + + [Column("completed")] + [JsonProperty("completed")] + public int Completed { get; set; } +} diff --git a/demos/CommandLine/NodeConnector.cs b/demos/CommandLine/NodeConnector.cs index b7c5dfc..043a6e5 100644 --- a/demos/CommandLine/NodeConnector.cs +++ b/demos/CommandLine/NodeConnector.cs @@ -10,7 +10,7 @@ using PowerSync.Common.Client; using PowerSync.Common.Client.Connection; using PowerSync.Common.DB.Crud; - +using CommandLine.Utils; public class NodeConnector : IPowerSyncBackendConnector { @@ -22,15 +22,15 @@ public class NodeConnector : IPowerSyncBackendConnector public string UserId { get; private set; } private string? clientId; - public NodeConnector() + public NodeConnector(Config config) { _httpClient = new HttpClient(); // Load or generate User ID UserId = LoadOrGenerateUserId(); - BackendUrl = "http://localhost:6060"; - PowerSyncUrl = "http://localhost:8080"; + BackendUrl = config.BackendUrl; + PowerSyncUrl = config.PowerSyncUrl; clientId = null; } diff --git a/demos/CommandLine/README.md b/demos/CommandLine/README.md index 0f9fb70..662834d 100644 --- a/demos/CommandLine/README.md +++ b/demos/CommandLine/README.md @@ -1,4 +1,4 @@ -# PowerSync CLI demo app +# PowerSync CLI Demo App This demo features a CLI-based table view that stays *live* using a *watch query*, ensuring the data updates in real time as changes occur. To run this demo, you need to have one of our Node.js self-host demos ([Postgres](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs) | [MongoDB](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs-mongodb) | [MySQL](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs-mysql)) running, as it provides the PowerSync server that this CLI's PowerSync SDK connects to. @@ -9,6 +9,36 @@ Changes made to the backend's source DB or to the self-hosted web UI will be syn This essentially uses anonymous authentication. A random user ID is generated and stored in local storage. The backend returns a valid token which is not linked to a specific user. All data is synced to all users. +> **Note for Supabase users:** +> If you are using `USE_SUPABASE=true`, this demo expects a valid, **already existing Supabase user**. +> You must provide their credentials via the `.env` file using `SUPABASE_USERNAME` and `SUPABASE_PASSWORD`. + +## Connection Options + +By default, this demo uses the NodeConnector for connecting to the PowerSync server. However, you can swap this out with the SupabaseConnector if needed + +1. Copy the `.env.template` file to a new `.env` file: + ```bash + # On Linux/macOS + cp .env.template .env + + # On Windows + copy .env.template .env + ``` + +2. Replace the necessary fields in the `.env` file with your Supabase and PowerSync credentials: + ``` + SUPABASE_URL=your-supabase-url + SUPABASE_ANON_KEY=your_anon_key_here + POWERSYNC_URL=your-powersync-url + BACKEND_URL=your-backend-url + SUPABASE_USERNAME=your-supabase-username + SUPABASE_PASSWORD=your-supabase-password + # Set to true if you want to use Supabase as the backend + # Set to false if you want to use the Powersync backend + USE_SUPABASE=false + ``` + ## Getting Started In the repo root, run the following to download the PowerSync extension: @@ -29,4 +59,4 @@ To run the Command-Line interface: ```bash dotnet run Demo -``` +``` \ No newline at end of file diff --git a/demos/CommandLine/SupabaseConnector.cs b/demos/CommandLine/SupabaseConnector.cs new file mode 100644 index 0000000..1e9fa0e --- /dev/null +++ b/demos/CommandLine/SupabaseConnector.cs @@ -0,0 +1,175 @@ +namespace CommandLine; + +using CommandLine.Helpers; +using CommandLine.Models.Supabase; +using CommandLine.Utils; +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 Config _config; + 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(Config config) + { + _config = config; + _supabase = new Supabase.Client(config.SupabaseUrl, config.SupabaseAnonKey, 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(_config.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 'Todo' 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 'Todo' 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; + } + } +} \ No newline at end of file diff --git a/demos/CommandLine/Utils/Config.cs b/demos/CommandLine/Utils/Config.cs new file mode 100644 index 0000000..41f534e --- /dev/null +++ b/demos/CommandLine/Utils/Config.cs @@ -0,0 +1,51 @@ +using dotenv.net; + +namespace CommandLine.Utils; + +#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 Config +{ + public string SupabaseUrl { get; set; } + public string SupabaseAnonKey { 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 Config() + { + 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; + + Console.WriteLine("Use Supabase: " + UseSupabase); + + PowerSyncUrl = GetRequiredEnv("POWERSYNC_URL"); + + if (UseSupabase) + { + SupabaseUrl = GetRequiredEnv("SUPABASE_URL"); + SupabaseAnonKey = GetRequiredEnv("SUPABASE_ANON_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."); + } +} \ No newline at end of file