• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

karanshukla / openresto / 26697780851

30 May 2026 11:28PM UTC coverage: 87.07%. First build
26697780851

push

github

web-flow
Merge pull request #83 from karanshukla/claude/epic-turing-M2z6n

fix: remap legacy migration history on existing VPS databases

2653 of 3379 branches covered (78.51%)

Branch coverage included in aggregate %.

23 of 72 new or added lines in 1 file covered. (31.94%)

5684 of 6196 relevant lines covered (91.74%)

69.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

57.47
/OpenRestoApi/Extensions/DatabaseExtensions.cs
1
using Microsoft.EntityFrameworkCore;
2
using OpenRestoApi.Infrastructure.Persistence;
3

4
namespace OpenRestoApi.Extensions;
5

6
public static partial class DatabaseExtensions
7
{
8
    [LoggerMessage(Level = LogLevel.Information, Message = "Startup Diagnostics:")]
9
    private static partial void LogStartupDiagnostics(ILogger logger);
10

11
    [LoggerMessage(Level = LogLevel.Information, Message = "  - Connection String: {ConnectionString}")]
12
    private static partial void LogConnectionString(ILogger logger, string connectionString);
13

14
    [LoggerMessage(Level = LogLevel.Information, Message = "  - Current User: {User}")]
15
    private static partial void LogCurrentUser(ILogger logger, string user);
16

17
    [LoggerMessage(Level = LogLevel.Information, Message = "  - Resolved DB Path: {Path}")]
18
    private static partial void LogResolvedDbPath(ILogger logger, string path);
19

20
    [LoggerMessage(Level = LogLevel.Information, Message = "  - DB Directory: {Dir} (Exists: {Exists})")]
21
    private static partial void LogDbDirectoryInfo(ILogger logger, string dir, bool exists);
22

23
    [LoggerMessage(Level = LogLevel.Information, Message = "  - DB Directory is writable.")]
24
    private static partial void LogDbDirectoryWritable(ILogger logger);
25

26
    [LoggerMessage(Level = LogLevel.Error, Message = "  - DB Directory IS NOT WRITABLE: {Message}")]
27
    private static partial void LogDbDirectoryNotWritable(ILogger logger, string message);
28

29
    [LoggerMessage(Level = LogLevel.Information, Message = "  - Created DB Directory: {Dir}")]
30
    private static partial void LogCreatedDbDirectory(ILogger logger, string dir);
31

32
    [LoggerMessage(Level = LogLevel.Error, Message = "  - Failed to create DB Directory: {Message}")]
33
    private static partial void LogFailedToCreateDbDirectory(ILogger logger, string message);
34

35
    [LoggerMessage(Level = LogLevel.Warning, Message = "Database volume not yet writable/available (SQLite Error {ErrorCode}). Retry {RetryCount}/{MaxRetries} in {Delay}ms...")]
36
    private static partial void LogDatabaseRetry(ILogger logger, int errorCode, int retryCount, int maxRetries, int delay);
37

38
    [LoggerMessage(Level = LogLevel.Critical, Message = "FATAL ERROR during database initialization. The application cannot start.")]
39
    private static partial void LogFatalError(ILogger logger, Exception ex);
40

41
    [LoggerMessage(Level = LogLevel.Information, Message = "  - Legacy migration history detected. Remapping {Count} old migration(s) to consolidated InitialCreate.")]
42
    private static partial void LogMigrationRemap(ILogger logger, int count);
43

44
    [LoggerMessage(Level = LogLevel.Information, Message = "  - Migration history is already up to date. No remap needed.")]
45
    private static partial void LogMigrationRemapSkipped(ILogger logger);
46

47
    private const string ConsolidatedMigrationId = "20260530173531_InitialCreate";
48

49
    // Migration IDs that existed before the history was squashed into InitialCreate.
50
    private static readonly string[] LegacyMigrationIds =
2✔
51
    [
2✔
52
        "20260129132410_AddSectionsAndTables",
2✔
53
        "20260131215731_AddBookingsTable",
2✔
54
        "20260503001441_AddRestaurantPauseUntil",
2✔
55
        "20260518000001_AddBookingSearchIndexes",
2✔
56
        "20260524000001_AddSendBookingConfirmations",
2✔
57
        "20260524000002_AddMediaImageUrls",
2✔
58
        "20260525213129_AddEmailFailures",
2✔
59
    ];
2✔
60

61
    private static void RemapLegacyMigrationHistory(AppDbContext db, ILogger logger)
62
    {
63
        // Only attempt this if the DB already exists (i.e. the history table exists).
64
        if (!db.Database.CanConnect())
20!
NEW
65
            return;
×
66

67
        try
68
        {
69
            // Use raw ADO.NET so we don't depend on the EF migration infrastructure itself.
70
            // Track whether we opened the connection so we can restore its original state.
71
            var connection = db.Database.GetDbConnection();
20✔
72
            bool weOpenedConnection = connection.State != System.Data.ConnectionState.Open;
20✔
73
            if (weOpenedConnection)
20!
NEW
74
                connection.Open();
×
75

76
            try
77
            {
78
                using var checkCmd = connection.CreateCommand();
20✔
79
                checkCmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
20✔
80
                var tableCount = (long)(checkCmd.ExecuteScalar() ?? 0L);
20!
81
                if (tableCount == 0)
20!
82
                    return; // fresh install — nothing to remap
20✔
83

84
                // Count how many legacy migration IDs are present.
NEW
85
                var placeholders = string.Join(",", LegacyMigrationIds.Select((_, i) => $"@p{i}"));
×
NEW
86
                using var countCmd = connection.CreateCommand();
×
NEW
87
                countCmd.CommandText = $"SELECT COUNT(*) FROM __EFMigrationsHistory WHERE MigrationId IN ({placeholders})";
×
NEW
88
                for (int i = 0; i < LegacyMigrationIds.Length; i++)
×
89
                {
NEW
90
                    var p = countCmd.CreateParameter();
×
NEW
91
                    p.ParameterName = $"@p{i}";
×
NEW
92
                    p.Value = LegacyMigrationIds[i];
×
NEW
93
                    countCmd.Parameters.Add(p);
×
94
                }
NEW
95
                var legacyCount = (long)(countCmd.ExecuteScalar() ?? 0L);
×
96

NEW
97
                if (legacyCount == 0)
×
98
                {
NEW
99
                    LogMigrationRemapSkipped(logger);
×
NEW
100
                    return;
×
101
                }
102

NEW
103
                LogMigrationRemap(logger, (int)legacyCount);
×
104

105
                // Wrap the remap in a transaction for atomicity.
NEW
106
                using var tx = connection.BeginTransaction();
×
107
                try
108
                {
109
                    // Remove all legacy entries.
NEW
110
                    using var deleteCmd = connection.CreateCommand();
×
NEW
111
                    deleteCmd.Transaction = tx;
×
NEW
112
                    deleteCmd.CommandText = $"DELETE FROM __EFMigrationsHistory WHERE MigrationId IN ({placeholders})";
×
NEW
113
                    for (int i = 0; i < LegacyMigrationIds.Length; i++)
×
114
                    {
NEW
115
                        var p = deleteCmd.CreateParameter();
×
NEW
116
                        p.ParameterName = $"@p{i}";
×
NEW
117
                        p.Value = LegacyMigrationIds[i];
×
NEW
118
                        deleteCmd.Parameters.Add(p);
×
119
                    }
NEW
120
                    deleteCmd.ExecuteNonQuery();
×
121

122
                    // Insert the consolidated migration ID if it isn't there yet.
NEW
123
                    using var insertCmd = connection.CreateCommand();
×
NEW
124
                    insertCmd.Transaction = tx;
×
NEW
125
                    insertCmd.CommandText =
×
NEW
126
                        "INSERT OR IGNORE INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES (@id, @ver)";
×
NEW
127
                    var idParam = insertCmd.CreateParameter();
×
NEW
128
                    idParam.ParameterName = "@id";
×
NEW
129
                    idParam.Value = ConsolidatedMigrationId;
×
NEW
130
                    insertCmd.Parameters.Add(idParam);
×
NEW
131
                    var verParam = insertCmd.CreateParameter();
×
NEW
132
                    verParam.ParameterName = "@ver";
×
NEW
133
                    verParam.Value = "10.0.0";
×
NEW
134
                    insertCmd.Parameters.Add(verParam);
×
NEW
135
                    insertCmd.ExecuteNonQuery();
×
136

NEW
137
                    tx.Commit();
×
NEW
138
                }
×
NEW
139
                catch
×
140
                {
NEW
141
                    tx.Rollback();
×
NEW
142
                    throw;
×
143
                }
NEW
144
            }
×
145
            finally
146
            {
147
                // Restore connection to its original state so EF's Migrate() isn't surprised.
148
                if (weOpenedConnection)
20!
NEW
149
                    connection.Close();
×
150
            }
20✔
NEW
151
        }
×
NEW
152
        catch (Exception ex)
×
153
        {
154
            // Non-fatal: if the remap fails, Migrate() will surface a clearer error.
NEW
155
            logger.LogWarning(ex, "Could not remap legacy migration history. Proceeding anyway.");
×
NEW
156
        }
×
157
    }
20✔
158

159
    public static string GetAppConnectionString(this IConfiguration configuration, IWebHostEnvironment env)
160
    {
161
        string? connectionString = configuration.GetConnectionString("DefaultConnection")
28✔
162
            ?? Environment.GetEnvironmentVariable("CONNECTION_STRING");
28✔
163

164
        if (string.IsNullOrEmpty(connectionString))
28✔
165
        {
166
            string dbPath = env.IsDevelopment() ? "./openresto.db" : "/data/openresto.db";
24✔
167
            connectionString = $"Data Source={dbPath}";
24✔
168
        }
169

170
        return connectionString;
28✔
171
    }
172

173
    public static IServiceCollection AddDatabaseSetup(this IServiceCollection services, string connectionString, IWebHostEnvironment env)
174
    {
175
        SqlitePragmaInterceptor pragmaInterceptor = new();
20✔
176

177
        services.AddDbContext<AppDbContext>(options =>
20✔
178
        {
20✔
179
            options.UseSqlite(connectionString, sqliteOptions =>
470✔
180
            {
470✔
181
                sqliteOptions.CommandTimeout(30);
470✔
182
                sqliteOptions.ExecutionStrategy(d => new SqliteRetryingExecutionStrategy(d));
900✔
183
            });
940✔
184
            options.AddInterceptors(pragmaInterceptor);
470✔
185
            options.ConfigureWarnings(w =>
470✔
186
                w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.MultipleCollectionIncludeWarning));
940✔
187
            options.EnableSensitiveDataLogging(env.IsDevelopment());
470✔
188
            options.EnableDetailedErrors(env.IsDevelopment());
470✔
189
        });
490✔
190

191
        return services;
20✔
192
    }
