From 8b6f5f13518ad18eb958aa2a56469baeb451093c Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Fri, 13 Mar 2026 12:05:58 -0600 Subject: [PATCH 1/5] add appCount column to tags table --- db/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/db/schema.ts b/db/schema.ts index 60b9d2a..9d22983 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -170,6 +170,7 @@ export const tags = sqliteTable( name: text("name").notNull(), slug: text("slug").notNull(), type: text("type").$type().notNull(), + appCount: integer("app_count").notNull().default(0), }, (table) => ({ uniqueTag: uniqueIndex("tag_unique").on(table.slug, table.type), From 825a5c175e199e951a53db74f1a362e75d3a7ba4 Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Fri, 13 Mar 2026 12:07:06 -0600 Subject: [PATCH 2/5] compute tag app counts during seed import --- db/seed/import.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/db/seed/import.ts b/db/seed/import.ts index 4b2c079..5d8e18b 100644 --- a/db/seed/import.ts +++ b/db/seed/import.ts @@ -464,6 +464,15 @@ async function upsertAlternatives() { console.log(` ${stats.alternativesCreated} alternative mappings upserted`); } +async function updateTagCounts() { + console.log("Updating tag counts..."); + await client.execute( + "UPDATE tags SET app_count = (SELECT COUNT(*) FROM app_tags WHERE tag_id = tags.id)", + ); + const result = await client.execute("SELECT COUNT(*) as total FROM tags WHERE app_count > 0"); + console.log(` ${result.rows[0].total} tags with apps`); +} + async function main() { console.log("\nSeed import"); console.log("═".repeat(50)); @@ -483,6 +492,7 @@ async function main() { await upsertWebApps(); await upsertProprietaryApps(); await upsertAlternatives(); + await updateTagCounts(); console.log(`\n${"═".repeat(50)}`); console.log("Import complete:"); From f791254368ba0be81ebfc42e430b6ef0351634aa Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Fri, 13 Mar 2026 12:07:51 -0600 Subject: [PATCH 3/5] use denormalized appCount for tag count queries --- db/queries.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/db/queries.ts b/db/queries.ts index 847b08a..2402f1f 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -200,7 +200,7 @@ export async function listTagsByType(db: DrizzleDB, type: TagType) { } export async function listCategoriesWithApps(db: DrizzleDB) { - const rows = await db + return db .select({ id: tags.id, name: tags.name, @@ -208,12 +208,8 @@ export async function listCategoriesWithApps(db: DrizzleDB) { type: tags.type, }) .from(tags) - .innerJoin(appTags, eq(tags.id, appTags.tagId)) - .where(eq(tags.type, "category")) - .groupBy(tags.id) + .where(and(eq(tags.type, "category"), sql`${tags.appCount} > 0`)) .orderBy(tags.name); - - return rows; } // ─── Tag / Category Page Queries ──────────────────────────────────── @@ -288,12 +284,10 @@ export async function listTagsWithCounts(db: DrizzleDB, type?: TagType) { name: tags.name, slug: tags.slug, type: tags.type, - appCount: sql`count(${appTags.appId})`, + appCount: tags.appCount, }) .from(tags) - .leftJoin(appTags, eq(tags.id, appTags.tagId)) .where(conditions.length ? and(...conditions) : undefined) - .groupBy(tags.id) .orderBy(tags.name); } From 0822d6d7bd0c3eb941ec6f6b992137a331f4ec9b Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Fri, 13 Mar 2026 12:11:50 -0600 Subject: [PATCH 4/5] add slim list query helper for app cards --- db/queries.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/db/queries.ts b/db/queries.ts index 2402f1f..d78316c 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -11,6 +11,7 @@ import { alternatives, appDownloads, apps, + appSources, appTags, comparisonPairs, EMBEDDING_DIMENSIONS, @@ -41,6 +42,93 @@ function desktopOnlyAppIds(db: DrizzleDB) { ); } +// ─── Slim List Helper ──────────────────────────────────────────────── +// List pages only need card-relevant fields. Loading full sources +// (with metadata/packageName) and all tags wastes rows. + +const appCardColumns = { + id: apps.id, + name: apps.name, + slug: apps.slug, + description: apps.description, + iconUrl: apps.iconUrl, +}; + +const sourceCardColumns = { + source: appSources.source, + url: appSources.url, +}; + +async function withSlimRelations( + db: DrizzleDB, + appRows: { id: string }[], +): Promise< + { + id: string; + name: string; + slug: string; + description: string | null; + iconUrl: string | null; + sources: { source: string; url: string }[]; + tags: { name: string; slug: string; type: string }[]; + }[] +> { + if (appRows.length === 0) return []; + + const appIds = appRows.map((a) => a.id); + + const [sources, platformTags] = await Promise.all([ + db + .select({ + appId: appSources.appId, + source: appSources.source, + url: appSources.url, + }) + .from(appSources) + .where(inArray(appSources.appId, appIds)), + db + .select({ + appId: appTags.appId, + name: tags.name, + slug: tags.slug, + type: tags.type, + }) + .from(appTags) + .innerJoin(tags, eq(appTags.tagId, tags.id)) + .where( + and( + inArray(appTags.appId, appIds), + eq(tags.type, "platform"), + ), + ), + ]); + + const sourcesByApp = new Map(); + for (const s of sources) { + const arr = sourcesByApp.get(s.appId) ?? []; + arr.push({ source: s.source, url: s.url }); + sourcesByApp.set(s.appId, arr); + } + + const tagsByApp = new Map< + string, + { name: string; slug: string; type: string }[] + >(); + for (const t of platformTags) { + const arr = tagsByApp.get(t.appId) ?? []; + arr.push({ name: t.name, slug: t.slug, type: t.type }); + tagsByApp.set(t.appId, arr); + } + + return (appRows as (typeof appRows[number] & Record)[]).map( + (app) => ({ + ...(app as any), + sources: sourcesByApp.get(app.id) ?? [], + tags: tagsByApp.get(app.id) ?? [], + }), + ); +} + // ─── Types ────────────────────────────────────────────────────────── export type AppWithDetails = Awaited>; From 47170d0a4ee65efd1a2221d92fc297f701a8ac57 Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Fri, 13 Mar 2026 12:14:09 -0600 Subject: [PATCH 5/5] convert list queries to use slim hydration --- db/queries.ts | 148 +++++++++++++++++++--------------------------- db/seed/import.ts | 4 +- 2 files changed, 64 insertions(+), 88 deletions(-) diff --git a/db/queries.ts b/db/queries.ts index d78316c..94ba3ec 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -10,8 +10,8 @@ import type { SourceType, TagType } from "./schema"; import { alternatives, appDownloads, - apps, appSources, + apps, appTags, comparisonPairs, EMBEDDING_DIMENSIONS, @@ -54,11 +54,6 @@ const appCardColumns = { iconUrl: apps.iconUrl, }; -const sourceCardColumns = { - source: appSources.source, - url: appSources.url, -}; - async function withSlimRelations( db: DrizzleDB, appRows: { id: string }[], @@ -95,12 +90,7 @@ async function withSlimRelations( }) .from(appTags) .innerJoin(tags, eq(appTags.tagId, tags.id)) - .where( - and( - inArray(appTags.appId, appIds), - eq(tags.type, "platform"), - ), - ), + .where(and(inArray(appTags.appId, appIds), eq(tags.type, "platform"))), ]); const sourcesByApp = new Map(); @@ -120,13 +110,13 @@ async function withSlimRelations( tagsByApp.set(t.appId, arr); } - return (appRows as (typeof appRows[number] & Record)[]).map( - (app) => ({ - ...(app as any), - sources: sourcesByApp.get(app.id) ?? [], - tags: tagsByApp.get(app.id) ?? [], - }), - ); + return ( + appRows as ((typeof appRows)[number] & Record)[] + ).map((app) => ({ + ...(app as any), + sources: sourcesByApp.get(app.id) ?? [], + tags: tagsByApp.get(app.id) ?? [], + })); } // ─── Types ────────────────────────────────────────────────────────── @@ -167,18 +157,15 @@ export async function listApps( conditions.push(inArray(apps.id, appIdsWithAllTags)); } - const results = await db.query.apps.findMany({ - where: and(...conditions), - with: { sources: true, tags: { with: { tag: true } } }, - limit, - offset, - orderBy: apps.name, - }); + const rows = await db + .select(appCardColumns) + .from(apps) + .where(and(...conditions)) + .limit(limit) + .offset(offset) + .orderBy(apps.name); - return results.map((app) => ({ - ...app, - tags: app.tags.map((at) => at.tag), - })); + return withSlimRelations(db, rows); } export async function getAppBySlug(db: DrizzleDB, slug: string) { @@ -349,18 +336,15 @@ export async function listAppsByTag( .from(appTags) .where(eq(appTags.tagId, tagRow[0].id)); - const results = await db.query.apps.findMany({ - where: inArray(apps.id, appIdsWithTag), - with: { sources: true, tags: { with: { tag: true } } }, - limit, - offset, - orderBy: apps.name, - }); + const rows = await db + .select(appCardColumns) + .from(apps) + .where(inArray(apps.id, appIdsWithTag)) + .limit(limit) + .offset(offset) + .orderBy(apps.name); - return results.map((app) => ({ - ...app, - tags: app.tags.map((at) => at.tag), - })); + return withSlimRelations(db, rows); } export async function listTagsWithCounts(db: DrizzleDB, type?: TagType) { @@ -384,16 +368,18 @@ export async function listTagsWithCounts(db: DrizzleDB, type?: TagType) { export async function searchApps(db: DrizzleDB, query: string) { const pattern = `%${query}%`; - const [appResults, propResults] = await Promise.all([ - db.query.apps.findMany({ - where: and( - like(apps.name, pattern), - notInArray(apps.id, desktopOnlyAppIds(db)), - ), - with: { sources: true, tags: { with: { tag: true } } }, - limit: 20, - orderBy: apps.name, - }), + const [appRows, propResults] = await Promise.all([ + db + .select(appCardColumns) + .from(apps) + .where( + and( + like(apps.name, pattern), + notInArray(apps.id, desktopOnlyAppIds(db)), + ), + ) + .limit(20) + .orderBy(apps.name), db .select() .from(proprietaryApps) @@ -403,10 +389,7 @@ export async function searchApps(db: DrizzleDB, query: string) { ]); return { - apps: appResults.map((app) => ({ - ...app, - tags: app.tags.map((at) => at.tag), - })), + apps: await withSlimRelations(db, appRows), proprietaryApps: propResults, }; } @@ -414,17 +397,14 @@ export async function searchApps(db: DrizzleDB, query: string) { // ─── Discovery Queries ────────────────────────────────────────────── export async function getRecentApps(db: DrizzleDB) { - const results = await db.query.apps.findMany({ - where: notInArray(apps.id, desktopOnlyAppIds(db)), - with: { sources: true, tags: { with: { tag: true } } }, - orderBy: sql`${apps.createdAt} desc`, - limit: 20, - }); + const rows = await db + .select(appCardColumns) + .from(apps) + .where(notInArray(apps.id, desktopOnlyAppIds(db))) + .orderBy(sql`${apps.createdAt} desc`) + .limit(20); - return results.map((app) => ({ - ...app, - tags: app.tags.map((at) => at.tag), - })); + return withSlimRelations(db, rows); } // ─── Desktop App Queries ──────────────────────────────────────────── @@ -448,18 +428,15 @@ export async function listDesktopApps( .where(inArray(appTags.tagId, desktopTagIds)) .groupBy(appTags.appId); - const results = await db.query.apps.findMany({ - where: inArray(apps.id, appsWithDesktopTag), - with: { sources: true, tags: { with: { tag: true } } }, - limit, - offset, - orderBy: apps.name, - }); + const rows = await db + .select(appCardColumns) + .from(apps) + .where(inArray(apps.id, appsWithDesktopTag)) + .limit(limit) + .offset(offset) + .orderBy(apps.name); - return results.map((app) => ({ - ...app, - tags: app.tags.map((at) => at.tag), - })); + return withSlimRelations(db, rows); } // ─── Scan Query ───────────────────────────────────────────────────── @@ -651,18 +628,15 @@ export async function listAppsByLicense( const limit = Math.min(rawLimit, 100); const offset = (page - 1) * limit; - const results = await db.query.apps.findMany({ - where: eq(apps.license, license), - with: { sources: true, tags: { with: { tag: true } } }, - limit, - offset, - orderBy: apps.name, - }); + const rows = await db + .select(appCardColumns) + .from(apps) + .where(eq(apps.license, license)) + .limit(limit) + .offset(offset) + .orderBy(apps.name); - return results.map((app) => ({ - ...app, - tags: app.tags.map((at) => at.tag), - })); + return withSlimRelations(db, rows); } // ─── Sitemap Queries ──────────────────────────────────────────────── diff --git a/db/seed/import.ts b/db/seed/import.ts index 5d8e18b..e36a280 100644 --- a/db/seed/import.ts +++ b/db/seed/import.ts @@ -469,7 +469,9 @@ async function updateTagCounts() { await client.execute( "UPDATE tags SET app_count = (SELECT COUNT(*) FROM app_tags WHERE tag_id = tags.id)", ); - const result = await client.execute("SELECT COUNT(*) as total FROM tags WHERE app_count > 0"); + const result = await client.execute( + "SELECT COUNT(*) as total FROM tags WHERE app_count > 0", + ); console.log(` ${result.rows[0].total} tags with apps`); }