Skip to content

Commit 750cd44

Browse files
aspiersClaude (Anthropic AI)
andcommitted
feat(sdk-core): support multiple locations for activity claims
Without this patch, the SDK only allowed a single location per hypercert activity claim, using a singular 'location' field in CreateHypercertParams. This was inconsistent with the lexicon specification (org.hypercerts.claim.activity) which defines 'locations' as an array of StrongRefs. This is a problem because: - The SDK didn't match the underlying lexicon schema - Users couldn't create hypercerts for activities spanning multiple places - Attempting to attach multiple locations would overwrite previous ones This patch solves the problem by: - Renaming 'location' to 'locations' (array) in CreateHypercertParams - Updating CreateHypercertResult to return locationUris/locationCids arrays - Refactoring processLocation() to processLocations() to handle arrays - Modifying attachLocation() to append to existing locations array - Adding comprehensive tests for multiple location support Breaking Changes: - CreateHypercertParams.location → CreateHypercertParams.locations (array) - CreateHypercertResult.locationUri → CreateHypercertResult.locationUris (array) - CreateHypercertResult.locationCid → CreateHypercertResult.locationCids (array) Also fixed: - Documentation examples now use correct field names (startDate/endDate) - README example updated to match current API Co-authored-by: Claude (Anthropic AI) <claude@anthropic.noreply.github.com>
1 parent 3b58749 commit 750cd44

5 files changed

Lines changed: 194 additions & 64 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@hypercerts-org/sdk-core": minor
3+
---
4+
5+
Support multiple locations for hypercert activity claims
6+
7+
**Breaking Changes:**
8+
9+
- `CreateHypercertParams.location` is now `CreateHypercertParams.locations` (plural, array)
10+
- `CreateHypercertResult.locationUri` is now `CreateHypercertResult.locationUris` (plural, array)
11+
- `CreateHypercertResult.locationCid` is now `CreateHypercertResult.locationCids` (plural, array)
12+
13+
**New Functionality:**
14+
15+
- Hypercerts can now have multiple locations to support activities spanning multiple places
16+
- Each location can be a StrongRef, string URI, or location object
17+
- `attachLocation()` now appends to existing locations array instead of replacing
18+
19+
**Migration:**
20+
21+
```typescript
22+
// Before (v0.10.0-beta.5 and earlier)
23+
await repo.hypercerts.create({
24+
...params,
25+
location: {
26+
lpVersion: "1.0.0",
27+
srs: "EPSG:4326",
28+
locationType: "coordinate-decimal",
29+
location: "https://example.com/location",
30+
},
31+
});
32+
33+
// After (v0.10.0-beta.6+)
34+
await repo.hypercerts.create({
35+
...params,
36+
locations: [
37+
{
38+
lpVersion: "1.0.0",
39+
srs: "EPSG:4326",
40+
locationType: "coordinate-decimal",
41+
location: "https://example.com/location",
42+
},
43+
],
44+
});
45+
46+
// Now supports multiple locations
47+
await repo.hypercerts.create({
48+
...params,
49+
locations: [
50+
{ location: "https://example.com/location1", ... },
51+
{ location: "https://example.com/location2", ... },
52+
],
53+
});
54+
```