193

194
    public static void InitializeDatabase(this WebApplication app, string connectionString, IConfiguration configuration)
195
    {
196
        using IServiceScope scope = app.Services.CreateScope();
20✔
197
        AppDbContext db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
20✔
198
        ILogger logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
20✔
199

200
        try
201
        {
202
            LogStartupDiagnostics(logger);
20✔
203
            LogConnectionString(logger, connectionString);
20✔
204
            LogCurrentUser(logger, Environment.UserName);
20✔
205

206
            // Ensure the DB directory exists (needed for Docker volume mounts)
207
            string dbFile = connectionString;
20✔
208
            if (connectionString.Contains(';'))
20!
209
            {
210
                var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries);
×
211
                var ds = parts.FirstOrDefault(p => p.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase));
×
212
                if (ds != null)
×
213
                {
214
                    dbFile = ds.Substring("Data Source=".Length);
×
215
                }
216
            }
217
            else if (connectionString.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase))
20✔
218
            {
219
                dbFile = connectionString.Substring("Data Source=".Length);
20✔
220
            }
221

222
            if (!string.IsNullOrEmpty(dbFile))
20✔
223
            {
224
                string fullPath = Path.GetFullPath(dbFile);
20✔
225
                string? dir = Path.GetDirectoryName(fullPath);
20✔
226
                LogResolvedDbPath(logger, fullPath);
20✔
227
                if (dir != null)
20✔
228
                {
229
                    bool dirExists = Directory.Exists(dir);
20✔
230
                    LogDbDirectoryInfo(logger, dir, dirExists);
20✔
231
                    if (!dirExists)
20!
232
                    {
233
                        try { Directory.CreateDirectory(dir); LogCreatedDbDirectory(logger, dir); }
20✔
234
                        catch (Exception ex) { LogFailedToCreateDbDirectory(logger, ex.Message); }
60✔
235
                    }
236
                    else
237
                    {
238
                        try
239
                        {
240
                            string testFile = Path.Combine(dir, ".write-test-" + Guid.NewGuid().ToString("N"));
×
241
                            File.WriteAllText(testFile, "test");
×
242
                            File.Delete(testFile);
×
243
                            LogDbDirectoryWritable(logger);
×
244
                        }
×
245
                        catch (Exception ex) { LogDbDirectoryNotWritable(logger, ex.Message); }
×
246
                    }
247
                }
248
            }
