@myelinbridge/cli 0.1.0 → 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 +4 -1
- package/bin/myelin.js +70 -22
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -16,7 +16,10 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
- **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
|
|
19
|
-
(path + size),
|
|
19
|
+
(path + size), and within a large file, parts that already landed are skipped
|
|
20
|
+
too (S3 multipart). Uploads go direct to storage over short-lived presigned
|
|
21
|
+
URLs — no credential is stored on your machine, and revoking the API key cuts
|
|
22
|
+
off signing immediately.
|
|
20
23
|
- **`check` costs nothing.** It evaluates your local manifest against the dataset's
|
|
21
24
|
quality checks server-side — same engine, same verdicts as submit — without
|
|
22
25
|
uploading. Exit code 2 means a blocking rule fails.
|
package/bin/myelin.js
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
12
12
|
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
13
13
|
import process from 'node:process'
|
|
14
|
-
import * as tus from 'tus-js-client'
|
|
15
14
|
|
|
16
15
|
const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
|
|
17
16
|
const KEY = process.env.MYELIN_API_KEY
|
|
@@ -97,24 +96,71 @@ function fmtBytes(n) {
|
|
|
97
96
|
return n + ' B'
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
onError: rejectP,
|
|
115
|
-
onSuccess: () => resolveP(),
|
|
116
|
-
}).start()
|
|
99
|
+
// Upload a file via S3 presigned multipart (SEC-TOKEN-01: no bearer token ever
|
|
100
|
+
// reaches the client — declare tells us the part size, and each part gets a
|
|
101
|
+
// short-lived presigned PUT URL scoped to exactly that one part of one object).
|
|
102
|
+
// Resumable: the sign step reports parts that already landed, so a re-run skips
|
|
103
|
+
// them. Returns { upload_id, parts: [{part_number, etag}] } for confirm.
|
|
104
|
+
const SIGN_WINDOW = 1000 // ≤ the server's max_parts_per_sign
|
|
105
|
+
|
|
106
|
+
async function readPart(absPath, start, end) {
|
|
107
|
+
const chunks = []
|
|
108
|
+
await new Promise((res, rej) => {
|
|
109
|
+
const s = createReadStream(absPath, { start, end }) // end inclusive
|
|
110
|
+
s.on('data', (c) => chunks.push(c))
|
|
111
|
+
s.on('end', res)
|
|
112
|
+
s.on('error', rej)
|
|
117
113
|
})
|
|
114
|
+
return Buffer.concat(chunks)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
|
|
118
|
+
const numParts = Math.max(1, Math.ceil(size / partSize))
|
|
119
|
+
const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
|
|
120
|
+
const etags = new Map() // part_number -> etag
|
|
121
|
+
let uploadId = null
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < allParts.length; i += SIGN_WINDOW) {
|
|
124
|
+
const window = allParts.slice(i, i + SIGN_WINDOW)
|
|
125
|
+
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: window }), 'sign upload parts')
|
|
126
|
+
uploadId = j.upload_id
|
|
127
|
+
for (const p of j.uploaded_parts) etags.set(p.part_number, p.etag) // already landed → skip
|
|
128
|
+
const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
|
|
129
|
+
|
|
130
|
+
// upload the still-missing parts in this window, up to 4 concurrent.
|
|
131
|
+
// Presigned URLs expire (~30 min); on a rejection we re-sign that one part
|
|
132
|
+
// and retry once — cheap, since upload-parts is idempotent.
|
|
133
|
+
const missing = window.filter((n) => !etags.has(n))
|
|
134
|
+
const queue = [...missing]
|
|
135
|
+
const resign = async (partNo) => {
|
|
136
|
+
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
|
|
137
|
+
const fresh = j.urls.find((u) => u.part_number === partNo)?.url
|
|
138
|
+
if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
|
|
139
|
+
urlByPart.set(partNo, fresh)
|
|
140
|
+
return fresh
|
|
141
|
+
}
|
|
142
|
+
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
143
|
+
for (;;) {
|
|
144
|
+
const partNo = queue.shift()
|
|
145
|
+
if (partNo === undefined) return
|
|
146
|
+
const start = (partNo - 1) * partSize
|
|
147
|
+
const end = Math.min(start + partSize, size) - 1
|
|
148
|
+
const bytes = await readPart(absPath, start, end)
|
|
149
|
+
let put = await fetch(urlByPart.get(partNo), { method: 'PUT', body: bytes }).catch(() => null)
|
|
150
|
+
if (!put || put.status === 403 || put.status === 401) {
|
|
151
|
+
put = await fetch(await resign(partNo), { method: 'PUT', body: bytes }).catch((e) => die(`upload part ${partNo}: ${e.message}`))
|
|
152
|
+
}
|
|
153
|
+
if (put.status !== 200) die(`upload part ${partNo}: HTTP ${put.status}`)
|
|
154
|
+
const etag = put.headers.get('etag')
|
|
155
|
+
if (!etag) die(`upload part ${partNo}: storage returned no ETag`)
|
|
156
|
+
etags.set(partNo, etag)
|
|
157
|
+
if (onPartDone) onPartDone()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
await Promise.all(workers)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) }
|
|
118
164
|
}
|
|
119
165
|
|
|
120
166
|
// ——— commands ———
|
|
@@ -226,20 +272,22 @@ async function cmdPush() {
|
|
|
226
272
|
declared.push(...r.json.files)
|
|
227
273
|
upload = r.json.upload
|
|
228
274
|
}
|
|
275
|
+
const partSize = upload?.part_size ?? 64 * 1024 * 1024
|
|
229
276
|
|
|
230
|
-
// 3. upload the delta (anything not already 'uploaded')
|
|
277
|
+
// 3. upload the delta (anything not already 'uploaded') via S3 multipart,
|
|
278
|
+
// 2 files in parallel (each file uploads its parts 4-wide internally)
|
|
231
279
|
const byPath = new Map(files.map((f) => [f.path, f]))
|
|
232
280
|
const pending = declared.filter((d) => d.upload_state !== 'uploaded')
|
|
233
281
|
out(`Uploading ${pending.length}/${declared.length} files (${fmtBytes(pending.reduce((s, d) => s + (byPath.get(d.path)?.size ?? 0), 0))}) · resumable`)
|
|
234
282
|
let done = 0
|
|
235
283
|
const queue = [...pending]
|
|
236
|
-
const workers = Array.from({ length: Math.min(
|
|
284
|
+
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
|
|
237
285
|
for (;;) {
|
|
238
286
|
const d = queue.shift()
|
|
239
287
|
if (!d) return
|
|
240
288
|
const local = byPath.get(d.path)
|
|
241
|
-
await
|
|
242
|
-
const c = await api('POST', `/files/${d.file_id}/confirm
|
|
289
|
+
const { upload_id, parts } = await uploadFileMultipart(local.abs, local.size, d.file_id, partSize)
|
|
290
|
+
const c = await api('POST', `/files/${d.file_id}/confirm`, { upload_id, parts })
|
|
243
291
|
if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
|
|
244
292
|
done++
|
|
245
293
|
out(` ✓ ${d.path} (${done}/${pending.length})`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myelinbridge/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Myelin Partner Ingestion CLI — push R&D data deliveries from a pipeline: preflight against the client's quality rules, resumable upload, submit, track review outcomes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,14 +13,11 @@
|
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=20"
|
|
15
15
|
},
|
|
16
|
-
"dependencies": {
|
|
17
|
-
"tus-js-client": "^4.1.0"
|
|
18
|
-
},
|
|
19
16
|
"keywords": [
|
|
20
17
|
"myelin",
|
|
21
18
|
"pharma",
|
|
22
19
|
"ingestion",
|
|
23
|
-
"
|
|
20
|
+
"s3"
|
|
24
21
|
],
|
|
25
22
|
"license": "UNLICENSED"
|
|
26
23
|
}
|