@filoz/repair-cli 0.2.1 → 0.4.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.
Files changed (82) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/cli.js +4 -0
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/commands/datasets.d.ts +1 -0
  5. package/dist/src/commands/datasets.d.ts.map +1 -1
  6. package/dist/src/commands/datasets.js +5 -5
  7. package/dist/src/commands/datasets.js.map +1 -1
  8. package/dist/src/commands/providers.d.ts +1 -0
  9. package/dist/src/commands/providers.d.ts.map +1 -1
  10. package/dist/src/commands/repair.d.ts +1 -0
  11. package/dist/src/commands/repair.d.ts.map +1 -1
  12. package/dist/src/commands/repair.js +7 -3
  13. package/dist/src/commands/repair.js.map +1 -1
  14. package/dist/src/commands/replicate.d.ts +1 -0
  15. package/dist/src/commands/replicate.d.ts.map +1 -1
  16. package/dist/src/commands/replicate.js +7 -3
  17. package/dist/src/commands/replicate.js.map +1 -1
  18. package/dist/src/commands/second-copy.d.ts +25 -0
  19. package/dist/src/commands/second-copy.d.ts.map +1 -0
  20. package/dist/src/commands/second-copy.js +175 -0
  21. package/dist/src/commands/second-copy.js.map +1 -0
  22. package/dist/src/commands/session-key.d.ts +25 -0
  23. package/dist/src/commands/session-key.d.ts.map +1 -0
  24. package/dist/src/commands/session-key.js +114 -0
  25. package/dist/src/commands/session-key.js.map +1 -0
  26. package/dist/src/commands/wallet.d.ts +1 -0
  27. package/dist/src/commands/wallet.d.ts.map +1 -1
  28. package/dist/src/commands/wallet.js +75 -26
  29. package/dist/src/commands/wallet.js.map +1 -1
  30. package/dist/src/db/dedupe-cids.d.ts +1 -1
  31. package/dist/src/db/repair-create.d.ts.map +1 -1
  32. package/dist/src/db/repair-create.js +2 -0
  33. package/dist/src/db/repair-create.js.map +1 -1
  34. package/dist/src/db/replicate-create.d.ts.map +1 -1
  35. package/dist/src/db/replicate-create.js +2 -0
  36. package/dist/src/db/replicate-create.js.map +1 -1
  37. package/dist/src/db/second-copy-create.d.ts +10 -0
  38. package/dist/src/db/second-copy-create.d.ts.map +1 -0
  39. package/dist/src/db/second-copy-create.js +104 -0
  40. package/dist/src/db/second-copy-create.js.map +1 -0
  41. package/dist/src/db/update-operation.d.ts +2 -2
  42. package/dist/src/db/update-operation.d.ts.map +1 -1
  43. package/dist/src/db/update-operation.js +2 -2
  44. package/dist/src/db/upsert-operations.js +1 -1
  45. package/dist/src/local-schema.d.ts +48 -11
  46. package/dist/src/local-schema.d.ts.map +1 -1
  47. package/dist/src/local-schema.js +3 -1
  48. package/dist/src/local-schema.js.map +1 -1
  49. package/dist/src/middleware.d.ts +2 -0
  50. package/dist/src/middleware.d.ts.map +1 -1
  51. package/dist/src/middleware.js +2 -0
  52. package/dist/src/middleware.js.map +1 -1
  53. package/dist/src/pipeline/add-pieces.d.ts.map +1 -1
  54. package/dist/src/pipeline/add-pieces.js +17 -3
  55. package/dist/src/pipeline/add-pieces.js.map +1 -1
  56. package/dist/src/pipeline/create-datasets.d.ts +4 -2
  57. package/dist/src/pipeline/create-datasets.d.ts.map +1 -1
  58. package/dist/src/pipeline/create-datasets.js +5 -5
  59. package/dist/src/pipeline/create-datasets.js.map +1 -1
  60. package/dist/src/utils.d.ts +34 -30
  61. package/dist/src/utils.d.ts.map +1 -1
  62. package/dist/src/utils.js +68 -3
  63. package/dist/src/utils.js.map +1 -1
  64. package/package.json +1 -1
  65. package/readme.md +123 -4
  66. package/src/cli.ts +4 -0
  67. package/src/commands/datasets.ts +7 -5
  68. package/src/commands/repair.ts +7 -3
  69. package/src/commands/replicate.ts +7 -3
  70. package/src/commands/second-copy.ts +187 -0
  71. package/src/commands/session-key.ts +121 -0
  72. package/src/commands/wallet.ts +75 -26
  73. package/src/db/repair-create.ts +2 -0
  74. package/src/db/replicate-create.ts +2 -0
  75. package/src/db/second-copy-create.ts +147 -0
  76. package/src/db/update-operation.ts +3 -3
  77. package/src/db/upsert-operations.ts +1 -1
  78. package/src/local-schema.ts +4 -7
  79. package/src/middleware.ts +2 -0
  80. package/src/pipeline/add-pieces.ts +20 -4
  81. package/src/pipeline/create-datasets.ts +20 -5
  82. package/src/utils.ts +95 -5
