feat(api/offline): idempotency-key middleware for safe write retries
Backend half of offline Phase 1. Lets the offline outbox replay a write after a
lost response without executing it twice (e.g. an order whose POST reached the
server but whose reply never came back).
- IdempotencyRecord entity + table (unique index on (Scope, Key)); migration
AddIdempotencyRecords. Standalone POCO — no tenant/soft-delete filters.
- IdempotencyMiddleware (after TenantMiddleware, before plan-limit/controllers):
opt-in via `Idempotency-Key` header on POST/PUT/PATCH/DELETE.
* Completed key → replays stored status+body with `Idempotent-Replay: true`.
* In-progress key → 409 IDEMPOTENCY_IN_PROGRESS; the unique index serializes
racing first requests; stale (>60s) reservations are recovered after a crash.
* Only <500 responses are cached; 5xx is released so the client can retry.
Bookkeeping runs in isolated DI scopes so it never contaminates the controller's
unit of work. Keys are scoped per café — no cross-tenant collisions.
- 5 middleware tests (replay/execute-once, distinct key, pass-through, tenant
isolation, 5xx-not-cached). Full suite 86 passing.
Next in Phase 1: generalize the POS order queue into a generic client outbox that
sends these keys and remaps client→server ids.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -82,10 +82,25 @@ public class AppDbContext : DbContext
|
||||
// Immutable audit trail of sensitive POS / management actions.
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
|
||||
// Idempotency keys for safe retry of offline-replayed writes.
|
||||
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<IdempotencyRecord>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
// One result per (tenant, key). The unique index also serializes
|
||||
// concurrent first-time requests carrying the same key.
|
||||
e.HasIndex(x => new { x.Scope, x.Key }).IsUnique();
|
||||
e.Property(x => x.Scope).HasMaxLength(64).IsRequired();
|
||||
e.Property(x => x.Key).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Method).HasMaxLength(10).IsRequired();
|
||||
e.Property(x => x.Path).HasMaxLength(512).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PushDevice>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
+3364
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIdempotencyRecords : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "IdempotencyRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Scope = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
Path = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
ResponseStatusCode = table.Column<int>(type: "integer", nullable: false),
|
||||
ResponseBody = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_IdempotencyRecords", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_IdempotencyRecords_Scope_Key",
|
||||
table: "IdempotencyRecords",
|
||||
columns: new[] { "Scope", "Key" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "IdempotencyRecords");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1129,6 +1129,54 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.ToTable("Expenses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.IdempotencyRecord", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Method")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("ResponseBody")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ResponseStatusCode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Scope", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("IdempotencyRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
||||
Reference in New Issue
Block a user