Success story · E-commerce · Data integrity

Promotions that started two hours late and barcodes that belonged to nobody.

A v0-built storefront admin for a brand selling in four countries. Two quiet bugs: date-times without a timezone read as UTC, and a supplier feed that put an internal counter into the barcode field and overwrote 193 real barcodes. The warehouse could not match its picking lists for a week.

Fashion e-commerce, 4 countries, 7 languagesBuilt with v0 + Next.jsE-commerce managerReadiness 2.0 → 3.848 hours
Success story · E-commerce · Data integrity
2 hpromotion shift, every campaign→717tests that encode the incidents
Promotions that started two hours late and barcodes that belonged to nobody.
prodreadyBuilt with v0 + Next.js · production-ready in 48 hours
2 h
promotion shift, every campaign
193
barcodes overwritten
620
'translations' that were copies
717
tests that encode the incidents

Sound familiar?

You built the storefront admin in v0 because the agency quoted six weeks and the supplier feed had to be live in two. It works. Products come in every hour, seven languages go out, four storefronts sell. Nothing has crashed in months. But a promotion went live "a bit late" last week and nobody can say why, and the warehouse has called twice about barcodes that scan to nothing. You have been telling yourself the data is fine because the app is up. Those are two different things.

What they sent us

A fashion brand selling in four countries, four currencies, seven languages. The e-commerce manager had built the storefront admin in v0 on Next.js, with Postgres behind it. At Discovery it held 3,782 product models across three SKU levels (model, model-colour, model-colour-size) in 62 tables. The dump was 106 MB. A supplier XML feed arrived over FTPS every hour, and a sync pushed the catalogue to the four storefronts every five minutes.

Two lines decided how data got in. The first was the merge in the importer: every field was written as incoming || stored. That is fine for a description. For a barcode it means whatever the supplier puts in that column wins, and the supplier had started putting an internal counter there. The second was the column type. Every date-time was a plain timestamp with no zone; the admin wrote wall-clock strings and the storefront read them back as UTC. On a summer day that is two hours.

The schema was applied with drizzle-kit push straight against the live database. When the schema file drifts, push asks "was table X renamed from Y?" and the wrong answer drops a production table. There was no test suite. There was no dry-run mode on any script. Every fix was a query typed into a console.

Before we changed a line we counted the damage: 193 colour-size variants whose barcode was a supplier counter, not a GTIN. 546 units on sale under sizes that existed twice, once as "ONE SIZE" and once as "ONESIZE". 620 rows in one market's language that were byte-for-byte copies of the source text, with zero characters from the target alphabet. Nobody had filed a bug for any of it, because none of it throws.

BEFORE: every write trusted the feed, every time was a bare string Supplier XML feed FTPS, hourly, no schema check Importer incoming || stored supplier counter lands in barcode field 193 real barcodes overwritten Catalogue 62 tables timestamp, no zone 00:00 local read as 00:00 UTC 4 store- fronts sync every 5 min promo live at 02:00 No tests. Schema pushed by hand. Fix scripts with no dry run. 546 phantom units and 620 untranslated rows already live.
Before: the importer merged every field with incoming || stored, so a supplier counter overwrote 193 real barcodes; date-times were stored without a zone and read back two hours off.

What would have happened

What actually happens

Data bugs do not crash. They sell things you do not have and hide things you do. On day 3 the next hourly feed run overwrites another batch of barcodes; the warehouse prints picking lists that scan to nothing and spends a week matching boxes to orders by hand. On day 12 the autumn campaign goes live in four countries at 02:00 instead of midnight; the email went out at midnight, and two hours of the best clicks of the quarter land on full price. On day 19 the last of the 546 phantom "ONESIZE" units is sold, and the cancellations start, one per customer, each with a discount code attached. On day 31 a customer in the market with the 620 copied rows posts a screenshot of a product page in the wrong language. Nobody files a bug at any point. The dashboard stays green.

Forty-eight hours

Hour 0 to 6: count before you touch. We restored the dump locally and ran three queries against it. Barcodes that did not match a thirteen-digit shape or did not carry a GS1 company prefix: 193. Sizes that collapsed to the same key after removing spaces and case: 546 units across duplicates. Translation rows equal to their source row: 620. We also found the SKU problem nobody had mentioned. The feed's internal id uses underscores, so a subset of size SKUs held _ where the rest held -, and the storefront treated them as different products.

Hour 6 to 18: a boundary. The importer wrote whatever arrived. We put a zod schema at the boundary and made it lenient where a bad value is harmless and strict where a bad value is an identifier. A missing name does not block a row. A barcode with the wrong shape does.

const Variant = z.object({
  sku:     z.string().trim().regex(/^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/),
  barcode: z.string().regex(/^\d{13}$/).optional(),  // strict: GTIN-13 or absent
  size:    z.string().trim().transform(normSize),      // "ONE SIZE" and "ONESIZE" → one key
  name:    z.string().catch(''),                      // lenient: never blocks a row
  price:   z.coerce.number().nonnegative().nullish(),
}).passthrough();