@@ -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
+ })
@@ -0,0 +1,121 @@
1
+ import * as p from '@clack/prompts'
2
+ import * as SessionKey from '@filoz/synapse-core/session-key'
3
+ import { Cli, z } from 'incur'
4
+ import { type Address, isAddress } from 'viem'
5
+ import { contextMiddleware, contextSchema } from '../middleware.ts'
6
+ import { globalOptions, hashLink } from '../utils.ts'
7
+
8
+ const sessionKeyAddressArgs = z.object({
9
+ address: z.string().refine(isAddress, 'Invalid session key address').describe('Session key address'),
10
+ })
11
+
12
+ export const sessionKey = Cli.create('session-key', {
13
+ description: 'Session key commands',
14
+ vars: contextSchema,
15
+ })
16
+
17
+ sessionKey.command('approve', {
18
+ description: 'Approve a session key for storage operations',
19
+ args: sessionKeyAddressArgs,
20
+ options: globalOptions.extend({
21
+ expiresInDays: z.coerce.number().gt(0).default(100).describe('Session key expiry duration in days'),
22
+ origin: z.string().optional().describe('Origin recorded on-chain for the authorization'),
23
+ }),
24
+ middleware: [contextMiddleware],
25
+ outputPolicy: 'agent-only',
26
+ run: async (c) => {
27
+ const { client, chain, source, isInteractive } = c.var
28
+ const address = c.args.address as Address
29
+ const expiresAt = BigInt(Math.floor(Date.now() / 1000 + c.options.expiresInDays * 24 * 60 * 60))
30
+ const spinner = p.spinner()
31
+
32
+ try {
33
+ if (isInteractive) {
34
+ spinner.start(`Approving session key ${address}...`)
35
+ }
36
+ const result = await SessionKey.loginSync(client, {
37
+ address,
38
+ expiresAt,
39
+ origin: c.options.origin ?? source,
40
+ onHash: (hash) => {
41
+ if (isInteractive) {
42
+ spinner.message(`Waiting for tx ${hashLink(hash, chain)} to be mined...`)
43
+ }
44
+ },
45
+ })
46
+
47
+ if (isInteractive) {
48
+ spinner.stop(`Session key ${address} approved successfully.`)
49
+ }
50
+
51
+ return c.ok({
52
+ address,
53
+ expiresAt: expiresAt.toString(),
54
+ transactionHash: result.receipt.transactionHash,
55
+ })
56
+ } catch (error) {
57
+ const msg = error instanceof Error ? error.message : 'Failed to approve session key'
58
+ if (isInteractive) {
59
+ spinner.error(msg)
60
+ }
61
+ if (c.options.debug) {
62
+ console.error(error)
63
+ }
64
+ return c.error({
65
+ code: 'FAILED_TO_APPROVE_SESSION_KEY',
66
+ message: msg,
67
+ })
68
+ }
69
+ },
70
+ })
71
+
72
+ sessionKey.command('revoke', {
73
+ description: 'Revoke a session key for storage operations',
74
+ args: sessionKeyAddressArgs,
75
+ options: globalOptions.extend({
76
+ origin: z.string().optional().describe('Origin recorded on-chain for the revocation'),
77
+ }),
78
+ middleware: [contextMiddleware],
79
+ outputPolicy: 'agent-only',
80
+ run: async (c) => {
81
+ const { client, chain, source, isInteractive } = c.var
82
+ const address = c.args.address as Address
83
+ const spinner = p.spinner()
84
+
85
+ try {
86
+ if (isInteractive) {
87
+ spinner.start(`Revoking session key ${address}...`)
88
+ }
89
+ const result = await SessionKey.revokeSync(client, {
90
+ address,
91
+ origin: c.options.origin ?? source,
92
+ onHash: (hash) => {
93
+ if (isInteractive) {
94
+ spinner.message(`Waiting for tx ${hashLink(hash, chain)} to be mined...`)
95
+ }
96
+ },
97
+ })
98
+
99
+ if (isInteractive) {
100
+ spinner.stop(`Session key ${address} revoked successfully.`)
101
+ }
102
+
103
+ return c.ok({
104
+ address,
105
+ transactionHash: result.receipt.transactionHash,
106
+ })
107
+ } catch (error) {
108
+ const msg = error instanceof Error ? error.message : 'Failed to revoke session key'
109
+ if (isInteractive) {
110
+ spinner.error(msg)
111
+ }
112
+ if (c.options.debug) {
113
+ console.error(error)
114
+ }
115
+ return c.error({
116
+ code: 'FAILED_TO_REVOKE_SESSION_KEY',
117
+ message: msg,
118
+ })
119
+ }
120
+ },
121
+ })
@@ -1,9 +1,11 @@
1
1
  /** biome-ignore-all lint/suspicious/noConsole: cli */
