Skip to content
← Public packages

@kentcdodds/skills

Store, retrieve, edit, version, and revert reusable agent skill documents via skillList then skillGet.

src/migrate-to-repo.ts

134 lines · 3.4 KB · TypeScript
import {
	deleteSkillsIndex,
	ensureSchema,
	getMeta,
	insertSkillRevision,
	listSkillsIndex,
	setMeta,
	upsertSkillsIndex,
} from './db.ts'
import {
	listSkillIdsFromRepo,
	withSkillsRepoSession,
	parseSkillMeta,
	readRepoFile,
	SKILLS_REPO_NAME,
	skillMetaPath,
} from './repo.ts'

export type MigrateToRepoInput = {
	/**
	 * Revision map produced while replaying legacy skill_versions as git commits.
	 * Each entry preserves the legacy versionId and points at the commit that
	 * holds that snapshot's content.
	 */
	revisions?: Array<{
		id: number
		commitOid: string
		skillId: string
		path: string
		replacedAt: string
		contentLength: number
	}>
	/** When true, flip registry_meta.backend to `repo` after syncing the index. */
	switchReads?: boolean
}

export type MigrateToRepoResult = {
	repo: string
	backendBefore: string | null
	backendAfter: string | null
	skillsIndexed: number
	revisionsUpserted: number
	skillIds: string[]
	legacyPointer: string
	switched: boolean
}

/**
 * After the plain `skills` repo has been populated (copy-first), sync the
 * packageStorage index/revision projections and optionally switch reads from
 * the legacy SQLite store to the repo. Never deletes legacy tables.
 */
export default async function migrateToRepo(
	input: MigrateToRepoInput = {},
): Promise<MigrateToRepoResult> {
	await ensureSchema()
	const backendBefore = await getMeta('backend')
	// Mint a fresh session per sync. A shared conversationId plus discard-after-use
	// lets concurrent repo.pushed handlers (or a later resume) hit a discarded
	// session. Session-branch refs are already ignored in on-repo-pushed.
	const ids = await withSkillsRepoSession(async (session) => {
		const skillIds = await listSkillIdsFromRepo(session.id)
		for (const id of skillIds) {
			const meta = parseSkillMeta(
				await readRepoFile(session.id, skillMetaPath(id)),
			)
			if (!meta) {
				throw new Error(
					`Missing or invalid ${skillMetaPath(id)} in skills repo.`,
				)
			}
			await upsertSkillsIndex({
				id,
				name: meta.name,
				description: meta.description,
				files: meta.files,
				createdAt: meta.createdAt,
				updatedAt: meta.updatedAt,
			})
		}
		return skillIds
	})

	const liveIds = new Set(ids)

	// Drop stale index rows left by interrupted deletes or bulk git edits.
	const indexed = await listSkillsIndex()
	for (const row of indexed) {
		if (!liveIds.has(row.id)) {
			await deleteSkillsIndex(row.id)
		}
	}

	let revisionsUpserted = 0
	for (const revision of input.revisions ?? []) {
		await insertSkillRevision({
			id: revision.id,
			commitOid: revision.commitOid,
			skillId: revision.skillId,
			path: revision.path,
			replacedAt: revision.replacedAt,
			contentLength: revision.contentLength,
		})
		revisionsUpserted += 1
	}

	await setMeta(
		'legacyStore',
		'intact; durable home is plain-repo:skills. Legacy skills/skill_files/skill_versions tables were not deleted.',
	)
	await setMeta('durableHome', `plain-repo:${SKILLS_REPO_NAME}`)
	await setMeta('migratedAt', new Date().toISOString())

	let switched = false
	if (input.switchReads) {
		await setMeta('backend', 'repo')
		switched = true
	}

	const indexedAfter = await listSkillsIndex()
	const backendAfter = await getMeta('backend')

	return {
		repo: SKILLS_REPO_NAME,
		backendBefore,
		backendAfter,
		skillsIndexed: indexedAfter.length,
		revisionsUpserted,
		skillIds: ids,
		legacyPointer:
			'registry_meta.durableHome=plain-repo:skills; legacy SQLite tables left intact',
		switched,
	}
}