diff --git a/Directory.Packages.props b/Directory.Packages.props
index 69725885..aee51786 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -30,6 +30,9 @@
+
+
+
diff --git a/Supabase.sln b/Supabase.sln
index 4561a97c..cdc394ad 100644
--- a/Supabase.sln
+++ b/Supabase.sln
@@ -175,7 +175,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "red-test", "red-test", "{61
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{996F8526-0051-4327-A551-1175C455CC5F}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DependencyInjection", "DependencyInjection", "{9FD9C523-BB83-D457-CA1C-9430D9580376}"
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DependencyInjection", "DependencyInjection", "{C0B9E91A-9577-B879-D937-00DB4C86FF49}"
ProjectSection(SolutionItems) = preProject
packages\extensions\DependencyInjection\README.md = packages\extensions\DependencyInjection\README.md
EndProjectSection
@@ -241,7 +241,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Quality Gate", "Quality Gat
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Public API", "Public API", "{77474E39-B8A1-46A4-BF35-C042DAFCAE4B}"
EndProject
-
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CapabilityMatrix", "CapabilityMatrix", "{287C1647-12FE-4F77-D529-18732EC0C527}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CapabilityMatrix", "packages\CapabilityMatrix\CapabilityMatrix\CapabilityMatrix.csproj", "{7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CapabilityMatrix.Tests", "packages\CapabilityMatrix\CapabilityMatrix.Tests\CapabilityMatrix.Tests.csproj", "{0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -444,16 +449,35 @@ Global
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x64.Build.0 = Release|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x86.ActiveCfg = Release|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x86.Build.0 = Release|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|x64.Build.0 = Debug|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|x86.Build.0 = Debug|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|x64.ActiveCfg = Release|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|x64.Build.0 = Release|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|x86.ActiveCfg = Release|Any CPU
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|x86.Build.0 = Release|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|x64.Build.0 = Debug|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|x86.Build.0 = Debug|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|x64.ActiveCfg = Release|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|x64.Build.0 = Release|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|x86.ActiveCfg = Release|Any CPU
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
- {1085D75D-D916-4F8D-8B9B-8C242DA847BE} = {3074019A-049A-4959-9FCC-410144E423F5}
- {320443A0-3589-44BD-85FD-59118B0D8AF4} = {3074019A-049A-4959-9FCC-410144E423F5}
- {D40502AB-54A1-4E0B-952B-C96AF567B08A} = {3074019A-049A-4959-9FCC-410144E423F5}
- {53322416-5ED2-433A-8CCD-36BC424C7ED0} = {3074019A-049A-4959-9FCC-410144E423F5}
- {9BB19953-99BC-4D4C-B164-FAC2444FE56C} = {3074019A-049A-4959-9FCC-410144E423F5}
{81C5FA5E-A62B-98A1-4CBC-09B5BB06FF07} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
{52157DD7-2D5D-41C3-87A4-2894AD47528C} = {81C5FA5E-A62B-98A1-4CBC-09B5BB06FF07}
{A1459477-0702-4211-956E-5D2F88BEF669} = {81C5FA5E-A62B-98A1-4CBC-09B5BB06FF07}
@@ -476,6 +500,8 @@ Global
{B898A942-9C1A-4C3F-8992-664B636D0ED8} = {48B3645E-6A5B-E050-DA5F-480AE4911C13}
{ACE6D9C0-1F55-4A95-8250-C074D5EBA913} = {48B3645E-6A5B-E050-DA5F-480AE4911C13}
{D1072B4D-1697-4401-9431-F90A6B9AD2BB} = {AB28C3BB-F406-476D-9004-804D35EAA048}
+ {226BEB75-46D1-4366-BBDE-03C9E7A6D6F1} = {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7}
+ {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} = {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7}
{27BCCB1B-2091-4A34-9966-098BCE684A6E} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5}
{A024290F-C0C4-4A3B-BE9D-7715CC9AF747} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5}
{AEA69720-4764-432F-9843-634D152C8C33} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5}
@@ -485,14 +511,20 @@ Global
{097CDFF6-9B52-46C9-88BF-62ED8FE62957} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5}
{612A3F59-5D51-4E02-80C1-4E687887B73D} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5}
{996F8526-0051-4327-A551-1175C455CC5F} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
- {9FD9C523-BB83-D457-CA1C-9430D9580376} = {996F8526-0051-4327-A551-1175C455CC5F}
- {481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6} = {9FD9C523-BB83-D457-CA1C-9430D9580376}
- {709EBF63-BDED-465F-9CEA-D1C7C9E2AB03} = {9FD9C523-BB83-D457-CA1C-9430D9580376}
+ {C0B9E91A-9577-B879-D937-00DB4C86FF49} = {996F8526-0051-4327-A551-1175C455CC5F}
+ {481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6} = {C0B9E91A-9577-B879-D937-00DB4C86FF49}
+ {709EBF63-BDED-465F-9CEA-D1C7C9E2AB03} = {C0B9E91A-9577-B879-D937-00DB4C86FF49}
+ {3074019A-049A-4959-9FCC-410144E423F5} = {77474E39-B8A1-46A4-BF35-C042DAFCAE4B}
+ {1085D75D-D916-4F8D-8B9B-8C242DA847BE} = {3074019A-049A-4959-9FCC-410144E423F5}
+ {320443A0-3589-44BD-85FD-59118B0D8AF4} = {3074019A-049A-4959-9FCC-410144E423F5}
+ {D40502AB-54A1-4E0B-952B-C96AF567B08A} = {3074019A-049A-4959-9FCC-410144E423F5}
+ {53322416-5ED2-433A-8CCD-36BC424C7ED0} = {3074019A-049A-4959-9FCC-410144E423F5}
+ {9BB19953-99BC-4D4C-B164-FAC2444FE56C} = {3074019A-049A-4959-9FCC-410144E423F5}
{B7F880FB-DCBF-4EE7-82F3-C8D2127984F7} = {97E13694-38F0-4D01-BA50-436D9ADCF99C}
- {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} = {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7}
{77474E39-B8A1-46A4-BF35-C042DAFCAE4B} = {97E13694-38F0-4D01-BA50-436D9ADCF99C}
- {3074019A-049A-4959-9FCC-410144E423F5} = {77474E39-B8A1-46A4-BF35-C042DAFCAE4B}
- {226BEB75-46D1-4366-BBDE-03C9E7A6D6F1} = {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7}
+ {287C1647-12FE-4F77-D529-18732EC0C527} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
+ {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8} = {287C1647-12FE-4F77-D529-18732EC0C527}
+ {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C} = {287C1647-12FE-4F77-D529-18732EC0C527}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {832DE89D-7252-4B03-9301-BB8D36B40992}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/CapabilityMatrix.Tests.csproj b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CapabilityMatrix.Tests.csproj
new file mode 100644
index 00000000..c9d1f920
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CapabilityMatrix.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ CapabilityMatrix.Tests
+ Supabase.CapabilityMatrix.Tests
+ false
+ enable
+ enable
+ true
+ true
+ latest
+ True
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliTests.cs
new file mode 100644
index 00000000..a6c18912
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliTests.cs
@@ -0,0 +1,84 @@
+using FluentAssertions;
+
+namespace Supabase.CapabilityMatrix.Tests;
+
+[TestClass]
+public class CliEntryPointTests
+{
+ private static (int Code, string Out, string Err) Run(params string[] args)
+ {
+ var output = new StringWriter();
+ var error = new StringWriter();
+ var code = CliEntryPoint.Run(args, output, error);
+ return (code, output.ToString(), error.ToString());
+ }
+
+ [TestMethod]
+ public void NoArgumentsPrintsUsage()
+ {
+ var (code, _, err) = Run();
+ code.Should().Be(2);
+ err.Should().Contain("Usage:");
+ }
+
+ [TestMethod]
+ public void UnknownCommandFails()
+ {
+ var (code, _, err) = Run("frobnicate");
+ code.Should().Be(2);
+ err.Should().Contain("Unknown command");
+ }
+
+ [TestMethod]
+ public void GenerateWithoutSpecFails()
+ {
+ var (code, _, err) = Run("generate", "--root", ".");
+ code.Should().Be(2);
+ err.Should().Contain("requires --spec");
+ }
+
+ [TestMethod]
+ public void MissingComplianceFileIsHandledAsAnEnvironmentError()
+ {
+ var (code, _, err) = Run("locate", "--root", Path.GetTempPath(), "--compliance", Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.yaml"));
+ code.Should().Be(2);
+ err.Should().Contain("❌");
+ }
+
+ [TestMethod]
+ public void GenerateEndToEndThroughTheFilesystem()
+ {
+ var root = Directory.CreateTempSubdirectory("capmatrix");
+ try
+ {
+ var pkg = Directory.CreateDirectory(Path.Combine(root.FullName, "packages", "Gotrue", "Gotrue"));
+ File.WriteAllText(Path.Combine(pkg.FullName, "Client.cs"),
+ """
+ using Supabase.Core.Attributes;
+ namespace Supabase.Gotrue
+ {
+ public class Client
+ {
+ [ImplementedCapability("auth.sign_in.sign_up")]
+ public void SignUp() { }
+ }
+ }
+ """);
+
+ var spec = Directory.CreateDirectory(Path.Combine(root.FullName, "spec"));
+ File.WriteAllText(Path.Combine(spec.FullName, "auth.yaml"),
+ "area: auth\ntitle: Authentication\ngroups:\n - id: sign_in\n title: Sign-in\nfeatures:\n - id: auth.sign_in.sign_up\n group: sign_in\n");
+
+ var outPath = Path.Combine(root.FullName, "sdk-compliance.yaml");
+ var (code, output, _) = Run("generate", "--root", root.FullName, "--spec", spec.FullName, "--out", outPath);
+
+ code.Should().Be(0);
+ output.Should().Contain("✅");
+ File.ReadAllText(outPath).Should().Contain("auth.sign_in.sign_up:").And.Contain("- Client.SignUp");
+ }
+ finally
+ {
+ root.Delete(recursive: true);
+ }
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/CommandRunnerTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CommandRunnerTests.cs
new file mode 100644
index 00000000..0bc630bf
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CommandRunnerTests.cs
@@ -0,0 +1,137 @@
+using FluentAssertions;
+
+namespace Supabase.CapabilityMatrix.Tests;
+
+internal sealed class InMemoryWorkspace : IWorkspace
+{
+ public List Sources { get; } = [];
+ public List SpecDocuments { get; } = [];
+ public string ComplianceText { get; set; } = "sdk: csharp\nfeatures:\n";
+ public string? Written { get; private set; }
+
+ public IReadOnlyList ReadSources() => this.Sources;
+
+ public IReadOnlyList ReadSpecDocuments() => this.SpecDocuments;
+
+ public string ReadComplianceText() => this.ComplianceText;
+
+ public void WriteComplianceText(string content) => this.Written = content;
+}
+
+[TestClass]
+public class CommandRunnerTests
+{
+ private const string Spec =
+ """
+ area: auth
+ title: Authentication
+ groups:
+ - id: sign_in
+ title: Sign-in
+ features:
+ - id: auth.sign_in.sign_up
+ group: sign_in
+ """;
+
+ private static InMemoryWorkspace WorkspaceWith(string source, string? compliance = null)
+ {
+ var workspace = new InMemoryWorkspace();
+ workspace.Sources.Add(new SymbolWalker.SourceFile("packages/Gotrue/Gotrue/Client.cs", source));
+ workspace.SpecDocuments.Add(Spec);
+ if (compliance is not null)
+ workspace.ComplianceText = compliance;
+ return workspace;
+ }
+
+ private const string GoodSource =
+ """
+ using Supabase.Core.Attributes;
+ namespace Supabase.Gotrue
+ {
+ public class Client
+ {
+ [ImplementedCapability("auth.sign_in.sign_up")]
+ public void SignUp() { }
+ }
+ }
+ """;
+
+ private static (int Code, string Output) Run(Func act, IWorkspace workspace)
+ {
+ var writer = new StringWriter();
+ var code = act(new CommandRunner(workspace, writer));
+ return (code, writer.ToString());
+ }
+
+ [TestMethod]
+ public void GenerateWritesTheFileOnACleanTree()
+ {
+ var workspace = WorkspaceWith(GoodSource);
+ var (code, output) = Run(r => r.Generate(), workspace);
+
+ code.Should().Be(CommandRunner.Success);
+ output.Should().Contain("✅");
+ workspace.Written.Should().Contain("auth.sign_in.sign_up:").And.Contain("- Client.SignUp");
+ }
+
+ [TestMethod]
+ public void GenerateFailsAndWritesNothingOnAnUnknownKey()
+ {
+ var source = GoodSource.Replace("auth.sign_in.sign_up", "auth.sign_in.typo");
+ var workspace = WorkspaceWith(source);
+ var (code, output) = Run(r => r.Generate(), workspace);
+
+ code.Should().Be(CommandRunner.Failure);
+ output.Should().Contain("Cannot generate").And.Contain("auth.sign_in.typo");
+ workspace.Written.Should().BeNull();
+ }
+
+ [TestMethod]
+ public void ValidatePassesOnACleanTreeAndFailsOnConflict()
+ {
+ Run(r => r.Validate(), WorkspaceWith(GoodSource)).Code.Should().Be(CommandRunner.Success);
+
+ var conflicting = GoodSource.Replace(
+ "[ImplementedCapability(\"auth.sign_in.sign_up\")]",
+ "[ImplementedCapability(\"auth.sign_in.sign_up\")]\n [PartialCapability(\"auth.sign_in.sign_up\", \"partial\")]");
+ var (code, output) = Run(r => r.Validate(), WorkspaceWith(conflicting));
+ code.Should().Be(CommandRunner.Failure);
+ output.Should().Contain("conflicting statuses");
+ }
+
+ [TestMethod]
+ public void CheckPassesWhenCommittedMatchesAndFailsOtherwise()
+ {
+ var workspace = WorkspaceWith(GoodSource);
+ workspace.ComplianceText = YamlEmitter.Emit(
+ ComplianceModel.Build(SymbolWalker.Extract(workspace.Sources)).Entries,
+ SpecModel.Parse(workspace.SpecDocuments));
+ Run(r => r.Check(), workspace).Code.Should().Be(CommandRunner.Success);
+
+ var stale = WorkspaceWith(GoodSource, "sdk: csharp\nfeatures:\n auth.sign_in.sign_up: implemented\n");
+ var (code, output) = Run(r => r.Check(), stale);
+ code.Should().Be(CommandRunner.Failure);
+ output.Should().Contain("out of sync").And.Contain("symbols differ");
+ }
+
+ [TestMethod]
+ public void LocateReportsResolvedAndFailsOnUnresolved()
+ {
+ var resolvedCompliance = "sdk: csharp\nfeatures:\n auth.sign_in.sign_up:\n status: implemented\n symbols:\n - Client.SignUp\n";
+ Run(r => r.Locate(), WorkspaceWith(GoodSource, resolvedCompliance)).Should()
+ .Match<(int Code, string Output)>(t => t.Code == CommandRunner.Success && t.Output.Contains("1 resolved"));
+
+ var missingCompliance = "sdk: csharp\nfeatures:\n auth.sign_in.sign_up:\n status: implemented\n symbols:\n - Client.Ghost\n";
+ var (code, output) = Run(r => r.Locate(), WorkspaceWith(GoodSource, missingCompliance));
+ code.Should().Be(CommandRunner.Failure);
+ output.Should().Contain("MISS").And.Contain("Client.Ghost");
+ }
+
+ [TestMethod]
+ public void IndexEmitsJsonForTheExtractedSurface()
+ {
+ var (code, output) = Run(r => r.Index(), WorkspaceWith(GoodSource));
+ code.Should().Be(CommandRunner.Success);
+ output.Should().Contain("\"QualifiedName\": \"Client.SignUp\"").And.Contain("auth.sign_in.sign_up");
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/ComplianceModelTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/ComplianceModelTests.cs
new file mode 100644
index 00000000..13b9c069
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/ComplianceModelTests.cs
@@ -0,0 +1,140 @@
+using FluentAssertions;
+
+namespace Supabase.CapabilityMatrix.Tests;
+
+[TestClass]
+public class ComplianceModelTests
+{
+ private static DeclaredSymbol Sym(string name, bool isPublic, params CapabilityAnnotation[] annotations) =>
+ new(name, "method", "F.cs", 1, isPublic, annotations);
+
+ private static CapabilityAnnotation Impl(string key, string? note = null) => new(key, CapabilityStatus.Implemented, note);
+
+ private static CapabilityAnnotation Part(string key, string note) => new(key, CapabilityStatus.PartiallyImplemented, note);
+
+ private static ComplianceEntry Entry(ComplianceResult result, string key) => result.Entries.Single(e => e.Key == key);
+
+ [TestMethod]
+ public void AggregatesSymbolsPerFeatureSortedAndDeduplicated()
+ {
+ var result = ComplianceModel.Build(
+ [
+ Sym("Client.SignInWithPassword", true, Impl("auth.sign_in")),
+ Sym("Client.SignIn", true, Impl("auth.sign_in")),
+ Sym("Client.SignIn", true, Impl("auth.sign_in")),
+ ]);
+
+ result.Errors.Should().BeEmpty();
+ Entry(result, "auth.sign_in").Symbols.Should().Equal("Client.SignIn", "Client.SignInWithPassword");
+ }
+
+ [TestMethod]
+ public void CarriesStatusAndNoteForImplementedAndPartial()
+ {
+ var result = ComplianceModel.Build(
+ [
+ Sym("Client.Invoke", true, Impl("fn.invoke", "three shapes")),
+ Sym("Client.SignUp", true, Part("auth.sign_up", "no session adopt")),
+ ]);
+
+ Entry(result, "fn.invoke").Should().BeEquivalentTo(new
+ {
+ Status = CapabilityStatus.Implemented,
+ Note = "three shapes",
+ NoPublicSymbols = false,
+ });
+ Entry(result, "auth.sign_up").Status.Should().Be(CapabilityStatus.PartiallyImplemented);
+ }
+
+ [TestMethod]
+ public void ReportsConflictingStatusesForOneFeature()
+ {
+ var result = ComplianceModel.Build(
+ [
+ Sym("A", true, Impl("x.y")),
+ Sym("B", true, Part("x.y", "partial")),
+ ]);
+
+ result.Errors.Should().ContainSingle()
+ .Which.Message.Should().Contain("conflicting statuses");
+ }
+
+ [TestMethod]
+ public void ReportsConflictingNotesForOneFeature()
+ {
+ var result = ComplianceModel.Build(
+ [
+ Sym("A", true, Part("x.y", "note one")),
+ Sym("B", true, Part("x.y", "note two")),
+ ]);
+
+ result.Errors.Should().ContainSingle().Which.Message.Should().Contain("differing notes");
+ }
+
+ [TestMethod]
+ public void TreatsANonPublicOnlyFeatureAsNoPublicSymbols()
+ {
+ var result = ComplianceModel.Build(
+ [
+ Sym("Client.GetAuthHeaders", false, Impl("client.sync", "injected via callback")),
+ ]);
+
+ result.Errors.Should().BeEmpty();
+ var entry = Entry(result, "client.sync");
+ entry.NoPublicSymbols.Should().BeTrue();
+ entry.Symbols.Should().BeEmpty();
+ entry.Note.Should().Be("injected via callback");
+ }
+
+ [TestMethod]
+ public void RequiresANoteWhenThereIsNoPublicSymbol()
+ {
+ var result = ComplianceModel.Build(
+ [
+ Sym("Internal.Thing", false, Impl("client.sync")),
+ ]);
+
+ result.Errors.Should().ContainSingle().Which.Message.Should().Contain("needs a note");
+ }
+
+ [TestMethod]
+ public void KeepsOnlyPublicSymbolsWhenAFeatureHasBoth()
+ {
+ var result = ComplianceModel.Build(
+ [
+ Sym("Public.M", true, Impl("x.y")),
+ Sym("Internal.M", false, Impl("x.y")),
+ ]);
+
+ var entry = Entry(result, "x.y");
+ entry.NoPublicSymbols.Should().BeFalse();
+ entry.Symbols.Should().Equal("Public.M");
+ }
+
+ [TestMethod]
+ public void IgnoresSymbolsWithoutAnnotations()
+ {
+ var result = ComplianceModel.Build([Sym("Plain.Method", true)]);
+ result.Entries.Should().BeEmpty();
+ result.Errors.Should().BeEmpty();
+ }
+}
+
+[TestClass]
+public class KeyValidatorTests
+{
+ [TestMethod]
+ public void FlagsUnknownKeysOnly()
+ {
+ var entries = new List
+ {
+ new("known.feature", CapabilityStatus.Implemented, null, ["A"]),
+ new("typo.feature", CapabilityStatus.Implemented, null, ["B"]),
+ };
+ var known = new HashSet(StringComparer.Ordinal) { "known.feature" };
+
+ var errors = KeyValidator.ValidateKeys(entries, known);
+
+ errors.Should().ContainSingle().Which.Message.Should().Contain("typo.feature");
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/ParseCompareLocateTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/ParseCompareLocateTests.cs
new file mode 100644
index 00000000..29ce86b6
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/ParseCompareLocateTests.cs
@@ -0,0 +1,144 @@
+using FluentAssertions;
+
+namespace Supabase.CapabilityMatrix.Tests;
+
+[TestClass]
+public class ComplianceYamlTests
+{
+ [TestMethod]
+ public void ParsesScalarAndMapEntries()
+ {
+ var compliance = ComplianceYaml.Parse(
+ """
+ sdk: csharp
+ features:
+ a.simple: implemented
+ a.rich:
+ status: partially_implemented
+ no_public_symbols: true
+ note: "missing bits"
+ symbols:
+ - Z.Last
+ - A.First
+ """);
+
+ compliance.Sdk.Should().Be("csharp");
+ compliance.Features["a.simple"].Should().BeEquivalentTo(
+ new NormalizedEntry("implemented", null, [], false));
+ var rich = compliance.Features["a.rich"];
+ rich.Status.Should().Be("partially_implemented");
+ rich.Note.Should().Be("missing bits");
+ rich.NoPublicSymbols.Should().BeTrue();
+ rich.Symbols.Should().Equal("A.First", "Z.Last");
+ }
+
+ [TestMethod]
+ public void ProjectsEntriesIntoTheSameNormalizedShape()
+ {
+ var normalized = ComplianceYaml.FromEntries(
+ [new ComplianceEntry("a.b", CapabilityStatus.PartiallyImplemented, "", ["B", "A"], true)]);
+
+ normalized.Sdk.Should().Be("csharp");
+ var entry = normalized.Features["a.b"];
+ entry.Status.Should().Be("partially_implemented");
+ entry.Note.Should().BeNull("an empty note normalizes to null");
+ entry.NoPublicSymbols.Should().BeTrue();
+ entry.Symbols.Should().Equal("A", "B");
+ }
+}
+
+[TestClass]
+public class ComplianceComparerTests
+{
+ private static NormalizedCompliance One(string key, NormalizedEntry entry) =>
+ new("csharp", new Dictionary(StringComparer.Ordinal) { [key] = entry });
+
+ [TestMethod]
+ public void ReportsNoDifferencesForEquivalentModels()
+ {
+ var a = One("k", new NormalizedEntry("implemented", "n", ["A"], false));
+ var b = One("k", new NormalizedEntry("implemented", "n", ["A"], false));
+ ComplianceComparer.Diff(a, b).Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ReportsEveryKindOfMismatch()
+ {
+ var expected = new NormalizedCompliance("csharp", new Dictionary(StringComparer.Ordinal)
+ {
+ ["only.expected"] = new("implemented", null, [], false),
+ ["shared"] = new("implemented", "old", ["Keep", "Gone"], false),
+ });
+ var actual = new NormalizedCompliance("dart", new Dictionary(StringComparer.Ordinal)
+ {
+ ["only.actual"] = new("implemented", null, [], false),
+ ["shared"] = new("partially_implemented", "new", ["Keep", "Added"], true),
+ });
+
+ var diff = ComplianceComparer.Diff(expected, actual);
+
+ diff.Should().Contain(d => d.Contains("sdk:"));
+ diff.Should().Contain(d => d.Contains("only.expected") && d.Contains("missing from generated"));
+ diff.Should().Contain(d => d.Contains("only.actual") && d.Contains("not in expected"));
+ diff.Should().Contain(d => d.Contains("shared: status"));
+ diff.Should().Contain(d => d.Contains("shared: note"));
+ diff.Should().Contain(d => d.Contains("shared: no_public_symbols"));
+ diff.Should().Contain(d => d.Contains("shared: symbols") && d.Contains("missing: Gone") && d.Contains("extra: Added"));
+ }
+}
+
+[TestClass]
+public class LocatorTests
+{
+ private static DeclaredSymbol Decl(string name, string file) => new(name, "method", file, 7, true, []);
+
+ [TestMethod]
+ public void ClassifiesResolvedAmbiguousAndUnresolvedSymbols()
+ {
+ var compliance = new NormalizedCompliance("csharp", new Dictionary(StringComparer.Ordinal)
+ {
+ ["f.one"] = new("implemented", null, ["Client.A"], false),
+ ["f.two"] = new("implemented", null, ["Client.B"], false),
+ ["f.three"] = new("implemented", null, ["Client.Missing"], false),
+ });
+ var symbols = new List
+ {
+ Decl("Client.A", "One.cs"),
+ Decl("Client.B", "Two.cs"),
+ Decl("Client.B", "Three.cs"),
+ };
+
+ var report = Locator.Locate(compliance, symbols);
+
+ report.Resolved.Should().ContainSingle().Which.Symbol.Should().Be("Client.A");
+ report.Ambiguous.Should().ContainSingle().Which.Candidates.Should().HaveCount(2);
+ report.Unresolved.Should().ContainSingle().Which.Symbol.Should().Be("Client.Missing");
+ }
+}
+
+[TestClass]
+public class SourceDiscoveryTests
+{
+ [TestMethod]
+ [DataRow("packages/Gotrue/Gotrue/Client.cs", true)]
+ [DataRow("packages/Storage/Storage/Sub/Dir/File.cs", true)]
+ [DataRow("packages/Gotrue/Gotrue/bin/Debug/Client.cs", false)]
+ [DataRow("packages/Gotrue/Gotrue/obj/Client.g.cs", false)]
+ [DataRow("packages/Gotrue/Gotrue.Tests/ClientTests.cs", false)]
+ [DataRow("packages/CapabilityMatrix/CapabilityMatrix/Program.cs", false)]
+ [DataRow("packages/Gotrue/Gotrue/README.md", false)]
+ [DataRow("scripts/tool.cs", false)]
+ public void IncludesOnlyProductionSdkSource(string path, bool expected) =>
+ SourceDiscovery.IsSdkSourceFile(path).Should().Be(expected);
+}
+
+[TestClass]
+public class CapabilityStatusTests
+{
+ [TestMethod]
+ public void MapsStatusesToMatrixSpellings()
+ {
+ CapabilityStatus.Implemented.ToYamlValue().Should().Be("implemented");
+ CapabilityStatus.PartiallyImplemented.ToYamlValue().Should().Be("partially_implemented");
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/SpecAndEmitterTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/SpecAndEmitterTests.cs
new file mode 100644
index 00000000..560f23f6
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/SpecAndEmitterTests.cs
@@ -0,0 +1,121 @@
+using FluentAssertions;
+
+namespace Supabase.CapabilityMatrix.Tests;
+
+[TestClass]
+public class SpecModelTests
+{
+ private const string AuthArea =
+ """
+ area: auth
+ title: Authentication
+ groups:
+ - id: sign_in
+ title: Sign-in
+ - id: session
+ title: Session
+ features:
+ - id: auth.sign_in.b
+ group: sign_in
+ - id: auth.sign_in.a
+ group: sign_in
+ - id: auth.session.x
+ group: session
+ - id: auth.loose
+ """;
+
+ [TestMethod]
+ public void CollectsKnownIdsAndPreservesGroupAndFeatureOrder()
+ {
+ var spec = SpecModel.Parse([AuthArea]);
+
+ spec.KnownIds.Should().BeEquivalentTo(["auth.sign_in.b", "auth.sign_in.a", "auth.session.x", "auth.loose"]);
+ var area = spec.Areas.Single();
+ area.Title.Should().Be("Authentication");
+ area.Groups.Select(g => g.Id).Should().Equal("sign_in", "session");
+ area.Groups[0].FeatureIds.Should().Equal("auth.sign_in.b", "auth.sign_in.a");
+ area.UngroupedFeatureIds.Should().Equal("auth.loose");
+ }
+
+ [TestMethod]
+ public void OrdersAreasByIdAndSkipsEmptyDocuments()
+ {
+ var spec = SpecModel.Parse(
+ [
+ "area: storage\ntitle: Storage\nfeatures: []",
+ "area: auth\ntitle: Auth\nfeatures: []",
+ "# just a comment\n",
+ ]);
+
+ spec.Areas.Select(a => a.Id).Should().Equal("auth", "storage");
+ }
+}
+
+[TestClass]
+public class YamlEmitterTests
+{
+ private static SpecModel Spec() => SpecModel.Parse(
+ [
+ """
+ area: auth
+ title: Authentication
+ groups:
+ - id: sign_in
+ title: Sign-in
+ - id: session
+ title: Session
+ features:
+ - id: auth.sign_in.a
+ group: sign_in
+ - id: auth.session.x
+ group: session
+ """,
+ ]);
+
+ [TestMethod]
+ public void EmitsHeaderBannerAndEntry()
+ {
+ var yaml = YamlEmitter.Emit(
+ [new ComplianceEntry("auth.sign_in.a", CapabilityStatus.Implemented, null, ["Client.A", "Client.B"])],
+ Spec());
+
+ yaml.Should().StartWith("sdk: csharp\nfeatures:\n");
+ var banner = yaml.Split('\n').Single(l => l.Contains("Authentication"));
+ banner.Should().Be(" # ── Authentication " + new string('─', 79 - " # ── Authentication ".Length));
+ banner.Length.Should().Be(79);
+ yaml.Should().Contain(" auth.sign_in.a:\n status: implemented\n symbols:\n - Client.A\n - Client.B\n");
+ yaml.Should().EndWith("\n");
+ }
+
+ [TestMethod]
+ public void EscapesNotesAndEmitsNoPublicSymbolsWithoutASymbolsBlock()
+ {
+ var yaml = YamlEmitter.Emit(
+ [new ComplianceEntry("auth.sign_in.a", CapabilityStatus.Implemented, "He said \"hi\" \\o/", [], NoPublicSymbols: true)],
+ Spec());
+
+ yaml.Should().Contain(" no_public_symbols: true\n");
+ yaml.Should().Contain(" note: \"He said \\\"hi\\\" \\\\o/\"\n");
+ yaml.Should().NotContain(" symbols:");
+ }
+
+ [TestMethod]
+ public void SeparatesGroupsWithABlankLineAndSkipsAbsentFeatures()
+ {
+ var yaml = YamlEmitter.Emit(
+ [
+ new ComplianceEntry("auth.sign_in.a", CapabilityStatus.Implemented, null, ["A"]),
+ new ComplianceEntry("auth.session.x", CapabilityStatus.Implemented, null, ["B"]),
+ ], Spec());
+
+ // A blank line separates the sign_in group block from the session group block.
+ yaml.Should().Contain(" - A\n\n auth.session.x:");
+ }
+
+ [TestMethod]
+ public void OmitsAreasWithNoMatchingEntries()
+ {
+ var yaml = YamlEmitter.Emit([], Spec());
+ yaml.Should().Be("sdk: csharp\nfeatures:\n");
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/SymbolWalkerTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/SymbolWalkerTests.cs
new file mode 100644
index 00000000..fadcb7e5
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/SymbolWalkerTests.cs
@@ -0,0 +1,194 @@
+using FluentAssertions;
+
+namespace Supabase.CapabilityMatrix.Tests;
+
+[TestClass]
+public class SymbolWalkerTests
+{
+ private static IReadOnlyList Extract(string source) =>
+ SymbolWalker.Extract([new SymbolWalker.SourceFile("Sample.cs", source)]);
+
+ private static DeclaredSymbol Named(IReadOnlyList symbols, string name) =>
+ symbols.Single(s => s.QualifiedName == name);
+
+ [TestMethod]
+ public void StripsNamespaceAndQualifiesByContainingType()
+ {
+ var symbols = Extract(
+ """
+ namespace Foo.Bar
+ {
+ public class Widget
+ {
+ public void Go() { }
+ }
+ }
+ """);
+
+ symbols.Select(s => s.QualifiedName).Should().Contain(["Widget", "Widget.Go"]);
+ symbols.Select(s => s.QualifiedName).Should().NotContain(n => n.StartsWith("Foo"));
+ Named(symbols, "Widget").Kind.Should().Be("class");
+ Named(symbols, "Widget.Go").Kind.Should().Be("method");
+ }
+
+ [TestMethod]
+ public void QualifiesNestedTypesEnumMembersAndFields()
+ {
+ var symbols = Extract(
+ """
+ namespace N
+ {
+ public static class Constants
+ {
+ public enum Operator { Equals, NotEqual }
+ }
+ public class Options
+ {
+ public string Headers = "";
+ }
+ }
+ """);
+
+ symbols.Select(s => s.QualifiedName).Should().Contain(
+ ["Constants", "Constants.Operator", "Constants.Operator.Equals", "Constants.Operator.NotEqual", "Options.Headers"]);
+ Named(symbols, "Constants.Operator.Equals").Kind.Should().Be("property");
+ Named(symbols, "Options.Headers").Kind.Should().Be("property");
+ }
+
+ [TestMethod]
+ public void MarksAccessibilityAcrossTheContainingChain()
+ {
+ var symbols = Extract(
+ """
+ namespace N
+ {
+ public class Pub
+ {
+ public void A() { }
+ internal void B() { }
+ private void C() { }
+ public static class Inner { public void D() { } }
+ }
+ internal class Hidden { public void E() { } }
+ public interface IFace { void F(); }
+ }
+ """);
+
+ Named(symbols, "Pub.A").IsPublic.Should().BeTrue();
+ Named(symbols, "Pub.B").IsPublic.Should().BeFalse();
+ Named(symbols, "Pub.C").IsPublic.Should().BeFalse();
+ Named(symbols, "Pub.Inner.D").IsPublic.Should().BeTrue();
+ Named(symbols, "Hidden").IsPublic.Should().BeFalse();
+ Named(symbols, "Hidden.E").IsPublic.Should().BeFalse("the containing type is not public");
+ Named(symbols, "IFace.F").IsPublic.Should().BeTrue("interface members are public by default");
+ }
+
+ [TestMethod]
+ public void ReadsBothCapabilityAttributesWithNotes()
+ {
+ var symbols = Extract(
+ """
+ using Supabase.Core.Attributes;
+ namespace N
+ {
+ public class C
+ {
+ [ImplementedCapability("a.b.c")]
+ public void Plain() { }
+
+ [ImplementedCapability("a.b.d", "with caveat")]
+ public void Caveat() { }
+
+ [PartialCapability("a.b.e", "missing X")]
+ public void Partial() { }
+ }
+ }
+ """);
+
+ Named(symbols, "C.Plain").Annotations.Should().ContainSingle()
+ .Which.Should().BeEquivalentTo(new { Key = "a.b.c", Status = CapabilityStatus.Implemented, Note = (string?) null });
+ Named(symbols, "C.Caveat").Annotations.Single().Note.Should().Be("with caveat");
+ var partial = Named(symbols, "C.Partial").Annotations.Single();
+ partial.Status.Should().Be(CapabilityStatus.PartiallyImplemented);
+ partial.Note.Should().Be("missing X");
+ }
+
+ [TestMethod]
+ public void AcceptsAttributeSuffixQualifiedNameAndNamedNoteArgument()
+ {
+ var symbols = Extract(
+ """
+ namespace N
+ {
+ public class C
+ {
+ [Supabase.Core.Attributes.ImplementedCapabilityAttribute("a.b.c", note: "n")]
+ public void M() { }
+
+ [System.Obsolete]
+ public void Ignored() { }
+ }
+ }
+ """);
+
+ Named(symbols, "C.M").Annotations.Single().Should()
+ .BeEquivalentTo(new { Key = "a.b.c", Note = "n" });
+ Named(symbols, "C.Ignored").Annotations.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void AllowsMultipleCapabilitiesOnOneMember()
+ {
+ var symbols = Extract(
+ """
+ using Supabase.Core.Attributes;
+ namespace N
+ {
+ public class C
+ {
+ [ImplementedCapability("a.one")]
+ [ImplementedCapability("a.two")]
+ public void M() { }
+ }
+ }
+ """);
+
+ Named(symbols, "C.M").Annotations.Select(a => a.Key).Should().BeEquivalentTo(["a.one", "a.two"]);
+ }
+
+ [TestMethod]
+ public void SkipsAttributesWhoseKeyIsNotAStringLiteral()
+ {
+ var symbols = Extract(
+ """
+ using Supabase.Core.Attributes;
+ namespace N
+ {
+ public class C
+ {
+ public const string K = "a.b.c";
+ [ImplementedCapability(K)]
+ public void M() { }
+ }
+ }
+ """);
+
+ Named(symbols, "C.M").Annotations.Should().BeEmpty("a non-literal key cannot be resolved from syntax");
+ }
+
+ [TestMethod]
+ public void OrdersDeterministicallyAndReportsOneBasedLines()
+ {
+ var symbols = Extract(
+ """
+ namespace N
+ {
+ public class B { public void Z() { } }
+ public class A { public void Y() { } }
+ }
+ """);
+
+ symbols.Select(s => s.QualifiedName).Should().BeInAscendingOrder(StringComparer.Ordinal);
+ Named(symbols, "A").Line.Should().BeGreaterThan(0);
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/CapabilityMatrix.csproj b/packages/CapabilityMatrix/CapabilityMatrix/CapabilityMatrix.csproj
new file mode 100644
index 00000000..f7a27f10
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/CapabilityMatrix.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Exe
+ net10.0
+ Supabase.CapabilityMatrix
+ Supabase.CapabilityMatrix
+ enable
+ enable
+
+ false
+ true
+ true
+ latest
+ True
+
+ true
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/CapabilityStatus.cs b/packages/CapabilityMatrix/CapabilityMatrix/CapabilityStatus.cs
new file mode 100644
index 00000000..97b97d43
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/CapabilityStatus.cs
@@ -0,0 +1,23 @@
+namespace Supabase.CapabilityMatrix;
+
+///
+/// The compliance status a capability annotation reports to the matrix. Mirrors
+/// the subset of matrix statuses an in-code annotation can express — a member
+/// that exists is, by definition, at least partially implemented.
+///
+public enum CapabilityStatus
+{
+ Implemented,
+ PartiallyImplemented,
+}
+
+public static class CapabilityStatusExtensions
+{
+ /// The canonical matrix spelling, as written in sdk-compliance.yaml.
+ public static string ToYamlValue(this CapabilityStatus status) => status switch
+ {
+ CapabilityStatus.Implemented => "implemented",
+ CapabilityStatus.PartiallyImplemented => "partially_implemented",
+ _ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
+ };
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/CommandRunner.cs b/packages/CapabilityMatrix/CapabilityMatrix/CommandRunner.cs
new file mode 100644
index 00000000..d731c592
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/CommandRunner.cs
@@ -0,0 +1,134 @@
+using System.Text.Json;
+
+namespace Supabase.CapabilityMatrix;
+
+///
+/// The four commands — generate, validate, locate, check — operating over an
+/// . All business logic lives in the pure classes it
+/// composes; this layer only loads inputs, reports, and chooses an exit code.
+///
+public sealed class CommandRunner
+{
+ public const int Success = 0;
+ public const int Failure = 1;
+
+ private static readonly JsonSerializerOptions IndexJsonOptions = new() { WriteIndented = true };
+
+ private readonly IWorkspace workspace;
+ private readonly TextWriter output;
+
+ public CommandRunner(IWorkspace workspace, TextWriter output)
+ {
+ this.workspace = workspace;
+ this.output = output;
+ }
+
+ /// Regenerates and writes sdk-compliance.yaml, failing on any authoring error.
+ public int Generate()
+ {
+ var (result, spec, errors) = this.Analyze();
+ if (errors.Count > 0)
+ {
+ this.ReportErrors("Cannot generate sdk-compliance.yaml", errors);
+ return Failure;
+ }
+
+ this.workspace.WriteComplianceText(YamlEmitter.Emit(result.Entries, spec));
+ this.output.WriteLine($"✅ Generated sdk-compliance.yaml ({result.Entries.Count} features).");
+ return Success;
+ }
+
+ /// Validates the capability annotations without touching the committed file.
+ public int Validate()
+ {
+ var (_, _, errors) = this.Analyze();
+ if (errors.Count > 0)
+ {
+ this.ReportErrors("Capability annotation check failed", errors);
+ return Failure;
+ }
+
+ this.output.WriteLine("✅ Capability annotations are valid.");
+ return Success;
+ }
+
+ /// Resolves every symbol in the committed file against the source and reports the result.
+ public int Locate()
+ {
+ var symbols = SymbolWalker.Extract(this.workspace.ReadSources());
+ var compliance = ComplianceYaml.Parse(this.workspace.ReadComplianceText());
+ var report = Locator.Locate(compliance, symbols);
+
+ foreach (var location in report.Resolved)
+ this.output.WriteLine($" ok {location.Key} → {location.Symbol} ({location.FilePath}:{location.Line})");
+ foreach (var ambiguous in report.Ambiguous)
+ this.output.WriteLine($" AMBIG {ambiguous.Key} → {ambiguous.Symbol} in {ambiguous.Candidates.Count} files: " +
+ string.Join(", ", ambiguous.Candidates.Select(c => $"{c.FilePath}:{c.Line}")));
+ foreach (var unresolved in report.Unresolved)
+ this.output.WriteLine($" MISS {unresolved.Key} → {unresolved.Symbol} (no such public symbol)");
+
+ this.output.WriteLine(
+ $"\n{report.Resolved.Count} resolved, {report.Ambiguous.Count} ambiguous, {report.Unresolved.Count} unresolved.");
+ return report.Ambiguous.Count == 0 && report.Unresolved.Count == 0 ? Success : Failure;
+ }
+
+ /// Writes the extracted public-symbol surface as JSON, for debugging and tooling.
+ public int Index()
+ {
+ var symbols = SymbolWalker.Extract(this.workspace.ReadSources())
+ .Select(symbol => new
+ {
+ symbol.QualifiedName,
+ symbol.Kind,
+ symbol.FilePath,
+ symbol.Line,
+ symbol.IsPublic,
+ Keys = symbol.Annotations.Select(annotation => annotation.Key).ToList(),
+ });
+ this.output.WriteLine(JsonSerializer.Serialize(symbols, IndexJsonOptions));
+ return Success;
+ }
+
+ /// Checks the committed file is semantically equal to what the annotations would generate.
+ public int Check()
+ {
+ var (result, _, errors) = this.Analyze();
+ if (errors.Count > 0)
+ {
+ this.ReportErrors("Capability annotation check failed", errors);
+ return Failure;
+ }
+
+ var expected = ComplianceYaml.Parse(this.workspace.ReadComplianceText());
+ var actual = ComplianceYaml.FromEntries(result.Entries);
+ var differences = ComplianceComparer.Diff(expected, actual);
+ if (differences.Count > 0)
+ {
+ this.output.WriteLine("❌ sdk-compliance.yaml is out of sync with the capability annotations:");
+ foreach (var difference in differences)
+ this.output.WriteLine($" - {difference}");
+ return Failure;
+ }
+
+ this.output.WriteLine("✅ sdk-compliance.yaml matches the capability annotations.");
+ return Success;
+ }
+
+ private (ComplianceResult Result, SpecModel Spec, IReadOnlyList Errors) Analyze()
+ {
+ var symbols = SymbolWalker.Extract(this.workspace.ReadSources());
+ var result = ComplianceModel.Build(symbols);
+ var spec = SpecModel.Parse(this.workspace.ReadSpecDocuments());
+ var errors = result.Errors
+ .Concat(KeyValidator.ValidateKeys(result.Entries, spec.KnownIds))
+ .ToList();
+ return (result, spec, errors);
+ }
+
+ private void ReportErrors(string headline, IReadOnlyList errors)
+ {
+ this.output.WriteLine($"❌ {headline}:");
+ foreach (var error in errors)
+ this.output.WriteLine($" - {error.Message}");
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/ComplianceComparer.cs b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceComparer.cs
new file mode 100644
index 00000000..f7e39ea2
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceComparer.cs
@@ -0,0 +1,50 @@
+namespace Supabase.CapabilityMatrix;
+
+///
+/// Compares two normalized compliance models for semantic equivalence — same sdk,
+/// same feature ids, and per feature the same status, note and set of symbols.
+/// Formatting and symbol order are already normalized away. Pure.
+///
+public static class ComplianceComparer
+{
+ /// Returns a human-readable difference per mismatch; empty when equivalent.
+ public static IReadOnlyList Diff(NormalizedCompliance expected, NormalizedCompliance actual)
+ {
+ var differences = new List();
+
+ if (expected.Sdk != actual.Sdk)
+ differences.Add($"sdk: expected '{expected.Sdk}', got '{actual.Sdk}'");
+
+ foreach (var key in expected.Features.Keys.Except(actual.Features.Keys).OrderBy(k => k, StringComparer.Ordinal))
+ differences.Add($"{key}: present in expected, missing from generated");
+
+ foreach (var key in actual.Features.Keys.Except(expected.Features.Keys).OrderBy(k => k, StringComparer.Ordinal))
+ differences.Add($"{key}: generated but not in expected");
+
+ foreach (var key in expected.Features.Keys.Intersect(actual.Features.Keys).OrderBy(k => k, StringComparer.Ordinal))
+ differences.AddRange(DiffEntry(key, expected.Features[key], actual.Features[key]));
+
+ return differences;
+ }
+
+ private static IEnumerable DiffEntry(string key, NormalizedEntry expected, NormalizedEntry actual)
+ {
+ if (expected.Status != actual.Status)
+ yield return $"{key}: status expected '{expected.Status}', got '{actual.Status}'";
+
+ if (expected.Note != actual.Note)
+ yield return $"{key}: note expected '{expected.Note ?? ""}', got '{actual.Note ?? ""}'";
+
+ if (expected.NoPublicSymbols != actual.NoPublicSymbols)
+ yield return $"{key}: no_public_symbols expected '{expected.NoPublicSymbols}', got '{actual.NoPublicSymbols}'";
+
+ if (!expected.Symbols.SequenceEqual(actual.Symbols))
+ {
+ var missing = expected.Symbols.Except(actual.Symbols).ToList();
+ var extra = actual.Symbols.Except(expected.Symbols).ToList();
+ yield return $"{key}: symbols differ" +
+ (missing.Count > 0 ? $" (missing: {string.Join(", ", missing)})" : string.Empty) +
+ (extra.Count > 0 ? $" (extra: {string.Join(", ", extra)})" : string.Empty);
+ }
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/ComplianceModel.cs b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceModel.cs
new file mode 100644
index 00000000..4cd00481
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceModel.cs
@@ -0,0 +1,95 @@
+namespace Supabase.CapabilityMatrix;
+
+/// One generated sdk-compliance.yaml feature entry.
+/// The capability-matrix feature id.
+/// The reported status.
+/// The note, if any.
+/// The implementing public symbols, sorted and de-duplicated.
+///
+/// True when the feature is implemented only through non-public API, so there is no
+/// public symbol to list. Mirrors the matrix's no_public_symbols flag.
+///
+public sealed record ComplianceEntry(
+ string Key,
+ CapabilityStatus Status,
+ string? Note,
+ IReadOnlyList Symbols,
+ bool NoPublicSymbols = false);
+
+/// A problem found while aggregating annotations into compliance entries.
+/// Human-readable description, surfaced on CI.
+public sealed record ValidationError(string Message);
+
+/// The aggregated compliance entries plus any errors found building them.
+public sealed record ComplianceResult(
+ IReadOnlyList Entries,
+ IReadOnlyList Errors);
+
+///
+/// Aggregates the capability annotations scattered across declared symbols into one
+/// entry per feature id. Pure. Two members tagged with the same key contribute two
+/// symbols to one entry; the same member may carry several keys.
+///
+public static class ComplianceModel
+{
+ public static ComplianceResult Build(IReadOnlyList symbols)
+ {
+ var errors = new List();
+
+ var annotated = symbols
+ .SelectMany(symbol => symbol.Annotations.Select(annotation => (symbol, annotation)))
+ .ToList();
+
+ var entries = new List();
+ foreach (var group in annotated.GroupBy(pair => pair.annotation.Key).OrderBy(g => g.Key, StringComparer.Ordinal))
+ {
+ var statuses = group.Select(pair => pair.annotation.Status).Distinct().ToList();
+ if (statuses.Count > 1)
+ {
+ errors.Add(new ValidationError(
+ $"'{group.Key}' is reported with conflicting statuses (implemented and partially_implemented) " +
+ "across members — a feature has a single status."));
+ }
+
+ var notes = group
+ .Select(pair => pair.annotation.Note)
+ .Where(note => !string.IsNullOrEmpty(note))
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(note => note, StringComparer.Ordinal)
+ .ToList();
+ if (notes.Count > 1)
+ {
+ errors.Add(new ValidationError(
+ $"'{group.Key}' carries {notes.Count} differing notes across members — a feature has a single note."));
+ }
+
+ var symbolNames = group
+ .Where(pair => pair.symbol.IsPublic)
+ .Select(pair => pair.symbol.QualifiedName)
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(name => name, StringComparer.Ordinal)
+ .ToList();
+
+ // Partial wins a status conflict (the conservative reading); it is reported
+ // as an error above regardless, so a clean tree never relies on this choice.
+ var status = statuses.Contains(CapabilityStatus.PartiallyImplemented)
+ ? CapabilityStatus.PartiallyImplemented
+ : CapabilityStatus.Implemented;
+
+ // A feature implemented only through non-public API has no public symbol to
+ // cite; the matrix records that with no_public_symbols, which requires a note.
+ var noPublicSymbols = symbolNames.Count == 0;
+ var note = notes.FirstOrDefault();
+ if (noPublicSymbols && string.IsNullOrEmpty(note))
+ {
+ errors.Add(new ValidationError(
+ $"'{group.Key}' is implemented only through non-public API and needs a note " +
+ "explaining how the capability is exposed (matrix: no_public_symbols requires a note)."));
+ }
+
+ entries.Add(new ComplianceEntry(group.Key, status, note, symbolNames, noPublicSymbols));
+ }
+
+ return new ComplianceResult(entries, errors);
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/ComplianceYaml.cs b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceYaml.cs
new file mode 100644
index 00000000..cd8a103a
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceYaml.cs
@@ -0,0 +1,75 @@
+using YamlDotNet.RepresentationModel;
+
+namespace Supabase.CapabilityMatrix;
+
+/// A feature entry read from an existing sdk-compliance.yaml, normalized for comparison.
+/// The status string, verbatim.
+/// The note, or null when absent.
+/// The symbols list, sorted for order-independent comparison.
+/// The no_public_symbols flag.
+public sealed record NormalizedEntry(string Status, string? Note, IReadOnlyList Symbols, bool NoPublicSymbols);
+
+/// A whole compliance file reduced to the facts the matrix cares about.
+public sealed record NormalizedCompliance(string Sdk, IReadOnlyDictionary Features);
+
+///
+/// Reads an existing sdk-compliance.yaml into a normalized model, and
+/// projects the generator's own entries into the same shape, so the two can be
+/// compared for semantic equivalence independent of formatting or symbol order.
+///
+public static class ComplianceYaml
+{
+ public static NormalizedCompliance Parse(string text)
+ {
+ var stream = new YamlStream();
+ using var reader = new StringReader(text);
+ stream.Load(reader);
+
+ var root = (YamlMappingNode) stream.Documents[0].RootNode;
+ var sdk = Scalar(root, "sdk") ?? string.Empty;
+
+ var features = new Dictionary(StringComparer.Ordinal);
+ if (TryGet(root, "features") is YamlMappingNode featureMap)
+ {
+ foreach (var (keyNode, valueNode) in featureMap.Children)
+ {
+ var key = ((YamlScalarNode) keyNode).Value!;
+ features[key] = ReadEntry(valueNode);
+ }
+ }
+
+ return new NormalizedCompliance(sdk, features);
+ }
+
+ /// Projects generated entries into the normalized shape used for comparison.
+ public static NormalizedCompliance FromEntries(IReadOnlyList entries) =>
+ new("csharp", entries.ToDictionary(
+ entry => entry.Key,
+ entry => new NormalizedEntry(
+ entry.Status.ToYamlValue(),
+ string.IsNullOrEmpty(entry.Note) ? null : entry.Note,
+ entry.Symbols.OrderBy(s => s, StringComparer.Ordinal).ToList(),
+ entry.NoPublicSymbols),
+ StringComparer.Ordinal));
+
+ private static NormalizedEntry ReadEntry(YamlNode valueNode)
+ {
+ if (valueNode is YamlScalarNode scalar)
+ return new NormalizedEntry(scalar.Value ?? string.Empty, null, [], false);
+
+ var map = (YamlMappingNode) valueNode;
+ var status = Scalar(map, "status") ?? string.Empty;
+ var note = Scalar(map, "note");
+ var noPublicSymbols = string.Equals(Scalar(map, "no_public_symbols"), "true", StringComparison.Ordinal);
+ var symbols = TryGet(map, "symbols") is YamlSequenceNode sequence
+ ? sequence.Children.OfType().Select(n => n.Value!).OrderBy(s => s, StringComparer.Ordinal).ToList()
+ : [];
+ return new NormalizedEntry(status, note, symbols, noPublicSymbols);
+ }
+
+ private static string? Scalar(YamlMappingNode map, string key) =>
+ TryGet(map, key) is YamlScalarNode scalar ? scalar.Value : null;
+
+ private static YamlNode? TryGet(YamlMappingNode map, string key) =>
+ map.Children.TryGetValue(new YamlScalarNode(key), out var node) ? node : null;
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/DeclaredSymbol.cs b/packages/CapabilityMatrix/CapabilityMatrix/DeclaredSymbol.cs
new file mode 100644
index 00000000..4870664d
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/DeclaredSymbol.cs
@@ -0,0 +1,28 @@
+namespace Supabase.CapabilityMatrix;
+
+///
+/// A single capability annotation found on a declared member.
+///
+/// The capability-matrix feature id (for example auth.sign_in.sign_up).
+/// Whether the member reports the feature as implemented or partial.
+/// The optional (for implemented) or required (for partial) explanatory note.
+public sealed record CapabilityAnnotation(string Key, CapabilityStatus Status, string? Note);
+
+///
+/// A public-API declaration extracted from the SDK source: a type or a member,
+/// named the way sdk-compliance.yaml spells it (the containing-type chain
+/// with the namespace stripped, for example Constants.Operator.Equals).
+///
+/// The namespace-stripped, containing-type-qualified name.
+/// The matrix symbol kind: class, method, property, function or variable.
+/// Source file the declaration lives in.
+/// 1-based line of the declaration.
+/// Whether the declaration (and its whole containing chain) is public.
+/// Capability annotations attached to this declaration.
+public sealed record DeclaredSymbol(
+ string QualifiedName,
+ string Kind,
+ string FilePath,
+ int Line,
+ bool IsPublic,
+ IReadOnlyList Annotations);
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/FileWorkspace.cs b/packages/CapabilityMatrix/CapabilityMatrix/FileWorkspace.cs
new file mode 100644
index 00000000..da4f8458
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/FileWorkspace.cs
@@ -0,0 +1,42 @@
+namespace Supabase.CapabilityMatrix;
+
+///
+/// The filesystem-backed — the imperative shell. Reads
+/// SDK source under packages/, the canonical spec YAML files, and the
+/// committed compliance file; writes the generated one.
+///
+public sealed class FileWorkspace(string repositoryRoot, string specDirectory, string compliancePath, string outputPath)
+ : IWorkspace
+{
+ public IReadOnlyList ReadSources()
+ {
+ var packages = Path.Combine(repositoryRoot, "packages");
+ if (!Directory.Exists(packages))
+ return [];
+
+ var sources = new List();
+ foreach (var path in Directory.EnumerateFiles(packages, "*.cs", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(repositoryRoot, path).Replace('\\', '/');
+ if (SourceDiscovery.IsSdkSourceFile(relative))
+ sources.Add(new SymbolWalker.SourceFile(relative, File.ReadAllText(path)));
+ }
+
+ return sources;
+ }
+
+ public IReadOnlyList ReadSpecDocuments()
+ {
+ if (!Directory.Exists(specDirectory))
+ throw new DirectoryNotFoundException($"Capability spec directory not found: {specDirectory}");
+
+ return Directory.EnumerateFiles(specDirectory, "*.yaml", SearchOption.TopDirectoryOnly)
+ .OrderBy(path => path, StringComparer.Ordinal)
+ .Select(File.ReadAllText)
+ .ToList();
+ }
+
+ public string ReadComplianceText() => File.ReadAllText(compliancePath);
+
+ public void WriteComplianceText(string content) => File.WriteAllText(outputPath, content);
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/IWorkspace.cs b/packages/CapabilityMatrix/CapabilityMatrix/IWorkspace.cs
new file mode 100644
index 00000000..b943e499
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/IWorkspace.cs
@@ -0,0 +1,22 @@
+namespace Supabase.CapabilityMatrix;
+
+///
+/// The I/O seam for the commands: everything that touches the filesystem goes
+/// through here, so command logic stays pure and testable with an in-memory
+/// workspace. The imperative shell () is the only
+/// implementation that reads or writes disk.
+///
+public interface IWorkspace
+{
+ /// The SDK's own source files, as (repo-relative path, text).
+ IReadOnlyList ReadSources();
+
+ /// The canonical capability-area YAML documents.
+ IReadOnlyList ReadSpecDocuments();
+
+ /// The current committed sdk-compliance.yaml text.
+ string ReadComplianceText();
+
+ /// Writes the generated sdk-compliance.yaml text.
+ void WriteComplianceText(string content);
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/KeyValidator.cs b/packages/CapabilityMatrix/CapabilityMatrix/KeyValidator.cs
new file mode 100644
index 00000000..13bb091a
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/KeyValidator.cs
@@ -0,0 +1,21 @@
+namespace Supabase.CapabilityMatrix;
+
+///
+/// Checks that every capability key used in the source is a real feature id from
+/// the canonical spec — the typo guard run in-PR. Pure.
+///
+public static class KeyValidator
+{
+ public static IReadOnlyList ValidateKeys(
+ IReadOnlyList entries,
+ IReadOnlySet knownIds)
+ {
+ return entries
+ .Where(entry => !knownIds.Contains(entry.Key))
+ .OrderBy(entry => entry.Key, StringComparer.Ordinal)
+ .Select(entry => new ValidationError(
+ $"'{entry.Key}' is not a known capability-matrix feature id " +
+ $"(used by: {string.Join(", ", entry.Symbols)}). Check for a typo against the canonical spec."))
+ .ToList();
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/Locator.cs b/packages/CapabilityMatrix/CapabilityMatrix/Locator.cs
new file mode 100644
index 00000000..1ae5fabf
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/Locator.cs
@@ -0,0 +1,61 @@
+namespace Supabase.CapabilityMatrix;
+
+/// Where a compliance symbol was found (or not) in the SDK source.
+public sealed record SymbolLocation(string Key, string Symbol, string FilePath, int Line);
+
+/// A compliance symbol that resolves to more than one declaration.
+public sealed record AmbiguousSymbol(string Key, string Symbol, IReadOnlyList Candidates);
+
+/// A compliance symbol with no matching declaration in the source.
+public sealed record UnresolvedSymbol(string Key, string Symbol);
+
+/// The result of resolving every symbol in a compliance file against the source.
+public sealed record LocateReport(
+ IReadOnlyList Resolved,
+ IReadOnlyList Ambiguous,
+ IReadOnlyList Unresolved);
+
+///
+/// Resolves each symbols entry of an existing compliance file to its
+/// declaration(s) in the extracted source index. Drives the one-off annotation
+/// pass and exposes stale or mistyped symbols in the hand-written file. Pure.
+///
+public static class Locator
+{
+ public static LocateReport Locate(NormalizedCompliance compliance, IReadOnlyList symbols)
+ {
+ var byName = symbols
+ .GroupBy(symbol => symbol.QualifiedName, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
+
+ var resolved = new List();
+ var ambiguous = new List();
+ var unresolved = new List();
+
+ var requested = compliance.Features
+ .SelectMany(feature => feature.Value.Symbols.Select(symbol => (Key: feature.Key, Symbol: symbol)))
+ .OrderBy(pair => pair.Key, StringComparer.Ordinal)
+ .ThenBy(pair => pair.Symbol, StringComparer.Ordinal);
+
+ foreach (var (key, symbol) in requested)
+ {
+ if (!byName.TryGetValue(symbol, out var matches))
+ {
+ unresolved.Add(new UnresolvedSymbol(key, symbol));
+ continue;
+ }
+
+ var locations = matches
+ .Select(match => new SymbolLocation(key, symbol, match.FilePath, match.Line))
+ .ToList();
+
+ var distinctFiles = matches.Select(match => match.FilePath).Distinct(StringComparer.Ordinal).Count();
+ if (distinctFiles > 1)
+ ambiguous.Add(new AmbiguousSymbol(key, symbol, locations));
+ else
+ resolved.Add(locations[0]);
+ }
+
+ return new LocateReport(resolved, ambiguous, unresolved);
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/Program.cs b/packages/CapabilityMatrix/CapabilityMatrix/Program.cs
new file mode 100644
index 00000000..4edb41c8
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/Program.cs
@@ -0,0 +1,105 @@
+using Supabase.CapabilityMatrix;
+
+return CliEntryPoint.Run(args, Console.Out, Console.Error);
+
+///
+/// Parses command-line arguments, builds a , and
+/// dispatches to the . The only argument-handling and
+/// process-exit logic; the work itself lives in the runner and its pure collaborators.
+///
+internal static class CliEntryPoint
+{
+ private const int UsageError = 2;
+
+ public static int Run(string[] args, TextWriter output, TextWriter error)
+ {
+ if (args.Length == 0)
+ {
+ error.WriteLine(Usage);
+ return UsageError;
+ }
+
+ var command = args[0];
+ var options = ParseOptions(args.Skip(1));
+
+ var root = Option(options, "root", Directory.GetCurrentDirectory());
+ var compliancePath = Option(options, "compliance", Path.Combine(root, "sdk-compliance.yaml"));
+ var outputPath = Option(options, "out", compliancePath);
+ var specDirectory = options.GetValueOrDefault("spec");
+
+ if (command is "generate" or "validate" or "check" && specDirectory is null)
+ {
+ error.WriteLine($"'{command}' requires --spec .");
+ return UsageError;
+ }
+
+ var workspace = new FileWorkspace(root, specDirectory ?? string.Empty, compliancePath, outputPath);
+ var runner = new CommandRunner(workspace, output);
+
+ try
+ {
+ return command switch
+ {
+ "generate" => runner.Generate(),
+ "validate" => runner.Validate(),
+ "locate" => runner.Locate(),
+ "index" => runner.Index(),
+ "check" => runner.Check(),
+ _ => Unknown(command, error),
+ };
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
+ {
+ error.WriteLine($"❌ {exception.Message}");
+ return UsageError;
+ }
+ }
+
+ private static int Unknown(string command, TextWriter error)
+ {
+ error.WriteLine($"Unknown command '{command}'.");
+ error.WriteLine(Usage);
+ return UsageError;
+ }
+
+ private static Dictionary ParseOptions(IEnumerable arguments)
+ {
+ var options = new Dictionary(StringComparer.Ordinal);
+ string? pending = null;
+ foreach (var argument in arguments)
+ {
+ if (argument.StartsWith("--", StringComparison.Ordinal))
+ {
+ pending = argument[2..];
+ options[pending] = string.Empty;
+ }
+ else if (pending is not null)
+ {
+ options[pending] = argument;
+ pending = null;
+ }
+ }
+
+ return options;
+ }
+
+ private static string Option(IReadOnlyDictionary options, string name, string fallback) =>
+ options.TryGetValue(name, out var value) && value.Length > 0 ? value : fallback;
+
+ private const string Usage =
+ """
+ Usage: Supabase.CapabilityMatrix [options]
+
+ Commands:
+ generate Write sdk-compliance.yaml from the in-code capability annotations.
+ validate Check the annotations (known keys, no conflicts); no file written.
+ locate Resolve the committed file's symbols against the source.
+ check Fail if the committed file is out of sync with the annotations.
+
+ Options:
+ --root Repository root (default: current directory).
+ --spec Canonical capabilities/ directory (required: generate, validate, check).
+ --compliance Committed sdk-compliance.yaml (default: /sdk-compliance.yaml).
+ --out Output path for generate (default: --compliance).
+ """;
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/SourceDiscovery.cs b/packages/CapabilityMatrix/CapabilityMatrix/SourceDiscovery.cs
new file mode 100644
index 00000000..f2f495f5
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/SourceDiscovery.cs
@@ -0,0 +1,30 @@
+namespace Supabase.CapabilityMatrix;
+
+///
+/// Decides which repository files make up the SDK's own source. Pure: the rule is a
+/// function of the repo-relative path, so it is unit-testable without a filesystem.
+///
+public static class SourceDiscovery
+{
+ private static readonly string[] ExcludedSegments = ["bin", "obj"];
+
+ ///
+ /// True for a production SDK C# file: under packages/, not in a build output
+ /// directory, not test code, and not the capability-matrix tool itself.
+ ///
+ public static bool IsSdkSourceFile(string relativePath)
+ {
+ if (!relativePath.EndsWith(".cs", StringComparison.Ordinal))
+ return false;
+
+ var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length < 2 || segments[0] != "packages")
+ return false;
+
+ if (segments[1] == "CapabilityMatrix")
+ return false;
+
+ return !segments.Any(segment =>
+ ExcludedSegments.Contains(segment) || segment.EndsWith(".Tests", StringComparison.Ordinal));
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/SpecModel.cs b/packages/CapabilityMatrix/CapabilityMatrix/SpecModel.cs
new file mode 100644
index 00000000..6751a425
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/SpecModel.cs
@@ -0,0 +1,92 @@
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace Supabase.CapabilityMatrix;
+
+/// A capability group within an area, carrying the ordered feature ids assigned to it.
+public sealed record GroupSpec(string Id, string Title, IReadOnlyList FeatureIds);
+
+/// A product area (auth, database, ...) with its ordered groups and feature ids.
+public sealed record AreaSpec(
+ string Id,
+ string Title,
+ IReadOnlyList Groups,
+ IReadOnlyList UngroupedFeatureIds);
+
+///
+/// The canonical capability spec loaded from the matrix repo's
+/// capabilities/*.yaml files. Provides the set of known feature ids (for
+/// validation) and the area → group → feature ordering (for emitting a stable file).
+///
+public sealed class SpecModel
+{
+ public IReadOnlyList Areas { get; }
+ public IReadOnlySet KnownIds { get; }
+
+ private SpecModel(IReadOnlyList areas, IReadOnlySet knownIds)
+ {
+ this.Areas = areas;
+ this.KnownIds = knownIds;
+ }
+
+ /// Parses the given capability-area YAML documents into a spec model.
+ public static SpecModel Parse(IEnumerable areaDocuments)
+ {
+ var deserializer = new DeserializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build();
+
+ var areas = new List();
+ var knownIds = new HashSet(StringComparer.Ordinal);
+
+ foreach (var document in areaDocuments)
+ {
+ var dto = deserializer.Deserialize(document);
+ if (dto?.Area is null)
+ continue;
+
+ var features = dto.Features ?? [];
+ foreach (var feature in features.Where(f => f.Id is not null))
+ knownIds.Add(feature.Id!);
+
+ var groups = (dto.Groups ?? [])
+ .Where(group => group.Id is not null)
+ .Select(group => new GroupSpec(
+ group.Id!,
+ group.Title ?? group.Id!,
+ features.Where(f => f.Id is not null && f.Group == group.Id).Select(f => f.Id!).ToList()))
+ .ToList();
+
+ var ungrouped = features
+ .Where(f => f.Id is not null && string.IsNullOrEmpty(f.Group))
+ .Select(f => f.Id!)
+ .ToList();
+
+ areas.Add(new AreaSpec(dto.Area, dto.Title ?? dto.Area, groups, ungrouped));
+ }
+
+ areas = areas.OrderBy(area => area.Id, StringComparer.Ordinal).ToList();
+ return new SpecModel(areas, knownIds);
+ }
+
+ private sealed class AreaFileDto
+ {
+ public string? Area { get; set; }
+ public string? Title { get; set; }
+ public List? Groups { get; set; }
+ public List? Features { get; set; }
+ }
+
+ private sealed class GroupDto
+ {
+ public string? Id { get; set; }
+ public string? Title { get; set; }
+ }
+
+ private sealed class FeatureDto
+ {
+ public string? Id { get; set; }
+ public string? Group { get; set; }
+ }
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/SymbolWalker.cs b/packages/CapabilityMatrix/CapabilityMatrix/SymbolWalker.cs
new file mode 100644
index 00000000..1f8bdb5b
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/SymbolWalker.cs
@@ -0,0 +1,229 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Supabase.CapabilityMatrix;
+
+///
+/// Extracts the SDK's declared types and members from C# source text, together
+/// with any capability annotations on them. Pure over its input: it parses
+/// syntax only (no compilation, no semantic model, no assembly loading), so the
+/// same list always yields the same symbols.
+///
+public static class SymbolWalker
+{
+ /// A source file as (repo-relative) path and its text.
+ public sealed record SourceFile(string Path, string Text);
+
+ private const string ImplementedName = "ImplementedCapability";
+ private const string PartialName = "PartialCapability";
+
+ ///
+ /// Walks every file and returns one per declared
+ /// type and member, ordered by qualified name then file for determinism.
+ ///
+ public static IReadOnlyList Extract(IEnumerable files)
+ {
+ var symbols = new List();
+ foreach (var file in files)
+ {
+ var root = CSharpSyntaxTree.ParseText(file.Text).GetCompilationUnitRoot();
+ foreach (var member in root.Members)
+ WalkMember(member, parentTypes: [], parentPublic: true, file.Path, symbols);
+ }
+
+ return symbols
+ .OrderBy(s => s.QualifiedName, StringComparer.Ordinal)
+ .ThenBy(s => s.FilePath, StringComparer.Ordinal)
+ .ThenBy(s => s.Line)
+ .ToList();
+ }
+
+ private static void WalkMember(
+ MemberDeclarationSyntax member,
+ IReadOnlyList parentTypes,
+ bool parentPublic,
+ string filePath,
+ List output)
+ {
+ switch (member)
+ {
+ case BaseNamespaceDeclarationSyntax ns:
+ // Namespaces are stripped from the qualified name; descend without pushing.
+ foreach (var child in ns.Members)
+ WalkMember(child, parentTypes, parentPublic, filePath, output);
+ break;
+
+ case TypeDeclarationSyntax type:
+ WalkType(type, type.Identifier.Text, type.Keyword, type.Members, parentTypes, parentPublic, filePath, output);
+ break;
+
+ case EnumDeclarationSyntax enumType:
+ WalkEnum(enumType, parentTypes, parentPublic, filePath, output);
+ break;
+ }
+ }
+
+ private static void WalkType(
+ TypeDeclarationSyntax type,
+ string name,
+ SyntaxToken keyword,
+ SyntaxList members,
+ IReadOnlyList parentTypes,
+ bool parentPublic,
+ string filePath,
+ List output)
+ {
+ var isInterface = keyword.IsKind(SyntaxKind.InterfaceKeyword);
+ var chain = Append(parentTypes, name);
+ var isPublic = parentPublic && IsTypePublic(type.Modifiers);
+
+ output.Add(Build(Join(chain), "class", type.Identifier, isPublic, type.AttributeLists, filePath));
+
+ foreach (var child in members)
+ {
+ switch (child)
+ {
+ case BaseTypeDeclarationSyntax:
+ WalkMember(child, chain, isPublic, filePath, output);
+ break;
+ case MethodDeclarationSyntax method:
+ Emit(output, chain, method.Identifier, "method", MemberIsPublic(method.Modifiers, isInterface), method.AttributeLists, isPublic, filePath);
+ break;
+ case PropertyDeclarationSyntax property:
+ Emit(output, chain, property.Identifier, "property", MemberIsPublic(property.Modifiers, isInterface), property.AttributeLists, isPublic, filePath);
+ break;
+ case FieldDeclarationSyntax field:
+ foreach (var variable in field.Declaration.Variables)
+ Emit(output, chain, variable.Identifier, "property", MemberIsPublic(field.Modifiers, isInterface), field.AttributeLists, isPublic, filePath);
+ break;
+ }
+ }
+ }
+
+ private static void WalkEnum(
+ EnumDeclarationSyntax enumType,
+ IReadOnlyList parentTypes,
+ bool parentPublic,
+ string filePath,
+ List output)
+ {
+ var chain = Append(parentTypes, enumType.Identifier.Text);
+ var isPublic = parentPublic && IsTypePublic(enumType.Modifiers);
+ output.Add(Build(Join(chain), "class", enumType.Identifier, isPublic, enumType.AttributeLists, filePath));
+
+ foreach (var value in enumType.Members)
+ Emit(output, chain, value.Identifier, "property", memberPublic: isPublic, value.AttributeLists, isPublic, filePath);
+ }
+
+ private static void Emit(
+ List output,
+ IReadOnlyList chain,
+ SyntaxToken identifier,
+ string kind,
+ bool memberPublic,
+ SyntaxList attributeLists,
+ bool chainPublic,
+ string filePath)
+ {
+ var name = Join(Append(chain, identifier.Text));
+ output.Add(Build(name, kind, identifier, chainPublic && memberPublic, attributeLists, filePath));
+ }
+
+ private static DeclaredSymbol Build(
+ string qualifiedName,
+ string kind,
+ SyntaxToken anchor,
+ bool isPublic,
+ SyntaxList attributeLists,
+ string filePath)
+ {
+ var line = anchor.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
+ return new DeclaredSymbol(qualifiedName, kind, filePath, line, isPublic, ReadAnnotations(attributeLists));
+ }
+
+ private static IReadOnlyList ReadAnnotations(SyntaxList attributeLists)
+ {
+ var annotations = new List();
+ foreach (var list in attributeLists)
+ {
+ foreach (var attribute in list.Attributes)
+ {
+ var status = StatusOf(SimpleName(attribute.Name));
+ if (status is null)
+ continue;
+
+ var (key, note) = ReadArguments(attribute.ArgumentList);
+ if (key is not null)
+ annotations.Add(new CapabilityAnnotation(key, status.Value, note));
+ }
+ }
+
+ return annotations;
+ }
+
+ private static CapabilityStatus? StatusOf(string simpleName) => simpleName switch
+ {
+ ImplementedName => CapabilityStatus.Implemented,
+ PartialName => CapabilityStatus.PartiallyImplemented,
+ _ => null,
+ };
+
+ private static (string? Key, string? Note) ReadArguments(AttributeArgumentListSyntax? argumentList)
+ {
+ if (argumentList is null)
+ return (null, null);
+
+ string? key = null;
+ string? note = null;
+ var positional = 0;
+ foreach (var argument in argumentList.Arguments)
+ {
+ var value = LiteralValue(argument.Expression);
+ var named = argument.NameColon?.Name.Identifier.Text ?? argument.NameEquals?.Name.Identifier.Text;
+ if (named == "note")
+ note = value;
+ else if (named == "key")
+ key = value;
+ else if (positional++ == 0)
+ key = value;
+ else
+ note = value;
+ }
+
+ return (key, note);
+ }
+
+ private static string? LiteralValue(ExpressionSyntax expression) =>
+ expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)
+ ? literal.Token.ValueText
+ : null;
+
+ private static string SimpleName(NameSyntax name)
+ {
+ var text = name switch
+ {
+ QualifiedNameSyntax qualified => qualified.Right.Identifier.Text,
+ SimpleNameSyntax simple => simple.Identifier.Text,
+ _ => name.ToString(),
+ };
+ return text.EndsWith("Attribute", StringComparison.Ordinal)
+ ? text[..^"Attribute".Length]
+ : text;
+ }
+
+ private static bool IsTypePublic(SyntaxTokenList modifiers) =>
+ modifiers.Any(SyntaxKind.PublicKeyword);
+
+ private static bool MemberIsPublic(SyntaxTokenList modifiers, bool isInterfaceMember)
+ {
+ if (modifiers.Any(SyntaxKind.PrivateKeyword) || modifiers.Any(SyntaxKind.ProtectedKeyword) || modifiers.Any(SyntaxKind.InternalKeyword))
+ return false;
+ // Interface members are public by default; class/struct members are not.
+ return isInterfaceMember || modifiers.Any(SyntaxKind.PublicKeyword);
+ }
+
+ private static IReadOnlyList Append(IReadOnlyList list, string value) => [.. list, value];
+
+ private static string Join(IReadOnlyList chain) => string.Join('.', chain);
+}
diff --git a/packages/CapabilityMatrix/CapabilityMatrix/YamlEmitter.cs b/packages/CapabilityMatrix/CapabilityMatrix/YamlEmitter.cs
new file mode 100644
index 00000000..a5f7bfa4
--- /dev/null
+++ b/packages/CapabilityMatrix/CapabilityMatrix/YamlEmitter.cs
@@ -0,0 +1,85 @@
+using System.Text;
+
+namespace Supabase.CapabilityMatrix;
+
+///
+/// Renders compliance entries to the exact sdk-compliance.yaml layout: an
+/// sdk header, a features: map grouped under per-area banners, a blank
+/// line between groups, and symbols listed one per line. Pure — a purpose-built
+/// serializer rather than a YAML library so the banners and grouping are reproducible.
+///
+public static class YamlEmitter
+{
+ private const string Sdk = "csharp";
+ private const int BannerWidth = 79;
+
+ public static string Emit(IReadOnlyList entries, SpecModel spec)
+ {
+ var byKey = entries.ToDictionary(entry => entry.Key, StringComparer.Ordinal);
+ var lines = new List { $"sdk: {Sdk}", "features:" };
+
+ foreach (var area in spec.Areas)
+ {
+ var groupBlocks = GroupBlocks(area, byKey).ToList();
+ if (groupBlocks.Count == 0)
+ continue;
+
+ lines.Add(string.Empty);
+ lines.Add(Banner(area.Title));
+ for (var index = 0; index < groupBlocks.Count; index++)
+ {
+ lines.Add(string.Empty);
+ lines.AddRange(groupBlocks[index]);
+ }
+ }
+
+ return string.Join('\n', lines) + '\n';
+ }
+
+ private static IEnumerable> GroupBlocks(
+ AreaSpec area,
+ IReadOnlyDictionary byKey)
+ {
+ var featureGroups = area.Groups
+ .Select(group => group.FeatureIds)
+ .Append(area.UngroupedFeatureIds);
+
+ foreach (var featureIds in featureGroups)
+ {
+ var block = featureIds
+ .Where(byKey.ContainsKey)
+ .SelectMany(id => EntryLines(byKey[id]))
+ .ToList();
+ if (block.Count > 0)
+ yield return block;
+ }
+ }
+
+ private static IEnumerable EntryLines(ComplianceEntry entry)
+ {
+ yield return $" {entry.Key}:";
+ yield return $" status: {entry.Status.ToYamlValue()}";
+ if (entry.NoPublicSymbols)
+ yield return " no_public_symbols: true";
+ if (!string.IsNullOrEmpty(entry.Note))
+ yield return $" note: \"{Escape(entry.Note)}\"";
+ if (entry.Symbols.Count > 0)
+ {
+ yield return " symbols:";
+ foreach (var symbol in entry.Symbols)
+ yield return $" - {symbol}";
+ }
+ }
+
+ private static string Banner(string title)
+ {
+ var prefix = $" # ── {title} ";
+ var builder = new StringBuilder(prefix);
+ while (builder.Length < BannerWidth)
+ builder.Append('─');
+ return builder.ToString();
+ }
+
+ private static string Escape(string note) =>
+ note.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal);
+}
diff --git a/packages/Core/Core/Attributes/CapabilityAttributes.cs b/packages/Core/Core/Attributes/CapabilityAttributes.cs
new file mode 100644
index 00000000..08de1f70
--- /dev/null
+++ b/packages/Core/Core/Attributes/CapabilityAttributes.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Supabase.Core.Attributes;
+
+///
+/// Marks a public member as the implementation of a Supabase capability-matrix
+/// feature. The capability-matrix tooling reads these annotations to generate
+/// sdk-compliance.yaml, so the mapping lives next to the code it describes
+/// and survives renames instead of drifting in a hand-maintained file.
+///
+///
+/// The status reported to the matrix is the concrete attribute type:
+/// or
+/// .
+///
+[ExcludeFromCodeCoverage]
+internal abstract class CapabilityAttribute : Attribute
+{
+ ///
+ /// The capability-matrix feature id this member implements, in
+ /// area.group.feature form (for example
+ /// auth.sign_in.sign_in_with_password).
+ ///
+ public string Key { get; }
+
+ ///
+ /// An optional human-readable note surfaced in sdk-compliance.yaml,
+ /// typically explaining how this SDK diverges from the canonical behaviour.
+ ///
+ public string? Note { get; }
+
+ /// The capability-matrix feature id this member implements.
+ /// An optional note surfaced in the generated compliance file.
+ protected CapabilityAttribute(string key, string? note)
+ {
+ this.Key = key;
+ this.Note = note;
+ }
+}
+
+///
+/// Marks a public member as a fully implemented Supabase capability-matrix feature.
+///
+[AttributeUsage(
+ AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum |
+ AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field,
+ AllowMultiple = true)]
+[ExcludeFromCodeCoverage]
+internal sealed class ImplementedCapabilityAttribute : CapabilityAttribute
+{
+ /// The capability-matrix feature id this member implements.
+ ///
+ /// An optional note surfaced in the generated compliance file, for an
+ /// implemented feature whose behaviour still warrants a caveat.
+ ///
+ public ImplementedCapabilityAttribute(string key, string? note = null)
+ : base(key, note)
+ {
+ }
+}
+
+///
+/// Marks a public member as a partially implemented Supabase capability-matrix
+/// feature. The note is required: the matrix rejects a partially_implemented
+/// entry that does not explain what is missing, so the constructor demands it.
+///
+[AttributeUsage(
+ AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum |
+ AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field,
+ AllowMultiple = true)]
+[ExcludeFromCodeCoverage]
+internal sealed class PartialCapabilityAttribute : CapabilityAttribute
+{
+ /// The capability-matrix feature id this member partially implements.
+ /// A required note explaining what is missing relative to the canonical behaviour.
+ public PartialCapabilityAttribute(string key, string note)
+ : base(key, note)
+ {
+ }
+}
diff --git a/packages/Core/Core/Core.csproj b/packages/Core/Core/Core.csproj
index d113daba..86f9c1d0 100644
--- a/packages/Core/Core/Core.csproj
+++ b/packages/Core/Core/Core.csproj
@@ -44,9 +44,12 @@
+
+
+
diff --git a/packages/Functions/Functions/Client.cs b/packages/Functions/Functions/Client.cs
index 32cf8b9e..92c74839 100644
--- a/packages/Functions/Functions/Client.cs
+++ b/packages/Functions/Functions/Client.cs
@@ -11,6 +11,7 @@
using System.Threading.Tasks;
using System.Web;
using Supabase.Core;
+using Supabase.Core.Attributes;
using Supabase.Core.Diagnostics;
using Supabase.Core.Extensions;
using Supabase.Core.Http;
@@ -45,6 +46,7 @@ public partial class Client : IFunctionsClient
///
/// Headers specified in the method parameters will ALWAYS take precedence over headers returned by this function.
///
+ [PartialCapability("functions.invocation.set_auth_token", "No stateful SetAuth; bearer token is supplied per invocation or via a GetHeaders provider hook, not stored for all subsequent calls.")]
public Func>? GetHeaders { get; set; }
/// The options this client was constructed with.
@@ -75,6 +77,7 @@ public Client(string baseUrl, FunctionRegion? region = null, ClientOptions? opti
/// Options
///
///
+ [ImplementedCapability("functions.invocation.invoke", "Three return shapes — string, deserialized T, and raw HttpContent.")]
public async Task RawInvoke(
string functionName,
string? token = null,
@@ -96,6 +99,7 @@ public async Task RawInvoke(
/// Invocation options.
/// Cancels the request and error-body reads. Cancel successful body reads separately.
/// The response, which the caller must dispose.
+ [ImplementedCapability("functions.invocation.streaming_response", "InvokeStream returns response bodies for incremental reading.")]
public Task InvokeStream(
string functionName,
string? token = null,
@@ -111,6 +115,7 @@ public Task InvokeStream(
/// Options
///
///
+ [ImplementedCapability("functions.invocation.invoke", "Three return shapes — string, deserialized T, and raw HttpContent.")]
public async Task Invoke(
string functionName,
string? token = null,
diff --git a/packages/Functions/Functions/InvokeFunctionOptions.cs b/packages/Functions/Functions/InvokeFunctionOptions.cs
index 162258bd..ed05ad33 100644
--- a/packages/Functions/Functions/InvokeFunctionOptions.cs
+++ b/packages/Functions/Functions/InvokeFunctionOptions.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json.Serialization;
+using Supabase.Core.Attributes;
namespace Supabase.Functions;
@@ -30,22 +31,26 @@ public class InvokeFunctionOptions
/// Timout value for HttpClient Requests, defaults to 100s.
/// https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.timeout?view=net-8.0#remarks
///
+ [PartialCapability("functions.invocation.timeout", "Per-invoke HttpTimeout exists, but it is not composable with a caller-supplied cancellation signal (Invoke accepts no CancellationToken).")]
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(100);
///
/// Http method of the Request
///
+ [ImplementedCapability("functions.invocation.method_override", "InvokeFunctionOptions.HttpMethod is wired into the request (default POST). [feature-parity.md called this POST-only; the source disagrees as of 2026-06-29.]")]
public HttpMethod HttpMethod { get; set; } = HttpMethod.Post;
///
/// Region of the request
///
+ [ImplementedCapability("functions.invocation.region_selection", "Full AWS region list; per-call override or client default; emits x-region.")]
public FunctionRegion? FunctionRegion { get; set; } = null;
}
///
/// Define the region for requests
///
+ [ImplementedCapability("functions.invocation.region_selection", "Full AWS region list; per-call override or client default; emits x-region.")]
public class FunctionRegion : IEquatable
{
private readonly string region;
diff --git a/packages/Gotrue/Gotrue/AdminClient.cs b/packages/Gotrue/Gotrue/AdminClient.cs
index 2a5bc3dd..410f61dc 100644
--- a/packages/Gotrue/Gotrue/AdminClient.cs
+++ b/packages/Gotrue/Gotrue/AdminClient.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
+using Supabase.Core.Attributes;
using Supabase.Gotrue.Interfaces;
using Supabase.Gotrue.Mfa;
using Supabase.Gotrue.Responses;
@@ -54,12 +55,14 @@ public Func>? GetHeaders
private readonly string serviceKey;
///
+ [ImplementedCapability("auth.admin.get_user")]
public Task GetUserById(string userId) => this.api.GetUserById(this.serviceKey, userId);
///
public Task GetUser(string jwt) => this.api.GetUser(jwt);
///
+ [ImplementedCapability("auth.admin.invite_user")]
public async Task InviteUserByEmail(string email, InviteUserByEmailOptions? options = null)
{
var response = await this.api.InviteUserByEmail(email, this.serviceKey, options);
@@ -68,6 +71,7 @@ public async Task InviteUserByEmail(string email, InviteUserByEmailOptions
}
///
+ [PartialCapability("auth.admin.delete_user", "Hard-delete only; no shouldSoftDelete flag (JS deleteUser(id, shouldSoftDelete)).")]
public async Task DeleteUser(string uid, bool shouldSoftDelete = false)
{
var result = await this.api.DeleteUser(uid, this.serviceKey, shouldSoftDelete);
@@ -76,6 +80,7 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false)
}
///
+ [ImplementedCapability("auth.admin.create_user")]
public Task CreateUser(string email, string password, AdminUserAttributes? attributes = null)
{
attributes ??= new AdminUserAttributes();
@@ -89,12 +94,15 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false)
public Task CreateUser(AdminUserAttributes attributes) => this.api.CreateUser(this.serviceKey, attributes);
///
+ [ImplementedCapability("auth.admin.list_users")]
public Task?> ListUsers(string? filter = null, string? sortBy = null, Constants.SortOrder sortOrder = Constants.SortOrder.Descending, int? page = null, int? perPage = null) => this.api.ListUsers(this.serviceKey, filter, sortBy, sortOrder, page, perPage);
///
+ [ImplementedCapability("auth.admin.update_user")]
public Task UpdateUserById(string userId, AdminUserAttributes userData) => this.api.UpdateUserById(this.serviceKey, userId, userData);
///
+ [ImplementedCapability("auth.admin.generate_link")]
public async Task GenerateLink(GenerateLinkOptions options)
{
var response = await this.api.GenerateLink(this.serviceKey, options);
@@ -107,6 +115,7 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false)
}
///
+ [ImplementedCapability("auth.admin.list_mfa_factors")]
public async Task ListFactors(MfaAdminListFactorsParams listFactorsParams)
{
var response = await this.api.ListFactors(this.serviceKey, listFactorsParams);
@@ -124,6 +133,7 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false)
return listFactorsResponse;
}
+ [ImplementedCapability("auth.admin.delete_mfa_factor")]
public async Task DeleteFactor(MfaAdminDeleteFactorParams deleteFactorParams) => await this.api.DeleteFactor(this.serviceKey, deleteFactorParams);
///
diff --git a/packages/Gotrue/Gotrue/Client.cs b/packages/Gotrue/Gotrue/Client.cs
index a5c7a7f5..1a9a9d92 100644
--- a/packages/Gotrue/Gotrue/Client.cs
+++ b/packages/Gotrue/Gotrue/Client.cs
@@ -8,6 +8,7 @@
using System.Threading;
using System.Threading.Tasks;
using System.Web;
+using Supabase.Core.Attributes;
using Supabase.Core.Diagnostics;
using Supabase.Gotrue.Claims;
using Supabase.Gotrue.Exceptions;
@@ -116,6 +117,7 @@ public Client(ClientOptions? options = null)
public TokenRefresh? TokenRefresh { get; private set; }
///
+ [ImplementedCapability("client.session_management.custom_storage")]
public void SetPersistence(IGotrueSessionPersistence persistence) =>
this.sessionPersistence = new PersistenceListener(persistence);
@@ -123,9 +125,11 @@ public void SetPersistence(IGotrueSessionPersistence persistence) =>
public ClientOptions Options { get; }
///
+ [ImplementedCapability("auth.session.get_user")]
public Task GetUser(string jwt) => this.api.GetUser(jwt);
///
+ [ImplementedCapability("auth.session.subscribe_auth_events")]
public void NotifyAuthStateChange(AuthState stateChanged) => this.InvokeStateChangedHandlers(stateChanged);
///
@@ -177,9 +181,11 @@ private void InvokeStateChangedHandlers(AuthState stateChanged)
}
///
+ [ImplementedCapability("auth.session.get_user")]
public User? CurrentUser => this.CurrentSession?.User;
///
+ [ImplementedCapability("auth.session.subscribe_auth_events")]
public void AddStateChangedListener(IGotrueClient.AuthEventHandler authEventHandler)
{
if (this.authEventHandlers.Contains(authEventHandler))
@@ -190,6 +196,7 @@ public void AddStateChangedListener(IGotrueClient.AuthEventHandle
}
///
+ [ImplementedCapability("auth.session.subscribe_auth_events")]
public void RemoveStateChangedListener(IGotrueClient.AuthEventHandler authEventHandler)
{
if (!this.authEventHandlers.Contains(authEventHandler))
@@ -206,9 +213,11 @@ public void RemoveStateChangedListener(IGotrueClient.AuthEventHan
public bool Online { get; set; } = true;
///
+ [ImplementedCapability("auth.session.get_session")]
public Session? CurrentSession { get; private set; }
///
+ [PartialCapability("auth.sign_in.sign_up", "API mirrors signUp, but on a session-returning sign-up (confirmations off) C# does NOT adopt the session — CurrentUser/CurrentSession stay null, so 'signed up' != 'signed in' as in JS.")]
public Task SignUp(string email, string password, SignUpOptions? options = null) =>
this.SignUp(SignUpType.Email, email, password, options);
@@ -238,6 +247,8 @@ public void RemoveStateChangedListener(IGotrueClient.AuthEventHan
}
///
+ [ImplementedCapability("auth.sign_in.sign_in_with_oauth")]
+ [ImplementedCapability("auth.sign_in.sign_in_with_password")]
public async Task SignIn(string email, SignInOptions? options = null)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SendMagicLink);
@@ -250,6 +261,7 @@ public async Task SignIn(string email, SignInOptions? options = null)
}
///
+ [ImplementedCapability("auth.sign_in.sign_in_with_id_token")]
public async Task SignInWithIdToken(Provider provider, string idToken, string? accessToken = null, string? nonce = null,
string? captchaToken = null)
{
@@ -266,6 +278,7 @@ public async Task SignIn(string email, SignInOptions? options = null)
}
///
+ [ImplementedCapability("auth.sign_in.sign_in_with_otp")]
public async Task SignInWithOtp(SignInWithPasswordlessEmailOptions options)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SignInWithOtp);
@@ -290,12 +303,14 @@ public async Task SignInWithOtp(SignInWithPasswordlessP
}
///
+ [ImplementedCapability("auth.sign_in.sign_in_with_otp")]
public Task SendMagicLink(string email, SignInOptions? options = null) => this.SignIn(email, options);
///
public Task SignIn(string email, string password) => this.SignIn(SignInType.Email, email, password);
///
+ [ImplementedCapability("auth.sign_in.sign_in_with_password")]
public Task SignInWithPassword(string email, string password) => this.SignIn(email, password);
///
@@ -371,6 +386,7 @@ public async Task SignIn(Provider provider, SignInOptions? op
}
///
+ [ImplementedCapability("auth.sign_in.sign_in_with_sso")]
public async Task SignInWithSSO(Guid providerId, SignInWithSSOOptions? options = null)
{
if (!this.Online)
@@ -391,6 +407,7 @@ public async Task SignIn(Provider provider, SignInOptions? op
}
///
+ [ImplementedCapability("auth.sign_in.sign_in_anonymously")]
public async Task SignInAnonymously(SignInAnonymouslyOptions? options = null)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SignInAnonymously);
@@ -405,6 +422,7 @@ public async Task SignIn(Provider provider, SignInOptions? op
}
///
+ [ImplementedCapability("auth.sign_in.verify_otp")]
public async Task VerifyOTP(string phone, string token, MobileOtpType type = MobileOtpType.SMS)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.VerifyOtp);
@@ -443,6 +461,7 @@ public async Task SignIn(Provider provider, SignInOptions? op
}
///
+ [ImplementedCapability("auth.sign_in.verify_otp")]
public async Task VerifyTokenHash(string tokenHash, EmailOtpType type = EmailOtpType.Email)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.VerifyTokenHash);
@@ -461,6 +480,7 @@ public async Task SignIn(Provider provider, SignInOptions? op
}
///
+ [ImplementedCapability("auth.identities.link_identity")]
public Task LinkIdentity(Provider provider, SignInOptions options)
{
if (!this.Online)
@@ -501,6 +521,7 @@ public Task LinkIdentity(Provider provider, SignInOptions opt
}
///
+ [ImplementedCapability("auth.identities.unlink_identity")]
public Task UnlinkIdentity(UserIdentity userIdentity)
{
if (!this.Online)
@@ -515,6 +536,7 @@ public Task UnlinkIdentity(UserIdentity userIdentity)
}
///
+ [ImplementedCapability("auth.session.sign_out")]
public async Task SignOut(SignOutScope scope = SignOutScope.Global)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SignOut);
@@ -536,6 +558,7 @@ public async Task SignOut(SignOutScope scope = SignOutScope.Global)
}
///
+ [ImplementedCapability("auth.session.update_user")]
public async Task Update(UserAttributes attributes)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.UpdateUser);
@@ -561,6 +584,7 @@ public async Task SignOut(SignOutScope scope = SignOutScope.Global)
}
///
+ [ImplementedCapability("auth.sign_in.reauthenticate")]
public async Task Reauthenticate()
{
if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken))
@@ -576,6 +600,7 @@ public async Task Reauthenticate()
}
///
+ [ImplementedCapability("auth.sign_in.send_password_reset_email")]
public async Task ResetPasswordForEmail(string email)
{
var result = await this.api.ResetPasswordForEmail(email);
@@ -591,6 +616,7 @@ public async Task ResetPasswordForEmail(ResetPasswor
}
///
+ [ImplementedCapability("auth.session.refresh_session")]
public async Task RefreshSession()
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.RefreshSession);
@@ -613,6 +639,7 @@ public async Task ResetPasswordForEmail(ResetPasswor
}
///
+ [ImplementedCapability("auth.session.set_session")]
public async Task SetSession(string accessToken, string refreshToken, bool forceAccessTokenRefresh = false)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SetSession);
@@ -720,6 +747,7 @@ public async Task SetSession(string accessToken, string refreshToken, b
}
///
+ [ImplementedCapability("auth.session.get_session")]
public async Task RetrieveSessionAsync()
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.RetrieveSession);
@@ -763,6 +791,7 @@ public async Task SetSession(string accessToken, string refreshToken, b
}
///
+ [ImplementedCapability("auth.sign_in.exchange_code_for_session")]
public async Task ExchangeCodeForSession(string codeVerifier, string authCode)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.ExchangeCode);
@@ -796,6 +825,7 @@ public void AddDebugListener(Action listener)
}
///
+ [ImplementedCapability("auth.session.refresh_session")]
public async Task RefreshToken(string accessToken, string refreshToken)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.RefreshToken);
@@ -908,6 +938,7 @@ private async Task RefreshCurrentSession(string accessToken, string refreshToken
}
///
+ [ImplementedCapability("auth.session.get_session")]
public void LoadSession()
{
if (this.sessionPersistence == null)
@@ -991,6 +1022,7 @@ public void StartAutoRefresh()
public void StopAutoRefresh() => this.TokenRefresh?.Stop();
///
+ [ImplementedCapability("auth.mfa.enroll")]
public async Task Enroll(MfaEnrollParams mfaEnrollParams)
{
if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken))
@@ -1005,6 +1037,7 @@ public void StartAutoRefresh()
}
///
+ [ImplementedCapability("auth.mfa.challenge")]
public async Task Challenge(MfaChallengeParams mfaChallengeParams)
{
if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken))
@@ -1019,6 +1052,7 @@ public void StartAutoRefresh()
}
///
+ [ImplementedCapability("auth.mfa.verify")]
public async Task Verify(MfaVerifyParams mfaVerifyParams)
{
if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken))
@@ -1048,6 +1082,7 @@ public void StartAutoRefresh()
}
///
+ [ImplementedCapability("auth.mfa.challenge_and_verify")]
public async Task ChallengeAndVerify(MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams)
{
if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken))
@@ -1090,6 +1125,7 @@ public void StartAutoRefresh()
}
///
+ [ImplementedCapability("auth.mfa.unenroll")]
public async Task Unenroll(MfaUnenrollParams mfaUnenrollParams)
{
if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken))
@@ -1104,6 +1140,7 @@ public void StartAutoRefresh()
}
///
+ [ImplementedCapability("auth.mfa.list_factors")]
public Task ListFactors()
{
if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken))
@@ -1118,6 +1155,7 @@ public void StartAutoRefresh()
return Task.FromResult(response);
}
+ [ImplementedCapability("auth.mfa.get_authenticator_assurance_level")]
public Task GetAuthenticatorAssuranceLevel()
{
if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken))
diff --git a/packages/Gotrue/Gotrue/ClientOptions.cs b/packages/Gotrue/Gotrue/ClientOptions.cs
index 78322056..ef491cc8 100644
--- a/packages/Gotrue/Gotrue/ClientOptions.cs
+++ b/packages/Gotrue/Gotrue/ClientOptions.cs
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
+using Supabase.Core.Attributes;
using Supabase.Core.Http;
using static Supabase.Gotrue.Constants;
@@ -39,6 +40,7 @@ public class ClientOptions
///
/// Should the Client automatically handle refreshing the User's Token?
///
+ [PartialCapability("auth.session.auto_refresh", "Refreshes on an internal background timer (ClientOptions.AutoRefreshToken), but does not expose JS's explicit startAutoRefresh/stopAutoRefresh control. Implicit-only.")]
public bool AutoRefreshToken { get; set; } = true;
///
diff --git a/packages/Gotrue/Gotrue/Constants.cs b/packages/Gotrue/Gotrue/Constants.cs
index fac3e04b..d34031f3 100644
--- a/packages/Gotrue/Gotrue/Constants.cs
+++ b/packages/Gotrue/Gotrue/Constants.cs
@@ -1,167 +1,167 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using Supabase.Core.Attributes;
#pragma warning disable CS1591
-namespace Supabase.Gotrue
+namespace Supabase.Gotrue;
+
+///
+/// Constants used throughout the Gotrue Client
+///
+public static class Constants
{
- ///
- /// Constants used throughout the Gotrue Client
- ///
- public static class Constants
- {
- ///
- /// URL for the Gotrue server. Defaults to localhost:9999.
- ///
- public const string GOTRUE_URL = "http://localhost:9999";
- public const string AUDIENCE = "";
- public const int EXPIRY_MARGIN = 60 * 1000;
- public const string STORAGE_KEY = "supabase.auth.token";
- public static readonly Dictionary CookieOptions = new Dictionary
- {
- { "name", "sb:token" },
- { "lifetime", 60 * 60 * 8 },
- { "domain", "" },
- { "path", "/" },
- { "sameSite", "lax" }
- };
+ ///
+ /// URL for the Gotrue server. Defaults to localhost:9999.
+ ///
+ public const string GOTRUE_URL = "http://localhost:9999";
+ public const string AUDIENCE = "";
+ public const int EXPIRY_MARGIN = 60 * 1000;
+ public const string STORAGE_KEY = "supabase.auth.token";
+ public static readonly Dictionary CookieOptions = new Dictionary
+ {
+ { "name", "sb:token" },
+ { "lifetime", 60 * 60 * 8 },
+ { "domain", "" },
+ { "path", "/" },
+ { "sameSite", "lax" }
+ };
- public enum SortOrder
- {
- [MapTo("asc")]
- Ascending,
- [MapTo("desc")]
- Descending
- }
+ public enum SortOrder
+ {
+ [MapTo("asc")]
+ Ascending,
+ [MapTo("desc")]
+ Descending
+ }
- public enum MobileOtpType
- {
- [MapTo("sms")]
- SMS,
- [MapTo("phone_change")]
- PhoneChange
- }
+ public enum MobileOtpType
+ {
+ [MapTo("sms")]
+ SMS,
+ [MapTo("phone_change")]
+ PhoneChange
+ }
- public enum EmailOtpType
- {
- [MapTo("signup")]
- Signup,
- [MapTo("invite")]
- Invite,
- [MapTo("magiclink")]
- MagicLink,
- [MapTo("recovery")]
- Recovery,
- [MapTo("email_change")]
- EmailChange,
- [MapTo("email")]
- Email
- }
+ public enum EmailOtpType
+ {
+ [MapTo("signup")]
+ Signup,
+ [MapTo("invite")]
+ Invite,
+ [MapTo("magiclink")]
+ MagicLink,
+ [MapTo("recovery")]
+ Recovery,
+ [MapTo("email_change")]
+ EmailChange,
+ [MapTo("email")]
+ Email
+ }
- ///
- /// Providers available to Supabase
- /// Ref: https://supabase.github.io/gotrue-js/modules.html#Provider
- ///
- public enum Provider
- {
- [MapTo("anonymous_users")]
- AnonymousUsers,
- [MapTo("apple")]
- Apple,
- [MapTo("azure")]
- Azure,
- [MapTo("bitbucket")]
- Bitbucket,
- [MapTo("discord")]
- Discord,
- [MapTo("facebook")]
- Facebook,
- [MapTo("figma")]
- Figma,
- [MapTo("fly")]
- Fly,
- [MapTo("github")]
- Github,
- [MapTo("gitlab")]
- Gitlab,
- [MapTo("google")]
- Google,
- [MapTo("kakao")]
- Kakao,
- [MapTo("keycloak")]
- KeyCloak,
- [MapTo("linkedin")]
- LinkedIn,
- [MapTo("linkedin_oidc")]
- LinkedInOIDC,
- [MapTo("notion")]
- Notion,
- [MapTo("slack")]
- Slack,
- [MapTo("spotify")]
- Spotify,
- [MapTo("twitch")]
- Twitch,
- [MapTo("twitter")]
- Twitter,
- [MapTo("workos")]
- WorkOS,
- [MapTo("zoom")]
- Zoom
- }
+ ///
+ /// Providers available to Supabase
+ /// Ref: https://supabase.github.io/gotrue-js/modules.html#Provider
+ ///
+ public enum Provider
+ {
+ [MapTo("anonymous_users")]
+ AnonymousUsers,
+ [MapTo("apple")]
+ Apple,
+ [MapTo("azure")]
+ Azure,
+ [MapTo("bitbucket")]
+ Bitbucket,
+ [MapTo("discord")]
+ Discord,
+ [MapTo("facebook")]
+ Facebook,
+ [MapTo("figma")]
+ Figma,
+ [MapTo("fly")]
+ Fly,
+ [MapTo("github")]
+ Github,
+ [MapTo("gitlab")]
+ Gitlab,
+ [MapTo("google")]
+ Google,
+ [MapTo("kakao")]
+ Kakao,
+ [MapTo("keycloak")]
+ KeyCloak,
+ [MapTo("linkedin")]
+ LinkedIn,
+ [MapTo("linkedin_oidc")]
+ LinkedInOIDC,
+ [MapTo("notion")]
+ Notion,
+ [MapTo("slack")]
+ Slack,
+ [MapTo("spotify")]
+ Spotify,
+ [MapTo("twitch")]
+ Twitch,
+ [MapTo("twitter")]
+ Twitter,
+ [MapTo("workos")]
+ WorkOS,
+ [MapTo("zoom")]
+ Zoom
+ }
- ///
- /// States that the Auth Client will raise events for.
- ///
- public enum AuthState
- {
- SignedIn,
- SignedOut,
- UserUpdated,
- PasswordRecovery,
- TokenRefreshed,
- Shutdown,
- MfaChallengeVerified
- }
+ ///
+ /// States that the Auth Client will raise events for.
+ ///
+ public enum AuthState
+ {
+ SignedIn,
+ SignedOut,
+ UserUpdated,
+ PasswordRecovery,
+ TokenRefreshed,
+ Shutdown,
+ MfaChallengeVerified
+ }
- ///
- /// Specifies the functionality expected from the `SignIn` method
- ///
- public enum SignInType
- {
- Email,
- Phone,
- RefreshToken
- }
+ ///
+ /// Specifies the functionality expected from the `SignIn` method
+ ///
+ public enum SignInType
+ {
+ Email,
+ Phone,
+ RefreshToken
+ }
- ///
- /// Represents an OAuth Flow type
- ///
- public enum OAuthFlowType
- {
- [MapTo("implicit")]
- Implicit,
- [MapTo("pkce")]
- PKCE
- }
+ ///
+ /// Represents an OAuth Flow type
+ ///
+ [PartialCapability("client.authentication_integration.oauth_flow_type", "Flow type (PKCE/Implicit) is chosen per sign-in via SignInOptions.FlowType, not as a single construction-time client setting.")]
+ public enum OAuthFlowType
+ {
+ [MapTo("implicit")]
+ Implicit,
+ [MapTo("pkce")]
+ PKCE
+ }
- ///
- /// Specifies the functionality expected from the `SignUp` method
- ///
- public enum SignUpType
- {
- Email,
- Phone
- }
+ ///
+ /// Specifies the functionality expected from the `SignUp` method
+ ///
+ public enum SignUpType
+ {
+ Email,
+ Phone
+ }
- public enum SignOutScope
- {
- [MapTo("global")]
- Global,
- [MapTo("local")]
- Local,
- [MapTo("others")]
- Others
- }
+ public enum SignOutScope
+ {
+ [MapTo("global")]
+ Global,
+ [MapTo("local")]
+ Local,
+ [MapTo("others")]
+ Others
}
}
diff --git a/packages/Gotrue/Gotrue/ProviderAuthState.cs b/packages/Gotrue/Gotrue/ProviderAuthState.cs
index e22f5b2e..a44a6672 100644
--- a/packages/Gotrue/Gotrue/ProviderAuthState.cs
+++ b/packages/Gotrue/Gotrue/ProviderAuthState.cs
@@ -1,4 +1,5 @@
using System;
+using Supabase.Core.Attributes;
namespace Supabase.Gotrue;
///
@@ -6,6 +7,7 @@ namespace Supabase.Gotrue;
///
/// For use with Provider Auth, PKCE Auth, and ID Token auth.
///
+[ImplementedCapability("auth.sign_in.sign_in_with_oauth")]
public class ProviderAuthState
{
///
diff --git a/packages/Gotrue/Gotrue/SignInOptions.cs b/packages/Gotrue/Gotrue/SignInOptions.cs
index c0e1de06..8b872ebf 100644
--- a/packages/Gotrue/Gotrue/SignInOptions.cs
+++ b/packages/Gotrue/Gotrue/SignInOptions.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using Supabase.Core.Attributes;
using static Supabase.Gotrue.Constants;
namespace Supabase.Gotrue;
@@ -52,5 +53,6 @@ public class SignInOptions
///
/// PKCE is recommended for mobile and server-side applications.
///
+ [PartialCapability("client.authentication_integration.oauth_flow_type", "Flow type (PKCE/Implicit) is chosen per sign-in via SignInOptions.FlowType, not as a single construction-time client setting.")]
public OAuthFlowType FlowType { get; set; } = OAuthFlowType.Implicit;
}
diff --git a/packages/Gotrue/Gotrue/User.cs b/packages/Gotrue/Gotrue/User.cs
index 7349173e..78643164 100644
--- a/packages/Gotrue/Gotrue/User.cs
+++ b/packages/Gotrue/Gotrue/User.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
+using Supabase.Core.Attributes;
using Supabase.Gotrue.Mfa;
#endregion
@@ -44,6 +45,7 @@ public class User
public string? Id { get; set; }
[JsonPropertyName("identities")]
+ [PartialCapability("auth.identities.list_identities", "No dedicated getUserIdentities method; linked identities are reachable only via the User.Identities property.")]
public List Identities { get; set; } = new List();
[JsonPropertyName("invited_at")]
diff --git a/packages/Postgrest/Postgrest/Client.cs b/packages/Postgrest/Postgrest/Client.cs
index 04ce8320..a29e564c 100644
--- a/packages/Postgrest/Postgrest/Client.cs
+++ b/packages/Postgrest/Postgrest/Client.cs
@@ -3,6 +3,7 @@
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
+using Supabase.Core.Attributes;
using Supabase.Core.Extensions;
using Supabase.Postgrest.Interfaces;
using Supabase.Postgrest.Models;
@@ -79,6 +80,7 @@ public Client(string baseUrl, ClientOptions? options = null)
///
+ [ImplementedCapability("database.query.from_table")]
public IPostgrestTable Table() where T : BaseModel, new() =>
new Table(this.BaseUrl, SerializerSettings(this.Options), this.Options)
{
diff --git a/packages/Postgrest/Postgrest/ClientOptions.cs b/packages/Postgrest/Postgrest/ClientOptions.cs
index 0eda3f04..03fc1e57 100644
--- a/packages/Postgrest/Postgrest/ClientOptions.cs
+++ b/packages/Postgrest/Postgrest/ClientOptions.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Net;
using System.Net.Http;
+using Supabase.Core.Attributes;
using Supabase.Core.Http;
#pragma warning disable CS1591
namespace Supabase.Postgrest;
@@ -13,6 +14,7 @@ namespace Supabase.Postgrest;
///
public class ClientOptions
{
+ [PartialCapability("database.query.schema_selection", "Schema is set once at construction (ClientOptions.Schema); no per-query schema switch like JS client.schema(name).")]
public string Schema { get; set; } = "public";
public readonly DateTimeStyles DateTimeStyles = DateTimeStyles.AdjustToUniversal;
diff --git a/packages/Postgrest/Postgrest/Constants.cs b/packages/Postgrest/Postgrest/Constants.cs
index c3ec3fe9..56088421 100644
--- a/packages/Postgrest/Postgrest/Constants.cs
+++ b/packages/Postgrest/Postgrest/Constants.cs
@@ -1,97 +1,120 @@
-using Supabase.Core.Attributes;
+using Supabase.Core.Attributes;
#pragma warning disable CS1591
-namespace Supabase.Postgrest
-{
+namespace Supabase.Postgrest;
+
- public static class Constants
- {
- ///
- /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=operators#operators
- ///
- public enum Operator
- {
- [MapTo("and")]
- And,
- [MapTo("or")]
- Or,
- [MapTo("eq")]
- Equals,
- [MapTo("gt")]
- GreaterThan,
- [MapTo("gte")]
- GreaterThanOrEqual,
- [MapTo("lt")]
- LessThan,
- [MapTo("lte")]
- LessThanOrEqual,
- [MapTo("neq")]
- NotEqual,
- [MapTo("like")]
- Like,
- [MapTo("ilike")]
- ILike,
- [MapTo("in")]
- In,
- [MapTo("is")]
- Is,
- [MapTo("fts")]
- FTS,
- [MapTo("plfts")]
- PLFTS,
- [MapTo("phfts")]
- PHFTS,
- [MapTo("wfts")]
- WFTS,
- [MapTo("cs")]
- Contains,
- [MapTo("cd")]
- ContainedIn,
- [MapTo("ov")]
- Overlap,
- [MapTo("sl")]
- StrictlyLeft,
- [MapTo("sr")]
- StrictlyRight,
- [MapTo("nxr")]
- NotRightOf,
- [MapTo("nxl")]
- NotLeftOf,
- [MapTo("adj")]
- Adjacent,
- [MapTo("not")]
- Not,
- }
+public static class Constants
+{
+ ///
+ /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=operators#operators
+ ///
+ public enum Operator
+ {
+ [MapTo("and")]
+ And,
+ [MapTo("or")]
+ [ImplementedCapability("database.using_filters.or")]
+ Or,
+ [MapTo("eq")]
+ [ImplementedCapability("database.using_filters.eq")]
+ Equals,
+ [MapTo("gt")]
+ [ImplementedCapability("database.using_filters.gt")]
+ GreaterThan,
+ [MapTo("gte")]
+ [ImplementedCapability("database.using_filters.gte")]
+ GreaterThanOrEqual,
+ [MapTo("lt")]
+ [ImplementedCapability("database.using_filters.lt")]
+ LessThan,
+ [MapTo("lte")]
+ [ImplementedCapability("database.using_filters.lte")]
+ LessThanOrEqual,
+ [MapTo("neq")]
+ [ImplementedCapability("database.using_filters.neq")]
+ NotEqual,
+ [MapTo("like")]
+ [ImplementedCapability("database.using_filters.like")]
+ Like,
+ [MapTo("ilike")]
+ [ImplementedCapability("database.using_filters.ilike")]
+ ILike,
+ [MapTo("in")]
+ [ImplementedCapability("database.using_filters.in")]
+ In,
+ [MapTo("is")]
+ [ImplementedCapability("database.using_filters.is")]
+ Is,
+ [MapTo("fts")]
+ [ImplementedCapability("database.using_filters.text_search", "FTS family: fts/plfts/phfts/wfts.")]
+ FTS,
+ [MapTo("plfts")]
+ [ImplementedCapability("database.using_filters.text_search", "FTS family: fts/plfts/phfts/wfts.")]
+ PLFTS,
+ [MapTo("phfts")]
+ [ImplementedCapability("database.using_filters.text_search", "FTS family: fts/plfts/phfts/wfts.")]
+ PHFTS,
+ [MapTo("wfts")]
+ [ImplementedCapability("database.using_filters.text_search", "FTS family: fts/plfts/phfts/wfts.")]
+ WFTS,
+ [MapTo("cs")]
+ [ImplementedCapability("database.using_filters.contains")]
+ Contains,
+ [MapTo("cd")]
+ [ImplementedCapability("database.using_filters.contained_by")]
+ ContainedIn,
+ [MapTo("ov")]
+ [ImplementedCapability("database.using_filters.overlaps")]
+ Overlap,
+ [MapTo("sl")]
+ [ImplementedCapability("database.using_filters.range_lt")]
+ StrictlyLeft,
+ [MapTo("sr")]
+ [ImplementedCapability("database.using_filters.range_gt")]
+ StrictlyRight,
+ [MapTo("nxr")]
+ [ImplementedCapability("database.using_filters.range_lte")]
+ NotRightOf,
+ [MapTo("nxl")]
+ [ImplementedCapability("database.using_filters.range_gte")]
+ NotLeftOf,
+ [MapTo("adj")]
+ [ImplementedCapability("database.using_filters.range_adjacent")]
+ Adjacent,
+ [MapTo("not")]
+ [ImplementedCapability("database.using_filters.not")]
+ Not,
+ }
- public enum Ordering
- {
- [MapTo("asc")]
- Ascending,
- [MapTo("desc")]
- Descending,
- }
+ public enum Ordering
+ {
+ [MapTo("asc")]
+ Ascending,
+ [MapTo("desc")]
+ Descending,
+ }
- ///
- /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=nulls%20first#ordering
- ///
- public enum NullPosition
- {
- [MapTo("nullsfirst")]
- First,
- [MapTo("nullslast")]
- Last
- }
+ ///
+ /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=nulls%20first#ordering
+ ///
+ public enum NullPosition
+ {
+ [MapTo("nullsfirst")]
+ First,
+ [MapTo("nullslast")]
+ Last
+ }
- ///
- /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=count#estimated-count
- ///
- public enum CountType
- {
- [MapTo("exact")]
- Exact,
- [MapTo("planned")]
- Planned,
- [MapTo("estimated")]
- Estimated
- }
- }
+ ///
+ /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=count#estimated-count
+ ///
+ public enum CountType
+ {
+ [MapTo("exact")]
+ Exact,
+ [MapTo("planned")]
+ Planned,
+ [MapTo("estimated")]
+ Estimated
+ }
}
diff --git a/packages/Postgrest/Postgrest/QueryOptions.cs b/packages/Postgrest/Postgrest/QueryOptions.cs
index 906afd1c..b2a68d4a 100644
--- a/packages/Postgrest/Postgrest/QueryOptions.cs
+++ b/packages/Postgrest/Postgrest/QueryOptions.cs
@@ -1,97 +1,96 @@
-using System;
using System.Collections.Generic;
-using Supabase.Postgrest.Extensions;
using Supabase.Core.Attributes;
+using Supabase.Postgrest.Extensions;
#pragma warning disable CS1591
-namespace Supabase.Postgrest
+namespace Supabase.Postgrest;
+
+
+public class QueryOptions
{
+ public enum ReturnType
+ {
+ [MapTo("minimal")]
+ Minimal,
+ [MapTo("representation")]
+ Representation
+ };
+
+ public enum CountType
+ {
+ [MapTo("none")]
+ None,
+ [MapTo("exact")]
+ Exact,
+ [MapTo("planned")]
+ Planned,
+ [MapTo("estimated")]
+ Estimated
+ };
+
+ public enum DuplicateResolutionType
+ {
+ [MapTo("merge-duplicates")]
+ MergeDuplicates,
+ [MapTo("ignore-duplicates")]
+ IgnoreDuplicates
+ }
+
+ ///
+ /// By default the new record is returned. Set this to 'Minimal' if you don't need this value.
+ ///
+ [ImplementedCapability("database.mutate.select_after_mutation", "Mutations return the affected rows by default (QueryOptions.Returning = Representation).")]
+ public ReturnType Returning { get; set; } = ReturnType.Representation;
+
+ ///
+ /// Specifies if duplicate rows should be ignored and not inserted.
+ ///
+ public DuplicateResolutionType DuplicateResolution { get; set; } = DuplicateResolutionType.MergeDuplicates;
+
+ ///
+ /// Count algorithm to use to count rows in a table.
+ ///
+ public CountType Count { get; set; } = CountType.None;
+
+ ///
+ /// If the record should be upserted
+ ///
+ public bool Upsert { get; set; }
+
+ ///
+ /// /// By specifying the onConflict query parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint.
+ ///
+ public string? OnConflict { get; set; }
+
+ public Dictionary ToHeaders()
+ {
+ var headers = new Dictionary();
+ var prefersHeaders = new List();
+
+ if (this.Upsert)
+ {
+ var resolverAttr = this.DuplicateResolution.GetAttribute();
+ prefersHeaders.Add($"resolution={resolverAttr?.Mapping}");
+ }
+
+ var returnAttr = this.Returning.GetAttribute();
+ if (returnAttr != null)
+ {
+ prefersHeaders.Add($"return={returnAttr.Mapping}");
+ }
+
+ var countAttr = this.Count.GetAttribute();
+ if (this.Count != CountType.None && countAttr != null)
+ {
+ prefersHeaders.Add($"count={countAttr.Mapping}");
+ }
+
+ headers.Add("Prefer", string.Join(",", prefersHeaders.ToArray()));
+
+ if (this.Returning == ReturnType.Minimal)
+ {
+ headers.Add("Accept", "*/*");
+ }
- public class QueryOptions
- {
- public enum ReturnType
- {
- [MapTo("minimal")]
- Minimal,
- [MapTo("representation")]
- Representation
- };
-
- public enum CountType
- {
- [MapTo("none")]
- None,
- [MapTo("exact")]
- Exact,
- [MapTo("planned")]
- Planned,
- [MapTo("estimated")]
- Estimated
- };
-
- public enum DuplicateResolutionType
- {
- [MapTo("merge-duplicates")]
- MergeDuplicates,
- [MapTo("ignore-duplicates")]
- IgnoreDuplicates
- }
-
- ///
- /// By default the new record is returned. Set this to 'Minimal' if you don't need this value.
- ///
- public ReturnType Returning { get; set; } = ReturnType.Representation;
-
- ///
- /// Specifies if duplicate rows should be ignored and not inserted.
- ///
- public DuplicateResolutionType DuplicateResolution { get; set; } = DuplicateResolutionType.MergeDuplicates;
-
- ///
- /// Count algorithm to use to count rows in a table.
- ///
- public CountType Count { get; set; } = CountType.None;
-
- ///
- /// If the record should be upserted
- ///
- public bool Upsert { get; set; }
-
- ///
- /// /// By specifying the onConflict query parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint.
- ///
- public string? OnConflict { get; set; }
-
- public Dictionary ToHeaders()
- {
- var headers = new Dictionary();
- var prefersHeaders = new List();
-
- if (Upsert)
- {
- var resolverAttr = DuplicateResolution.GetAttribute();
- prefersHeaders.Add($"resolution={resolverAttr?.Mapping}");
- }
-
- var returnAttr = Returning.GetAttribute();
- if (returnAttr != null)
- {
- prefersHeaders.Add($"return={returnAttr.Mapping}");
- }
-
- var countAttr = Count.GetAttribute();
- if (Count != CountType.None && countAttr != null)
- {
- prefersHeaders.Add($"count={countAttr.Mapping}");
- }
-
- headers.Add("Prefer", String.Join(",", prefersHeaders.ToArray()));
-
- if (Returning == ReturnType.Minimal)
- {
- headers.Add("Accept", "*/*");
- }
-
- return headers;
- }
- }
+ return headers;
+ }
}
diff --git a/packages/Postgrest/Postgrest/Table.cs b/packages/Postgrest/Postgrest/Table.cs
index db61846d..168fb63e 100644
--- a/packages/Postgrest/Postgrest/Table.cs
+++ b/packages/Postgrest/Postgrest/Table.cs
@@ -102,6 +102,7 @@ public Table(string baseUrl, JsonSerializerOptions serializerSettings, ClientOpt
}
///
+ [ImplementedCapability("database.using_filters.eq")]
public IPostgrestTable Filter(Expression> predicate, Operator op,
TCriterion? criterion)
{
@@ -181,6 +182,8 @@ public IPostgrestTable Filter(string columnName, Operator op
}
///
+ [ImplementedCapability("database.using_filters.not")]
+ [ImplementedCapability("database.using_filters.not_in", "Composed via the Not(column, Operator.In, list) overload.")]
public IPostgrestTable Not(IPostgrestQueryFilter filter)
{
this.filters.Add(new QueryFilter(Operator.Not, filter));
@@ -253,6 +256,7 @@ public IPostgrestTable And(List filters)
}
///
+ [ImplementedCapability("database.using_filters.or")]
public IPostgrestTable Or(List filters)
{
this.filters.Add(new QueryFilter(Operator.Or, filters));
@@ -260,6 +264,7 @@ public IPostgrestTable Or(List filters)
}
///
+ [ImplementedCapability("database.using_filters.match")]
public IPostgrestTable Match(TModel model)
{
foreach (var kvp in model.PrimaryKey)
@@ -282,6 +287,7 @@ public IPostgrestTable Match(Dictionary query)
}
///
+ [ImplementedCapability("database.using_modifiers.order", "Capability at parity, but the primary Order(\"col\", ...) form is stringly-typed (breaks silently on a property rename).")]
public IPostgrestTable Order(Expression> predicate, Ordering ordering,
NullPosition nullPosition = NullPosition.First)
{
@@ -314,6 +320,7 @@ public IPostgrestTable Order(string foreignTable, string column, Orderin
}
///
+ [ImplementedCapability("database.using_modifiers.range")]
public IPostgrestTable Range(int from)
{
this.rangeFrom = from;
@@ -329,6 +336,8 @@ public IPostgrestTable Range(int from, int to)
}
///
+ [ImplementedCapability("database.query.select")]
+ [PartialCapability("database.using_modifiers.relationship_embed", "Embeds expressible via PostgREST string syntax in Select(); no typed relationship builder (LINQ embed mapping is weak).")]
public IPostgrestTable Select(string columnQuery)
{
this.method = HttpMethod.Get;
@@ -350,6 +359,7 @@ public IPostgrestTable Select(Expression> predica
}
///
+ [ImplementedCapability("database.using_filters.eq")]
public IPostgrestTable Where(Expression> predicate)
{
var visitor = new WhereExpressionVisitor();
@@ -372,6 +382,7 @@ public IPostgrestTable Where(Expression> predicate)
///
+ [ImplementedCapability("database.using_modifiers.limit")]
public IPostgrestTable Limit(int limit, string? foreignTableName = null)
{
this.limit = limit;
@@ -381,6 +392,7 @@ public IPostgrestTable Limit(int limit, string? foreignTableName = null)
///
+ [ImplementedCapability("database.mutate.upsert")]
public IPostgrestTable OnConflict(string columnName)
{
this.onConflict = columnName;
@@ -427,6 +439,7 @@ public IPostgrestTable Columns(Expression> predic
///
+ [ImplementedCapability("database.using_modifiers.limit")]
public IPostgrestTable Offset(int offset, string? foreignTableName = null)
{
this.offset = offset;
@@ -444,6 +457,7 @@ public IPostgrestTable StripNulls()
///
+ [ImplementedCapability("database.mutate.insert")]
public Task> Insert(TModel model, QueryOptions? options = null,
CancellationToken cancellationToken = default) =>
this.PerformInsert(model, options, cancellationToken);
@@ -456,6 +470,7 @@ public Task> Insert(ICollection models, QueryOpt
///
+ [ImplementedCapability("database.mutate.upsert")]
public Task> Upsert(TModel model, QueryOptions? options = null,
CancellationToken cancellationToken = default)
{
@@ -482,6 +497,7 @@ public Task> Upsert(ICollection model, QueryOpti
///
+ [ImplementedCapability("database.mutate.update")]
public IPostgrestTable Set(Expression> keySelector, object? value)
{
var visitor = new SetExpressionVisitor();
@@ -525,6 +541,7 @@ public IPostgrestTable Set(Expression
+ [ImplementedCapability("database.mutate.update")]
public Task> Update(QueryOptions? options = null,
CancellationToken cancellationToken = default)
{
@@ -557,6 +574,7 @@ public Task> Update(TModel model, QueryOptions? options
///
+ [ImplementedCapability("database.mutate.delete")]
public Task> Delete(QueryOptions? options = null, CancellationToken cancellationToken = default)
{
options ??= new QueryOptions();
@@ -600,6 +618,7 @@ public async Task Count(CountType type, CancellationToken cancellationToken
///
+ [ImplementedCapability("database.using_modifiers.single_row")]
public async Task Single(CancellationToken cancellationToken = default)
{
this.method = HttpMethod.Get;
@@ -622,6 +641,7 @@ public async Task Count(CountType type, CancellationToken cancellationToken
}
///
+ [ImplementedCapability("database.using_modifiers.request_cancellation", "Every terminal (Get/Insert/Update/Delete/Single/Count) accepts a CancellationToken.")]
public Task> Get(CancellationToken cancellationToken = default, CountType type = CountType.Estimated)
{
var attr = type.GetAttribute();
diff --git a/packages/Realtime/Realtime/Broadcast/BroadcastOptions.cs b/packages/Realtime/Realtime/Broadcast/BroadcastOptions.cs
index 64ad9832..da947f31 100644
--- a/packages/Realtime/Realtime/Broadcast/BroadcastOptions.cs
+++ b/packages/Realtime/Realtime/Broadcast/BroadcastOptions.cs
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
+using Supabase.Core.Attributes;
namespace Supabase.Realtime.Broadcast;
@@ -12,12 +13,14 @@ public class BroadcastOptions
/// self option enables client to receive message it broadcast
///
[JsonPropertyName("self")]
+ [ImplementedCapability("realtime.subscriptions.broadcast_self")]
public bool BroadcastSelf { get; set; } = false;
///
/// ack option instructs server to acknowledge that broadcast message was received
///
[JsonPropertyName("ack")]
+ [ImplementedCapability("realtime.subscriptions.broadcast_ack")]
public bool BroadcastAck { get; set; } = false;
///
diff --git a/packages/Realtime/Realtime/Client.cs b/packages/Realtime/Realtime/Client.cs
index 7b1e40fc..a6f22467 100644
--- a/packages/Realtime/Realtime/Client.cs
+++ b/packages/Realtime/Realtime/Client.cs
@@ -5,6 +5,7 @@
using System.Net.WebSockets;
using System.Text.Json;
using System.Threading.Tasks;
+using Supabase.Core.Attributes;
using Supabase.Realtime.Channel;
using Supabase.Realtime.Exceptions;
using Supabase.Realtime.Interfaces;
@@ -31,6 +32,7 @@ public class Client : IRealtimeClient
///
/// Exposes all Realtime RealtimeChannel Subscriptions for R/O public consumption
///
+ [PartialCapability("realtime.client.get_channels", "Active channels exposed as a Subscriptions dictionary; no getChannels() method.")]
public ReadOnlyDictionary Subscriptions => new(this.subscriptions);
///
@@ -113,6 +115,7 @@ public Client(string realtimeUrl, ClientOptions? options = null)
/// Returns when socket has successfully connected.
///
///
+ [ImplementedCapability("realtime.client.connect")]
public async Task> ConnectAsync()
{
var tsc = new TaskCompletionSource>();
@@ -164,6 +167,7 @@ public async Task> ConnectAsync
///
///
[Obsolete("Please use ConnectAsync() instead.")]
+ [ImplementedCapability("realtime.client.connect")]
public IRealtimeClient Connect(
Action, RealtimeException?>? callback = null)
{
@@ -209,6 +213,7 @@ public IRealtimeClient Connect(
///
/// Adds a listener to be notified when the socket state changes.
///
+ [PartialCapability("realtime.client.connection_state", "Socket state delivered via SocketStateEventHandler events; no synchronous connection-state getter on the client.")]
public void AddStateChangedHandler(
IRealtimeClient.SocketStateEventHandler handler)
{
@@ -252,6 +257,7 @@ private void NotifySocketStateChange(SocketState stateChanged)
/// Adds a debug handler, likely used within a logging solution of some kind.
///
///
+ [PartialCapability("realtime.configuration.custom_logger", "Diagnostics routed through a debug handler hook; no custom logging function with a log-level filter.")]
public void AddDebugHandler(IRealtimeDebugger.DebugEventHandler handler) =>
Debugger.Instance.AddDebugHandler(handler);
@@ -284,6 +290,7 @@ private void HandleSocketHeartbeat(IRealtimeSocket sender, SocketResponse messag
/// Status Code
/// Reason for disconnect
///
+ [ImplementedCapability("realtime.client.disconnect")]
public IRealtimeClient Disconnect(
WebSocketCloseStatus code = WebSocketCloseStatus.NormalClosure, string reason = "Programmatic Disconnect")
{
@@ -303,6 +310,7 @@ public IRealtimeClient Disconnect(
/// Ref: https://github.com/supabase/realtime-js/pull/117 | https://github.com/supabase/realtime-js/pull/117
///
///
+ [ImplementedCapability("realtime.client.set_auth_token")]
public void SetAuth(string jwt)
{
this.AccessToken = jwt;
@@ -332,6 +340,7 @@ public void SetAuth(string jwt)
///
///
///
+ [ImplementedCapability("realtime.client.channel", "Plain-topic form plus a typed convenience overload Channel(db, schema, table, col, value).")]
public RealtimeChannel Channel(string channelName) =>
this.Channel(channelName, ChannelOptions.Public(this.Options, () => this.AccessToken, this.SerializerSettings));
@@ -400,6 +409,7 @@ public RealtimeChannel Channel(string database = "realtime", string schema = "pu
/// Removes a channel subscription.
///
///
+ [ImplementedCapability("realtime.client.remove_channel")]
public void Remove(RealtimeChannel channel)
{
if (this.subscriptions.ContainsKey(channel.Topic))
diff --git a/packages/Realtime/Realtime/ClientOptions.cs b/packages/Realtime/Realtime/ClientOptions.cs
index 1dd003ed..c5462620 100644
--- a/packages/Realtime/Realtime/ClientOptions.cs
+++ b/packages/Realtime/Realtime/ClientOptions.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using Supabase.Core.Attributes;
using Supabase.Postgrest.Interfaces;
using Supabase.Realtime.Socket;
using Supabase.Realtime.Sockets;
@@ -40,11 +41,13 @@ public class ClientOptions
///
/// The interval to send a heartbeat message
///
+ [ImplementedCapability("realtime.configuration.heartbeat_interval")]
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(20);
///
/// The interval to reconnect
///
+ [ImplementedCapability("realtime.configuration.reconnect_backoff")]
public Func ReconnectAfterInterval { get; set; } = (tries) =>
{
var intervals = new[] { 30, 45, 60, 120 };
diff --git a/packages/Realtime/Realtime/PostgresChanges/PostgresChangesOptions.cs b/packages/Realtime/Realtime/PostgresChanges/PostgresChangesOptions.cs
index be8ebcfe..4f1ea046 100644
--- a/packages/Realtime/Realtime/PostgresChanges/PostgresChangesOptions.cs
+++ b/packages/Realtime/Realtime/PostgresChanges/PostgresChangesOptions.cs
@@ -69,6 +69,7 @@ public enum ListenType
///
[JsonPropertyName("filter")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [ImplementedCapability("realtime.subscriptions.postgres_changes_filter", "Filter is a hand-built PostgREST string (e.g. 'user_id=eq.{id}'), untied to the model.")]
public string? Filter { get; set; }
///
diff --git a/packages/Realtime/Realtime/RealtimeBroadcast.cs b/packages/Realtime/Realtime/RealtimeBroadcast.cs
index 22e487dc..21460584 100644
--- a/packages/Realtime/Realtime/RealtimeBroadcast.cs
+++ b/packages/Realtime/Realtime/RealtimeBroadcast.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
+using Supabase.Core.Attributes;
using Supabase.Realtime.Broadcast;
using Supabase.Realtime.Interfaces;
using Supabase.Realtime.Models;
@@ -20,6 +21,7 @@ namespace Supabase.Realtime;
/// A common use-case is sharing a user's cursor position with other clients in an online game.
///
/// A model representing expected payload.
+[ImplementedCapability("realtime.subscriptions.broadcast", "Typed broadcast payloads via generics; better-typed than JS on('broadcast', ...).")]
public class RealtimeBroadcast : IRealtimeBroadcast where TBroadcastModel : BaseBroadcast
{
private readonly RealtimeChannel channel;
diff --git a/packages/Realtime/Realtime/RealtimeChannel.cs b/packages/Realtime/Realtime/RealtimeChannel.cs
index c5c9515a..30fff4fc 100644
--- a/packages/Realtime/Realtime/RealtimeChannel.cs
+++ b/packages/Realtime/Realtime/RealtimeChannel.cs
@@ -5,6 +5,7 @@
using System.Text.Json;
using System.Threading.Tasks;
using System.Timers;
+using Supabase.Core.Attributes;
using Supabase.Realtime.Broadcast;
using Supabase.Realtime.Channel;
using Supabase.Realtime.Exceptions;
@@ -99,6 +100,7 @@ public class RealtimeChannel : IRealtimeChannel
/// Returns the instance.
///
///
+ [ImplementedCapability("realtime.subscriptions.broadcast", "Typed broadcast payloads via generics; better-typed than JS on('broadcast', ...).")]
public IRealtimeBroadcast? Broadcast() => this.broadcast;
///
@@ -200,6 +202,9 @@ private void HandleSocketStateChanged(IRealtimeSocket _, SocketState state)
/// instructs server to acknowledge that broadcast message was received
///
///
+ [ImplementedCapability("realtime.presence.presence_key", "Set via Channel.Register(presenceKey).")]
+ [ImplementedCapability("realtime.subscriptions.broadcast", "Typed broadcast payloads via generics; better-typed than JS on('broadcast', ...).")]
+ [ImplementedCapability("realtime.subscriptions.postgres_changes", "Model-typed path via SupabaseTable.On (blessed) plus the low-level Channel().Register(PostgresChangesOptions).")]
public RealtimeBroadcast Register(bool broadcastSelf = false,
bool broadcastAck = false) where TBroadcastResponse : BaseBroadcast =>
this.Register(new BroadcastOptions(broadcastSelf, broadcastAck));
@@ -364,6 +369,7 @@ public IRealtimeChannel OnPostgresChange(PostgresChangesHandler postgresChangeHa
/// The type of event this callback should process.
///
[Obsolete("Favor OnPostgresChange instead.")]
+ [ImplementedCapability("realtime.subscriptions.postgres_changes", "Model-typed path via SupabaseTable.On (blessed) plus the low-level Channel().Register(PostgresChangesOptions).")]
public void AddPostgresChangeHandler(ListenType listenType, PostgresChangesHandler postgresChangeHandler) => this.BindPostgresChangesHandler(listenType, postgresChangeHandler);
///
@@ -469,6 +475,7 @@ internal void RegisterPostgresChangesOptions(PostgresChangesOptions postgresChan
/// Subscribes to the channel given supplied Options/params.
///
///
+ [ImplementedCapability("realtime.channel.subscribe")]
public Task Subscribe(int timeoutMs = DefaultTimeout)
{
var tsc = new TaskCompletionSource();
@@ -533,6 +540,7 @@ public Task Subscribe(int timeoutMs = DefaultTimeout)
///
/// Unsubscribes from the channel.
///
+ [ImplementedCapability("realtime.channel.unsubscribe")]
public IRealtimeChannel Unsubscribe()
{
this.IsSubscribed = false;
@@ -556,6 +564,7 @@ public IRealtimeChannel Unsubscribe()
///
///
///
+ [ImplementedCapability("realtime.channel.broadcast")]
public Push Push(string eventName, string? type = null, object? payload = null, int timeoutMs = DefaultTimeout)
{
if (!this.hasJoinedOnce)
@@ -580,6 +589,7 @@ public Push Push(string eventName, string? type = null, object? payload = null,
///
///
///
+ [ImplementedCapability("realtime.channel.broadcast")]
public Task Send(ChannelEventName eventName, string? type, object payload, int timeoutMs = DefaultTimeout)
{
var push = this.Push(Core.Helpers.GetMappedToAttr(eventName).Mapping, type, payload, timeoutMs);
diff --git a/packages/Realtime/Realtime/RealtimePresence.cs b/packages/Realtime/Realtime/RealtimePresence.cs
index 016abb9b..925ab774 100644
--- a/packages/Realtime/Realtime/RealtimePresence.cs
+++ b/packages/Realtime/Realtime/RealtimePresence.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
+using Supabase.Core.Attributes;
using Supabase.Realtime.Channel;
using Supabase.Realtime.Exceptions;
using Supabase.Realtime.Interfaces;
@@ -21,17 +22,20 @@ namespace Supabase.Realtime;
/// If a client is suddenly disconnected (for example, they go offline), their state will be automatically removed from the shared state.
///
/// A model representing expected payload.
+[PartialCapability("realtime.subscriptions.subscribe_presence", "Join/leave/sync handlers exist, but the presence sync diverges from JS (see realtime.presence.presence_state).")]
public class RealtimePresence : IRealtimePresence where TPresenceModel : BasePresence
{
///
/// The Last State of this Presence instance.
///
+ [PartialCapability("realtime.presence.presence_state", "Typed CurrentState/LastState exist, but the initial presence snapshot is dropped, so any client that joins after others undercounts everyone — a behavioral gap, not just a shape gap.")]
public Dictionary> LastState { get; private set; } =
new();
///
/// The Current State of this Presence instance.
///
+ [PartialCapability("realtime.presence.presence_state", "Typed CurrentState/LastState exist, but the initial presence snapshot is dropped, so any client that joins after others undercounts everyone — a behavioral gap, not just a shape gap.")]
public Dictionary> CurrentState { get; } = new();
private PresenceOptions options;
@@ -152,6 +156,7 @@ public void TriggerDiff(SocketResponse response)
///
///
///
+ [ImplementedCapability("realtime.presence.track")]
public Task Track(object? payload, int timeoutMs = DefaultTimeout)
{
var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping;
@@ -179,6 +184,7 @@ public Task Track(object? payload, int timeoutMs = DefaultTimeout)
///
/// Untracks an event.
///
+ [ImplementedCapability("realtime.presence.untrack")]
public Task Untrack()
{
var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping;
diff --git a/packages/Storage/Storage/Client.cs b/packages/Storage/Storage/Client.cs
index 66f0df3e..257b18c7 100644
--- a/packages/Storage/Storage/Client.cs
+++ b/packages/Storage/Storage/Client.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using Supabase.Core.Attributes;
using Supabase.Storage.Interfaces;
namespace Supabase.Storage;
@@ -16,6 +17,7 @@ public Client(string url, ClientOptions? options, Dictionary? he
///
/// Bucket Id
///
+ [ImplementedCapability("storage.file_buckets.access_bucket")]
public IStorageFileApi From(string id) =>
new StorageFileApi(this.Url, id, this.Options, this.Headers, this.requestClient, this.uploadClient, this.downloadClient);
}
diff --git a/packages/Storage/Storage/DestinationOptions.cs b/packages/Storage/Storage/DestinationOptions.cs
index 0f749c50..6c6bbc10 100644
--- a/packages/Storage/Storage/DestinationOptions.cs
+++ b/packages/Storage/Storage/DestinationOptions.cs
@@ -1,3 +1,4 @@
+using Supabase.Core.Attributes;
namespace Supabase.Storage;
///
@@ -8,5 +9,7 @@ public class DestinationOptions
///
/// Gets or sets the name of the destination bucket in the context of Supabase Storage.
///
+ [ImplementedCapability("storage.file_buckets.copy_cross_bucket")]
+ [ImplementedCapability("storage.file_buckets.move_cross_bucket")]
public string? DestinationBucket { get; set; }
-}
\ No newline at end of file
+}
diff --git a/packages/Storage/Storage/FileOptions.cs b/packages/Storage/Storage/FileOptions.cs
index 4b353102..bb13483b 100644
--- a/packages/Storage/Storage/FileOptions.cs
+++ b/packages/Storage/Storage/FileOptions.cs
@@ -1,14 +1,17 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
+using Supabase.Core.Attributes;
namespace Supabase.Storage;
public class FileOptions
{
[JsonPropertyName("cacheControl")]
+ [ImplementedCapability("storage.file_buckets.upload_with_metadata")]
public string CacheControl { get; set; } = "3600";
[JsonPropertyName("contentType")]
+ [ImplementedCapability("storage.file_buckets.upload_with_metadata")]
public string ContentType { get; set; } = "text/plain;charset=UTF-8";
[JsonPropertyName("upsert")]
@@ -18,6 +21,7 @@ public class FileOptions
public string? Duplex { get; set; }
[JsonPropertyName("metadata")]
+ [ImplementedCapability("storage.file_buckets.upload_with_metadata")]
public Dictionary? Metadata { get; set; }
[JsonPropertyName("headers")]
diff --git a/packages/Storage/Storage/StorageBucketApi.cs b/packages/Storage/Storage/StorageBucketApi.cs
index 7c56cd2b..3a0ac544 100644
--- a/packages/Storage/Storage/StorageBucketApi.cs
+++ b/packages/Storage/Storage/StorageBucketApi.cs
@@ -4,6 +4,7 @@
using System.Threading;
using System.Threading.Tasks;
using Supabase.Core;
+using Supabase.Core.Attributes;
using Supabase.Core.Extensions;
using Supabase.Storage.Exceptions;
using Supabase.Storage.Extensions;
@@ -73,6 +74,7 @@ protected StorageBucketApi(string url, Dictionary? headers = nul
/// Retrieves the details of all Storage buckets within an existing product.
///
///
+ [ImplementedCapability("storage.file_buckets.list_file_buckets")]
public Task?> ListBuckets() =>
Helpers.MakeRequestAsync>(this.requestClient, this.Options.Retry, HttpMethod.Get, $"{this.Url}/bucket", null, this.Headers);
@@ -81,6 +83,7 @@ protected StorageBucketApi(string url, Dictionary? headers = nul
///
///
///
+ [ImplementedCapability("storage.file_buckets.get_bucket")]
public async Task GetBucket(string id)
{
try
@@ -103,6 +106,7 @@ protected StorageBucketApi(string url, Dictionary? headers = nul
///
///
/// Bucket Id
+ [ImplementedCapability("storage.file_buckets.create_file_bucket")]
public async Task CreateBucket(string id, BucketUpsertOptions? options = null)
{
options ??= new BucketUpsertOptions();
@@ -127,6 +131,7 @@ public async Task