[object Object]

← back to Homesonspec

Contrarian fixes: StagedRecord unique (source,snapshot,home) + upsert/delete-recreate → FORCE_REEXTRACT idempotent (no raw-row bloat); img loading=lazy + hero fetchPriority

f5b91116a2d8c191b4740d6e99c2c6555e5e4c30 · 2026-07-22 20:49:20 -0700 · Steve

Files touched

Diff

commit f5b91116a2d8c191b4740d6e99c2c6555e5e4c30
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 20:49:20 2026 -0700

    Contrarian fixes: StagedRecord unique (source,snapshot,home) + upsert/delete-recreate → FORCE_REEXTRACT idempotent (no raw-row bloat); img loading=lazy + hero fetchPriority
---
 apps/web/src/app/homes/[id]/page.tsx               |  6 ++---
 apps/web/src/app/page.tsx                          |  2 +-
 apps/workers/src/pipeline.ts                       | 31 ++++++++++++++--------
 .../migration.sql                                  | 20 ++++++++++++++
 4 files changed, 44 insertions(+), 15 deletions(-)

diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
index b7196ff..10968d8 100644
--- a/apps/web/src/app/homes/[id]/page.tsx
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -92,7 +92,7 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
           {home.images[0] ? (
             // eslint-disable-next-line @next/next/no-img-element
             <img src={home.images[0]} alt={`${home.street ?? home.community.name} exterior`}
-              className="absolute inset-0 h-full w-full object-cover" />
+              fetchPriority="high" className="absolute inset-0 h-full w-full object-cover" />
           ) : null}
           {/* scrim keeps the wordmark + price legible over any photo */}
           <div className="absolute inset-0 bg-gradient-to-t from-brand-950/85 via-brand-950/25 to-brand-950/10" />
@@ -131,7 +131,7 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
         <div className="mt-3 flex gap-2 overflow-x-auto pb-1" data-testid="home-gallery">
           {home.images.slice(0, 8).map((src, i) => (
             // eslint-disable-next-line @next/next/no-img-element
-            <img key={i} src={src} alt={`${home.community.name} photo ${i + 1}`}
+            <img key={i} src={src} alt={`${home.community.name} photo ${i + 1}`} loading="lazy"
               className="h-20 w-28 shrink-0 rounded-lg object-cover ring-1 ring-neutral-200" />
           ))}
         </div>