packages/sdk-core/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ const claim = await repo.hypercerts.create({
4141
shortDescription: "1000 trees planted in rainforest",
4242
description: "Planted 1000 trees in the Amazon rainforest region",
4343
workScope: "Environmental Conservation",
44-
workTimeFrameFrom: "2025-01-01T00:00:00Z",
45-
workTimeFrameTo: "2025-12-31T23:59:59Z",
44+
startDate: "2025-01-01",
45+
endDate: "2025-12-31",
4646
rights: {
4747
name: "Attribution",
4848
type: "license",

packages/sdk-core/src/repository/HypercertOperationsImpl.ts

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
252252
* @param rightsUri - URI of the associated rights record
253253
* @param rightsCid - CID of the associated rights record
254254
* @param imageBlobRef - Optional image blob reference
255-
* @param locationRef - Optional strong reference to the associated location record
255+
* @param locationRefs - Optional array of strong references to the associated location records
256256
* @param contributorsData - Optional array of contributor data (inline or StrongRef) to embed in the claim
257257
* @param createdAt - ISO timestamp for creation
258258
* @param onProgress - Optional progress callback
@@ -266,7 +266,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
266266
rightsUri: string,
267267
rightsCid: string,
268268
imageBlobRef: JsonBlobRef | undefined,
269-
locationRef: { uri: string; cid: string } | undefined,
269+
locationRefs: Array<{ uri: string; cid: string }> | undefined,
270270
contributorsData:
271271
| Array<{ contributorIdentity: string; contributionDetails?: string | { uri: string; cid: string } }>
272272
| undefined,
@@ -290,9 +290,9 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
290290
hypercertRecord.image = imageBlobRef;
291291
}
292292

293-
// Add location as embedded StrongRef if provided
294-
if (locationRef) {
295-
hypercertRecord.locations = [{ uri: locationRef.uri, cid: locationRef.cid }];
293+
// Add locations as embedded StrongRefs if provided
294+
if (locationRefs && locationRefs.length > 0) {
295+
hypercertRecord.locations = locationRefs.map((ref) => ({ uri: ref.uri, cid: ref.cid }));
296296
}
297297

298298
if (params.shortDescriptionFacets) {
@@ -349,8 +349,8 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
349349
type: params.rights.type,
350350
description: params.rights.description,
351351
},
352-
// Location: use resolved StrongRef (uri+cid), not raw params which may be Blob
353-
locationRef: locationRef ? { uri: locationRef.uri, cid: locationRef.cid } : undefined,
352+
// Locations: use resolved StrongRefs (uri+cid), not raw params which may be Blob
353+
locationRefs: locationRefs?.map((ref) => ({ uri: ref.uri, cid: ref.cid })),
354354
// Contributors: use already-processed canonical format from processContributors()
355355
contributors: contributorsData,
356356
};
@@ -522,9 +522,10 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
522522
* const result = await repo.hypercerts.create({
523523
* title: "My Impact",
524524
* description: "Description of impact work",
525+
* shortDescription: "Impact work",
525526
* workScope: "Education",
526-
* workTimeframeFrom: "2024-01-01",
527-
* workTimeframeTo: "2024-06-30",
527+
* startDate: "2024-01-01",
528+
* endDate: "2024-06-30",
528529
* rights: {
529530
* name: "Attribution",
530531
* type: "license",
@@ -540,11 +541,11 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
540541
* description: "Planted 10,000 trees...",
541542
* shortDescription: "10K trees planted",
542543
* workScope: "Environment",
543-
* workTimeframeFrom: "2024-01-01",
544-
* workTimeframeTo: "2024-12-31",
544+
* startDate: "2024-01-01",
545+
* endDate: "2024-12-31",
545546
* rights: { name: "Open", type: "impact", description: "..." },
546547
* image: coverImageBlob,
547-
* location: { value: "Amazon, Brazil", name: "Amazon Basin" },
548+
* locations: [{ value: "Amazon, Brazil", name: "Amazon Basin" }],
548549
* contributions: [
549550
* { contributors: ["did:plc:org1"], role: "coordinator" },
550551
* { contributors: ["did:plc:org2"], role: "implementer" },
@@ -567,12 +568,12 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
567568
// Step 1: Upload image if provided
568569
const imageBlobRef = params.image ? await this.uploadImageBlob(params.image, params.onProgress) : undefined;
569570

570-
// Step 2: Create location record if provided (must be before hypercert)
571-
// If location is provided, it must succeed - failing silently would change the rKey on retries
572-
const locationRef = await this.processLocation(params.location, params.onProgress);
573-
if (locationRef) {
574-
result.locationUri = locationRef.uri;
575-
result.locationCid = locationRef.cid;
571+
// Step 2: Create location records if provided (must be before hypercert)
572+
// If locations are provided, they must succeed - failing silently would change the rKey on retries
573+
const locationRefs = await this.processLocations(params.locations, params.onProgress);
574+
if (locationRefs && locationRefs.length > 0) {
575+
result.locationUris = locationRefs.map((ref) => ref.uri);
576+
result.locationCids = locationRefs.map((ref) => ref.cid);
576577
}
577578

578579
// Step 3: Create rights record
@@ -587,13 +588,13 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
587588
// Step 4: Build contributors data for embedding (if provided)
588589
const contributorsData = await this.processContributors(params.contributions, params.onProgress);
589590

590-
// Step 5: Create hypercert record (with embedded location, rights, and contributors)
591+
// Step 5: Create hypercert record (with embedded locations, rights, and contributors)
591592
const { uri: hypercertUri, cid: hypercertCid } = await this.createHypercertRecord(
592593
params,
593594
rightsUri,
594595
rightsCid,
595596
imageBlobRef,
596-
locationRef,
597+
locationRefs,
597598
contributorsData,
598599
createdAt,
599600
params.onProgress,
@@ -914,18 +915,25 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
914915
*/
915916
async attachLocation(hypercertUri: string, location: LocationParams): Promise<CreateResult> {
916917
try {
917-
// Validate that hypercert exists (unused but confirms hypercert is valid)
918-
await this.get(hypercertUri);
918+
// Get existing hypercert to preserve current locations
919+
const existing = await this.get(hypercertUri);
919920
const resolvedLocation = await this.resolveLocation(location);
920921

922+
// Build new locations array: existing + new location
923+
const existingLocations = existing.record.locations || [];
924+
const newLocations = [
925+
...existingLocations,
926+
{
927+
$type: "com.atproto.repo.strongRef",
928+
uri: resolvedLocation.uri,
929+
cid: resolvedLocation.cid,
930+
} as StrongRef,
931+
];
932+
921933
await this.update({
922934
uri: hypercertUri,
923935
updates: {
924-
location: {
925-
$type: "com.atproto.repo.strongRef",
926-
uri: resolvedLocation.uri,
927-
cid: resolvedLocation.cid,
928-
},
936+
locations: newLocations,
929937
},
930938
});
931939

@@ -1108,28 +1116,28 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
11081116
}
11091117

11101118
/**
1111-
* Processes location parameters, creating a location record if necessary.
1119+
* Processes location parameters, creating location records if necessary.
11121120
*
1113-
* @param locationParams - Location parameters from create request
1121+
* @param locationParams - Location parameters array from create request
11141122
* @param onProgress - Optional progress callback
1115-
* @returns Promise resolving to location StrongRef or undefined
1123+
* @returns Promise resolving to array of location StrongRefs or undefined
11161124
* @internal
11171125
*/
1118-
private async processLocation(
1119-
locationParams: LocationParams | undefined,
1126+
private async processLocations(
1127+
locationParams: LocationParams[] | undefined,
11201128
onProgress?: (step: ProgressStep) => void,
1121-
): Promise<{ uri: string; cid: string } | undefined> {
1122-
if (!locationParams) return undefined;
1129+
): Promise<Array<{ uri: string; cid: string }> | undefined> {
1130+
if (!locationParams || locationParams.length === 0) return undefined;
11231131

11241132
try {
11251133
this.emitProgress(onProgress, { name: "createLocation", status: "start" });
1126-
const locationRef = await this.resolveLocation(locationParams);
1134+
const locationRefs = await Promise.all(locationParams.map((loc) => this.resolveLocation(loc)));
11271135
this.emitProgress(onProgress, {
11281136
name: "createLocation",
11291137
status: "success",
1130-
data: { uri: locationRef.uri },
1138+
data: { count: locationRefs.length },
11311139
});
1132-
return locationRef;
1140+
return locationRefs;
11331141
} catch (error) {
11341142
this.emitProgress(onProgress, { name: "createLocation", status: "error", error: error as Error });
11351143
this.logger?.warn(`Failed to create location: ${error instanceof Error ? error.message : "Unknown"}`);

packages/sdk-core/src/repository/interfaces.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ export type { LocationParams };
5151
* const params: CreateHypercertParams = {
5252
* title: "Community Garden Project",
5353
* description: "Established a 1-acre community garden serving 50 families",
54+
* shortDescription: "1-acre community garden",
5455
* workScope: "Food Security",
55-
* workTimeframeFrom: "2024-01-01",
56-
* workTimeframeTo: "2024-06-30",
56+
* startDate: "2024-01-01",
57+
* endDate: "2024-06-30",
5758
* rights: {
5859
* name: "Attribution",
5960
* type: "license",
@@ -69,19 +70,21 @@ export type { LocationParams };
6970
* description: "Planted 10,000 trees in deforested areas",
7071
* shortDescription: "10K trees planted",
7172
* workScope: "Environmental Restoration",
72-
* workTimeframeFrom: "2024-01-01",
73-
* workTimeframeTo: "2024-12-31",
73+
* startDate: "2024-01-01",
74+
* endDate: "2024-12-31",
7475
* rights: {
7576
* name: "Open Impact",
7677
* type: "impact-rights",
7778
* description: "Transferable impact rights",
7879
* },
7980
* image: coverImageBlob,
80-
* location: {
81-
* value: "Amazon Rainforest, Brazil",
82-
* name: "Amazon Basin",
83-
* description: "Southern Amazon region",
84-
* },
81+
* locations: [
82+
* {
83+
* value: "Amazon Rainforest, Brazil",
84+
* name: "Amazon Basin",
85+
* description: "Southern Amazon region",
86+
* },
87+
* ],
8588
* contributions: [
8689
* {
8790
* contributors: ["did:plc:lead-org"],
@@ -225,9 +228,12 @@ export interface CreateHypercertParams {
225228
image?: Blob;
226229

227230
/**
228-
* Optional geographic location of the impact.
231+
* Optional geographic locations of the impact.
232+
*
233+
* Can provide multiple locations for activities spanning multiple places.
234+
* Each location can be a StrongRef, string URI, or location object.
229235
*/
230-
location?: LocationParams;
236+
locations?: LocationParams[];
231237

232238
/**
233239
* Optional list of contributions to the impact.
@@ -356,14 +362,14 @@ export interface CreateHypercertResult {
356362
rightsCid: string;
357363

358364
/**
359-
* AT-URI of the location record, if location was provided.
365+
* AT-URIs of location records, if locations were provided.
360366
*/
361-
locationUri?: string;
367+
locationUris?: string[];
362368

363369
/**
364-
* CID of the location record
370+
* CIDs of location records, if locations were provided.
365371
*/
366-
locationCid?: string;
372+
locationCids?: string[];
367373

368374
/**
369375
* AT-URIs of contribution records, if contributions were provided.

0 commit comments

Comments
 (0)