Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/skills

src/migrate-to-repo.ts

148 lines · 3.7 KB · TypeScript
import { isDryRun } from './dry-run.ts'
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 version_id and points at the commit that
	 * holds that snapshot's content.
	 */
	revisions?: Array<{
		id: number
		commit_oid: string
		skill_id: string
		path: string
		replaced_at: string
		content_length: number
	}>
	/** When true, flip registry_meta.backend to `repo` after syncing the index. */
	switch_reads?: boolean
	/** Preview index/revision work without writing package storage. */
	dryRun?: boolean
}

export type MigrateToRepoResult = {
	repo: string
	backend_before: string | null
	backend_after: string | null
	skills_indexed: number
	revisions_upserted: number
	skill_ids: string[]
	legacy_pointer: string
	switched: boolean
	dryRun?: true
}

/**
 * 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')
	const dryRun = isDryRun(input.dryRun)
	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.`,
				)
			}
			if (dryRun) continue
			await upsertSkillsIndex({
				id,
				name: meta.name,
				description: meta.description,
				files: meta.files,
				created_at: meta.created_at,
				updated_at: meta.updated_at,
			})
		}
		return skillIds
	}, 'skills-index-sync')

	const liveIds = new Set(ids)

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

	let revisionsUpserted = 0
	for (const revision of input.revisions ?? []) {
		if (dryRun) {
			revisionsUpserted += 1
			continue
		}
		await insertSkillRevision({
			id: revision.id,
			commit_oid: revision.commit_oid,
			skill_id: revision.skill_id,
			path: revision.path,
			replaced_at: revision.replaced_at,
			content_length: revision.content_length,
		})
		revisionsUpserted += 1
	}

	if (!dryRun) {
		await setMeta(
			'legacy_store',
			'intact; durable home is plain-repo:skills. Legacy skills/skill_files/skill_versions tables were not deleted.',
		)
		await setMeta('durable_home', `plain-repo:${SKILLS_REPO_NAME}`)
		await setMeta('migrated_at', new Date().toISOString())
	}

	let switched = false
	if (input.switch_reads && !dryRun) {
		await setMeta('backend', 'repo')
		switched = true
	}

	const indexedAfter = dryRun ? ids : (await listSkillsIndex()).map((row) => row.id)
	const backendAfter = dryRun
		? backendBefore
		: await getMeta('backend')

	return {
		repo: SKILLS_REPO_NAME,
		backend_before: backendBefore,
		backend_after: backendAfter,
		skills_indexed: indexedAfter.length,
		revisions_upserted: revisionsUpserted,
		skill_ids: ids,
		legacy_pointer:
			'registry_meta.durable_home=plain-repo:skills; legacy SQLite tables left intact',
		switched,
		...(dryRun ? { dryRun: true as const } : {}),
	}
}