Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7f19f4e
feat(migrate): port instance reads, readiness and checks from #479 at…
royanger Oct 6, 2026
70e24db
feat(migrate): trim instance reads, readiness and checks to slice 1
royanger Oct 6, 2026
796d7f6
docs(changeset): add an empty changeset; migrate stays hidden and ref…
royanger Oct 6, 2026
b5d0db6
fix(migrate): retry a rate-limited read of the instance's settings
royanger Oct 7, 2026
32c31aa
fix(migrate): keep a Supabase user on a disabled provider who can sig…
royanger Oct 8, 2026
4e84266
fix(migrate): retry a rate-limited read of the instance's user count
royanger Oct 8, 2026
6c67c6f
fix(migrate): name the app in a suggested config patch, or leave it t…
royanger Oct 8, 2026
49c98bd
chore(migrate): drop two leftover doc comments
royanger Oct 8, 2026
f9dfd02
test(migrate): give the checks' scheduler stub a pause
royanger Oct 8, 2026
1fa8532
fix(migrate): reject a user without a password when nothing else sign…
royanger Oct 8, 2026
0e10def
fix(migrate): keep the identifiers Clerk keeps, by Clerk's rule
royanger Oct 8, 2026
1796eb6
fix(migrate): check duplicates only on the identifiers the create sends
royanger Oct 8, 2026
0a0e661
fix(migrate): say a name is stored, not dropped, when names are off
royanger Oct 8, 2026
1ed0909
fix(migrate): drop a blank or over-long name, as Clerk refuses both
royanger Oct 8, 2026
d2b85a3
fix(migrate): put the right article on each field in the warnings
royanger Oct 8, 2026
b3bf4d4
fix(migrate): use APP_ID as the fix command's app placeholder
royanger Oct 8, 2026
27fcc3a
fix(migrate): offer the config patch a username reject asks for
royanger Oct 8, 2026
afc1b0c
fix(migrate): pause every user lookup on a 429, not just the one that…
royanger Oct 9, 2026
f9f6ddb
fix(migrate): warn that a ban due to end stays until it is lifted in …
royanger Oct 9, 2026
58ea9c8
fix(migrate): reject a bcrypt digest under cost 4 in the checks
royanger Oct 9, 2026
d894514
refactor(migrate): name the stored-field fix rows' verb so oxlint's n…
royanger Oct 9, 2026
63fa798
fix(migrate): say a user with no identifier may have a username Clerk…
royanger Oct 9, 2026
cf197b6
fix(migrate): say numeric usernames make Clerk require E.164 phones, …
royanger Oct 9, 2026
3c5e92d
fix(migrate): drop phones not in E.164 form where numeric usernames a…
royanger Oct 9, 2026
211354c
fix(migrate): trim a phone before its E.164 check, as Clerk does
royanger Oct 13, 2026
65a2a3e
fix(migrate): leave phones the instance has off out of the E.164 checks
royanger Oct 13, 2026
9fb8a97
fix(migrate): name the E.164 drop when it leaves a user without a req…
royanger Oct 13, 2026
9303054
fix(migrate): count the instance lookups on one spinner total, so it …
royanger Oct 13, 2026
a698253
fix(migrate): read every page of a lookup batch, from starting_after
royanger Oct 13, 2026
36f728e
fix(migrate): name the kept record on a duplicate source ID too
royanger Oct 13, 2026
fe59c2a
fix(migrate): offer no setting fix for a user that is a duplicate
royanger Oct 13, 2026
f191d45
fix(migrate): count the legacy Twitter strategy as enabling X
royanger Oct 13, 2026
dea6f90
fix(migrate): leave a duplicate out of the fixes when a check changed…
royanger Oct 13, 2026
d96fc3e
fix(migrate): stop paging a lookup when its cursor does not move
royanger Oct 13, 2026
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
2 changes: 2 additions & 0 deletions .changeset/migrate-slice-1c.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
97 changes: 97 additions & 0 deletions packages/cli-core/src/commands/migrate/lib/analysis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { analyzeFields, hasValue } from "./analysis.ts";

describe("hasValue", () => {
test.each([
["a string", "x", true],
["zero", 0, true],
["false", false, true],
["a populated array", ["a"], true],
["an object", {}, true],
["an empty string", "", false],
["an empty array", [], false],
["null", null, false],
["undefined", undefined, false],
])("treats %s as present: %p", (_label, value, expected) => {
expect(hasValue(value)).toBe(expected);
});
});

