mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
feat(server): chunked import preview endpoint and resumable upload in the Import page and CLI (#11224)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The previous pull request added server-side chunked resumable import transfers; without clients, large imports still ride the single fragile upload > - The Import page and the CLI need to slice large packages, upload parts with retry and progress, resume after interruptions, and preview before applying > - Preview is the missing server piece: the browser flow is preview-then-import, so a completed spool must be previewable without re-uploading > - This pull request adds the transfer preview endpoint, switches the Import page to the chunked path for zips over 48 MB, and teaches the CLI the same for oversized local imports > - The benefit is that large imports get progress, per-part retry, and resume in both clients, while small imports keep the exact single-shot path they have today ## Linked Issues or Issue Description **What happened?** With only the server transfer routes in place, users still upload large company packages as one request from the Import page and the CLI: no progress indication, no retry below the whole file, and no resume after a dropped connection or refresh. The preview-then-import flow also cannot run against an uploaded transfer, forcing a second full upload. **Expected behavior** A large package uploads once as verified parts with visible progress; preview and import both run against the uploaded spool; an interrupted upload resumes with only the missing parts re-sent; packages at or below 48 MB behave exactly as before. **Steps to reproduce** 1. Select a 500 MB zip on the Import page over an unreliable connection. 2. Watch the single upload fail near the end and restart from zero, twice — once for preview, once for import. 3. Same story headless via the CLI. ## What Changed - Server: `POST /import/transfers/:id/preview` runs the existing preview logic against the completed spool (shared assembly + whole-file verification helper with apply); preview neither completes the run nor deletes the spool, so the subsequent apply reuses it. Missing parts respond with the missing list. - UI: zips over 48 MB take the chunked path in both preview and import — the file is sliced into 32 MB parts hashed with WebCrypto (single ArrayBuffer, no second copy), the transfer is created or resumed (the create response's missing-parts list drives what uploads), parts upload sequentially with three attempts each and visible progress, then transfer preview/apply replace the multipart calls. The existing preview pane, collision handling, adapter overrides, and async job polling are unchanged; ≤ 48 MB keeps the single-shot path. - CLI: oversized local `.zip` or folder imports zip/slice/hash with node crypto, upload with resume and per-part retry and progress lines, and use transfer preview/apply. Small packages keep the inline path byte-identical. - Failure honesty: adapters/API errors fail open to existing behavior; a part failing all attempts surfaces a durable error panel with resume intact. ## Verification - Server: preview-then-apply on one spool (run stays open, spool intact, then apply completes), preview with missing parts rejected — added to the transfer route suite (embedded Postgres). - UI suite: large file takes the chunked path (manifest shape, part uploads, progress, apply on a resumed transfer, single-shot endpoints never called), small file stays single-shot, part failure after three attempts surfaces the error panel without running preview, resume re-uploads only the missing part. - CLI: manifest slicing/hashing, threshold behavior for zip and folder sources, folder-zip round-trip through the real zip reader, upload resume/retry/exhaustion/already-completed, full-command chunked and small-zip inline flows. - Server, ui, cli typechecks clean. Exact counts in the PR checks. ## Risks - The 48 MB threshold only routes between two verified paths; behavior below it is untouched. - Chunked CLI imports use the board-scoped transfer routes, so oversized CLI imports need board credentials (agent tokens keep the agent-safe small-file path). No privilege change — board actors already had the generic routes — but the two size regimes differ semantically; called out for review. - A CLI dry-run over the threshold uploads parts before previewing; the spool persists (24 h sweep) and a later apply resumes without re-upload — inherent to preview-against-spool. Stacked on #11223 — merge that first; this PR then shows only the preview endpoint and client changes. ## Model Used - Claude Fable 5 (`claude-fable-5`) via Claude Code CLI, with extended thinking and tool use (multi-agent implementation with independent verification). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
1 parent
8f478242f1
commit
0a95ada1be
13 files changed
+1658
-66
No files matched your search
@@ -68,7 +68,10 @@ vi.mock("../services/index.js", () => ({
|
||||
// Passthrough wrapper around the real spool module whose removeImportTransferSpool
|
||||
// can be made to throw, so tests can exercise a post-success cleanup failure
|
||||
// (e.g. EACCES on the spool dir) without touching real filesystem permissions.
|
||||
// assembleImportTransferZip can likewise be made to throw, standing in for the
|
||||
// spool being deleted out from under an unclaimed preview mid-assembly.
|
||||
const spoolRemovalFailure = vi.hoisted(() => ({ error: null as Error | null }));
|
||||
const assemblyFailure = vi.hoisted(() => ({ error: null as Error | null }));
|
||||
vi.mock("../services/company-import-transfers.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/company-import-transfers.js")>();
|
||||
return {
|
||||
@@ -77,6 +80,10 @@ vi.mock("../services/company-import-transfers.js", async (importOriginal) => {
|
||||
if (spoolRemovalFailure.error) throw spoolRemovalFailure.error;
|
||||
return actual.removeImportTransferSpool(spoolRoot, runId);
|
||||
},
|
||||
assembleImportTransferZip: async (spoolRoot: string, runId: string, partCount: number) => {
|
||||
if (assemblyFailure.error) throw assemblyFailure.error;
|
||||
return actual.assembleImportTransferZip(spoolRoot, runId, partCount);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -260,6 +267,7 @@ describeEmbeddedPostgres("company import transfer routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
spoolRemovalFailure.error = null;
|
||||
assemblyFailure.error = null;
|
||||
mockCompanyPortabilityService.importBundle.mockResolvedValue({
|
||||
company: { id: companyId, action: "updated" },
|
||||
agents: [{ id: "agent-1" }],
|
||||
@@ -331,6 +339,173 @@ describeEmbeddedPostgres("company import transfer routes", () => {
|
||||
expect(finished.body.missingParts).toEqual([]);
|
||||
});
|
||||
|
||||
it("previews the assembled spool without consuming it, then applies the same transfer", async () => {
|
||||
mockCompanyPortabilityService.previewImport.mockResolvedValue({
|
||||
plan: { companyAction: "update" },
|
||||
warnings: [],
|
||||
errors: [],
|
||||
});
|
||||
const zip = buildFixtureZip();
|
||||
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 3));
|
||||
const created = await request(app).post("/api/companies/import/transfers").send(body);
|
||||
const transferId = created.body.transferId as string;
|
||||
for (const [index, slice] of slices.entries()) {
|
||||
expect((await putPart(transferId, index, slice)).status).toBe(200);
|
||||
}
|
||||
|
||||
const previewed = await request(app)
|
||||
.post(`/api/companies/import/transfers/${transferId}/preview`)
|
||||
.send(importMeta);
|
||||
expect(previewed.status).toBe(200);
|
||||
expect(previewed.body.plan).toEqual({ companyAction: "update" });
|
||||
|
||||
// The assembled zip fed the existing preview pipeline unchanged.
|
||||
expect(mockCompanyPortabilityService.previewImport).toHaveBeenCalledTimes(1);
|
||||
const previewBody = mockCompanyPortabilityService.previewImport.mock.calls[0]![0];
|
||||
expect(previewBody.source.type).toBe("inline");
|
||||
expect(previewBody.source.files["COMPANY.md"]).toContain("Chunked Import");
|
||||
expect(previewBody.target).toEqual(importMeta.target);
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
|
||||
// Preview consumed nothing: the run stays open, the spool stays on disk,
|
||||
// and no part needs re-uploading before the apply.
|
||||
const run = (await companyTransferRunService.getRun(db, transferId))!;
|
||||
expect(run.status).not.toBe("completed");
|
||||
await expect(fs.stat(path.join(spoolRoot, transferId))).resolves.toBeDefined();
|
||||
const status = await request(app).get(`/api/companies/import/transfers/${transferId}`);
|
||||
expect(status.body.missingParts).toEqual([]);
|
||||
|
||||
const applied = await request(app)
|
||||
.post(`/api/companies/import/transfers/${transferId}/apply`)
|
||||
.send(importMeta);
|
||||
expect(applied.status).toBe(200);
|
||||
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledTimes(1);
|
||||
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("refuses to preview while parts are missing", async () => {
|
||||
const zip = buildFixtureZip();
|
||||
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 3));
|
||||
const created = await request(app).post("/api/companies/import/transfers").send(body);
|
||||
const transferId = created.body.transferId as string;
|
||||
expect((await putPart(transferId, 1, slices[1]!)).status).toBe(200);
|
||||
|
||||
const previewed = await request(app)
|
||||
.post(`/api/companies/import/transfers/${transferId}/preview`)
|
||||
.send(importMeta);
|
||||
expect(previewed.status).toBe(409);
|
||||
expect(previewed.body.missingParts).toEqual([0, 2]);
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to preview while an apply holds the claim and leaves the spool untouched", async () => {
|
||||
const zip = buildFixtureZip();
|
||||
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
|
||||
const created = await request(app).post("/api/companies/import/transfers").send(body);
|
||||
const transferId = created.body.transferId as string;
|
||||
for (const [index, slice] of slices.entries()) {
|
||||
expect((await putPart(transferId, index, slice)).status).toBe(200);
|
||||
}
|
||||
|
||||
// Claim the run as an in-flight apply would: the preview must refuse
|
||||
// before reading the spool the apply is assembling (and deleting on
|
||||
// success) rather than race it.
|
||||
expect(await companyTransferRunService.claimApply(db, transferId)).toBe(true);
|
||||
try {
|
||||
const previewed = await request(app)
|
||||
.post(`/api/companies/import/transfers/${transferId}/preview`)
|
||||
.send(importMeta);
|
||||
expect(previewed.status).toBe(409);
|
||||
expect(previewed.body.error).toContain("apply is in progress");
|
||||
} finally {
|
||||
await companyTransferRunService.releaseApplyClaim(db, transferId, "test release");
|
||||
}
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
// Every part file survived for the apply to read.
|
||||
for (const [index] of slices.entries()) {
|
||||
await expect(fs.stat(path.join(spoolRoot, transferId, `part-${index}`))).resolves.toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to preview an already-applied transfer", async () => {
|
||||
const zip = buildFixtureZip();
|
||||
const { body, slices } = declareTransfer(zip, zip.length);
|
||||
const created = await request(app).post("/api/companies/import/transfers").send(body);
|
||||
const transferId = created.body.transferId as string;
|
||||
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
|
||||
expect(
|
||||
(await request(app).post(`/api/companies/import/transfers/${transferId}/apply`).send(importMeta)).status,
|
||||
).toBe(200);
|
||||
|
||||
const previewed = await request(app)
|
||||
.post(`/api/companies/import/transfers/${transferId}/preview`)
|
||||
.send(importMeta);
|
||||
expect(previewed.status).toBe(409);
|
||||
expect(previewed.body.error).toContain("already been applied");
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 410 for a preview against a swept transfer", async () => {
|
||||
const zip = buildFixtureZip();
|
||||
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
|
||||
const created = await request(app).post("/api/companies/import/transfers").send(body);
|
||||
const transferId = created.body.transferId as string;
|
||||
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
|
||||
|
||||
const later = new Date(Date.now() + 25 * 60 * 60 * 1000);
|
||||
const swept = await sweepAbandonedImportTransferSpools(db, spoolRoot, { now: later });
|
||||
expect(swept.swept).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const previewed = await request(app)
|
||||
.post(`/api/companies/import/transfers/${transferId}/preview`)
|
||||
.send(importMeta);
|
||||
expect(previewed.status).toBe(410);
|
||||
expect(previewed.body.error).toContain("expired");
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns a clean conflict when an apply completes and sweeps the spool mid-preview-assembly", async () => {
|
||||
const zip = buildFixtureZip();
|
||||
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
|
||||
const created = await request(app).post("/api/companies/import/transfers").send(body);
|
||||
const transferId = created.body.transferId as string;
|
||||
for (const [index, slice] of slices.entries()) {
|
||||
expect((await putPart(transferId, index, slice)).status).toBe(200);
|
||||
}
|
||||
|
||||
// The race: the preview's initial read sees an open run, then a
|
||||
// concurrent apply settles the run completed and deletes the spool while
|
||||
// the preview is assembling it. The stale initial read comes from a
|
||||
// one-shot getRunForActor stub; the ledger itself already says completed
|
||||
// and the assembly blows up the way missing part files would.
|
||||
expect(await companyTransferRunService.claimApply(db, transferId)).toBe(true);
|
||||
await companyTransferRunService.complete(db, transferId);
|
||||
const completedRun = (await companyTransferRunService.getRun(db, transferId))!;
|
||||
expect(completedRun.status).toBe("completed");
|
||||
const staleRead = vi
|
||||
.spyOn(companyTransferRunService, "getRunForActor")
|
||||
.mockResolvedValueOnce({ ...completedRun, status: "running" });
|
||||
assemblyFailure.error = Object.assign(new Error("ENOENT: no such file or directory, open 'part-0'"), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
try {
|
||||
const previewed = await request(app)
|
||||
.post(`/api/companies/import/transfers/${transferId}/preview`)
|
||||
.send(importMeta);
|
||||
// A clean already-applied conflict, not a 500 from the torn spool.
|
||||
expect(previewed.status).toBe(409);
|
||||
expect(previewed.body.error).toContain("already been applied");
|
||||
} finally {
|
||||
staleRead.mockRestore();
|
||||
assemblyFailure.error = null;
|
||||
}
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
// Preview stayed read-only: the run keeps the terminal state the apply
|
||||
// gave it.
|
||||
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("applies through the async import job machinery when requested", async () => {
|
||||
const zip = buildFixtureZip();
|
||||
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
|
||||
|
||||
+106
-26
@@ -836,16 +836,48 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan
|
||||
} satisfies CompanyImportTransferStatus);
|
||||
});
|
||||
|
||||
// Assemble the spooled parts back into the original zip, verify it against
|
||||
// the declared whole-file hash, and run it through the exact import path a
|
||||
// single-shot zip upload takes. The body carries the same meta fields the
|
||||
// multipart route's `meta` field does (include/target/collisionStrategy/...).
|
||||
router.post(`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId/apply`, async (req, res) => {
|
||||
assertBoard(req);
|
||||
/**
|
||||
* Resolve a completed transfer into the same raw preview/import body a
|
||||
* single-shot zip upload produces: load the run, require every part, then
|
||||
* assemble the spool and verify it against the declared whole-file hash.
|
||||
* Shared by the transfer preview and apply routes; returns null after
|
||||
* responding 409 when parts are still missing. A whole-file mismatch fails
|
||||
* closed for both callers: every part verified individually but the whole
|
||||
* does not match the declaration, so the spool is deleted and a resume
|
||||
* re-uploads every part instead of re-assembling the same corrupt bytes.
|
||||
*
|
||||
* With `claimApply` (the apply route), the run is atomically claimed before
|
||||
* the spool is touched: the import pipeline is not idempotent, so of any
|
||||
* overlapping applies of this transfer exactly one may run it — a single
|
||||
* guarded status flip to "applying" that losers see as 409. The claim is
|
||||
* settled by complete() on success or the guarded releaseApplyClaim() on
|
||||
* error (a released run stays retryable; a settled one is never reopened).
|
||||
* The preview route takes no claim — it never consumes the transfer — so
|
||||
* both its status gate and its assembly errors are re-checked against the
|
||||
* ledger (see the catch below) to keep a concurrent apply's spool cleanup
|
||||
* from surfacing as an internal error.
|
||||
*/
|
||||
async function resolveImportTransferBody(
|
||||
req: Request,
|
||||
res: Response,
|
||||
options: { claimApply?: boolean } = {},
|
||||
) {
|
||||
const run = await requireImportTransferRun(req);
|
||||
if (run.status === "completed") {
|
||||
throw conflict("Import transfer has already been applied");
|
||||
}
|
||||
if (run.status === "applying") {
|
||||
// An apply holds the claim right now: its assembly is reading the spool
|
||||
// and its success deletes it, so neither a preview nor a second apply
|
||||
// may touch the parts until the claim settles.
|
||||
throw conflict("Import transfer apply is in progress; retry after it settles");
|
||||
}
|
||||
if (run.status === "cancelled") {
|
||||
// The abandoned-spool sweep cancelled the run and deleted its parts —
|
||||
// same contract as the part-upload route: the caller re-creates it.
|
||||
res.status(410).json({ error: "Import transfer expired — re-create it" });
|
||||
return null;
|
||||
}
|
||||
const manifest = storedImportTransferManifest(run);
|
||||
const missingParts = await importTransferMissingParts(run, manifest);
|
||||
if (missingParts.length > 0) {
|
||||
@@ -853,33 +885,82 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan
|
||||
error: "Import transfer is missing parts",
|
||||
missingParts,
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
// Atomic claim: the import pipeline is not idempotent, so of any
|
||||
// overlapping applies of this transfer exactly one may run it. The claim
|
||||
// is a single guarded status flip to "applying"; complete() or the
|
||||
// guarded releaseApplyClaim() below settle it (a released run stays
|
||||
// retryable). Losers get 409.
|
||||
if (!(await companyTransferRunService.claimApply(db, run.id))) {
|
||||
if (options.claimApply && !(await companyTransferRunService.claimApply(db, run.id))) {
|
||||
throw conflict("Import transfer apply is already in progress");
|
||||
}
|
||||
try {
|
||||
const zipBytes = await assembleImportTransferZip(importTransferSpoolRoot, run.id, manifest.parts.length);
|
||||
const zipSha256 = createHash("sha256").update(zipBytes).digest("hex");
|
||||
if (zipBytes.length !== manifest.totalBytes || zipSha256 !== manifest.zipSha256) {
|
||||
// Fail closed: every part verified individually but the whole does not
|
||||
// match the declaration. The spool is deleted so a resume re-uploads
|
||||
// every part instead of re-assembling the same corrupt bytes.
|
||||
await companyTransferRunService.fail(db, run.id, "Assembled import package failed whole-file verification");
|
||||
await removeImportTransferSpool(importTransferSpoolRoot, run.id);
|
||||
throw unprocessable("Assembled import package failed verification; upload the transfer again");
|
||||
}
|
||||
const archive = await readImportZipArchive(zipBytes);
|
||||
const meta = importTransferApplyMeta(req.body);
|
||||
const rawImportBody = {
|
||||
...meta,
|
||||
source: { type: "inline", rootPath: archive.rootPath, files: archive.files },
|
||||
return {
|
||||
run,
|
||||
rawBody: {
|
||||
...importTransferApplyMeta(req.body),
|
||||
source: { type: "inline", rootPath: archive.rootPath, files: archive.files },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Whatever escapes here (a malformed zip, the verification failure
|
||||
// above) must not leave a claimed run parked in "applying": release
|
||||
// the claim, which fails the run and keeps it retryable. The release
|
||||
// is guarded on "applying", so the verification path that already
|
||||
// failed the run keeps its more specific terminal state.
|
||||
if (options.claimApply) {
|
||||
await companyTransferRunService.releaseApplyClaim(db, run.id, errorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
// Unclaimed (preview) path: assembly read the spool without freezing
|
||||
// it, so a concurrent apply may have completed — or the sweep cancelled
|
||||
// the run — and deleted the part files mid-assembly. Re-read the ledger
|
||||
// and surface those races as the same clean signals the status gate
|
||||
// above sends; only a run that is genuinely still open rethrows as a
|
||||
// real assembly failure.
|
||||
const current = await companyTransferRunService.getRun(db, run.id);
|
||||
if (current?.status === "completed") {
|
||||
throw conflict("Import transfer has already been applied");
|
||||
}
|
||||
if (current?.status === "applying") {
|
||||
throw conflict("Import transfer apply is in progress; retry after it settles");
|
||||
}
|
||||
if (current?.status === "cancelled") {
|
||||
res.status(410).json({ error: "Import transfer expired — re-create it" });
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the import preview against the assembled spool without consuming the
|
||||
// transfer: the ledger run stays open and the parts stay spooled, so the
|
||||
// subsequent apply reuses them instead of re-uploading. The body carries the
|
||||
// same meta fields the multipart preview route's `meta` field does.
|
||||
router.post(`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId/preview`, async (req, res) => {
|
||||
assertBoard(req);
|
||||
const resolved = await resolveImportTransferBody(req, res);
|
||||
if (!resolved) return;
|
||||
const body = companyPortabilityPreviewSchema.parse(resolved.rawBody);
|
||||
assertImportTargetAccess(req, body.target);
|
||||
const preview = await portability.previewImport(body);
|
||||
res.json(preview);
|
||||
});
|
||||
|
||||
// Assemble the spooled parts back into the original zip, verify it against
|
||||
// the declared whole-file hash, and run it through the exact import path a
|
||||
// single-shot zip upload takes. The body carries the same meta fields the
|
||||
// multipart route's `meta` field does (include/target/collisionStrategy/...).
|
||||
router.post(`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId/apply`, async (req, res) => {
|
||||
assertBoard(req);
|
||||
const resolved = await resolveImportTransferBody(req, res, { claimApply: true });
|
||||
if (!resolved) return;
|
||||
const { run, rawBody: rawImportBody } = resolved;
|
||||
try {
|
||||
await executeImportRequest(req, res, rawImportBody, {
|
||||
onSuccess: async (result) => {
|
||||
// Settle the run the moment the import is committed: every later
|
||||
@@ -944,12 +1025,11 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Whatever escapes here (bad meta, malformed zip, an import error
|
||||
// rethrown after onFailure) must not leave the run parked in
|
||||
// "applying": release the claim, which keeps the run retryable. The
|
||||
// release is guarded on "applying", so a run that already settled —
|
||||
// failed by onFailure, or completed before a late error — keeps its
|
||||
// terminal state.
|
||||
// Whatever escapes here (bad meta, an import error rethrown after
|
||||
// onFailure) must not leave the run parked in "applying": release the
|
||||
// claim, which keeps the run retryable. The release is guarded on
|
||||
// "applying", so a run that already settled — failed by onFailure, or
|
||||
// completed before a late error — keeps its terminal state.
|
||||
await companyTransferRunService.releaseApplyClaim(db, run.id, errorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -6007,6 +6007,34 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: `${COMPANY_IMPORT_TRANSFERS_API_PATH}/{transferId}/preview`,
|
||||
tags: ["companies"],
|
||||
summary: "Preview a completed company import transfer",
|
||||
description:
|
||||
"Runs the import preview against the assembled spool without consuming the transfer: the " +
|
||||
"ledger run stays open and the parts stay spooled, so the subsequent apply reuses them " +
|
||||
"instead of re-uploading. The JSON body carries the same fields as the multipart preview " +
|
||||
"route's `meta` field (include, target, collisionStrategy, ...).",
|
||||
request: {
|
||||
params: z.object({ transferId: z.string() }),
|
||||
body: jsonBody(companyPortabilityPreviewSchema.omit({ source: true })),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
404: r.notFound,
|
||||
409: {
|
||||
description:
|
||||
"Parts are still missing, an apply is in progress, or the transfer was already applied",
|
||||
},
|
||||
410: { description: "The transfer expired and its spooled parts were deleted" },
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: `${COMPANY_IMPORT_TRANSFERS_API_PATH}/{transferId}/apply`,
|
||||
@@ -6034,6 +6062,7 @@ registry.registerPath({
|
||||
description:
|
||||
"Parts are still missing, an apply is already in progress, or the transfer was already applied",
|
||||
},
|
||||
410: { description: "The transfer expired and its spooled parts were deleted" },
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user