Skip to content

Commit 1ca668d

Browse files
committed
feat: second copy command
1 parent 1081ee7 commit 1ca668d

11 files changed

Lines changed: 453 additions & 8 deletions

File tree

packages/repair-cli/readme.md

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ Each repair includes:
225225
- block number used when the repair was created
226226
- total operations and counts by `pending`, `failed`, `completed`, and `skipped`
227227

228-
This command lists provider-level repairs only. Dataset replications are listed with `repair replicate list`.
228+
This command lists provider-level repairs only. Dataset replications are listed with `repair replicate list`. Second-copy jobs are listed with `repair second-copy list`.
229229

230230
### `repair repair run <repairId>`
231231

@@ -324,6 +324,75 @@ repair replicate delete 1
324324

325325
This only deletes local SQLite state. It does not delete on-chain datasets or remove pieces from a provider.
326326

327+
### `repair second-copy create`
328+
329+
Creates a local second-copy job from a pieces JSON file and an explicit source provider URL.
330+
331+
```bash
332+
repair second-copy create \
333+
--pieces-file ./secondary-copy-pieces.json \
334+
--source-provider-url https://mainnet-sp.example/pdp \
335+
--target-provider-id 202
336+
```
337+
338+
`--pieces-file` is a JSON file with an `aggregates` array. Each aggregate must include a `piece_cid` string. Only aggregate `piece_cid` values are used; `sub_pieces` are ignored.
339+
340+
`--source-provider-url` is the provider URL pieces are pulled from. It is not looked up in the indexer, so it can point at a provider on another network (for example Mainnet while the CLI is configured for Calibration).
341+
342+
`--target-provider-id` is the provider on the configured chain that should receive the copied pieces. Its service URL must differ from `--source-provider-url`.
343+
344+
The command snapshots the current chain block number, creates a local second-copy row, and creates local `add_piece` operations for every unique `piece_cid` in the file. The command returns a `secondCopyId`.
345+
346+
### `repair second-copy list`
347+
348+
Lists local second-copy jobs.
349+
350+
```bash
351+
repair second-copy list
352+
```
353+
354+
Each job includes:
355+
356+
- second-copy ID and status
357+
- source provider URL
358+
- target provider ID and target provider URL
359+
- target dataset ID, when one has been created or found
360+
- block number used when the job was created
361+
- total operations and counts by `pending`, `failed`, `completed`, and `skipped`
362+
363+
### `repair second-copy run <secondCopyId>`
364+
365+
Runs a pending second-copy job.
366+
367+
```bash
368+
repair second-copy run 1
369+
```
370+
371+
The command first ensures the target dataset exists for the configured wallet and target provider (same as repair: IPFS indexing enabled, CDN disabled). Then it processes pending `add_piece` operations by pulling pieces from `--source-provider-url` into the target provider and committing them on-chain.
372+
373+
Options:
374+
375+
- `--concurrency <number>` controls how many pull batches run at once. Defaults to `4`.
376+
- `--batch-size <number>` controls the maximum number of `add_piece` operations per batch. Defaults to `40`.
377+
- `--payer <address>` overrides the payer address used when creating or finding the target dataset. Defaults to the configured wallet address.
378+
379+
Example:
380+
381+
```bash
382+
repair second-copy run 1 --concurrency 8 --batch-size 40
383+
repair second-copy run 1 --payer 0x1234567890123456789012345678901234567890
384+
```
385+
386+
### `repair second-copy delete <secondCopyId>`
387+
388+
Deletes a local second-copy job and its operations.
389+
390+
```bash
391+
repair second-copy delete 1
392+
```
393+
394+
This only deletes local SQLite state. It does not delete on-chain datasets or remove pieces from a provider.
395+
327396
## Typical Workflow
328397

