Skip to content

Commit fde309e

Browse files
s-adamantineaspiers
authored andcommitted
feat(sdk-core): add ContributorIdentityParams union type
Contributors now support: - string: DID (e.g., "did:plc:abc123") - { uri, cid }: StrongRef to existing contributorInformation record - CreateContributorInformationParams: auto-create record Added: - CreateContributorInformationParams interface - ContributorIdentityParams + ResolvedContributorIdentity types - addContributorInformation() method - resolveContributorIdentity() helper - contributorCreated event 124 tests passing.
1 parent 13c7e7c commit fde309e

3 files changed

Lines changed: 267 additions & 55 deletions

File tree

packages/sdk-core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export type {
9696
LocationParams,
9797
ContributionDetailsParams,
9898
CreateContributionDetailsParams,
99+
ResolvedContributionDetails,
100+
ContributorIdentityParams,
101+
CreateContributorInformationParams,
102+
ResolvedContributorIdentity,
99103
} from "./repository/interfaces.js";
100104

101105
// ============================================================================

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

Lines changed: 208 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type HypercertClaim,
2323
type HypercertCollection,
2424
type HypercertContributionDetails,
25+
type HypercertContributorInformation,
2526
type HypercertEvaluation,
2627
type HypercertEvidence,
2728
type HypercertLocation,
@@ -43,6 +44,8 @@ import type {
4344
HypercertOperations,
4445
ContributionDetailsParams,
4546
ResolvedContributionDetails,
47+
ContributorIdentityParams,
48+
ResolvedContributorIdentity,
4649
} from "./interfaces.js";
4750
import type { CreateResult, ListParams, PaginatedList, ProgressStep, UpdateResult } from "./types.js";
4851
import { $Typed } from "@atproto/api";
@@ -271,7 +274,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
271274
locationRefs: Array<{ uri: string; cid: string }> | undefined,
272275
contributorsData:
273276
| Array<{
274-
contributorIdentity: string | { uri: string; cid: string };
277+
contributorIdentity: ResolvedContributorIdentity;
275278
contributionWeight?: string;
276279
contributionDetails?: ResolvedContributionDetails;
277280
}>
@@ -1161,7 +1164,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
11611164
}
11621165

