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