@ichibase/cli 0.1.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/dist/push.js ADDED
@@ -0,0 +1,107 @@
1
+ // Upload a bundle to a project + start the import + stream progress.
2
+ // Parts go browser-style: presigned PUT straight to R2 (the API only ever sees
3
+ // metadata + the manifest, never the dump bytes).
4
+ import { readFile, readdir } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ export async function push(opts) {
7
+ const manifestRaw = await readFile(path.join(opts.bundleDir, 'manifest.json'), 'utf8');
8
+ const manifest = JSON.parse(manifestRaw);
9
+ const auth = { Authorization: `Bearer ${opts.token}` };
10
+ const baseJobs = `${opts.api}/v1/projects/${encodeURIComponent(opts.project)}/migrate/jobs`;
11
+ // 1) create the job. NoSQL sources carry target_flavor (mongo|postgres); SQL
12
+ // sources omit it (the server derives Postgres).
13
+ const created = await jpost(baseJobs, auth, {
14
+ source_kind: manifest.source_kind,
15
+ target_flavor: manifest.target_flavor,
16
+ });
17
+ const jobId = String(created.id);
18
+ process.stderr.write(`created job ${jobId} (${manifest.source_kind} → ${created.target_flavor})\n`);
19
+ // 2) upload every bundle part directly to R2
20
+ for (const rel of await listFiles(opts.bundleDir)) {
21
+ const body = await readFile(path.join(opts.bundleDir, rel));
22
+ // Empty tables dump a 0-byte NDJSON file. Skip uploading it — the importer
23
+ // creates the table from schema.json and treats a missing data part as 0
24
+ // rows, so an empty object would just be dead weight in R2.
25
+ if (body.length === 0 && rel.startsWith('data/')) {
26
+ process.stderr.write(` ↷ ${rel} (empty table, skipped)\n`);
27
+ continue;
28
+ }
29
+ const contentType = contentTypeFor(rel);
30
+ const u = await jpost(`${baseJobs}/${jobId}/upload-url`, auth, {
31
+ part: rel,
32
+ content_type: contentType,
33
+ size: body.length,
34
+ });
35
+ const put = await fetch(String(u.url), {
36
+ method: 'PUT',
37
+ headers: { 'Content-Type': contentType },
38
+ body,
39
+ });
40
+ if (!put.ok)
41
+ throw new Error(`upload of ${rel} failed: HTTP ${put.status}`);
42
+ process.stderr.write(` ↑ ${rel} (${body.length} bytes)\n`);
43
+ }
44
+ // 3) start (hands the worker the manifest, including any source row counts)
45
+ await jpost(`${baseJobs}/${jobId}/start`, auth, { manifest: JSON.parse(manifestRaw) });
46
+ process.stderr.write('started — importing…\n');
47
+ // 4) poll to completion
48
+ for (;;) {
49
+ await sleep(2000);
50
+ const s = await jget(`${baseJobs}/${jobId}`, auth);
51
+ const job = s.job;
52
+ process.stderr.write(` state: ${job.state}\n`);
53
+ if (job.state === 'completed') {
54
+ process.stderr.write('✓ migration completed\n');
55
+ if (job.stats)
56
+ process.stderr.write(` stats: ${JSON.stringify(job.stats)}\n`);
57
+ if (job.warnings?.length) {
58
+ process.stderr.write(' warnings:\n' + job.warnings.map((w) => ` - ${w}`).join('\n') + '\n');
59
+ }
60
+ return;
61
+ }
62
+ if (job.state === 'failed')
63
+ throw new Error(`migration failed: ${job.last_error ?? 'unknown'}`);
64
+ }
65
+ }
66
+ async function listFiles(dir, prefix = '') {
67
+ const entries = await readdir(dir, { withFileTypes: true });
68
+ const out = [];
69
+ for (const e of entries) {
70
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
71
+ if (e.isDirectory())
72
+ out.push(...(await listFiles(path.join(dir, e.name), rel)));
73
+ else
74
+ out.push(rel);
75
+ }
76
+ return out;
77
+ }
78
+ function contentTypeFor(rel) {
79
+ if (rel.endsWith('.json'))
80
+ return 'application/json';
81
+ if (rel.endsWith('.ndjson'))
82
+ return 'application/x-ndjson';
83
+ if (rel.endsWith('.archive') || rel.endsWith('.gz'))
84
+ return 'application/gzip';
85
+ return 'application/octet-stream';
86
+ }
87
+ async function jpost(url, headers, body) {
88
+ const res = await fetch(url, {
89
+ method: 'POST',
90
+ headers: { ...headers, 'Content-Type': 'application/json' },
91
+ body: JSON.stringify(body),
92
+ });
93
+ const text = await res.text();
94
+ if (!res.ok)
95
+ throw new Error(`POST ${url} → ${res.status}: ${text.slice(0, 300)}`);
96
+ return text ? JSON.parse(text) : {};
97
+ }
98
+ async function jget(url, headers) {
99
+ const res = await fetch(url, { headers });
100
+ const text = await res.text();
101
+ if (!res.ok)
102
+ throw new Error(`GET ${url} → ${res.status}: ${text.slice(0, 300)}`);
103
+ return JSON.parse(text);
104
+ }
105
+ function sleep(ms) {
106
+ return new Promise((r) => setTimeout(r, ms));
107
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@ichibase/cli",
3
+ "version": "0.1.0",
4
+ "description": "ichibase migration CLI — extract Supabase / Postgres / MongoDB / Atlas into an ichibase migration bundle and push it to your project.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ichibase": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "tsx --test src/infer.test.ts src/auth-map.test.ts"
19
+ },
20
+ "dependencies": {
21
+ "firebase-admin": "^13.0.0",
22
+ "mongodb": "^6.10.0",
23
+ "pg": "^8.13.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "@types/pg": "^8.11.0",
28
+ "tsx": "^4.19.0",
29
+ "typescript": "^5.6.0"
30
+ }
31
+ }