From 0789ed5c4f8065e5f956728804548967742e04fa Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 5 Oct 2026 16:26:19 +1100 Subject: [PATCH 1/6] fix(identity): canonicalize starred watches through digest selection --- .../app/composables/useActionFailure.ts | 6 +- layers/identity/app/composables/useLikes.ts | 9 +- layers/identity/app/pages/me/index.vue | 5 + .../app/pages/onboarding/discover.vue | 4 +- .../identity/app/pages/onboarding/email.vue | 2 + .../server/api/me/subscriptions/index.post.ts | 10 +- layers/identity/server/utils/digest-select.ts | 10 +- layers/identity/server/utils/starred-repos.ts | 18 +-- layers/identity/server/utils/watch-actions.ts | 14 +-- layers/identity/server/utils/watches.ts | 67 ++++++++-- .../0135_repository_identity_nocase.sql | 2 + test/unit/digest-delivery.test.ts | 3 +- test/unit/digest-select.test.ts | 11 +- test/unit/email-form-submit.test.ts | 2 +- test/unit/like-button.test.ts | 24 ++++ test/unit/like-oauth-replay.test.ts | 6 +- test/unit/login-interactions.test.ts | 2 +- test/unit/me-watchlist.test.ts | 2 +- test/unit/starred-repository-identity.test.ts | 119 ++++++++++++++++++ 19 files changed, 264 insertions(+), 52 deletions(-) create mode 100644 migrations/0135_repository_identity_nocase.sql create mode 100644 test/unit/starred-repository-identity.test.ts diff --git a/layers/identity/app/composables/useActionFailure.ts b/layers/identity/app/composables/useActionFailure.ts index 84958bec..8ae846d7 100644 --- a/layers/identity/app/composables/useActionFailure.ts +++ b/layers/identity/app/composables/useActionFailure.ts @@ -5,10 +5,11 @@ // so callers keep their existing "did it work?" checks. export function useActionFailure() { const toast = useToast() - return (action: string) => (error: unknown): null => { + const failure = (action: string, scope = action) => (error: unknown): null => { const reason = error instanceof Error ? error.message : String(error) console.warn(`[action:${action}] ${reason}`) toast.add({ + id: `action-failure:${scope}`, title: `Could not ${action}`, description: 'Something went wrong. Your change was not saved, so try again.', color: 'error', @@ -16,4 +17,7 @@ export function useActionFailure() { }) return null } + return Object.assign(failure, { + clear: (action: string, scope = action) => toast.remove(`action-failure:${scope}`), + }) } diff --git a/layers/identity/app/composables/useLikes.ts b/layers/identity/app/composables/useLikes.ts index 42398299..586988e7 100644 --- a/layers/identity/app/composables/useLikes.ts +++ b/layers/identity/app/composables/useLikes.ts @@ -108,6 +108,7 @@ export function useLikes() { await ensureLoaded() const key = likeKey(ref) const wasLiked = !!liked.value[key] + fail.clear('save your like', `like:${key}`) const previousCount = likeCounts.value[key] liked.value = applyLiked(liked.value, key, !wasLiked) @@ -121,8 +122,8 @@ export function useLikes() { const path = `/api/me/likes/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}/${encodeURIComponent(ref.name)}` const res = wasLiked - ? await $fetch(path, { method: 'DELETE' }).catch(fail('remove your like')) - : await $fetch('/api/me/likes', { method: 'POST', body: ref }).catch(fail('save your like')) + ? await $fetch(path, { method: 'DELETE' }).catch(fail('remove your like', `like:${key}`)) + : await $fetch('/api/me/likes', { method: 'POST', body: ref }).catch(fail('save your like', `like:${key}`)) const { [key]: _dropped, ...restPending } = pending.value pending.value = restPending @@ -151,6 +152,8 @@ export function useLikes() { if (existing) return existing + fail.clear('save your like', `like:${key}`) + liked.value = applyLiked(liked.value, key, true) const previousCount = likeCounts.value[key] if (previousCount !== undefined) @@ -162,7 +165,7 @@ export function useLikes() { likeCounts.value = { ...likeCounts.value, [key]: response.likeCount } return true }) - .catch(fail('save your like')) + .catch(fail('save your like', `like:${key}`)) .then((result) => { if (!result) { liked.value = applyLiked(liked.value, key, false) diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue index c0f6a712..8a2adb31 100644 --- a/layers/identity/app/pages/me/index.vue +++ b/layers/identity/app/pages/me/index.vue @@ -53,6 +53,7 @@ const removeSubscriptionMutation = useNuxtMutation actionFailed.clear('stop watching that repo'), }) const saveEmailMutation = useNuxtMutation({ @@ -62,6 +63,7 @@ const saveEmailMutation = useNuxtMutation actionFailed.clear('save your email settings'), }) const savePrivacyMutation = useNuxtMutation({ @@ -71,6 +73,7 @@ const savePrivacyMutation = useNuxtMutation actionFailed.clear('save your privacy settings'), }) async function setLikesPublic(likesPublic: boolean) { await savePrivacyMutation.mutateSafe({ likes_public: likesPublic }) @@ -87,6 +90,7 @@ const removeLikeMutation = useNuxtMutation({ return result }, onError: actionFailed('remove that skill'), + onMutate: () => actionFailed.clear('remove that skill'), }) const removingSkill = ref() async function unlike(skill: LikedSkill) { @@ -104,6 +108,7 @@ const syncMutation = useNuxtMutation({ return result }, onError: actionFailed('sync your starred repos'), + onMutate: () => actionFailed.clear('sync your starred repos'), }) async function sync() { await syncMutation.mutateSafe() diff --git a/layers/identity/app/pages/onboarding/discover.vue b/layers/identity/app/pages/onboarding/discover.vue index 7830c15c..c463c6f4 100644 --- a/layers/identity/app/pages/onboarding/discover.vue +++ b/layers/identity/app/pages/onboarding/discover.vue @@ -86,6 +86,7 @@ const submitting = ref(false) async function watchSelected() { if (submitting.value || syncing.value) return + actionFailed.clear('start watching those repos') if (!selected.value.size) { await navigateTo('/onboarding/email') return @@ -137,7 +138,8 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' }) Page {{ syncProgress.page || '…' }} · {{ syncProgress.total }} repos cached · {{ syncProgress.matched }} match the registry - Last GitHub import {{ new Date(data.syncedAt * 1000).toLocaleString() }} + Last GitHub import + UTC diff --git a/layers/identity/app/pages/onboarding/email.vue b/layers/identity/app/pages/onboarding/email.vue index a60f2021..7cf7e770 100644 --- a/layers/identity/app/pages/onboarding/email.vue +++ b/layers/identity/app/pages/onboarding/email.vue @@ -22,12 +22,14 @@ const saveEmailMutation = useNuxtMutation rpc.execute(identityAccountQueries.saveEmail(), body), invalidates: ['identity:me'], onError: actionFailed('save your digest email'), + onMutate: () => actionFailed.clear('save your digest email'), }) const finishOnboardingMutation = useNuxtMutation({ mutation: () => rpc.execute(identityAccountQueries.finishOnboarding()), invalidates: ['identity:me'], onError: actionFailed('finish setting up your account'), + onMutate: () => actionFailed.clear('finish setting up your account'), }) // Opting in needs somewhere to send the digest, so the address is required diff --git a/layers/identity/server/api/me/subscriptions/index.post.ts b/layers/identity/server/api/me/subscriptions/index.post.ts index d6689401..babd0f6a 100644 --- a/layers/identity/server/api/me/subscriptions/index.post.ts +++ b/layers/identity/server/api/me/subscriptions/index.post.ts @@ -2,6 +2,7 @@ import { defineApiHandler } from '#shared/server/handler' import { authenticated } from '../../../policies/authenticated' import { SubscriptionsCreateInput } from '../../../schemas/subscriptions' import { requireUserRow } from '../../../utils/users' +import { createRepositoryWatches } from '../../../utils/watches' export default defineApiHandler({ schema: SubscriptionsCreateInput, @@ -10,12 +11,7 @@ export default defineApiHandler({ const u = await requireUserRow(event) if (!body.repos.length) return { ok: true as const, inserted: 0 } - const now = Math.floor(Date.now() / 1000) - const stmts = body.repos.map(r => platform.db.prepare( - `INSERT OR IGNORE INTO skill_subscriptions (user_id, owner, repo, source, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)`, - ).bind(u.id, r.owner, r.repo, body.source, now)) - await platform.db.batch(stmts) - return { ok: true as const, inserted: stmts.length } + const inserted = await createRepositoryWatches(platform.db, u.id, body.repos, body.source) + return { ok: true as const, inserted } }, }) diff --git a/layers/identity/server/utils/digest-select.ts b/layers/identity/server/utils/digest-select.ts index e5f0a898..21674832 100644 --- a/layers/identity/server/utils/digest-select.ts +++ b/layers/identity/server/utils/digest-select.ts @@ -71,7 +71,15 @@ export async function selectDigestForUser( FROM activity a JOIN skills s ON s.owner = a.owner AND s.repo = a.repo AND s.name = a.name JOIN repos r ON r.owner = s.owner AND r.repo = s.repo - JOIN skill_subscriptions sub ON sub.user_id = ?1 AND sub.owner = s.owner AND sub.repo = s.repo + JOIN ( + SELECT owner COLLATE NOCASE AS owner, repo COLLATE NOCASE AS repo, source, muted_until + FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY owner COLLATE NOCASE, repo COLLATE NOCASE + ORDER BY source = 'like', created_at, source, owner, repo + ) AS watch_rank FROM skill_subscriptions WHERE user_id = ?1 + ) WHERE watch_rank = 1 + ) sub ON sub.owner = s.owner AND sub.repo = s.repo WHERE a.id > ?2 AND a.id <= ?3 AND (sub.muted_until IS NULL OR sub.muted_until <= ?4) AND r.repo_kind != 'aggregator' diff --git a/layers/identity/server/utils/starred-repos.ts b/layers/identity/server/utils/starred-repos.ts index 50b0f62d..87a881b0 100644 --- a/layers/identity/server/utils/starred-repos.ts +++ b/layers/identity/server/utils/starred-repos.ts @@ -20,14 +20,16 @@ export interface StarredRow { export async function loadStarredRows(db: D1Database, userId: number): Promise { const res = await db.prepare( - `SELECT s.owner, s.repo, s.starred_at, s.has_skill, - CASE WHEN sub.user_id IS NULL THEN 0 ELSE 1 END as watching, + `SELECT COALESCE(r.owner, s.owner) AS owner, COALESCE(r.repo, s.repo) AS repo, s.starred_at, + CASE WHEN sk.name IS NULL THEN 0 ELSE 1 END AS has_skill, + EXISTS (SELECT 1 FROM skill_subscriptions sub WHERE sub.user_id = s.user_id + AND sub.owner = s.owner COLLATE NOCASE AND sub.repo = s.repo COLLATE NOCASE) AS watching, sk.name as skill_name, sk.display_name as skill_display, sk.slug as skill_slug FROM user_starred_repos s - LEFT JOIN skills sk ON sk.owner = s.owner AND sk.repo = s.repo - LEFT JOIN skill_subscriptions sub ON sub.user_id = s.user_id AND sub.owner = s.owner AND sub.repo = s.repo + LEFT JOIN repos r ON r.owner = s.owner COLLATE NOCASE AND r.repo = s.repo COLLATE NOCASE + LEFT JOIN skills sk ON sk.owner = r.owner AND sk.repo = r.repo WHERE s.user_id = ?1 - ORDER BY s.has_skill DESC, s.starred_at DESC, sk.name + ORDER BY has_skill DESC, s.starred_at DESC, sk.name LIMIT 1000`, ).bind(userId).all() return res.results ?? [] @@ -99,9 +101,11 @@ export async function importStarredPage(input: { const starredAt = Math.floor(new Date(it.starred_at).getTime() / 1000) return db.prepare( `INSERT OR REPLACE INTO user_starred_repos (user_id, owner, repo, starred_at, has_skill) - VALUES (?1, ?2, ?3, ?4, + VALUES (?1, + COALESCE((SELECT owner FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?2), + COALESCE((SELECT repo FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?3), ?4, (SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END - FROM skills WHERE owner = ?2 AND repo = ?3))`, + FROM skills WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE))`, ).bind(userId, owner, repo, starredAt) }) const chunk = 50 diff --git a/layers/identity/server/utils/watch-actions.ts b/layers/identity/server/utils/watch-actions.ts index d9dac12a..a53756d4 100644 --- a/layers/identity/server/utils/watch-actions.ts +++ b/layers/identity/server/utils/watch-actions.ts @@ -1,5 +1,6 @@ import type { H3Event } from 'h3' import { likeSkill } from './likes' +import { createRepositoryWatches } from './watches' // Post-OAuth side effects driven by ?action= query string. Anonymous "Like X" // and "Watch X" clicks bounce through OAuth with an action token; the callback @@ -44,7 +45,6 @@ export async function handleWatchAction( returnTo: string, ): Promise { const ctx = parseReturnTo(returnTo) - const now = Math.floor(Date.now() / 1000) const d = db(event) // likeSkill derives the repo subscription itself, so there is no separate @@ -55,10 +55,7 @@ export async function handleWatchAction( } if (action === 'watch-skill' && ctx.skillOwner && ctx.skillRepo) { - await d.prepare( - `INSERT OR IGNORE INTO skill_subscriptions (user_id, owner, repo, source, created_at) - VALUES (?1, ?2, ?3, 'manual', ?4)`, - ).bind(userId, ctx.skillOwner, ctx.skillRepo, now).run() + await createRepositoryWatches(d, userId, [{ owner: ctx.skillOwner, repo: ctx.skillRepo }], 'manual') return } @@ -71,11 +68,6 @@ export async function handleWatchAction( WHERE u.login = ?1 AND c.slug = ?2 AND c.deleted_at IS NULL`, ).bind(ctx.collectionLogin, ctx.collectionSlug).all<{ owner: string, repo: string }>() const source = `collection:${ctx.collectionSlug}` - const stmts = (skills.results ?? []).map(r => d.prepare( - `INSERT OR IGNORE INTO skill_subscriptions (user_id, owner, repo, source, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)`, - ).bind(userId, r.owner, r.repo, source, now)) - if (stmts.length) - await d.batch(stmts) + await createRepositoryWatches(d, userId, skills.results ?? [], source) } } diff --git a/layers/identity/server/utils/watches.ts b/layers/identity/server/utils/watches.ts index 05f3317d..abbe227d 100644 --- a/layers/identity/server/utils/watches.ts +++ b/layers/identity/server/utils/watches.ts @@ -22,9 +22,16 @@ export interface WatchRow { /** Every watch of one person, newest first. */ export async function loadWatches(db: D1Database, userId: number): Promise { const res = await db.prepare( - `SELECT owner, repo, source, muted_until, created_at - FROM skill_subscriptions - WHERE user_id = ?1 + `SELECT COALESCE(r.owner, s.owner) AS owner, COALESCE(r.repo, s.repo) AS repo, + s.source, s.muted_until, s.created_at + FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY owner COLLATE NOCASE, repo COLLATE NOCASE + ORDER BY source = 'like', created_at, source, owner, repo + ) AS watch_rank FROM skill_subscriptions WHERE user_id = ?1 + ) s + LEFT JOIN repos r ON r.owner = s.owner COLLATE NOCASE AND r.repo = s.repo COLLATE NOCASE + WHERE s.watch_rank = 1 ORDER BY created_at DESC`, ).bind(userId).all() return res.results ?? [] @@ -33,7 +40,7 @@ export async function loadWatches(db: D1Database, userId: number): Promise { const row = await db.prepare( - `SELECT 1 AS found FROM repos WHERE owner = ?1 AND repo = ?2`, + `SELECT 1 AS found FROM repos WHERE owner = ?1 COLLATE NOCASE AND repo = ?2 COLLATE NOCASE`, ).bind(ref.owner, ref.repo).first<{ found: number }>() return row !== null } @@ -46,17 +53,55 @@ export async function repositoryExists(db: D1Database, ref: RepositoryRef): Prom * deliberate choice and keeps its reason. */ export async function watchRepository(db: D1Database, userId: number, ref: RepositoryRef): Promise { - await db.prepare( - `INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at) - VALUES (?1, ?2, ?3, 'manual', ?4) - ON CONFLICT (user_id, owner, repo) DO UPDATE SET source = 'manual' - WHERE skill_subscriptions.source = 'like'`, - ).bind(userId, ref.owner, ref.repo, Math.floor(Date.now() / 1000)).run() + await createRepositoryWatches(db, userId, [ref], 'manual') +} + +/** Resolve GitHub spelling once, then persist the registry's frozen identity. */ +export async function createRepositoryWatches( + db: D1Database, + userId: number, + refs: readonly RepositoryRef[], + source: string, +): Promise { + if (!refs.length) + return 0 + const matches = await db.batch(refs.map(ref => db.prepare( + `SELECT owner, repo FROM repos WHERE owner = ?1 COLLATE NOCASE AND repo = ?2 COLLATE NOCASE`, + ).bind(ref.owner, ref.repo))) + const canonicalRefs = matches.map((match) => { + const canonical = match.results?.[0] + if (!canonical) + throw createError({ statusCode: 404, message: 'Repository not found' }) + return canonical + }) + const now = Math.floor(Date.now() / 1000) + const result = await db.batch(canonicalRefs.flatMap(canonical => [ + db.prepare( + `INSERT INTO skill_subscriptions (user_id, owner, repo, source, muted_until, created_at) + SELECT ?1, ?2, ?3, + COALESCE((SELECT source FROM skill_subscriptions WHERE user_id = ?1 + AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE AND source != 'like' + ORDER BY created_at, source LIMIT 1), ?4), + (SELECT muted_until FROM skill_subscriptions WHERE user_id = ?1 + AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE + ORDER BY source = 'like', created_at, source, owner, repo LIMIT 1), + COALESCE((SELECT MIN(created_at) FROM skill_subscriptions WHERE user_id = ?1 + AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?5) + ON CONFLICT(user_id, owner, repo) DO UPDATE SET + source = excluded.source, muted_until = excluded.muted_until, created_at = excluded.created_at`, + ).bind(userId, canonical.owner, canonical.repo, source, now), + db.prepare( + `DELETE FROM skill_subscriptions WHERE user_id = ?1 + AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE + AND (owner != ?2 OR repo != ?3)`, + ).bind(userId, canonical.owner, canonical.repo), + ])) + return result.reduce((inserted, row, index) => inserted + (index % 2 === 0 ? row.meta?.changes ?? 0 : 0), 0) } /** Remove a watch, whatever its source. Likes stay. */ export async function unwatchRepository(db: D1Database, userId: number, ref: RepositoryRef): Promise { await db.prepare( - `DELETE FROM skill_subscriptions WHERE user_id = ?1 AND owner = ?2 AND repo = ?3`, + `DELETE FROM skill_subscriptions WHERE user_id = ?1 AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE`, ).bind(userId, ref.owner, ref.repo).run() } diff --git a/migrations/0135_repository_identity_nocase.sql b/migrations/0135_repository_identity_nocase.sql new file mode 100644 index 00000000..120ea682 --- /dev/null +++ b/migrations/0135_repository_identity_nocase.sql @@ -0,0 +1,2 @@ +-- Resolve GitHub spelling without changing frozen registry identities. +CREATE INDEX idx_repos_identity_nocase ON repos(owner COLLATE NOCASE, repo COLLATE NOCASE); diff --git a/test/unit/digest-delivery.test.ts b/test/unit/digest-delivery.test.ts index 3ec6201b..06bcc30c 100644 --- a/test/unit/digest-delivery.test.ts +++ b/test/unit/digest-delivery.test.ts @@ -637,7 +637,8 @@ const TEST_SCHEMA = ` owner TEXT NOT NULL, repo TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'manual', - muted_until INTEGER + muted_until INTEGER, + created_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE skill_likes ( user_id INTEGER NOT NULL, diff --git a/test/unit/digest-select.test.ts b/test/unit/digest-select.test.ts index f47fa757..99a895dd 100644 --- a/test/unit/digest-select.test.ts +++ b/test/unit/digest-select.test.ts @@ -37,7 +37,8 @@ describe('digest selection', () => { owner TEXT, repo TEXT, source TEXT NOT NULL DEFAULT 'manual', - muted_until INTEGER + muted_until INTEGER, + created_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE skill_likes ( user_id INTEGER NOT NULL, @@ -59,7 +60,7 @@ describe('digest selection', () => { INSERT INTO skills VALUES ('nuxt', 'nuxt', 'nuxt', 'Nuxt framework', 'blob-new', 'skills/nuxt/SKILL.md'); INSERT INTO repos VALUES ('nuxt', 'nuxt', 'source', 'main'); - INSERT INTO skill_subscriptions VALUES (1, 'nuxt', 'nuxt', 'manual', NULL); + INSERT INTO skill_subscriptions (user_id, owner, repo, source, muted_until) VALUES (1, 'nuxt', 'nuxt', 'manual', NULL); INSERT INTO activity VALUES (1, 'nuxt', 'nuxt', 'nuxt', 500, 1500, 'blob-old'); INSERT INTO activity VALUES (2, 'nuxt', 'nuxt', 'nuxt', 1500, 1600, 'blob-new'); INSERT INTO skill_revisions VALUES ('nuxt', 'nuxt', 'nuxt', 'commit-old', 500, 'old change'); @@ -130,7 +131,7 @@ describe('digest selection', () => { INSERT INTO skills VALUES ('nuxt', 'ui', 'design-tokens', 'Tokens', 'a', 'design-tokens/SKILL.md'); INSERT INTO skills VALUES ('nuxt', 'ui', 'motion', 'Motion', 'b', 'motion/SKILL.md'); INSERT INTO repos VALUES ('nuxt', 'ui', 'source', 'main'); - INSERT INTO skill_subscriptions VALUES (2, 'nuxt', 'ui', 'like', NULL); + INSERT INTO skill_subscriptions (user_id, owner, repo, source, muted_until) VALUES (2, 'nuxt', 'ui', 'like', NULL); INSERT INTO skill_likes VALUES (2, 'nuxt', 'ui', 'design-tokens', 1); INSERT INTO activity VALUES (10, 'nuxt', 'ui', 'design-tokens', 1500, 1600, 'a'); INSERT INTO activity VALUES (11, 'nuxt', 'ui', 'motion', 1500, 1600, 'b'); @@ -156,7 +157,7 @@ describe('digest selection', () => { INSERT INTO skills VALUES ('nuxt', 'ui', 'design-tokens', 'Tokens', 'a', 'design-tokens/SKILL.md'); INSERT INTO skills VALUES ('nuxt', 'ui', 'motion', 'Motion', 'b', 'motion/SKILL.md'); INSERT INTO repos VALUES ('nuxt', 'ui', 'source', 'main'); - INSERT INTO skill_subscriptions VALUES (3, 'nuxt', 'ui', 'manual', NULL); + INSERT INTO skill_subscriptions (user_id, owner, repo, source, muted_until) VALUES (3, 'nuxt', 'ui', 'manual', NULL); INSERT INTO skill_likes VALUES (3, 'nuxt', 'ui', 'design-tokens', 1); INSERT INTO activity VALUES (10, 'nuxt', 'ui', 'design-tokens', 1500, 1600, 'a'); INSERT INTO activity VALUES (11, 'nuxt', 'ui', 'motion', 1500, 1600, 'b'); @@ -179,7 +180,7 @@ describe('digest selection', () => { INSERT INTO skills VALUES ('nuxt', 'ui', 'design-tokens', 'Tokens', 'a', 'design-tokens/SKILL.md'); INSERT INTO skills VALUES ('nuxt', 'ui', 'motion', 'Motion', 'b', 'motion/SKILL.md'); INSERT INTO repos VALUES ('nuxt', 'ui', 'source', 'main'); - INSERT INTO skill_subscriptions VALUES (4, 'nuxt', 'ui', 'like', NULL); + INSERT INTO skill_subscriptions (user_id, owner, repo, source, muted_until) VALUES (4, 'nuxt', 'ui', 'like', NULL); INSERT INTO skill_likes VALUES (4, 'nuxt', 'ui', 'design-tokens', 1); INSERT INTO activity VALUES (11, 'nuxt', 'ui', 'motion', 1500, 1600, 'b'); `) diff --git a/test/unit/email-form-submit.test.ts b/test/unit/email-form-submit.test.ts index 499d6e79..302e7b0b 100644 --- a/test/unit/email-form-submit.test.ts +++ b/test/unit/email-form-submit.test.ts @@ -30,7 +30,7 @@ mockNuxtImport('useFetch', () => () => ({ data: ref({ items: [] }), status: ref( mockNuxtImport('useNuxtRpc', () => () => ({ execute: mocks.execute, query: mocks.query })) mockNuxtImport('useAuth', () => () => ({ fetchSession: mocks.session })) mockNuxtImport('navigateTo', () => mocks.navigate) -mockNuxtImport('useActionFailure', () => () => () => vi.fn()) +mockNuxtImport('useActionFailure', () => () => Object.assign(() => vi.fn(), { clear: vi.fn() })) mockNuxtImport('useNuxtMutation', () => (options: { mutation: (body?: unknown) => Promise }) => ({ pending: ref(false), mutateSafe: async (body?: unknown) => ({ _tag: 'ok', data: await options.mutation(body) }), diff --git a/test/unit/like-button.test.ts b/test/unit/like-button.test.ts index dee4f4a6..d840c3cf 100644 --- a/test/unit/like-button.test.ts +++ b/test/unit/like-button.test.ts @@ -9,6 +9,15 @@ const loggedIn = ref(true) /** False while the browser is still loading the session after hydration. */ const sessionKnown = ref(true) const fetchMock = vi.hoisted(() => vi.fn()) +const activeToasts = vi.hoisted(() => new Map()) +mockNuxtImport('useToast', () => () => ({ + add: (input: { id?: string, title: string }) => { + const id = input.id ?? `toast-${activeToasts.size}` + activeToasts.set(id, input) + return { id } + }, + remove: (id: string) => activeToasts.delete(id), +})) mockNuxtImport('useAuth', () => () => ({ state: computed(() => { @@ -61,6 +70,7 @@ function resetLikeState() { } beforeEach(() => { + activeToasts.clear() likedItems = [] mutation = { _tag: 'ok', likeCount: 0 } listGate = null @@ -176,6 +186,20 @@ describe('likeButton toggle semantics', () => { }) describe('useLikes hydration', () => { + it('clears the failed like after retry without dismissing another action', async () => { + const likes = useLikes() + mutation = { _tag: 'error' } + await likes.toggle(skill) + expect([...activeToasts.values()].map(toast => toast.title)).toContain('Could not save your like') + activeToasts.set('email', { title: 'Could not save your email settings' }) + + mutation = { _tag: 'ok', likeCount: 1 } + await likes.toggle(skill) + + expect(likes.isLiked(skill)).toBe(true) + expect([...activeToasts.values()].map(toast => toast.title)).toEqual(['Could not save your email settings']) + }) + it('shares one in-flight request across every mounted heart', async () => { // A `loaded` boolean alone let the second LikeButton on a page return before // the data arrived and snapshot an empty like set, which double-counted the diff --git a/test/unit/like-oauth-replay.test.ts b/test/unit/like-oauth-replay.test.ts index b65a36a2..ead3bc47 100644 --- a/test/unit/like-oauth-replay.test.ts +++ b/test/unit/like-oauth-replay.test.ts @@ -38,6 +38,8 @@ describe('post-OAuth like replay', () => { CREATE TABLE collection_skills_v2 (collection_id INTEGER, owner TEXT, repo TEXT, name TEXT); CREATE TABLE collections_v2 (id INTEGER PRIMARY KEY, slug TEXT, author_user_id INTEGER, deleted_at INTEGER); CREATE TABLE users (id INTEGER PRIMARY KEY, login TEXT); + CREATE TABLE repos (owner TEXT, repo TEXT, PRIMARY KEY (owner, repo)); + INSERT INTO repos VALUES ('nuxt', 'ui'); `) handleWatchAction = (await import('../../layers/identity/server/utils/watch-actions')).handleWatchAction @@ -96,7 +98,9 @@ function wrapSqlite(sqlite: Database.Database) { bind(...params: unknown[]) { const prepared = expandNumberedPlaceholders(sql, params) return { - _run: () => sqlite.prepare(prepared.sql).run(...prepared.params), + _run: () => /^\s*SELECT/i.test(prepared.sql) + ? { results: sqlite.prepare(prepared.sql).all(...prepared.params) } + : { meta: { changes: sqlite.prepare(prepared.sql).run(...prepared.params).changes } }, async run() { return sqlite.prepare(prepared.sql).run(...prepared.params) }, diff --git a/test/unit/login-interactions.test.ts b/test/unit/login-interactions.test.ts index 9f5949d5..edf50159 100644 --- a/test/unit/login-interactions.test.ts +++ b/test/unit/login-interactions.test.ts @@ -12,7 +12,7 @@ mockNuxtImport('$fetch', () => mocks.fetch) mockNuxtImport('navigateTo', () => mocks.navigate) mockNuxtImport('useToast', () => () => ({ add: mocks.toast })) mockNuxtImport('removeNuxtQueries', () => mocks.remove) -mockNuxtImport('useActionFailure', () => () => () => mocks.failure) +mockNuxtImport('useActionFailure', () => () => Object.assign(() => mocks.failure, { clear: vi.fn() })) mockNuxtImport('useUserSession', () => () => ({ ready: ref(true), loggedIn: ref(true), diff --git a/test/unit/me-watchlist.test.ts b/test/unit/me-watchlist.test.ts index 2caf8adf..76137c67 100644 --- a/test/unit/me-watchlist.test.ts +++ b/test/unit/me-watchlist.test.ts @@ -134,7 +134,7 @@ mockNuxtImport('useNuxtRpc', () => { }) mockNuxtImport('useActionFailure', () => { - return () => () => vi.fn() + return () => Object.assign(() => vi.fn(), { clear: vi.fn() }) }) async function mountPage() { diff --git a/test/unit/starred-repository-identity.test.ts b/test/unit/starred-repository-identity.test.ts new file mode 100644 index 00000000..9f16e9a9 --- /dev/null +++ b/test/unit/starred-repository-identity.test.ts @@ -0,0 +1,119 @@ +import type { EventHandler, H3Event } from 'h3' +import type { SqliteD1 } from './helpers/d1-sqlite' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { selectDigestForUser } from '../../layers/identity/server/utils/digest-select' +import { importStarredPage, loadStarredRows } from '../../layers/identity/server/utils/starred-repos' +import { createRepositoryWatches, loadWatches } from '../../layers/identity/server/utils/watches' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' +import { SIGNED_IN_HEADERS } from './helpers/session' + +describe('starred repository identity', () => { + let d1: SqliteD1 + let subscribe: EventHandler + + beforeEach(async () => { + d1 = createSqliteD1(allMigrations()) + d1.raw.exec(` + INSERT OR REPLACE INTO users (id, github_id, login, created_at, last_login_at, onboarded_at) VALUES (1, 11, 'test-user', 1, 1, 1); + INSERT INTO repos (owner, repo, repo_kind, default_branch) VALUES ('leonxlnx', 'taste-skill', 'catalog', 'main'); + INSERT INTO skills (owner, repo, name, slug, display_name, current_sha, rendered_skill_path) + VALUES ('leonxlnx', 'taste-skill', 'brandkit', 'leonxlnx/taste-skill/brandkit', 'Brandkit', 'blob', 'skills/brandkit/SKILL.md'); + INSERT INTO activity (id, owner, repo, name, type, occurred_at, ingested_at, sha) VALUES (1, 'leonxlnx', 'taste-skill', 'brandkit', 'skill_updated', 10, 10, 'blob'); + `) + vi.stubGlobal('defineEventHandler', (handler: EventHandler) => handler) + vi.stubGlobal('createError', (input: Record) => Object.assign(new Error(String(input.message)), input)) + vi.stubGlobal('getUserSession', async () => ({ user: { id: 1, login: 'test-user' } })) + subscribe = (await import('../../layers/identity/server/api/me/subscriptions/index.post')).default + }) + afterEach(() => { + d1.close() + vi.unstubAllGlobals() + }) + + async function importRepository(owner: string, repo: string) { + return importStarredPage({ + db: d1.db, + userId: 1, + githubToken: 'fixture', + page: 1, + now: () => 20, + fetch: async () => Response.json([{ starred_at: '2026-10-05T00:00:00Z', repo: { name: repo, owner: { login: owner } } }]), + }) + } + + it('imports a differently cased GitHub repository using registry spelling', async () => { + expect(await importRepository('Leonxlnx', 'Taste-Skill')).toMatchObject({ _tag: 'Imported', matched: 1 }) + expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'leonxlnx', repo: 'taste-skill', has_skill: 1, skill_name: 'brandkit' }]) + }) + + it('preserves deliberately mixed-case frozen registry identity', async () => { + d1.raw.exec(`INSERT INTO repos (owner, repo) VALUES ('MixedOwner', 'Mixed-Skills'); + INSERT INTO skills (owner, repo, name, slug, display_name) VALUES ('MixedOwner', 'Mixed-Skills', 'motion', 'mixed/motion', 'Motion');`) + await importRepository('mixedowner', 'mixed-skills') + expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'MixedOwner', repo: 'Mixed-Skills', has_skill: 1 }]) + }) + + it('finds current Skills in an old false import and recognizes a raw-case watch', async () => { + d1.raw.exec(`INSERT INTO user_starred_repos VALUES (1, 'Leonxlnx', 'Taste-Skill', 10, 0); + INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at) VALUES (1, 'LEONXLNX', 'TASTE-SKILL', 'manual', 10);`) + expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'leonxlnx', repo: 'taste-skill', has_skill: 1, watching: 1, skill_name: 'brandkit' }]) + }) + + it('creates one canonical watch, preserves its deliberate source and selects digest changes', async () => { + d1.raw.exec(`INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at) VALUES (1, 'LEONXLNX', 'TASTE-SKILL', 'manual', 10);`) + vi.stubGlobal('readBody', async () => ({ source: 'star-import', repos: [{ owner: 'Leonxlnx', repo: 'Taste-Skill' }] })) + await subscribe({ method: 'POST', context: { platform: { db: d1.db } }, node: { req: { headers: SIGNED_IN_HEADERS } } } as unknown as H3Event) + expect(d1.raw.prepare('SELECT owner, repo, source FROM skill_subscriptions').all()).toEqual([{ owner: 'leonxlnx', repo: 'taste-skill', source: 'manual' }]) + const selection = await selectDigestForUser(d1.db, { id: 1, login: 'test-user', digest_email: 'fixture@example.com', email: null, email_opt_in: 1, onboarded_at: 1 }, 20, { cursorStart: 0, cursorEnd: 1 }) + expect(selection?.entries).toMatchObject([{ owner: 'leonxlnx', repo: 'taste-skill', skillNames: ['brandkit'] }]) + }) + + it('stores new star-import watches canonically for digest selection', async () => { + vi.stubGlobal('readBody', async () => ({ source: 'star-import', repos: [{ owner: 'Leonxlnx', repo: 'Taste-Skill' }] })) + await subscribe({ method: 'POST', context: { platform: { db: d1.db } }, node: { req: { headers: SIGNED_IN_HEADERS } } } as unknown as H3Event) + expect(d1.raw.prepare('SELECT owner, repo, source FROM skill_subscriptions').all()).toEqual([{ owner: 'leonxlnx', repo: 'taste-skill', source: 'star-import' }]) + const selection = await selectDigestForUser(d1.db, { id: 1, login: 'test-user', digest_email: 'fixture@example.com', email: null, email_opt_in: 1, onboarded_at: 1 }, 20, { cursorStart: 0, cursorEnd: 1 }) + expect(selection?.entries[0]?.skillNames).toEqual(['brandkit']) + }) + + it('includes old mixed-case watches once when canonical duplicates coexist', async () => { + d1.raw.exec(`INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at) VALUES + (1, 'Leonxlnx', 'Taste-Skill', 'manual', 10), + (1, 'leonxlnx', 'taste-skill', 'like', 11);`) + const selection = await selectDigestForUser(d1.db, { id: 1, login: 'test-user', digest_email: 'fixture@example.com', email: null, email_opt_in: 1, onboarded_at: 1 }, 20, { cursorStart: 0, cursorEnd: 1 }) + expect(selection?.entries).toMatchObject([{ owner: 'leonxlnx', repo: 'taste-skill', changeCount: 1, skillNames: ['brandkit'] }]) + }) + + it('keeps an unknown repository unmatched', async () => { + expect(await importRepository('Unknown', 'unknown-skills')).toMatchObject({ matched: 0 }) + expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'Unknown', repo: 'unknown-skills', has_skill: 0, skill_name: null }]) + }) + + it('lists old and canonical watch duplicates as one canonical deliberate watch', async () => { + d1.raw.exec(`INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at) VALUES + (1, 'Leonxlnx', 'Taste-Skill', 'manual', 10), + (1, 'leonxlnx', 'taste-skill', 'like', 11);`) + expect(await loadWatches(d1.db, 1)).toMatchObject([{ owner: 'leonxlnx', repo: 'taste-skill', source: 'manual' }]) + await createRepositoryWatches(d1.db, 1, [{ owner: 'LEONXLNX', repo: 'TASTE-SKILL' }], 'star-import') + expect(d1.raw.prepare('SELECT owner, repo, source FROM skill_subscriptions').all()).toEqual([{ owner: 'leonxlnx', repo: 'taste-skill', source: 'manual' }]) + }) + + it('rejects an unknown watch before inserting any valid watch in the batch', async () => { + await expect(createRepositoryWatches(d1.db, 1, [ + { owner: 'Leonxlnx', repo: 'Taste-Skill' }, + { owner: 'Unknown', repo: 'unknown-skills' }, + ], 'star-import')).rejects.toMatchObject({ statusCode: 404 }) + expect(await loadWatches(d1.db, 1)).toEqual([]) + }) + + it('preserves a deliberate watch mute when replacing its like duplicate', async () => { + d1.raw.exec(`INSERT INTO skill_subscriptions (user_id, owner, repo, source, muted_until, created_at) VALUES + (1, 'Leonxlnx', 'Taste-Skill', 'manual', 999, 10), + (1, 'leonxlnx', 'taste-skill', 'like', NULL, 11); + INSERT INTO skill_likes (user_id, owner, repo, name, created_at) VALUES (1, 'leonxlnx', 'taste-skill', 'brandkit', 10);`) + const user = { id: 1, login: 'test-user', digest_email: 'fixture@example.com', email: null, email_opt_in: 1, onboarded_at: 1 } + expect((await selectDigestForUser(d1.db, user, 20, { cursorStart: 0, cursorEnd: 1 }))?.entries).toEqual([]) + await createRepositoryWatches(d1.db, 1, [{ owner: 'LEONXLNX', repo: 'TASTE-SKILL' }], 'star-import') + expect(await loadWatches(d1.db, 1)).toMatchObject([{ source: 'manual', muted_until: 999, created_at: 10 }]) + }) +}) From bf4c7a95d348926abc60eaf7ec9855c64d9513cf Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 5 Oct 2026 16:40:15 +1100 Subject: [PATCH 2/6] fix(ui): preserve fresh errors during rapid retries --- .../app/composables/useActionFailure.ts | 19 ++++-- test/unit/action-failure-toast.test.ts | 63 +++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 test/unit/action-failure-toast.test.ts diff --git a/layers/identity/app/composables/useActionFailure.ts b/layers/identity/app/composables/useActionFailure.ts index 8ae846d7..d9cbc144 100644 --- a/layers/identity/app/composables/useActionFailure.ts +++ b/layers/identity/app/composables/useActionFailure.ts @@ -5,19 +5,28 @@ // so callers keep their existing "did it work?" checks. export function useActionFailure() { const toast = useToast() + const failureIds = useState>('action-failure-toasts', () => ({})) + const clear = (action: string, scope = action) => { + const id = failureIds.value[scope] + if (!id) + return + const { [scope]: _removed, ...remaining } = failureIds.value + failureIds.value = remaining + toast.remove(id) + } const failure = (action: string, scope = action) => (error: unknown): null => { const reason = error instanceof Error ? error.message : String(error) console.warn(`[action:${action}] ${reason}`) - toast.add({ - id: `action-failure:${scope}`, + clear(action, scope) + // Nuxt UI defers removal, so each failure needs a fresh generated ID. + const added = toast.add({ title: `Could not ${action}`, description: 'Something went wrong. Your change was not saved, so try again.', color: 'error', icon: 'i-lucide-alert-circle', }) + failureIds.value = { ...failureIds.value, [scope]: added.id } return null } - return Object.assign(failure, { - clear: (action: string, scope = action) => toast.remove(`action-failure:${scope}`), - }) + return Object.assign(failure, { clear }) } diff --git a/test/unit/action-failure-toast.test.ts b/test/unit/action-failure-toast.test.ts new file mode 100644 index 00000000..7d0d6078 --- /dev/null +++ b/test/unit/action-failure-toast.test.ts @@ -0,0 +1,63 @@ +import { useToast } from '@nuxt/ui/composables/useToast' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { flushPromises } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { useActionFailure } from '../../layers/identity/app/composables/useActionFailure' + +describe('action failure toast recovery', () => { + let toast: ReturnType + let first: ReturnType + let second: ReturnType + + beforeEach(async () => { + await mountSuspended(defineComponent({ + setup() { + toast = useToast() + toast.clear() + first = useActionFailure() + second = useActionFailure() + return () => h('div') + }, + })) + vi.useFakeTimers() + }) + afterEach(() => vi.useRealTimers()) + + async function settleRemoval() { + await flushPromises() + await vi.advanceTimersByTimeAsync(250) + } + + it('keeps a fast first failure after clearing an absent prior error', async () => { + first.clear('save your like', 'first-fast-like') + first('save your like', 'first-fast-like')(new Error('offline')) + await settleRemoval() + expect(toast.toasts.value.filter(item => item.open).map(item => item.title)).toEqual(['Could not save your like']) + }) + + it('keeps the latest rapid retry failure across composable instances', async () => { + first('save your like', 'retry-like')(new Error('offline')) + await flushPromises() + second.clear('save your like', 'retry-like') + second('save your like', 'retry-like')(new Error('still offline')) + await flushPromises() + first.clear('save your like', 'retry-like') + first('save your like', 'retry-like')(new Error('offline again')) + await settleRemoval() + expect(toast.toasts.value.filter(item => item.open).map(item => item.title)).toEqual(['Could not save your like']) + }) + + it('clears successful recovery without dismissing another action or Skill', async () => { + first('save your like', 'recovered-like')(new Error('offline')) + first('save your like', 'other-like')(new Error('offline')) + first('save your email settings')(new Error('offline')) + await flushPromises() + second.clear('save your like', 'recovered-like') + await settleRemoval() + expect(toast.toasts.value.filter(item => item.open).map(item => item.title)).toEqual([ + 'Could not save your like', + 'Could not save your email settings', + ]) + }) +}) From 6a9820eaf8688b8b96c8ab62d41406ca95cd2cb3 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 5 Oct 2026 16:40:53 +1100 Subject: [PATCH 3/6] test(ui): verify retained toast instances after recovery --- test/unit/action-failure-toast.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/action-failure-toast.test.ts b/test/unit/action-failure-toast.test.ts index 7d0d6078..f4c7444a 100644 --- a/test/unit/action-failure-toast.test.ts +++ b/test/unit/action-failure-toast.test.ts @@ -53,8 +53,11 @@ describe('action failure toast recovery', () => { first('save your like', 'other-like')(new Error('offline')) first('save your email settings')(new Error('offline')) await flushPromises() + const [recoveredId, otherSkillId, emailId] = toast.toasts.value.map(item => item.id) second.clear('save your like', 'recovered-like') await settleRemoval() + expect(toast.toasts.value.map(item => item.id)).toEqual([otherSkillId, emailId]) + expect(toast.toasts.value.map(item => item.id)).not.toContain(recoveredId) expect(toast.toasts.value.filter(item => item.open).map(item => item.title)).toEqual([ 'Could not save your like', 'Could not save your email settings', From e7a3a3f1b341482fd892f36a24d94ae48cecf798 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 5 Oct 2026 16:41:26 +1100 Subject: [PATCH 4/6] style(test): sort toast regression imports --- test/unit/action-failure-toast.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/action-failure-toast.test.ts b/test/unit/action-failure-toast.test.ts index f4c7444a..4267f421 100644 --- a/test/unit/action-failure-toast.test.ts +++ b/test/unit/action-failure-toast.test.ts @@ -1,5 +1,5 @@ -import { useToast } from '@nuxt/ui/composables/useToast' import { mountSuspended } from '@nuxt/test-utils/runtime' +import { useToast } from '@nuxt/ui/composables/useToast' import { flushPromises } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h } from 'vue' From 36727ada22d33f2640bf4c783ba05e9e0ff8f81d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 5 Oct 2026 16:42:30 +1100 Subject: [PATCH 5/6] fix(ui): accept toast identifiers from Nuxt UI --- layers/identity/app/composables/useActionFailure.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/layers/identity/app/composables/useActionFailure.ts b/layers/identity/app/composables/useActionFailure.ts index d9cbc144..92fde6d9 100644 --- a/layers/identity/app/composables/useActionFailure.ts +++ b/layers/identity/app/composables/useActionFailure.ts @@ -5,10 +5,10 @@ // so callers keep their existing "did it work?" checks. export function useActionFailure() { const toast = useToast() - const failureIds = useState>('action-failure-toasts', () => ({})) + const failureIds = useState>('action-failure-toasts', () => ({})) const clear = (action: string, scope = action) => { const id = failureIds.value[scope] - if (!id) + if (id === undefined) return const { [scope]: _removed, ...remaining } = failureIds.value failureIds.value = remaining From 0f389eebf681b77957f21cb7cf2890e730a0fb3b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 5 Oct 2026 17:21:17 +1100 Subject: [PATCH 6/6] fix(db): number repository identity index after merged migrations --- ...ry_identity_nocase.sql => 0137_repository_identity_nocase.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{0135_repository_identity_nocase.sql => 0137_repository_identity_nocase.sql} (100%) diff --git a/migrations/0135_repository_identity_nocase.sql b/migrations/0137_repository_identity_nocase.sql similarity index 100% rename from migrations/0135_repository_identity_nocase.sql rename to migrations/0137_repository_identity_nocase.sql