const r = Variant.safeParse(raw);
if (!r.success) { skipped.push({ raw, error: r.error.message }); continue; }

Invalid rows are skipped with a warning and counted in a summary at the end of the run, so a bad feed file produces a number, not a silent write. The barcode rule sits behind the schema and is the line that would have saved the warehouse a week. An identifier is never merged. It is accepted or refused.

// identifiers are not merged. They are accepted or refused.
function acceptBarcode(incoming?: string, stored?: string) {
  if (!incoming) return stored;                        // feed silent: keep ours
  if (!/^\d{13}$/.test(incoming)) return stored;       // wrong shape: keep ours
  if (!hasGs1Prefix(incoming) || !checkDigitOk(incoming)) {
    report('barcode.rejected', { incoming, stored });   // a counter, not a GTIN
    return stored;
  }
  if (stored && stored !== incoming) report('barcode.changed', { incoming, stored });
  return incoming;
}

The GS1 prefix is the part that separates a real barcode from a supplier's counter: real GTINs start with a registered company prefix, counters start with whatever the supplier's database was up to. Add the check digit and the counter cannot get through by accident. A changed barcode on an existing variant is allowed, but it is reported, because it is the one field the warehouse keys on.

Hour 18 to 36: one clock, then tests. We replaced every date-time column with timestamptz in an additive, numbered SQL migration, and routed every time in the app through one helper with a fixed zone. The admin form produces wall-clock text. The helper turns it into an instant. Nothing else in the codebase is allowed to construct a date from a string.

import { fromZonedTime, toZonedTime } from 'date-fns-tz';
const ZONE = process.env.SHOP_TZ ?? 'Europe/Berlin';   // one zone, set once, never guessed

// wall-clock from the admin form → instant for the database
export const fromShop = (wall: string) => fromZonedTime(wall, ZONE);
// instant from the database → wall-clock for the screen
export const toShop   = (at: Date)     => toZonedTime(at, ZONE);

// the only allowed way to build a promotion window
export const promoWindow = (start: string, end: string) =>
  ({ startsAt: fromShop(start), endsAt: fromShop(end) });

Then the tests. The rule for this suite was simple: every fixture is a real bad input from the incident log, not a made-up one. The size normaliser is tested against the non-breaking spaces that Excel exports and the typographic dashes that a copy-paste from a PDF produces. The retry classifier is tested against the literal HTML page a reverse proxy returns on a 502, because that page once arrived where JSON was expected. The barcode rule is tested against the exact counter values that overwrote the 193. By hour 36 the suite ran 717 cases across 40 files, and a change that reintroduced any of the incidents would fail before it reached a branch.

Hour 36 to 48: repair, in batches, with a dry run. The 193 barcodes, the 546 phantom units, the 620 copied rows and the underscore SKUs all needed fixing in place. Every fix script we wrote runs dry by default, prints the rows it would change, and only writes when passed --apply. It works in batches of 200 and prints an ETA, because a script that says nothing for eleven minutes gets killed by a nervous operator.

const DRY = !process.argv.includes('--apply');           // dry run unless told otherwise
const rows = await db.select().from(variants)
  .where(sql`size <> upper(replace(size, ' ', ''))`);
console.log(`${rows.length} rows to merge${DRY ? ' (dry run)' : ''}`);
const t0 = Date.now();
for (const [i, batch] of chunk(rows, 200).entries()) {
  if (!DRY) await mergeBatch(batch);   // on 23505: drop the bad row and its country rows
  const done = Math.min((i + 1) * 200, rows.length);
  const eta = ((Date.now() - t0) / done) * (rows.length - done);
  console.log(`${done}/${rows.length}  eta ${Math.round(eta / 1000)}s`);
}

The SKU cleanup showed why the dry run matters. Rewriting _ to - in place collided with correct rows that already existed, error 23505 on the unique constraint. The dry run listed the collisions before anything was written; the real run deleted the bad row and its country rows on collision instead of stopping halfway. The translation fix got its own rule set: brand, model and fit names stay in Latin script, because we checked ten local retailers in that market and not one transliterates them. Rows with zero characters from the target alphabet are refused before they reach the storefront.

Two smaller things closed the window. The VAT table now returns null for a country it does not know, instead of falling through to the first rate; a null fails loudly at the price, a wrong rate does not. And the schema is now four hand-written, forward-only SQL files, numbered 0001 to 0004, with a README that logs the date each one was applied. Nobody answers a "was this renamed?" prompt against production again.

