@payload-auth-link/migrate 0.0.0-stage → 0.2.0

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.
Files changed (72) hide show
  1. package/README.md +49 -2
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +257 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/doctor/bands.d.ts +16 -0
  7. package/dist/doctor/bands.d.ts.map +1 -0
  8. package/dist/doctor/bands.js +49 -0
  9. package/dist/doctor/bands.js.map +1 -0
  10. package/dist/doctor/database.d.ts +3 -0
  11. package/dist/doctor/database.d.ts.map +1 -0
  12. package/dist/doctor/database.js +110 -0
  13. package/dist/doctor/database.js.map +1 -0
  14. package/dist/doctor/plugins.d.ts +12 -0
  15. package/dist/doctor/plugins.d.ts.map +1 -0
  16. package/dist/doctor/plugins.js +202 -0
  17. package/dist/doctor/plugins.js.map +1 -0
  18. package/dist/doctor/run-doctor.d.ts +48 -0
  19. package/dist/doctor/run-doctor.d.ts.map +1 -0
  20. package/dist/doctor/run-doctor.js +236 -0
  21. package/dist/doctor/run-doctor.js.map +1 -0
  22. package/dist/doctor/source-scan.d.ts +10 -0
  23. package/dist/doctor/source-scan.d.ts.map +1 -0
  24. package/dist/doctor/source-scan.js +142 -0
  25. package/dist/doctor/source-scan.js.map +1 -0
  26. package/dist/doctor/types.d.ts +86 -0
  27. package/dist/doctor/types.d.ts.map +1 -0
  28. package/dist/doctor/types.js +7 -0
  29. package/dist/doctor/types.js.map +1 -0
  30. package/dist/doctor/users.d.ts +10 -0
  31. package/dist/doctor/users.d.ts.map +1 -0
  32. package/dist/doctor/users.js +176 -0
  33. package/dist/doctor/users.js.map +1 -0
  34. package/dist/importer/apply.d.ts +39 -0
  35. package/dist/importer/apply.d.ts.map +1 -0
  36. package/dist/importer/apply.js +588 -0
  37. package/dist/importer/apply.js.map +1 -0
  38. package/dist/importer/lifecycle.d.ts +52 -0
  39. package/dist/importer/lifecycle.d.ts.map +1 -0
  40. package/dist/importer/lifecycle.js +265 -0
  41. package/dist/importer/lifecycle.js.map +1 -0
  42. package/dist/importer/run-import.d.ts +40 -0
  43. package/dist/importer/run-import.d.ts.map +1 -0
  44. package/dist/importer/run-import.js +176 -0
  45. package/dist/importer/run-import.js.map +1 -0
  46. package/dist/importer/source.d.ts +49 -0
  47. package/dist/importer/source.d.ts.map +1 -0
  48. package/dist/importer/source.js +147 -0
  49. package/dist/importer/source.js.map +1 -0
  50. package/dist/importer/types.d.ts +69 -0
  51. package/dist/importer/types.d.ts.map +1 -0
  52. package/dist/importer/types.js +13 -0
  53. package/dist/importer/types.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +17 -0
  57. package/dist/index.js.map +1 -0
  58. package/package.json +70 -4
  59. package/src/cli.ts +287 -0
  60. package/src/doctor/bands.ts +66 -0
  61. package/src/doctor/database.ts +130 -0
  62. package/src/doctor/plugins.ts +216 -0
  63. package/src/doctor/run-doctor.ts +278 -0
  64. package/src/doctor/source-scan.ts +148 -0
  65. package/src/doctor/types.ts +92 -0
  66. package/src/doctor/users.ts +207 -0
  67. package/src/importer/apply.ts +731 -0
  68. package/src/importer/lifecycle.ts +328 -0
  69. package/src/importer/run-import.ts +237 -0
  70. package/src/importer/source.ts +211 -0
  71. package/src/importer/types.ts +96 -0
  72. 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'