Files
kestrelsnest-blog/content/posts/2025-09-15-the-architecture-challenge-translating-19-years-of-rails-logic-to-modern-sveltekit.md
2025-09-15 09:38:20 -04:00

980 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: "The Architecture Challenge: Translating 19 Years of Rails Logic to Modern SvelteKit"
description: How I translated a 19-year Rails monolith to SvelteKit—TypeScript services, Drizzle, subdomain multitenancy, role-based auth, secure theming — without downtime.
date: 2025-09-15T01:44:05.496Z
preview: ""
draft: false
tags:
- locallygrown
- svelte
- sveltekit
- rails
categories:
- locallygrown
lastmod: 2025-09-15T02:22:37.194Z
keywords:
- locallygrown
- svelte
- rails
slug: locallygrown-rails-svelte-migration
---
_How I migrated decades of complex ActiveRecord relationships, subdomain multitenancy, and business logic to a modern stack without losing a single feature_
> **Note on Code Examples:** All code samples in this post are simplified, illustrative examples to demonstrate concepts, and not actual production code. They're edited for clarity and brevity. Please don't point out all the bugs! 😊
---
## TL;DR
* Rebuilt 19 years of Rails in SvelteKit while production stayed live.
* TypeScript domain model + service layer, Drizzle ORM, subdomain multitenancy, role-based auth, secure theming.
* Zero data transformation; two-month preview; 2 AM, all-or-nothing cutover.
* 1,865 commits → feature parity.
---
## The Mountain I Had to Climb
Early 2025: I opened my 19-year-old Rails codebase and knew this wouldn't be a migration: it would be a translation of an entire business. Quietly, I gave myself until June to prove it could work. If not, 2025 would be the final year for LocallyGrown.net.
I began working in secret. I didn't want to signal that the platform was in trouble and cause markets to jump ship early, but I also couldn't promise fixes to the mounting requests that had been piling up. If by June I didn't believe it would work, I'd begin winding down the system and helping markets migrate to competitors.
Everything rode on this attempt. I gave it long odds.
**The numbers were staggering:**
* **23 ActiveRecord models** with complex relationships
* **23 controllers** handling everything from user authentication to harvest coordination
* **Custom subdomain routing** isolating dozens of markets
* **Role-based permission system** with 5 user types across multiple contexts
* **Dynamic customization engine** allowing markets to inject custom HTML, CSS, and JavaScript
* **Complex pricing logic** with market-specific fees, grower percentages, and customer credits
* **Automated email workflows** triggered by state changes throughout the system
* **File upload handling** for product images, virtual farm tours, and market documents
* **Background job processing** for reports and notifications
Rails conventions interconnected each piece so tightly that removing Rails would collapse the system like a house of cards.
---
## Architecture Challenges Quick Navigation
* [Challenge 1: Domain Model Translation](#challenge-1-the-domain-model-translation) - Untangling ActiveRecord into TypeScript services
* [Challenge 2: Schema Strategy](#challenge-2-schema-strategy-keep-vs-redesign) - Why I kept the messy Rails database
* [Challenge 3: Subdomain Multitenancy](#challenge-3-subdomain-multitenancy) - Building market isolation without Rails magic
* [Challenge 4: Permission System](#challenge-4-the-permission-system) - Centralizing scattered authorization checks
* [Challenge 5: Customization Redesign](#challenge-5-the-customization-system-redesign) - From dangerous injection to secure theming
* [Challenge 6: All-or-Nothing Migration](#challenge-6-the-all-or-nothing-migration-constraint) - Why parallel systems were impossible
* [Challenge 7: Asset Migration](#challenge-7-the-asset-migration-nobody-thinks-about) - Migrating 19 years of images to cloud storage
* [Challenge 8: Beta Testing Strategy](#challenge-8-the-beta-testing-strategy) - Two months of parallel preview testing
---
## The Strategy: Decompose, Translate, Rebuild
I couldn't just port Rails code to SvelteKit, as the paradigms are fundamentally different. Rails concentrated domain logic inside ActiveRecord; I moved that into framework-agnostic TypeScript services. SvelteKit handled routing/SSR, but business rules lived outside the web layer. I needed a systematic approach.
**My Migration Strategy:**
1. **Extract the domain model** from Rails into pure TypeScript classes
2. **Build a service layer** to handle business logic outside the framework
3. **Create database access patterns** that worked with Drizzle ORM instead of ActiveRecord
4. **Implement authentication and authorization** without Rails' built-in systems
5. **Preserve subdomain multitenancy** in a framework that doesn't natively support it
6. **Replace unsafe customization** with a secure theming system
7. **Maintain all existing URLs** to avoid breaking bookmarks and SEO
The key insight: I needed to extract what each piece of my Rails code was accomplishing, then rebuild that functionality using modern patterns.
---
## Challenge 1: The Domain Model Translation
_Learn how to untangle business logic from ActiveRecord and rebuild it in clean TypeScript services._
Rails ActiveRecord encourages putting business logic directly in model classes. My `User` model alone was nearly 500 lines of mixed concerns: authentication, authorization, market permissions, and business rules all tangled together.
**Before (Rails):**
```ruby
class User < ActiveRecord::Base
belongs_to :market
belongs_to :grower
has_many :orders
has_many :cart_items
has_many :managed_markets, class_name: 'Market', foreign_key: 'manager_id'
def full_name
"#{first_name} #{last_name}"
end
def can_manage_market?(market)
return true if superuser?
return true if market_manager? && managed_markets.include?(market)
false
end
def has_active_cart?
cart_items.where(checked_out: false).any?
end
def total_purchases
orders.where(status: 'completed').sum(:total)
end
# ... 300+ more lines of mixed business logic
end
```
I had to untangle this into clean separation of concerns:
**After (TypeScript):**
```typescript
// Pure domain model
export interface User {
id: string;
email: string;
marketId: string;
roles: UserRole[];
createdAt: Date;
updatedAt: Date;
}
// Business logic in services
export class AuthorizationService {
canManageMarket(user: User, market: Market): boolean {
if (user.roles.includes('superuser')) return true;
if (user.roles.includes('manager') && user.marketId === market.id) return true;
return false;
}
}
// Cart service
export class CartService {
async hasActiveCart(userId: string): Promise<boolean> {
const items = await db.select()
.from(cartItems)
.where(and(
eq(cartItems.userId, userId),
eq(cartItems.checkedOut, false)
));
return items.length > 0;
}
}
// Order service
export class OrderService {
async getTotalPurchases(userId: string): Promise<number> {
const result = await db.select({ total: sum(orders.total) })
.from(orders)
.where(and(
eq(orders.userId, userId),
eq(orders.status, 'completed')
));
return result[0]?.total || 0;
}
}
```
This separation made testing easier and removed framework dependencies from business logic. But it meant manually recreating every Rails association and validation in TypeScript.
**Outcome:** Business rules left Rails' gravity and became testable, framework-agnostic services.
[↑ Back to navigation](#architecture-challenges-quick-navigation)
---
## Challenge 2: Schema Strategy - Keep vs. Redesign
_Discover why keeping the messy 19-year-old database schema was the smartest decision I made._
> **Why I Didn't Redesign the Database (And Why That Saved Me)**
> The existing schema had every antipattern in the book: denormalized data, legacy columns, Rails-specific naming. But keeping it meant zero data transformation, instant production testing, and eliminating an entire category of migration failures. When you're doing an all-or-nothing cutover at 2 AM, fewer moving parts is everything.
When starting the migration, I faced a fundamental choice: design a clean, modern database schema optimized for SvelteKit, or keep the existing Rails-era schema with all its quirks and compromises from decades of evolution.
**The Temptation of a Fresh Start:**
I originally built the schema around ActiveRecord conventions from 2006:
* Complex many-to-many relationships
* A single user could be a customer, belong to one grower profile, and be a market manager
* Multiple users could share management of a single grower profile (family farms collaborating)
* Denormalized data for Rails performance optimizations that modern databases don't need
* I had left legacy columns in place from features I removed or refined years ago
* Naming conventions that made sense in Rails but felt awkward in TypeScript
I could have designed beautiful, normalized tables with proper constraints, better indexing, and naming that matched the modern domain model.
**Why I Kept the Old Schema:**
```sql
-- The existing schema reflected complex real-world relationships
CREATE TABLE products (
id int PRIMARY KEY,
grower_id int, -- References growers table, not users
market_id int,
name varchar(255),
price decimal(10,2),
quantity_available int,
created_at datetime, -- Rails timestamps
updated_at datetime
);
CREATE TABLE growers (
id int PRIMARY KEY,
market_id int,
name varchar(255),
-- Multiple users can manage this grower profile
);
CREATE TABLE users (
id int PRIMARY KEY,
email varchar(255),
grower_id int, -- Can belong to one grower profile (nullable)
market_id int,
admin tinyint,
volunteer tinyint,
-- A user can be customer, grower, manager, or all three
);
```
```typescript
// Drizzle had to respect these complex relationships
export const products = mysqlTable('products', {
id: int('id').primaryKey(),
growerId: int('grower_id'), // NOT a user_id - references growers table
marketId: int('market_id'),
name: varchar('name', { length: 255 }),
price: decimal('price', { precision: 10, scale: 2 }),
quantityAvailable: int('quantity_available'),
createdAt: datetime('created_at'),
updatedAt: datetime('updated_at')
});
```
**The Killer Advantages:**
1. **Zero Data Transformation:**
```bash
# Migration became trivially simple
mysqldump rails_production > backup.sql
mysql svelte_production < backup.sql
# Done. No ETL scripts, no data mapping, no conversion errors
```
2. **Development with Real Data:**
```typescript
// I could copy production data to dev and immediately test
// No mock data, no fixtures, actual market complexity
const realOrders = await db.select().from(orders).where(/* real conditions */);
// Found edge cases immediately that mock data would never reveal
```
3. **Instant Rollback Capability (in theory):**
If something went catastrophically wrong in the first hours, I could theoretically point Rails back at the database. The schema hadn't changed, just the MySQL version. (Though as we'll see in Challenge 6, version incompatibility made this impossible in practice.)
4. **Reduced Migration Risk:**
Every data transformation is a chance for corruption. By keeping the schema identical, I eliminated an entire category of potential failures. When you're doing an all-or-nothing migration at 2 AM, fewer moving parts is better.
**Living with the Compromises:**
Yes, it meant accepting some awkwardness:
```typescript
// Had to preserve Rails-era naming conventions
// Even when they didn't match modern naming standards
class GrowerService {
async getUsersForGrower(growerId: string) {
// One grower can have multiple users (family farm)
return db.select()
.from(users)
.where(eq(users.growerId, growerId));
}
async getGrowerForUser(userId: string) {
// But each user can only belong to one grower
const user = await db.select()
.from(users)
.where(eq(users.id, userId))
.first();
if (!user?.growerId) return null;
return db.select()
.from(growers)
.where(eq(growers.id, user.growerId))
.first();
}
}
// The role system complexity from Rails carried over
interface UserContext {
user: User;
roles: ('customer' | 'grower' | 'manager' | 'superuser')[];
growerProfile?: Grower; // Can belong to ONE grower profile
marketPermissions: MarketPermission[];
}
```
But these minor inconveniences were worth it for the ability to develop against real production data. Every day during development, I could sync the latest data and test with actual market orders, real product catalogs, and genuine edge cases that 19 years of production use had created.
My choice to keep the messy schema traded elegance for certainty — and certainty wins at 2 AM. If I was successful, I could always migrate to a cleaner schema later using all the tools Drizzle provides.
**The Validation:**
This decision was validated immediately. Being able to test with real production data meant I could verify that complex market workflows, grower relationships, and order patterns all worked correctly from day one. No mock data, no fixtures, just real-world complexity that ensured the new system would handle everything the old one did.
**Outcome:** Zero data transformation meant zero data corruption and the messy schema became my safety net.
[↑ Back to navigation](#architecture-challenges-quick-navigation)
---
## Challenge 3: Subdomain Multitenancy
_Learn how to implement market isolation when the framework doesn't support it natively._
Rails made subdomain isolation look easy with a simple before_action filter:
```ruby
def get_market
subdomain = request.subdomain
@market = Market.find_by(subdomain: subdomain)
redirect_to_root unless @market
end
```
SvelteKit doesn't have built-in subdomain handling. I had to implement this at the infrastructure level:
**SvelteKit Solution:**
```typescript
// hooks.server.ts
import { redirect } from '@sveltejs/kit';
import { MarketService } from '$lib/server/services/MarketService';
export async function handle({ event, resolve }) {
// Extract subdomain from host
const host = event.request.headers.get('host') || '';
const subdomain = host.split('.')[0];
// Load market context
const market = await MarketService.findBySubdomain(subdomain);
if (!market) {
throw redirect(302, 'https://locallygrown.net/');
}
// Add market to request context
event.locals.market = market;
return resolve(event);
}
```
Every page load now automatically has market context, just like Rails. But I had to manually ensure this worked with:
* Static site generation
* Server-side rendering
* Client-side navigation
* Development vs production environments (using `athens.locallygrown.lcl.host:5173` for local subdomain testing)
**Outcome:** Every market now loads with proper isolation, just like Rails but without the Rails magic.
[↑ Back to navigation](#architecture-challenges-quick-navigation)
---
## Challenge 4: The Permission System
_See how to centralize authorization checks scattered throughout controllers and views._
Rails had a complex role-based system where users could have different permissions in different contexts:
* **Superusers:** Access to everything across all markets
* **Market Managers:** Full access within their assigned market
* **Growers:** Can manage their own products and orders
* **Customers:** Can browse and purchase
* **Volunteers:** Limited administrative functions
The Rails code scattered these checks throughout controllers and views:
```ruby
# In controllers
before_filter :require_superuser_or_market_manager
before_filter :require_ownership, only: [:edit, :update, :destroy]
# In views
<% if can_edit_product?(@product) %>
<%= link_to "Edit", edit_product_path(@product) %>
<% end %>
```
I centralized this into a comprehensive authorization service:
```typescript
export class AuthorizationService {
private user: User;
private market: Market;
constructor(user: User, market: Market) {
this.user = user;
this.market = market;
}
canEditProduct(product: Product): boolean {
// Superusers can edit anything
if (this.user.roles.includes('superuser')) return true;
// Market managers can edit products in their market
if (this.user.roles.includes('manager') && this.user.marketId === this.market.id) return true;
// Growers can edit their own products
if (this.user.roles.includes('grower') && product.growerId === this.user.growerId) return true;
return false;
}
canManageOrders(): boolean {
return this.hasRole(['superuser', 'manager']);
}
private hasRole(roles: UserRole[]): boolean {
return roles.some(role => this.user.roles.includes(role));
}
}
```
Then in SvelteKit pages, I computed permissions server-side and passed them to components:
```typescript
// +page.server.ts
export async function load({ locals }) {
const auth = new AuthorizationService(locals.user, locals.market);
const product = await getProduct(params.id);
return {
product,
canEdit: auth.canEditProduct(product),
canManageOrders: auth.canManageOrders()
};
}
```
```svelte
<!-- +page.svelte -->
<script lang="ts">
export let data;
</script>
{#if data.canEdit}
<a href="/products/{data.product.id}/edit">Edit Product</a>
{/if}
```
> **Note:** Permissions are computed server-side and hydrated into the page data; the client never decides authority. This ensures business logic stays on the backend and prevents client-side authorization bypasses.
**Outcome:** Authorization logic moved from scattered checks to a single source of truth.
[↑ Back to navigation](#architecture-challenges-quick-navigation)
---
## Challenge 5: The Customization System Redesign
_Understand the journey from dangerous HTML/CSS/JS injection to secure theming._
The old system let markets inject arbitrary HTML, CSS, and JavaScript which is powerful but dangerous:
```ruby
# This was scary but effective
def custom_header
raw(@market.custom_header_html)
end
def custom_styles
content_tag(:style, @market.custom_css, type: 'text/css')
end
```
I needed something that preserved flexibility while eliminating security risks and enabling mobile optimization. My solution: a constrained theming system.
**Secure Theme System:**
```typescript
export interface ThemeConfig {
colors: {
primary: string;
secondary: string;
accent: string;
};
fonts: {
heading: string;
body: string;
};
layout: {
headerStyle: 'minimal' | 'full' | 'banner';
showMarketInfo: boolean;
featuredProductsCount: number;
};
customContent: {
welcomeMessage: string; // Sanitized Markdown
aboutSection: string; // HTML whitelist only
footerText: string; // No script injection
};
}
```
Markets could customize colors, fonts, layout options, and content blocks, but couldn't inject arbitrary code. Content fields are sanitized with a strict HTML whitelist, preventing any script injection. The theme system generated CSS custom properties:
```svelte
<!-- +layout.svelte -->
<style>
:global(:root) {
--color-primary: {theme.colors.primary};
--color-secondary: {theme.colors.secondary};
--font-heading: {theme.fonts.heading};
--font-body: {theme.fonts.body};
}
</style>
```
This preserved the custom look each market wanted while ensuring mobile responsiveness and security.
**Outcome:** Markets kept their unique identity without the security nightmares.
[↑ Back to navigation](#architecture-challenges-quick-navigation)
---
## Challenge 6: The All-or-Nothing Migration Constraint
_Understand why shared databases and cross-market features made parallel systems impossible._
Unlike many migrations where you can run old and new systems in parallel, I faced a critical constraint: LocallyGrown.net uses a single shared database across all markets, with features that allow growers to sell across multiple markets by linking inventory. This meant I couldn't have some users on Rails while others used SvelteKit. It had to be everyone, all at once.
### Why Parallel Systems Were Impossible
The shared database architecture that made LocallyGrown powerful also made gradual migration impossible:
```sql
-- Single database, all markets share tables
-- Products can be linked across markets
-- Inventory is shared when growers sell at multiple markets
-- Orders from different markets all go in the same orders table
```
### Real-Time Conflict Risks
If Rails and SvelteKit were running simultaneously:
1. **Order Processing Chaos:**
* Customer starts order in Rails (market A still on old system)
* Adds products to cart, stored in database
* Market switches to SvelteKit mid-shopping
* Cart format incompatible, order lost or corrupted
2. **Inventory Nightmares:**
* Grower updates inventory in Rails interface
* Rails uses one caching strategy
* SvelteKit uses different caching
* Same product shows different quantities to different customers
* Overselling becomes inevitable
3. **Session Conflicts:**
* Rails sessions stored one way
* SvelteKit sessions stored differently
* User logged into Rails
* Hits SvelteKit page, appears logged out
* Tries to log in again, corrupts session
4. **Business Logic Discrepancies:**
```ruby
# Rails: Calculates prices one way
def calculate_total
subtotal * (1 + market_fee) # Rounds at end
end
```
```typescript
// SvelteKit: Slightly different calculation
function calculateTotal() {
return roundToCents(subtotal * (1 + marketFee)); // Rounds differently
}
```
Same order could have different totals depending on which system processed it.
### The Shared Inventory Killer
The feature that lets growers sell at multiple markets made this worse:
```sql
-- Grower has 100 tomatoes
-- Sells at Athens, Madison, and Macon markets
-- Athens on Rails, Madison on SvelteKit
-- Both systems modifying the same inventory row
-- Race conditions everywhere
```
If Athens market (on Rails) and Madison market (on SvelteKit) both had customers ordering the same product simultaneously, the inventory updates would conflict, leading to overselling or lost sales.
### The Version Incompatibility Trap
Even if I wanted to try database replication, the technology stack made it impossible:
```ruby
# Rails 3 stack (circa 2011)
# MySQL 5.5
# mysql2 gem version 0.3.x
# ActiveRecord 3.x
# These old libraries literally couldn't connect to modern MySQL
```
```typescript
// SvelteKit stack (2025)
// MySQL 8.0
// Drizzle ORM
// Modern connection pooling
// Using authentication methods Rails 3 didn't understand
```
The Rails app was using MySQL 5.5 with authentication methods from 2011. The new stack required MySQL 8.0 with modern security features. I couldnt even establish a connection with the old Ruby mysql2 gem to the new database server.
And trying to maintain real-time bidirectional replication between MySQL 5.5 and MySQL 8.0? That's not a migration strategy, that's a recipe for data corruption. The replication protocols, character encodings, and even timestamp handling had changed significantly between versions.
### The Legacy Layout Illusion
Since I couldn't run both systems, I tried to make the new system feel like the old one:
```svelte
<!-- The legacy layout that looked like Rails -->
{#if user.preferredLayout === 'legacy'}
<div class="rails-style-layout">
<table class="old-school-product-table">
<!-- Mimicking 2010-era Rails HTML -->
</table>
</div>
{:else}
<ModernProductGrid />
{/if}
```
But this was just UI deep: underneath, everything was different:
* Different database queries
* Different calculation methods
* Different session handling
* Different URL structures
### The Point of No Return
The version gap created the most terrifying aspect: **no rollback was possible**.
Once I migrated the database from MySQL 5.5 to MySQL 8.0, the Rails app could never connect to it again. The authentication methods, character encodings, timestamp column types, and even the way NULL values were handled had all changed. It was a one-way door.
Once MySQL 8 went live, Rails 3 could never connect again. Once the new system was live and creating data, there was no rollback. There was only forward progress.
```bash
# The migration sequence that couldn't be undone:
1. Export data from MySQL 5.5
2. Transform data for MySQL 8.0 compatibility (mainly Textile markup to Markdown for all custom content)
3. Import into MySQL 8.0
4. Update DNS to SvelteKit
5. Hold your breath
# If anything went wrong after step 3, there was no going back
```
### The Migration Moment
August 14, 2025, 2 AM: The most nerve-wracking deployment of my career (45 minutes migration + 10 minutes verification + two whole hours of DNS propagation):
1. **2:00 AM:** Put Rails in maintenance mode
2. **2:05 AM:** Final backup of MySQL 5.5 database
3. **2:10 AM:** Run migration scripts to MySQL 8.0 (45 minutes of pure anxiety)
4. **2:55 AM:** Verify data integrity (spot checking critical tables)
5. **3:00 AM:** Update DNS to point to SvelteKit
6. **3:05 AM:** Remove maintenance mode
7. **5:00 AM:** Start breathing again
After two months of preview testing, when August 14 arrived, every market, every grower, every customer switched simultaneously.
If something went catastrophically wrong, my only option would be to restore the old MySQL 5.5 database and lose all transactions that happened after the migration. Every minute the new system ran made rolling back more painful.
This constraint shaped everything: I had to design the UI familiar enough that people wouldnt panic, the data migration had to be perfect, and I had to fix issues in production immediately because there was no "switch back to the old system" option. The new system had to work, period.
**Outcome:** The one-way door forced perfection: there was no safety net, only forward momentum.
[↑ Back to navigation](#architecture-challenges-quick-navigation)
---
## Challenge 7: The Asset Migration Nobody Thinks About
_Learn why migrating thousands of images and documents requires weeks of preparation._
One challenge that's easy to overlook: LocallyGrown had 19 years of uploaded assets: product photos, virtual farm tours, market documents. Rails stored everything in the filesystem. Not only did every URL need to keep working, but the new system required different image sizes for modern responsive galleries.
**The Hidden Complexity:**
```bash
# Rails stored everything on disk
/public/system/products/images/000/001/234/original/tomatoes.jpg
/public/system/products/images/000/001/234/thumb/tomatoes.jpg
/public/system/markets/documents/000/000/042/grower_agreement.pdf
```
The new system needed:
* S3-compatible cloud storage for scalability
* Multiple image sizes for responsive design
* WebP versions for modern browsers
* Preserved URLs for SEO and bookmarks
**The Migration Strategy:**
Luckily, I had kept all original images throughout the years, a decision that paid off massively. I could regenerate any size needed.
```javascript
// Node script for image processing
async function migrateProductImages() {
const sizes = {
thumb: { width: 150, height: 150 },
small: { width: 300, height: 300 },
medium: { width: 600, height: 600 },
large: { width: 1200, height: 1200 },
webp_thumb: { width: 150, height: 150, format: 'webp' },
webp_medium: { width: 600, height: 600, format: 'webp' }
};
for (const product of products) {
const original = await getOriginalImage(product.id);
for (const [name, config] of Object.entries(sizes)) {
await sharp(original)
.resize(config.width, config.height, { fit: 'inside' })
.toFormat(config.format || 'jpeg')
.toFile(`processed/${product.id}/${name}.${config.format || 'jpg'}`);
}
}
}
```
**The Rehearsal Process:**
For weeks before the migration, I ran synchronized uploads:
```bash
# Nightly sync scripts running for weeks
rsync -av /rails/public/system/ /staging/assets/
node process-images.js
s3sync processed/ s3://locallygrown-assets/ --parallel=10
# URL rewrite rules to maintain compatibility
/system/products/images/* → https://assets.locallygrown.net/products/*
```
By migration night, all assets were already in place. When DNS switched over, every image URL continued working, but now served from cloud storage with modern responsive variants.
**Outcome:** 19 years of images migrated seamlessly. Users never knew their photos moved to the cloud.
[↑ Back to navigation](#architecture-challenges-quick-navigation)
---
## Challenge 8: The Beta Testing Strategy
_Learn how to de-risk an irreversible migration with parallel preview testing._
Because a dual-run wasn't possible (see Challenge 6), I created a full preview environment at locallygrown.us that gave us two months to prepare with real users.
**The Preview System Architecture:**
Starting in June 2025, I launched a fully functional preview that mirrored production data:
```typescript
// Regular sync process
1. Export production data from Rails/MySQL 5.5
2. Sanitize sensitive data (emails replaced to prevent accidental sends)
3. Import into preview MySQL 8.0 database
4. Managers could test everything except real payments
// Access required two-step login:
1. Basic http auth credentials to keep people who stumbled upon the site out
2. Then their regular LocallyGrown credentials (the same ones they use on their live site)
```
**The Communication Campaign:**
**June 2025: The Announcement**
By June, I had proven to myself that the migration could work. The secret experiment had succeeded. Now came the nerve-wracking part: going public with a promise I had to keep.
I sent a comprehensive letter to all market managers explaining:
* Why we needed to migrate (infrastructure costs, mobile support, integration limitations)
* What would change and what would stay the same
* How to access their preview at [market].locallygrown.us
* Promise of one month's notice before the actual migration
**July 2025: Building Confidence**
Regular updates (Migration Updates #1-4) with:
* Progress reports on feature completion
* Instructions for testing specific workflows
* Screenshots of the new design system
* Reassurances about data preservation
**July 31: Go Live Date Confirmed**
* Confirmed August 14 migration date
* Outlined the exact timeline (2-3 AM Eastern)
* Gave managers a week to finalize customizations
* "If anything goes unexpectedly, I will keep the existing site live"
**August 7: Final Week**
* Stopped resetting preview customizations
* Managers could configure their preferred layouts
* Last chance for feedback and concerns
* Daily communication about readiness
**The Communication Infrastructure:**
I built features specifically for the migration:
```svelte
<!-- In-app announcements system -->
<AnnouncementBanner>
<h3>System Migration: August 14</h3>
<p>The new system goes live in {daysRemaining} days.</p>
<a href="/migration-guide">See what's changing →</a>
</AnnouncementBanner>
```
**The Tutorial System:**
Knowing the interface was different, I created interactive tutorials:
```typescript
// Market shopping tutorial
const shoppingSteps = [
{ element: '.category-filter', text: 'Filter products by category here' },
{ element: '.search-box', text: 'Search for specific products' },
{ element: '.add-to-cart', text: 'Click to add items to your cart' },
{ element: '.cart-icon', text: 'View your cart here' }
];
// Grower inventory tutorial
const inventorySteps = [
{ element: '.quick-edit', text: 'Update quantities inline' },
{ element: '.bulk-actions', text: 'Select multiple products' },
{ element: '.save-all', text: 'Save all changes at once' }
];
```
**Key Features Built for Migration:**
1. **Interface Style Preferences**
* Markets could choose which user groups saw legacy vs modern interface
* Allowed gradual adoption within each market
* "Keep customers on the familiar legacy interface while testing the modern interface with your staff"
2. **Comprehensive Documentation**
* New docs site with guides for managers, growers, and customers
* "What's New" guide to share with users
* Technical documentation for future developers
3. **Design System Showcase**
* Live preview at locallygrown.net/showcase
* Demonstrated the mobile-first approach
* Built confidence in the new design
**The Feedback and Refinement:**
The preview period was crucial for discovering issues:
* **Customization preservation:** "95%+ feature complete"
* **Mobile-first admin screens:** Complete redesign for managers
* **Legacy layout option:** Keeping the familiar interface available
* **Theme builder:** Started building custom theme capabilities
**What the Preview Surfaced:**
The preview period helped identify workflow issues, UI confusion points, and missing features that only became apparent with real users testing their actual day-to-day tasks.
**Building Trust Through Transparency:**
From my June announcement:
> "I'm taking extra time to ensure that all the custom styling and themes that markets have developed over the years are properly preserved and working beautifully on the new platform."
From the July 31 update:
> "I've been working on this transition literally every day for over four months now, and I'm excited to move forward with the final steps."
The key message throughout: "Your data is safe, and I'm taking every precaution."
**Why This Mattered:**
When launch day came, it wasn't a surprise. Power users had been using the system for 8 weeks. Market managers had tested their specific workflows. Growers knew what to expect. The tutorials were refined based on real confusion points.
Yes, we still had issues (as Part 4 will detail), but imagine if we hadn't had this preview period. Instead of "the button moved" complaints, I would have had "nothing works" disasters.
Even with the preview system, some problems slipped through. The real production load, real money, and real urgency would reveal new issues. But, it transformed the migration from a blind leap to a calculated risk.
**Outcome:** The preview period gave me the muscle memory to handle post-launch fires. I had enough experience explaining the new system to identify and fix problems in real-time.
[↑ Back to navigation](#architecture-challenges-quick-navigation)
---
## The Development Process
Working evenings and weekends in secret meant I needed extreme discipline and clear priorities. No one knew I was attempting this rebuild: not the market managers, not the users, not even my closest colleagues. The weight of potentially shutting down LocallyGrown.net if I failed made every coding session feel critical.
**My Workflow:**
* **Monday-Wednesday:** Review my Rails implementation, plan TypeScript equivalents
* **Thursday-Friday:** Code new features, write tests
* **Saturday:** Integration work, bug fixes (I set a 10:30 PM "stop coding" alarm; I rarely obeyed it)
* **Sunday:** Deploy to staging, test with real data
Every commit had to be production-ready. I couldn't afford broken builds when I only had a few hours each day.
**Key Tools:** TypeScript, Drizzle ORM, Vitest, Docker, linear process (one feature at a time, no shortcuts)
---
## What's Next: The Reality of Production
After six months of methodical development, I had built a complete SvelteKit application that replicated every Rails feature. The code was tested, the architecture was solid, and I was ready for the cutover described in Challenge 6.
Then real users started using it, and I discovered what I'd actually missed.
**Part 4 will cover:**
* The first 48 hours after the 2 AM cutover
* When "it works just like before" wasn't actually true
* Product filtering bugs that made shopping frustrating
* Growers struggling to manage inventory with the new interface
* Market managers overwhelmed supporting confused users
* Site customizations that didn't quite translate to the new system
* Payment calculation errors that affected real money
* How fixing "small" workflow issues became the real work
This taught me that migrating features, writing tests, and inviting testers isn't enough. And when you can't roll back, every issue becomes urgent.
---
_This is part three of a series documenting the rescue and modernization of LocallyGrown.net._
Part 4 is the messy part: real users, real money, and the bugs that only show up when a farm manager is closing orders at 11:58 PM.
### The Series
1. [From Accidental Discovery to Agricultural Infrastructure (2002-2011)](https://blog.kestrelsnest.social/posts/locallygrown-origin-story/)
2. [The 23-Year Rescue Mission: Saving Agricultural Innovation from Technical Extinction](https://blog.kestrelsnest.social/posts/locallygrown-rescue-mission/)
3. **The Architecture Challenge: Translating 19 Years of Rails Logic to Modern SvelteKit**_You are here_
4. Crisis Response: When Launch Day Goes Wrong
5. Lessons from the Solo Developer + AI Trenches
6. The Future: Building on Modern Foundations