Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ coverage
playwright-report
test-results
.playwright
.agents
/skills
convex/_generated
skills-lock.json
.claude
15 changes: 15 additions & 0 deletions convex/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,21 @@ triggers.register("skills", async (ctx, change) => {
} else {
await syncSkillSearchDigestForSkill(ctx, change.newDoc);
}

// Maintain denormalized activeSkillCount on the publisher.
const wasActive = (doc: Doc<"skills"> | undefined) => doc && !doc.softDeletedAt;
const oldActive = change.operation !== "insert" && wasActive(change.oldDoc);
const newActive = change.operation !== "delete" && wasActive(change.newDoc);
if (oldActive !== newActive) {
const doc = change.operation === "delete" ? change.oldDoc : change.newDoc;
const publisher = await getOwnerPublisher(ctx, doc);
if (publisher) {
const delta = newActive ? 1 : -1;
await ctx.db.patch(publisher._id, {
activeSkillCount: Math.max(0, (publisher.activeSkillCount ?? 0) + delta),
});
}
}
});

triggers.register("packages", async (ctx, change) => {
Expand Down
40 changes: 40 additions & 0 deletions convex/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1868,3 +1868,43 @@ function clampInt(value: number, min: number, max: number) {
if (!Number.isFinite(rounded)) return min;
return Math.min(max, Math.max(min, rounded));
}

/** Backfill `activeSkillCount` on all publishers.
* Run: `bunx convex run internal.maintenance.backfillActiveSkillCounts` */
export const backfillActiveSkillCounts = internalMutation({
args: {},
handler: async (ctx) => {
const publishers = await ctx.db.query("publishers").collect();
let updated = 0;
for (const publisher of publishers) {
let count: number;
if (publisher.kind === "user" && publisher.linkedUserId) {
// Count skills owned by the linked user
const skills = await ctx.db
.query("skills")
.withIndex("by_owner", (q) => q.eq("ownerUserId", publisher.linkedUserId!))
.filter((q) => q.eq(q.field("softDeletedAt"), undefined))
.collect();
const publisherSkills = await ctx.db
.query("skills")
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", publisher._id))
.filter((q) => q.eq(q.field("softDeletedAt"), undefined))
.collect();
const ids = new Set([...skills.map((s) => s._id), ...publisherSkills.map((s) => s._id)]);
count = ids.size;
} else {
const skills = await ctx.db
.query("skills")
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", publisher._id))
.filter((q) => q.eq(q.field("softDeletedAt"), undefined))
.collect();
count = skills.length;
}
if (publisher.activeSkillCount !== count) {
await ctx.db.patch(publisher._id, { activeSkillCount: count });
updated++;
}
}
return { total: publishers.length, updated };
},
});
7 changes: 6 additions & 1 deletion convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const publishers = defineTable({
image: v.optional(v.string()),
linkedUserId: v.optional(v.id("users")),
trustedPublisher: v.optional(v.boolean()),
activeSkillCount: v.optional(v.number()),
deactivatedAt: v.optional(v.number()),
deletedAt: v.optional(v.number()),
createdAt: v.number(),
Expand Down Expand Up @@ -330,7 +331,11 @@ const skills = defineTable({
"isSuspicious",
"statsInstallsAllTime",
"updatedAt",
]);
])
.searchIndex("search_dashboard", {
searchField: "displayName",
filterFields: ["ownerUserId", "ownerPublisherId", "softDeletedAt"],
});

const skillSlugAliases = defineTable({
slug: v.string(),
Expand Down
275 changes: 275 additions & 0 deletions convex/skills.dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { describe, expect, it, vi } from "vitest";

vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));

import { countDashboard, searchDashboard } from "./skills";

type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};

const countHandler = (
countDashboard as unknown as WrappedHandler<
{ ownerUserId?: string; ownerPublisherId?: string },
number
>
)._handler;

const searchHandler = (
searchDashboard as unknown as WrappedHandler<
{ ownerUserId?: string; ownerPublisherId?: string; search: string; limit?: number },
Array<{ slug: string }>
>
)._handler;

function makeSkill(slug: string, overrides: Record<string, unknown> = {}) {
return {
_id: `skills:${slug}`,
_creationTime: 1,
slug,
displayName: slug.charAt(0).toUpperCase() + slug.slice(1),
summary: `${slug} integration.`,
ownerUserId: "users:owner",
ownerPublisherId: undefined,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: undefined,
tags: {},
badges: undefined,
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
moderationReason: undefined,
...overrides,
};
}

/** Build a mock ctx where `skills` queries return `allSkills`,
* and search-index queries return `searchHits` (defaults to allSkills). */
function makeCtx(
allSkills: ReturnType<typeof makeSkill>[],
searchHits?: ReturnType<typeof makeSkill>[],
) {
return {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return { _id: "users:owner", _creationTime: 1, handle: "owner", displayName: "Owner" };
}
if (id === "publishers:pub") {
return {
_id: "publishers:pub",
_creationTime: 1,
kind: "user",
handle: "owner",
displayName: "Owner",
linkedUserId: "users:owner",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(null),
})),
};
}
if (table === "skills") {
const makeFilterChain = (items: ReturnType<typeof makeSkill>[]) => ({
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue(items),
paginate: vi.fn().mockResolvedValue({
page: items,
isDone: true,
continueCursor: "",
}),
})),
collect: vi.fn().mockResolvedValue(items),
});
return {
withIndex: vi.fn(() => ({
filter: vi.fn(() => makeFilterChain(allSkills)),
...makeFilterChain(allSkills),
})),
withSearchIndex: vi.fn(() => ({
take: vi.fn().mockResolvedValue(searchHits ?? allSkills),
})),
};
}
if (table === "skillBadges") {
return {
withIndex: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
}

// ---------------------------------------------------------------------------
// countDashboard
// ---------------------------------------------------------------------------

describe("skills.countDashboard", () => {
function makeCountCtx(publisher: Record<string, unknown> | null, membership: unknown = null) {
return {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return { _id: "users:owner", _creationTime: 1, handle: "owner", displayName: "Owner" };
}
if (id === "publishers:pub") return publisher;
return null;
}),
query: vi.fn((table: string) => {
if (table === "publishers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(publisher),
})),
};
}
if (table === "publisherMembers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(membership),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
}

