mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 17:02:11 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04471c626 | ||
|
|
6cc90e48ea | ||
|
|
dcd9e8ff77 |
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
import { toPublicSkill } from "./public";
|
||||
import { toPublicSkill, toPublicUser } from "./public";
|
||||
|
||||
function makeSkill(overrides: Partial<Doc<"skills">> = {}): Doc<"skills"> {
|
||||
return {
|
||||
@@ -43,6 +43,25 @@ function makeSkill(overrides: Partial<Doc<"skills">> = {}): Doc<"skills"> {
|
||||
} as Doc<"skills">;
|
||||
}
|
||||
|
||||
describe("public user mapping", () => {
|
||||
it("normalizes public handles to lowercase", () => {
|
||||
const user = {
|
||||
_id: "users:1",
|
||||
_creationTime: 1,
|
||||
handle: "JaredforReal",
|
||||
name: "JaredforReal",
|
||||
displayName: "Jared Wen",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
} as Doc<"users">;
|
||||
|
||||
expect(toPublicUser(user)).toMatchObject({
|
||||
handle: "jaredforreal",
|
||||
name: "JaredforReal",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("public skill mapping", () => {
|
||||
it("normalizes stats when legacy skill record is missing stats object", () => {
|
||||
const legacySkill = makeSkill({
|
||||
|
||||
@@ -80,12 +80,17 @@ export type PublicSoul = Pick<
|
||||
| "updatedAt"
|
||||
>;
|
||||
|
||||
function normalizePublicHandle(handle: string | undefined | null) {
|
||||
const normalized = handle?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function toPublicUser(user: Doc<"users"> | null | undefined): PublicUser | null {
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return {
|
||||
_id: user._id,
|
||||
_creationTime: user._creationTime,
|
||||
handle: user.handle,
|
||||
handle: normalizePublicHandle(user.handle),
|
||||
name: user.name,
|
||||
displayName: user.displayName,
|
||||
image: user.image,
|
||||
|
||||
@@ -81,7 +81,7 @@ export function buildDiscordPayload(
|
||||
}
|
||||
|
||||
export function buildSkillUrl(skill: WebhookSkillPayload, siteUrl: string) {
|
||||
const owner = skill.ownerHandle?.trim();
|
||||
const owner = skill.ownerHandle?.trim().toLowerCase();
|
||||
if (owner) return `${siteUrl}/${owner}/${skill.slug}`;
|
||||
return `${siteUrl}/skills/${skill.slug}`;
|
||||
}
|
||||
|
||||
+1
-6
@@ -844,12 +844,7 @@ const packageCapabilitySearchDigest = defineTable({
|
||||
"executesCode",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_family_tag_updated", [
|
||||
"softDeletedAt",
|
||||
"family",
|
||||
"capabilityTag",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_family_tag_updated", ["softDeletedAt", "family", "capabilityTag", "updatedAt"])
|
||||
.index("by_active_family_tag_executes_updated", [
|
||||
"softDeletedAt",
|
||||
"family",
|
||||
|
||||
+17
-9
@@ -2,9 +2,9 @@ import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { getPage, type IndexKey, paginator } from "convex-helpers/server/pagination";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v, type Value } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import {
|
||||
action,
|
||||
internalAction,
|
||||
@@ -453,7 +453,7 @@ function buildConflictingSkillUrl(
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id);
|
||||
const ownerParam = owner.handle?.trim().toLowerCase() || String(owner._id);
|
||||
if (!ownerParam) return null;
|
||||
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`;
|
||||
}
|
||||
@@ -2055,7 +2055,8 @@ export const list = query({
|
||||
)
|
||||
.unique());
|
||||
const isOwnDashboard = Boolean(
|
||||
membership || (userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
|
||||
membership ||
|
||||
(userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
|
||||
);
|
||||
const scopedEntries = await ctx.db
|
||||
.query("skills")
|
||||
@@ -2775,12 +2776,13 @@ function decodeSkillCatalogCursor(raw: string | null | undefined): SkillCatalogC
|
||||
return { cursor: raw, offset: 0, pageSize: null, done: false };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw.slice(SKILL_CATALOG_CURSOR_PREFIX.length)) as Partial<SkillCatalogCursorState>;
|
||||
const parsed = JSON.parse(
|
||||
raw.slice(SKILL_CATALOG_CURSOR_PREFIX.length),
|
||||
) as Partial<SkillCatalogCursorState>;
|
||||
return {
|
||||
cursor: typeof parsed.cursor === "string" ? parsed.cursor : null,
|
||||
offset: typeof parsed.offset === "number" && parsed.offset > 0 ? parsed.offset : 0,
|
||||
pageSize:
|
||||
typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null,
|
||||
pageSize: typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null,
|
||||
done: parsed.done === true,
|
||||
};
|
||||
} catch {
|
||||
@@ -2864,7 +2866,9 @@ function scoreSkillCatalogResult(digest: Doc<"skillSearchDigest">, queryText: st
|
||||
|
||||
export const listPackageCatalogPage = query({
|
||||
args: {
|
||||
channel: v.optional(v.union(v.literal("official"), v.literal("community"), v.literal("private"))),
|
||||
channel: v.optional(
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
@@ -2894,7 +2898,9 @@ export const listPackageCatalogPage = query({
|
||||
loops += 1;
|
||||
const effectivePageSize = Math.min(
|
||||
remainingScanBudget,
|
||||
offset > 0 && pageSize ? Math.max(pageSize, offset + 1) : Math.max(targetCount * 3, targetCount),
|
||||
offset > 0 && pageSize
|
||||
? Math.max(pageSize, offset + 1)
|
||||
: Math.max(targetCount * 3, targetCount),
|
||||
);
|
||||
if (effectivePageSize <= 0) break;
|
||||
remainingScanBudget -= effectivePageSize;
|
||||
@@ -2948,7 +2954,9 @@ export const searchPackageCatalogPublic = query({
|
||||
args: {
|
||||
query: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
channel: v.optional(v.union(v.literal("official"), v.literal("community"), v.literal("private"))),
|
||||
channel: v.optional(
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
|
||||
+558
-4
@@ -17,7 +17,9 @@ const { requireUser } = await import("./lib/access");
|
||||
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { insertStatEvent } = await import("./skillStatEvents");
|
||||
const {
|
||||
backfillCanonicalHandlesInternal,
|
||||
ensureHandler,
|
||||
getByHandle,
|
||||
list,
|
||||
searchInternal,
|
||||
banUserInternal,
|
||||
@@ -32,6 +34,14 @@ type WrappedHandler<TArgs, TResult> = {
|
||||
};
|
||||
|
||||
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
|
||||
const getByHandleHandler = (getByHandle as unknown as WrappedHandler<{ handle: string }, unknown>)
|
||||
._handler;
|
||||
const backfillCanonicalHandlesInternalHandler = (
|
||||
backfillCanonicalHandlesInternal as unknown as WrappedHandler<
|
||||
{ cursor?: string; batchSize?: number; dryRun?: boolean },
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn();
|
||||
@@ -132,6 +142,200 @@ function makeListCtx(users: Array<Record<string, unknown>>) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeHandleBackfillCtx(
|
||||
seedUsers: Array<Record<string, unknown>>,
|
||||
seedPublishers: Array<Record<string, unknown>> = [],
|
||||
) {
|
||||
const userRows = new Map(seedUsers.map((row) => [String(row._id), { ...row }]));
|
||||
const publisherRows = new Map(seedPublishers.map((row) => [String(row._id), { ...row }]));
|
||||
const publisherMembers: Array<Record<string, unknown>> = [];
|
||||
|
||||
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
||||
if (userRows.has(id)) {
|
||||
userRows.set(id, { ...userRows.get(id), ...value });
|
||||
return;
|
||||
}
|
||||
if (publisherRows.has(id)) {
|
||||
publisherRows.set(id, { ...publisherRows.get(id), ...value });
|
||||
return;
|
||||
}
|
||||
const member = publisherMembers.find((entry) => entry._id === id);
|
||||
if (member) Object.assign(member, value);
|
||||
});
|
||||
|
||||
const get = vi.fn(async (id: string) => {
|
||||
const key = String(id);
|
||||
return userRows.get(key) ?? publisherRows.get(key) ?? null;
|
||||
});
|
||||
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
if (table === "publishers") {
|
||||
const handle = typeof value.handle === "string" ? value.handle : "user";
|
||||
const id = `publishers:${handle}`;
|
||||
publisherRows.set(id, { _id: id, _creationTime: 1, ...value });
|
||||
return id;
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
const id = `publisherMembers:${publisherMembers.length + 1}`;
|
||||
publisherMembers.push({ _id: id, ...value });
|
||||
return id;
|
||||
}
|
||||
if (table === "auditLogs") return "auditLogs:1";
|
||||
throw new Error(`Unexpected insert table ${table}`);
|
||||
});
|
||||
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle_active_updatedAt") {
|
||||
throw new Error(`Unexpected reservedHandles index ${name}`);
|
||||
}
|
||||
return { order: () => ({ take: vi.fn(async () => []) }) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
return [...userRows.values()].find((row) => row.handle === handle) ?? null;
|
||||
}),
|
||||
};
|
||||
},
|
||||
order: (direction: string) => {
|
||||
if (direction !== "asc") throw new Error(`Unexpected users order ${direction}`);
|
||||
return {
|
||||
paginate: vi.fn(
|
||||
async ({ cursor, numItems }: { cursor?: string | null; numItems: number }) => {
|
||||
const rows = [...userRows.values()].sort(
|
||||
(a, b) => Number(a._creationTime ?? 0) - Number(b._creationTime ?? 0),
|
||||
);
|
||||
const start = cursor ? Number.parseInt(cursor, 10) : 0;
|
||||
const page = rows.slice(start, start + numItems);
|
||||
const nextOffset = start + page.length;
|
||||
return {
|
||||
page,
|
||||
isDone: nextOffset >= rows.length,
|
||||
continueCursor: nextOffset >= rows.length ? null : String(nextOffset),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
if (name === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
return [...publisherRows.values()].find((row) => row.handle === handle) ?? null;
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
return (
|
||||
[...publisherRows.values()].find((row) => row.linkedUserId === linkedUserId) ??
|
||||
null
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
let publisherId = "";
|
||||
let userId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "publisherId") publisherId = value;
|
||||
if (field === "userId") userId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
return (
|
||||
publisherMembers.find(
|
||||
(row) => row.publisherId === publisherId && row.userId === userId,
|
||||
) ?? null
|
||||
);
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_owner_publisher") {
|
||||
throw new Error(`Unexpected ${table} index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
|
||||
return {
|
||||
ctx: { db: { get, insert, patch, query, normalizeId: vi.fn() } } as never,
|
||||
userRows,
|
||||
publisherRows,
|
||||
publisherMembers,
|
||||
patch,
|
||||
insert,
|
||||
get,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBanCtx() {
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
@@ -204,6 +408,28 @@ describe("ensureHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a mixed-case handle to lowercase", async () => {
|
||||
const { ctx, patch } = makeCtx();
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:case",
|
||||
user: {
|
||||
_creationTime: 1,
|
||||
handle: "JaredforReal",
|
||||
displayName: "Jared Wen",
|
||||
name: "JaredforReal",
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await ensureHandler(ctx);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith("users:case", {
|
||||
handle: "jaredforreal",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override a custom display name when syncing handle", async () => {
|
||||
const { ctx, patch } = makeCtx();
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
@@ -303,7 +529,6 @@ describe("ensureHandler", () => {
|
||||
await ensureHandler(ctx);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith("users:admin", {
|
||||
displayName: "steipete",
|
||||
role: "admin",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
@@ -335,6 +560,32 @@ describe("ensureHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves GitHub login casing for derived display names", async () => {
|
||||
const { ctx, patch } = makeCtx();
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:github",
|
||||
user: {
|
||||
_creationTime: 1,
|
||||
handle: undefined,
|
||||
displayName: undefined,
|
||||
name: "JohnDoe",
|
||||
email: undefined,
|
||||
role: undefined,
|
||||
createdAt: undefined,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await ensureHandler(ctx);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith("users:github", {
|
||||
handle: "johndoe",
|
||||
displayName: "JohnDoe",
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-claim a reserved handle for another user", async () => {
|
||||
const { ctx, patch, query } = makeCtx();
|
||||
query.mockImplementation(((table: string) => {
|
||||
@@ -377,7 +628,10 @@ describe("ensureHandler", () => {
|
||||
|
||||
await ensureHandler(ctx);
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(patch).toHaveBeenCalledWith("users:other", {
|
||||
displayName: "openclaw",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-claim a handle already owned by an org publisher", async () => {
|
||||
@@ -395,7 +649,10 @@ describe("ensureHandler", () => {
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
@@ -501,6 +758,68 @@ describe("me", () => {
|
||||
});
|
||||
|
||||
describe("users.syncGitHubProfileInternal", () => {
|
||||
it("normalizes a mixed-case stored handle to lowercase", async () => {
|
||||
const { ctx, get, patch } = makeCtx();
|
||||
get.mockResolvedValue({
|
||||
_id: "users:other",
|
||||
handle: "JaredforReal",
|
||||
displayName: "Jared Wen",
|
||||
name: "JaredforReal",
|
||||
});
|
||||
|
||||
const handler = (
|
||||
syncGitHubProfileInternal as unknown as {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<void>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
await handler(ctx, {
|
||||
userId: "users:other",
|
||||
name: "JaredforReal",
|
||||
syncedAt: 10,
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"users:other",
|
||||
expect.objectContaining({
|
||||
githubProfileSyncedAt: 10,
|
||||
handle: "jaredforreal",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves GitHub login casing for derived display names", async () => {
|
||||
const { ctx, get, patch } = makeCtx();
|
||||
get.mockResolvedValue({
|
||||
_id: "users:other",
|
||||
handle: "old-handle",
|
||||
displayName: "old-handle",
|
||||
name: "old-handle",
|
||||
});
|
||||
|
||||
const handler = (
|
||||
syncGitHubProfileInternal as unknown as {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<void>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
await handler(ctx, {
|
||||
userId: "users:other",
|
||||
name: "JohnDoe",
|
||||
syncedAt: 10,
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"users:other",
|
||||
expect.objectContaining({
|
||||
githubProfileSyncedAt: 10,
|
||||
name: "JohnDoe",
|
||||
handle: "johndoe",
|
||||
displayName: "JohnDoe",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a derived handle unchanged when the new login is reserved", async () => {
|
||||
const { ctx, get, patch, query } = makeCtx();
|
||||
get.mockResolvedValue({
|
||||
@@ -580,7 +899,10 @@ describe("users.syncGitHubProfileInternal", () => {
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
@@ -673,6 +995,238 @@ describe("users.syncGitHubProfileInternal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.getByHandle", () => {
|
||||
it("resolves lowercase lookups through the linked publisher handle", async () => {
|
||||
const user = {
|
||||
_id: "users:1",
|
||||
_creationTime: 1,
|
||||
handle: "JaredforReal",
|
||||
name: "JaredforReal",
|
||||
displayName: "Jared Wen",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
};
|
||||
|
||||
const get = vi.fn(async (id: string) => (id === "users:1" ? user : null));
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (
|
||||
_name: string,
|
||||
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder(q);
|
||||
return {
|
||||
unique: vi.fn(async () => (handle === "JaredforReal" ? user : null)),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (
|
||||
_name: string,
|
||||
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder(q);
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
handle === "jaredforreal"
|
||||
? {
|
||||
_id: "publishers:jaredforreal",
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:1",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
|
||||
const result = (await getByHandleHandler(
|
||||
{ db: { get, query } },
|
||||
{ handle: "jaredforreal" },
|
||||
)) as { handle: string | null } | null;
|
||||
|
||||
expect(result).toMatchObject({ handle: "jaredforreal" });
|
||||
});
|
||||
|
||||
it("falls back to a bounded user scan when no publisher row exists", async () => {
|
||||
const user = {
|
||||
_id: "users:1",
|
||||
_creationTime: 1,
|
||||
handle: "JaredforReal",
|
||||
name: "JaredforReal",
|
||||
displayName: "Jared Wen",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
};
|
||||
|
||||
const get = vi.fn(async () => null);
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (
|
||||
_name: string,
|
||||
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder(q);
|
||||
return {
|
||||
unique: vi.fn(async () => (handle === "JaredforReal" ? user : null)),
|
||||
};
|
||||
},
|
||||
order: (direction: string) => {
|
||||
if (direction !== "asc") throw new Error(`Unexpected users order ${direction}`);
|
||||
return {
|
||||
paginate: vi.fn(async () => ({
|
||||
page: [user],
|
||||
isDone: true,
|
||||
continueCursor: null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: () => ({ unique: vi.fn(async () => null) }),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
|
||||
const result = (await getByHandleHandler(
|
||||
{ db: { get, query } },
|
||||
{ handle: "jaredforreal" },
|
||||
)) as { handle: string | null } | null;
|
||||
|
||||
expect(result).toMatchObject({ handle: "jaredforreal" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.backfillCanonicalHandlesInternal", () => {
|
||||
it("normalizes mixed-case handles and syncs personal publishers", async () => {
|
||||
const { ctx, publisherRows, userRows } = makeHandleBackfillCtx(
|
||||
[
|
||||
{
|
||||
_id: "users:1",
|
||||
_creationTime: 1,
|
||||
handle: "JaredforReal",
|
||||
name: "JaredforReal",
|
||||
displayName: "Jared Wen",
|
||||
personalPublisherId: "publishers:jaredforreal",
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
_id: "publishers:jaredforreal",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "JaredforReal",
|
||||
displayName: "Jared Wen",
|
||||
linkedUserId: "users:1",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = (await backfillCanonicalHandlesInternalHandler(ctx, {
|
||||
batchSize: 10,
|
||||
})) as {
|
||||
scanned: number;
|
||||
normalizedUsers: number;
|
||||
syncedPublishers: number;
|
||||
skippedUsers: number;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
expect(result).toEqual({
|
||||
scanned: 1,
|
||||
normalizedUsers: 1,
|
||||
syncedPublishers: 1,
|
||||
skippedUsers: 0,
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
dryRun: false,
|
||||
ok: true,
|
||||
});
|
||||
expect(userRows.get("users:1")).toMatchObject({
|
||||
handle: "jaredforreal",
|
||||
personalPublisherId: "publishers:jaredforreal",
|
||||
});
|
||||
expect(publisherRows.get("publishers:jaredforreal")).toMatchObject({
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:1",
|
||||
kind: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports dry-run without writing changes", async () => {
|
||||
const { ctx, patch, insert, publisherRows, userRows } = makeHandleBackfillCtx([
|
||||
{
|
||||
_id: "users:1",
|
||||
_creationTime: 1,
|
||||
handle: "JaredforReal",
|
||||
name: "JaredforReal",
|
||||
displayName: "Jared Wen",
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = (await backfillCanonicalHandlesInternalHandler(ctx, {
|
||||
batchSize: 10,
|
||||
dryRun: true,
|
||||
})) as {
|
||||
normalizedUsers: number;
|
||||
syncedPublishers: number;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
expect(result).toMatchObject({
|
||||
normalizedUsers: 1,
|
||||
syncedPublishers: 1,
|
||||
dryRun: true,
|
||||
});
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(userRows.get("users:1")).toMatchObject({ handle: "JaredforReal" });
|
||||
expect(publisherRows.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.list", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireUser).mockReset();
|
||||
|
||||
+240
-36
@@ -1,13 +1,13 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
isHandleReservedForAnotherUser,
|
||||
@@ -22,6 +22,8 @@ const ADMIN_HANDLE = "steipete";
|
||||
const MAX_USER_LIST_LIMIT = 200;
|
||||
const MAX_USER_SEARCH_SCAN = 5_000;
|
||||
const MIN_USER_SEARCH_SCAN = 500;
|
||||
const DEFAULT_HANDLE_BACKFILL_BATCH_SIZE = 100;
|
||||
const MAX_HANDLE_BACKFILL_BATCH_SIZE = 500;
|
||||
|
||||
export const getById = query({
|
||||
args: { userId: v.id("users") },
|
||||
@@ -33,15 +35,77 @@ export const getByIdInternal = internalQuery({
|
||||
handler: async (ctx, args) => ctx.db.get(args.userId),
|
||||
});
|
||||
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedHandle = normalizeReservedHandle(args.handle);
|
||||
if (!normalizedHandle) return null;
|
||||
return await ctx.db
|
||||
async function scanUsersByNormalizedHandle(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
normalizedHandle: string,
|
||||
) {
|
||||
let cursor: string | null = null;
|
||||
let scanned = 0;
|
||||
|
||||
while (scanned < MAX_USER_SEARCH_SCAN) {
|
||||
const pageSize = Math.min(500, MAX_USER_SEARCH_SCAN - scanned);
|
||||
const result = await ctx.db
|
||||
.query("users")
|
||||
.order("asc")
|
||||
.paginate({ cursor, numItems: pageSize });
|
||||
|
||||
scanned += result.page.length;
|
||||
const match = result.page.find(
|
||||
(user) =>
|
||||
!user.deletedAt &&
|
||||
!user.deactivatedAt &&
|
||||
normalizeReservedHandle(user.handle) === normalizedHandle,
|
||||
);
|
||||
if (match) return match;
|
||||
if (result.isDone || !result.continueCursor) return null;
|
||||
cursor = result.continueCursor;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getUserByHandleCaseAware(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
const trimmedHandle = handle?.trim();
|
||||
const normalizedHandle = normalizeReservedHandle(handle);
|
||||
if (!trimmedHandle || !normalizedHandle) return null;
|
||||
|
||||
const exactMatch = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", trimmedHandle))
|
||||
.unique();
|
||||
if (exactMatch && !exactMatch.deletedAt && !exactMatch.deactivatedAt) return exactMatch;
|
||||
|
||||
if (trimmedHandle !== normalizedHandle) {
|
||||
const normalizedMatch = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
if (normalizedMatch && !normalizedMatch.deletedAt && !normalizedMatch.deactivatedAt) {
|
||||
return normalizedMatch;
|
||||
}
|
||||
}
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, normalizedHandle);
|
||||
if (publisher?.kind === "user" && publisher.linkedUserId) {
|
||||
const linkedUser = await ctx.db.get(publisher.linkedUserId);
|
||||
if (linkedUser && !linkedUser.deletedAt && !linkedUser.deactivatedAt) {
|
||||
return linkedUser;
|
||||
}
|
||||
}
|
||||
|
||||
// Migration bridge: older users may still have mixed-case handles without a
|
||||
// personal publisher row yet. Fall back to a bounded scan so canonicalized
|
||||
// lowercase profile URLs continue to resolve until the backfill finishes.
|
||||
return await scanUsersByNormalizedHandle(ctx, normalizedHandle);
|
||||
}
|
||||
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return await getUserByHandleCaseAware(ctx, args.handle);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,7 +161,19 @@ export const syncGitHubProfileInternal = internalMutation({
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return;
|
||||
const canClaimNewHandle = await canUserClaimHandle(ctx, args.name, args.userId);
|
||||
|
||||
const rawUserHandle = user.handle?.trim();
|
||||
const rawNewLogin = normalizeText(args.name);
|
||||
const canonicalUserHandle = normalizeReservedHandle(user.handle);
|
||||
const canonicalOldLogin = normalizeReservedHandle(user.name);
|
||||
const canonicalNewLogin = normalizeReservedHandle(args.name);
|
||||
const canClaimNewHandle = canonicalNewLogin
|
||||
? await canUserClaimHandle(ctx, canonicalNewLogin, args.userId)
|
||||
: false;
|
||||
const canNormalizeExistingHandle =
|
||||
rawUserHandle && canonicalUserHandle && rawUserHandle !== canonicalUserHandle
|
||||
? await canUserClaimHandle(ctx, canonicalUserHandle, args.userId)
|
||||
: false;
|
||||
|
||||
const updates: Partial<Doc<"users">> = { githubProfileSyncedAt: args.syncedAt };
|
||||
let didChangeProfile = false;
|
||||
@@ -107,19 +183,34 @@ export const syncGitHubProfileInternal = internalMutation({
|
||||
didChangeProfile = true;
|
||||
}
|
||||
|
||||
// Update handle if it was derived from the old username
|
||||
if (user.handle === user.name && user.name !== args.name && canClaimNewHandle) {
|
||||
updates.handle = args.name;
|
||||
if (canNormalizeExistingHandle && canonicalUserHandle) {
|
||||
updates.handle = canonicalUserHandle;
|
||||
didChangeProfile = true;
|
||||
}
|
||||
|
||||
// Update displayName if it was derived from the old username
|
||||
// Update handle if it was derived from the old username.
|
||||
if (
|
||||
(user.displayName === user.name || user.displayName === user.handle) &&
|
||||
user.name !== args.name &&
|
||||
canonicalUserHandle &&
|
||||
canonicalOldLogin &&
|
||||
canonicalNewLogin &&
|
||||
canonicalUserHandle === canonicalOldLogin &&
|
||||
canonicalOldLogin !== canonicalNewLogin &&
|
||||
canClaimNewHandle
|
||||
) {
|
||||
updates.displayName = args.name;
|
||||
updates.handle = canonicalNewLogin;
|
||||
didChangeProfile = true;
|
||||
}
|
||||
|
||||
// Update displayName if it was derived from the old username and the login actually changed.
|
||||
if (
|
||||
rawNewLogin &&
|
||||
canonicalOldLogin &&
|
||||
canonicalNewLogin &&
|
||||
canonicalOldLogin !== canonicalNewLogin &&
|
||||
(user.displayName === user.name || user.displayName === user.handle) &&
|
||||
canClaimNewHandle
|
||||
) {
|
||||
updates.displayName = rawNewLogin;
|
||||
didChangeProfile = true;
|
||||
}
|
||||
|
||||
@@ -185,8 +276,8 @@ export const ensure = mutation({
|
||||
handler: ensureHandler,
|
||||
});
|
||||
|
||||
function normalizeHandle(handle: string | undefined) {
|
||||
const normalized = handle?.trim();
|
||||
function normalizeText(value: string | undefined | null) {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
@@ -231,11 +322,29 @@ async function canUserClaimHandle(
|
||||
return publisher.kind === "user" && publisher.linkedUserId === userId;
|
||||
}
|
||||
|
||||
async function needsPersonalPublisherSync(
|
||||
ctx: MutationCtx,
|
||||
user: Doc<"users">,
|
||||
canonicalHandle: string | undefined,
|
||||
handleChanged: boolean,
|
||||
) {
|
||||
if (handleChanged) return true;
|
||||
if (!canonicalHandle) return false;
|
||||
if (!user.personalPublisherId) return true;
|
||||
|
||||
const publisher = await ctx.db.get(user.personalPublisherId);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return true;
|
||||
if (publisher.kind !== "user" || publisher.linkedUserId !== user._id) return true;
|
||||
return publisher.handle !== canonicalHandle;
|
||||
}
|
||||
|
||||
async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
const existingHandle = normalizeHandle(user.handle);
|
||||
const githubLogin = normalizeHandle(user.name);
|
||||
const rawExistingHandle = normalizeText(user.handle);
|
||||
const rawGithubLogin = normalizeText(user.name);
|
||||
const existingHandle = normalizeReservedHandle(user.handle);
|
||||
const githubLogin = normalizeReservedHandle(user.name);
|
||||
const requestedHandle = deriveHandle({
|
||||
existingHandle,
|
||||
githubLogin,
|
||||
@@ -245,25 +354,44 @@ async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
||||
requestedHandle && (await canUserClaimHandle(ctx, requestedHandle, user._id))
|
||||
? requestedHandle
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
!derivedHandle &&
|
||||
rawExistingHandle &&
|
||||
existingHandle &&
|
||||
rawExistingHandle !== existingHandle
|
||||
) {
|
||||
derivedHandle = (await canUserClaimHandle(ctx, existingHandle, user._id))
|
||||
? existingHandle
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (!derivedHandle && !existingHandle) {
|
||||
const emailFallback = !requestedHandle && user.email ? user.email.split("@")[0]?.trim() : user.email?.split("@")[0]?.trim();
|
||||
const emailFallback = user.email?.split("@")[0]?.trim();
|
||||
derivedHandle =
|
||||
(emailFallback &&
|
||||
emailFallback !== requestedHandle &&
|
||||
(await resolveAvailableHandle(ctx, emailFallback, user._id))) ||
|
||||
emailFallback !== requestedHandle &&
|
||||
(await resolveAvailableHandle(ctx, emailFallback, user._id))) ||
|
||||
(await resolveAvailableHandle(ctx, requestedHandle, user._id));
|
||||
}
|
||||
const baseHandle = derivedHandle ?? existingHandle;
|
||||
|
||||
if (derivedHandle && existingHandle !== derivedHandle) {
|
||||
if (derivedHandle && rawExistingHandle !== derivedHandle) {
|
||||
updates.handle = derivedHandle;
|
||||
}
|
||||
|
||||
const displayName = normalizeHandle(user.displayName);
|
||||
if (!displayName && baseHandle) {
|
||||
updates.displayName = baseHandle;
|
||||
} else if (derivedHandle && displayName === existingHandle) {
|
||||
updates.displayName = derivedHandle;
|
||||
const displayName = normalizeText(user.displayName);
|
||||
const preferredDisplayName = rawGithubLogin ?? rawExistingHandle ?? baseHandle;
|
||||
if (!displayName && preferredDisplayName) {
|
||||
updates.displayName = preferredDisplayName;
|
||||
} else if (
|
||||
preferredDisplayName &&
|
||||
derivedHandle &&
|
||||
rawExistingHandle &&
|
||||
displayName === rawExistingHandle &&
|
||||
normalizeReservedHandle(rawExistingHandle) !== derivedHandle
|
||||
) {
|
||||
updates.displayName = preferredDisplayName;
|
||||
}
|
||||
|
||||
if (!user.role) {
|
||||
@@ -284,7 +412,9 @@ export async function ensureHandler(ctx: MutationCtx) {
|
||||
updates.updatedAt = Date.now();
|
||||
await ctx.db.patch(userId, updates);
|
||||
}
|
||||
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
|
||||
const ensuredUser = hasUpdates
|
||||
? ({ ...user, ...updates } as Doc<"users">)
|
||||
: ((await ctx.db.get(userId)) ?? user);
|
||||
await ensurePersonalPublisherForUser(ctx, ensuredUser);
|
||||
return await ctx.db.get(userId);
|
||||
}
|
||||
@@ -396,11 +526,84 @@ function clampInt(value: number, min: number, max: number) {
|
||||
export const getByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db
|
||||
return toPublicUser(await getUserByHandleCaseAware(ctx, args.handle));
|
||||
},
|
||||
});
|
||||
|
||||
// Cursor-based admin backfill for legacy mixed-case user handles.
|
||||
// Run one batch manually:
|
||||
// bunx convex run users:backfillCanonicalHandlesInternal '{"batchSize":100}' --prod
|
||||
// Or use the helper script:
|
||||
// bun scripts/backfill-user-handles.ts --prod --batch-size 100
|
||||
export const backfillCanonicalHandlesInternal = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(
|
||||
args.batchSize ?? DEFAULT_HANDLE_BACKFILL_BATCH_SIZE,
|
||||
1,
|
||||
MAX_HANDLE_BACKFILL_BATCH_SIZE,
|
||||
);
|
||||
const dryRun = args.dryRun ?? false;
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", args.handle))
|
||||
.unique();
|
||||
return toPublicUser(user);
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
|
||||
let normalizedUsers = 0;
|
||||
let syncedPublishers = 0;
|
||||
let skippedUsers = 0;
|
||||
|
||||
for (const user of page) {
|
||||
if (user.deletedAt || user.deactivatedAt) continue;
|
||||
|
||||
const rawHandle = normalizeText(user.handle);
|
||||
const canonicalHandle = normalizeReservedHandle(user.handle);
|
||||
let nextUser = user;
|
||||
let handleChanged = false;
|
||||
|
||||
if (rawHandle && canonicalHandle && rawHandle !== canonicalHandle) {
|
||||
if (!(await canUserClaimHandle(ctx, canonicalHandle, user._id))) {
|
||||
skippedUsers += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
handleChanged = true;
|
||||
normalizedUsers += 1;
|
||||
const updatedAt = Date.now();
|
||||
nextUser = { ...user, handle: canonicalHandle, updatedAt };
|
||||
|
||||
if (!dryRun) {
|
||||
await ctx.db.patch(user._id, {
|
||||
handle: canonicalHandle,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await needsPersonalPublisherSync(ctx, nextUser, canonicalHandle, handleChanged))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
syncedPublishers += 1;
|
||||
if (!dryRun) {
|
||||
await ensurePersonalPublisherForUser(ctx, nextUser);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
scanned: page.length,
|
||||
normalizedUsers,
|
||||
syncedPublishers,
|
||||
skippedUsers,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
dryRun,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -822,7 +1025,8 @@ async function ensurePublisherHandleWithActor(
|
||||
|
||||
if (existing) {
|
||||
const nextDisplayName =
|
||||
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
|
||||
args.displayName?.trim() &&
|
||||
(!existing.displayName || existing.displayName === existing.handle)
|
||||
? displayName
|
||||
: existing.displayName;
|
||||
await ctx.db.patch(existing._id, {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"backfill:user-handles": "bun scripts/backfill-user-handles.ts",
|
||||
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bun
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
type Options = {
|
||||
batchSize: number;
|
||||
pauseMs: number;
|
||||
maxBatches: number | null;
|
||||
dryRun: boolean;
|
||||
prod: boolean;
|
||||
deploymentName: string | null;
|
||||
previewName: string | null;
|
||||
envFile: string | null;
|
||||
};
|
||||
|
||||
type BatchResult = {
|
||||
ok: true;
|
||||
scanned: number;
|
||||
normalizedUsers: number;
|
||||
syncedPublishers: number;
|
||||
skippedUsers: number;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage:
|
||||
bun scripts/backfill-user-handles.ts --prod [--batch-size 100] [--pause-ms 250] [--max-batches 20] [--dry-run]
|
||||
bun scripts/backfill-user-handles.ts --deployment-name <name> [--batch-size 100]
|
||||
|
||||
Options:
|
||||
--batch-size <n> Users per batch. Default: 100
|
||||
--pause-ms <n> Delay between batches in ms. Default: 250
|
||||
--max-batches <n> Stop after n batches even if more remain
|
||||
--dry-run Report what would change without writing
|
||||
--prod Run against prod
|
||||
--deployment-name <n> Run against a named deployment
|
||||
--preview-name <n> Run against a preview deployment
|
||||
--env-file <path> Custom env file for Convex CLI
|
||||
--help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
function requireValue(args: string[], index: number, flag: string) {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${flag}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, flag: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${flag} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseNonNegativeInt(value: string, flag: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`${flag} must be a non-negative integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const options: Options = {
|
||||
batchSize: 100,
|
||||
pauseMs: 250,
|
||||
maxBatches: null,
|
||||
dryRun: false,
|
||||
prod: false,
|
||||
deploymentName: null,
|
||||
previewName: null,
|
||||
envFile: null,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case "--batch-size":
|
||||
options.batchSize = parsePositiveInt(requireValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--pause-ms":
|
||||
options.pauseMs = parseNonNegativeInt(requireValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--max-batches":
|
||||
options.maxBatches = parsePositiveInt(requireValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--dry-run":
|
||||
options.dryRun = true;
|
||||
break;
|
||||
case "--prod":
|
||||
options.prod = true;
|
||||
break;
|
||||
case "--deployment-name":
|
||||
options.deploymentName = requireValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--preview-name":
|
||||
options.previewName = requireValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--env-file":
|
||||
options.envFile = requireValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentFlags = [
|
||||
options.prod,
|
||||
Boolean(options.deploymentName),
|
||||
Boolean(options.previewName),
|
||||
].filter(Boolean).length;
|
||||
if (deploymentFlags !== 1) {
|
||||
throw new Error(
|
||||
"Choose exactly one deployment target: --prod, --deployment-name, or --preview-name",
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildConvexArgs(options: Options, cursor: string | null) {
|
||||
const payload = {
|
||||
batchSize: options.batchSize,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(options.dryRun ? { dryRun: true } : {}),
|
||||
};
|
||||
|
||||
const args = [
|
||||
"convex",
|
||||
"run",
|
||||
"users:backfillCanonicalHandlesInternal",
|
||||
JSON.stringify(payload),
|
||||
"--codegen",
|
||||
"disable",
|
||||
"--typecheck",
|
||||
"disable",
|
||||
];
|
||||
|
||||
if (options.prod) {
|
||||
args.push("--prod");
|
||||
} else if (options.deploymentName) {
|
||||
args.push("--deployment-name", options.deploymentName);
|
||||
} else if (options.previewName) {
|
||||
args.push("--preview-name", options.previewName);
|
||||
}
|
||||
|
||||
if (options.envFile) {
|
||||
args.push("--env-file", options.envFile);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function runBatch(options: Options, cursor: string | null): BatchResult {
|
||||
try {
|
||||
const output = execFileSync("bunx", buildConvexArgs(options, cursor), {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
}).trim();
|
||||
|
||||
return JSON.parse(output) as BatchResult;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to run users:backfillCanonicalHandlesInternal. Deploy/push the new Convex function first.\n${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
let cursor: string | null = null;
|
||||
let batches = 0;
|
||||
let totalScanned = 0;
|
||||
let totalNormalized = 0;
|
||||
let totalPublisherSyncs = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
console.log(
|
||||
`[backfill-user-handles] starting${options.dryRun ? " (dry-run)" : ""} with batchSize=${options.batchSize}`,
|
||||
);
|
||||
|
||||
while (true) {
|
||||
if (options.maxBatches !== null && batches >= options.maxBatches) {
|
||||
console.log(
|
||||
`[backfill-user-handles] reached max batches (${options.maxBatches}), stopping early`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const result = runBatch(options, cursor);
|
||||
batches += 1;
|
||||
totalScanned += result.scanned;
|
||||
totalNormalized += result.normalizedUsers;
|
||||
totalPublisherSyncs += result.syncedPublishers;
|
||||
totalSkipped += result.skippedUsers;
|
||||
cursor = result.cursor;
|
||||
|
||||
console.log(
|
||||
`[backfill-user-handles] batch=${batches} scanned=${result.scanned} normalized=${result.normalizedUsers} publishers=${result.syncedPublishers} skipped=${result.skippedUsers} done=${result.isDone}`,
|
||||
);
|
||||
|
||||
if (result.isDone || result.cursor === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (options.pauseMs > 0) {
|
||||
await sleep(options.pauseMs);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[backfill-user-handles] complete batches=${batches} scanned=${totalScanned} normalized=${totalNormalized} publishers=${totalPublisherSyncs} skipped=${totalSkipped} remainingCursor=${cursor ?? "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[backfill-user-handles] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -18,16 +18,16 @@ export function UserBadge({
|
||||
showName = false,
|
||||
}: UserBadgeProps) {
|
||||
const userName = user && "name" in user ? user.name?.trim() : undefined;
|
||||
const displayName =
|
||||
user?.displayName?.trim() || userName || null;
|
||||
const displayName = user?.displayName?.trim() || userName || null;
|
||||
const handle = user?.handle ?? fallbackHandle ?? null;
|
||||
const hrefHandle = handle?.trim().toLowerCase() || null;
|
||||
const href =
|
||||
user?.handle && "kind" in user
|
||||
hrefHandle && user?.handle && "kind" in user
|
||||
? user.kind === "org"
|
||||
? `/orgs/${encodeURIComponent(user.handle)}`
|
||||
: `/u/${encodeURIComponent(user.handle)}`
|
||||
: user?.handle
|
||||
? `/u/${encodeURIComponent(user.handle)}`
|
||||
? `/orgs/${encodeURIComponent(hrefHandle)}`
|
||||
: `/u/${encodeURIComponent(hrefHandle)}`
|
||||
: hrefHandle
|
||||
? `/u/${encodeURIComponent(hrefHandle)}`
|
||||
: null;
|
||||
const label = handle ? `@${handle}` : "user";
|
||||
const image = user?.image ?? null;
|
||||
|
||||
@@ -6,7 +6,7 @@ export function buildSkillHref(
|
||||
ownerId: Id<"users"> | Id<"publishers"> | null,
|
||||
slug: string,
|
||||
) {
|
||||
const owner = ownerHandle?.trim() || (ownerId ? String(ownerId) : "unknown");
|
||||
const owner = ownerHandle?.trim().toLowerCase() || (ownerId ? String(ownerId) : "unknown");
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
|
||||
+24
-10
@@ -14,11 +14,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
|
||||
const emptyPluginPublishSearch = {
|
||||
ownerHandle: undefined,
|
||||
@@ -104,7 +104,8 @@ function Dashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPublisherId) return;
|
||||
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
const personal =
|
||||
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher._id) {
|
||||
setSelectedPublisherId(personal.publisher._id);
|
||||
}
|
||||
@@ -121,7 +122,10 @@ function Dashboard() {
|
||||
const skills = mySkills ?? [];
|
||||
const packages = myPackages ?? [];
|
||||
const ownerHandle =
|
||||
selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id;
|
||||
selectedPublisher?.publisher.handle ??
|
||||
me.handle?.trim().toLowerCase() ??
|
||||
me.name?.trim().toLowerCase() ??
|
||||
me._id;
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
@@ -177,9 +181,14 @@ function Dashboard() {
|
||||
{skills.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
|
||||
<strong>No skills yet.</strong> Publish your first skill to share it with the
|
||||
community.
|
||||
</div>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Link
|
||||
to="/publish-skill"
|
||||
search={{ updateSlug: undefined }}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
@@ -211,7 +220,8 @@ function Dashboard() {
|
||||
{packages.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
|
||||
<strong>No plugins yet.</strong> Publish your first plugin release to validate and
|
||||
distribute it.
|
||||
</div>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
@@ -264,7 +274,8 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
</div>
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
@@ -355,7 +366,9 @@ function PackageStatusTag({
|
||||
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
const scanLabel = scanStatusLabel(pkg.scanStatus);
|
||||
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
|
||||
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
|
||||
const sourceLabel = pkg.sourceRepo
|
||||
?.replace(/^https?:\/\/github\.com\//, "")
|
||||
.replace(/\.git$/, "");
|
||||
const scanTone =
|
||||
pkg.scanStatus === "pending"
|
||||
? "pending"
|
||||
@@ -400,7 +413,8 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
|
||||
</div>
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
|
||||
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(pkg.stats.downloads)}
|
||||
</span>
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
|
||||
|
||||
@@ -199,7 +199,10 @@ export function ImportGitHub() {
|
||||
});
|
||||
const nextSlug = result.slug;
|
||||
setStatus("Imported.");
|
||||
const ownerParam = me?.handle ?? (me?._id ? String(me._id) : "unknown");
|
||||
const ownerParam =
|
||||
me?.handle?.trim().toLowerCase() ??
|
||||
me?.name?.trim().toLowerCase() ??
|
||||
(me?._id ? String(me._id) : "unknown");
|
||||
await navigate({ to: "/$owner/$slug", params: { owner: ownerParam, slug: nextSlug } });
|
||||
} catch (e) {
|
||||
setError(getUserFacingConvexError(e, "Import failed"));
|
||||
|
||||
@@ -8,10 +8,7 @@ import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import {
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "../../convex/lib/publishLimits";
|
||||
import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
import { getPublicSlugCollision } from "../lib/slugCollision";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles";
|
||||
@@ -194,7 +191,9 @@ export function Upload() {
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personalPublisher = publisherMemberships?.find((entry) => entry.publisher.kind === "user");
|
||||
const personalPublisher = publisherMemberships?.find(
|
||||
(entry) => entry.publisher.kind === "user",
|
||||
);
|
||||
if (personalPublisher?.publisher.handle) {
|
||||
setOwnerHandle(personalPublisher.publisher.handle);
|
||||
}
|
||||
@@ -419,7 +418,10 @@ export function Upload() {
|
||||
setChangelogSource("user");
|
||||
if (result) {
|
||||
const ownerParam =
|
||||
ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown");
|
||||
ownerHandle ||
|
||||
me?.handle?.trim().toLowerCase() ||
|
||||
me?.name?.trim().toLowerCase() ||
|
||||
(me?._id ? String(me._id) : "unknown");
|
||||
void navigate({
|
||||
to: isSoulMode ? "/souls/$slug" : "/$owner/$slug",
|
||||
params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug },
|
||||
|
||||
@@ -32,6 +32,7 @@ export type SkillSearchEntry = {
|
||||
};
|
||||
|
||||
export function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
|
||||
const owner = ownerHandle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
|
||||
const owner =
|
||||
ownerHandle?.trim().toLowerCase() || String(skill.ownerPublisherId ?? skill.ownerUserId);
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user