Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
SQLITECLOUD_URL=sqlitecloud://your-project.g6.sqlite.cloud:8860/cloud.db?apikey=your-api-key
TURSO_DATABASE_URL=libsql://your-database-your-org.aws-us-west-2.turso.io
TURSO_AUTH_TOKEN=your-turso-auth-token
# For local development, point at a file instead and leave the token unset:
# TURSO_DATABASE_URL=file:./local.db
NEXT_PUBLIC_BASE_URL=https://c0upons.com
COINPAY_API_KEY=cp_live_your_api_key
COINPAY_CLIENT_ID=cp_live_your_api_key
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/db-keepalive.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
name: db-keepalive

# SQLite Cloud parks a free node after a stretch of inactivity, and a parked
# node refuses new connections — so no amount of visitor traffic revives it,
# only a restart from https://dashboard.sqlitecloud.io. Prevention is the only
# automatable half: this keeps a query flowing so the node never goes idle
# long enough to be parked.
# Turso scales an idle free database to zero and wakes it automatically on the
# next request, but a group left idle for ten days is archived, and that state
# needs an explicit unarchive call. Keeping a query flowing means the ten-day
# clock never runs down.
#
# Two caveats worth knowing before trusting it as the sole guard:
# * GitHub delays scheduled runs under load and drops them entirely when the
Expand Down Expand Up @@ -47,5 +46,6 @@ jobs:
sleep 10
done
echo "::error::Database unreachable after 3 attempts (last status $status)."
echo "::error::If the body says the node is paused, restart it at https://dashboard.sqlitecloud.io"
echo "::error::If the group was archived for inactivity, unarchive it:"
echo "::error:: turso group unarchive default (or POST /v1/organizations/{org}/groups/{group}/unarchive)"
exit 1
14 changes: 7 additions & 7 deletions apps/web/app/api/health/db/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';

// Never cache: a cached 200 would keep reporting health after the node parks,
// and the keep-alive query has to actually reach SQLite Cloud to count as
// Never cache: a cached 200 would keep reporting health after the database goes
// away, and the keep-alive query has to actually reach Turso to count as
// activity.
export const dynamic = 'force-dynamic';