Every one of those bugs was a single line. Every one of them cost a week.
48 HOURS: the engineering window, after Discovery, before the customer's own traffic 0 h 6 h 18 h 36 h 48 h Count 193 / 546 / 620 Boundary zod at the feed, GS1 rule One clock, then tests timestamptz, 717 cases with incident fixtures Repair dry-run scripts, batches of 200 dump restored locally three queries, no writes lenient on names, strict on ids bad rows skipped and counted Excel spaces, PDF dashes, the 502 page the exact counters that overwrote the 193 23505 collisions listed first migrations 0001 to 0004, hand-over readiness 2.0 at hour 0, 3.8 at hour 48
The 48-hour window: six hours counting without writing, twelve building the boundary and the identifier rule, eighteen on one clock and the tests, twelve on dry-run repair scripts and hand-over.

What it looks like now

The feed still arrives every hour and the sync still runs every five minutes. What changed is that nothing reaches the catalogue without passing a schema, no identifier is ever merged, and every time in the system is an instant in one named zone. Readiness went from 2.0 to 3.8. The suite that guards it has 717 cases, and the ones that matter most are named after the week they would have cost.

AFTER: accept or refuse at the boundary, one clock, tests that remember Supplier feed Boundary validation zod, bad rows counted Identifier rules GS1 prefix, never merged Fixed-timezone helper one zone, timestamptz Catalogue 4 storefronts sync every 5 min 717 tests with incident fixtures Excel spaces, PDF dashes, the 502 page, the counters Dry-run fix scripts --apply to write, batches of 200, ETA Schema: four numbered, additive SQL migrations with a dated README. VAT for an unknown country returns null, never a guess.
After: the feed passes boundary validation and identifier rules before it can touch the catalogue; one fixed-timezone helper owns every date, 717 tests replay the incidents, and fix scripts run dry unless told otherwise.
FieldBeforeAfter
Barcodeincoming || stored; supplier counter overwrote 193 real barcodesAccepted only with a GS1 prefix and a valid check digit; anything else refused and reported
Date-timestimestamp without zone, read as UTC; promotions two hours latetimestamptz through one fixed-timezone helper; no other date construction allowed
Feed rowsEvery row written as it arrivedzod schema at the boundary; invalid rows skipped, counted, summarised per run
Sizes and SKUs"ONE SIZE" and "ONESIZE" both on sale; _ and - mixedNormalised once at import; duplicates merged; unique constraint enforced
Translations620 rows shipped as copies of the source textRefused when zero target-alphabet characters; brand and model names kept Latin
VAT lookupUnknown country fell through to a default rateUnknown country returns null and the price fails loudly
Schema changesdrizzle-kit push against production, interactive promptsNumbered, additive SQL files 0001 to 0004; README logs each apply date
TestsNone717 cases in 40 files; fixtures are the real bad inputs

From our desk

The same brand later found that nobody had ever read stock back from the storefronts, so the catalogue and the shops had drifted apart by 78,482 units over two months. The first reconciliation matched on barcode, took a false reading and pulled 11,778 real units off sale for two hours. The lesson is the same one as the importer: match on the key the sync actually uses, auto-fix only the one category of mismatch you fully understand, and send the rest to a person.


Do this tonight

Do this tonight

1. Find the naive clocks. Run SELECT table_name, column_name FROM information_schema.columns WHERE data_type = 'timestamp without time zone'; against your database. Every row is a column that will read a midnight launch as 02:00 the moment your server and your customers are in different zones. Then SHOW timezone; and compare it with what your app assumes.

2. Find the merged identifiers. In the repo: grep -rnE "(barcode|ean|gtin|sku)[^;]*(\|\||\?\?)" src/. Any hit where an identifier falls back to a stored value is a supplier counter waiting to land. In the database: SELECT count(*) FROM variants WHERE barcode !~ '^\d{13}$';. If that is not zero, your warehouse already knows.

3. Find the phantoms and the copies. SELECT upper(replace(size, ' ', '')) AS k, count(DISTINCT size) FROM variants GROUP BY 1 HAVING count(DISTINCT size) > 1; shows sizes sold twice under two spellings. SELECT lang, count(*) FROM translations t JOIN products p USING (product_id) WHERE t.name = p.name GROUP BY lang; shows "translations" that are copies.

These three find the rows that are already wrong. They do not stop the next feed run from writing more, and they do not tell you which of your 62 tables has the same || two functions deeper. That is the work prodready does in the 48 hours, and it is why the tests are named after incidents.

The rule

The rule

Identifiers are never merged; they are accepted or refused. Times are never strings; they are instants in one named zone. Everything else can be lenient.

Every one of those bugs was a single line. Every one of them cost a week.— E-commerce manager

Send us what you have.

The readiness scan is free — install our MCP and your own coding agent runs it. When you want it fixed, send us what you have: fixed scope, fixed price, agreed before the clock starts. 48 hours later, it's production-ready — on your own cloud, with the tests to prove it.

Feed the machineWatch the 49-second film