@supertokens-plugins/rownd-nodejs 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 +164 -0
- package/package.json +50 -0
- package/scripts/bulkMigrate.ts +450 -0
- package/scripts/config.yaml +27 -0
- package/src/constants.ts +5 -0
- package/src/errors.ts +13 -0
- package/src/index.ts +7 -0
- package/src/logger.ts +7 -0
- package/src/plugin.test.ts +790 -0
- package/src/plugin.ts +189 -0
- package/src/pluginImplementation.ts +187 -0
- package/src/telemetry/axiomTelemetryClient.ts +34 -0
- package/src/telemetry/createTelemetryClient.ts +87 -0
- package/src/telemetry/openTelemetryClient.ts +32 -0
- package/src/types.ts +124 -0
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# SuperTokens Rownd User Migration Plugin
|
|
2
|
+
|
|
3
|
+
This plugin facilitates the migration of users and sessions from Rownd to SuperTokens.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @supertokens-plugins/rownd-nodejs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### Backend Configuration
|
|
14
|
+
|
|
15
|
+
Initialize the plugin in your SuperTokens backend configuration.
|
|
16
|
+
|
|
17
|
+
> [!IMPORTANT]
|
|
18
|
+
> This plugin requires the `Session` and `UserMetadata` recipes to be initialized in your SuperTokens configuration.
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import SuperTokens from "supertokens-node";
|
|
22
|
+
import Session from "supertokens-node/recipe/session";
|
|
23
|
+
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
|
24
|
+
import RowndMigrationPlugin from "@supertokens-plugins/rownd-nodejs";
|
|
25
|
+
|
|
26
|
+
SuperTokens.init({
|
|
27
|
+
appInfo: {
|
|
28
|
+
// your app info
|
|
29
|
+
},
|
|
30
|
+
recipeList: [
|
|
31
|
+
Session.init(),
|
|
32
|
+
UserMetadata.init(),
|
|
33
|
+
// your other recipes
|
|
34
|
+
],
|
|
35
|
+
experimental: {
|
|
36
|
+
plugins: [
|
|
37
|
+
RowndMigrationPlugin.init({
|
|
38
|
+
rowndAppKey: process.env.ROWND_APP_KEY,
|
|
39
|
+
rowndAppSecret: process.env.ROWND_APP_SECRET,
|
|
40
|
+
enableDebugLogs: process.env.ENABLE_DEBUG_LOGS === "true",
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API Endpoint
|
|
48
|
+
|
|
49
|
+
The plugin exposes a single endpoint:
|
|
50
|
+
|
|
51
|
+
> [!IMPORTANT]
|
|
52
|
+
> The plugin always migrates users and sessions into the `public` tenant.
|
|
53
|
+
> Rownd users with multiple supported login methods are rejected unless SuperTokens account linking is enabled in the target environment.
|
|
54
|
+
|
|
55
|
+
### Migrate
|
|
56
|
+
|
|
57
|
+
- **POST** `/plugin/rownd/migrate`
|
|
58
|
+
- **Headers**: `Authorization: Bearer <Rownd_JWT>`
|
|
59
|
+
- **Description**: Validates the Rownd JWT, ensures the user is migrated to SuperTokens in the `public` tenant, syncs Rownd user data to SuperTokens UserMetadata, and then creates a new SuperTokens session for that user.
|
|
60
|
+
|
|
61
|
+
## Debug Logging
|
|
62
|
+
|
|
63
|
+
Set `enableDebugLogs: true` in the plugin config to enable debug logging.
|
|
64
|
+
|
|
65
|
+
## Telemetry
|
|
66
|
+
|
|
67
|
+
Telemetry is optional. If `telemetry` is omitted from the plugin config, no telemetry is emitted.
|
|
68
|
+
|
|
69
|
+
The plugin emits exactly one telemetry event per `/migrate` call result.
|
|
70
|
+
|
|
71
|
+
### Event shape
|
|
72
|
+
|
|
73
|
+
Each event includes endpoint outcome data only (not step-by-step events), including:
|
|
74
|
+
|
|
75
|
+
- `operation`: `migrate`
|
|
76
|
+
- `outcome`: `success` or `error`
|
|
77
|
+
- `durationMs`
|
|
78
|
+
- `tenantId` (when available)
|
|
79
|
+
- `rowndUserId` (when available)
|
|
80
|
+
- `superTokensUserId` (when available)
|
|
81
|
+
- `migrationState`: `already-migrated` or `imported-during-request` (when available)
|
|
82
|
+
- for errors: `error.message` and `error.name`
|
|
83
|
+
|
|
84
|
+
> [!NOTE]
|
|
85
|
+
> Telemetry failures never fail migration endpoints. Errors in telemetry reporting are swallowed.
|
|
86
|
+
|
|
87
|
+
### Provider: OpenTelemetry
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
RowndMigrationPlugin.init({
|
|
91
|
+
rowndAppKey: process.env.ROWND_APP_KEY,
|
|
92
|
+
rowndAppSecret: process.env.ROWND_APP_SECRET,
|
|
93
|
+
telemetry: {
|
|
94
|
+
provider: "opentelemetry",
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
> [!IMPORTANT]
|
|
100
|
+
> This plugin uses `@opentelemetry/api` only. You still need to initialize OpenTelemetry SDK/exporters in your app for spans to be exported.
|
|
101
|
+
|
|
102
|
+
### Provider: Axiom
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
RowndMigrationPlugin.init({
|
|
106
|
+
rowndAppKey: process.env.ROWND_APP_KEY,
|
|
107
|
+
rowndAppSecret: process.env.ROWND_APP_SECRET,
|
|
108
|
+
telemetry: {
|
|
109
|
+
provider: "axiom",
|
|
110
|
+
token: process.env.AXIOM_TOKEN!,
|
|
111
|
+
dataset: process.env.AXIOM_DATASET!,
|
|
112
|
+
// optional, defaults to https://api.axiom.co/v1/datasets
|
|
113
|
+
// url: "https://api.axiom.co/v1/datasets",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Provider: Custom
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
RowndMigrationPlugin.init({
|
|
122
|
+
rowndAppKey: process.env.ROWND_APP_KEY,
|
|
123
|
+
rowndAppSecret: process.env.ROWND_APP_SECRET,
|
|
124
|
+
telemetry: {
|
|
125
|
+
provider: "custom",
|
|
126
|
+
factory: () => ({
|
|
127
|
+
recordEvent: async (event) => {
|
|
128
|
+
// send to your telemetry backend
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Bulk Import Script
|
|
136
|
+
|
|
137
|
+
The package includes a bulk migration script for importing Rownd users into SuperTokens.
|
|
138
|
+
|
|
139
|
+
The script now runs directly from a YAML config file that lives beside the script:
|
|
140
|
+
|
|
141
|
+
- config file: `packages/rownd-nodejs/scripts/config.yaml`
|
|
142
|
+
- script: `packages/rownd-nodejs/scripts/bulkMigrate.ts`
|
|
143
|
+
|
|
144
|
+
### Usage
|
|
145
|
+
|
|
146
|
+
1. Edit `scripts/config.yaml` with your Rownd and SuperTokens credentials.
|
|
147
|
+
2. Run the script from `packages/rownd-nodejs`.
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npm run bulk-import
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The script:
|
|
154
|
+
|
|
155
|
+
- fetches users from Rownd page by page
|
|
156
|
+
- validates the Rownd payload shape with `zod`
|
|
157
|
+
- maps users with `mapRowndUserToSuperTokens`
|
|
158
|
+
- imports them into SuperTokens in bounded batches
|
|
159
|
+
- writes a checkpoint file so the run can resume later
|
|
160
|
+
|
|
161
|
+
### Config File
|
|
162
|
+
|
|
163
|
+
All runtime config is read from `scripts/config.yaml`.
|
|
164
|
+
There is no environment variable parsing.
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@supertokens-plugins/rownd-nodejs",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Rownd User Migration Plugin for SuperTokens",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src",
|
|
11
|
+
"scripts"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
15
|
+
"bulk-import": "tsx scripts/bulkMigrate.ts",
|
|
16
|
+
"lint": "eslint ./src --ext .ts",
|
|
17
|
+
"test": "TEST_MODE=testing vitest run --pool=forks"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"supertokens",
|
|
21
|
+
"rownd",
|
|
22
|
+
"migration",
|
|
23
|
+
"plugin"
|
|
24
|
+
],
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@opentelemetry/api": "^1.9.0",
|
|
28
|
+
"@rownd/node": "^3.0.4"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"supertokens-node": ">=23.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@shared/eslint": "*",
|
|
35
|
+
"@shared/nodejs": "*",
|
|
36
|
+
"@shared/tsconfig": "*",
|
|
37
|
+
"@types/node": "^20.0.0",
|
|
38
|
+
"eslint": "^8.57.0",
|
|
39
|
+
"testcontainers": "^10.7.1",
|
|
40
|
+
"tsup": "^8.0.2",
|
|
41
|
+
"tsx": "^4.21.0",
|
|
42
|
+
"typescript": "^5.7.3",
|
|
43
|
+
"vitest": "^1.3.1",
|
|
44
|
+
"yaml": "^2.8.3",
|
|
45
|
+
"zod": "^4.3.6"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { type RowndUser, type SuperTokensUserImport } from "../src/types";
|
|
7
|
+
import { mapRowndUserToSuperTokens } from "../src/pluginImplementation";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG_FILE_NAME = "config.yaml";
|
|
10
|
+
const SCRIPT_DIR = __dirname;
|
|
11
|
+
const DEFAULT_CONFIG_FILE_PATH = resolve(SCRIPT_DIR, DEFAULT_CONFIG_FILE_NAME);
|
|
12
|
+
|
|
13
|
+
export type RetryConfig = {
|
|
14
|
+
maxAttempts: number;
|
|
15
|
+
initialDelayMs: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CheckpointConfig = {
|
|
19
|
+
file: string;
|
|
20
|
+
resume: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RowndSourceConfig = {
|
|
24
|
+
appId: string;
|
|
25
|
+
appKey: string;
|
|
26
|
+
appSecret: string;
|
|
27
|
+
pageSize: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SuperTokensTargetConfig = {
|
|
31
|
+
connectionURI: string;
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
batchSize: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type BulkMigrateConfig = {
|
|
37
|
+
limit: number;
|
|
38
|
+
checkpoint: CheckpointConfig;
|
|
39
|
+
retry: RetryConfig;
|
|
40
|
+
rownd: RowndSourceConfig;
|
|
41
|
+
supertokens: SuperTokensTargetConfig;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Checkpoint = {
|
|
45
|
+
cursor?: string;
|
|
46
|
+
importedCount?: number;
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const ConfigSchema = z.object({
|
|
51
|
+
limit: z.preprocess(
|
|
52
|
+
(value) => (value === undefined ? Number.POSITIVE_INFINITY : value),
|
|
53
|
+
z.union([z.literal(Number.POSITIVE_INFINITY), z.number().int().positive()]),
|
|
54
|
+
),
|
|
55
|
+
checkpoint: z.object({
|
|
56
|
+
file: z.string(),
|
|
57
|
+
resume: z.boolean().default(false),
|
|
58
|
+
}),
|
|
59
|
+
retry: z.object({
|
|
60
|
+
maxAttempts: z.number().int().positive(),
|
|
61
|
+
initialDelayMs: z.number().int().positive(),
|
|
62
|
+
}),
|
|
63
|
+
rownd: z.object({
|
|
64
|
+
appId: z.string(),
|
|
65
|
+
appKey: z.string(),
|
|
66
|
+
appSecret: z.string(),
|
|
67
|
+
pageSize: z.number().int().positive(),
|
|
68
|
+
}),
|
|
69
|
+
supertokens: z.object({
|
|
70
|
+
connectionURI: z.string(),
|
|
71
|
+
apiKey: z.string().optional(),
|
|
72
|
+
batchSize: z.number().int().positive(),
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const RowndUserSchema = z.looseObject({
|
|
77
|
+
state: z.string().optional(),
|
|
78
|
+
auth_level: z.string().optional(),
|
|
79
|
+
data: z.looseObject({
|
|
80
|
+
user_id: z.string(),
|
|
81
|
+
email: z.string().optional(),
|
|
82
|
+
phone_number: z.string().optional(),
|
|
83
|
+
google_id: z.string().optional(),
|
|
84
|
+
apple_id: z.string().optional(),
|
|
85
|
+
first_name: z.string().optional(),
|
|
86
|
+
last_name: z.string().optional(),
|
|
87
|
+
}),
|
|
88
|
+
verified_data: z.record(z.string(), z.unknown()).optional(),
|
|
89
|
+
attributes: z.record(z.string(), z.unknown()).optional(),
|
|
90
|
+
groups: z.array(z.string()).optional(),
|
|
91
|
+
meta: z.record(z.string(), z.unknown()).optional(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const RowndUsersPageSchema = z
|
|
95
|
+
.object({
|
|
96
|
+
results: z.array(RowndUserSchema).optional(),
|
|
97
|
+
data: z.array(RowndUserSchema).optional(),
|
|
98
|
+
})
|
|
99
|
+
.superRefine((value, context) => {
|
|
100
|
+
if (!value.results && !value.data) {
|
|
101
|
+
context.addIssue({
|
|
102
|
+
code: z.ZodIssueCode.custom,
|
|
103
|
+
message: "Rownd API response is missing a results/data array.",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const CheckpointSchema = z.object({
|
|
109
|
+
cursor: z.string().optional(),
|
|
110
|
+
importedCount: z.number().int().nonnegative().optional(),
|
|
111
|
+
updatedAt: z.string(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function formatIssuePath(path: Array<string | number>) {
|
|
115
|
+
if (path.length === 0) {
|
|
116
|
+
return "<root>";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return path
|
|
120
|
+
.map((segment, index) => {
|
|
121
|
+
if (typeof segment === "number") {
|
|
122
|
+
return `[${segment}]`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return index === 0 ? segment : `.${segment}`;
|
|
126
|
+
})
|
|
127
|
+
.join("");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function formatZodError(error: z.ZodError) {
|
|
131
|
+
return error.issues
|
|
132
|
+
.map(
|
|
133
|
+
(issue) =>
|
|
134
|
+
`${formatIssuePath(issue.path.filter((segment): segment is string | number => typeof segment === "string" || typeof segment === "number"))}: ${issue.message}`,
|
|
135
|
+
)
|
|
136
|
+
.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseConfig(
|
|
140
|
+
rawConfig: unknown,
|
|
141
|
+
configDir: string = SCRIPT_DIR,
|
|
142
|
+
): BulkMigrateConfig {
|
|
143
|
+
const parsed = ConfigSchema.parse(rawConfig);
|
|
144
|
+
const checkpointFile = isAbsolute(parsed.checkpoint.file)
|
|
145
|
+
? parsed.checkpoint.file
|
|
146
|
+
: resolve(configDir, parsed.checkpoint.file);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
limit: parsed.limit,
|
|
150
|
+
checkpoint: {
|
|
151
|
+
file: checkpointFile,
|
|
152
|
+
resume: parsed.checkpoint.resume,
|
|
153
|
+
},
|
|
154
|
+
retry: parsed.retry,
|
|
155
|
+
rownd: parsed.rownd,
|
|
156
|
+
supertokens: parsed.supertokens,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function loadConfig(
|
|
161
|
+
configFilePath: string = DEFAULT_CONFIG_FILE_PATH,
|
|
162
|
+
) {
|
|
163
|
+
const configFile = await fs.readFile(configFilePath, "utf8");
|
|
164
|
+
|
|
165
|
+
return parseConfig(parseYaml(configFile), dirname(configFilePath));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isRetryableStatus(status: number) {
|
|
169
|
+
return status === 429 || status >= 500;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchWithRetry({
|
|
173
|
+
url,
|
|
174
|
+
requestInit,
|
|
175
|
+
retryConfig,
|
|
176
|
+
operation,
|
|
177
|
+
}: {
|
|
178
|
+
url: string;
|
|
179
|
+
requestInit?: RequestInit;
|
|
180
|
+
retryConfig: RetryConfig;
|
|
181
|
+
operation: string;
|
|
182
|
+
}) {
|
|
183
|
+
let lastError: Error | undefined;
|
|
184
|
+
|
|
185
|
+
for (let attempt = 1; attempt <= retryConfig.maxAttempts; attempt += 1) {
|
|
186
|
+
try {
|
|
187
|
+
const response = await fetch(url, requestInit);
|
|
188
|
+
if (
|
|
189
|
+
response.ok ||
|
|
190
|
+
!isRetryableStatus(response.status) ||
|
|
191
|
+
attempt === retryConfig.maxAttempts
|
|
192
|
+
) {
|
|
193
|
+
return response;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const responseText = await response.text();
|
|
197
|
+
lastError = new Error(
|
|
198
|
+
`${operation} failed with ${response.status}${responseText ? ` ${responseText}` : ""}`,
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (attempt === retryConfig.maxAttempts) {
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
lastError =
|
|
206
|
+
error instanceof Error ? error : new Error(`${operation} failed`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const delayMs = Math.round(
|
|
210
|
+
retryConfig.initialDelayMs * 2 ** (attempt - 1) * (1 + Math.random() * 0.2),
|
|
211
|
+
);
|
|
212
|
+
console.log(
|
|
213
|
+
`${operation} attempt ${attempt} failed. Retrying in ${delayMs}ms.`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
throw lastError ?? new Error(`${operation} failed`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseRowndUser(rawUser: unknown) {
|
|
223
|
+
const parsed = RowndUserSchema.parse(rawUser);
|
|
224
|
+
const rowndUserId = (parsed.data.user_id ||
|
|
225
|
+
parsed.user_id ||
|
|
226
|
+
parsed.app_user_id) as string | undefined;
|
|
227
|
+
|
|
228
|
+
if (!rowndUserId) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"Rownd user is missing a stable user id. Cannot continue pagination safely.",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const rowndUser: RowndUser = {
|
|
235
|
+
state: parsed.state ?? "",
|
|
236
|
+
auth_level: parsed.auth_level ?? "",
|
|
237
|
+
data: parsed.data as RowndUser["data"],
|
|
238
|
+
verified_data: (parsed.verified_data ?? {}) as RowndUser["verified_data"],
|
|
239
|
+
attributes: parsed.attributes as RowndUser["attributes"],
|
|
240
|
+
groups: parsed.groups,
|
|
241
|
+
meta: parsed.meta as RowndUser["meta"],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return { rowndUser, rowndUserId };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function fetchRowndUsersPage(
|
|
248
|
+
config: BulkMigrateConfig,
|
|
249
|
+
cursor?: string,
|
|
250
|
+
pageSize?: number,
|
|
251
|
+
) {
|
|
252
|
+
const url = new URL(
|
|
253
|
+
`/applications/${config.rownd.appId}/users/data`,
|
|
254
|
+
"https://api.rownd.io",
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (cursor) {
|
|
258
|
+
url.searchParams.set("after", cursor);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
url.searchParams.set("include_duplicates", "true");
|
|
262
|
+
url.searchParams.set("page_size", String(pageSize ?? config.rownd.pageSize));
|
|
263
|
+
|
|
264
|
+
const response = await fetchWithRetry({
|
|
265
|
+
url: url.toString(),
|
|
266
|
+
requestInit: {
|
|
267
|
+
headers: {
|
|
268
|
+
"x-rownd-app-key": config.rownd.appKey,
|
|
269
|
+
"x-rownd-app-secret": config.rownd.appSecret,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
retryConfig: config.retry,
|
|
273
|
+
operation: "Fetching Rownd users",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Rownd API error: ${response.status} ${await response.text()}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const page = RowndUsersPageSchema.parse(await response.json());
|
|
283
|
+
return (page.results ?? page.data ?? []).map(parseRowndUser);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function loadCheckpoint(checkpointFile: string) {
|
|
287
|
+
try {
|
|
288
|
+
const checkpoint = await fs.readFile(checkpointFile, "utf8");
|
|
289
|
+
return CheckpointSchema.parse(JSON.parse(checkpoint));
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function saveCheckpoint(checkpointFile: string, checkpoint: Checkpoint) {
|
|
300
|
+
const tempFile = `${checkpointFile}.tmp`;
|
|
301
|
+
await fs.writeFile(tempFile, JSON.stringify(checkpoint, null, 2), "utf8");
|
|
302
|
+
await fs.rename(tempFile, checkpointFile);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function stageUsersForImport(config: {
|
|
306
|
+
users: SuperTokensUserImport[];
|
|
307
|
+
supertokens: Pick<SuperTokensTargetConfig, "connectionURI" | "apiKey">;
|
|
308
|
+
retry: RetryConfig;
|
|
309
|
+
}) {
|
|
310
|
+
const headers: Record<string, string> = {
|
|
311
|
+
"Content-Type": "application/json",
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
if (config.supertokens.apiKey) {
|
|
315
|
+
headers["api-key"] = config.supertokens.apiKey;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const response = await fetchWithRetry({
|
|
319
|
+
url: new URL(
|
|
320
|
+
"/bulk-import/users",
|
|
321
|
+
config.supertokens.connectionURI,
|
|
322
|
+
).toString(),
|
|
323
|
+
requestInit: {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers,
|
|
326
|
+
body: JSON.stringify({ users: config.users }),
|
|
327
|
+
},
|
|
328
|
+
retryConfig: config.retry,
|
|
329
|
+
operation: "Importing users into SuperTokens",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`SuperTokens bulk import error: ${response.status} ${await response.text()}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function importUsersBatch(
|
|
340
|
+
config: BulkMigrateConfig,
|
|
341
|
+
users: Array<{ rowndUserId: string; user: SuperTokensUserImport }>,
|
|
342
|
+
totalImportedBeforeBatch: number,
|
|
343
|
+
) {
|
|
344
|
+
for (
|
|
345
|
+
let index = 0;
|
|
346
|
+
index < users.length;
|
|
347
|
+
index += config.supertokens.batchSize
|
|
348
|
+
) {
|
|
349
|
+
const batch = users.slice(index, index + config.supertokens.batchSize);
|
|
350
|
+
console.log(
|
|
351
|
+
`Importing batch ${Math.floor(totalImportedBeforeBatch / config.supertokens.batchSize) + 1} (${batch.length} users)`,
|
|
352
|
+
);
|
|
353
|
+
await stageUsersForImport({
|
|
354
|
+
users: batch.map((entry) => entry.user),
|
|
355
|
+
supertokens: config.supertokens,
|
|
356
|
+
retry: config.retry,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function migrateRowndUsersToSuperTokens(
|
|
362
|
+
config: BulkMigrateConfig,
|
|
363
|
+
) {
|
|
364
|
+
const checkpoint = config.checkpoint.resume
|
|
365
|
+
? await loadCheckpoint(config.checkpoint.file)
|
|
366
|
+
: null;
|
|
367
|
+
let cursor = checkpoint?.cursor;
|
|
368
|
+
let totalProcessed = 0;
|
|
369
|
+
let totalImported = checkpoint?.importedCount ?? 0;
|
|
370
|
+
|
|
371
|
+
if (config.checkpoint.resume) {
|
|
372
|
+
console.log(
|
|
373
|
+
checkpoint
|
|
374
|
+
? `Resuming migration from cursor ${checkpoint.cursor ?? "<start>"}`
|
|
375
|
+
: "No checkpoint found. Starting a fresh migration.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
while (totalProcessed < config.limit) {
|
|
380
|
+
const remaining = Number.isFinite(config.limit)
|
|
381
|
+
? Math.max(config.limit - totalProcessed, 0)
|
|
382
|
+
: config.rownd.pageSize;
|
|
383
|
+
const requestedPageSize = Number.isFinite(config.limit)
|
|
384
|
+
? Math.min(config.rownd.pageSize, remaining)
|
|
385
|
+
: config.rownd.pageSize;
|
|
386
|
+
|
|
387
|
+
if (requestedPageSize === 0) {
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
console.log(
|
|
392
|
+
`Fetching page ${Math.floor(totalProcessed / config.rownd.pageSize) + 1}`,
|
|
393
|
+
);
|
|
394
|
+
const pageUsers = await fetchRowndUsersPage(
|
|
395
|
+
config,
|
|
396
|
+
cursor,
|
|
397
|
+
requestedPageSize,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
if (pageUsers.length === 0) {
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const mappedUsers = pageUsers.map(({ rowndUser, rowndUserId }) => ({
|
|
405
|
+
rowndUserId,
|
|
406
|
+
user: mapRowndUserToSuperTokens(rowndUser),
|
|
407
|
+
}));
|
|
408
|
+
|
|
409
|
+
await importUsersBatch(config, mappedUsers, totalImported);
|
|
410
|
+
|
|
411
|
+
totalProcessed += pageUsers.length;
|
|
412
|
+
totalImported += mappedUsers.length;
|
|
413
|
+
cursor = pageUsers[pageUsers.length - 1]?.rowndUserId;
|
|
414
|
+
|
|
415
|
+
await saveCheckpoint(config.checkpoint.file, {
|
|
416
|
+
cursor,
|
|
417
|
+
importedCount: totalImported,
|
|
418
|
+
updatedAt: new Date().toISOString(),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
console.log(`Processed ${totalProcessed} users so far`);
|
|
422
|
+
console.log(`Imported ${totalImported} users so far`);
|
|
423
|
+
|
|
424
|
+
if (pageUsers.length < requestedPageSize) {
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
totalProcessed,
|
|
431
|
+
totalImported,
|
|
432
|
+
cursor,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function runCli() {
|
|
437
|
+
const result = await migrateRowndUsersToSuperTokens(await loadConfig());
|
|
438
|
+
console.log(`Migrated ${result.totalImported} users`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
runCli().catch((error: unknown) => {
|
|
442
|
+
if (error instanceof z.ZodError) {
|
|
443
|
+
console.error(formatZodError(error));
|
|
444
|
+
} else {
|
|
445
|
+
console.error(
|
|
446
|
+
error instanceof Error ? error.message : "Bulk migration failed",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
process.exitCode = 1;
|
|
450
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Maximum number of users to process. Remove this field to process everything.
|
|
2
|
+
limit: 100
|
|
3
|
+
|
|
4
|
+
checkpoint:
|
|
5
|
+
# File used to persist migration progress so a later run can resume.
|
|
6
|
+
file: ./rownd-migration-checkpoint.json
|
|
7
|
+
# When true, continue from the saved checkpoint instead of starting over.
|
|
8
|
+
resume: false
|
|
9
|
+
|
|
10
|
+
retry:
|
|
11
|
+
# Number of attempts for retryable HTTP failures such as 429 and 5xx.
|
|
12
|
+
maxAttempts: 5
|
|
13
|
+
# Base backoff delay in milliseconds before exponential retry waits.
|
|
14
|
+
initialDelayMs: 500
|
|
15
|
+
|
|
16
|
+
rownd:
|
|
17
|
+
appId: <APP_ID>
|
|
18
|
+
appKey: <APP_KEY>
|
|
19
|
+
appSecret: <APP_SECRET>
|
|
20
|
+
# Number of Rownd users to request per page.
|
|
21
|
+
pageSize: 100
|
|
22
|
+
|
|
23
|
+
supertokens:
|
|
24
|
+
connectionURI: <CONNECTION_URI>
|
|
25
|
+
apiKey: <API_KEY>
|
|
26
|
+
# Number of users to send to SuperTokens per bulk import request.
|
|
27
|
+
batchSize: 500
|
package/src/constants.ts
ADDED
package/src/errors.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const ROWND_PLUGIN_ERROR_MESSAGES = {
|
|
2
|
+
MISSING_AUTHORIZATION_HEADER: "Missing authorization header",
|
|
3
|
+
INVALID_TOKEN: "Invalid token",
|
|
4
|
+
ROWND_USER_NOT_FOUND: "User not found in Rownd",
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
export type RowndPluginErrorType = keyof typeof ROWND_PLUGIN_ERROR_MESSAGES;
|
|
8
|
+
|
|
9
|
+
export class RowndPluginError extends Error {
|
|
10
|
+
constructor(type: RowndPluginErrorType) {
|
|
11
|
+
super(ROWND_PLUGIN_ERROR_MESSAGES[type]);
|
|
12
|
+
}
|
|
13
|
+
}
|