Files
Bike-Tinder/docs/DATA-MODEL.md

4.8 KiB

DATA-MODEL — Bike-Tinder

SQLite, WAL mode, busy_timeout. One file: data/bike-tinder.sqlite3. Schema as consulted (Torres, 2026-11-10), locked for v1.

Tables

listings (
  id             INTEGER PRIMARY KEY AUTOINCREMENT,
  region         TEXT NOT NULL CHECK (region IN ('ns','nb')),
  src_id         TEXT NOT NULL,                    -- source ad id (Kijiji id in v1)
  title          TEXT NOT NULL,
  price_display  TEXT,                             -- "$5,000"; "Please Contact"
  price_num      INTEGER,                          -- NULL when unpackable
  km             INTEGER,                          -- NULL when not stated (~2/3 of cards)
  location       TEXT,
  url            TEXT NOT NULL,                    -- source ad page
  img_url        TEXT,                             -- source CDN card image
  desc           TEXT,                             -- card description snippet
  family         TEXT,                             -- top rubric family label (taste signal)
  score          INTEGER NOT NULL DEFAULT 0,       -- rubric score, recomputed each import
  status         TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','gone','dup')),
  dup_of         INTEGER,                          -- canonical row id when status='dup'
  first_seen     TEXT,  last_seen TEXT,
  created_at     TEXT DEFAULT (datetime('now')),  updated_at TEXT,
  UNIQUE (region, src_id)
);
CREATE INDEX idx_listings_deck ON listings (status, score DESC);
CREATE INDEX idx_listings_dup  ON listings (dup_of) WHERE status = 'dup';

swipes (
  listing_id  INTEGER PRIMARY KEY REFERENCES listings(id) ON DELETE CASCADE,
  action      TEXT NOT NULL CHECK (action IN ('like','nope','maybe')),
  created_at  TEXT DEFAULT (datetime('now')),
  updated_at  TEXT
);

import_runs (
  id, started_at, ok, partial,
  src_fetched_ns, src_fetched_nb,
  total, new, gone, dup, error
);                                                 -- one observability row per nightly run

prefs (
  key TEXT PRIMARY KEY, value TEXT
);                                                 -- deck_size, depth, etc. — tiny

Notes:

  • One row per swipe; a new swipe upserts (a re-swiped card overwrites its action). The latest action is the truth; edits from the list view are upserts. Append-history is deliberately not kept in v1.
  • import_runs is the dashboard's evidence trail — every run writes one row, success or failure.

State semantics

  • new — active with no swipes row → the deck.
  • seen — has any swipe row.
  • gone — was active, then absent from ≥2 consecutive good imports → status='gone' (kept forever, swipes preserved; reappearance flips it back to active). Single misses and failed/partial imports never mark gone.
  • dup — the second province's twin of the same ad (dedupe below) → hidden from the deck, visible under its canonical row in the list.
  • nope — never re-enters the deck unless cleared from the list (DELETE swipe → back to unswiped).
  • maybe — soft skip: the deck query re-admits maybe rows after 7 days (computed at query time — no cron needed). Editing a maybe to like/nope is just an upsert.
  • like — pins to the Liked ("matches") view.

Deck query (one statement)

SELECT * FROM listings
WHERE status = 'active'
  AND id NOT IN (SELECT listing_id FROM swipes WHERE action = 'nope')
  AND id NOT IN (SELECT listing_id FROM swipes WHERE action IN ('like','maybe')
                 AND (action = 'like' OR updated_at > datetime('now','-7 days')))
ORDER BY score DESC, first_seen DESC
LIMIT :deck_size;

(Implementers: a LEFT JOIN swipes variant is equivalent and cheaper — the intent above is the contract. Unscored rows sort below scored ones; nothing is hidden.)

Dedupe (Kijiji cross-province)

  • Group imports by (normalized_title, price_num); when a group spans both provinces, keep the more-complete row canonical (img present → km present → older row) and mark the twin(s) status='dup', dup_of=canonical. The twin's URL is preserved on the canonical row (extra note field / desc suffix).
  • Today's snapshot: 58/320 NS rows are twins of NB rows (dealers cross-post one ad to both provinces).
  • False merges are possible (same title + price, different bike) but rare; every merge is logged in import_runs and reversible in the DB. Honest v1 tradeoff.

Import gates (must pass or the run aborts)

  1. State file ok == true and partial == false.
  2. fetched_at within 26 hours.
  3. Non-empty items.
  4. On failure: log, leave the DB untouched (deck stays last-known-good).

Maintenance

  • Backup: nightly sqlite3 .backup to data/backup/ immediately after import (house rule; the DB is the app's only mutable state).
  • Prune: mirrored image files for rows gone >30 days; rows themselves are never auto-deleted in v1.
  • VACUUM: optional; the DB is KV-sized (a few thousand rows).