249

250
            // Squash migration history: if the DB still has the old incremental migration IDs
251
            // (from before the consolidation into InitialCreate), replace them all with the
252
            // single consolidated migration so EF doesn't try to CREATE already-existing tables.
253
            RemapLegacyMigrationHistory(db, logger);
20✔
254

255
            // Apply any pending EF migrations (creates DB on first run, adds columns on upgrade)
256
            int maxRetries = 10;
20✔
257
            int retryDelayMs = 2000;
20✔
258
            bool success = false;
20✔
259

260
            for (int i = 1; i <= maxRetries; i++)
40✔
261
            {
262
                try
263
                {
264
                    db.Database.Migrate();
20✔
265

266
                    DbSeeder.Seed(db);
20✔
267

268
                    if (!db.AdminCredentials.Any())
20✔
269
                    {
270
                        string? configEmail = configuration["Admin:Email"];
20✔
271
                        string email = !string.IsNullOrWhiteSpace(configEmail)
20!
272
                            ? configEmail
20✔
273
                            : Environment.GetEnvironmentVariable("ADMIN_EMAIL") ?? "admin@openresto.com";
20✔
274

275
                        string? configPassword = configuration["Admin:Password"];
20✔
276
                        string? password = !string.IsNullOrWhiteSpace(configPassword)
20!
277
                            ? configPassword
20✔
278
                            : Environment.GetEnvironmentVariable("ADMIN_PASSWORD");
20✔
279

280
                        if (string.IsNullOrWhiteSpace(password))
20!
281
                        {
282
                            throw new InvalidOperationException(
×
283
                                "Admin:Password must be configured before first use. Set it via ADMIN_PASSWORD env var.");
×
284
                        }
285
                        byte[] saltBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(16);
20✔
286
                        string salt = Convert.ToBase64String(saltBytes);
20✔
287
                        byte[] hashBytes = System.Security.Cryptography.Rfc2898DeriveBytes.Pbkdf2(
20✔
288
                            password, saltBytes, 100_000,
20✔
289
                            System.Security.Cryptography.HashAlgorithmName.SHA256, 32);
20✔
290
                        string hash = Convert.ToBase64String(hashBytes);
20✔
291
                        db.AdminCredentials.Add(new OpenRestoApi.Core.Domain.AdminCredential
20✔
292
                        {
20✔
293
                            Email = email,
20✔
294
                            PasswordHash = hash,
20✔
295
                            PasswordSalt = salt,
20✔
296
                        });
20✔
297
                        db.SaveChanges();
20✔
298
                    }
299

300
                    success = true;
20✔
301
                    break;
20✔
302
                }
303
                catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode == 8 || ex.SqliteErrorCode == 14 || ex.SqliteErrorCode == 5)
×
304
                {
305
                    LogDatabaseRetry(logger, ex.SqliteErrorCode, i, maxRetries, retryDelayMs);
×
306
                    if (i == maxRetries)
×
307
                    {
308
                        throw;
×
309
                    }
310

311
                    Thread.Sleep(retryDelayMs);
×
312
                }
×
313
            }
314

315
            if (!success)
20!
316
            {
317
                throw new InvalidOperationException("Failed to initialize database after multiple retries.");
×
318
            }
319
        }
20✔
320
        catch (Exception ex)
×
321
        {
322
            LogFatalError(logger, ex);
×
323
            throw;
×
324
        }
325
    }
20✔
326
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc