Skip to content

Commit 1951abb

Browse files
authored
feat: write DUDEWHERE link and SATNAV car indexes. (#2238)
- write a CARv2 index so we can use the `SATNAV` bucket to find where Blocks are. - write a key to map root CID to car CID so we can use the `DUDEWHERE` bucket to find where our CAR is in `CARPARK` (for a given root CID). This enables [freeway](https://github.com/web3-storage/freeway) to lookup CAR CID(s) for a given root data CID and UnixFS export data directly from R2. port of web3-storage/web3.storage#2035 License: MIT Signed-off-by: Oli Evans <oli@protocol.ai>
1 parent 109462f commit 1951abb

10 files changed

Lines changed: 216 additions & 31 deletions

File tree

packages/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@nftstorage/ipfs-cluster": "^5.0.1",
2828
"@noble/ed25519": "^1.6.1",
2929
"@supabase/postgrest-js": "^0.34.1",
30+
"cardex": "^1.0.0",
3031
"ipfs-car": "^0.6.1",
3132
"it-last": "^2.0.0",
3233
"merge-options": "^3.0.4",

packages/api/src/bindings.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ export interface ServiceConfiguration {
3434
/** Salt for API key generation */
3535
SALT: string
3636

37-
/** R2Bucket binding */
37+
/** R2Bucket for CARv2 indexes mapping block offsets within CAR files. */
38+
SATNAV: R2Bucket
39+
40+
/** R2Bucket mapping root data CIDs to CAR CID(s). */
41+
DUDEWHERE: R2Bucket
42+
43+
/** R2Bucket for CAR files */
3844
CARPARK: R2Bucket
3945

4046
/** Public URL prefix for CARPARK R2 Bucket */

packages/api/src/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export function serviceConfigFromVariables(vars) {
5959
MAINTENANCE_MODE: maintenanceModeFromString(vars.MAINTENANCE_MODE),
6060

6161
SALT: vars.SALT,
62+
SATNAV: vars.SATNAV,
63+
DUDEWHERE: vars.DUDEWHERE,
6264
CARPARK: vars.CARPARK,
6365
CARPARK_URL: vars.CARPARK_URL,
6466
DATABASE_URL: vars.DATABASE_URL,
@@ -108,6 +110,8 @@ export function loadConfigVariables() {
108110
'ENV',
109111
'DEBUG',
110112
'SALT',
113+
'SATNAV',
114+
'DUDEWHERE',
111115
'CARPARK',
112116
'CARPARK_URL',
113117
'DATABASE_URL',

packages/api/src/utils/car.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { CID } from 'multiformats/cid'
22
import { CarWriter } from '@ipld/car'
33
import { sha256 } from 'multiformats/hashes/sha2'
4+
import { CarIndexer } from '@ipld/car/indexer'
5+
import { MultihashIndexSortedWriter } from 'cardex'
46

57
/**
68
* @typedef {import('multiformats/block').Block<unknown>} Block
@@ -39,3 +41,23 @@ export const CAR_CODE = 0x202
3941
export async function createCarCid(carBytes) {
4042
return CID.createV1(CAR_CODE, await sha256.digest(carBytes))
4143
}
44+
45+
/**
46+
* @param {Uint8Array} carBytes
47+
*/
48+
export async function createCarIndex(carBytes) {
49+
const indexer = await CarIndexer.fromBytes(carBytes)
50+
const { writer, out } = MultihashIndexSortedWriter.create()
51+
52+
for await (const blockIndexData of indexer) {
53+
// @ts-expect-error CID impl conflict
54+
writer.put(blockIndexData)
55+
}
56+
writer.close()
57+
58+
const chunks = []
59+
for await (const chunk of out) {
60+
chunks.push(chunk)
61+
}
62+
return new Uint8Array(await new Blob(chunks).arrayBuffer())
63+
}

packages/api/src/utils/context.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ export async function getContext(event, params) {
2727
{ endpoint: config.S3_ENDPOINT, appName: 'nft' }
2828
)
2929

30-
const r2Uploader = new R2Uploader(config.CARPARK, config.CARPARK_URL)
30+
const r2Uploader = new R2Uploader({
31+
carpark: config.CARPARK,
32+
publicUrl: config.CARPARK_URL,
33+
dudewhere: config.DUDEWHERE,
34+
satnav: config.SATNAV,
35+
})
3136

3237
const linkdexApi = config.LINKDEX_URL
3338
? new LinkdexApi(config.LINKDEX_URL)
Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
import pRetry from 'p-retry'
2+
import { sha256 } from 'multiformats/hashes/sha2'
23
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
4+
import { createCarIndex } from '../car.js'
35

46
/**
57
* @typedef {import('../../bindings').Uploader} Uploader
68
* @implements {Uploader}
79
*/
810
export class R2Uploader {
911
/**
10-
* @param {R2Bucket} bucket
11-
* @param {string} publicUrl
12+
* @param {object} config
13+
* @param {R2Bucket} config.carpark
14+
* @param {R2Bucket} config.dudewhere
15+
* @param {R2Bucket} config.satnav
16+
* @param {string} config.publicUrl
1217
*/
13-
constructor(bucket, publicUrl) {
14-
if (!bucket) throw new Error('missing R2 bucket')
15-
if (!publicUrl) throw new Error('missing public url for R2 bucket')
16-
17-
/**
18-
* @private
19-
*/
20-
this._bucket = bucket
21-
22-
/**
23-
* @private
24-
*/
18+
constructor({ carpark, dudewhere, satnav, publicUrl }) {
19+
if (!carpark) throw new Error('missing carpark R2 bucket')
20+
if (!dudewhere) throw new Error('missing dudewhere R2 bucket')
21+
if (!satnav) throw new Error('missing satnav R2 bucket')
22+
if (!publicUrl) throw new Error('missing public url for carpark R2 bucket')
23+
this._carpark = carpark
24+
this._dudewhere = dudewhere
25+
this._satnav = satnav
2526
this._publicUrl = new URL(publicUrl)
2627
}
2728

@@ -42,14 +43,55 @@ export class R2Uploader {
4243
customMetadata: metadata,
4344
}
4445

45-
const put = () => this._bucket.put(key, carBytes, opts)
46+
const put = () => this._carpark.put(key, carBytes, opts)
4647

4748
try {
4849
await pRetry(put, { retries: 3, onFailedAttempt: console.log })
50+
await Promise.all([
51+
this.uploadDudeWhereLink(metadata.rootCid, carCid),
52+
this.uploadSatNavIndex(carBytes, carCid),
53+
])
4954
return { key, url }
5055
} catch (cause) {
5156
// @ts-expect-error wen ts understand Error object?
5257
throw new Error('Failed to upload CAR to R2', { cause })
5358
}
5459
}
60+
61+
/**
62+
* Write a CARv2 index for the passed CAR file to R2.
63+
* @param {import('multiformats').CID} carCid
64+
* @param {Uint8Array} carBytes
65+
*/
66+
async uploadSatNavIndex(carBytes, carCid) {
67+
const indexBytes = await createCarIndex(carBytes)
68+
const digest = await sha256.encode(indexBytes)
69+
const key = `${carCid}/${carCid}.car.idx`
70+
const opts = { sha256: uint8ArrayToString(digest, 'base16') }
71+
try {
72+
return await pRetry(async () => this._satnav.put(key, indexBytes, opts), {
73+
retries: 3,
74+
})
75+
} catch (cause) {
76+
// @ts-expect-error error.cause is legit.
77+
throw new Error('Failed to write satnav index to R2', { cause })
78+
}
79+
}
80+
81+
/**
82+
* @param {string} rootCid
83+
* @param {import('multiformats').CID} carCid
84+
*/
85+
async uploadDudeWhereLink(rootCid, carCid) {
86+
const key = `${rootCid}/${carCid}`
87+
const data = new Uint8Array()
88+
try {
89+
return await pRetry(async () => this._dudewhere.put(key, data), {
90+
retries: 3,
91+
})
92+
} catch (cause) {
93+
// @ts-expect-error error.cause is legit.
94+
throw new Error('Failed to write dudewhere index to R2', { cause })
95+
}
96+
}
5597
}

packages/api/test/config.spec.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const BASE_CONFIG = {
3636
CLUSTER_API_URL: 'http://127.0.0.1:9094',
3737
S3_ENDPOINT: 'http://127.0.0.1:9000',
3838
SLACK_USER_REQUEST_WEBHOOK_URL: '',
39+
SATNAV: '?',
40+
DUDEWHERE: '?',
3941
CARPARK: '?',
4042
CARPARK_URL: 'http://example.org',
4143
LINKDEX_URL: 'http://example.org',

packages/api/test/nfts-upload.spec.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import test from 'ava'
2-
import pRetry from 'p-retry'
32
import { CID } from 'multiformats/cid'
43
import * as Block from 'multiformats/block'
54
import { sha256, sha512 } from 'multiformats/hashes/sha2'
65
import * as pb from '@ipld/dag-pb'
76
import { CarWriter } from '@ipld/car'
87
import { packToBlob } from 'ipfs-car/pack/blob'
8+
import { MultihashIndexSortedReader } from 'cardex'
99
import { TreewalkCarSplitter } from 'carbites/treewalk'
10+
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
1011
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
1112
import { createClientWithUser, getRawClient } from './scripts/helpers.js'
1213
import { createCar } from './scripts/car.js'
@@ -21,6 +22,7 @@ import {
2122
import { File } from 'nft.storage/src/platform.js'
2223
import crypto from 'node:crypto'
2324
import { FormData } from 'undici'
25+
import { createCarCid } from '../src/utils/car.js'
2426

2527
test.before(async (t) => {
2628
const linkdexUrl = 'http://fake.api.net'
@@ -735,6 +737,75 @@ test.serial('should update a single file', async (t) => {
735737
t.is(uploadData.name, name)
736738
})
737739

740+
test.serial('should write satnav index', async (t) => {
741+
const client = await createClientWithUser(t)
742+
const config = getTestServiceConfig(t)
743+
const mf = getMiniflareContext(t)
744+
const { root, car: carBody } = await createCar('satnav')
745+
const carBytes = new Uint8Array(await carBody.arrayBuffer())
746+
const carCid = await createCarCid(carBytes)
747+
748+
const res = await mf.dispatchFetch('http://miniflare.test/upload', {
749+
method: 'POST',
750+
headers: {
751+
Authorization: `Bearer ${client.token}`,
752+
'Content-Type': 'application/car',
753+
},
754+
body: carBody,
755+
})
756+
757+
const { ok, value } = await res.json()
758+
t.truthy(ok, 'Server response payload has `ok` property')
759+
t.is(value.cid, root.toString(), 'Server responded with expected CID')
760+
t.is(value.type, 'application/car', 'type should match car mime-type')
761+
762+
const r2Bucket = await mf.getR2Bucket('SATNAV')
763+
const r2Object = await r2Bucket.get(`${carCid}/${carCid}.car.idx`)
764+
if (!r2Object?.body) {
765+
t.fail('repsonse stream must exist')
766+
}
767+
// @ts-expect-error
768+
const reader = MultihashIndexSortedReader.fromIterable(r2Object?.body)
769+
const entries = []
770+
for await (const entry of reader.entries()) {
771+
entries.push(entry)
772+
}
773+
774+
t.is(entries.length, 1, 'Index contains a single entry')
775+
t.true(
776+
uint8ArrayEquals(entries[0].digest, root.multihash.digest),
777+
'Index entry is for root data CID'
778+
)
779+
})
780+
781+
test.serial('should write dudewhere index', async (t) => {
782+
const client = await createClientWithUser(t)
783+
const config = getTestServiceConfig(t)
784+
const mf = getMiniflareContext(t)
785+
const { root, car: carBody } = await createCar('dude')
786+
const carBytes = new Uint8Array(await carBody.arrayBuffer())
787+
const carCid = await createCarCid(carBytes)
788+
789+
const res = await mf.dispatchFetch('http://miniflare.test/upload', {
790+
method: 'POST',
791+
headers: {
792+
Authorization: `Bearer ${client.token}`,
793+
'Content-Type': 'application/car',
794+
},
795+
body: carBody,
796+
})
797+
798+
const { ok, value } = await res.json()
799+
t.truthy(ok, 'Server response payload has `ok` property')
800+
t.is(value.cid, root.toString(), 'Server responded with expected CID')
801+
t.is(value.type, 'application/car', 'type should match car mime-type')
802+
803+
const r2Bucket = await mf.getR2Bucket('DUDEWHERE')
804+
const r2Objects = await r2Bucket.list({ prefix: `${root}/` })
805+
t.is(r2Objects.objects.length, 1)
806+
t.is(r2Objects.objects[0].key, `${root}/${carCid}`)
807+
})
808+
738809
/**
739810
* @param {Uint8Array} data
740811
*/

packages/api/wrangler.toml

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,21 @@ workers_dev = true
99
compatibility_date = "2021-08-23"
1010
compatibility_flags = ["formdata_parser_supports_files"]
1111
no_bundle = true
12+
r2_buckets = [
13+
# CAR files live here.
14+
{ binding = "CARPARK", bucket_name = "carpark-staging-0" },
15+
# Mapping root data CIDs to CAR CID(s).
16+
{ binding = "DUDEWHERE", bucket_name = "dudewhere-staging-0" },
17+
# CARv2 indexes - a mapping of block offsets within CAR files.
18+
{ binding = "SATNAV", bucket_name = "satnav-staging-0" }
19+
]
1220

1321
[vars]
1422
ENV = "dev"
1523
DEBUG = "true"
1624
DATABASE_URL = "http://localhost:3000"
1725
CARPARK_URL = "https://carpark-dev.web3.storage"
1826

19-
[[r2_buckets]]
20-
binding = 'CARPARK'
21-
bucket_name = 'carpark-dev-0'
22-
2327
[build]
2428
command = "scripts/cli.js build"
2529

@@ -29,17 +33,18 @@ command = "scripts/cli.js build"
2933
name = "nft-storage-staging"
3034
route = "api-staging.nft.storage/*"
3135
usage_model = "unbound"
36+
r2_buckets = [
37+
{ binding = "CARPARK", bucket_name = "carpark-staging-0" },
38+
{ binding = "DUDEWHERE", bucket_name = "dudewhere-staging-0" },
39+
{ binding = "SATNAV", bucket_name = "satnav-staging-0" }
40+
]
3241

3342
[env.staging.vars]
3443
ENV = "staging"
3544
DEBUG = "true"
3645
DATABASE_URL = "https://nft-storage-pgrest-staging.herokuapp.com"
3746
CARPARK_URL = "https://carpark-staging.web3.storage"
3847

39-
[[env.staging.r2_buckets]]
40-
binding = 'CARPARK'
41-
bucket_name = 'carpark-staging-0'
42-
4348
[env.staging.build]
4449
command = "scripts/cli.js build --env staging"
4550
watch_dir = "src"
@@ -53,17 +58,18 @@ format = "service-worker"
5358
name = "nft-storage"
5459
route = "api.nft.storage/*"
5560
usage_model = "unbound"
61+
r2_buckets = [
62+
{ binding = "CARPARK", bucket_name = "carpark-prod-0" },
63+
{ binding = "DUDEWHERE", bucket_name = "dudewhere-prod-0" },
64+
{ binding = "SATNAV", bucket_name = "satnav-prod-0" }
65+
]
5666

5767
[env.production.vars]
5868
ENV = "production"
5969
DEBUG = "false"
6070
DATABASE_URL = "https://nft-storage-pgrest-prod.herokuapp.com"
6171
CARPARK_URL = "https://carpark.web3.storage"
6272

63-
[[env.production.r2_buckets]]
64-
binding = 'CARPARK'
65-
bucket_name = 'carpark-prod-0'
66-
6773
[env.production.build]
6874
command = "scripts/cli.js build --env production"
6975
watch_dir = "src"

0 commit comments

Comments
 (0)