329398
1. Configure the CLI.
@@ -378,6 +447,19 @@ repair replicate list
378447
repair replicate run 1
379448
```
380449

450+
### Second-copy from a pieces file
451+
452+
Use second-copy when you have a list of piece CIDs to pull from an explicit provider URL into a target provider on the configured chain. This supports cross-network pulls, such as Mainnet source URL → Calibration target provider.
453+
454+
```bash
455+
repair second-copy create \
456+
--pieces-file ./secondary-copy-pieces.json \
457+
--source-provider-url https://mainnet-sp.example/pdp \
458+
--target-provider-id 202
459+
repair second-copy list
460+
repair second-copy run 1
461+
```
462+
381463
## Contributing
382464

383465
Read contributing [guidelines](../../.github/CONTRIBUTING.md).

packages/repair-cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { datasets } from './commands/datasets.ts'
44
import { providers } from './commands/providers.ts'
55
import { repair } from './commands/repair.ts'
66
import { replicate } from './commands/replicate.ts'
7+
import { secondCopy } from './commands/second-copy.ts'
78
import { sessionKey } from './commands/session-key.ts'
89
import { setup } from './commands/setup.ts'
910
import { wallet } from './commands/wallet.ts'
@@ -18,6 +19,7 @@ cli.command(setup)
1819
cli.command(wallet)
1920
cli.command(repair)
2021
cli.command(replicate)
22+
cli.command(secondCopy)
2123
cli.command(datasets)
2224
cli.command(providers)
2325
cli.command(sessionKey)

packages/repair-cli/src/commands/repair.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { and, desc, eq, inArray, isNull } from 'drizzle-orm'
1+
import { and, desc, eq, inArray } from 'drizzle-orm'
22
import { Cli, z } from 'incur'
33
import { isAddress } from 'viem'
44
import { repairCreate } from '../db/repair-create.ts'
@@ -52,7 +52,7 @@ repair.command('list', {
5252
try {
5353
const localSchema = c.var.localDb._.fullSchema
5454
const repairs = await c.var.localDb.query.repairs.findMany({
55-
where: isNull(localSchema.repairs.repairDataSetId),
55+
where: eq(localSchema.repairs.kind, 'repair'),
5656
orderBy: [desc(localSchema.repairs.createdAt)],
5757
with: {
5858
operations: true,
@@ -147,7 +147,7 @@ repair.command('run', {
147147
where: and(
148148
eq(schema.repairs.id, c.args.repairId),
149149
inArray(schema.repairs.status, ['pending', 'failed']),
150-
isNull(schema.repairs.repairDataSetId)
150+
eq(schema.repairs.kind, 'repair')
151151
),
152152
})
153153
if (!repair) {

packages/repair-cli/src/commands/replicate.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { and, desc, eq, inArray, isNotNull } from 'drizzle-orm'
1+
import { and, desc, eq, inArray } from 'drizzle-orm'
22
import { Cli, z } from 'incur'
33
import { isAddress } from 'viem'
44
import { repairDelete } from '../db/repair-delete.ts'
@@ -52,7 +52,7 @@ replicate.command('list', {
5252
try {
5353
const localSchema = c.var.localDb._.fullSchema
5454
const replications = await c.var.localDb.query.repairs.findMany({
55-
where: isNotNull(localSchema.repairs.repairDataSetId),
55+
where: eq(localSchema.repairs.kind, 'replicate'),
5656
orderBy: [desc(localSchema.repairs.createdAt)],
5757
with: {
5858
operations: true,
@@ -148,7 +148,7 @@ replicate.command('run', {
148148
where: and(
149149
eq(schema.repairs.id, c.args.replicateId),
150150
inArray(schema.repairs.status, ['pending', 'failed']),
151-
isNotNull(schema.repairs.repairDataSetId)
151+
eq(schema.repairs.kind, 'replicate')
152152
),
153153
})
154154
if (!repair) {
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { and, desc, eq, inArray } from 'drizzle-orm'
2+
import { Cli, z } from 'incur'
3+
import { isAddress } from 'viem'
4+
import { repairDelete } from '../db/repair-delete.ts'
5+
import { secondCopyCreate } from '../db/second-copy-create.ts'
6+
import { contextMiddleware, contextSchema } from '../middleware.ts'
7+
import { runAddPieces } from '../pipeline/add-pieces.ts'
8+
import { ensureRepairDataset } from '../pipeline/create-datasets.ts'
9+
import { globalOptions } from '../utils.ts'
10+
11+
export const secondCopy = Cli.create('second-copy', {
12+
description: 'Second-copy piece commands',
13+
vars: contextSchema,
14+
})
15+
16+
secondCopy.command('create', {
17+
description: 'Create a second-copy job from a pieces file',
18+
options: globalOptions.extend({
19+
piecesFile: z.string().describe('Path to JSON file with aggregates[].piece_cid'),
20+
sourceProviderUrl: z.string().url().describe('Source provider URL to pull pieces from'),
21+
targetProviderId: z.coerce.bigint().describe('Target provider ID for second-copy'),
22+
}),
23+
middleware: [contextMiddleware],
24+
run: async (c) => {
25+
try {
26+
const { piecesFile, sourceProviderUrl, targetProviderId } = c.options
27+
28+
const secondCopyId = await secondCopyCreate({
29+
...c.var,
30+
piecesFile,
31+
sourceProviderUrl,
32+
targetProviderId,
33+
})
34+
35+
return c.ok({
36+
secondCopyId,
37+
})
38+
} catch (error) {
39+
console.error(error)
40+
return c.error({
41+
code: 'SECOND_COPY_FAILED',
42+
message: error instanceof Error ? error.message : 'Failed to create the second-copy job',
43+
retryable: true,
44+
})
45+
}
46+
},
47+
})
48+
49+
secondCopy.command('list', {
50+
description: 'List all second-copy jobs',
51+
options: globalOptions,
52+
middleware: [contextMiddleware],
53+
run: async (c) => {
54+
try {
55+
const localSchema = c.var.localDb._.fullSchema
56+
const jobs = await c.var.localDb.query.repairs.findMany({
57+
where: eq(localSchema.repairs.kind, 'second_copy'),
58+
orderBy: [desc(localSchema.repairs.createdAt)],
59+
with: {
60+
operations: true,
61+
},
62+
})
63+
64+
const secondCopies = jobs.map((repair) => {
65+
const { operations, ...job } = repair
66+
return {
67+
id: job.id,
68+
status: job.status,
69+
sourceProviderUrl: job.sourceProviderUrl,
70+
targetProviderId: job.targetProviderId,
71+
targetProviderUrl: job.targetProviderUrl,
72+
targetDataSetId: job.targetDataSetId,
73+
blockNumber: job.blockNumber,
74+
operations: operations.length,
75+
pending: operations.filter((operation) => operation.status === 'pending').length,
76+
failed: operations.filter((operation) => operation.status === 'failed').length,
77+
completed: operations.filter((operation) => operation.status === 'completed').length,
78+
skipped: operations.filter((operation) => operation.status === 'skipped').length,
79+
}
80+
})
81+
82+
return c.ok({
83+
secondCopies,
84+
})
85+
} catch (error) {
86+
console.error(error)
87+
return c.error({
88+
code: 'SECOND_COPY_FAILED',
89+
message: error instanceof Error ? error.message : 'Failed to list second-copy jobs',
90+
retryable: true,
91+
})
92+
}
93+
},
94+
})
95+
96+
secondCopy.command('delete', {
97+
description: 'Delete a second-copy job',
98+
args: z.object({
99+
secondCopyId: z.coerce.number().describe('Second-copy ID to delete'),
100+
}),
101+
options: globalOptions,
102+
middleware: [contextMiddleware],
103+
run: async (c) => {
104+
try {
105+
const { deleted, operationsDeleted } = await repairDelete({
106+
localDb: c.var.localDb,
107+
repairId: c.args.secondCopyId,
108+
})
109+
110+
if (!deleted) {
111+
return c.error({
112+
code: 'SECOND_COPY_NOT_FOUND',
113+
message: 'Second-copy job not found',
114+
retryable: false,
115+
})
116+
}
117+
118+
return c.ok({
119+
secondCopyId: c.args.secondCopyId,
120+
operationsDeleted,
121+
})
122+
} catch (error) {
123+
console.error(error)
124+
return c.error({
125+
code: 'SECOND_COPY_FAILED',
126+
message: error instanceof Error ? error.message : 'Failed to delete the second-copy job',
127+
retryable: true,
128+
})
129+
}
130+
},
131+
})
132+
133+
secondCopy.command('run', {
134+
description: 'Run a second-copy job',
135+
args: z.object({
136+
secondCopyId: z.coerce.number().describe('Second-copy ID to run'),
137+
}),
138+
options: globalOptions.extend({
139+
concurrency: z.coerce.number().min(1).max(10).default(4).describe('Concurrency level'),
140+
batchSize: z.coerce.number().min(1).max(40).default(40).describe('Max pieces per batch'),
141+
payer: z.string().refine(isAddress, 'Invalid address').optional().describe('Payer address'),
142+
}),
143+
middleware: [contextMiddleware],
144+
run: async (c) => {
145+
try {
146+
const schema = c.var.localDb._.fullSchema
147+
const payer = c.options.payer ?? c.var.client.account.address
148+
const repair = await c.var.localDb.query.repairs.findFirst({
149+
where: and(
150+
eq(schema.repairs.id, c.args.secondCopyId),
151+
inArray(schema.repairs.status, ['pending', 'failed']),
152+
eq(schema.repairs.kind, 'second_copy')
153+
),
154+
})
155+
if (!repair) {
156+
return c.error({
157+
code: 'SECOND_COPY_NOT_FOUND',
158+
message: 'Second-copy job not found, it may have already been run or completed',
159+
retryable: false,
160+
})
161+
}
162+
163+
await ensureRepairDataset({
164+
...c.var,
165+
repair,
166+
payer,
167+
})
168+
169+
await runAddPieces({
170+
...c.var,
171+
repair,
172+
concurrency: c.options.concurrency,
173+
batchSize: c.options.batchSize,
174+
})
175+
return c.ok({
176+
secondCopyId: repair.id,
177+
})
178+
} catch (error) {
179+
console.error(error)
180+
return c.error({
181+
code: 'SECOND_COPY_FAILED',
182+
message: error instanceof Error ? error.message : 'Failed to run the second-copy job',
183+
retryable: true,
184+
})
185+
}
186+
},
187+
})

packages/repair-cli/src/db/repair-create.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ export async function repairCreate(options: RepairCreateOptions): Promise<number
4646
const [repair] = await localDb
4747
.insert(localSchema.repairs)
4848
.values({
49+
kind: 'repair',
4950
repairProviderId,
5051
repairDataSetId: null,
52+
sourceProviderUrl: null,
5153
targetProviderId: targetProvider.providerId,
5254
targetProviderUrl: targetProvider.serviceUrl,
5355
targetDataSetId: null,

packages/repair-cli/src/db/replicate-create.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ export async function replicateCreate(options: ReplicateCreateOptions): Promise<
6969
const [repair] = await localDb
7070
.insert(localSchema.repairs)
7171
.values({
72+
kind: 'replicate',
7273
repairProviderId: sourceProvider.providerId,
7374
repairDataSetId: sourceDataSet.dataSetId,
75+
sourceProviderUrl: sourceProvider.serviceUrl,
7476
targetProviderId: targetProvider.providerId,
7577
targetProviderUrl: targetProvider.serviceUrl,
7678
targetDataSetId: null,

0 commit comments

Comments
 (0)