const personalPublisher = {
_id: "publishers:pub",
_creationTime: 1,
kind: "user",
handle: "owner",
displayName: "Owner",
linkedUserId: "users:owner",
activeSkillCount: 42,
createdAt: 1,
updatedAt: 1,
};

it("reads denormalized activeSkillCount for authenticated owner", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeCountCtx(personalPublisher);
const result = await countHandler(ctx as never, { ownerPublisherId: "publishers:pub" } as never);
expect(result).toBe(42);
});

it("returns 0 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockResolvedValue(null as never);
const ctx = makeCountCtx(personalPublisher);
const result = await countHandler(ctx as never, { ownerPublisherId: "publishers:pub" } as never);
expect(result).toBe(0);
});

it("returns 0 when caller is not the owner", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:other" as never);
const ctx = makeCountCtx(personalPublisher);
const result = await countHandler(ctx as never, { ownerPublisherId: "publishers:pub" } as never);
expect(result).toBe(0);
});

it("returns 0 when no owner specified", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeCountCtx(null);
const result = await countHandler(ctx as never, {});
expect(result).toBe(0);
});

it("defaults to 0 when activeSkillCount is not yet backfilled", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeCountCtx({ ...personalPublisher, activeSkillCount: undefined });
const result = await countHandler(ctx as never, { ownerPublisherId: "publishers:pub" } as never);
expect(result).toBe(0);
});
});

// ---------------------------------------------------------------------------
// searchDashboard
// ---------------------------------------------------------------------------

describe("skills.searchDashboard", () => {
const allSkills = [
makeSkill("slack", { displayName: "Slack", summary: "Slack messaging." }),
makeSkill("stripe", { displayName: "Stripe", summary: "Payment processing." }),
makeSkill("github", { displayName: "GitHub", summary: "Code hosting." }),
];

it("returns empty array when search is empty", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeCtx(allSkills);
const result = await searchHandler(ctx as never, {
ownerUserId: "users:owner",
search: "",
} as never);
expect(result).toEqual([]);
});

it("returns matched skills from search index", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const hits = [allSkills[0]]; // Slack
const ctx = makeCtx(allSkills, hits);
const result = await searchHandler(ctx as never, {
ownerUserId: "users:owner",
search: "Slack",
} as never);
expect(result).toEqual([expect.objectContaining({ slug: "slack" })]);
});

it("returns multiple search hits", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const hits = [allSkills[0], allSkills[2]]; // Slack, GitHub
const ctx = makeCtx(allSkills, hits);
const result = await searchHandler(ctx as never, {
ownerUserId: "users:owner",
search: "integration",
} as never);
expect(result).toHaveLength(2);
});

it("returns empty when no hits", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeCtx(allSkills, []);
const result = await searchHandler(ctx as never, {
ownerUserId: "users:owner",
search: "nonexistent",
} as never);
expect(result).toEqual([]);
});

it("returns empty when no owner specified", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeCtx(allSkills);
const result = await searchHandler(ctx as never, { search: "slack" } as never);
expect(result).toEqual([]);
});

it("works with ownerPublisherId", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const hits = [allSkills[1]]; // Stripe
const ctx = makeCtx(allSkills, hits);
const result = await searchHandler(ctx as never, {
ownerPublisherId: "publishers:pub",
search: "Stripe",
} as never);
expect(result).toEqual([expect.objectContaining({ slug: "stripe" })]);
});
});
Loading