Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/Trax.Effect.Data.Postgres/Migrations/035_persisted_operations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
-- Persisted GraphQL operations: server-managed manifest mapping operation IDs to documents.
-- Mobile clients (and any other shipped consumer) ship a build-time-stable id
-- (e.g. "userProfile.v1"). The server resolves the id to the current document text,
-- which can be hot-edited without a client redeploy as long as the response shape
-- stays compatible. The shape_fingerprint column captures the structural hash that
-- the application-layer guardrail compares against on edits.

create table if not exists trax.persisted_operation
(
-- Tenant scope. Empty string ('') is the sentinel for "no tenant" / single-tenant
-- deployments; a real tenant id is any non-empty string. The column is NOT NULL
-- because it participates in the primary key (Postgres disallows NULLs in PK columns).
-- The storage layer normalizes string? at the C# boundary: null is mapped to ''
-- on write and back to null on read, so callers see clean nullable semantics.
-- Kept on the primary key to allow per-tenant manifests in a future release without
-- requiring a schema migration.
tenant_key varchar not null default '',

-- Stable build-time id. Convention is "<operationName>.v<N>" (e.g. "userProfile.v1");
-- developers bump the version manually when a breaking shape change is required.
id varchar not null,

-- Original GraphQL operation name. Stored separately so dashboards / CLI can group
-- by name regardless of version.
operation_name varchar not null,

-- Numeric version extracted from the id suffix. Convenience column for ordering.
version integer not null,

-- The GraphQL document text that the id resolves to.
document text not null,

-- Canonicalized structural hash of the response shape (sha-256 hex).
-- The shape-diff guardrail compares old vs new on edits and rejects changes
-- that would break shipped clients (unless explicitly forced).
shape_fingerprint varchar not null,

-- Soft-delete flag. Deactivated rows are not served; clients sending the id
-- get a typed error so the consumer app can prompt the user to update.
is_active boolean not null default true,

-- Required reason when deactivating. Surfaces in audit history.
deprecation_reason varchar,

-- Optional human-readable description (operator-facing).
description varchar,

-- Audit timestamps.
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),

-- Composite PK: scoped by tenant so tenant A's "userProfile.v1" and tenant B's
-- "userProfile.v1" do not collide. NULL tenant_key participates in the PK.
constraint persisted_operation_pkey primary key (tenant_key, id)
);

-- Partial index on active rows: the request path filters by is_active = true,
-- and the inactive set is small relative to active.
create index if not exists persisted_operation_active_idx
on trax.persisted_operation (tenant_key, id)
where is_active = true;

-- Index for dashboards/CLI grouping by operation name within a tenant.
create index if not exists persisted_operation_name_idx
on trax.persisted_operation (tenant_key, operation_name);


-- History table: every upsert / deactivate / restore appends a row here so the
-- dashboard can show "what changed and when" and roll back to a prior document.
-- The active row in trax.persisted_operation is always the latest; history is
-- for audit and rollback only, never read on the request path.
create table if not exists trax.persisted_operation_history
(
-- Auto-incrementing surrogate. bigserial because edit history can grow large
-- in a long-lived deployment.
history_id bigserial primary key,

-- Mirrors the (tenant_key, id) of the operation row. Same sentinel semantics
-- as trax.persisted_operation.tenant_key: '' means "no tenant".
tenant_key varchar not null default '',
id varchar not null,

-- Snapshot of the document text at the time of the change.
document text not null,

-- Snapshot of the shape fingerprint at the time of the change.
shape_fingerprint varchar not null,

-- The kind of change: "upsert", "deactivate", "restore".
change_type varchar not null,

-- When the change happened.
changed_at timestamptz not null default now(),

-- Required on deactivate, optional on upsert/restore.
changed_reason varchar
);

-- Lookup index for "show me the history of this operation in reverse-chronological order".
create index if not exists persisted_operation_history_id_idx
on trax.persisted_operation_history (tenant_key, id, changed_at desc);
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
-- Persisted GraphQL operations: server-managed manifest mapping operation IDs
-- to documents. See the Postgres counterpart (035_persisted_operations.sql)
-- for the full design rationale; this is the SQLite-shape mirror used by
-- the integration tests.

CREATE TABLE IF NOT EXISTS persisted_operation (
-- '' is the sentinel for "no tenant"; the C# storage layer normalizes
-- null<->'' at the boundary because SQL forbids NULLs in PK columns.
tenant_key TEXT NOT NULL DEFAULT '',
id TEXT NOT NULL,
operation_name TEXT NOT NULL,
version INTEGER NOT NULL,
document TEXT NOT NULL,
shape_fingerprint TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
deprecation_reason TEXT,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (tenant_key, id)
);

