Skip to content

Storage

The platform uses SQLite with FTS5 (full-text search) as the primary store, with JSON export for AI consumption. Both are shared across all verticals; schemas are per-vertical.

Why SQLite

The spike's decision, validated:

  • Zero-config, portable, easy to back up
  • FTS5 is sufficient for the search patterns we need (destination, service, specialty, free text)
  • One file per environment — easy to inspect, easy to ship
  • Postgres is the right answer at 10× the scale; we're not there

When (if) we outgrow SQLite, the migration path is well-defined: the Pydantic models and MCP tool contracts don't change, only the storage backend.

Schema Pattern (Per Vertical)

Each vertical gets its own set of tables. The DMC schema, validated in the spike:

-- Core entity table
CREATE TABLE dmc (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT,
  source TEXT NOT NULL,
  source_url TEXT,
  crawled_at TIMESTAMP NOT NULL
);

-- Normalized relationship tables (many-to-many)
CREATE TABLE dmc_destinations (
  dmc_id TEXT REFERENCES dmc(id) ON DELETE CASCADE,
  destination TEXT NOT NULL,
  PRIMARY KEY (dmc_id, destination)
);

CREATE TABLE dmc_services (
  dmc_id TEXT REFERENCES dmc(id) ON DELETE CASCADE,
  service TEXT NOT NULL,
  PRIMARY KEY (dmc_id, service)
);

CREATE TABLE dmc_specialties (
  dmc_id TEXT REFERENCES dmc(id) ON DELETE CASCADE,
  specialty TEXT NOT NULL,
  PRIMARY KEY (dmc_id, specialty)
);

-- One-to-one related tables
CREATE TABLE dmc_contact (
  dmc_id TEXT PRIMARY KEY REFERENCES dmc(id) ON DELETE CASCADE,
  emails TEXT,      -- JSON array
  phones TEXT,      -- JSON array
  website TEXT,
  social TEXT       -- JSON object
);

CREATE TABLE dmc_team (
  dmc_id TEXT REFERENCES dmc(id) ON DELETE CASCADE,
  name TEXT,
  role TEXT,
  email TEXT,
  PRIMARY KEY (dmc_id, email)
);

-- Full-text search
CREATE VIRTUAL TABLE dmc_fts USING fts5(
  name, description, destinations, services, specialties,
  content='dmc', content_rowid='rowid'
);

Adding a New Vertical's Schema

To add the Dining vertical:

CREATE TABLE restaurant (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT,
  cuisine TEXT,
  price_tier TEXT,
  source TEXT NOT NULL,
  source_url TEXT,
  crawled_at TIMESTAMP NOT NULL
);

CREATE TABLE restaurant_locations (
  restaurant_id TEXT REFERENCES restaurant(id) ON DELETE CASCADE,
  neighborhood TEXT,
  city TEXT,
  country TEXT,
  address TEXT,
  latitude REAL,
  longitude REAL,
  PRIMARY KEY (restaurant_id, city)
);

-- ... etc

CREATE VIRTUAL TABLE restaurant_fts USING fts5(
  name, description, cuisine, locations,
  content='restaurant', content_rowid='rowid'
);

The pattern is: core entity, normalized relationship tables, FTS5 mirror.

Upsert Logic

Every storage write is an upsert keyed on the entity's external ID. The spike's SQLiteStore implements this with cascade deletes:

def upsert(self, dmc: DMC) -> None:
    with self.conn:
        # Insert or replace the core row
        self.conn.execute(
            "INSERT OR REPLACE INTO dmc (...) VALUES (...)",
            (...)
        )
        # Delete existing relationship rows
        self.conn.execute("DELETE FROM dmc_destinations WHERE dmc_id = ?", (dmc.id,))
        # Re-insert
        for dest in dmc.destinations:
            self.conn.execute(
                "INSERT INTO dmc_destinations VALUES (?, ?)",
                (dmc.id, dest)
            )
        # ... same for services, specialties
        # FTS5 mirror is updated via triggers

Cascade deletes mean: replace the entity completely on re-crawl. No stale relationship rows.

The storage layer exposes typed search methods:

store.search_dmc(
    query="luxury Greece",          # full-text query
    destination="Greece",           # filter
    service="Luxury Travel",        # filter
    specialty=None,                 # filter
    source=None,                    # filter
    limit=20,
)

Searches combine FTS5 with structured filters. The MCP server wraps these. See MCP Server.

JSON Export

storage/json_store.py exports the entire vertical to a single JSON file for AI consumption:

[
  {
    "id": "cf4f5c1676a7",
    "name": "62°N",
    "description": "...",
    "destinations": ["Faroe Islands"],
    "services": ["Accommodation", "..."],
    "specialties": ["Luxury Travel", "..."],
    "contact": { "emails": [...], "phones": [...], "website": "..." },
    "source": "inside_travel"
  },
  ...
]

The JSON export is the "give the LLM the whole world" path — useful for prompts that need broad context, even if the structured search is the primary interface.

Schema Migrations

Schema changes are versioned. A schema_version table tracks the current version. Migrations are forward-only, idempotent, and run on startup if needed.

Backups

  • SQLite file is the entire database — cp dmc_data.db dmc_data.db.bak is a complete backup
  • Backups run daily to object storage
  • Retention: 30 days of daily backups

Marchay Platform Documentation