describe("analyzeFields", () => {
test("returns zeroed counts for an empty file", () => {
const result = analyzeFields([]);
expect(result.totalUsers).toBe(0);
expect(result.identifiers.hasAnyIdentifier).toBe(0);
expect(result.fieldCounts).toEqual({});
});

test("counts each identifier kind separately", () => {
const result = analyzeFields([
{ userId: "1", email: "a@x.dev" },
{ userId: "2", unverifiedEmailAddresses: ["b@x.dev"] },
{ userId: "3", phone: "+15555550100" },
{ userId: "4", unverifiedPhoneNumbers: ["+15555550101"] },
{ userId: "5", username: "carol" },
]);

expect(result.identifiers).toMatchObject({
verifiedEmails: 1,
unverifiedEmails: 1,
verifiedPhones: 1,
unverifiedPhones: 1,
username: 1,
hasAnyIdentifier: 5,
});
});

test("counts emailAddresses towards verified emails", () => {
const result = analyzeFields([{ userId: "1", emailAddresses: ["a@x.dev"] }]);
expect(result.identifiers.verifiedEmails).toBe(1);
});

test("counts a user with several identifiers once", () => {
const result = analyzeFields([
{ userId: "1", email: "a@x.dev", phone: "+15555550100", username: "ada" },
]);
expect(result.identifiers.hasAnyIdentifier).toBe(1);
expect(result.identifiers.verifiedEmails).toBe(1);
expect(result.identifiers.verifiedPhones).toBe(1);
});

// These users cannot be imported under any instance configuration, which is
// what makes the count worth surfacing separately.
test("counts users carrying no identifier at all", () => {
const result = analyzeFields([
{ userId: "1", email: "a@x.dev" },
{ userId: "2", firstName: "Nobody" },
{ userId: "3" },
]);
expect(result.totalUsers).toBe(3);
expect(result.identifiers.hasAnyIdentifier).toBe(1);
});

test("counts the analyzed non-identifier fields", () => {
const result = analyzeFields([
{ userId: "1", email: "a@x.dev", firstName: "Ada", password: "d", totpSecret: "s" },
{ userId: "2", email: "b@x.dev", firstName: "Grace" },
{ userId: "3", email: "c@x.dev", lastName: "Hopper" },
]);
expect(result.fieldCounts).toEqual({
firstName: 2,
lastName: 1,
password: 1,
totpSecret: 1,
});
});

test("omits fields no user carries, rather than reporting them as zero", () => {
const result = analyzeFields([{ userId: "1", email: "a@x.dev" }]);
expect("password" in result.fieldCounts).toBe(false);
});

test("does not count an empty value as present", () => {
const result = analyzeFields([{ userId: "1", email: "a@x.dev", firstName: "", lastName: [] }]);
expect(result.fieldCounts.firstName).toBeUndefined();
expect(result.fieldCounts.lastName).toBeUndefined();
});
});
88 changes: 88 additions & 0 deletions packages/cli-core/src/commands/migrate/lib/analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Counts what an import file actually contains, per field.
*
* Ported from the standalone migration-tool's `src/lib/analysis.ts`. Runs on
* transformed-but-unvalidated users so the report describes the whole file,
* including the rows that will be skipped.
*/

import type { User } from "../types.ts";

/** Non-identifier fields the readiness rows report coverage for. */
export const ANALYZED_FIELDS = [
{ key: "firstName", label: "First name" },
{ key: "lastName", label: "Last name" },
{ key: "password", label: "Password" },
{ key: "totpSecret", label: "TOTP secret" },
] as const;

export type IdentifierCounts = {
verifiedEmails: number;
unverifiedEmails: number;
verifiedPhones: number;
unverifiedPhones: number;
username: number;
/** Users with any email, verified or not: each user counted once. */
anyEmail: number;
/** Users with any phone, verified or not: each user counted once. */
anyPhone: number;
/** Users with at least one identifier — the rest cannot be imported at all. */
hasAnyIdentifier: number;
};

export type FieldAnalysis = {
identifiers: IdentifierCounts;
totalUsers: number;
fieldCounts: Record<string, number>;
};

/** True for anything with real content — `0` and `false` count, `""` and `[]` do not. */
export function hasValue(value: unknown): boolean {
if (value === undefined || value === null || value === "") return false;
if (Array.isArray(value)) return value.length > 0;
return true;
}

export function analyzeFields(users: (User | Record<string, unknown>)[]): FieldAnalysis {
const identifiers: IdentifierCounts = {
verifiedEmails: 0,
unverifiedEmails: 0,
verifiedPhones: 0,
unverifiedPhones: 0,
username: 0,
anyEmail: 0,
anyPhone: 0,
hasAnyIdentifier: 0,
};
const fieldCounts: Record<string, number> = {};

for (const entry of users) {
const user = entry as Record<string, unknown>;

for (const field of ANALYZED_FIELDS) {
if (hasValue(user[field.key])) {
fieldCounts[field.key] = (fieldCounts[field.key] ?? 0) + 1;
}
}

const verifiedEmail = hasValue(user.email) || hasValue(user.emailAddresses);
const unverifiedEmail = hasValue(user.unverifiedEmailAddresses);
const verifiedPhone = hasValue(user.phone) || hasValue(user.phoneNumbers);
const unverifiedPhone = hasValue(user.unverifiedPhoneNumbers);
const username = hasValue(user.username);

if (verifiedEmail) identifiers.verifiedEmails++;
if (unverifiedEmail) identifiers.unverifiedEmails++;
if (verifiedPhone) identifiers.verifiedPhones++;
if (unverifiedPhone) identifiers.unverifiedPhones++;
if (username) identifiers.username++;
if (verifiedEmail || unverifiedEmail) identifiers.anyEmail++;
if (verifiedPhone || unverifiedPhone) identifiers.anyPhone++;

if (verifiedEmail || unverifiedEmail || verifiedPhone || unverifiedPhone || username) {
identifiers.hasAnyIdentifier++;
}
}

return { identifiers, totalUsers: users.length, fieldCounts };
}
Loading
Loading