2
+ import * as p from '@clack/prompts'
2
3
  import { calibration } from '@filoz/synapse-core/chains'
3
4
  import * as ERC20 from '@filoz/synapse-core/erc20'
4
5
  import * as Pay from '@filoz/synapse-core/pay'
5
6
  import { claimTokens, formatBalance, formatFraction, parseUnits } from '@filoz/synapse-core/utils'
6
7
  import { Cli, z } from 'incur'
8
+ import { isAddress } from 'viem'
7
9
  import { getBalance, waitForTransactionReceipt } from 'viem/actions'
8
10
  import { contextMiddleware, contextSchema } from '../middleware.ts'
9
11
  import { globalOptions, hashLink } from '../utils.ts'
@@ -17,8 +19,9 @@ wallet.command('fund', {
17
19
  description: 'Fund a calibration wallet from a faucet',
18
20
  options: globalOptions,
19
21
  middleware: [contextMiddleware],
20
- async *run(c) {
21
- const { client, chain } = c.var
22
+ outputPolicy: 'agent-only',
23
+ run: async (c) => {
24
+ const { client, chain, isInteractive } = c.var
22
25
 
23
26
  if (chain.id !== calibration.id) {
24
27
  return c.error({
@@ -27,28 +30,41 @@ wallet.command('fund', {
27
30
  })
28
31
  }
29
32
 
30
- yield 'Funding wallet...'
33
+ const spinner = p.spinner()
31
34
  try {
35
+ if (isInteractive) {
36
+ spinner.start('Funding wallet...')
37
+ }
32
38
  const hashes = await claimTokens({ address: client.account.address })
33
39
 
34
- yield `Waiting for tx ${hashLink(hashes[0].tx_hash, chain)} to be mined...`
40
+ if (isInteractive) {
41
+ spinner.message(`Waiting for tx ${hashLink(hashes[0].tx_hash, chain)} to be mined...`)
42
+ }
35
43
  await waitForTransactionReceipt(client, {
36
44
  hash: hashes[0].tx_hash,
37
45
  })
38
46
  const balance = await getBalance(client, {
39
47
  address: client.account.address,
40
48
  })
41
- yield {
49
+ if (isInteractive) {
50
+ spinner.stop('Wallet funded successfully.')
51
+ }
52
+ return c.ok({
42
53
  address: client.account.address,
43
54
  balance: formatBalance({ value: balance }),
44
- }
55
+ transactionHash: hashes[0].tx_hash,
56
+ })
45
57
  } catch (error) {
58
+ const msg = error instanceof Error ? error.message : 'Failed to fund wallet'
59
+ if (isInteractive) {
60
+ spinner.error(msg)
61
+ }
46
62
  if (c.options.debug) {
47
63
  console.error(error)
48
64
  }
49
65
  return c.error({
50
66
  code: 'FAILED_TO_FUND_WALLET',
51
- message: 'Failed to fund wallet',
67
+ message: msg,
52
68
  })
53
69
  }
54
70
  },
@@ -56,23 +72,26 @@ wallet.command('fund', {
56
72
 
57
73
  wallet.command('balance', {
58
74
  description: 'Get wallet and pay account summary',
59
- options: globalOptions,
75
+ options: globalOptions.extend({
76
+ address: z.string().refine(isAddress, 'Invalid address').optional().describe('Address to get balance for'),
77
+ }),
60
78
  middleware: [contextMiddleware],
61
79
  async run(c) {
62
80
  const { client } = c.var
81
+ const address = c.options.address ?? client.account.address
63
82
  const balanceFIL = await getBalance(client, {
64
- address: client.account.address,
83
+ address,
65
84
  })
66
85
 
67
86
  const balanceUSDFC = await ERC20.balance(client, {
68
- address: client.account.address,
87
+ address,
69
88
  })
70
89
 
71
90
  const summary = await Pay.getAccountSummary(client, {
72
- address: client.account.address,
91
+ address,
73
92
  })
74
93
  return {
75
- address: client.account.address,
94
+ address,
76
95
  fil: formatBalance({ value: balanceFIL }),
77
96
  usdfc: formatBalance({ value: balanceUSDFC.value }),
78
97
  pay: {
@@ -99,27 +118,42 @@ wallet.command('deposit', {
99
118
  }),
100
119
  options: globalOptions,
101
120
  middleware: [contextMiddleware],
102
- async *run(c) {
103
- const { client, chain } = c.var
121
+ outputPolicy: 'agent-only',
122
+ run: async (c) => {
123
+ const { client, chain, isInteractive } = c.var
124
+ const spinner = p.spinner()
104
125
 
105
126
  try {
106
- yield `Depositing ${c.args.amount} tokens to wallet...`
127
+ if (isInteractive) {
128
+ spinner.start(`Depositing ${c.args.amount} USDFC to pay account...`)
129
+ }
107
130
  const hash = await Pay.depositAndApprove(client, {
108
131
  amount: parseUnits(c.args.amount),
109
132
  })
110
- yield `Waiting for tx ${hashLink(hash, chain)} to be mined...`
133
+ if (isInteractive) {
134
+ spinner.message(`Waiting for tx ${hashLink(hash, chain)} to be mined...`)
135
+ }
111
136
  await waitForTransactionReceipt(client, {
112
137
  hash,
113
138
  })
114
- yield `Deposit successful`
115
- return
139
+ if (isInteractive) {
140
+ spinner.stop('Deposit successful.')
141
+ }
142
+ return c.ok({
143
+ amount: c.args.amount,
144
+ transactionHash: hash,
145
+ })
116
146
  } catch (error) {
147
+ const msg = error instanceof Error ? error.message : 'Failed to deposit'
148
+ if (isInteractive) {
149
+ spinner.error(msg)
150
+ }
117
151
  if (c.options.debug) {
118
152
  console.error(error)
119
153
  }
120
154
  return c.error({
121
155
  code: 'FAILED_TO_DEPOSIT',
122
- message: (error as Error).message,
156
+ message: msg,
123
157
  })
124
158
  }
125
159
  },
@@ -132,27 +166,42 @@ wallet.command('withdraw', {
132
166
  }),
133
167
  options: globalOptions,
134
168
  middleware: [contextMiddleware],
135
- async *run(c) {
136
- const { client, chain } = c.var
169
+ outputPolicy: 'agent-only',
170
+ run: async (c) => {
171
+ const { client, chain, isInteractive } = c.var
172
+ const spinner = p.spinner()
137
173
 
138
174
  try {
139
- yield `Withdrawing ${c.args.amount} USDFC from pay account...`
175
+ if (isInteractive) {
176
+ spinner.start(`Withdrawing ${c.args.amount} USDFC from pay account...`)
177
+ }
140
178
  const hash = await Pay.withdraw(client, {
141
179
  amount: parseUnits(c.args.amount),
142
180
  })
143
- yield `Waiting for tx ${hashLink(hash, chain)} to be mined...`
181
+ if (isInteractive) {
182
+ spinner.message(`Waiting for tx ${hashLink(hash, chain)} to be mined...`)
183
+ }
144
184
  await waitForTransactionReceipt(client, {
145
185
  hash,
146
186
  })
147
- yield `Withdrawal successful`
148
- return
187
+ if (isInteractive) {
188
+ spinner.stop('Withdrawal successful.')
189
+ }
190
+ return c.ok({
191
+ amount: c.args.amount,
192
+ transactionHash: hash,
193
+ })
149
194
  } catch (error) {
195
+ const msg = error instanceof Error ? error.message : 'Failed to withdraw'
196
+ if (isInteractive) {
197
+ spinner.error(msg)
198
+ }
150
199
  if (c.options.debug) {
151
200
  console.error(error)
152
201
  }
153
202
  return c.error({
154
203
  code: 'FAILED_TO_WITHDRAW',
155
- message: (error as Error).message,
204
+ message: msg,
156
205
  })
157
206
  }
158
207
  },
@@ -46,8 +46,10 @@ export async function repairCreate(options: RepairCreateOptions): Promise<number
46
46
  const [repair] = await localDb
47
47
  .insert(localSchema.repairs)
48
48
  .values({
49
+ kind: 'repair',
49
50
  repairProviderId,
50
51
  repairDataSetId: null,
52
+ sourceProviderUrl: null,
51
53
  targetProviderId: targetProvider.providerId,
52
54
  targetProviderUrl: targetProvider.serviceUrl,
53
55
  targetDataSetId: null,
@@ -69,8 +69,10 @@ export async function replicateCreate(options: ReplicateCreateOptions): Promise<
69
69
  const [repair] = await localDb
70
70
  .insert(localSchema.repairs)
71
71
  .values({
72
+ kind: 'replicate',
72
73
  repairProviderId: sourceProvider.providerId,
73
74
  repairDataSetId: sourceDataSet.dataSetId,
75
+ sourceProviderUrl: sourceProvider.serviceUrl,
74
76
  targetProviderId: targetProvider.providerId,
75
77
  targetProviderUrl: targetProvider.serviceUrl,
76
78
  targetDataSetId: null,
@@ -0,0 +1,147 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { taskLog } from '@clack/prompts'
3
+ import { getBlockNumber } from 'viem/actions'
4
+ import { NoAlternateProviderError, RepairCreationError } from '../error.ts'
5
+ import type { OperationInsert } from '../local-schema.ts'
6
+ import type { Context } from '../types.ts'
7
+ import { getRepairProvider } from './get-repair-provider.ts'
8
+
9
+ const OPERATIONS_INSERT_BATCH_SIZE = 500
10
+
11
+ export interface SecondCopyCreateOptions extends Context {
12
+ piecesFile: string
13
+ sourceProviderUrl: string
14
+ targetProviderId: bigint
15
+ }
16
+
17
+ type PiecesFile = {
18
+ aggregates: Array<{ piece_cid?: unknown }>
19
+ }
20
+
21
+ /**
22
+ * Normalize a provider service URL for storage and equality checks.
23
+ */
24
+ export function normalizeProviderUrl(url: string): string {
25
+ let parsed: URL
26
+ try {
27
+ parsed = new URL(url)
28
+ } catch {
29
+ throw new RepairCreationError(`Invalid source provider URL: ${url}`)
30
+ }
31
+
32
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
33
+ throw new RepairCreationError(`Source provider URL must be http(s): ${url}`)
34
+ }
35
+
36
+ // Drop trailing slash so endpoint comparisons are stable.
37
+ return parsed.toString().replace(/\/$/, '')
38
+ }
39
+
40
+ /**
41
+ * Parse a second-copy pieces file and return unique aggregate piece CIDs.
42
+ */
43
+ export async function parsePieceCidsFromFile(piecesFile: string): Promise<string[]> {
44
+ let raw: unknown
45
+ try {
46
+ raw = JSON.parse(await readFile(piecesFile, 'utf8'))
47
+ } catch (error) {
48
+ throw new RepairCreationError(
49
+ error instanceof Error ? `Failed to read pieces file: ${error.message}` : 'Failed to read pieces file'
50
+ )
51
+ }
52
+
53
+ if (raw == null || typeof raw !== 'object' || !Array.isArray((raw as PiecesFile).aggregates)) {
54
+ throw new RepairCreationError('Pieces file must contain an "aggregates" array')
55
+ }
56
+
57
+ const cids = new Set<string>()
58
+ for (const aggregate of (raw as PiecesFile).aggregates) {
59
+ if (typeof aggregate?.piece_cid !== 'string' || aggregate.piece_cid.length === 0) {
60
+ throw new RepairCreationError('Each aggregate must include a non-empty "piece_cid" string')
61
+ }
62
+ cids.add(aggregate.piece_cid)
63
+ }
64
+
65
+ if (cids.size === 0) {
66
+ throw new RepairCreationError('Pieces file contains no piece CIDs')
67
+ }
68
+
69
+ return [...cids]
70
+ }
71
+
72
+ /**
73
+ * Prepare a second-copy job from a pieces file and an explicit source provider URL.
74
+ *
75
+ * @param options - The options for creating a second-copy job.
76
+ * @returns The ID of the created local job.
77
+ */
78
+ export async function secondCopyCreate(options: SecondCopyCreateOptions): Promise<number> {
79
+ const { indexerDb, localDb, piecesFile, sourceProviderUrl, targetProviderId, client } = options
80
+ const localSchema = localDb._.fullSchema
81
+ const now = Date.now()
82
+ const blockNumber = await getBlockNumber(client)
83
+ const normalizedSourceUrl = normalizeProviderUrl(sourceProviderUrl)
84
+
85
+ const log = taskLog({
86
+ title: 'Creating second-copy',
87
+ limit: 10,
88
+ retainLog: true,
89
+ })
90
+
91
+ const targetProvider = await getRepairProvider({
92
+ indexerDb,
93
+ providerId: targetProviderId,
94
+ })
95
+
96
+ if (!targetProvider) {
97
+ throw new NoAlternateProviderError(targetProviderId)
98
+ }
99
+
100
+ const normalizedTargetUrl = normalizeProviderUrl(targetProvider.serviceUrl)
101
+ if (normalizedSourceUrl === normalizedTargetUrl) {
102
+ throw new RepairCreationError('Source provider URL must differ from the target provider URL')
103
+ }
104
+
105
+ const pieceCids = await parsePieceCidsFromFile(piecesFile)
106
+ log.message(`Loaded ${pieceCids.length} unique piece CIDs from ${piecesFile}`)
107
+
108
+ const [repair] = await localDb
109
+ .insert(localSchema.repairs)
110
+ .values({
111
+ kind: 'second_copy',
112
+ repairProviderId: 0n,
113
+ repairDataSetId: null,
114
+ sourceProviderUrl: normalizedSourceUrl,
115
+ targetProviderId: targetProvider.providerId,
116
+ targetProviderUrl: targetProvider.serviceUrl,
117
+ targetDataSetId: null,
118
+ blockNumber,
119
+ createdAt: now,
120
+ updatedAt: now,
121
+ })
122
+ .returning({ id: localSchema.repairs.id })
123
+
124
+ if (!repair) throw new RepairCreationError('Failed to create second-copy row')
125
+
126
+ let totalOperations = 0
127
+ for (let offset = 0; offset < pieceCids.length; offset += OPERATIONS_INSERT_BATCH_SIZE) {
128
+ const batchCids = pieceCids.slice(offset, offset + OPERATIONS_INSERT_BATCH_SIZE)
129
+ const operations: OperationInsert[] = batchCids.map((cid) => ({
130
+ repairId: repair.id,
131
+ type: 'add_piece',
132
+ status: 'pending',
133
+ cid,
134
+ metadata: {},
135
+ alternateProvider: normalizedSourceUrl,
136
+ createdAt: now,
137
+ updatedAt: now,
138
+ }))
139
+
140
+ await localDb.insert(localSchema.operations).values(operations)
141
+ totalOperations += operations.length
142
+ log.message(`Inserted ${operations.length} operations (${totalOperations}/${pieceCids.length})`)
143
+ }
144
+
145
+ log.success(`Created second-copy ${repair.id} with ${totalOperations} operations`, { showLog: true })
146
+ return repair.id
147
+ }