@payload-auth-link/migrate 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -2
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor/bands.d.ts +16 -0
- package/dist/doctor/bands.d.ts.map +1 -0
- package/dist/doctor/bands.js +49 -0
- package/dist/doctor/bands.js.map +1 -0
- package/dist/doctor/database.d.ts +3 -0
- package/dist/doctor/database.d.ts.map +1 -0
- package/dist/doctor/database.js +110 -0
- package/dist/doctor/database.js.map +1 -0
- package/dist/doctor/plugins.d.ts +12 -0
- package/dist/doctor/plugins.d.ts.map +1 -0
- package/dist/doctor/plugins.js +202 -0
- package/dist/doctor/plugins.js.map +1 -0
- package/dist/doctor/run-doctor.d.ts +48 -0
- package/dist/doctor/run-doctor.d.ts.map +1 -0
- package/dist/doctor/run-doctor.js +236 -0
- package/dist/doctor/run-doctor.js.map +1 -0
- package/dist/doctor/source-scan.d.ts +10 -0
- package/dist/doctor/source-scan.d.ts.map +1 -0
- package/dist/doctor/source-scan.js +142 -0
- package/dist/doctor/source-scan.js.map +1 -0
- package/dist/doctor/types.d.ts +86 -0
- package/dist/doctor/types.d.ts.map +1 -0
- package/dist/doctor/types.js +7 -0
- package/dist/doctor/types.js.map +1 -0
- package/dist/doctor/users.d.ts +10 -0
- package/dist/doctor/users.d.ts.map +1 -0
- package/dist/doctor/users.js +176 -0
- package/dist/doctor/users.js.map +1 -0
- package/dist/importer/apply.d.ts +39 -0
- package/dist/importer/apply.d.ts.map +1 -0
- package/dist/importer/apply.js +588 -0
- package/dist/importer/apply.js.map +1 -0
- package/dist/importer/lifecycle.d.ts +52 -0
- package/dist/importer/lifecycle.d.ts.map +1 -0
- package/dist/importer/lifecycle.js +265 -0
- package/dist/importer/lifecycle.js.map +1 -0
- package/dist/importer/run-import.d.ts +40 -0
- package/dist/importer/run-import.d.ts.map +1 -0
- package/dist/importer/run-import.js +176 -0
- package/dist/importer/run-import.js.map +1 -0
- package/dist/importer/source.d.ts +49 -0
- package/dist/importer/source.d.ts.map +1 -0
- package/dist/importer/source.js +147 -0
- package/dist/importer/source.js.map +1 -0
- package/dist/importer/types.d.ts +69 -0
- package/dist/importer/types.d.ts.map +1 -0
- package/dist/importer/types.js +13 -0
- package/dist/importer/types.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -4
- package/src/cli.ts +287 -0
- package/src/doctor/bands.ts +66 -0
- package/src/doctor/database.ts +130 -0
- package/src/doctor/plugins.ts +216 -0
- package/src/doctor/run-doctor.ts +278 -0
- package/src/doctor/source-scan.ts +148 -0
- package/src/doctor/types.ts +92 -0
- package/src/doctor/users.ts +207 -0
- package/src/importer/apply.ts +731 -0
- package/src/importer/lifecycle.ts +328 -0
- package/src/importer/run-import.ts +237 -0
- package/src/importer/source.ts +211 -0
- package/src/importer/types.ts +96 -0
- package/src/index.ts +53 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Client } from 'pg'
|
|
2
|
+
import type { Unmappable } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read side (issue #50): the payload-auth layout, straight from
|
|
6
|
+
* Postgres. Rows are read with plain SELECTs — the source tables are
|
|
7
|
+
* never modified until `finalize` (which only marks the manifest; the
|
|
8
|
+
* drop itself stays the operator's step).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One payload-auth source user with its role rows and accounts. */
|
|
12
|
+
export type SourceUser = {
|
|
13
|
+
readonly id: string
|
|
14
|
+
readonly email: string
|
|
15
|
+
readonly name: string | null
|
|
16
|
+
readonly emailVerified: boolean
|
|
17
|
+
readonly image: string | null
|
|
18
|
+
readonly createdAt: Date
|
|
19
|
+
readonly updatedAt: Date
|
|
20
|
+
/** Stored role values from the `users_role` child table. */
|
|
21
|
+
readonly roles: readonly string[]
|
|
22
|
+
readonly accounts: readonly SourceAccount[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SourceAccount = {
|
|
26
|
+
readonly id: string
|
|
27
|
+
readonly accountId: string
|
|
28
|
+
readonly providerId: string
|
|
29
|
+
readonly password: string | null
|
|
30
|
+
readonly accessToken: string | null
|
|
31
|
+
readonly refreshToken: string | null
|
|
32
|
+
readonly idToken: string | null
|
|
33
|
+
readonly scope: string | null
|
|
34
|
+
readonly accessTokenExpiresAt: Date | null
|
|
35
|
+
readonly refreshTokenExpiresAt: Date | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SourceRow = Record<string, unknown>
|
|
39
|
+
|
|
40
|
+
export async function openSource(connectionString: string): Promise<Client> {
|
|
41
|
+
const client = new Client({ connectionString })
|
|
42
|
+
await client.connect()
|
|
43
|
+
return client
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The columns one table actually has (customized layouts drop columns). */
|
|
47
|
+
async function columnsOf(client: Client, table: string): Promise<Set<string>> {
|
|
48
|
+
const result = await client.query(
|
|
49
|
+
`SELECT column_name FROM information_schema.columns
|
|
50
|
+
WHERE table_schema = 'public' AND table_name = $1`,
|
|
51
|
+
[table]
|
|
52
|
+
)
|
|
53
|
+
return new Set(result.rows.map((row) => String((row as { column_name: unknown }).column_name)))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function tableExists(client: Client, table: string): Promise<boolean> {
|
|
57
|
+
const result = await client.query(
|
|
58
|
+
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
|
|
59
|
+
[table]
|
|
60
|
+
)
|
|
61
|
+
return result.rows.length > 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function countTables(
|
|
65
|
+
client: Client,
|
|
66
|
+
tables: readonly string[]
|
|
67
|
+
): Promise<Record<string, number>> {
|
|
68
|
+
const counts: Record<string, number> = {}
|
|
69
|
+
for (const table of tables) {
|
|
70
|
+
if (await tableExists(client, table)) {
|
|
71
|
+
const counted = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
|
|
72
|
+
counts[table] = (counted.rows[0] as { n?: number } | undefined)?.n ?? 0
|
|
73
|
+
} else {
|
|
74
|
+
counts[table] = 0
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return counts
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function iso(value: unknown): Date | null {
|
|
81
|
+
return value instanceof Date ? value : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function text(value: unknown): string | null {
|
|
85
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every source user, with role rows and accounts attached. */
|
|
89
|
+
export async function readSourceUsers(client: Client): Promise<SourceUser[]> {
|
|
90
|
+
const hasRoleTable = await tableExists(client, 'users_role')
|
|
91
|
+
const users = await client.query(
|
|
92
|
+
`SELECT id, email, name, email_verified, image, created_at, updated_at FROM users ORDER BY id`
|
|
93
|
+
)
|
|
94
|
+
const roleRows = hasRoleTable
|
|
95
|
+
? await client.query(`SELECT parent_id, value FROM users_role ORDER BY parent_id, "order"`)
|
|
96
|
+
: { rows: [] as unknown[] }
|
|
97
|
+
const rolesByUser = new Map<string, string[]>()
|
|
98
|
+
for (const row of roleRows.rows as { parent_id: number; value: string }[]) {
|
|
99
|
+
const list = rolesByUser.get(String(row.parent_id)) ?? []
|
|
100
|
+
list.push(row.value)
|
|
101
|
+
rolesByUser.set(String(row.parent_id), list)
|
|
102
|
+
}
|
|
103
|
+
const accounts = await client.query(
|
|
104
|
+
`SELECT id, account_id, provider_id, password, access_token, refresh_token, id_token,
|
|
105
|
+
scope, access_token_expires_at, refresh_token_expires_at, user_id
|
|
106
|
+
FROM accounts ORDER BY id`
|
|
107
|
+
)
|
|
108
|
+
const accountsByUser = new Map<string, SourceAccount[]>()
|
|
109
|
+
for (const row of accounts.rows as Record<string, unknown>[]) {
|
|
110
|
+
const userId = String(row.user_id)
|
|
111
|
+
const account: SourceAccount = {
|
|
112
|
+
id: String(row.id),
|
|
113
|
+
accountId: String(row.account_id),
|
|
114
|
+
providerId: String(row.provider_id),
|
|
115
|
+
password: text(row.password),
|
|
116
|
+
accessToken: text(row.access_token),
|
|
117
|
+
refreshToken: text(row.refresh_token),
|
|
118
|
+
idToken: text(row.id_token),
|
|
119
|
+
scope: text(row.scope),
|
|
120
|
+
accessTokenExpiresAt: iso(row.access_token_expires_at),
|
|
121
|
+
refreshTokenExpiresAt: iso(row.refresh_token_expires_at),
|
|
122
|
+
}
|
|
123
|
+
const list = accountsByUser.get(userId) ?? []
|
|
124
|
+
list.push(account)
|
|
125
|
+
accountsByUser.set(userId, list)
|
|
126
|
+
}
|
|
127
|
+
const out: SourceUser[] = []
|
|
128
|
+
for (const row of users.rows as Record<string, unknown>[]) {
|
|
129
|
+
const id = String(row.id)
|
|
130
|
+
out.push({
|
|
131
|
+
id,
|
|
132
|
+
email: String(row.email),
|
|
133
|
+
name: text(row.name),
|
|
134
|
+
emailVerified: row.email_verified === true,
|
|
135
|
+
image: text(row.image),
|
|
136
|
+
createdAt: iso(row.created_at) ?? new Date(),
|
|
137
|
+
updatedAt: iso(row.updated_at) ?? new Date(),
|
|
138
|
+
roles: rolesByUser.get(id) ?? [],
|
|
139
|
+
accounts: accountsByUser.get(id) ?? [],
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Rows of one flat plugin table (sessions, verifications, apiKeys, jwks, …). */
|
|
146
|
+
export async function readFlatRows(
|
|
147
|
+
client: Client,
|
|
148
|
+
table: string,
|
|
149
|
+
columns: readonly string[]
|
|
150
|
+
): Promise<SourceRow[]> {
|
|
151
|
+
if (!(await tableExists(client, table))) {
|
|
152
|
+
return []
|
|
153
|
+
}
|
|
154
|
+
const result = await client.query(`SELECT ${columns.join(', ')} FROM ${table} ORDER BY id`)
|
|
155
|
+
return result.rows as SourceRow[]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Members and invitations reference organizations and users by numeric
|
|
160
|
+
* id; the importer remaps to the target's baIds. Orphaned references
|
|
161
|
+
* are unmappable — they abort the run rather than skip silently.
|
|
162
|
+
*/
|
|
163
|
+
export async function findOrphans(client: Client): Promise<readonly Unmappable[]> {
|
|
164
|
+
const orphans: Unmappable[] = []
|
|
165
|
+
const checks: readonly { model: string; table: string; column: string; target: string }[] = [
|
|
166
|
+
{ model: 'account', table: 'accounts', column: 'user_id', target: 'users' },
|
|
167
|
+
{ model: 'session', table: 'sessions', column: 'user_id', target: 'users' },
|
|
168
|
+
{ model: 'member', table: 'members', column: 'organization_id', target: 'organizations' },
|
|
169
|
+
{ model: 'member', table: 'members', column: 'user_id', target: 'users' },
|
|
170
|
+
{
|
|
171
|
+
model: 'invitation',
|
|
172
|
+
table: 'invitations',
|
|
173
|
+
column: 'organization_id',
|
|
174
|
+
target: 'organizations',
|
|
175
|
+
},
|
|
176
|
+
{ model: 'invitation', table: 'invitations', column: 'inviter_id', target: 'users' },
|
|
177
|
+
{ model: 'apikey', table: 'api_keys', column: 'user_id', target: 'users' },
|
|
178
|
+
]
|
|
179
|
+
for (const check of checks) {
|
|
180
|
+
const exists = await Promise.all([
|
|
181
|
+
tableExists(client, check.table),
|
|
182
|
+
tableExists(client, check.target),
|
|
183
|
+
])
|
|
184
|
+
if (!exists[0] || !exists[1]) {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
// Customized layouts drop columns; a missing reference column is
|
|
188
|
+
// not an orphan farm.
|
|
189
|
+
if (!(await columnsOf(client, check.table)).has(check.column)) {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const result = await client.query(
|
|
193
|
+
`SELECT t.id FROM ${check.table} t
|
|
194
|
+
WHERE t.${check.column} IS NOT NULL
|
|
195
|
+
AND NOT EXISTS (SELECT 1 FROM ${check.target} s WHERE s.id = t.${check.column})`
|
|
196
|
+
)
|
|
197
|
+
for (const row of result.rows as { id: unknown }[]) {
|
|
198
|
+
orphans.push({
|
|
199
|
+
model: check.model,
|
|
200
|
+
key: String(row.id),
|
|
201
|
+
reason: `${check.table}.${check.column} references a missing ${check.target} row`,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return orphans
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Users whose email never verified — reported, never granted `linkable`. */
|
|
209
|
+
export function unverifiedEmails(users: readonly SourceUser[]): readonly string[] {
|
|
210
|
+
return users.filter((user) => !user.emailVerified).map((user) => user.email)
|
|
211
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importer manifest (issue #50): every row created and every field
|
|
3
|
+
* changed, so `rollback` is exact. The manifest is the run's contract —
|
|
4
|
+
* `verify`, `rollback`, and `finalize` all read it.
|
|
5
|
+
*/
|
|
6
|
+
export type CreatedRow = {
|
|
7
|
+
/** Target collection slug. */
|
|
8
|
+
readonly collection: string
|
|
9
|
+
/** Payload document id of the created row. */
|
|
10
|
+
readonly id: string | number
|
|
11
|
+
/** The source identity this row was created for (dedupe on resume). */
|
|
12
|
+
readonly sourceKey: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type FieldWrite = {
|
|
16
|
+
readonly collection: string
|
|
17
|
+
readonly id: string | number
|
|
18
|
+
readonly field: string
|
|
19
|
+
/** The prior value, JSON-serialized, restored by rollback. */
|
|
20
|
+
readonly before: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ImportManifest = {
|
|
24
|
+
readonly version: 1
|
|
25
|
+
readonly createdAt: string
|
|
26
|
+
readonly sourceDatabase: string
|
|
27
|
+
readonly options: {
|
|
28
|
+
readonly sessions: 'carry' | 'drop'
|
|
29
|
+
}
|
|
30
|
+
readonly created: CreatedRow[]
|
|
31
|
+
readonly fieldWrites: FieldWrite[]
|
|
32
|
+
readonly finalized: boolean
|
|
33
|
+
/** Counts per source table at plan time (the rollback baseline). */
|
|
34
|
+
readonly sourceCounts: Readonly<Record<string, number>>
|
|
35
|
+
/** Cumulative plan totals (rows created + skipped across resumes). */
|
|
36
|
+
readonly totals: Readonly<Record<string, number>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Per-model plan row for the dry run. */
|
|
40
|
+
export type PlanRow = {
|
|
41
|
+
readonly model: string
|
|
42
|
+
readonly sourceTable: string
|
|
43
|
+
readonly targetCollection: string
|
|
44
|
+
readonly rows: number
|
|
45
|
+
readonly skipped: number
|
|
46
|
+
readonly unmappable: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Why a row cannot be imported — the run aborts rather than skips. */
|
|
50
|
+
export type Unmappable = {
|
|
51
|
+
readonly model: string
|
|
52
|
+
readonly key: string
|
|
53
|
+
readonly reason: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ImportPlan = {
|
|
57
|
+
readonly rows: readonly PlanRow[]
|
|
58
|
+
readonly unmappable: readonly Unmappable[]
|
|
59
|
+
readonly sessions: 'carry' | 'drop'
|
|
60
|
+
readonly warnings: readonly string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ImportResult = {
|
|
64
|
+
readonly created: number
|
|
65
|
+
readonly skipped: number
|
|
66
|
+
readonly fieldWrites: number
|
|
67
|
+
readonly plan: ImportPlan
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The models the importer copies. */
|
|
71
|
+
export type TargetModel =
|
|
72
|
+
| 'user'
|
|
73
|
+
| 'session'
|
|
74
|
+
| 'account'
|
|
75
|
+
| 'verification'
|
|
76
|
+
| 'apikey'
|
|
77
|
+
| 'jwks'
|
|
78
|
+
| 'organization'
|
|
79
|
+
| 'member'
|
|
80
|
+
| 'invitation'
|
|
81
|
+
|
|
82
|
+
/** Target slugs with the known models always present (index-safe). */
|
|
83
|
+
export type TargetSlugMap = Record<TargetModel, string> & Record<string, string>
|
|
84
|
+
|
|
85
|
+
/** Default target slugs; must match the app's engine wiring. */
|
|
86
|
+
export const defaultTargetSlugs: TargetSlugMap = {
|
|
87
|
+
user: 'ba-users',
|
|
88
|
+
session: 'ba-sessions',
|
|
89
|
+
account: 'ba-accounts',
|
|
90
|
+
verification: 'ba-verifications',
|
|
91
|
+
apikey: 'ba-apikey',
|
|
92
|
+
jwks: 'ba-jwks',
|
|
93
|
+
organization: 'ba-organization',
|
|
94
|
+
member: 'ba-member',
|
|
95
|
+
invitation: 'ba-invitation',
|
|
96
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API of `@payload-auth-link/migrate` (issues #49–#59). The
|
|
3
|
+
* CLI is the primary surface; these exports let tools and tests run
|
|
4
|
+
* the doctor in-process and consume its typed report.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
runDoctor,
|
|
8
|
+
behaviourChanges,
|
|
9
|
+
findPayloadAuthPlugin,
|
|
10
|
+
betterAuthPluginIds,
|
|
11
|
+
usersSlugOf,
|
|
12
|
+
} from './doctor/run-doctor.js'
|
|
13
|
+
export type { DoctorOptions } from './doctor/run-doctor.js'
|
|
14
|
+
export { checkVersions, payloadBand, betterAuthBand, nextBand } from './doctor/bands.js'
|
|
15
|
+
export { classifyPlugins } from './doctor/plugins.js'
|
|
16
|
+
export { scanSource, projectedUserFields } from './doctor/source-scan.js'
|
|
17
|
+
export { analyzeUsersCollection, findUsersCollection, localStrategyOn } from './doctor/users.js'
|
|
18
|
+
export { introspectDatabase } from './doctor/database.js'
|
|
19
|
+
export { runFromPayloadAuth, runVerify, runRollback, runFinalize } from './importer/run-import.js'
|
|
20
|
+
export { applyImport, summarizePlanRows } from './importer/apply.js'
|
|
21
|
+
export {
|
|
22
|
+
readManifest,
|
|
23
|
+
writeManifest,
|
|
24
|
+
verifyImport,
|
|
25
|
+
rollbackImport,
|
|
26
|
+
finalizeManifest,
|
|
27
|
+
defaultManifestPath,
|
|
28
|
+
type VerifyIssue,
|
|
29
|
+
type VerifyResult,
|
|
30
|
+
} from './importer/lifecycle.js'
|
|
31
|
+
export {
|
|
32
|
+
defaultTargetSlugs,
|
|
33
|
+
type CreatedRow,
|
|
34
|
+
type TargetModel,
|
|
35
|
+
type TargetSlugMap,
|
|
36
|
+
type FieldWrite,
|
|
37
|
+
type ImportManifest,
|
|
38
|
+
type ImportPlan,
|
|
39
|
+
type ImportResult,
|
|
40
|
+
type PlanRow,
|
|
41
|
+
type Unmappable,
|
|
42
|
+
} from './importer/types.js'
|
|
43
|
+
export { cliMain } from './cli.js'
|
|
44
|
+
export type {
|
|
45
|
+
BehaviourChange,
|
|
46
|
+
DatabaseFinding,
|
|
47
|
+
DoctorIssue,
|
|
48
|
+
DoctorReport,
|
|
49
|
+
PluginFinding,
|
|
50
|
+
SourceFinding,
|
|
51
|
+
UsersFinding,
|
|
52
|
+
VersionFinding,
|
|
53
|
+
} from './doctor/types.js'
|