diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7ff98ad9a..af87bfa88 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,9 +10,12 @@
+
+
+
diff --git a/docs/system-migrations.md b/docs/system-migrations.md
new file mode 100644
index 000000000..c3c818fbc
--- /dev/null
+++ b/docs/system-migrations.md
@@ -0,0 +1,29 @@
+# System migrations
+
+`SystemDbContext` is the model of the system tables: what `SystemModelBuilder` maps, with no content tables and with the history table `cms__migrations`. Each data provider package holds the migrations of that model for its database, in its `Migrations` folder.
+
+## Adding a migration
+
+Do this in the pull request that changes `SystemModelBuilder`, such as one that adds `cms_site` or `cms_job`. You need the `dotnet-ef` tool (`dotnet tool install --global dotnet-ef`). No database is needed, because the design-time factory in each package never connects.
+
+1. Map the table in `SystemModelBuilder`.
+
+2. Scaffold the migration once per provider, with the same name. `dotnet ef` starts a fresh context each time, which matters: scaffolding on a context that has already called `HasPendingModelChanges()` silently emits `CreateTable` for every table.
+
+ ```bash
+ dotnet ef migrations add AddSites --project src/FluentCMS.Data.Sqlite --context SystemDbContext --output-dir Migrations
+ ```
+
+ ```bash
+ dotnet ef migrations add AddSites --project src/FluentCMS.Data.PostgreSql --context SystemDbContext --output-dir Migrations
+ ```
+
+ ```bash
+ dotnet ef migrations add AddSites --project src/FluentCMS.Data.SqlServer --context SystemDbContext --output-dir Migrations
+ ```
+
+3. Add `// ` as the first line of each new migration file. EF marks only the designer and snapshot files, and the composite index columns in the migration file otherwise fail the build with CA1861.
+
+4. Run `dotnet test --project tests/FluentCMS.Data.Tests -c Release`. The tests fail if a provider's snapshot no longer matches the model, so a skipped provider is caught.
+
+Do not edit a migration that has shipped. The PostgreSQL and SQL Server migrations have never been applied to a database; [#2711](https://github.com/fluentcms/FluentCMS/issues/2711) validates them.
diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs
new file mode 100644
index 000000000..229aa1b32
--- /dev/null
+++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs
@@ -0,0 +1,32 @@
+using FluentCMS.Core.Storage;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FluentCMS.Core.DependencyInjection;
+
+public sealed class FluentCmsBuilder
+{
+ internal FluentCmsBuilder(IServiceCollection services)
+ {
+ Services = services;
+ }
+
+ public IServiceCollection Services { get; }
+
+ internal IDataProvider? DataProvider { get; private set; }
+
+ // The seam for a data provider package, whose own UseXxx method calls this.
+ public FluentCmsBuilder UseDataProvider(IDataProvider provider)
+ {
+ ArgumentNullException.ThrowIfNull(provider);
+
+ if (DataProvider is not null)
+ {
+ throw new FluentCmsConfigurationException(
+ StorageErrorCodes.SeveralDataProviders,
+ $"AddFluentCms got a second data provider, {provider.Name}, after {DataProvider.Name}; a host uses exactly one.");
+ }
+
+ DataProvider = provider;
+ return this;
+ }
+}
diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsConfigurationException.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsConfigurationException.cs
new file mode 100644
index 000000000..e11e506d6
--- /dev/null
+++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsConfigurationException.cs
@@ -0,0 +1,13 @@
+namespace FluentCMS.Core.DependencyInjection;
+
+// A host is set up in a way that FluentCMS cannot start with.
+public sealed class FluentCmsConfigurationException : InvalidOperationException
+{
+ internal FluentCmsConfigurationException(string code, string message)
+ : base($"{code}: {message}")
+ {
+ Code = code;
+ }
+
+ public string Code { get; }
+}
diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs
new file mode 100644
index 000000000..16d815943
--- /dev/null
+++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs
@@ -0,0 +1,46 @@
+using FluentCMS.Core.Schema;
+using FluentCMS.Core.Storage;
+using FluentCMS.Schema;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace FluentCMS.Core.DependencyInjection;
+
+public static class FluentCmsServiceCollectionExtensions
+{
+ public static IServiceCollection AddFluentCms(this IServiceCollection services, Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ var builder = new FluentCmsBuilder(services);
+ configure(builder);
+
+ var provider = builder.DataProvider ?? throw new FluentCmsConfigurationException(
+ StorageErrorCodes.NoDataProvider,
+ "AddFluentCms has no data provider. Add the package for your database and call cms.UseSqlite(...), cms.UsePostgreSql(...) or cms.UseSqlServer(...) inside AddFluentCms.");
+
+ services.AddSingleton(provider);
+ services.AddSingleton(provider.Conventions);
+ services.AddSingleton(provider.JsonDialect);
+ services.AddSingleton(provider.JobClaimDialect);
+
+ // Until content types can be added to the builder, a schema comes from the container, and none means system tables only.
+ services.TryAddSingleton(_ => new SchemaBuilder().Build());
+
+ services.AddDbContext((serviceProvider, options) =>
+ {
+ provider.ConfigureDbContext(options);
+ options.UseCmsModel(serviceProvider.GetRequiredService(), provider.Conventions, provider.JsonDialect);
+ });
+
+ services.AddDbContext(options =>
+ {
+ provider.ConfigureSystemMigrations(options);
+ options.UseSystemModel(provider.Conventions);
+ });
+
+ return services;
+ }
+}
diff --git a/src/FluentCMS.Core/Storage/CmsDbContext.cs b/src/FluentCMS.Core/Storage/CmsDbContext.cs
index af4bf1bdd..a6b62d470 100644
--- a/src/FluentCMS.Core/Storage/CmsDbContext.cs
+++ b/src/FluentCMS.Core/Storage/CmsDbContext.cs
@@ -25,6 +25,8 @@ public CmsDbContext(DbContextOptions options)
internal string ConventionsKey => _model.Conventions.CacheKey;
+ internal string? JsonDialectKey => _model.JsonDialect?.GetType().FullName;
+
// The rows of a content table, such as cms_article or cms_article_i18n.
public DbSet> Rows(string table) => Set>(table);
@@ -35,5 +37,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
{
new SystemModelBuilder(_model.Conventions).Apply(modelBuilder);
new ContentModelBuilder(_model.Schema, _model.Conventions).Apply(modelBuilder);
+ _model.JsonDialect?.ConfigureFunctions(modelBuilder);
}
}
diff --git a/src/FluentCMS.Core/Storage/CmsDbContextOptionsExtensions.cs b/src/FluentCMS.Core/Storage/CmsDbContextOptionsExtensions.cs
index d913c3aca..53a083e8f 100644
--- a/src/FluentCMS.Core/Storage/CmsDbContextOptionsExtensions.cs
+++ b/src/FluentCMS.Core/Storage/CmsDbContextOptionsExtensions.cs
@@ -1,3 +1,4 @@
+using FluentCMS.Core.Schema;
using FluentCMS.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -6,16 +7,27 @@ namespace FluentCMS.Core.Storage;
public static class CmsDbContextOptionsExtensions
{
- // Gives CmsDbContext the schema to build its model from. Without conventions the model stays neutral;
- // a data provider package passes its own.
- public static TBuilder UseCmsModel(this TBuilder builder, SchemaModel schema, IStorageConventions? conventions = null)
+ private static readonly SchemaModel NoContent = new SchemaBuilder().Build();
+
+ // Gives CmsDbContext the schema to build its model from. Without conventions the model stays neutral, and
+ // without a dialect it has no JSON functions; a data provider package passes its own.
+ public static TBuilder UseCmsModel(this TBuilder builder, SchemaModel schema, IStorageConventions? conventions = null, IJsonSqlDialect? jsonDialect = null)
where TBuilder : DbContextOptionsBuilder
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(schema);
- ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(new CmsModelOptionsExtension(schema, conventions ?? new StorageConventions()));
+ ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(new CmsModelOptionsExtension(schema, conventions ?? new StorageConventions(), jsonDialect));
builder.ReplaceService();
return builder;
}
+
+ // Gives SystemDbContext the conventions to build its model with.
+ public static TBuilder UseSystemModel(this TBuilder builder, IStorageConventions conventions)
+ where TBuilder : DbContextOptionsBuilder
+ {
+ ArgumentNullException.ThrowIfNull(conventions);
+
+ return builder.UseCmsModel(NoContent, conventions);
+ }
}
diff --git a/src/FluentCMS.Core/Storage/CmsModelCacheKeyFactory.cs b/src/FluentCMS.Core/Storage/CmsModelCacheKeyFactory.cs
index 8c4b6bc9f..77fdc27b7 100644
--- a/src/FluentCMS.Core/Storage/CmsModelCacheKeyFactory.cs
+++ b/src/FluentCMS.Core/Storage/CmsModelCacheKeyFactory.cs
@@ -5,7 +5,10 @@ namespace FluentCMS.Core.Storage;
internal sealed class CmsModelCacheKeyFactory : IModelCacheKeyFactory
{
- public object Create(DbContext context, bool designTime) => context is CmsDbContext cms
- ? (context.GetType(), cms.ModelKey, cms.ConventionsKey, designTime)
- : (context.GetType(), designTime);
+ public object Create(DbContext context, bool designTime) => context switch
+ {
+ CmsDbContext cms => (context.GetType(), cms.ModelKey, cms.ConventionsKey, cms.JsonDialectKey, designTime),
+ SystemDbContext system => (context.GetType(), system.ConventionsKey, designTime),
+ _ => (context.GetType(), designTime),
+ };
}
diff --git a/src/FluentCMS.Core/Storage/CmsModelOptionsExtension.cs b/src/FluentCMS.Core/Storage/CmsModelOptionsExtension.cs
index d1ff471a7..77ff7749c 100644
--- a/src/FluentCMS.Core/Storage/CmsModelOptionsExtension.cs
+++ b/src/FluentCMS.Core/Storage/CmsModelOptionsExtension.cs
@@ -6,7 +6,7 @@ namespace FluentCMS.Core.Storage;
// Carries the schema to CmsDbContext through its options, so the context has the one constructor that
// AddDbContext, pooling and design-time factories expect.
-internal sealed class CmsModelOptionsExtension(SchemaModel schema, IStorageConventions conventions) : IDbContextOptionsExtension
+internal sealed class CmsModelOptionsExtension(SchemaModel schema, IStorageConventions conventions, IJsonSqlDialect? jsonDialect) : IDbContextOptionsExtension
{
private ExtensionInfo? _info;
@@ -14,6 +14,8 @@ internal sealed class CmsModelOptionsExtension(SchemaModel schema, IStorageConve
public IStorageConventions Conventions { get; } = conventions;
+ public IJsonSqlDialect? JsonDialect { get; } = jsonDialect;
+
public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this);
public void ApplyServices(IServiceCollection services)
diff --git a/src/FluentCMS.Core/Storage/IDataProvider.cs b/src/FluentCMS.Core/Storage/IDataProvider.cs
new file mode 100644
index 000000000..f9790c794
--- /dev/null
+++ b/src/FluentCMS.Core/Storage/IDataProvider.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace FluentCMS.Core.Storage;
+
+// Everything a data provider package gives core. Its UseXxx method passes one to FluentCmsBuilder.UseDataProvider.
+public interface IDataProvider
+{
+ string Name { get; }
+
+ // The default of Database:MigrateOnStartup. The migration runner acts on it.
+ bool MigrateOnStartup { get; }
+
+ IStorageConventions Conventions { get; }
+
+ IJsonSqlDialect JsonDialect { get; }
+
+ IJobClaimDialect JobClaimDialect { get; }
+
+ // Points the options at the database.
+ void ConfigureDbContext(DbContextOptionsBuilder options);
+
+ // The same, for SystemDbContext: adds the assembly that holds the system migrations and their history table.
+ void ConfigureSystemMigrations(DbContextOptionsBuilder options);
+}
diff --git a/src/FluentCMS.Core/Storage/IJobClaimDialect.cs b/src/FluentCMS.Core/Storage/IJobClaimDialect.cs
new file mode 100644
index 000000000..c20c0dec6
--- /dev/null
+++ b/src/FluentCMS.Core/Storage/IJobClaimDialect.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+
+namespace FluentCMS.Core.Storage;
+
+// The next job due on a queue, leased to one worker until LeaseUntil.
+public sealed record JobClaim(string Queue, string Owner, DateTimeOffset Now, DateTimeOffset LeaseUntil);
+
+// The values of cms_job.status that claiming reads and writes.
+public static class JobClaimStatus
+{
+ public const string Pending = "pending";
+
+ public const string Running = "running";
+}
+
+// Claims one job from cms_job, so that of any number of concurrent workers exactly one gets each job. A candidate
+// is pending or running, due, under max_attempts, and has no lease or an expired one, which is how a job comes back
+// after its worker died. The next is the highest priority, then the earliest run_at. The claim sets status to
+// running, takes the lease and adds one to attempts. Dates are bound as the provider's conventions store them.
+public interface IJobClaimDialect
+{
+ // The id of the claimed job, or null if there was nothing to claim.
+ Task ClaimAsync(DatabaseFacade database, JobClaim claim, CancellationToken cancellationToken = default);
+}
diff --git a/src/FluentCMS.Core/Storage/IJsonSqlDialect.cs b/src/FluentCMS.Core/Storage/IJsonSqlDialect.cs
new file mode 100644
index 000000000..b8d3b9f46
--- /dev/null
+++ b/src/FluentCMS.Core/Storage/IJsonSqlDialect.cs
@@ -0,0 +1,33 @@
+using System.Linq.Expressions;
+using Microsoft.EntityFrameworkCore;
+
+namespace FluentCMS.Core.Storage;
+
+// Builds the JSON filters of the query compiler as LINQ expressions over a provider's EF function mappings,
+// limited to what Q7 supports. Each method returns a bool expression, so filters compose with And, Or and Not.
+//
+// A json argument is a string expression holding JSON: EF.Property(row, "sections"), or the block that
+// AnyBlock hands to its predicate. A value argument is the compiler's parameter expression; bind it by reading
+// a member of a holder object, because a bare constant becomes a SQL literal.
+public interface IJsonSqlDialect
+{
+ // Declares the functions the expressions below call. CmsDbContext applies this to its model.
+ void ConfigureFunctions(ModelBuilder modelBuilder);
+
+ // The block list holds a block of this type. blockType is a string expression.
+ Expression HasBlock(Expression json, Expression blockType);
+
+ // The block list holds a block of this type that also satisfies the predicate, so that every condition
+ // applies to the same block. The predicate receives the block and calls the Scalar and Array methods on it.
+ Expression AnyBlock(Expression json, Expression blockType, Func predicate);
+
+ // The value at the top-level key has this type and equals the value, whose CLR type is JsonScalar.ClrType(type).
+ // A JSON number never equals the text '12'.
+ Expression ScalarEquals(Expression json, string field, JsonScalarType type, Expression value);
+
+ // The value at the top-level key is JSON text that contains the string value, case-sensitively.
+ Expression ScalarContains(Expression json, string field, Expression value);
+
+ // The value at the top-level key is an array holding the string value as an element.
+ Expression ArrayContains(Expression json, string field, Expression value);
+}
diff --git a/src/FluentCMS.Core/Storage/JsonScalar.cs b/src/FluentCMS.Core/Storage/JsonScalar.cs
new file mode 100644
index 000000000..60dd62567
--- /dev/null
+++ b/src/FluentCMS.Core/Storage/JsonScalar.cs
@@ -0,0 +1,57 @@
+using System.Linq.Expressions;
+using FluentCMS.Core.Schema.FieldTypes;
+
+namespace FluentCMS.Core.Storage;
+
+// How a scalar inside JSON is compared. A JSON extraction keeps the value's own type, so each kind has its own comparison.
+public enum JsonScalarType
+{
+ Text,
+ WholeNumber,
+ DecimalNumber,
+ Boolean,
+}
+
+public static class JsonScalar
+{
+ // The comparison for a field type, or null if the type has no scalar value inside JSON. Dates are compared as
+ // the ISO 8601 text that the JSON holds.
+ public static JsonScalarType? TypeOf(string fieldType) => fieldType switch
+ {
+ FieldTypeNames.Text or FieldTypeNames.LongText or FieldTypeNames.Email or FieldTypeNames.Url or FieldTypeNames.Slug
+ or FieldTypeNames.Select or FieldTypeNames.Date or FieldTypeNames.DateTime => JsonScalarType.Text,
+ FieldTypeNames.Integer => JsonScalarType.WholeNumber,
+ FieldTypeNames.Decimal => JsonScalarType.DecimalNumber,
+ FieldTypeNames.Boolean => JsonScalarType.Boolean,
+ _ => null,
+ };
+
+ public static Type ClrType(JsonScalarType type) => type switch
+ {
+ JsonScalarType.Text => typeof(string),
+ JsonScalarType.WholeNumber => typeof(long),
+ JsonScalarType.DecimalNumber => typeof(decimal),
+ JsonScalarType.Boolean => typeof(bool),
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
+ };
+
+ // Q7 caps paths at one level. Dialects put the result into a path or key literal, so only a plain name may pass.
+ public static string CheckField(string field)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(field);
+
+ return field.All(character => char.IsAsciiLetterOrDigit(character) || character == '_')
+ ? field
+ : throw new ArgumentException($"'{field}' is not a one-level JSON path.", nameof(field));
+ }
+
+ public static void CheckValue(JsonScalarType type, Expression value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+
+ if (value.Type != ClrType(type))
+ {
+ throw new ArgumentException($"A {type} comparison needs a {ClrType(type).Name} value, not {value.Type.Name}.", nameof(value));
+ }
+ }
+}
diff --git a/src/FluentCMS.Core/Storage/StorageErrorCodes.cs b/src/FluentCMS.Core/Storage/StorageErrorCodes.cs
new file mode 100644
index 000000000..445bbeb25
--- /dev/null
+++ b/src/FluentCMS.Core/Storage/StorageErrorCodes.cs
@@ -0,0 +1,8 @@
+namespace FluentCMS.Core.Storage;
+
+// FCMS1301 is the runner's missing column error (#2690).
+internal static class StorageErrorCodes
+{
+ public const string NoDataProvider = "FCMS1302";
+ public const string SeveralDataProviders = "FCMS1303";
+}
diff --git a/src/FluentCMS.Core/Storage/SystemDbContext.cs b/src/FluentCMS.Core/Storage/SystemDbContext.cs
new file mode 100644
index 000000000..fd60e6d6b
--- /dev/null
+++ b/src/FluentCMS.Core/Storage/SystemDbContext.cs
@@ -0,0 +1,27 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace FluentCMS.Core.Storage;
+
+// The system migration stream: only what SystemModelBuilder maps, never a content table. Each data provider
+// package holds the migrations of this model for its database; docs/system-migrations.md says how to add one.
+public sealed class SystemDbContext : DbContext
+{
+ public const string HistoryTable = "cms__migrations";
+
+ private readonly CmsModelOptionsExtension _model;
+
+ public SystemDbContext(DbContextOptions options)
+ : base(options)
+ {
+ _model = options.FindExtension()
+ ?? throw new InvalidOperationException($"{nameof(SystemDbContext)} needs conventions; call {nameof(CmsDbContextOptionsExtensions.UseSystemModel)} on its options.");
+ }
+
+ internal string ConventionsKey => _model.Conventions.CacheKey;
+
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
+ _model.Conventions.ConfigureConventions(configurationBuilder);
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder) =>
+ new SystemModelBuilder(_model.Conventions).Apply(modelBuilder);
+}
diff --git a/src/FluentCMS.Data.PostgreSql/FluentCMS.Data.PostgreSql.csproj b/src/FluentCMS.Data.PostgreSql/FluentCMS.Data.PostgreSql.csproj
index daaac7f5b..4c5d1357c 100644
--- a/src/FluentCMS.Data.PostgreSql/FluentCMS.Data.PostgreSql.csproj
+++ b/src/FluentCMS.Data.PostgreSql/FluentCMS.Data.PostgreSql.csproj
@@ -1,5 +1,17 @@
+
+ Experimental: the PostgreSQL data provider for FluentCMS, not yet validated against a database. Storage conventions, JSON filters, job claiming and the system migrations.
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.Designer.cs b/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.Designer.cs
new file mode 100644
index 000000000..e60dcb735
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.Designer.cs
@@ -0,0 +1,242 @@
+//
+using System;
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FluentCMS.Data.PostgreSql.Migrations
+{
+ [DbContext(typeof(SystemDbContext))]
+ [Migration("20261102151140_InitialSystem")]
+ partial class InitialSystem
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid")
+ .HasColumnName("author_id");
+
+ b.Property("ClientId")
+ .HasColumnType("text")
+ .HasColumnName("client_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DocumentId")
+ .HasColumnType("uuid")
+ .HasColumnName("document_id");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("locale");
+
+ b.Property("Message")
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("Number")
+ .HasColumnType("integer")
+ .HasColumnName("number");
+
+ b.Property("Origin")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("origin");
+
+ b.Property("Snapshot")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("snapshot");
+
+ b.Property("StatusAfter")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("status_after");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Locale", "Number")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_version_document_locale_number");
+
+ b.ToTable("cms_version", (string)null);
+ });
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by");
+
+ b.Property("DocumentId")
+ .HasColumnType("uuid")
+ .HasColumnName("document_id");
+
+ b.Property("DraftVersion")
+ .HasColumnType("integer")
+ .HasColumnName("draft_version");
+
+ b.Property("FirstPublishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_published_at");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("locale");
+
+ b.Property("PublishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("published_at");
+
+ b.Property("PublishedVersion")
+ .HasColumnType("integer")
+ .HasColumnName("published_version");
+
+ b.Property("SchedulePublishAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("schedule_publish_at");
+
+ b.Property("ScheduleUnpublishAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("schedule_unpublish_at");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("status");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Locale")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_document_document_locale");
+
+ b.ToTable("cms_document", (string)null);
+ });
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Field")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("field");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("locale");
+
+ b.Property("Position")
+ .HasColumnType("integer")
+ .HasColumnName("position");
+
+ b.Property("SourceDocumentId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_document_id");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("source_type");
+
+ b.Property("State")
+ .HasColumnType("smallint")
+ .HasColumnName("state");
+
+ b.Property("TargetId")
+ .HasColumnType("uuid")
+ .HasColumnName("target_id");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("target_type");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TargetType", "TargetId")
+ .HasDatabaseName("ix_cms_reference_target");
+
+ b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_reference_source");
+
+ b.ToTable("cms_reference", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.cs b/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.cs
new file mode 100644
index 000000000..bd8c28571
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.cs
@@ -0,0 +1,120 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FluentCMS.Data.PostgreSql.Migrations
+{
+ ///
+ public partial class InitialSystem : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "cms_document",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ document_id = table.Column(type: "uuid", nullable: false),
+ locale = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ site_id = table.Column(type: "uuid", nullable: true),
+ status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ draft_version = table.Column(type: "integer", nullable: false),
+ published_version = table.Column(type: "integer", nullable: true),
+ published_at = table.Column(type: "timestamp with time zone", nullable: true),
+ first_published_at = table.Column(type: "timestamp with time zone", nullable: true),
+ deleted_at = table.Column(type: "timestamp with time zone", nullable: true),
+ deleted_by = table.Column(type: "uuid", nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ created_by = table.Column(type: "uuid", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false),
+ updated_by = table.Column(type: "uuid", nullable: false),
+ schedule_publish_at = table.Column(type: "timestamp with time zone", nullable: true),
+ schedule_unpublish_at = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_cms_document", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "cms_reference",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ source_type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ source_document_id = table.Column(type: "uuid", nullable: false),
+ state = table.Column(type: "smallint", nullable: false),
+ locale = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ field = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ position = table.Column(type: "integer", nullable: false),
+ target_type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ target_id = table.Column(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_cms_reference", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "cms_version",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ document_id = table.Column(type: "uuid", nullable: false),
+ locale = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ number = table.Column(type: "integer", nullable: false),
+ snapshot = table.Column(type: "jsonb", nullable: false),
+ status_after = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ author_id = table.Column(type: "uuid", nullable: false),
+ origin = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ client_id = table.Column(type: "text", nullable: true),
+ message = table.Column(type: "text", nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_cms_version", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "uq_cms_document_document_locale",
+ table: "cms_document",
+ columns: new[] { "document_id", "locale" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_cms_reference_target",
+ table: "cms_reference",
+ columns: new[] { "target_type", "target_id" });
+
+ migrationBuilder.CreateIndex(
+ name: "uq_cms_reference_source",
+ table: "cms_reference",
+ columns: new[] { "source_type", "source_document_id", "state", "locale", "field", "position" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "uq_cms_version_document_locale_number",
+ table: "cms_version",
+ columns: new[] { "document_id", "locale", "number" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "cms_document");
+
+ migrationBuilder.DropTable(
+ name: "cms_reference");
+
+ migrationBuilder.DropTable(
+ name: "cms_version");
+ }
+ }
+}
diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs b/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs
new file mode 100644
index 000000000..cf305ff9a
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs
@@ -0,0 +1,239 @@
+//
+using System;
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FluentCMS.Data.PostgreSql.Migrations
+{
+ [DbContext(typeof(SystemDbContext))]
+ partial class SystemDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid")
+ .HasColumnName("author_id");
+
+ b.Property("ClientId")
+ .HasColumnType("text")
+ .HasColumnName("client_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DocumentId")
+ .HasColumnType("uuid")
+ .HasColumnName("document_id");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("locale");
+
+ b.Property("Message")
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("Number")
+ .HasColumnType("integer")
+ .HasColumnName("number");
+
+ b.Property("Origin")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("origin");
+
+ b.Property("Snapshot")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("snapshot");
+
+ b.Property("StatusAfter")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("status_after");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Locale", "Number")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_version_document_locale_number");
+
+ b.ToTable("cms_version", (string)null);
+ });
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by");
+
+ b.Property("DocumentId")
+ .HasColumnType("uuid")
+ .HasColumnName("document_id");
+
+ b.Property("DraftVersion")
+ .HasColumnType("integer")
+ .HasColumnName("draft_version");
+
+ b.Property("FirstPublishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_published_at");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("locale");
+
+ b.Property("PublishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("published_at");
+
+ b.Property("PublishedVersion")
+ .HasColumnType("integer")
+ .HasColumnName("published_version");
+
+ b.Property("SchedulePublishAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("schedule_publish_at");
+
+ b.Property("ScheduleUnpublishAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("schedule_unpublish_at");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("status");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Locale")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_document_document_locale");
+
+ b.ToTable("cms_document", (string)null);
+ });
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Field")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("field");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("locale");
+
+ b.Property("Position")
+ .HasColumnType("integer")
+ .HasColumnName("position");
+
+ b.Property("SourceDocumentId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_document_id");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("source_type");
+
+ b.Property("State")
+ .HasColumnType("smallint")
+ .HasColumnName("state");
+
+ b.Property("TargetId")
+ .HasColumnType("uuid")
+ .HasColumnName("target_id");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("target_type");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TargetType", "TargetId")
+ .HasDatabaseName("ix_cms_reference_target");
+
+ b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_reference_source");
+
+ b.ToTable("cms_reference", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlBuilderExtensions.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlBuilderExtensions.cs
new file mode 100644
index 000000000..7eeff29c6
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlBuilderExtensions.cs
@@ -0,0 +1,14 @@
+using FluentCMS.Core.DependencyInjection;
+
+namespace FluentCMS.Data.PostgreSql;
+
+public static class PostgreSqlBuilderExtensions
+{
+ public static FluentCmsBuilder UsePostgreSql(this FluentCmsBuilder cms, string? connectionString)
+ {
+ ArgumentNullException.ThrowIfNull(cms);
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
+
+ return cms.UseDataProvider(new PostgreSqlDataProvider(connectionString));
+ }
+}
diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlDataProvider.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlDataProvider.cs
new file mode 100644
index 000000000..20c6cbd76
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlDataProvider.cs
@@ -0,0 +1,24 @@
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+
+namespace FluentCMS.Data.PostgreSql;
+
+public sealed class PostgreSqlDataProvider(string connectionString) : IDataProvider
+{
+ public string Name => "PostgreSQL";
+
+ public bool MigrateOnStartup => false;
+
+ public IStorageConventions Conventions { get; } = new PostgreSqlStorageConventions();
+
+ public IJsonSqlDialect JsonDialect { get; } = new PostgreSqlJsonDialect();
+
+ public IJobClaimDialect JobClaimDialect { get; } = new PostgreSqlJobClaimDialect();
+
+ public void ConfigureDbContext(DbContextOptionsBuilder options) => options.UseNpgsql(connectionString);
+
+ public void ConfigureSystemMigrations(DbContextOptionsBuilder options) =>
+ options.UseNpgsql(connectionString, npgsql => npgsql
+ .MigrationsAssembly(typeof(PostgreSqlDataProvider).Assembly)
+ .MigrationsHistoryTable(SystemDbContext.HistoryTable));
+}
diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlJobClaimDialect.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlJobClaimDialect.cs
new file mode 100644
index 000000000..8825b3c40
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlJobClaimDialect.cs
@@ -0,0 +1,35 @@
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+
+namespace FluentCMS.Data.PostgreSql;
+
+// Not yet run on a database (#2711).
+public sealed class PostgreSqlJobClaimDialect : IJobClaimDialect
+{
+ public async Task ClaimAsync(DatabaseFacade database, JobClaim claim, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(database);
+ ArgumentNullException.ThrowIfNull(claim);
+
+ var now = claim.Now.ToUniversalTime();
+ var ids = await database.SqlQuery($"""
+ UPDATE cms_job
+ SET status = {JobClaimStatus.Running}, lease_owner = {claim.Owner}, lease_until = {claim.LeaseUntil.ToUniversalTime()}, attempts = attempts + 1
+ WHERE id = (
+ SELECT id
+ FROM cms_job
+ WHERE queue = {claim.Queue}
+ AND status IN ({JobClaimStatus.Pending}, {JobClaimStatus.Running})
+ AND run_at <= {now}
+ AND attempts < max_attempts
+ AND (lease_until IS NULL OR lease_until < {now})
+ ORDER BY priority DESC, run_at, id
+ LIMIT 1
+ FOR UPDATE SKIP LOCKED)
+ RETURNING id AS "Value"
+ """).ToListAsync(cancellationToken);
+
+ return ids.Count == 0 ? null : ids[0];
+ }
+}
diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonDialect.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonDialect.cs
new file mode 100644
index 000000000..e6ac028cc
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonDialect.cs
@@ -0,0 +1,114 @@
+using System.Linq.Expressions;
+using System.Reflection;
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace FluentCMS.Data.PostgreSql;
+
+// Equality is jsonb containment (@>): it keeps the JSON type, so a number never equals the text '12', does not
+// throw on an unexpected shape, and is what a GIN index on the column serves. Not yet run on a database (#2711).
+public sealed class PostgreSqlJsonDialect : IJsonSqlDialect
+{
+ private const string BlockTypeField = "type";
+ private const string Jsonb = "jsonb";
+
+ private static readonly MethodInfo Elements = Function(nameof(PostgreSqlJsonFunctions.Elements));
+ private static readonly MethodInfo Extract = Function(nameof(PostgreSqlJsonFunctions.Extract));
+ private static readonly MethodInfo ExtractText = Function(nameof(PostgreSqlJsonFunctions.ExtractText));
+ private static readonly MethodInfo TypeOf = Function(nameof(PostgreSqlJsonFunctions.TypeOf));
+ private static readonly MethodInfo ObjectOfText = Function(nameof(PostgreSqlJsonFunctions.ObjectOfText));
+ private static readonly MethodInfo ObjectOfBigint = Function(nameof(PostgreSqlJsonFunctions.ObjectOfBigint));
+ private static readonly MethodInfo ObjectOfNumeric = Function(nameof(PostgreSqlJsonFunctions.ObjectOfNumeric));
+ private static readonly MethodInfo ObjectOfBoolean = Function(nameof(PostgreSqlJsonFunctions.ObjectOfBoolean));
+ private static readonly MethodInfo ObjectOfJson = Function(nameof(PostgreSqlJsonFunctions.ObjectOfJson));
+ private static readonly MethodInfo ArrayOfText = Function(nameof(PostgreSqlJsonFunctions.ArrayOfText));
+ private static readonly MethodInfo ArrayOfJson = Function(nameof(PostgreSqlJsonFunctions.ArrayOfJson));
+ private static readonly MethodInfo JsonContains = typeof(NpgsqlJsonDbFunctionsExtensions).GetMethod(nameof(NpgsqlJsonDbFunctionsExtensions.JsonContains))!;
+ private static readonly MethodInfo StringContains = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!;
+ private static readonly MethodInfo AnyElement = typeof(Queryable).GetMethods()
+ .Single(method => method.Name == nameof(Queryable.Any) && method.GetParameters().Length == 2)
+ .MakeGenericMethod(typeof(PostgreSqlJsonElement));
+
+ public void ConfigureFunctions(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity().HasNoKey().ToTable((string?)null);
+
+ modelBuilder.HasDbFunction(Elements).HasName("jsonb_array_elements").IsBuiltIn().HasParameter("json").HasStoreType(Jsonb);
+
+ Declare(modelBuilder, Extract, "jsonb_extract_path", Jsonb).HasParameter("json").HasStoreType(Jsonb);
+ Declare(modelBuilder, ExtractText, "jsonb_extract_path_text", "text").HasParameter("json").HasStoreType(Jsonb);
+ Declare(modelBuilder, TypeOf, "jsonb_typeof", "text").HasParameter("json").HasStoreType(Jsonb);
+
+ Declare(modelBuilder, ObjectOfText, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType("text");
+ Declare(modelBuilder, ObjectOfBigint, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType("bigint");
+ Declare(modelBuilder, ObjectOfNumeric, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType("numeric");
+ Declare(modelBuilder, ObjectOfBoolean, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType("boolean");
+ Declare(modelBuilder, ObjectOfJson, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType(Jsonb);
+ Declare(modelBuilder, ArrayOfText, "jsonb_build_array", Jsonb).HasParameter("value").HasStoreType("text");
+ Declare(modelBuilder, ArrayOfJson, "jsonb_build_array", Jsonb).HasParameter("value").HasStoreType(Jsonb);
+ }
+
+ public Expression HasBlock(Expression json, Expression blockType) =>
+ Contains(json, Expression.Call(ArrayOfJson, BlockOfType(blockType)));
+
+ // The outer containment lets a GIN index narrow the rows before the elements are expanded.
+ public Expression AnyBlock(Expression json, Expression blockType, Func predicate)
+ {
+ var element = Expression.Parameter(typeof(PostgreSqlJsonElement), "element");
+ var block = Expression.Property(element, nameof(PostgreSqlJsonElement.Value));
+ var body = Expression.AndAlso(Contains(block, BlockOfType(blockType)), predicate(block));
+
+ return Expression.AndAlso(
+ HasBlock(json, blockType),
+ Expression.Call(AnyElement, Expression.Call(Elements, json), Expression.Quote(Expression.Lambda>(body, element))));
+ }
+
+ public Expression ScalarEquals(Expression json, string field, JsonScalarType type, Expression value)
+ {
+ JsonScalar.CheckValue(type, value);
+ var key = Key(field);
+
+ return Contains(json, type switch
+ {
+ JsonScalarType.Text => Expression.Call(ObjectOfText, key, value),
+ JsonScalarType.WholeNumber => Expression.Call(ObjectOfBigint, key, Expression.Convert(value, typeof(long?))),
+ JsonScalarType.DecimalNumber => Expression.Call(ObjectOfNumeric, key, Expression.Convert(value, typeof(decimal?))),
+ JsonScalarType.Boolean => Expression.Call(ObjectOfBoolean, key, Expression.Convert(value, typeof(bool?))),
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
+ });
+ }
+
+ public Expression ScalarContains(Expression json, string field, Expression value)
+ {
+ JsonScalar.CheckValue(JsonScalarType.Text, value);
+ var key = Key(field);
+
+ return Expression.AndAlso(
+ Expression.Equal(Expression.Call(TypeOf, Expression.Call(Extract, json, key)), Expression.Constant("string")),
+ Expression.Call(Expression.Call(ExtractText, json, key), StringContains, value));
+ }
+
+ public Expression ArrayContains(Expression json, string field, Expression value)
+ {
+ JsonScalar.CheckValue(JsonScalarType.Text, value);
+
+ return Contains(json, Expression.Call(ObjectOfJson, Key(field), Expression.Call(ArrayOfText, value)));
+ }
+
+ private static MethodCallExpression BlockOfType(Expression blockType) =>
+ Expression.Call(ObjectOfText, Key(BlockTypeField), blockType);
+
+ private static MethodCallExpression Contains(Expression json, Expression contained) => Expression.Call(
+ JsonContains,
+ Expression.Property(null, typeof(EF), nameof(EF.Functions)),
+ Expression.Convert(json, typeof(object)),
+ Expression.Convert(contained, typeof(object)));
+
+ private static ConstantExpression Key(string field) => Expression.Constant(JsonScalar.CheckField(field));
+
+ private static DbFunctionBuilder Declare(ModelBuilder modelBuilder, MethodInfo function, string name, string storeType) =>
+ modelBuilder.HasDbFunction(function).HasName(name).IsBuiltIn().HasStoreType(storeType);
+
+ private static MethodInfo Function(string name) => typeof(PostgreSqlJsonFunctions).GetMethod(name)!;
+}
diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonFunctions.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonFunctions.cs
new file mode 100644
index 000000000..83610daf3
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonFunctions.cs
@@ -0,0 +1,36 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FluentCMS.Data.PostgreSql;
+
+// A row of jsonb_array_elements.
+public sealed class PostgreSqlJsonElement
+{
+ [Column("value", TypeName = "jsonb")]
+ public string? Value { get; set; }
+}
+
+// EF function mappings; PostgreSqlJsonDialect declares them on the model. They run only as SQL.
+public static class PostgreSqlJsonFunctions
+{
+ public static IQueryable Elements(string? json) => throw new NotSupportedException();
+
+ public static string? Extract(string? json, string key) => throw new NotSupportedException();
+
+ public static string? ExtractText(string? json, string key) => throw new NotSupportedException();
+
+ public static string? TypeOf(string? json) => throw new NotSupportedException();
+
+ public static string ObjectOfText(string key, string? value) => throw new NotSupportedException();
+
+ public static string ObjectOfBigint(string key, long? value) => throw new NotSupportedException();
+
+ public static string ObjectOfNumeric(string key, decimal? value) => throw new NotSupportedException();
+
+ public static string ObjectOfBoolean(string key, bool? value) => throw new NotSupportedException();
+
+ public static string ObjectOfJson(string key, string? value) => throw new NotSupportedException();
+
+ public static string ArrayOfText(string? value) => throw new NotSupportedException();
+
+ public static string ArrayOfJson(string? value) => throw new NotSupportedException();
+}
diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlStorageConventions.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlStorageConventions.cs
new file mode 100644
index 000000000..34f107128
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlStorageConventions.cs
@@ -0,0 +1,23 @@
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace FluentCMS.Data.PostgreSql;
+
+public sealed class PostgreSqlStorageConventions : StorageConventions
+{
+ // Npgsql writes a DateTimeOffset to timestamptz only with offset 0.
+ public override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
+ configurationBuilder.Properties().HaveConversion();
+
+ public override void ConfigureJsonColumn(EntityTypeBuilder entity, PropertyBuilder column) =>
+ column.HasColumnType("jsonb");
+
+ public override void ConfigureJsonIndex(EntityTypeBuilder entity, string column) =>
+ entity.HasIndex(column).HasMethod("gin").HasDatabaseName($"gin_{entity.Metadata.GetTableName()}_{column}");
+
+ private sealed class UtcConverter() : ValueConverter(
+ value => value.ToUniversalTime(),
+ value => value);
+}
diff --git a/src/FluentCMS.Data.PostgreSql/SystemDbContextFactory.cs b/src/FluentCMS.Data.PostgreSql/SystemDbContextFactory.cs
new file mode 100644
index 000000000..24ac0be44
--- /dev/null
+++ b/src/FluentCMS.Data.PostgreSql/SystemDbContextFactory.cs
@@ -0,0 +1,18 @@
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace FluentCMS.Data.PostgreSql;
+
+// Used only by `dotnet ef migrations add`, which never opens this database. See docs/system-migrations.md.
+public sealed class SystemDbContextFactory : IDesignTimeDbContextFactory
+{
+ public SystemDbContext CreateDbContext(string[] args)
+ {
+ var provider = new PostgreSqlDataProvider("Host=localhost");
+ var options = new DbContextOptionsBuilder();
+ provider.ConfigureSystemMigrations(options);
+ options.UseSystemModel(provider.Conventions);
+ return new SystemDbContext(options.Options);
+ }
+}
diff --git a/src/FluentCMS.Data.SqlServer/FluentCMS.Data.SqlServer.csproj b/src/FluentCMS.Data.SqlServer/FluentCMS.Data.SqlServer.csproj
index daaac7f5b..94ad9cab1 100644
--- a/src/FluentCMS.Data.SqlServer/FluentCMS.Data.SqlServer.csproj
+++ b/src/FluentCMS.Data.SqlServer/FluentCMS.Data.SqlServer.csproj
@@ -1,5 +1,22 @@
+
+ Experimental: the SQL Server data provider for FluentCMS, not yet validated against a database. Storage conventions, JSON filters, job claiming and the system migrations.
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.Designer.cs b/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.Designer.cs
new file mode 100644
index 000000000..48eace499
--- /dev/null
+++ b/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.Designer.cs
@@ -0,0 +1,245 @@
+//
+using System;
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace FluentCMS.Data.SqlServer.Migrations
+{
+ [DbContext(typeof(SystemDbContext))]
+ [Migration("20261102151146_InitialSystem")]
+ partial class InitialSystem
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("author_id");
+
+ b.Property("ClientId")
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("client_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("created_at");
+
+ b.Property("DocumentId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("document_id");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("locale");
+
+ b.Property("Message")
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("message");
+
+ b.Property("Number")
+ .HasColumnType("int")
+ .HasColumnName("number");
+
+ b.Property("Origin")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("origin");
+
+ b.Property("Snapshot")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("snapshot");
+
+ b.Property("StatusAfter")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("status_after");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Locale", "Number")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_version_document_locale_number");
+
+ b.ToTable("cms_version", null, t =>
+ {
+ t.HasCheckConstraint("ck_cms_version_snapshot_json", "ISJSON([snapshot]) = 1");
+ });
+ });
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("created_by");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("deleted_by");
+
+ b.Property("DocumentId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("document_id");
+
+ b.Property("DraftVersion")
+ .HasColumnType("int")
+ .HasColumnName("draft_version");
+
+ b.Property("FirstPublishedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("first_published_at");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("locale");
+
+ b.Property("PublishedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("published_at");
+
+ b.Property("PublishedVersion")
+ .HasColumnType("int")
+ .HasColumnName("published_version");
+
+ b.Property("SchedulePublishAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("schedule_publish_at");
+
+ b.Property("ScheduleUnpublishAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("schedule_unpublish_at");
+
+ b.Property("SiteId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("site_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("status");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Locale")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_document_document_locale");
+
+ b.ToTable("cms_document", (string)null);
+ });
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("id");
+
+ b.Property("Field")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)")
+ .HasColumnName("field");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("locale");
+
+ b.Property("Position")
+ .HasColumnType("int")
+ .HasColumnName("position");
+
+ b.Property("SourceDocumentId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("source_document_id");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)")
+ .HasColumnName("source_type");
+
+ b.Property("State")
+ .HasColumnType("smallint")
+ .HasColumnName("state");
+
+ b.Property("TargetId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("target_id");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)")
+ .HasColumnName("target_type");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TargetType", "TargetId")
+ .HasDatabaseName("ix_cms_reference_target");
+
+ b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_reference_source");
+
+ b.ToTable("cms_reference", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.cs b/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.cs
new file mode 100644
index 000000000..c5892f775
--- /dev/null
+++ b/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.cs
@@ -0,0 +1,121 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FluentCMS.Data.SqlServer.Migrations
+{
+ ///
+ public partial class InitialSystem : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "cms_document",
+ columns: table => new
+ {
+ id = table.Column(type: "uniqueidentifier", nullable: false),
+ document_id = table.Column(type: "uniqueidentifier", nullable: false),
+ locale = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false),
+ type = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false),
+ site_id = table.Column(type: "uniqueidentifier", nullable: true),
+ status = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false),
+ draft_version = table.Column(type: "int", nullable: false),
+ published_version = table.Column(type: "int", nullable: true),
+ published_at = table.Column(type: "datetimeoffset", nullable: true),
+ first_published_at = table.Column(type: "datetimeoffset", nullable: true),
+ deleted_at = table.Column(type: "datetimeoffset", nullable: true),
+ deleted_by = table.Column(type: "uniqueidentifier", nullable: true),
+ created_at = table.Column(type: "datetimeoffset", nullable: false),
+ created_by = table.Column(type: "uniqueidentifier", nullable: false),
+ updated_at = table.Column(type: "datetimeoffset", nullable: false),
+ updated_by = table.Column(type: "uniqueidentifier", nullable: false),
+ schedule_publish_at = table.Column(type: "datetimeoffset", nullable: true),
+ schedule_unpublish_at = table.Column(type: "datetimeoffset", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_cms_document", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "cms_reference",
+ columns: table => new
+ {
+ id = table.Column(type: "uniqueidentifier", nullable: false),
+ source_type = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false),
+ source_document_id = table.Column(type: "uniqueidentifier", nullable: false),
+ state = table.Column(type: "smallint", nullable: false),
+ locale = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false),
+ field = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false),
+ position = table.Column(type: "int", nullable: false),
+ target_type = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false),
+ target_id = table.Column(type: "uniqueidentifier", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_cms_reference", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "cms_version",
+ columns: table => new
+ {
+ id = table.Column(type: "uniqueidentifier", nullable: false),
+ document_id = table.Column(type: "uniqueidentifier", nullable: false),
+ locale = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false),
+ number = table.Column(type: "int", nullable: false),
+ snapshot = table.Column(type: "nvarchar(max)", nullable: false),
+ status_after = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false),
+ author_id = table.Column(type: "uniqueidentifier", nullable: false),
+ origin = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false),
+ client_id = table.Column(type: "nvarchar(max)", nullable: true),
+ message = table.Column(type: "nvarchar(max)", nullable: true),
+ created_at = table.Column(type: "datetimeoffset", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_cms_version", x => x.id);
+ table.CheckConstraint("ck_cms_version_snapshot_json", "ISJSON([snapshot]) = 1");
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "uq_cms_document_document_locale",
+ table: "cms_document",
+ columns: new[] { "document_id", "locale" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_cms_reference_target",
+ table: "cms_reference",
+ columns: new[] { "target_type", "target_id" });
+
+ migrationBuilder.CreateIndex(
+ name: "uq_cms_reference_source",
+ table: "cms_reference",
+ columns: new[] { "source_type", "source_document_id", "state", "locale", "field", "position" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "uq_cms_version_document_locale_number",
+ table: "cms_version",
+ columns: new[] { "document_id", "locale", "number" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "cms_document");
+
+ migrationBuilder.DropTable(
+ name: "cms_reference");
+
+ migrationBuilder.DropTable(
+ name: "cms_version");
+ }
+ }
+}
diff --git a/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs b/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs
new file mode 100644
index 000000000..2cdcea3ea
--- /dev/null
+++ b/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs
@@ -0,0 +1,242 @@
+//
+using System;
+using FluentCMS.Core.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace FluentCMS.Data.SqlServer.Migrations
+{
+ [DbContext(typeof(SystemDbContext))]
+ partial class SystemDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("author_id");
+
+ b.Property("ClientId")
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("client_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("created_at");
+
+ b.Property("DocumentId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("document_id");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("locale");
+
+ b.Property("Message")
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("message");
+
+ b.Property("Number")
+ .HasColumnType("int")
+ .HasColumnName("number");
+
+ b.Property("Origin")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("origin");
+
+ b.Property("Snapshot")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("snapshot");
+
+ b.Property("StatusAfter")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("status_after");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Locale", "Number")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_version_document_locale_number");
+
+ b.ToTable("cms_version", null, t =>
+ {
+ t.HasCheckConstraint("ck_cms_version_snapshot_json", "ISJSON([snapshot]) = 1");
+ });
+ });
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("created_by");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("deleted_by");
+
+ b.Property("DocumentId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("document_id");
+
+ b.Property("DraftVersion")
+ .HasColumnType("int")
+ .HasColumnName("draft_version");
+
+ b.Property("FirstPublishedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("first_published_at");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("locale");
+
+ b.Property("PublishedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("published_at");
+
+ b.Property("PublishedVersion")
+ .HasColumnType("int")
+ .HasColumnName("published_version");
+
+ b.Property("SchedulePublishAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("schedule_publish_at");
+
+ b.Property("ScheduleUnpublishAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("schedule_unpublish_at");
+
+ b.Property("SiteId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("site_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("status");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetimeoffset")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Locale")
+ .IsUnique()
+ .HasDatabaseName("uq_cms_document_document_locale");
+
+ b.ToTable("cms_document", (string)null);
+ });
+
+ modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("id");
+
+ b.Property("Field")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)")
+ .HasColumnName("field");
+
+ b.Property("Locale")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)")
+ .HasColumnName("locale");
+
+ b.Property("Position")
+ .HasColumnType("int")
+ .HasColumnName("position");
+
+ b.Property("SourceDocumentId")
+ .HasColumnType("uniqueidentifier")
+ .HasColumnName("source_document_id");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)")
+ .HasColumnName("source_type");
+
+ b.Property