@@ -255,7 +255,7 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
                 <div className="relative h-32 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500">
                   {h.images[0] ? (
                     // eslint-disable-next-line @next/next/no-img-element
-                    <img src={h.images[0]} alt="" className="absolute inset-0 h-full w-full object-cover" />
+                    <img src={h.images[0]} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
                   ) : null}
                   <span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-white/95 px-2 py-0.5 text-[11px] font-medium text-neutral-700">
                     <span className="h-1.5 w-1.5 rounded-full" style={{ background: STATUS_DOT[h.constructionStatus] ?? "#16a34a" }} />
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index aa69cb0..fc9e0e9 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -134,7 +134,7 @@ export default async function HomePage() {
                 <div className="relative h-44 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500">
                   {home.images[0] ? (
                     // eslint-disable-next-line @next/next/no-img-element
-                    <img src={home.images[0]} alt={`${home.community.name} home`} className="absolute inset-0 h-full w-full object-cover" />
+                    <img src={home.images[0]} alt={`${home.community.name} home`} loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
                   ) : null}
                   <span className="absolute left-3 top-3 inline-flex items-center rounded-full bg-white/95 px-2.5 py-0.5 text-xs font-semibold text-brand-800 shadow-sm">
                     {tier}
diff --git a/apps/workers/src/pipeline.ts b/apps/workers/src/pipeline.ts
index bc3b193..bcb9ed1 100644
--- a/apps/workers/src/pipeline.ts
+++ b/apps/workers/src/pipeline.ts
@@ -108,20 +108,26 @@ export async function extractStage(
     }
 
     const key = canonicalKey(normalized.canonicalHints);
-    const staged = await prisma.stagedRecord.create({
-      data: {
-        sourceId: source.id,
-        snapshotId,
-        entityType: ENTITY_TYPE_MAP[normalized.entityType],
-        canonicalKey: key,
-        payload: normalized.fields as unknown as Prisma.InputJsonValue,
-        hints: normalized.canonicalHints as unknown as Prisma.InputJsonValue,
-        extractorVersion: adapter.version,
-        status: "EXTRACTED",
-      },
+    const entityType = ENTITY_TYPE_MAP[normalized.entityType];
+    const payloadData = {
+      entityType,
+      payload: normalized.fields as unknown as Prisma.InputJsonValue,
+      hints: normalized.canonicalHints as unknown as Prisma.InputJsonValue,
+      extractorVersion: adapter.version,
+      status: "EXTRACTED" as const,
+    };
+    // Upsert on (source, snapshot, home) so re-extracting the same snapshot (e.g.
+    // FORCE_REEXTRACT to backfill a new field) updates the row instead of piling
+    // up duplicate staged/evidence rows.
+    const staged = await prisma.stagedRecord.upsert({
+      where: { sourceId_snapshotId_canonicalKey: { sourceId: source.id, snapshotId, canonicalKey: key } },
+      create: { sourceId: source.id, snapshotId, canonicalKey: key, ...payloadData },
+      update: payloadData,
     });
     stagedIds.push(staged.id);
 
+    // Replace this row's evidence rather than append (idempotent re-extract).
+    await prisma.sourceEvidence.deleteMany({ where: { stagedRecordId: staged.id } });
     await prisma.sourceEvidence.createMany({
       data: Object.entries(normalized.fields).map(([field, envelope]) => ({
         stagedRecordId: staged.id,
@@ -200,6 +206,9 @@ export async function validateStage(stagedRecordId: string, sourceKey: string) {
     now: new Date(),
   });
 
+  // Idempotent re-validation: drop this row's prior events + open review items first.
+  await prisma.validationEvent.deleteMany({ where: { stagedRecordId: staged.id } });
+  await prisma.reviewItem.deleteMany({ where: { stagedRecordId: staged.id } });
   await prisma.validationEvent.createMany({
     data: events.map((event) => ({
       stagedRecordId: staged.id,
diff --git a/packages/database/prisma/migrations/20260723030000_stagedrecord_unique_idempotent/migration.sql b/packages/database/prisma/migrations/20260723030000_stagedrecord_unique_idempotent/migration.sql
new file mode 100644
index 0000000..14b0a5d
--- /dev/null
+++ b/packages/database/prisma/migrations/20260723030000_stagedrecord_unique_idempotent/migration.sql
@@ -0,0 +1,20 @@
+-- Make (source, snapshot, home) unique so FORCE_REEXTRACT upserts instead of
+-- appending duplicate staged/evidence/event rows. Dedup existing rows first
+-- (keep newest per group, cascade-delete their children) so the index applies.
+CREATE TEMP TABLE _dup_staged AS
+  SELECT id FROM (
+    SELECT id, row_number() OVER (
+      PARTITION BY "sourceId","snapshotId","canonicalKey"
+      ORDER BY "createdAt" DESC, id DESC
+    ) AS rn
+    FROM "StagedRecord" WHERE "snapshotId" IS NOT NULL
+  ) t WHERE rn > 1;
+
+DELETE FROM "SourceEvidence"  WHERE "stagedRecordId" IN (SELECT id FROM _dup_staged);
+DELETE FROM "ValidationEvent" WHERE "stagedRecordId" IN (SELECT id FROM _dup_staged);
+DELETE FROM "ReviewItem"      WHERE "stagedRecordId" IN (SELECT id FROM _dup_staged);
+DELETE FROM "StagedRecord"    WHERE id IN (SELECT id FROM _dup_staged);
+DROP TABLE _dup_staged;
+
+CREATE UNIQUE INDEX "StagedRecord_sourceId_snapshotId_canonicalKey_key"
+  ON "StagedRecord"("sourceId","snapshotId","canonicalKey");

← bda9b29 auto-save: 2026-07-22T20:46:37 (1 files) — packages/database  ·  back to Homesonspec  ·  Builder-image kill-switch: BUILDER_IMAGES_ENABLED env gates 2968a92 →