132 lines
4 KiB
JavaScript
132 lines
4 KiB
JavaScript
|
|
/**
|
||
|
|
* The listings database — a SQLite file next to the service.
|
||
|
|
*
|
||
|
|
* The service is small enough that one file + prepared statements beats a
|
||
|
|
* server-side DB, and node:sqlite (Node 22+) keeps that free from a native
|
||
|
|
* build step. Everything synchronous — fine for the volume we expect at
|
||
|
|
* launch, and the schema migrates easily to Postgres if volume forces it.
|
||
|
|
*
|
||
|
|
* The schema deliberately duplicates the publisher BNS name into the row:
|
||
|
|
* a listing is identified by `<publisher>/<slug>`, and that composite is
|
||
|
|
* what browse URLs point at. Changing a publisher name later would mean a
|
||
|
|
* new row, not an UPDATE — which matches reality (ownership transfers of a
|
||
|
|
* BNS name do not transfer the apps published under it).
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { DatabaseSync } from 'node:sqlite';
|
||
|
|
import { mkdirSync } from 'node:fs';
|
||
|
|
import { resolve, dirname } from 'node:path';
|
||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
|
||
|
|
const DB_PATH = process.env.PROMETHEUS_DB
|
||
|
|
|| resolve(dirname(fileURLToPath(import.meta.url)), '..', 'db.sqlite');
|
||
|
|
|
||
|
|
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||
|
|
|
||
|
|
export const db = new DatabaseSync(DB_PATH);
|
||
|
|
|
||
|
|
db.exec(`
|
||
|
|
PRAGMA journal_mode = WAL;
|
||
|
|
PRAGMA foreign_keys = ON;
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS listings (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
publisher TEXT NOT NULL,
|
||
|
|
slug TEXT NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
description TEXT NOT NULL,
|
||
|
|
category TEXT NOT NULL,
|
||
|
|
version TEXT NOT NULL,
|
||
|
|
homepage TEXT,
|
||
|
|
icon TEXT,
|
||
|
|
download_url TEXT NOT NULL,
|
||
|
|
download_sha256 TEXT NOT NULL,
|
||
|
|
download_size INTEGER NOT NULL,
|
||
|
|
scan_report TEXT NOT NULL, -- the base64url signed envelope from /api/upload
|
||
|
|
signer_address TEXT NOT NULL,
|
||
|
|
signature TEXT NOT NULL, -- the publisher's BIP-137 signature
|
||
|
|
signed_message TEXT NOT NULL, -- exact bytes the publisher signed
|
||
|
|
created_at TEXT NOT NULL,
|
||
|
|
withdrawn_at TEXT,
|
||
|
|
UNIQUE (publisher, slug)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS idx_listings_category
|
||
|
|
ON listings(category) WHERE withdrawn_at IS NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS idx_listings_publisher
|
||
|
|
ON listings(publisher);
|
||
|
|
`);
|
||
|
|
|
||
|
|
/* ----------------------- prepared statements ----------------------- */
|
||
|
|
|
||
|
|
const stmtInsert = db.prepare(`
|
||
|
|
INSERT INTO listings (
|
||
|
|
id, publisher, slug, name, description, category, version,
|
||
|
|
homepage, icon, download_url, download_sha256, download_size,
|
||
|
|
scan_report, signer_address, signature, signed_message, created_at
|
||
|
|
) VALUES (
|
||
|
|
@id, @publisher, @slug, @name, @description, @category, @version,
|
||
|
|
@homepage, @icon, @download_url, @download_sha256, @download_size,
|
||
|
|
@scan_report, @signer_address, @signature, @signed_message, @created_at
|
||
|
|
)
|
||
|
|
`);
|
||
|
|
|
||
|
|
const stmtGet = db.prepare(`SELECT * FROM listings WHERE id = ?`);
|
||
|
|
|
||
|
|
const stmtAllActive = db.prepare(`
|
||
|
|
SELECT * FROM listings WHERE withdrawn_at IS NULL ORDER BY created_at DESC
|
||
|
|
`);
|
||
|
|
|
||
|
|
const stmtByCategory = db.prepare(`
|
||
|
|
SELECT * FROM listings
|
||
|
|
WHERE category = ? AND withdrawn_at IS NULL
|
||
|
|
ORDER BY created_at DESC
|
||
|
|
`);
|
||
|
|
|
||
|
|
const stmtByPublisher = db.prepare(`
|
||
|
|
SELECT * FROM listings WHERE publisher = ? ORDER BY created_at DESC
|
||
|
|
`);
|
||
|
|
|
||
|
|
const stmtCountActive = db.prepare(`
|
||
|
|
SELECT COUNT(*) AS n FROM listings WHERE withdrawn_at IS NULL
|
||
|
|
`);
|
||
|
|
|
||
|
|
const stmtCountByCategory = db.prepare(`
|
||
|
|
SELECT category, COUNT(*) AS n FROM listings
|
||
|
|
WHERE withdrawn_at IS NULL
|
||
|
|
GROUP BY category
|
||
|
|
`);
|
||
|
|
|
||
|
|
/* ----------------------- public helpers ----------------------- */
|
||
|
|
|
||
|
|
export function insertListing(row) {
|
||
|
|
stmtInsert.run(row);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getListing(id) {
|
||
|
|
return stmtGet.get(id) || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function allActiveListings() {
|
||
|
|
return stmtAllActive.all();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function listingsByCategory(category) {
|
||
|
|
return stmtByCategory.all(category);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function listingsByPublisher(publisher) {
|
||
|
|
return stmtByPublisher.all(publisher);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function countActive() {
|
||
|
|
return stmtCountActive.get().n;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function countByCategory() {
|
||
|
|
const rows = stmtCountByCategory.all();
|
||
|
|
const out = {};
|
||
|
|
for (const row of rows) out[row.category] = row.n;
|
||
|
|
return out;
|
||
|
|
}
|