@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.
- 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,731 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import { readFlatRows, openSource, readSourceUsers, type SourceUser } from './source.js'
|
|
3
|
+
import {
|
|
4
|
+
defaultTargetSlugs,
|
|
5
|
+
type CreatedRow,
|
|
6
|
+
type TargetSlugMap,
|
|
7
|
+
type FieldWrite,
|
|
8
|
+
type ImportManifest,
|
|
9
|
+
type ImportPlan,
|
|
10
|
+
type ImportResult,
|
|
11
|
+
type PlanRow,
|
|
12
|
+
} from './types.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Apply side (issue #50): copy payload-auth rows into the
|
|
16
|
+
* payload-auth-link collections through the Payload Local API.
|
|
17
|
+
*
|
|
18
|
+
* - Password hashes, OAuth tokens, API-key hashes, and jwks carry over
|
|
19
|
+
* VERBATIM (payload-auth used Better Auth's own hashing).
|
|
20
|
+
* - `linkable` + `linkableEmail` are granted from VERIFIED emails only;
|
|
21
|
+
* unverified users are reported by the plan, never linked.
|
|
22
|
+
* - Sessions default to `carry` so nobody is logged out: the carried
|
|
23
|
+
* rows keep their tokens, and with the same `advanced.cookiePrefix`
|
|
24
|
+
* the existing cookies keep working.
|
|
25
|
+
* - Idempotent and resumable: every target row carries a deterministic
|
|
26
|
+
* `baId` derived from the source id (`acct-<source id>`), so a
|
|
27
|
+
* re-run after a crash finds its own earlier rows and skips them.
|
|
28
|
+
* - Every created row and every field write lands in the manifest, so
|
|
29
|
+
* `rollback` is exact and the source tables stay untouched.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type ApplyOptions = {
|
|
33
|
+
readonly payload: Payload
|
|
34
|
+
readonly sourceConnectionString: string
|
|
35
|
+
readonly apply: boolean
|
|
36
|
+
readonly sessions: 'carry' | 'drop'
|
|
37
|
+
readonly manifest: ImportManifest | null
|
|
38
|
+
/** Written (merged) on `apply`; required when `apply` is true. */
|
|
39
|
+
readonly manifestPath: string
|
|
40
|
+
readonly sourceCounts: Readonly<Record<string, number>>
|
|
41
|
+
readonly sourceDatabase: string
|
|
42
|
+
/** Target collection slugs; defaults must match the engine wiring. */
|
|
43
|
+
readonly slugs?: Readonly<Record<string, string>> | undefined
|
|
44
|
+
/** Injectable clock for deterministic `linkedAt` stamps. */
|
|
45
|
+
readonly now?: () => Date
|
|
46
|
+
readonly log?: (message: string) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const slugsOf = (overrides?: Readonly<Record<string, string>>): TargetSlugMap => ({
|
|
50
|
+
...defaultTargetSlugs,
|
|
51
|
+
...(overrides ?? {}),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
function textOrNull(value: unknown): string | undefined {
|
|
55
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function numberOrNull(value: unknown): number | undefined {
|
|
59
|
+
if (value === null || value === undefined) {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
const numeric = typeof value === 'number' ? value : Number(value)
|
|
63
|
+
return Number.isFinite(numeric) ? numeric : undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Find an existing target row by `baId` — the resumable dedupe key. */
|
|
67
|
+
async function findByBaId(payload: Payload, collection: string, baId: string): Promise<boolean> {
|
|
68
|
+
const found = await payload.find({
|
|
69
|
+
collection,
|
|
70
|
+
where: { baId: { equals: baId } },
|
|
71
|
+
limit: 1,
|
|
72
|
+
depth: 0,
|
|
73
|
+
pagination: false,
|
|
74
|
+
overrideAccess: true,
|
|
75
|
+
})
|
|
76
|
+
return found.docs.length > 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Create one target row, deduped by its deterministic baId. */
|
|
80
|
+
async function createDeduped(
|
|
81
|
+
payload: Payload,
|
|
82
|
+
collection: string,
|
|
83
|
+
baId: string,
|
|
84
|
+
data: Record<string, unknown>,
|
|
85
|
+
created: CreatedRow[],
|
|
86
|
+
sourceKey: string
|
|
87
|
+
): Promise<boolean> {
|
|
88
|
+
if (await findByBaId(payload, collection, baId)) {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
const doc: unknown = await payload.create({
|
|
92
|
+
collection,
|
|
93
|
+
data: { baId, ...data },
|
|
94
|
+
overrideAccess: true,
|
|
95
|
+
})
|
|
96
|
+
if (typeof doc === 'object' && doc !== null && 'id' in doc) {
|
|
97
|
+
const id: unknown = doc['id']
|
|
98
|
+
if (typeof id === 'string' || typeof id === 'number') {
|
|
99
|
+
created.push({ collection, id, sourceKey })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Capture a host field's current value for the rollback manifest. */
|
|
106
|
+
async function captureBefore(
|
|
107
|
+
payload: Payload,
|
|
108
|
+
hostId: number,
|
|
109
|
+
field: string
|
|
110
|
+
): Promise<string | null> {
|
|
111
|
+
const doc: unknown = await payload.findByID({
|
|
112
|
+
collection: 'users',
|
|
113
|
+
id: hostId,
|
|
114
|
+
depth: 0,
|
|
115
|
+
overrideAccess: true,
|
|
116
|
+
})
|
|
117
|
+
if (typeof doc !== 'object' || doc === null || !(field in doc)) {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
return JSON.stringify((doc as Record<string, unknown>)[field] ?? null)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Identity rows pre-populated on the host user (verified emails only). */
|
|
124
|
+
function identityRows(
|
|
125
|
+
user: SourceUser,
|
|
126
|
+
now: () => Date
|
|
127
|
+
): { providerId: string; providerUserId: string; email: string; linkedAt: string }[] {
|
|
128
|
+
const rows: { providerId: string; providerUserId: string; email: string; linkedAt: string }[] = []
|
|
129
|
+
for (const account of user.accounts) {
|
|
130
|
+
if (account.providerId === 'credential') {
|
|
131
|
+
// A password credential is not an identity: the engine signs the
|
|
132
|
+
// user in through ba-accounts; no link is claimed.
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
rows.push({
|
|
136
|
+
providerId: account.providerId,
|
|
137
|
+
providerUserId: account.accountId,
|
|
138
|
+
email: user.email,
|
|
139
|
+
linkedAt: now().toISOString(),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
return rows
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function applyImport(options: ApplyOptions): Promise<ImportResult> {
|
|
146
|
+
const {
|
|
147
|
+
payload,
|
|
148
|
+
sourceConnectionString,
|
|
149
|
+
apply,
|
|
150
|
+
sessions,
|
|
151
|
+
log = () => {},
|
|
152
|
+
now = () => new Date(),
|
|
153
|
+
} = options
|
|
154
|
+
const slugs = slugsOf(options.slugs)
|
|
155
|
+
const created: CreatedRow[] = [...(options.manifest?.created ?? [])]
|
|
156
|
+
const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
|
|
157
|
+
const totals: Record<string, number> = { ...(options.manifest?.totals ?? {}) }
|
|
158
|
+
const planRows: PlanRow[] = []
|
|
159
|
+
const unmappable: { model: string; key: string; reason: string }[] = []
|
|
160
|
+
let createdCount = 0
|
|
161
|
+
let skippedCount = 0
|
|
162
|
+
let fieldWriteCount = 0
|
|
163
|
+
|
|
164
|
+
const record = (row: PlanRow): void => {
|
|
165
|
+
planRows.push(row)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const client = await openSource(sourceConnectionString)
|
|
169
|
+
try {
|
|
170
|
+
const users = await readSourceUsers(client)
|
|
171
|
+
const seenUsers = new Set<string>()
|
|
172
|
+
|
|
173
|
+
// --- 1. ba-users + host field writes ---------------------------------
|
|
174
|
+
for (const user of users) {
|
|
175
|
+
const baId = String(user.id)
|
|
176
|
+
if (seenUsers.has(baId)) {
|
|
177
|
+
unmappable.push({ model: 'user', key: baId, reason: 'duplicate source id' })
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
seenUsers.add(baId)
|
|
181
|
+
totals.user = (totals.user ?? 0) + 1
|
|
182
|
+
const isNew = apply
|
|
183
|
+
? await createDeduped(
|
|
184
|
+
payload,
|
|
185
|
+
slugs.user,
|
|
186
|
+
baId,
|
|
187
|
+
{
|
|
188
|
+
email: user.email,
|
|
189
|
+
name: user.name ?? '',
|
|
190
|
+
emailVerified: user.emailVerified,
|
|
191
|
+
...(user.image === null ? {} : { image: user.image }),
|
|
192
|
+
createdAt: user.createdAt.toISOString(),
|
|
193
|
+
updatedAt: user.updatedAt.toISOString(),
|
|
194
|
+
},
|
|
195
|
+
created,
|
|
196
|
+
`user:${baId}`
|
|
197
|
+
)
|
|
198
|
+
: false
|
|
199
|
+
if (apply) {
|
|
200
|
+
if (isNew) {
|
|
201
|
+
createdCount += 1
|
|
202
|
+
} else {
|
|
203
|
+
skippedCount += 1
|
|
204
|
+
}
|
|
205
|
+
// Host-side writes on the EXISTING Payload user (the source
|
|
206
|
+
// layout's user id IS the host id): identities, roles, and the
|
|
207
|
+
// verified-email linking proof. Captured `before` values make
|
|
208
|
+
// rollback exact.
|
|
209
|
+
const hostId = Number(baId)
|
|
210
|
+
if (Number.isSafeInteger(hostId)) {
|
|
211
|
+
const writes: Record<string, unknown> = {
|
|
212
|
+
identities: identityRows(user, now),
|
|
213
|
+
linkable: user.emailVerified,
|
|
214
|
+
}
|
|
215
|
+
const fields: readonly string[] = ['identities', 'roles', 'linkable', 'linkableEmail']
|
|
216
|
+
for (const field of fields) {
|
|
217
|
+
fieldWrites.push({
|
|
218
|
+
collection: 'users',
|
|
219
|
+
id: hostId,
|
|
220
|
+
field,
|
|
221
|
+
before: await captureBefore(payload, hostId, field),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
fieldWriteCount += fields.length
|
|
225
|
+
if (user.roles.length > 0) {
|
|
226
|
+
// Stored role values pass through unchanged (issue #56: the
|
|
227
|
+
// storage edge owns translation; the map is the app's).
|
|
228
|
+
writes.roles = user.roles
|
|
229
|
+
}
|
|
230
|
+
if (user.emailVerified) {
|
|
231
|
+
writes.linkableEmail = user.email
|
|
232
|
+
}
|
|
233
|
+
await payload.update({
|
|
234
|
+
collection: 'users',
|
|
235
|
+
id: hostId,
|
|
236
|
+
data: writes,
|
|
237
|
+
overrideAccess: true,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
record({
|
|
242
|
+
model: 'user',
|
|
243
|
+
sourceTable: 'users',
|
|
244
|
+
targetCollection: slugs.user,
|
|
245
|
+
rows: 1,
|
|
246
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
247
|
+
unmappable: [],
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- 2. accounts (hashes verbatim) ------------------------------------
|
|
252
|
+
for (const user of users) {
|
|
253
|
+
for (const account of user.accounts) {
|
|
254
|
+
totals.account = (totals.account ?? 0) + 1
|
|
255
|
+
const baId = `acct-${account.id}`
|
|
256
|
+
const isNew = apply
|
|
257
|
+
? await createDeduped(
|
|
258
|
+
payload,
|
|
259
|
+
slugs.account,
|
|
260
|
+
baId,
|
|
261
|
+
{
|
|
262
|
+
accountId: account.accountId,
|
|
263
|
+
providerId: account.providerId,
|
|
264
|
+
userId: String(user.id),
|
|
265
|
+
...(account.password === null ? {} : { password: account.password }),
|
|
266
|
+
...(account.accessToken === null ? {} : { accessToken: account.accessToken }),
|
|
267
|
+
...(account.refreshToken === null ? {} : { refreshToken: account.refreshToken }),
|
|
268
|
+
...(account.idToken === null ? {} : { idToken: account.idToken }),
|
|
269
|
+
...(account.scope === null ? {} : { scope: account.scope }),
|
|
270
|
+
...(account.accessTokenExpiresAt === null
|
|
271
|
+
? {}
|
|
272
|
+
: { accessTokenExpiresAt: account.accessTokenExpiresAt.toISOString() }),
|
|
273
|
+
...(account.refreshTokenExpiresAt === null
|
|
274
|
+
? {}
|
|
275
|
+
: { refreshTokenExpiresAt: account.refreshTokenExpiresAt.toISOString() }),
|
|
276
|
+
},
|
|
277
|
+
created,
|
|
278
|
+
`account:${account.id}`
|
|
279
|
+
)
|
|
280
|
+
: false
|
|
281
|
+
if (apply) {
|
|
282
|
+
if (isNew) {
|
|
283
|
+
createdCount += 1
|
|
284
|
+
} else {
|
|
285
|
+
skippedCount += 1
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
record({
|
|
289
|
+
model: 'account',
|
|
290
|
+
sourceTable: 'accounts',
|
|
291
|
+
targetCollection: slugs.account,
|
|
292
|
+
rows: 1,
|
|
293
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
294
|
+
unmappable: [],
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- 3. sessions (carry keeps every token) -----------------------------
|
|
300
|
+
if (sessions === 'carry') {
|
|
301
|
+
const rows = await readFlatRows(client, 'sessions', [
|
|
302
|
+
'id',
|
|
303
|
+
'user_id',
|
|
304
|
+
'token',
|
|
305
|
+
'expires_at',
|
|
306
|
+
'ip_address',
|
|
307
|
+
'user_agent',
|
|
308
|
+
'created_at',
|
|
309
|
+
'updated_at',
|
|
310
|
+
])
|
|
311
|
+
for (const row of rows) {
|
|
312
|
+
totals.session = (totals.session ?? 0) + 1
|
|
313
|
+
const baId = `sess-${String(row.id)}`
|
|
314
|
+
const isNew = apply
|
|
315
|
+
? await createDeduped(
|
|
316
|
+
payload,
|
|
317
|
+
slugs.session,
|
|
318
|
+
baId,
|
|
319
|
+
{
|
|
320
|
+
userId: String(row.user_id),
|
|
321
|
+
token: String(row.token),
|
|
322
|
+
expiresAt:
|
|
323
|
+
row.expires_at instanceof Date
|
|
324
|
+
? row.expires_at.toISOString()
|
|
325
|
+
: now().toISOString(),
|
|
326
|
+
...(textOrNull(row.ip_address) === undefined
|
|
327
|
+
? {}
|
|
328
|
+
: { ipAddress: textOrNull(row.ip_address) }),
|
|
329
|
+
...(textOrNull(row.user_agent) === undefined
|
|
330
|
+
? {}
|
|
331
|
+
: { userAgent: textOrNull(row.user_agent) }),
|
|
332
|
+
...(row.created_at instanceof Date
|
|
333
|
+
? { createdAt: row.created_at.toISOString() }
|
|
334
|
+
: {}),
|
|
335
|
+
...(row.updated_at instanceof Date
|
|
336
|
+
? { updatedAt: row.updated_at.toISOString() }
|
|
337
|
+
: {}),
|
|
338
|
+
},
|
|
339
|
+
created,
|
|
340
|
+
`session:${String(row.id)}`
|
|
341
|
+
)
|
|
342
|
+
: false
|
|
343
|
+
if (apply) {
|
|
344
|
+
if (isNew) {
|
|
345
|
+
createdCount += 1
|
|
346
|
+
} else {
|
|
347
|
+
skippedCount += 1
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
record({
|
|
351
|
+
model: 'session',
|
|
352
|
+
sourceTable: 'sessions',
|
|
353
|
+
targetCollection: slugs.session,
|
|
354
|
+
rows: 1,
|
|
355
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
356
|
+
unmappable: [],
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- 4. verifications ---------------------------------------------------
|
|
362
|
+
const verifications = await readFlatRows(client, 'verifications', [
|
|
363
|
+
'id',
|
|
364
|
+
'identifier',
|
|
365
|
+
'value',
|
|
366
|
+
'expires_at',
|
|
367
|
+
])
|
|
368
|
+
for (const row of verifications) {
|
|
369
|
+
totals.verification = (totals.verification ?? 0) + 1
|
|
370
|
+
const baId = `ver-${String(row.id)}`
|
|
371
|
+
const isNew = apply
|
|
372
|
+
? await createDeduped(
|
|
373
|
+
payload,
|
|
374
|
+
slugs.verification,
|
|
375
|
+
baId,
|
|
376
|
+
{
|
|
377
|
+
identifier: String(row.identifier),
|
|
378
|
+
value: String(row.value),
|
|
379
|
+
expiresAt:
|
|
380
|
+
row.expires_at instanceof Date ? row.expires_at.toISOString() : now().toISOString(),
|
|
381
|
+
},
|
|
382
|
+
created,
|
|
383
|
+
`verification:${String(row.id)}`
|
|
384
|
+
)
|
|
385
|
+
: false
|
|
386
|
+
if (apply) {
|
|
387
|
+
if (isNew) {
|
|
388
|
+
createdCount += 1
|
|
389
|
+
} else {
|
|
390
|
+
skippedCount += 1
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
record({
|
|
394
|
+
model: 'verification',
|
|
395
|
+
sourceTable: 'verifications',
|
|
396
|
+
targetCollection: slugs.verification,
|
|
397
|
+
rows: 1,
|
|
398
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
399
|
+
unmappable: [],
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// --- 5. api keys (hash, prefix, metadata, rate-limit state) --------------
|
|
404
|
+
const apiKeys = await readFlatRows(client, 'api_keys', [
|
|
405
|
+
'id',
|
|
406
|
+
'config_id',
|
|
407
|
+
'name',
|
|
408
|
+
'start',
|
|
409
|
+
'prefix',
|
|
410
|
+
'key',
|
|
411
|
+
// payload-auth stores the satellite's userId as referenceId
|
|
412
|
+
// (the #49 field map's source side).
|
|
413
|
+
'reference_id',
|
|
414
|
+
'refill_interval',
|
|
415
|
+
'refill_amount',
|
|
416
|
+
'last_refill_at',
|
|
417
|
+
'enabled',
|
|
418
|
+
'rate_limit_enabled',
|
|
419
|
+
'rate_limit_time_window',
|
|
420
|
+
'rate_limit_max',
|
|
421
|
+
'request_count',
|
|
422
|
+
'remaining',
|
|
423
|
+
'last_request',
|
|
424
|
+
'expires_at',
|
|
425
|
+
'permissions',
|
|
426
|
+
'metadata',
|
|
427
|
+
])
|
|
428
|
+
for (const row of apiKeys) {
|
|
429
|
+
totals.apikey = (totals.apikey ?? 0) + 1
|
|
430
|
+
const baId = `key-${String(row.id)}`
|
|
431
|
+
const isNew = apply
|
|
432
|
+
? await createDeduped(
|
|
433
|
+
payload,
|
|
434
|
+
slugs.apikey,
|
|
435
|
+
baId,
|
|
436
|
+
{
|
|
437
|
+
configId: textOrNull(row.config_id) ?? '',
|
|
438
|
+
...(textOrNull(row.name) === undefined ? {} : { name: textOrNull(row.name) }),
|
|
439
|
+
...(textOrNull(row.start) === undefined ? {} : { start: textOrNull(row.start) }),
|
|
440
|
+
...(textOrNull(row.prefix) === undefined ? {} : { prefix: textOrNull(row.prefix) }),
|
|
441
|
+
key: String(row.key),
|
|
442
|
+
// The satellite's user reference arrives as referenceId in
|
|
443
|
+
// payload-auth layouts; BA 1.7's own schema names it the
|
|
444
|
+
// same, so the target field is offered both names.
|
|
445
|
+
userId: String(row.reference_id),
|
|
446
|
+
referenceId: String(row.reference_id),
|
|
447
|
+
...(numberOrNull(row.refill_interval) === undefined
|
|
448
|
+
? {}
|
|
449
|
+
: { refillInterval: numberOrNull(row.refill_interval) }),
|
|
450
|
+
...(numberOrNull(row.refill_amount) === undefined
|
|
451
|
+
? {}
|
|
452
|
+
: { refillAmount: numberOrNull(row.refill_amount) }),
|
|
453
|
+
...(row.last_refill_at instanceof Date
|
|
454
|
+
? { lastRefillAt: row.last_refill_at.toISOString() }
|
|
455
|
+
: {}),
|
|
456
|
+
...(row.enabled === null ? {} : { enabled: row.enabled === true }),
|
|
457
|
+
...(row.rate_limit_enabled === null
|
|
458
|
+
? {}
|
|
459
|
+
: { rateLimitEnabled: row.rate_limit_enabled === true }),
|
|
460
|
+
...(numberOrNull(row.rate_limit_time_window) === undefined
|
|
461
|
+
? {}
|
|
462
|
+
: { rateLimitTimeWindow: numberOrNull(row.rate_limit_time_window) }),
|
|
463
|
+
...(numberOrNull(row.rate_limit_max) === undefined
|
|
464
|
+
? {}
|
|
465
|
+
: { rateLimitMax: numberOrNull(row.rate_limit_max) }),
|
|
466
|
+
...(numberOrNull(row.request_count) === undefined
|
|
467
|
+
? {}
|
|
468
|
+
: { requestCount: numberOrNull(row.request_count) }),
|
|
469
|
+
...(numberOrNull(row.remaining) === undefined
|
|
470
|
+
? {}
|
|
471
|
+
: { remaining: numberOrNull(row.remaining) }),
|
|
472
|
+
...(row.last_request instanceof Date
|
|
473
|
+
? { lastRequest: row.last_request.toISOString() }
|
|
474
|
+
: {}),
|
|
475
|
+
...(row.expires_at instanceof Date
|
|
476
|
+
? { expiresAt: row.expires_at.toISOString() }
|
|
477
|
+
: {}),
|
|
478
|
+
...(textOrNull(row.permissions) === undefined
|
|
479
|
+
? {}
|
|
480
|
+
: { permissions: textOrNull(row.permissions) }),
|
|
481
|
+
...(textOrNull(row.metadata) === undefined
|
|
482
|
+
? {}
|
|
483
|
+
: { metadata: textOrNull(row.metadata) }),
|
|
484
|
+
},
|
|
485
|
+
created,
|
|
486
|
+
`apikey:${String(row.id)}`
|
|
487
|
+
)
|
|
488
|
+
: false
|
|
489
|
+
if (apply) {
|
|
490
|
+
if (isNew) {
|
|
491
|
+
createdCount += 1
|
|
492
|
+
} else {
|
|
493
|
+
skippedCount += 1
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
record({
|
|
497
|
+
model: 'apikey',
|
|
498
|
+
sourceTable: 'api_keys',
|
|
499
|
+
targetCollection: slugs.apikey,
|
|
500
|
+
rows: 1,
|
|
501
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
502
|
+
unmappable: [],
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// --- 6. jwks --------------------------------------------------------------
|
|
507
|
+
const jwks = await readFlatRows(client, 'jwks', ['id', 'public_key', 'private_key'])
|
|
508
|
+
for (const row of jwks) {
|
|
509
|
+
totals.jwks = (totals.jwks ?? 0) + 1
|
|
510
|
+
const baId = `jwks-${String(row.id)}`
|
|
511
|
+
const isNew = apply
|
|
512
|
+
? await createDeduped(
|
|
513
|
+
payload,
|
|
514
|
+
slugs.jwks,
|
|
515
|
+
baId,
|
|
516
|
+
{
|
|
517
|
+
publicKey: String(row.public_key),
|
|
518
|
+
privateKey: String(row.private_key),
|
|
519
|
+
},
|
|
520
|
+
created,
|
|
521
|
+
`jwks:${String(row.id)}`
|
|
522
|
+
)
|
|
523
|
+
: false
|
|
524
|
+
if (apply) {
|
|
525
|
+
if (isNew) {
|
|
526
|
+
createdCount += 1
|
|
527
|
+
} else {
|
|
528
|
+
skippedCount += 1
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
record({
|
|
532
|
+
model: 'jwks',
|
|
533
|
+
sourceTable: 'jwks',
|
|
534
|
+
targetCollection: slugs.jwks,
|
|
535
|
+
rows: 1,
|
|
536
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
537
|
+
unmappable: [],
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// --- 7. organization plugin rows ---------------------------------------
|
|
542
|
+
const organizations = await readFlatRows(client, 'organizations', [
|
|
543
|
+
'id',
|
|
544
|
+
'name',
|
|
545
|
+
'slug',
|
|
546
|
+
'logo',
|
|
547
|
+
'metadata',
|
|
548
|
+
])
|
|
549
|
+
for (const row of organizations) {
|
|
550
|
+
totals.organization = (totals.organization ?? 0) + 1
|
|
551
|
+
const baId = `org-${String(row.id)}`
|
|
552
|
+
const isNew = apply
|
|
553
|
+
? await createDeduped(
|
|
554
|
+
payload,
|
|
555
|
+
slugs.organization,
|
|
556
|
+
baId,
|
|
557
|
+
{
|
|
558
|
+
name: String(row.name),
|
|
559
|
+
slug: String(row.slug),
|
|
560
|
+
...(textOrNull(row.logo) === undefined ? {} : { logo: textOrNull(row.logo) }),
|
|
561
|
+
...(textOrNull(row.metadata) === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: { metadata: textOrNull(row.metadata) }),
|
|
564
|
+
},
|
|
565
|
+
created,
|
|
566
|
+
`organization:${String(row.id)}`
|
|
567
|
+
)
|
|
568
|
+
: false
|
|
569
|
+
if (apply) {
|
|
570
|
+
if (isNew) {
|
|
571
|
+
createdCount += 1
|
|
572
|
+
} else {
|
|
573
|
+
skippedCount += 1
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
record({
|
|
577
|
+
model: 'organization',
|
|
578
|
+
sourceTable: 'organizations',
|
|
579
|
+
targetCollection: slugs.organization,
|
|
580
|
+
rows: 1,
|
|
581
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
582
|
+
unmappable: [],
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const members = await readFlatRows(client, 'members', [
|
|
587
|
+
'id',
|
|
588
|
+
'organization_id',
|
|
589
|
+
'user_id',
|
|
590
|
+
'role',
|
|
591
|
+
])
|
|
592
|
+
for (const row of members) {
|
|
593
|
+
totals.member = (totals.member ?? 0) + 1
|
|
594
|
+
const baId = `member-${String(row.id)}`
|
|
595
|
+
const isNew = apply
|
|
596
|
+
? await createDeduped(
|
|
597
|
+
payload,
|
|
598
|
+
slugs.member,
|
|
599
|
+
baId,
|
|
600
|
+
{
|
|
601
|
+
organizationId: `org-${String(row.organization_id)}`,
|
|
602
|
+
userId: String(row.user_id),
|
|
603
|
+
...(textOrNull(row.role) === undefined ? {} : { role: textOrNull(row.role) }),
|
|
604
|
+
},
|
|
605
|
+
created,
|
|
606
|
+
`member:${String(row.id)}`
|
|
607
|
+
)
|
|
608
|
+
: false
|
|
609
|
+
if (apply) {
|
|
610
|
+
if (isNew) {
|
|
611
|
+
createdCount += 1
|
|
612
|
+
} else {
|
|
613
|
+
skippedCount += 1
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
record({
|
|
617
|
+
model: 'member',
|
|
618
|
+
sourceTable: 'members',
|
|
619
|
+
targetCollection: slugs.member,
|
|
620
|
+
rows: 1,
|
|
621
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
622
|
+
unmappable: [],
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const invitations = await readFlatRows(client, 'invitations', [
|
|
627
|
+
'id',
|
|
628
|
+
'organization_id',
|
|
629
|
+
'email',
|
|
630
|
+
'role',
|
|
631
|
+
'status',
|
|
632
|
+
'expires_at',
|
|
633
|
+
'inviter_id',
|
|
634
|
+
])
|
|
635
|
+
for (const row of invitations) {
|
|
636
|
+
totals.invitation = (totals.invitation ?? 0) + 1
|
|
637
|
+
const baId = `inv-${String(row.id)}`
|
|
638
|
+
const isNew = apply
|
|
639
|
+
? await createDeduped(
|
|
640
|
+
payload,
|
|
641
|
+
slugs.invitation,
|
|
642
|
+
baId,
|
|
643
|
+
{
|
|
644
|
+
organizationId: `org-${String(row.organization_id)}`,
|
|
645
|
+
email: String(row.email),
|
|
646
|
+
...(textOrNull(row.role) === undefined ? {} : { role: textOrNull(row.role) }),
|
|
647
|
+
...(textOrNull(row.status) === undefined ? {} : { status: textOrNull(row.status) }),
|
|
648
|
+
...(row.expires_at instanceof Date
|
|
649
|
+
? { expiresAt: row.expires_at.toISOString() }
|
|
650
|
+
: {}),
|
|
651
|
+
...(typeof row.inviter_id === 'number' ? { inviterId: String(row.inviter_id) } : {}),
|
|
652
|
+
},
|
|
653
|
+
created,
|
|
654
|
+
`invitation:${String(row.id)}`
|
|
655
|
+
)
|
|
656
|
+
: false
|
|
657
|
+
if (apply) {
|
|
658
|
+
if (isNew) {
|
|
659
|
+
createdCount += 1
|
|
660
|
+
} else {
|
|
661
|
+
skippedCount += 1
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
record({
|
|
665
|
+
model: 'invitation',
|
|
666
|
+
sourceTable: 'invitations',
|
|
667
|
+
targetCollection: slugs.invitation,
|
|
668
|
+
rows: 1,
|
|
669
|
+
skipped: isNew ? 0 : apply ? 1 : 0,
|
|
670
|
+
unmappable: [],
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
log(
|
|
675
|
+
`plan: ${planRows.length} row group(s), ${createdCount} created, ${skippedCount} skipped, ${fieldWriteCount} field writes`
|
|
676
|
+
)
|
|
677
|
+
if (apply) {
|
|
678
|
+
// The manifest is the run's contract: merge this pass onto any
|
|
679
|
+
// resumed run's rows and write it back (issue #50).
|
|
680
|
+
const manifest: ImportManifest = {
|
|
681
|
+
version: 1,
|
|
682
|
+
createdAt: options.manifest?.createdAt ?? now().toISOString(),
|
|
683
|
+
sourceDatabase: options.sourceDatabase,
|
|
684
|
+
options: { sessions },
|
|
685
|
+
created,
|
|
686
|
+
fieldWrites,
|
|
687
|
+
finalized: options.manifest?.finalized ?? false,
|
|
688
|
+
sourceCounts: options.sourceCounts,
|
|
689
|
+
totals: {
|
|
690
|
+
...options.sourceCounts,
|
|
691
|
+
...(options.manifest?.totals ?? {}),
|
|
692
|
+
...totals,
|
|
693
|
+
},
|
|
694
|
+
}
|
|
695
|
+
const { writeManifest } = await import('./lifecycle.js')
|
|
696
|
+
writeManifest(manifest, options.manifestPath)
|
|
697
|
+
}
|
|
698
|
+
const plan: ImportPlan = {
|
|
699
|
+
rows: planRows,
|
|
700
|
+
unmappable,
|
|
701
|
+
sessions,
|
|
702
|
+
warnings: [],
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
created: createdCount,
|
|
706
|
+
skipped: skippedCount,
|
|
707
|
+
fieldWrites: fieldWriteCount,
|
|
708
|
+
plan,
|
|
709
|
+
}
|
|
710
|
+
} finally {
|
|
711
|
+
await client.end()
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Collapse per-row plan entries into one line per model (dry-run print). */
|
|
716
|
+
export function summarizePlanRows(rows: readonly PlanRow[]): PlanRow[] {
|
|
717
|
+
const byModel = new Map<string, PlanRow>()
|
|
718
|
+
for (const row of rows) {
|
|
719
|
+
const existing = byModel.get(row.model)
|
|
720
|
+
if (existing === undefined) {
|
|
721
|
+
byModel.set(row.model, { ...row })
|
|
722
|
+
continue
|
|
723
|
+
}
|
|
724
|
+
byModel.set(row.model, {
|
|
725
|
+
...existing,
|
|
726
|
+
rows: existing.rows + row.rows,
|
|
727
|
+
skipped: existing.skipped + row.skipped,
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
return [...byModel.values()]
|
|
731
|
+
}
|