/**
* Liveness probe for the database, and the query the keep-alive schedule runs.
*
* SQLite Cloud parks a free node after a stretch with no queries, and a parked
* node cannot be woken by traffic — only by a restart from the dashboard. So
* the cheapest cure is to never go idle: `.github/workflows/db-keepalive.yml`
* calls this on a schedule, and the `SELECT 1` is the activity that keeps the
* node awake. It doubles as monitoring — a paused node answers 503 here loudly
* Turso archives a free group after ten days without activity, and an archived
* group needs an explicit unarchive call before it serves queries again. So the
* cheapest cure is to never go idle: `.github/workflows/db-keepalive.yml` calls
* this on a schedule, and the `SELECT 1` is the activity that resets the clock.
* It doubles as monitoring — an unreachable database answers 503 here loudly
* instead of silently emptying the pages that swallow their own DB errors.
*/
export async function GET() {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default function PrivacyPage() {
<Section title="Third-party services">
<ul className="list-disc list-inside flex flex-col gap-1">
<li><strong>Railway</strong> — cloud hosting (<a href="https://railway.com/legal/privacy" target="_blank" rel="noopener noreferrer" className="text-orange-500 hover:underline">privacy policy</a>)</li>
<li><strong>SQLite Cloud</strong> — database provider</li>
<li><strong>Turso</strong> — database provider (<a href="https://turso.tech/privacy-policy" target="_blank" rel="noopener noreferrer" className="text-orange-500 hover:underline">privacy policy</a>)</li>
<li><strong>CoinPay</strong> — optional OAuth authentication for voting</li>
<li><strong>CrawlProof</strong> — privacy-friendly analytics</li>
</ul>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function Footer() {

<div className="border-t border-gray-800 pt-6 flex flex-col sm:flex-row justify-between items-center gap-3 text-xs text-gray-500">
<p>© {new Date().getFullYear()} c0upons. Community-powered savings.</p>
<p>Built with Next.js · Powered by SQLite Cloud</p>
<p>Built with Next.js · Powered by Turso</p>
</div>
</div>
</footer>
Expand Down
17 changes: 9 additions & 8 deletions apps/web/lib/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@ import { NextResponse } from 'next/server';
import { isDbPaused } from './db';

/**
* What a caller sees when the database node is parked. It names the cause
* rather than the fix: the dashboard restart is an operator action, and the
* person hitting the API can only wait.
* What a caller sees when the database cannot be reached. It names the cause
* rather than the fix: reviving an archived database is an operator action, and
* the person hitting the API can only wait.
*/
export const DB_PAUSED_MESSAGE =
'The coupon database is temporarily unavailable and should be back shortly.';

/**
* Map an error caught in a route handler onto a response.
*
* A paused database is a transient infrastructure state, not a bad request and
* not a bug, so it answers 503 + Retry-After instead of a blanket 500. Anything
* else keeps the route's own 500 and message, so a genuine defect still reads
* as a defect. `code` is stable for clients to branch on — the CLI prints its
* own wording for `database_paused` rather than echoing a raw HTTP status.
* An unreachable database is a transient infrastructure state, not a bad
* request and not a bug, so it answers 503 + Retry-After instead of a blanket
* 500. Anything else keeps the route's own 500 and message, so a genuine defect
* still reads as a defect. The `database_paused` code is the published wire
* contract for clients that want to branch on this rather than parse prose; it
* predates the move to Turso and is kept as-is so existing callers don't break.
*/
export function dbErrorResponse(err: unknown, fallback: string): NextResponse {
if (isDbPaused(err)) {
Expand Down
113 changes: 55 additions & 58 deletions apps/web/lib/db.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,67 @@
import 'server-only';
import { Database } from '@sqlitecloud/drivers';
import { createClient, type Client, type InValue } from '@libsql/client';
import { loadRootEnv } from './root-env';

let client: Database | null = null;
let client: Client | null = null;

function createClient(): Database {
function getClient(): Client {
if (client) return client;
loadRootEnv();
const url = process.env.SQLITECLOUD_URL;
if (!url) throw new Error('SQLITECLOUD_URL is not set');
return new Database({ connectionstring: url, usewebsocket: true });
const url = process.env.TURSO_DATABASE_URL;
if (!url) throw new Error('TURSO_DATABASE_URL is not set');
// A `file:` URL needs no token, which is what makes local runs and tests
// possible without production credentials.
client = createClient({ url, authToken: process.env.TURSO_AUTH_TOKEN });
return client;
}

// A cached client can hold a dead websocket after the SQLite Cloud node pauses
// and resumes (free-tier auto-pause). Without this, getDb() keeps returning the
// same broken connection and every query fails with "Connection unavailable"
// until the process is restarted — which is exactly how /blog silently emptied.
// Treat disconnect-class errors as recoverable: drop the client and retry once
// with a fresh connection.
function isDisconnect(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /connection unavailable|got disconnected|disconnected|connection not been established|ERR_CONNECTION/i.test(
msg
);
}

// SQLite Cloud parks a free-tier node after a stretch of inactivity. Every
// query then fails with error 10010 until someone restarts it from the
// dashboard, and the node refuses new sockets too — so reconnecting cannot fix
// it. That is why this is deliberately NOT folded into isDisconnect() above:
// retrying a paused node just pays the connection cost twice before failing
// identically. Callers use it to tell "c0upons is down" (transient, 503) apart
// from "c0upons is broken" (a real 500).
export function isDbPaused(err: unknown): boolean {
const code = (err as { errorCode?: string | number } | null)?.errorCode;
if (code != null && String(code) === '10010') return true;
const msg = err instanceof Error ? err.message : String(err);
return /node has been paused|paused due to inactivity/i.test(msg);
// libSQL refuses `undefined` outright ("undefined cannot be passed as argument
// to the database"), while the routes hand it over freely for absent optional
// fields — an unscraped image_url, a webhook payload without a thumbnail. The
// old driver swallowed those; coercing here keeps a missing field a NULL
// instead of turning it into a 500.
function bind(value: unknown): InValue {
return value === undefined ? null : (value as InValue);
}

async function runSql(args: unknown[]): Promise<unknown> {
if (!client) client = createClient();
try {
return await (client.sql as (...a: unknown[]) => Promise<unknown>)(...args);
} catch (err) {
if (!isDisconnect(err)) throw err;
try {
(client as unknown as { close?: () => void }).close?.();
} catch {
/* ignore close failures on an already-dead socket */
}
client = createClient();
return await (client.sql as (...a: unknown[]) => Promise<unknown>)(...args);
}
/**
* The database handle. Only `db.sql` is used anywhere in the app, so this
* exposes exactly that: a tagged template that binds every interpolated value
* as a parameter and resolves to the rows.
*
* libSQL rows are array-like *and* object-like, and serialize to plain named
* objects, so callers keep working unchanged — `rows.length`, destructuring a
* single COUNT row, and `NextResponse.json(rows)` all behave as before.
*
* Unlike the SQLite Cloud driver this replaces, there is no long-lived
* websocket to go stale, so no reconnect dance is needed: the HTTP client
* establishes a connection per request and a dropped one cannot poison the
* cached handle the way it once silently emptied /blog.
*/
export function getDb() {
return {
// The app assigns results straight to its own row types
// (`const stores: StoreWithCount[] = await db.sql...`), which is why this
// stays `any` rather than forcing a cast at all 77 call sites.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sql: async (strings: TemplateStringsArray, ...values: unknown[]): Promise<any> => {
const rs = await getClient().execute({
sql: strings.join('?'),
args: values.map(bind),
});
return rs.rows;
},
};
}

// Returns the shared client with its `sql` tagged-template wrapped so a stale
// connection self-heals. All call sites use only `db.sql`, so this is drop-in.
export function getDb(): Database {
if (!client) client = createClient();
return new Proxy(client, {
get(target, prop, receiver) {
if (prop === 'sql') {
return (...args: unknown[]) => runSql(args);
}
const value = Reflect.get(target, prop, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
// Turso scales an idle free database to zero and wakes it automatically on the
// next request, so a brief stall is normal rather than an outage. A group left
// idle for ten days is archived instead, and that state does need an explicit
// unarchive call — the shapes below are the ones seen for a database that is
// gone or unreachable rather than merely asleep. Callers use this to tell
// "c0upons is down" (transient, 503) apart from "c0upons is broken" (a real
// 500).
export function isDbPaused(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /archived|not found|unavailable|SERVER_ERROR|502|503/i.test(msg);
}
112 changes: 74 additions & 38 deletions apps/web/lib/schema.sql
Original file line number Diff line number Diff line change
@@ -1,56 +1,92 @@
-- Reference copy of the c0upons schema.
--
-- scripts/migrate.mjs is what actually creates and evolves the database; this
-- file documents the result. It is dumped from a database built by that script,
-- so the two agree — an earlier copy of this file stopped at blog_posts and
-- omitted coupon_votes and bounties entirely, which made rebuilding from it
-- silently incomplete. Regenerate with `turso db shell <db> ".schema"` after
-- changing the migration rather than hand-editing.

CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS stores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
logo_url TEXT,
website TEXT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
logo_url TEXT,
website TEXT,
category_id INTEGER REFERENCES categories(id),
created_at TEXT DEFAULT (datetime('now'))
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS coupons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
store_id INTEGER NOT NULL REFERENCES stores(id),
code TEXT,
title TEXT NOT NULL,
description TEXT,
discount TEXT,
discount_type TEXT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
code TEXT,
title TEXT NOT NULL,
description TEXT,
discount TEXT,
discount_type TEXT,
discount_value REAL,
expiry_date TEXT,
verified INTEGER DEFAULT 0,
votes INTEGER DEFAULT 0,
url TEXT,
image_url TEXT,
created_at TEXT DEFAULT (datetime('now'))
expiry_date TEXT,
url TEXT,
image_url TEXT,
votes INTEGER NOT NULL DEFAULT 0,
verified INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_coupons_store ON coupons(store_id);
CREATE INDEX IF NOT EXISTS idx_stores_slug ON stores(slug);
CREATE INDEX IF NOT EXISTS idx_categories_slug ON categories(slug);
CREATE TABLE IF NOT EXISTS coupon_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
coupon_id INTEGER NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
voter_did TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(coupon_id, voter_did)
);

CREATE TABLE IF NOT EXISTS blog_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT NOT NULL DEFAULT '',
cover_image TEXT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT NOT NULL DEFAULT '',
cover_image TEXT,
thumbnail_image TEXT,
banner_image TEXT,
author TEXT,
status TEXT NOT NULL DEFAULT 'published',
source TEXT,
source_id TEXT,
published_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
banner_image TEXT,
author TEXT,
status TEXT NOT NULL DEFAULT 'published',
source TEXT,
source_id TEXT,
published_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS bounties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT,
creator_did TEXT NOT NULL,
store_id INTEGER REFERENCES stores(id),
store_name TEXT,
title TEXT NOT NULL,
description TEXT,
url TEXT,
reward_usd REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
payment_id TEXT,
coupon_id INTEGER REFERENCES coupons(id),
claimer_did TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_coupons_store ON coupons(store_id);
CREATE INDEX IF NOT EXISTS idx_stores_slug ON stores(slug);
CREATE INDEX IF NOT EXISTS idx_categories_slug ON categories(slug);
CREATE INDEX IF NOT EXISTS idx_blog_posts_status_published ON blog_posts(status, published_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_blog_posts_source_id ON blog_posts(source, source_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_bounties_public_id ON bounties(public_id);
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.104.1",
"@libsql/client": "^0.17.4",
"@profullstack/stack": "^0.1.3",
"@serwist/next": "^9.5.0",
"@sqlitecloud/drivers": "^1.0.880",
"next": "16.2.7",
"posthog-js": "^1.381.0",
"react": "19.2.4",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ c0upons is a free, open-source coupon aggregator where anyone can browse deals,
- **Community-driven:** Anyone can submit codes; votes surface the best ones.
- **Open source:** MIT licensed, source on GitHub.
- **API:** `GET /api/coupons`, `GET /api/stores`, `GET /api/search?q=query`
- **Stack:** Next.js 16, SQLite Cloud, Tailwind CSS v4, deployed on Railway.
- **Stack:** Next.js 16, Turso (libSQL), Tailwind CSS v4, deployed on Railway.
Loading
Loading