From 7a479eb175b30412f1d406db698f13a8516e04c9 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 11 Oct 2026 12:02:18 +1100 Subject: [PATCH] perf(d1): bound scoring and discovery query work --- layers/registry/server/api/tags/[slug].get.ts | 48 +++++++------ .../server/tasks/drain-skill-dirty.spec.ts | 24 ++++++- .../server/tasks/drain-skill-dirty.ts | 9 +-- .../server/utils/ai-generation-work.ts | 17 +++-- .../registry/server/utils/recompute-scores.ts | 48 ++++++++++++- .../registry/server/utils/skills-registry.ts | 14 ++-- test/unit/ai-generation-work.test.ts | 22 +++++- test/unit/recompute-scores-identity.test.ts | 62 ++++++++++++++++- .../skills-registry-unique-owners.test.ts | 16 ++++- test/unit/tag-profile-cache.test.ts | 69 +++++++++++++++++-- 10 files changed, 276 insertions(+), 53 deletions(-) diff --git a/layers/registry/server/api/tags/[slug].get.ts b/layers/registry/server/api/tags/[slug].get.ts index 315e22f0..6d1942b9 100644 --- a/layers/registry/server/api/tags/[slug].get.ts +++ b/layers/registry/server/api/tags/[slug].get.ts @@ -122,34 +122,40 @@ async function buildTagProfile(event: H3Event, slug: string): Promise ({ vi.mock('../utils/recompute-scores', () => ({ recomputeIndexabilityForSkill: vi.fn(), - recomputeTrustForSkill: vi.fn(), })) vi.stubGlobal('defineScheduledTask', (task: unknown) => task) @@ -57,4 +56,27 @@ describe('drain-skill-dirty task', () => { durationMs: expect.any(Number), }) }) + + it('recomputes and removes successful queue entries', async () => { + const key = { owner: 'acme', repo: 'tools', name: 'review' } + const run = vi.fn().mockResolvedValue({ meta: { changes: 1 } }) + const all = vi.fn().mockResolvedValue({ results: [key] }) + const bound = { all, run } + const bind = vi.fn(() => bound) + const batch = vi.fn().mockResolvedValue([]) + const db = { prepare: vi.fn(() => ({ bind })), batch } + mocks.resolveCloudflareBindings.mockReturnValue({ DB: db }) + + const result = await task.run({ context: {} } as never) + + expect(result).toEqual({ result: { drained: 1, failed: 0, scanned: 1 } }) + expect(run).toHaveBeenCalledOnce() + expect(batch).toHaveBeenCalledWith([bound]) + expect(mocks.reportJobRun).toHaveBeenCalledWith(db, 'drain-skill-dirty', { + cron: '*/5 * * * *', + status: 'ok', + durationMs: expect.any(Number), + error: null, + }) + }) }) diff --git a/layers/registry/server/tasks/drain-skill-dirty.ts b/layers/registry/server/tasks/drain-skill-dirty.ts index 6f4ba292..dab4c5ac 100644 --- a/layers/registry/server/tasks/drain-skill-dirty.ts +++ b/layers/registry/server/tasks/drain-skill-dirty.ts @@ -4,10 +4,7 @@ import { resolveCloudflareBindings } from '@harlan-zw/nuxt-cloudflare/bindings' import { runObservedScheduledTask } from '~~/server/utils/scheduled-run' import { reportJobRun } from '~~/server/utils/sync-job-reporter' import { observedSchedulePolicy } from '#shared/schedule-policy' -import { - recomputeIndexabilityForSkill, - recomputeTrustForSkill, -} from '../utils/recompute-scores' +import { recomputeIndexabilityForSkill } from '../utils/recompute-scores' import { SKILL_COUNTER_RECOMPUTE_SQL } from '../utils/skill-counter-recompute' const BATCH = 200 @@ -89,10 +86,8 @@ export default defineScheduledTask({ .then(async () => { // Counters just moved; re-score indexability + trust for this // skill so seo_index_score / trust_tier track the new inputs. - // Indexability writes both sets of columns in one UPDATE; trust - // helper is a no-op when indexability already covered the change. + // Indexability writes both sets of columns in one UPDATE. await recomputeIndexabilityForSkill(db, { owner, repo, name }) - await recomputeTrustForSkill(db, { owner, repo, name }) updated++ successful.push({ owner, repo, name }) }) diff --git a/layers/registry/server/utils/ai-generation-work.ts b/layers/registry/server/utils/ai-generation-work.ts index 824d3021..2d2af619 100644 --- a/layers/registry/server/utils/ai-generation-work.ts +++ b/layers/registry/server/utils/ai-generation-work.ts @@ -108,7 +108,10 @@ export type PersistAbstractnessResult = | { _tag: 'written' } | { _tag: 'source_changed' } -const ELIGIBLE_SKILL_SQL = ` +// Match String.trim(), including Unicode whitespace. +const NONEMPTY_SOURCE_SQL = `LENGTH(TRIM(s.rendered_raw, char(9, 10, 11, 12, 13, 32, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288, 65279))) > 0` + +const ELIGIBLE_SKILL_BASE_SQL = ` SELECT s.owner, s.repo, @@ -121,20 +124,22 @@ const ELIGIBLE_SKILL_SQL = ` WHERE r.broken_since IS NULL AND s.current_sha IS NOT NULL AND s.rendered_raw IS NOT NULL - -- Match String.trim(): empty content cannot produce an embedding or classification. - AND LENGTH(TRIM(s.rendered_raw, char(9, 10, 11, 12, 13, 32, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288, 65279))) > 0 AND s.rendered_status = 'ok' AND s.seo_indexable = 1 ` +const ELIGIBLE_SKILL_SQL = `${ELIGIBLE_SKILL_BASE_SQL} AND ${NONEMPTY_SOURCE_SQL}` + /** * Count the embedding backlog so the limit can respond to it. One extra query * against a budget of 850 is a cheap price for not silently falling behind. */ export async function countMissingEmbeddings(db: D1Database): Promise { const row = await db.prepare( - `SELECT COUNT(*) AS pending FROM (${ELIGIBLE_SKILL_SQL} - AND NOT EXISTS ( + // CASE evaluates the source only when its embedding needs work. Trimming + // every completed source cost 2.2 seconds on the live catalog. + `SELECT COUNT(*) AS pending FROM (${ELIGIBLE_SKILL_BASE_SQL} + AND CASE WHEN NOT EXISTS ( SELECT 1 FROM skill_generated generated WHERE generated.owner = s.owner @@ -142,7 +147,7 @@ export async function countMissingEmbeddings(db: D1Database): Promise { AND generated.name = s.name AND generated.kind = 'embedding' AND generated.sha = s.current_sha - ) + ) THEN ${NONEMPTY_SOURCE_SQL} ELSE 0 END )`, ).first<{ pending: number }>() return Number(row?.pending ?? 0) diff --git a/layers/registry/server/utils/recompute-scores.ts b/layers/registry/server/utils/recompute-scores.ts index cd194b7b..97173634 100644 --- a/layers/registry/server/utils/recompute-scores.ts +++ b/layers/registry/server/utils/recompute-scores.ts @@ -88,7 +88,10 @@ const BASE_SELECT = ` s.trust_score, s.trust_reasons, o.tier AS override_tier, - o.reason AS override_reason, + o.reason AS override_reason +` + +const LIVE_COUNTER_SELECT = ` ( SELECT COUNT(*) FROM collection_skills_v2 cs @@ -135,6 +138,44 @@ const FROM_JOIN = ` LEFT JOIN repo_trust_overrides o ON o.owner = s.owner AND o.repo = s.repo ` +// Group once for the full pass. Counting siblings for every skill makes +// large repositories cost quadratically more reads. +const ALL_COUNTERS_CTE = ` + WITH curator_counts AS ( + SELECT cs.owner, cs.repo, cs.name, + COUNT(*) AS curator_count, + SUM(CASE WHEN length(trim(COALESCE(cs.reason, ''))) >= 20 THEN 1 ELSE 0 END) AS curator_reason_count + FROM collection_skills_v2 cs + JOIN collections_v2 c ON c.id = cs.collection_id + WHERE c.deleted_at IS NULL + GROUP BY cs.owner, cs.repo, cs.name + ), social_counts AS ( + SELECT skill_slug, COUNT(*) AS approved_social_count, + SUM(CASE WHEN role = 'author' THEN 1 ELSE 0 END) AS author_social_count + FROM skill_social_posts + WHERE status = 'approved' + GROUP BY skill_slug + ), repo_counts AS ( + SELECT owner, repo, COUNT(*) AS repo_skill_count + FROM skills + GROUP BY owner, repo + ) +` + +const ALL_COUNTER_SELECT = ` + COALESCE(cc.curator_count, 0) AS curator_count, + COALESCE(cc.curator_reason_count, 0) AS curator_reason_count, + COALESCE(sc.approved_social_count, 0) AS approved_social_count, + COALESCE(sc.author_social_count, 0) AS author_social_count, + CASE WHEN r.broken_since IS NULL THEN COALESCE(rc.repo_skill_count, 0) ELSE 0 END AS repo_skill_count +` + +const ALL_COUNTER_JOINS = ` + LEFT JOIN curator_counts cc ON cc.owner = s.owner AND cc.repo = s.repo AND cc.name = s.name + LEFT JOIN social_counts sc ON sc.skill_slug = s.slug + LEFT JOIN repo_counts rc ON rc.owner = s.owner AND rc.repo = s.repo +` + function computeFromRow(row: ScoreRow, now: number): { isOfficial: boolean sourceResolved: boolean @@ -290,7 +331,7 @@ function updateTrustStmt( async function fetchOne(db: D1Database, key: SkillKey): Promise { const row = await db - .prepare(`SELECT ${BASE_SELECT} ${FROM_JOIN} WHERE s.owner = ?1 AND s.repo = ?2 AND s.name = ?3 LIMIT 1`) + .prepare(`SELECT ${BASE_SELECT}, ${LIVE_COUNTER_SELECT} ${FROM_JOIN} WHERE s.owner = ?1 AND s.repo = ?2 AND s.name = ?3 LIMIT 1`) .bind(key.owner, key.repo, key.name) .first() return row ?? null @@ -340,7 +381,8 @@ export async function recomputeAllSkillScores( const res = await db .prepare( - `SELECT ${BASE_SELECT} ${FROM_JOIN} + `${ALL_COUNTERS_CTE} + SELECT ${BASE_SELECT}, ${ALL_COUNTER_SELECT} ${FROM_JOIN} ${ALL_COUNTER_JOINS} ORDER BY r.stars DESC, s.owner ASC, s.repo ASC, s.name ASC ${limitClause}`, ) diff --git a/layers/registry/server/utils/skills-registry.ts b/layers/registry/server/utils/skills-registry.ts index 7480fcb9..ad19563f 100644 --- a/layers/registry/server/utils/skills-registry.ts +++ b/layers/registry/server/utils/skills-registry.ts @@ -424,26 +424,28 @@ export async function querySkills(event: H3Event, opts: SkillsQuery): Promise { sqlite.prepare('UPDATE skills SET rendered_raw = ?').run(source) expect(await selectMissingGeneratedSkills(db, 'abstractness', 50)).toEqual([]) expect(await selectMissingGeneratedSkills(db, 'embedding', 50)).toEqual([]) + expect(await countMissingEmbeddings(db)).toBe(0) + }) + + it('counts only eligible sources without a current embedding', async () => { + expect(await countMissingEmbeddings(db)).toBe(1) + sqlite.prepare('UPDATE skill_generated SET sha = \'old\' WHERE name = \'paused-only\' AND kind = \'embedding\'').run() + expect(await countMissingEmbeddings(db)).toBe(2) + sqlite.prepare('UPDATE skills SET rendered_status = \'failed\' WHERE name = \'paused-only\'').run() + expect(await countMissingEmbeddings(db)).toBe(1) + sqlite.prepare('UPDATE repos SET broken_since = 1').run() + expect(await countMissingEmbeddings(db)).toBe(0) + }) + + it('does not trim sources whose embedding is current', async () => { + const trim = vi.fn((source: string) => source.trim()) + sqlite.function('trim', { varargs: true }, trim) + expect(await countMissingEmbeddings(db)).toBe(1) + expect(trim.mock.calls.map(([source]) => source)).toEqual(['# Embed']) }) it('does not select current runtime kinds because paused batch kinds are missing', async () => { @@ -267,6 +286,7 @@ function wrapSqlite(sqlite: Database.Database): D1Database { all: async () => ({ results: sqlite.prepare(sql).all(bindings(params)) as T[], }), + first: async () => (sqlite.prepare(sql).get(bindings(params)) as T | undefined) ?? null, run: async () => { const result = sqlite.prepare(sql).run(bindings(params)) return { meta: { changes: result.changes } } diff --git a/test/unit/recompute-scores-identity.test.ts b/test/unit/recompute-scores-identity.test.ts index 517debcf..2250ee56 100644 --- a/test/unit/recompute-scores-identity.test.ts +++ b/test/unit/recompute-scores-identity.test.ts @@ -1,12 +1,13 @@ import Database from 'better-sqlite3' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { recomputeIndexabilityForSkill } from '../../layers/registry/server/utils/recompute-scores' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { recomputeAllSkillScores, recomputeIndexabilityForSkill } from '../../layers/registry/server/utils/recompute-scores' describe('score recomputation identity and source health', () => { let sqlite: Database.Database let db: D1Database beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(1791676800000) sqlite = new Database(':memory:') sqlite.exec(` CREATE TABLE repos ( @@ -97,6 +98,7 @@ describe('score recomputation identity and source health', () => { }) afterEach(() => { + vi.restoreAllMocks() sqlite.close() }) @@ -162,6 +164,62 @@ describe('score recomputation identity and source health', () => { }) }) + it('matches individual recomputes for live evidence and broken sources', async () => { + sqlite.exec(` + INSERT INTO collections_v2 (id, deleted_at) VALUES (2, 1), (3, NULL); + INSERT INTO collection_skills_v2 (collection_id, owner, repo, name, reason) VALUES + (2, 'acme', 'one', 'shared', 'Deleted collections must not contribute this reason.'), + (3, 'acme', 'one', 'shared', NULL), + (3, 'acme', 'two', 'shared', 'short'); + INSERT INTO skill_social_posts (skill_slug, status, role) VALUES + ('acme/one/shared', 'approved', 'author'), + ('acme/one/shared', 'approved', NULL), + ('acme/one/shared', 'pending', 'author'), + ('acme/two/shared', 'approved', 'community'); + `) + const keys = sqlite.prepare('SELECT owner, repo, name FROM skills').all() as Array<{ owner: string, repo: string, name: string }> + for (const key of keys) + await recomputeIndexabilityForSkill(db, key) + const individual = sqlite.prepare('SELECT * FROM skills ORDER BY owner, repo, name').all() + sqlite.exec(`UPDATE skills SET curator_count = 77, curator_reason_count = 77, + approved_social_count = 77, author_social_count = 77, seo_index_score = 77, + trust_score = 77`) + + const result = await recomputeAllSkillScores(db) + + expect(result).toEqual({ scanned: 5, indexabilityUpdated: 5, trustUpdated: 0 }) + expect(sqlite.prepare('SELECT * FROM skills ORDER BY owner, repo, name').all()).toEqual(individual) + expect(sqlite.prepare(`SELECT curator_count, curator_reason_count, approved_social_count, + author_social_count FROM skills WHERE repo = 'one' AND name = 'shared'`).get()).toEqual({ + curator_count: 2, + curator_reason_count: 1, + approved_social_count: 2, + author_social_count: 1, + }) + }) + + it('limits the pass while counting every sibling skill', async () => { + sqlite.exec(`UPDATE repos SET stars = 2000 WHERE repo = 'one'`) + const insert = sqlite.prepare(`INSERT INTO skills + (owner, repo, name, slug, description, current_sha, sync_status) + VALUES ('acme', 'one', ?, ?, 'Sibling', 'sha', 'ok')`) + for (let index = 0; index < 300; index++) { + const name = `a${String(index).padStart(3, '0')}` + insert.run(name, `acme/one/${name}`) + } + const full = await recomputeAllSkillScores(db) + const first = sqlite.prepare(`SELECT * FROM skills WHERE repo = 'one' AND name = 'a000'`).get() + sqlite.exec('UPDATE skills SET seo_index_score = 77, trust_score = 77') + + const limited = await recomputeAllSkillScores(db, { limit: 1 }) + + expect(full.scanned).toBe(305) + expect(limited).toEqual({ scanned: 1, indexabilityUpdated: 1, trustUpdated: 0 }) + expect(sqlite.prepare(`SELECT * FROM skills WHERE repo = 'one' AND name = 'a000'`).get()).toEqual(first) + expect(first).toMatchObject({ trust_source: 'repo-scale' }) + expect(sqlite.prepare('SELECT COUNT(*) AS n FROM skills WHERE seo_index_score = 77').get()).toEqual({ n: 304 }) + }) + function readSkill(repo: string, name: string): Record { return sqlite.prepare( `SELECT source_resolved, curator_count, curator_reason_count, diff --git a/test/unit/skills-registry-unique-owners.test.ts b/test/unit/skills-registry-unique-owners.test.ts index 30802c29..8c712ee4 100644 --- a/test/unit/skills-registry-unique-owners.test.ts +++ b/test/unit/skills-registry-unique-owners.test.ts @@ -5,6 +5,11 @@ import { querySkills } from '../../layers/registry/server/utils/skills-registry' describe('skills registry unique owner browse', () => { it('returns each owner once using their highest ranked skill', async () => { const sqlite = new Database(':memory:') + let descriptionReads = 0 + sqlite.function('read_description', { deterministic: true }, (owner: string) => { + descriptionReads++ + return `${owner} description` + }) let forbidSkillBodyRead = false sqlite.function('forbid_skill_body_read', { deterministic: true }, (owner: string) => { if (forbidSkillBodyRead) @@ -36,7 +41,7 @@ describe('skills registry unique owner browse', () => { display_name TEXT NOT NULL, slug TEXT NOT NULL, like_count INTEGER NOT NULL DEFAULT 0, - description TEXT, + description TEXT GENERATED ALWAYS AS (read_description(owner)) VIRTUAL, rendered_raw_sha256 TEXT, seo_index_score INTEGER NOT NULL DEFAULT 0, seo_indexable INTEGER NOT NULL DEFAULT 1, @@ -67,6 +72,7 @@ describe('skills registry unique owner browse', () => { ('gone', 'deleted-repo', 'ghost', 'Ghost', 'gone/deleted-repo/ghost', 99, 0); `) forbidSkillBodyRead = true + descriptionReads = 0 try { const result = await querySkills(eventFor(sqlite), { @@ -77,10 +83,18 @@ describe('skills registry unique owner browse', () => { // `gone/ghost` has the highest like count but its source is unresolved, // so it must not win the antfu-free owner browse or appear at all. expect(result.total).toBe(2) + expect(descriptionReads).toBe(result.items.length) expect(result.items.map(skill => [skill.owner, skill.name, skill.likeCount])).toEqual([ ['antfu', 'vite', 9], ['vuejs', 'vue', 5], ]) + for (const sort of ['stars', 'name', 'owner', 'updated'] as const) { + descriptionReads = 0 + const paged = await querySkills(eventFor(sqlite), { uniqueOwners: true, sort, limit: 1, page: 2 }) + expect(paged.items.map(skill => [skill.owner, skill.name])).toEqual([['vuejs', 'vue']]) + expect(paged.total).toBe(2) + expect(descriptionReads).toBe(1) + } const focused = await querySkills(eventFor(sqlite), { uniqueOwners: true, maintainerRepos: true } as Parameters[1]) expect(focused.items.map(skill => [skill.owner, skill.repo])).toEqual([['antfu', 'top-repo']]) expect(focused.total).toBe(1) diff --git a/test/unit/tag-profile-cache.test.ts b/test/unit/tag-profile-cache.test.ts index 1eba7ca1..8d4140f8 100644 --- a/test/unit/tag-profile-cache.test.ts +++ b/test/unit/tag-profile-cache.test.ts @@ -1,4 +1,5 @@ import type { H3Event } from 'h3' +import type { RegistrySkill } from '../../layers/registry/server/utils/skills-registry' import type { SqliteD1 } from './helpers/d1-sqlite' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' @@ -70,7 +71,7 @@ vi.stubGlobal('getQuery', () => ({})) vi.stubGlobal('createError', (input: Record) => Object.assign(new Error(String(input.message)), input)) let harness: SqliteD1 -let handler: (event: H3Event) => Promise<{ skills: unknown[] }> +let handler: (event: H3Event) => Promise<{ skills: RegistrySkill[] }> beforeEach(async () => { vi.resetModules() @@ -86,7 +87,7 @@ beforeEach(async () => { harness = createSqliteD1(allMigrations()) harness.raw.prepare(`INSERT INTO repos (owner, repo) VALUES ('acme', 'tools')`).run() seedSkill('frontend-lint') - handler = (await import('../../layers/registry/server/api/tags/[slug].get')).default as (event: H3Event) => Promise<{ skills: unknown[] }> + handler = (await import('../../layers/registry/server/api/tags/[slug].get')).default as typeof handler }) afterEach(() => { @@ -163,11 +164,69 @@ describe('tag profile cache', () => { }) }) -function seedSkill(name: string) { +describe('tag profile membership', () => { + it('merges identifier, owner, and generated matches without duplicates or excluded Skills', async () => { + harness.raw.prepare(`INSERT INTO repos (owner, repo, stars) VALUES ('frontend', 'kit', 100)`).run() + harness.raw.prepare(`INSERT INTO owners (owner, name) VALUES ('frontend', 'Frontend Authors')`).run() + seedSkill('frontend-alpha', 'frontend', 'kit') + seedSkill('beta', 'frontend', 'kit') + seedSkill('classified') + seedSkill('description-only') + seedSkill('frontend-unresolved') + harness.raw.prepare(`UPDATE skills SET source_resolved = 0 WHERE name = 'frontend-unresolved'`).run() + harness.raw.prepare(`UPDATE skills SET description = 'frontend' WHERE name = 'description-only'`).run() + for (const [owner, repo, name] of [ + ['frontend', 'kit', 'frontend-alpha'], + ['acme', 'tools', 'classified'], + ['acme', 'tools', 'frontend-unresolved'], + ['missing', 'repo', 'orphan'], + ]) { + harness.raw.prepare( + `INSERT INTO skill_generated (owner, repo, name, kind, sha, payload, generated_at) + VALUES (?, ?, ?, 'tags', 'sha', '{"tags":["frontend","frontend"]}', '2026-10-01')`, + ).run(owner!, repo!, name!) + } + harness.raw.prepare(`INSERT INTO repos (owner, repo, stars, broken_since) VALUES ('broken', 'tools', 1000, unixepoch() - 8 * 86400)`).run() + seedSkill('frontend-broken', 'broken', 'tools') + harness.raw.prepare(`INSERT INTO repos (owner, repo, stars, broken_since) VALUES ('grace', 'tools', 50, unixepoch() - 86400)`).run() + seedSkill('frontend-grace', 'grace', 'tools') + seedSkill('frontend-lint', 'grace', 'tools') + + const profile = await handler(event()) + + expect(profile.skills.map(skill => `${skill.owner}/${skill.repo}/${skill.name}`)).toEqual([ + 'frontend/kit/beta', + 'frontend/kit/frontend-alpha', + 'grace/tools/frontend-grace', + 'grace/tools/frontend-lint', + 'acme/tools/classified', + 'acme/tools/frontend-lint', + ]) + expect(profile.skills[0]).toMatchObject({ authorName: 'Frontend Authors', registryPath: '/gh/frontend/kit/beta' }) + }) + + it('limits after excluding unresolved and broken matches, then hydrates the first 200 Skills', async () => { + harness.raw.prepare(`UPDATE repos SET stars = 10 WHERE owner = 'acme'`).run() + for (let index = 0; index < 205; index++) + seedSkill(`frontend-${String(index).padStart(3, '0')}`) + harness.raw.prepare(`INSERT INTO repos (owner, repo, stars) VALUES ('unresolved', 'tools', 1000)`).run() + seedSkill('frontend-excluded', 'unresolved', 'tools') + harness.raw.prepare(`UPDATE skills SET source_resolved = 0 WHERE owner = 'unresolved'`).run() + + const profile = await handler(event()) + + expect(profile.skills.map(skill => skill.name)).toEqual( + Array.from({ length: 200 }, (_, index) => `frontend-${String(index).padStart(3, '0')}`), + ) + expect(profile.skills[199]).toMatchObject({ registryPath: '/gh/acme/tools/frontend-199', stars: 10 }) + }) +}) + +function seedSkill(name: string, owner = 'acme', repo = 'tools') { harness.raw.prepare( `INSERT INTO skills (owner, repo, name, slug, display_name, source_resolved) - VALUES ('acme', 'tools', ?, ?, ?, 1)`, - ).run(name, `acme/tools/${name}`, name) + VALUES (?, ?, ?, ?, ?, 1)`, + ).run(owner, repo, name, `${owner}/${repo}/${name}`, name) } function advanceClock(seconds: number) {