CREATE INDEX IF NOT EXISTS persisted_operation_active_idx
ON persisted_operation (tenant_key, id)
WHERE is_active = 1;

CREATE INDEX IF NOT EXISTS persisted_operation_name_idx
ON persisted_operation (tenant_key, operation_name);


CREATE TABLE IF NOT EXISTS persisted_operation_history (
history_id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_key TEXT NOT NULL DEFAULT '',
id TEXT NOT NULL,
document TEXT NOT NULL,
shape_fingerprint TEXT NOT NULL,
change_type TEXT NOT NULL,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
changed_reason TEXT
);

CREATE INDEX IF NOT EXISTS persisted_operation_history_id_idx
ON persisted_operation_history (tenant_key, id, changed_at DESC);
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using BaseModel = Trax.Effect.Models.PersistedOperation.PersistedOperation;

namespace Trax.Effect.Data.Models.PersistedOperation;

/// <summary>
/// Provides EF Core configuration for
/// <see cref="Trax.Effect.Models.PersistedOperation.PersistedOperation"/>.
/// Mirrors the <see cref="Trax.Effect.Data.Models.Manifest.PersistentManifest"/>
/// pattern.
/// </summary>
public class PersistentPersistedOperation : BaseModel
{
/// <summary>
/// Apply EF Core mapping for <see cref="BaseModel"/>. Public because
/// the GraphQL persisted-operations package consumes this from a
/// different assembly to wire its dedicated DbContext.
/// </summary>
public static void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<BaseModel>(entity =>
{
entity.ToTable("persisted_operation", "trax");
entity.HasKey(e => new { e.TenantKey, e.Id });

// tenant_key is NOT NULL in the schema; the storage layer
// normalizes null at the boundary so callers can pass null.
entity.Property(e => e.TenantKey).HasDefaultValueSql("''").IsRequired();

entity.Property(e => e.Id).IsRequired();
entity.Property(e => e.OperationName).IsRequired();
entity.Property(e => e.Document).HasColumnType("text").IsRequired();
entity.Property(e => e.ShapeFingerprint).IsRequired();
entity.Property(e => e.IsActive).HasDefaultValue(true);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()");

entity
.HasIndex(e => new { e.TenantKey, e.Id })
.HasFilter("is_active = true")
.HasDatabaseName("persisted_operation_active_idx");
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using BaseModel = Trax.Effect.Models.PersistedOperationHistory.PersistedOperationHistory;

namespace Trax.Effect.Data.Models.PersistedOperationHistory;

/// <summary>
/// EF Core configuration for
/// <see cref="Trax.Effect.Models.PersistedOperationHistory.PersistedOperationHistory"/>.
/// </summary>
public class PersistentPersistedOperationHistory : BaseModel
{
/// <summary>
/// Apply EF Core mapping for <see cref="BaseModel"/>. Public because
/// the GraphQL persisted-operations package consumes this from a
/// different assembly to wire its dedicated DbContext.
/// </summary>
public static void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<BaseModel>(entity =>
{
entity.ToTable("persisted_operation_history", "trax");
entity.HasKey(e => e.HistoryId);
entity.Property(e => e.HistoryId).ValueGeneratedOnAdd();

entity.Property(e => e.TenantKey).HasDefaultValueSql("''").IsRequired();

entity.Property(e => e.Id).IsRequired();
entity.Property(e => e.Document).HasColumnType("text").IsRequired();
entity.Property(e => e.ShapeFingerprint).IsRequired();
entity.Property(e => e.ChangeType).IsRequired();
entity.Property(e => e.ChangedAt).HasDefaultValueSql("now()");

entity
.HasIndex(e => new
{
e.TenantKey,
e.Id,
e.ChangedAt,
})
.IsDescending(false, false, true)
.HasDatabaseName("persisted_operation_history_id_idx");
});
}
}
18 changes: 18 additions & 0 deletions src/Trax.Effect.Data/Services/DataContext/DataContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ public class DataContext<TDbContext>(DbContextOptions<TDbContext> options)

public DbSet<SchedulerConfig> SchedulerConfigs { get; set; }

/// <summary>
/// Gets or sets the DbSet for persisted GraphQL operation manifest rows.
/// </summary>
public DbSet<Effect.Models.PersistedOperation.PersistedOperation> PersistedOperations { get; set; }

/// <summary>
/// Gets or sets the DbSet for persisted-operation audit history.
/// </summary>
public DbSet<Effect.Models.PersistedOperationHistory.PersistedOperationHistory> PersistedOperationHistories { get; set; }

#endregion

/// <summary>
Expand All @@ -120,6 +130,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
base.OnModelCreating(modelBuilder);

modelBuilder.ApplyEntityOnModelCreating();

// Persisted-operation entities use a string composite primary key,
// so they do not implement IModel and are not discovered by
// ApplyEntityOnModelCreating. Map them explicitly.
Models.PersistedOperation.PersistentPersistedOperation.OnModelCreating(modelBuilder);
Models.PersistedOperationHistory.PersistentPersistedOperationHistory.OnModelCreating(
modelBuilder
);
}

/// <summary>
Expand Down
12 changes: 12 additions & 0 deletions src/Trax.Effect.Data/Services/DataContext/IDataContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ public interface IDataContext : IEffectProvider, IAsyncDisposable
/// </summary>
DbSet<SchedulerConfig> SchedulerConfigs { get; }

/// <summary>
/// Persisted GraphQL operation manifest rows. Maps a build-time-stable
/// operation id to the document text the server resolves it to.
/// </summary>
DbSet<Effect.Models.PersistedOperation.PersistedOperation> PersistedOperations { get; }

/// <summary>
/// Append-only audit history for every persisted-operation upsert,
/// deactivate, and restore.
/// </summary>
DbSet<Effect.Models.PersistedOperationHistory.PersistedOperationHistory> PersistedOperationHistories { get; }

#endregion

/// <summary>
Expand Down
88 changes: 88 additions & 0 deletions src/Trax.Effect/Models/PersistedOperation/PersistedOperation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.ComponentModel.DataAnnotations.Schema;

namespace Trax.Effect.Models.PersistedOperation;

/// <summary>
/// Base model for <c>trax.persisted_operation</c>: a build-time-stable
/// GraphQL operation id mapping to a server-managed document. The mapping
/// lets shipped clients (mobile, etc.) hot-fix server-side queries without
/// a redeploy.
/// </summary>
/// <remarks>
/// EF Core mapping lives in
/// <see cref="Trax.Effect.Data.Models.PersistedOperation.PersistentPersistedOperation"/>.
/// <para>
/// <see cref="TenantKey"/> uses null at the C# boundary; storage layers
/// normalize null to the empty-string sentinel used by the database
/// composite primary key.
/// </para>
/// </remarks>
public class PersistedOperation
{
/// <summary>
/// Tenant scope. Null at the C# boundary; persisted as empty string to
/// satisfy the composite primary key (Postgres disallows NULLs in PK columns).
/// </summary>
[Column("tenant_key")]
public string? TenantKey { get; set; }

/// <summary>
/// Build-time-stable id (e.g. <c>userProfile_v1</c>).
/// </summary>
[Column("id")]
public string Id { get; set; } = null!;

/// <summary>
/// Original GraphQL operation name (e.g. <c>UserProfile</c>).
/// </summary>
[Column("operation_name")]
public string OperationName { get; set; } = null!;

/// <summary>
/// Numeric version extracted from the id suffix.
/// </summary>
[Column("version")]
public int Version { get; set; }

/// <summary>
/// The GraphQL document text the id resolves to.
/// </summary>
[Column("document")]
public string Document { get; set; } = null!;

/// <summary>
/// Canonicalized structural hash of the response shape (sha-256 hex).
/// </summary>
[Column("shape_fingerprint")]
public string ShapeFingerprint { get; set; } = null!;

/// <summary>
/// True when the row is being served. False indicates a soft-delete.
/// </summary>
[Column("is_active")]
public bool IsActive { get; set; } = true;

/// <summary>
/// Required when <see cref="IsActive"/> is false.
/// </summary>
[Column("deprecation_reason")]
public string? DeprecationReason { get; set; }

/// <summary>
/// Optional human-readable description shown to operators.
/// </summary>
[Column("description")]
public string? Description { get; set; }

/// <summary>
/// When the row was first inserted.
/// </summary>
[Column("created_at")]
public DateTime CreatedAt { get; set; }

/// <summary>
/// When the row was last modified (upsert, deactivate, or restore).
/// </summary>
[Column("updated_at")]
public DateTime UpdatedAt { get; set; }
}
Loading
Loading