11631166
/**
1164-
* Processes contribution parameters, creating detailed contribution records if necessary.
1167+
* Processes contribution parameters, creating contributor/contribution records if necessary.
11651168
*
11661169
* @param contributions - Array of contribution parameters
11671170
* @param onProgress - Optional progress callback
@@ -1171,15 +1174,15 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
11711174
private async processContributors(
11721175
contributions:
11731176
| Array<{
1174-
contributors: Array<string | { uri: string; cid: string }>;
1177+
contributors: Array<ContributorIdentityParams>;
11751178
contributionDetails: ContributionDetailsParams;
11761179
weight?: string;
11771180
}>
11781181
| undefined,
11791182
onProgress?: (step: ProgressStep) => void,
11801183
): Promise<
11811184
| Array<{
1182-
contributorIdentity: string | { uri: string; cid: string };
1185+
contributorIdentity: ResolvedContributorIdentity;
11831186
contributionWeight?: string;
11841187
contributionDetails?: ResolvedContributionDetails;
11851188
}>
@@ -1188,55 +1191,16 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
11881191
if (!contributions || contributions.length === 0) return undefined;
11891192

11901193
const contributorsPromises = contributions.map(async (contrib) => {
1191-
let detailsRef: ResolvedContributionDetails;
1192-
const details = contrib.contributionDetails;
1193-
1194-
// Determine the type of contributionDetails
1195-
if (typeof details === "string") {
1196-
// Inline role string
1197-
detailsRef = details;
1198-
} else if ("uri" in details && "cid" in details && !("role" in details)) {
1199-
// StrongRef to existing record (has uri+cid but no role)
1200-
detailsRef = { uri: details.uri as string, cid: details.cid as string };
1201-
} else if ("role" in details) {
1202-
// CreateContributionDetailsParams - auto-create record
1203-
try {
1204-
this.emitProgress(onProgress, { name: "createContribution", status: "start" });
1205-
const { role, contributionDescription, startDate, endDate, ...extraProps } = details as {
1206-
role: string;
1207-
contributionDescription?: string;
1208-
startDate?: string;
1209-
endDate?: string;
1210-
[key: string]: unknown;
1211-
};
1212-
const result = await this.addContribution({
1213-
role,
1214-
description: contributionDescription,
1215-
startDate,
1216-
endDate,
1217-
...extraProps,
1218-
});
1219-
detailsRef = { uri: result.uri, cid: result.cid };
1220-
this.emitProgress(onProgress, {
1221-
name: "createContribution",
1222-
status: "success",
1223-
data: result,
1224-
});
1225-
} catch (error) {
1226-
this.emitProgress(onProgress, {
1227-
name: "createContribution",
1228-
status: "error",
1229-
error: error as Error,
1230-
});
1231-
throw error;
1232-
}
1233-
} else {
1234-
// Fallback - shouldn't happen with proper types
1235-
throw new Error("Invalid contributionDetails format");
1236-
}
1194+
// Resolve contributionDetails
1195+
const detailsRef = await this.resolveContributionDetails(contrib.contributionDetails, onProgress);
12371196

1238-
// Expand to one entry per contributor (DID string or StrongRef)
1239-
return contrib.contributors.map((identity) => ({
1197+
// Resolve each contributor identity
1198+
const resolvedContributors = await Promise.all(
1199+
contrib.contributors.map((identity) => this.resolveContributorIdentity(identity, onProgress)),
1200+
);
1201+
1202+
// Expand to one entry per contributor
1203+
return resolvedContributors.map((identity) => ({
12401204
contributorIdentity: identity,
12411205
contributionWeight: contrib.weight,
12421206
contributionDetails: detailsRef,
@@ -1247,6 +1211,116 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
12471211
return nestedContributors.flat();
12481212
}
12491213

1214+
/**
1215+
* Resolves ContributionDetailsParams to a ResolvedContributionDetails.
1216+
* Creates a record if CreateContributionDetailsParams is provided.
1217+
* @internal
1218+
*/
1219+
private async resolveContributionDetails(
1220+
details: ContributionDetailsParams,
1221+
onProgress?: (step: ProgressStep) => void,
1222+
): Promise<ResolvedContributionDetails> {
1223+
if (typeof details === "string") {
1224+
// Inline role string
1225+
return details;
1226+
} else if ("uri" in details && "cid" in details && !("role" in details)) {
1227+
// StrongRef to existing record
1228+
return { uri: details.uri as string, cid: details.cid as string };
1229+
} else if ("role" in details) {
1230+
// CreateContributionDetailsParams - auto-create record
1231+
try {
1232+
this.emitProgress(onProgress, { name: "createContribution", status: "start" });
1233+
const { role, contributionDescription, startDate, endDate, ...extraProps } = details as {
1234+
role: string;
1235+
contributionDescription?: string;
1236+
startDate?: string;
1237+
endDate?: string;
1238+
[key: string]: unknown;
1239+
};
1240+
const result = await this.addContribution({
1241+
role,
1242+
description: contributionDescription,
1243+
startDate,
1244+
endDate,
1245+
...extraProps,
1246+
});
1247+
this.emitProgress(onProgress, {
1248+
name: "createContribution",
1249+
status: "success",
1250+
data: result,
1251+
});
1252+
return { uri: result.uri, cid: result.cid };
1253+
} catch (error) {
1254+
this.emitProgress(onProgress, {
1255+
name: "createContribution",
1256+
status: "error",
1257+
error: error as Error,
1258+
});
1259+
throw error;
1260+
}
1261+
}
1262+
throw new Error("Invalid contributionDetails format");
1263+
}
1264+
1265+
/**
1266+
* Resolves ContributorIdentityParams to a ResolvedContributorIdentity.
1267+
* Creates a contributorInformation record if CreateContributorInformationParams is provided.
1268+
* @internal
1269+
*/
1270+
private async resolveContributorIdentity(
1271+
identity: ContributorIdentityParams,
1272+
onProgress?: (step: ProgressStep) => void,
1273+
): Promise<ResolvedContributorIdentity> {
1274+
if (typeof identity === "string") {
1275+
// DID or inline string
1276+
return identity;
1277+
} else if ("uri" in identity && "cid" in identity && !("identifier" in identity)) {
1278+
// StrongRef to existing record
1279+
return { uri: identity.uri as string, cid: identity.cid as string };
1280+
} else if ("identifier" in identity) {
1281+
// CreateContributorInformationParams - auto-create record
1282+
try {
1283+
this.emitProgress(onProgress, { name: "createContributorInformation", status: "start" });
1284+
const { identifier, displayName, image, ...extraProps } = identity as {
1285+
identifier: string;
1286+
displayName?: string;
1287+
image?: string | Blob;
1288+
[key: string]: unknown;
1289+
};
1290+
1291+
// Handle image upload if it's a Blob
1292+
let imageRef: JsonBlobRef | string | undefined;
1293+
if (image instanceof Blob) {
1294+
const uploadResult = await this.uploadImageBlob(image, onProgress);
1295+
imageRef = uploadResult;
1296+
} else {
1297+
imageRef = image;
1298+
}
1299+
1300+
const result = await this.addContributorInformation({
1301+
identifier,
1302+
displayName,
1303+
image: imageRef,
1304+
...extraProps,
1305+
});
1306+
this.emitProgress(onProgress, {
1307+
name: "createContributorInformation",
1308+
status: "success",
1309+
data: result,
1310+
});
1311+
return { uri: result.uri, cid: result.cid };
1312+
} catch (error) {
1313+
this.emitProgress(onProgress, {
1314+
name: "createContributorInformation",
1315+
status: "error",
1316+
error: error as Error,
1317+
});
1318+
throw error;
1319+
}
1320+
}
1321+
throw new Error("Invalid contributorIdentity format");
1322+
}
1323+
12501324
/**
12511325
* Creates a contribution details record.
12521326
*
@@ -1333,6 +1407,88 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
13331407
}
13341408
}
13351409

1410+
/**
1411+
* Creates a contributor information record.
1412+
*
1413+
* This creates a contributor profile record that can be referenced
1414+
* from an activity's `contributors` array via a strong reference.
1415+
*
1416+
* @param params - Contributor parameters
1417+
* @param params.identifier - DID or URI of the contributor
1418+
* @param params.displayName - Display name of the contributor
1419+
* @param params.image - Optional image URI or blob ref
1420+
* @returns Promise resolving to contributor information record URI and CID
1421+
* @throws {@link ValidationError} if validation fails
1422+
* @throws {@link NetworkError} if the operation fails
1423+
*
1424+
* @example
1425+
* ```typescript
1426+
* await repo.hypercerts.addContributorInformation({
1427+
* identifier: "did:plc:contributor123",
1428+
* displayName: "Alice",
1429+
* });
1430+
* ```
1431+
*/
1432+
async addContributorInformation(params: {
1433+
identifier: string;
1434+
displayName?: string;
1435+
image?: JsonBlobRef | string;
1436+
[key: string]: unknown;
1437+
}): Promise<CreateResult> {
1438+
try {
1439+
const createdAt = new Date().toISOString();
1440+
const { identifier, displayName, image, ...extraProps } = params;
1441+
1442+
// Resolve image to proper lexicon type if provided
1443+
let resolvedImage: HypercertContributorInformation["image"];
1444+
if (image) {
1445+
if (typeof image === "string") {
1446+
// URI string - wrap in typed object
1447+
resolvedImage = { $type: "org.hypercerts.defs#uri", uri: image } as HypercertContributorInformation["image"];
1448+
} else {
1449+
// JsonBlobRef from upload - wrap in smallImage
1450+
resolvedImage = {
1451+
$type: "org.hypercerts.defs#smallImage",
1452+
image: image,
1453+
} as HypercertContributorInformation["image"];
1454+
}
1455+
}
1456+
1457+
const contributorRecord = {
1458+
$type: HYPERCERT_COLLECTIONS.CONTRIBUTOR_INFORMATION,
1459+
identifier,
1460+
displayName,
1461+
image: resolvedImage,
1462+
createdAt,
1463+
...extraProps,
1464+
} as HypercertContributorInformation;
1465+
1466+
const validation = validate(contributorRecord, HYPERCERT_COLLECTIONS.CONTRIBUTOR_INFORMATION, "main", false);
1467+
if (!validation.success) {
1468+
throw new ValidationError(`Invalid contributor information record: ${validation.error?.message}`);
1469+
}
1470+
1471+
const result = await this.agent.com.atproto.repo.createRecord({
1472+
repo: this.repoDid,
1473+
collection: HYPERCERT_COLLECTIONS.CONTRIBUTOR_INFORMATION,
1474+
record: contributorRecord as Record<string, unknown>,
1475+
});
1476+
1477+
if (!result.success) {
1478+
throw new NetworkError("Failed to create contributor information");
1479+
}
1480+
1481+
this.emit("contributorCreated", { uri: result.data.uri, cid: result.data.cid });
1482+
return { uri: result.data.uri, cid: result.data.cid };
1483+
} catch (error) {
1484+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
1485+
throw new NetworkError(
1486+
`Failed to add contributor information: ${error instanceof Error ? error.message : "Unknown"}`,
1487+
error,
1488+
);
1489+
}
1490+
}
1491+
13361492
/**
13371493
* Creates a measurement record for a hypercert.
13381494
*

0 commit comments

Comments
 (0)