@hypercerts-org/sdk-core 0.2.0-beta.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.
- package/.turbo/turbo-build.log +328 -0
- package/.turbo/turbo-test.log +118 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/errors.cjs +260 -0
- package/dist/errors.cjs.map +1 -0
- package/dist/errors.d.ts +233 -0
- package/dist/errors.mjs +253 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.cjs +4531 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +3430 -0
- package/dist/index.mjs +4448 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lexicons.cjs +420 -0
- package/dist/lexicons.cjs.map +1 -0
- package/dist/lexicons.d.ts +227 -0
- package/dist/lexicons.mjs +410 -0
- package/dist/lexicons.mjs.map +1 -0
- package/dist/storage.cjs +270 -0
- package/dist/storage.cjs.map +1 -0
- package/dist/storage.d.ts +474 -0
- package/dist/storage.mjs +267 -0
- package/dist/storage.mjs.map +1 -0
- package/dist/testing.cjs +415 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.ts +928 -0
- package/dist/testing.mjs +410 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types.cjs +220 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.ts +2118 -0
- package/dist/types.mjs +212 -0
- package/dist/types.mjs.map +1 -0
- package/eslint.config.mjs +22 -0
- package/package.json +90 -0
- package/rollup.config.js +75 -0
- package/src/auth/OAuthClient.ts +497 -0
- package/src/core/SDK.ts +410 -0
- package/src/core/config.ts +243 -0
- package/src/core/errors.ts +257 -0
- package/src/core/interfaces.ts +324 -0
- package/src/core/types.ts +281 -0
- package/src/errors.ts +57 -0
- package/src/index.ts +107 -0
- package/src/lexicons.ts +64 -0
- package/src/repository/BlobOperationsImpl.ts +199 -0
- package/src/repository/CollaboratorOperationsImpl.ts +288 -0
- package/src/repository/HypercertOperationsImpl.ts +1146 -0
- package/src/repository/LexiconRegistry.ts +332 -0
- package/src/repository/OrganizationOperationsImpl.ts +234 -0
- package/src/repository/ProfileOperationsImpl.ts +281 -0
- package/src/repository/RecordOperationsImpl.ts +340 -0
- package/src/repository/Repository.ts +482 -0
- package/src/repository/interfaces.ts +868 -0
- package/src/repository/types.ts +111 -0
- package/src/services/hypercerts/types.ts +87 -0
- package/src/storage/InMemorySessionStore.ts +127 -0
- package/src/storage/InMemoryStateStore.ts +146 -0
- package/src/storage.ts +63 -0
- package/src/testing/index.ts +67 -0
- package/src/testing/mocks.ts +142 -0
- package/src/testing/stores.ts +285 -0
- package/src/testing.ts +64 -0
- package/src/types.ts +86 -0
- package/tests/auth/OAuthClient.test.ts +164 -0
- package/tests/core/SDK.test.ts +176 -0
- package/tests/core/errors.test.ts +81 -0
- package/tests/repository/BlobOperationsImpl.test.ts +154 -0
- package/tests/repository/CollaboratorOperationsImpl.test.ts +323 -0
- package/tests/repository/HypercertOperationsImpl.test.ts +652 -0
- package/tests/repository/LexiconRegistry.test.ts +192 -0
- package/tests/repository/OrganizationOperationsImpl.test.ts +242 -0
- package/tests/repository/ProfileOperationsImpl.test.ts +254 -0
- package/tests/repository/RecordOperationsImpl.test.ts +375 -0
- package/tests/repository/Repository.test.ts +149 -0
- package/tests/utils/fixtures.ts +117 -0
- package/tests/utils/mocks.ts +109 -0
- package/tests/utils/repository-fixtures.ts +78 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +30 -0
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HypercertOperationsImpl - High-level hypercert operations.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the implementation for creating and managing
|
|
5
|
+
* hypercerts, including related records like rights, locations,
|
|
6
|
+
* contributions, measurements, and evaluations.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Agent } from "@atproto/api";
|
|
12
|
+
import { EventEmitter } from "eventemitter3";
|
|
13
|
+
import { NetworkError, ValidationError } from "../core/errors.js";
|
|
14
|
+
import type { LoggerInterface } from "../core/interfaces.js";
|
|
15
|
+
import type { LexiconRegistry } from "./LexiconRegistry.js";
|
|
16
|
+
import {
|
|
17
|
+
HYPERCERT_COLLECTIONS,
|
|
18
|
+
type BlobRef,
|
|
19
|
+
type HypercertEvidence,
|
|
20
|
+
type HypercertClaim,
|
|
21
|
+
type HypercertRights,
|
|
22
|
+
type HypercertContribution,
|
|
23
|
+
type HypercertMeasurement,
|
|
24
|
+
type HypercertEvaluation,
|
|
25
|
+
type HypercertCollection,
|
|
26
|
+
type HypercertLocation,
|
|
27
|
+
} from "../services/hypercerts/types.js";
|
|
28
|
+
import type {
|
|
29
|
+
HypercertOperations,
|
|
30
|
+
HypercertEvents,
|
|
31
|
+
CreateHypercertParams,
|
|
32
|
+
CreateHypercertResult,
|
|
33
|
+
} from "./interfaces.js";
|
|
34
|
+
import type { CreateResult, UpdateResult, PaginatedList, ListParams, ProgressStep } from "./types.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Implementation of high-level hypercert operations.
|
|
38
|
+
*
|
|
39
|
+
* This class provides a convenient API for creating and managing hypercerts
|
|
40
|
+
* with automatic handling of:
|
|
41
|
+
*
|
|
42
|
+
* - Image upload and blob reference management
|
|
43
|
+
* - Rights record creation and linking
|
|
44
|
+
* - Location attachment with optional GeoJSON support
|
|
45
|
+
* - Contribution tracking
|
|
46
|
+
* - Measurement and evaluation records
|
|
47
|
+
* - Hypercert collections
|
|
48
|
+
*
|
|
49
|
+
* The class extends EventEmitter to provide real-time progress notifications
|
|
50
|
+
* during complex operations.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* This class is typically not instantiated directly. Access it through
|
|
54
|
+
* {@link Repository.hypercerts}.
|
|
55
|
+
*
|
|
56
|
+
* **Record Relationships**:
|
|
57
|
+
* - Hypercert → Rights (required, 1:1)
|
|
58
|
+
* - Hypercert → Location (optional, 1:many)
|
|
59
|
+
* - Hypercert → Contribution (optional, 1:many)
|
|
60
|
+
* - Hypercert → Measurement (optional, 1:many)
|
|
61
|
+
* - Hypercert → Evaluation (optional, 1:many)
|
|
62
|
+
* - Collection → Hypercerts (1:many via claims array)
|
|
63
|
+
*
|
|
64
|
+
* @example Creating a hypercert with progress tracking
|
|
65
|
+
* ```typescript
|
|
66
|
+
* repo.hypercerts.on("recordCreated", ({ uri }) => {
|
|
67
|
+
* console.log(`Hypercert created: ${uri}`);
|
|
68
|
+
* });
|
|
69
|
+
*
|
|
70
|
+
* const result = await repo.hypercerts.create({
|
|
71
|
+
* title: "Climate Impact",
|
|
72
|
+
* description: "Reduced emissions by 100 tons",
|
|
73
|
+
* workScope: "Climate",
|
|
74
|
+
* workTimeframeFrom: "2024-01-01",
|
|
75
|
+
* workTimeframeTo: "2024-12-31",
|
|
76
|
+
* rights: { name: "CC-BY", type: "license", description: "..." },
|
|
77
|
+
* onProgress: (step) => console.log(`${step.name}: ${step.status}`),
|
|
78
|
+
* });
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> implements HypercertOperations {
|
|
84
|
+
/**
|
|
85
|
+
* Creates a new HypercertOperationsImpl.
|
|
86
|
+
*
|
|
87
|
+
* @param agent - AT Protocol Agent for making API calls
|
|
88
|
+
* @param repoDid - DID of the repository to operate on
|
|
89
|
+
* @param _serverUrl - Server URL (reserved for future use)
|
|
90
|
+
* @param lexiconRegistry - Registry for record validation
|
|
91
|
+
* @param logger - Optional logger for debugging
|
|
92
|
+
*
|
|
93
|
+
* @internal
|
|
94
|
+
*/
|
|
95
|
+
constructor(
|
|
96
|
+
private agent: Agent,
|
|
97
|
+
private repoDid: string,
|
|
98
|
+
private _serverUrl: string,
|
|
99
|
+
private lexiconRegistry: LexiconRegistry,
|
|
100
|
+
private logger?: LoggerInterface,
|
|
101
|
+
) {
|
|
102
|
+
super();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Emits a progress event to the optional progress handler.
|
|
107
|
+
*
|
|
108
|
+
* @param onProgress - Progress callback from create params
|
|
109
|
+
* @param step - Progress step information
|
|
110
|
+
* @internal
|
|
111
|
+
*/
|
|
112
|
+
private emitProgress(onProgress: ((step: ProgressStep) => void) | undefined, step: ProgressStep): void {
|
|
113
|
+
if (onProgress) {
|
|
114
|
+
try {
|
|
115
|
+
onProgress(step);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
this.logger?.error(`Error in progress handler: ${err instanceof Error ? err.message : "Unknown"}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates a new hypercert with all related records.
|
|
124
|
+
*
|
|
125
|
+
* This method orchestrates the creation of a hypercert and its associated
|
|
126
|
+
* records in the correct order:
|
|
127
|
+
*
|
|
128
|
+
* 1. Upload image (if provided)
|
|
129
|
+
* 2. Create rights record
|
|
130
|
+
* 3. Create hypercert record (referencing rights)
|
|
131
|
+
* 4. Attach location (if provided)
|
|
132
|
+
* 5. Create contributions (if provided)
|
|
133
|
+
*
|
|
134
|
+
* @param params - Creation parameters (see {@link CreateHypercertParams})
|
|
135
|
+
* @returns Promise resolving to URIs and CIDs of all created records
|
|
136
|
+
* @throws {@link ValidationError} if any record fails validation
|
|
137
|
+
* @throws {@link NetworkError} if any API call fails
|
|
138
|
+
*
|
|
139
|
+
* @remarks
|
|
140
|
+
* The operation is not atomic - if a later step fails, earlier records
|
|
141
|
+
* will still exist. The result object will contain URIs for all
|
|
142
|
+
* successfully created records.
|
|
143
|
+
*
|
|
144
|
+
* **Progress Steps**:
|
|
145
|
+
* - `uploadImage`: Image blob upload
|
|
146
|
+
* - `createRights`: Rights record creation
|
|
147
|
+
* - `createHypercert`: Main hypercert record creation
|
|
148
|
+
* - `attachLocation`: Location record creation
|
|
149
|
+
* - `createContributions`: Contribution records creation
|
|
150
|
+
*
|
|
151
|
+
* @example Minimal hypercert
|
|
152
|
+
* ```typescript
|
|
153
|
+
* const result = await repo.hypercerts.create({
|
|
154
|
+
* title: "My Impact",
|
|
155
|
+
* description: "Description of impact work",
|
|
156
|
+
* workScope: "Education",
|
|
157
|
+
* workTimeframeFrom: "2024-01-01",
|
|
158
|
+
* workTimeframeTo: "2024-06-30",
|
|
159
|
+
* rights: {
|
|
160
|
+
* name: "Attribution",
|
|
161
|
+
* type: "license",
|
|
162
|
+
* description: "CC-BY-4.0",
|
|
163
|
+
* },
|
|
164
|
+
* });
|
|
165
|
+
* ```
|
|
166
|
+
*
|
|
167
|
+
* @example Full hypercert with all options
|
|
168
|
+
* ```typescript
|
|
169
|
+
* const result = await repo.hypercerts.create({
|
|
170
|
+
* title: "Reforestation Project",
|
|
171
|
+
* description: "Planted 10,000 trees...",
|
|
172
|
+
* shortDescription: "10K trees planted",
|
|
173
|
+
* workScope: "Environment",
|
|
174
|
+
* workTimeframeFrom: "2024-01-01",
|
|
175
|
+
* workTimeframeTo: "2024-12-31",
|
|
176
|
+
* rights: { name: "Open", type: "impact", description: "..." },
|
|
177
|
+
* image: coverImageBlob,
|
|
178
|
+
* location: { value: "Amazon, Brazil", name: "Amazon Basin" },
|
|
179
|
+
* contributions: [
|
|
180
|
+
* { contributors: ["did:plc:org1"], role: "coordinator" },
|
|
181
|
+
* { contributors: ["did:plc:org2"], role: "implementer" },
|
|
182
|
+
* ],
|
|
183
|
+
* evidence: [{ uri: "https://...", description: "Satellite data" }],
|
|
184
|
+
* onProgress: console.log,
|
|
185
|
+
* });
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
async create(params: CreateHypercertParams): Promise<CreateHypercertResult> {
|
|
189
|
+
const createdAt = new Date().toISOString();
|
|
190
|
+
const result: CreateHypercertResult = {
|
|
191
|
+
hypercertUri: "",
|
|
192
|
+
rightsUri: "",
|
|
193
|
+
hypercertCid: "",
|
|
194
|
+
rightsCid: "",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
// Step 1: Upload image if provided
|
|
199
|
+
let imageBlobRef: BlobRef | undefined;
|
|
200
|
+
if (params.image) {
|
|
201
|
+
this.emitProgress(params.onProgress, { name: "uploadImage", status: "start" });
|
|
202
|
+
try {
|
|
203
|
+
const arrayBuffer = await params.image.arrayBuffer();
|
|
204
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
205
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
206
|
+
encoding: params.image.type || "image/jpeg",
|
|
207
|
+
});
|
|
208
|
+
if (uploadResult.success) {
|
|
209
|
+
imageBlobRef = {
|
|
210
|
+
$type: "blob",
|
|
211
|
+
ref: uploadResult.data.blob.ref,
|
|
212
|
+
mimeType: uploadResult.data.blob.mimeType,
|
|
213
|
+
size: uploadResult.data.blob.size,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
this.emitProgress(params.onProgress, {
|
|
217
|
+
name: "uploadImage",
|
|
218
|
+
status: "success",
|
|
219
|
+
data: { size: params.image.size },
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.emitProgress(params.onProgress, { name: "uploadImage", status: "error", error: error as Error });
|
|
223
|
+
throw new NetworkError(
|
|
224
|
+
`Failed to upload image: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
225
|
+
error,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Step 2: Create rights record
|
|
231
|
+
this.emitProgress(params.onProgress, { name: "createRights", status: "start" });
|
|
232
|
+
const rightsRecord: Omit<HypercertRights, "$type"> = {
|
|
233
|
+
rightsName: params.rights.name,
|
|
234
|
+
rightsType: params.rights.type,
|
|
235
|
+
rightsDescription: params.rights.description,
|
|
236
|
+
createdAt,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const rightsValidation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.RIGHTS, rightsRecord);
|
|
240
|
+
if (!rightsValidation.valid) {
|
|
241
|
+
throw new ValidationError(`Invalid rights record: ${rightsValidation.error}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const rightsResult = await this.agent.com.atproto.repo.createRecord({
|
|
245
|
+
repo: this.repoDid,
|
|
246
|
+
collection: HYPERCERT_COLLECTIONS.RIGHTS,
|
|
247
|
+
record: rightsRecord as Record<string, unknown>,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (!rightsResult.success) {
|
|
251
|
+
throw new NetworkError("Failed to create rights record");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
result.rightsUri = rightsResult.data.uri;
|
|
255
|
+
result.rightsCid = rightsResult.data.cid;
|
|
256
|
+
this.emit("rightsCreated", { uri: result.rightsUri, cid: result.rightsCid });
|
|
257
|
+
this.emitProgress(params.onProgress, {
|
|
258
|
+
name: "createRights",
|
|
259
|
+
status: "success",
|
|
260
|
+
data: { uri: result.rightsUri },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Step 3: Create hypercert record
|
|
264
|
+
this.emitProgress(params.onProgress, { name: "createHypercert", status: "start" });
|
|
265
|
+
const hypercertRecord: Record<string, unknown> = {
|
|
266
|
+
title: params.title,
|
|
267
|
+
description: params.description,
|
|
268
|
+
workScope: params.workScope,
|
|
269
|
+
workTimeframeFrom: params.workTimeframeFrom,
|
|
270
|
+
workTimeframeTo: params.workTimeframeTo,
|
|
271
|
+
rights: { uri: result.rightsUri, cid: result.rightsCid },
|
|
272
|
+
createdAt,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
if (params.shortDescription) {
|
|
276
|
+
hypercertRecord.shortDescription = params.shortDescription;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (imageBlobRef) {
|
|
280
|
+
hypercertRecord.image = imageBlobRef;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (params.evidence && params.evidence.length > 0) {
|
|
284
|
+
hypercertRecord.evidence = params.evidence;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const hypercertValidation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.CLAIM, hypercertRecord);
|
|
288
|
+
if (!hypercertValidation.valid) {
|
|
289
|
+
throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const hypercertResult = await this.agent.com.atproto.repo.createRecord({
|
|
293
|
+
repo: this.repoDid,
|
|
294
|
+
collection: HYPERCERT_COLLECTIONS.CLAIM,
|
|
295
|
+
record: hypercertRecord,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (!hypercertResult.success) {
|
|
299
|
+
throw new NetworkError("Failed to create hypercert record");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
result.hypercertUri = hypercertResult.data.uri;
|
|
303
|
+
result.hypercertCid = hypercertResult.data.cid;
|
|
304
|
+
this.emit("recordCreated", { uri: result.hypercertUri, cid: result.hypercertCid });
|
|
305
|
+
this.emitProgress(params.onProgress, {
|
|
306
|
+
name: "createHypercert",
|
|
307
|
+
status: "success",
|
|
308
|
+
data: { uri: result.hypercertUri },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Step 4: Attach location if provided
|
|
312
|
+
if (params.location) {
|
|
313
|
+
this.emitProgress(params.onProgress, { name: "attachLocation", status: "start" });
|
|
314
|
+
try {
|
|
315
|
+
const locationResult = await this.attachLocation(result.hypercertUri, params.location);
|
|
316
|
+
result.locationUri = locationResult.uri;
|
|
317
|
+
this.emitProgress(params.onProgress, {
|
|
318
|
+
name: "attachLocation",
|
|
319
|
+
status: "success",
|
|
320
|
+
data: { uri: result.locationUri },
|
|
321
|
+
});
|
|
322
|
+
} catch (error) {
|
|
323
|
+
this.emitProgress(params.onProgress, { name: "attachLocation", status: "error", error: error as Error });
|
|
324
|
+
this.logger?.warn(`Failed to attach location: ${error instanceof Error ? error.message : "Unknown"}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Step 5: Create contributions if provided
|
|
329
|
+
if (params.contributions && params.contributions.length > 0) {
|
|
330
|
+
this.emitProgress(params.onProgress, { name: "createContributions", status: "start" });
|
|
331
|
+
result.contributionUris = [];
|
|
332
|
+
try {
|
|
333
|
+
for (const contrib of params.contributions) {
|
|
334
|
+
const contribResult = await this.addContribution({
|
|
335
|
+
hypercertUri: result.hypercertUri,
|
|
336
|
+
contributors: contrib.contributors,
|
|
337
|
+
role: contrib.role,
|
|
338
|
+
description: contrib.description,
|
|
339
|
+
});
|
|
340
|
+
result.contributionUris.push(contribResult.uri);
|
|
341
|
+
}
|
|
342
|
+
this.emitProgress(params.onProgress, {
|
|
343
|
+
name: "createContributions",
|
|
344
|
+
status: "success",
|
|
345
|
+
data: { count: result.contributionUris.length },
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
this.emitProgress(params.onProgress, { name: "createContributions", status: "error", error: error as Error });
|
|
349
|
+
this.logger?.warn(`Failed to create contributions: ${error instanceof Error ? error.message : "Unknown"}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return result;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
356
|
+
throw new NetworkError(
|
|
357
|
+
`Failed to create hypercert: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
358
|
+
error,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Updates an existing hypercert record.
|
|
365
|
+
*
|
|
366
|
+
* @param params - Update parameters
|
|
367
|
+
* @param params.uri - AT-URI of the hypercert to update
|
|
368
|
+
* @param params.updates - Partial record with fields to update
|
|
369
|
+
* @param params.image - New image blob, `null` to remove, `undefined` to keep existing
|
|
370
|
+
* @returns Promise resolving to update result
|
|
371
|
+
* @throws {@link ValidationError} if the URI format is invalid or record fails validation
|
|
372
|
+
* @throws {@link NetworkError} if the update fails
|
|
373
|
+
*
|
|
374
|
+
* @remarks
|
|
375
|
+
* This is a partial update - only specified fields are changed.
|
|
376
|
+
* The `createdAt` and `rights` fields cannot be changed.
|
|
377
|
+
*
|
|
378
|
+
* @example Update title and description
|
|
379
|
+
* ```typescript
|
|
380
|
+
* await repo.hypercerts.update({
|
|
381
|
+
* uri: "at://did:plc:abc/org.hypercerts.hypercert/xyz",
|
|
382
|
+
* updates: {
|
|
383
|
+
* title: "Updated Title",
|
|
384
|
+
* description: "New description",
|
|
385
|
+
* },
|
|
386
|
+
* });
|
|
387
|
+
* ```
|
|
388
|
+
*
|
|
389
|
+
* @example Update with new image
|
|
390
|
+
* ```typescript
|
|
391
|
+
* await repo.hypercerts.update({
|
|
392
|
+
* uri: hypercertUri,
|
|
393
|
+
* updates: { title: "New Title" },
|
|
394
|
+
* image: newImageBlob,
|
|
395
|
+
* });
|
|
396
|
+
* ```
|
|
397
|
+
*
|
|
398
|
+
* @example Remove image
|
|
399
|
+
* ```typescript
|
|
400
|
+
* await repo.hypercerts.update({
|
|
401
|
+
* uri: hypercertUri,
|
|
402
|
+
* updates: {},
|
|
403
|
+
* image: null, // Explicitly remove image
|
|
404
|
+
* });
|
|
405
|
+
* ```
|
|
406
|
+
*/
|
|
407
|
+
async update(params: {
|
|
408
|
+
uri: string;
|
|
409
|
+
updates: Partial<Omit<HypercertClaim, "$type" | "createdAt" | "rights">>;
|
|
410
|
+
image?: Blob | null;
|
|
411
|
+
}): Promise<UpdateResult> {
|
|
412
|
+
try {
|
|
413
|
+
const uriMatch = params.uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
414
|
+
if (!uriMatch) {
|
|
415
|
+
throw new ValidationError(`Invalid URI format: ${params.uri}`);
|
|
416
|
+
}
|
|
417
|
+
const [, , collection, rkey] = uriMatch;
|
|
418
|
+
|
|
419
|
+
const existing = await this.agent.com.atproto.repo.getRecord({
|
|
420
|
+
repo: this.repoDid,
|
|
421
|
+
collection,
|
|
422
|
+
rkey,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// The existing record comes from ATProto, use it directly
|
|
426
|
+
// TypeScript ensures type safety through the HypercertClaim interface
|
|
427
|
+
const existingRecord = existing.data.value as HypercertClaim;
|
|
428
|
+
|
|
429
|
+
const recordForUpdate: Record<string, unknown> = {
|
|
430
|
+
...existingRecord,
|
|
431
|
+
...params.updates,
|
|
432
|
+
createdAt: existingRecord.createdAt,
|
|
433
|
+
rights: existingRecord.rights,
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Handle image update
|
|
437
|
+
delete (recordForUpdate as { image?: unknown }).image;
|
|
438
|
+
if (params.image !== undefined) {
|
|
439
|
+
if (params.image === null) {
|
|
440
|
+
// Remove image
|
|
441
|
+
} else {
|
|
442
|
+
const arrayBuffer = await params.image.arrayBuffer();
|
|
443
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
444
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
445
|
+
encoding: params.image.type || "image/jpeg",
|
|
446
|
+
});
|
|
447
|
+
if (uploadResult.success) {
|
|
448
|
+
recordForUpdate.image = {
|
|
449
|
+
$type: "blob",
|
|
450
|
+
ref: uploadResult.data.blob.ref,
|
|
451
|
+
mimeType: uploadResult.data.blob.mimeType,
|
|
452
|
+
size: uploadResult.data.blob.size,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} else if (existingRecord.image) {
|
|
457
|
+
// Preserve existing image
|
|
458
|
+
recordForUpdate.image = existingRecord.image;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const validation = this.lexiconRegistry.validate(collection, recordForUpdate);
|
|
462
|
+
if (!validation.valid) {
|
|
463
|
+
throw new ValidationError(`Invalid hypercert record: ${validation.error}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const result = await this.agent.com.atproto.repo.putRecord({
|
|
467
|
+
repo: this.repoDid,
|
|
468
|
+
collection,
|
|
469
|
+
rkey,
|
|
470
|
+
record: recordForUpdate,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
if (!result.success) {
|
|
474
|
+
throw new NetworkError("Failed to update hypercert");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
this.emit("recordUpdated", { uri: result.data.uri, cid: result.data.cid });
|
|
478
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
481
|
+
throw new NetworkError(
|
|
482
|
+
`Failed to update hypercert: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
483
|
+
error,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Gets a hypercert by its AT-URI.
|
|
490
|
+
*
|
|
491
|
+
* @param uri - AT-URI of the hypercert (e.g., "at://did:plc:abc/org.hypercerts.hypercert/xyz")
|
|
492
|
+
* @returns Promise resolving to hypercert URI, CID, and parsed record
|
|
493
|
+
* @throws {@link ValidationError} if the URI format is invalid or record doesn't match schema
|
|
494
|
+
* @throws {@link NetworkError} if the record cannot be fetched
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* ```typescript
|
|
498
|
+
* const { uri, cid, record } = await repo.hypercerts.get(hypercertUri);
|
|
499
|
+
* console.log(`${record.title}: ${record.description}`);
|
|
500
|
+
* ```
|
|
501
|
+
*/
|
|
502
|
+
async get(uri: string): Promise<{ uri: string; cid: string; record: HypercertClaim }> {
|
|
503
|
+
try {
|
|
504
|
+
const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
505
|
+
if (!uriMatch) {
|
|
506
|
+
throw new ValidationError(`Invalid URI format: ${uri}`);
|
|
507
|
+
}
|
|
508
|
+
const [, , collection, rkey] = uriMatch;
|
|
509
|
+
|
|
510
|
+
const result = await this.agent.com.atproto.repo.getRecord({
|
|
511
|
+
repo: this.repoDid,
|
|
512
|
+
collection,
|
|
513
|
+
rkey,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (!result.success) {
|
|
517
|
+
throw new NetworkError("Failed to get hypercert");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Validate with lexicon registry (more lenient - doesn't require $type)
|
|
521
|
+
const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.CLAIM, result.data.value);
|
|
522
|
+
if (!validation.valid) {
|
|
523
|
+
throw new ValidationError(`Invalid hypercert record format: ${validation.error}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
uri: result.data.uri,
|
|
528
|
+
cid: result.data.cid ?? "",
|
|
529
|
+
record: result.data.value as HypercertClaim,
|
|
530
|
+
};
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
533
|
+
throw new NetworkError(`Failed to get hypercert: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Lists hypercerts in the repository with pagination.
|
|
539
|
+
*
|
|
540
|
+
* @param params - Optional pagination parameters
|
|
541
|
+
* @returns Promise resolving to paginated list of hypercerts
|
|
542
|
+
* @throws {@link NetworkError} if the list operation fails
|
|
543
|
+
*
|
|
544
|
+
* @example
|
|
545
|
+
* ```typescript
|
|
546
|
+
* // Get first page
|
|
547
|
+
* const { records, cursor } = await repo.hypercerts.list({ limit: 20 });
|
|
548
|
+
*
|
|
549
|
+
* // Get next page
|
|
550
|
+
* if (cursor) {
|
|
551
|
+
* const nextPage = await repo.hypercerts.list({ limit: 20, cursor });
|
|
552
|
+
* }
|
|
553
|
+
* ```
|
|
554
|
+
*/
|
|
555
|
+
async list(params?: ListParams): Promise<PaginatedList<{ uri: string; cid: string; record: HypercertClaim }>> {
|
|
556
|
+
try {
|
|
557
|
+
const result = await this.agent.com.atproto.repo.listRecords({
|
|
558
|
+
repo: this.repoDid,
|
|
559
|
+
collection: HYPERCERT_COLLECTIONS.CLAIM,
|
|
560
|
+
limit: params?.limit,
|
|
561
|
+
cursor: params?.cursor,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (!result.success) {
|
|
565
|
+
throw new NetworkError("Failed to list hypercerts");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
records:
|
|
570
|
+
result.data.records?.map((r) => ({
|
|
571
|
+
uri: r.uri,
|
|
572
|
+
cid: r.cid,
|
|
573
|
+
record: r.value as HypercertClaim,
|
|
574
|
+
})) || [],
|
|
575
|
+
cursor: result.data.cursor ?? undefined,
|
|
576
|
+
};
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (error instanceof NetworkError) throw error;
|
|
579
|
+
throw new NetworkError(`Failed to list hypercerts: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Deletes a hypercert record.
|
|
585
|
+
*
|
|
586
|
+
* @param uri - AT-URI of the hypercert to delete
|
|
587
|
+
* @throws {@link ValidationError} if the URI format is invalid
|
|
588
|
+
* @throws {@link NetworkError} if the deletion fails
|
|
589
|
+
*
|
|
590
|
+
* @remarks
|
|
591
|
+
* This only deletes the hypercert record itself. Related records
|
|
592
|
+
* (rights, locations, contributions) are not automatically deleted.
|
|
593
|
+
*
|
|
594
|
+
* @example
|
|
595
|
+
* ```typescript
|
|
596
|
+
* await repo.hypercerts.delete(hypercertUri);
|
|
597
|
+
* ```
|
|
598
|
+
*/
|
|
599
|
+
async delete(uri: string): Promise<void> {
|
|
600
|
+
try {
|
|
601
|
+
const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
602
|
+
if (!uriMatch) {
|
|
603
|
+
throw new ValidationError(`Invalid URI format: ${uri}`);
|
|
604
|
+
}
|
|
605
|
+
const [, , collection, rkey] = uriMatch;
|
|
606
|
+
|
|
607
|
+
const result = await this.agent.com.atproto.repo.deleteRecord({
|
|
608
|
+
repo: this.repoDid,
|
|
609
|
+
collection,
|
|
610
|
+
rkey,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
if (!result.success) {
|
|
614
|
+
throw new NetworkError("Failed to delete hypercert");
|
|
615
|
+
}
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
618
|
+
throw new NetworkError(
|
|
619
|
+
`Failed to delete hypercert: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
620
|
+
error,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Attaches a location to an existing hypercert.
|
|
627
|
+
*
|
|
628
|
+
* @param hypercertUri - AT-URI of the hypercert to attach location to
|
|
629
|
+
* @param location - Location data
|
|
630
|
+
* @param location.value - Location value (address, coordinates, or description)
|
|
631
|
+
* @param location.name - Optional human-readable name
|
|
632
|
+
* @param location.description - Optional description
|
|
633
|
+
* @param location.srs - Spatial Reference System (e.g., "EPSG:4326")
|
|
634
|
+
* @param location.geojson - Optional GeoJSON blob for precise boundaries
|
|
635
|
+
* @returns Promise resolving to location record URI and CID
|
|
636
|
+
* @throws {@link ValidationError} if validation fails
|
|
637
|
+
* @throws {@link NetworkError} if the operation fails
|
|
638
|
+
*
|
|
639
|
+
* @example Simple location
|
|
640
|
+
* ```typescript
|
|
641
|
+
* await repo.hypercerts.attachLocation(hypercertUri, {
|
|
642
|
+
* value: "San Francisco, CA",
|
|
643
|
+
* name: "SF Bay Area",
|
|
644
|
+
* });
|
|
645
|
+
* ```
|
|
646
|
+
*
|
|
647
|
+
* @example Location with GeoJSON
|
|
648
|
+
* ```typescript
|
|
649
|
+
* const geojsonBlob = new Blob([JSON.stringify(geojson)], {
|
|
650
|
+
* type: "application/geo+json"
|
|
651
|
+
* });
|
|
652
|
+
*
|
|
653
|
+
* await repo.hypercerts.attachLocation(hypercertUri, {
|
|
654
|
+
* value: "Custom Region",
|
|
655
|
+
* srs: "EPSG:4326",
|
|
656
|
+
* geojson: geojsonBlob,
|
|
657
|
+
* });
|
|
658
|
+
* ```
|
|
659
|
+
*/
|
|
660
|
+
async attachLocation(
|
|
661
|
+
hypercertUri: string,
|
|
662
|
+
location: { value: string; name?: string; description?: string; srs?: string; geojson?: Blob },
|
|
663
|
+
): Promise<CreateResult> {
|
|
664
|
+
try {
|
|
665
|
+
// Get hypercert to get CID
|
|
666
|
+
const hypercert = await this.get(hypercertUri);
|
|
667
|
+
const createdAt = new Date().toISOString();
|
|
668
|
+
|
|
669
|
+
let locationValue: string | BlobRef = location.value;
|
|
670
|
+
if (location.geojson) {
|
|
671
|
+
const arrayBuffer = await location.geojson.arrayBuffer();
|
|
672
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
673
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
674
|
+
encoding: location.geojson.type || "application/geo+json",
|
|
675
|
+
});
|
|
676
|
+
if (uploadResult.success) {
|
|
677
|
+
locationValue = {
|
|
678
|
+
$type: "blob",
|
|
679
|
+
ref: uploadResult.data.blob.ref,
|
|
680
|
+
mimeType: uploadResult.data.blob.mimeType,
|
|
681
|
+
size: uploadResult.data.blob.size,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const locationRecord: Omit<HypercertLocation, "$type"> = {
|
|
687
|
+
hypercert: { uri: hypercert.uri, cid: hypercert.cid },
|
|
688
|
+
value: locationValue,
|
|
689
|
+
createdAt,
|
|
690
|
+
name: location.name,
|
|
691
|
+
description: location.description,
|
|
692
|
+
srs: location.srs,
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.LOCATION, locationRecord);
|
|
696
|
+
if (!validation.valid) {
|
|
697
|
+
throw new ValidationError(`Invalid location record: ${validation.error}`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const result = await this.agent.com.atproto.repo.createRecord({
|
|
701
|
+
repo: this.repoDid,
|
|
702
|
+
collection: HYPERCERT_COLLECTIONS.LOCATION,
|
|
703
|
+
record: locationRecord as Record<string, unknown>,
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
if (!result.success) {
|
|
707
|
+
throw new NetworkError("Failed to attach location");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
this.emit("locationAttached", { uri: result.data.uri, cid: result.data.cid, hypercertUri });
|
|
711
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
714
|
+
throw new NetworkError(`Failed to attach location: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Adds evidence to an existing hypercert.
|
|
720
|
+
*
|
|
721
|
+
* @param hypercertUri - AT-URI of the hypercert
|
|
722
|
+
* @param evidence - Array of evidence items to add
|
|
723
|
+
* @returns Promise resolving to update result
|
|
724
|
+
* @throws {@link ValidationError} if validation fails
|
|
725
|
+
* @throws {@link NetworkError} if the operation fails
|
|
726
|
+
*
|
|
727
|
+
* @remarks
|
|
728
|
+
* Evidence is appended to existing evidence, not replaced.
|
|
729
|
+
*
|
|
730
|
+
* @example
|
|
731
|
+
* ```typescript
|
|
732
|
+
* await repo.hypercerts.addEvidence(hypercertUri, [
|
|
733
|
+
* { uri: "https://example.com/report.pdf", description: "Impact report" },
|
|
734
|
+
* { uri: "https://example.com/data.csv", description: "Raw data" },
|
|
735
|
+
* ]);
|
|
736
|
+
* ```
|
|
737
|
+
*/
|
|
738
|
+
async addEvidence(hypercertUri: string, evidence: HypercertEvidence[]): Promise<UpdateResult> {
|
|
739
|
+
try {
|
|
740
|
+
const existing = await this.get(hypercertUri);
|
|
741
|
+
const existingEvidence = existing.record.evidence || [];
|
|
742
|
+
const updatedEvidence = [...existingEvidence, ...evidence];
|
|
743
|
+
|
|
744
|
+
const result = await this.update({
|
|
745
|
+
uri: hypercertUri,
|
|
746
|
+
updates: { evidence: updatedEvidence },
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
this.emit("evidenceAdded", { uri: result.uri, cid: result.cid });
|
|
750
|
+
return result;
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
753
|
+
throw new NetworkError(`Failed to add evidence: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Creates a contribution record.
|
|
759
|
+
*
|
|
760
|
+
* @param params - Contribution parameters
|
|
761
|
+
* @param params.hypercertUri - Optional hypercert to link (can be standalone)
|
|
762
|
+
* @param params.contributors - Array of contributor DIDs
|
|
763
|
+
* @param params.role - Role of the contributors (e.g., "coordinator", "implementer")
|
|
764
|
+
* @param params.description - Optional description of the contribution
|
|
765
|
+
* @returns Promise resolving to contribution record URI and CID
|
|
766
|
+
* @throws {@link ValidationError} if validation fails
|
|
767
|
+
* @throws {@link NetworkError} if the operation fails
|
|
768
|
+
*
|
|
769
|
+
* @example
|
|
770
|
+
* ```typescript
|
|
771
|
+
* await repo.hypercerts.addContribution({
|
|
772
|
+
* hypercertUri: hypercertUri,
|
|
773
|
+
* contributors: ["did:plc:alice", "did:plc:bob"],
|
|
774
|
+
* role: "implementer",
|
|
775
|
+
* description: "On-ground implementation team",
|
|
776
|
+
* });
|
|
777
|
+
* ```
|
|
778
|
+
*/
|
|
779
|
+
async addContribution(params: {
|
|
780
|
+
hypercertUri?: string;
|
|
781
|
+
contributors: string[];
|
|
782
|
+
role: string;
|
|
783
|
+
description?: string;
|
|
784
|
+
}): Promise<CreateResult> {
|
|
785
|
+
try {
|
|
786
|
+
const createdAt = new Date().toISOString();
|
|
787
|
+
const contributionRecord: Omit<HypercertContribution, "$type"> = {
|
|
788
|
+
contributors: params.contributors,
|
|
789
|
+
role: params.role,
|
|
790
|
+
createdAt,
|
|
791
|
+
description: params.description,
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
if (params.hypercertUri) {
|
|
795
|
+
const hypercert = await this.get(params.hypercertUri);
|
|
796
|
+
contributionRecord.hypercert = { uri: hypercert.uri, cid: hypercert.cid };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.CONTRIBUTION, contributionRecord);
|
|
800
|
+
if (!validation.valid) {
|
|
801
|
+
throw new ValidationError(`Invalid contribution record: ${validation.error}`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const result = await this.agent.com.atproto.repo.createRecord({
|
|
805
|
+
repo: this.repoDid,
|
|
806
|
+
collection: HYPERCERT_COLLECTIONS.CONTRIBUTION,
|
|
807
|
+
record: contributionRecord as Record<string, unknown>,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
if (!result.success) {
|
|
811
|
+
throw new NetworkError("Failed to create contribution");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
this.emit("contributionCreated", { uri: result.data.uri, cid: result.data.cid });
|
|
815
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
816
|
+
} catch (error) {
|
|
817
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
818
|
+
throw new NetworkError(
|
|
819
|
+
`Failed to add contribution: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
820
|
+
error,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Creates a measurement record for a hypercert.
|
|
827
|
+
*
|
|
828
|
+
* Measurements quantify the impact claimed in a hypercert with
|
|
829
|
+
* specific metrics and values.
|
|
830
|
+
*
|
|
831
|
+
* @param params - Measurement parameters
|
|
832
|
+
* @param params.hypercertUri - AT-URI of the hypercert being measured
|
|
833
|
+
* @param params.measurers - DIDs of entities who performed the measurement
|
|
834
|
+
* @param params.metric - Name of the metric (e.g., "CO2 Reduced", "Trees Planted")
|
|
835
|
+
* @param params.value - Measured value with units (e.g., "100 tons", "10000")
|
|
836
|
+
* @param params.methodUri - Optional URI describing the measurement methodology
|
|
837
|
+
* @param params.evidenceUris - Optional URIs to supporting evidence
|
|
838
|
+
* @returns Promise resolving to measurement record URI and CID
|
|
839
|
+
* @throws {@link ValidationError} if validation fails
|
|
840
|
+
* @throws {@link NetworkError} if the operation fails
|
|
841
|
+
*
|
|
842
|
+
* @example
|
|
843
|
+
* ```typescript
|
|
844
|
+
* await repo.hypercerts.addMeasurement({
|
|
845
|
+
* hypercertUri: hypercertUri,
|
|
846
|
+
* measurers: ["did:plc:auditor"],
|
|
847
|
+
* metric: "Carbon Offset",
|
|
848
|
+
* value: "150 tons CO2e",
|
|
849
|
+
* methodUri: "https://example.com/methodology",
|
|
850
|
+
* evidenceUris: ["https://example.com/audit-report"],
|
|
851
|
+
* });
|
|
852
|
+
* ```
|
|
853
|
+
*/
|
|
854
|
+
async addMeasurement(params: {
|
|
855
|
+
hypercertUri: string;
|
|
856
|
+
measurers: string[];
|
|
857
|
+
metric: string;
|
|
858
|
+
value: string;
|
|
859
|
+
methodUri?: string;
|
|
860
|
+
evidenceUris?: string[];
|
|
861
|
+
}): Promise<CreateResult> {
|
|
862
|
+
try {
|
|
863
|
+
const hypercert = await this.get(params.hypercertUri);
|
|
864
|
+
const createdAt = new Date().toISOString();
|
|
865
|
+
|
|
866
|
+
const measurementRecord: Omit<HypercertMeasurement, "$type"> = {
|
|
867
|
+
hypercert: { uri: hypercert.uri, cid: hypercert.cid },
|
|
868
|
+
measurers: params.measurers,
|
|
869
|
+
metric: params.metric,
|
|
870
|
+
value: params.value,
|
|
871
|
+
createdAt,
|
|
872
|
+
measurementMethodURI: params.methodUri,
|
|
873
|
+
evidenceURI: params.evidenceUris,
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.MEASUREMENT, measurementRecord);
|
|
877
|
+
if (!validation.valid) {
|
|
878
|
+
throw new ValidationError(`Invalid measurement record: ${validation.error}`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const result = await this.agent.com.atproto.repo.createRecord({
|
|
882
|
+
repo: this.repoDid,
|
|
883
|
+
collection: HYPERCERT_COLLECTIONS.MEASUREMENT,
|
|
884
|
+
record: measurementRecord as Record<string, unknown>,
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
if (!result.success) {
|
|
888
|
+
throw new NetworkError("Failed to create measurement");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
894
|
+
throw new NetworkError(`Failed to add measurement: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Creates an evaluation record for a hypercert or other subject.
|
|
900
|
+
*
|
|
901
|
+
* Evaluations provide third-party assessments of impact claims.
|
|
902
|
+
*
|
|
903
|
+
* @param params - Evaluation parameters
|
|
904
|
+
* @param params.subjectUri - AT-URI of the record being evaluated
|
|
905
|
+
* @param params.evaluators - DIDs of evaluating entities
|
|
906
|
+
* @param params.summary - Summary of the evaluation findings
|
|
907
|
+
* @returns Promise resolving to evaluation record URI and CID
|
|
908
|
+
* @throws {@link ValidationError} if validation fails
|
|
909
|
+
* @throws {@link NetworkError} if the operation fails
|
|
910
|
+
*
|
|
911
|
+
* @example
|
|
912
|
+
* ```typescript
|
|
913
|
+
* await repo.hypercerts.addEvaluation({
|
|
914
|
+
* subjectUri: hypercertUri,
|
|
915
|
+
* evaluators: ["did:plc:evaluator-org"],
|
|
916
|
+
* summary: "Verified impact claims through site visit and data analysis",
|
|
917
|
+
* });
|
|
918
|
+
* ```
|
|
919
|
+
*/
|
|
920
|
+
async addEvaluation(params: { subjectUri: string; evaluators: string[]; summary: string }): Promise<CreateResult> {
|
|
921
|
+
try {
|
|
922
|
+
const subject = await this.get(params.subjectUri);
|
|
923
|
+
const createdAt = new Date().toISOString();
|
|
924
|
+
|
|
925
|
+
const evaluationRecord: Omit<HypercertEvaluation, "$type"> = {
|
|
926
|
+
subject: { uri: subject.uri, cid: subject.cid },
|
|
927
|
+
evaluators: params.evaluators,
|
|
928
|
+
summary: params.summary,
|
|
929
|
+
createdAt,
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.EVALUATION, evaluationRecord);
|
|
933
|
+
if (!validation.valid) {
|
|
934
|
+
throw new ValidationError(`Invalid evaluation record: ${validation.error}`);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const result = await this.agent.com.atproto.repo.createRecord({
|
|
938
|
+
repo: this.repoDid,
|
|
939
|
+
collection: HYPERCERT_COLLECTIONS.EVALUATION,
|
|
940
|
+
record: evaluationRecord as Record<string, unknown>,
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
if (!result.success) {
|
|
944
|
+
throw new NetworkError("Failed to create evaluation");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
948
|
+
} catch (error) {
|
|
949
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
950
|
+
throw new NetworkError(`Failed to add evaluation: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Creates a collection of hypercerts.
|
|
956
|
+
*
|
|
957
|
+
* Collections group related hypercerts with optional weights
|
|
958
|
+
* for relative importance.
|
|
959
|
+
*
|
|
960
|
+
* @param params - Collection parameters
|
|
961
|
+
* @param params.title - Collection title
|
|
962
|
+
* @param params.claims - Array of hypercert references with weights
|
|
963
|
+
* @param params.shortDescription - Optional short description
|
|
964
|
+
* @param params.coverPhoto - Optional cover image blob
|
|
965
|
+
* @returns Promise resolving to collection record URI and CID
|
|
966
|
+
* @throws {@link ValidationError} if validation fails
|
|
967
|
+
* @throws {@link NetworkError} if the operation fails
|
|
968
|
+
*
|
|
969
|
+
* @example
|
|
970
|
+
* ```typescript
|
|
971
|
+
* const collection = await repo.hypercerts.createCollection({
|
|
972
|
+
* title: "Climate Projects 2024",
|
|
973
|
+
* shortDescription: "Our climate impact portfolio",
|
|
974
|
+
* claims: [
|
|
975
|
+
* { uri: hypercert1Uri, cid: hypercert1Cid, weight: "0.5" },
|
|
976
|
+
* { uri: hypercert2Uri, cid: hypercert2Cid, weight: "0.3" },
|
|
977
|
+
* { uri: hypercert3Uri, cid: hypercert3Cid, weight: "0.2" },
|
|
978
|
+
* ],
|
|
979
|
+
* coverPhoto: coverImageBlob,
|
|
980
|
+
* });
|
|
981
|
+
* ```
|
|
982
|
+
*/
|
|
983
|
+
async createCollection(params: {
|
|
984
|
+
title: string;
|
|
985
|
+
claims: Array<{ uri: string; cid: string; weight: string }>;
|
|
986
|
+
shortDescription?: string;
|
|
987
|
+
coverPhoto?: Blob;
|
|
988
|
+
}): Promise<CreateResult> {
|
|
989
|
+
try {
|
|
990
|
+
const createdAt = new Date().toISOString();
|
|
991
|
+
|
|
992
|
+
let coverPhotoRef: BlobRef | undefined;
|
|
993
|
+
if (params.coverPhoto) {
|
|
994
|
+
const arrayBuffer = await params.coverPhoto.arrayBuffer();
|
|
995
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
996
|
+
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
|
|
997
|
+
encoding: params.coverPhoto.type || "image/jpeg",
|
|
998
|
+
});
|
|
999
|
+
if (uploadResult.success) {
|
|
1000
|
+
coverPhotoRef = {
|
|
1001
|
+
$type: "blob",
|
|
1002
|
+
ref: uploadResult.data.blob.ref,
|
|
1003
|
+
mimeType: uploadResult.data.blob.mimeType,
|
|
1004
|
+
size: uploadResult.data.blob.size,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const collectionRecord: Record<string, unknown> = {
|
|
1010
|
+
title: params.title,
|
|
1011
|
+
claims: params.claims.map((c) => ({ claim: { uri: c.uri, cid: c.cid }, weight: c.weight })),
|
|
1012
|
+
createdAt,
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
if (params.shortDescription) {
|
|
1016
|
+
collectionRecord.shortDescription = params.shortDescription;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (coverPhotoRef) {
|
|
1020
|
+
collectionRecord.coverPhoto = coverPhotoRef;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.COLLECTION, collectionRecord);
|
|
1024
|
+
if (!validation.valid) {
|
|
1025
|
+
throw new ValidationError(`Invalid collection record: ${validation.error}`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const result = await this.agent.com.atproto.repo.createRecord({
|
|
1029
|
+
repo: this.repoDid,
|
|
1030
|
+
collection: HYPERCERT_COLLECTIONS.COLLECTION,
|
|
1031
|
+
record: collectionRecord,
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
if (!result.success) {
|
|
1035
|
+
throw new NetworkError("Failed to create collection");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
this.emit("collectionCreated", { uri: result.data.uri, cid: result.data.cid });
|
|
1039
|
+
return { uri: result.data.uri, cid: result.data.cid };
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
1042
|
+
throw new NetworkError(
|
|
1043
|
+
`Failed to create collection: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
1044
|
+
error,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Gets a collection by its AT-URI.
|
|
1051
|
+
*
|
|
1052
|
+
* @param uri - AT-URI of the collection
|
|
1053
|
+
* @returns Promise resolving to collection URI, CID, and parsed record
|
|
1054
|
+
* @throws {@link ValidationError} if the URI format is invalid or record doesn't match schema
|
|
1055
|
+
* @throws {@link NetworkError} if the record cannot be fetched
|
|
1056
|
+
*
|
|
1057
|
+
* @example
|
|
1058
|
+
* ```typescript
|
|
1059
|
+
* const { record } = await repo.hypercerts.getCollection(collectionUri);
|
|
1060
|
+
* console.log(`Collection: ${record.title}`);
|
|
1061
|
+
* console.log(`Contains ${record.claims.length} hypercerts`);
|
|
1062
|
+
* ```
|
|
1063
|
+
*/
|
|
1064
|
+
async getCollection(uri: string): Promise<{ uri: string; cid: string; record: HypercertCollection }> {
|
|
1065
|
+
try {
|
|
1066
|
+
const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
1067
|
+
if (!uriMatch) {
|
|
1068
|
+
throw new ValidationError(`Invalid URI format: ${uri}`);
|
|
1069
|
+
}
|
|
1070
|
+
const [, , collection, rkey] = uriMatch;
|
|
1071
|
+
|
|
1072
|
+
const result = await this.agent.com.atproto.repo.getRecord({
|
|
1073
|
+
repo: this.repoDid,
|
|
1074
|
+
collection,
|
|
1075
|
+
rkey,
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
if (!result.success) {
|
|
1079
|
+
throw new NetworkError("Failed to get collection");
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Validate with lexicon registry (more lenient - doesn't require $type)
|
|
1083
|
+
const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.COLLECTION, result.data.value);
|
|
1084
|
+
if (!validation.valid) {
|
|
1085
|
+
throw new ValidationError(`Invalid collection record format: ${validation.error}`);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return {
|
|
1089
|
+
uri: result.data.uri,
|
|
1090
|
+
cid: result.data.cid ?? "",
|
|
1091
|
+
record: result.data.value as HypercertCollection,
|
|
1092
|
+
};
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
if (error instanceof ValidationError || error instanceof NetworkError) throw error;
|
|
1095
|
+
throw new NetworkError(`Failed to get collection: ${error instanceof Error ? error.message : "Unknown"}`, error);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Lists collections in the repository with pagination.
|
|
1101
|
+
*
|
|
1102
|
+
* @param params - Optional pagination parameters
|
|
1103
|
+
* @returns Promise resolving to paginated list of collections
|
|
1104
|
+
* @throws {@link NetworkError} if the list operation fails
|
|
1105
|
+
*
|
|
1106
|
+
* @example
|
|
1107
|
+
* ```typescript
|
|
1108
|
+
* const { records } = await repo.hypercerts.listCollections();
|
|
1109
|
+
* for (const { record } of records) {
|
|
1110
|
+
* console.log(`${record.title}: ${record.claims.length} claims`);
|
|
1111
|
+
* }
|
|
1112
|
+
* ```
|
|
1113
|
+
*/
|
|
1114
|
+
async listCollections(
|
|
1115
|
+
params?: ListParams,
|
|
1116
|
+
): Promise<PaginatedList<{ uri: string; cid: string; record: HypercertCollection }>> {
|
|
1117
|
+
try {
|
|
1118
|
+
const result = await this.agent.com.atproto.repo.listRecords({
|
|
1119
|
+
repo: this.repoDid,
|
|
1120
|
+
collection: HYPERCERT_COLLECTIONS.COLLECTION,
|
|
1121
|
+
limit: params?.limit,
|
|
1122
|
+
cursor: params?.cursor,
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
if (!result.success) {
|
|
1126
|
+
throw new NetworkError("Failed to list collections");
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
records:
|
|
1131
|
+
result.data.records?.map((r) => ({
|
|
1132
|
+
uri: r.uri,
|
|
1133
|
+
cid: r.cid,
|
|
1134
|
+
record: r.value as HypercertCollection,
|
|
1135
|
+
})) || [],
|
|
1136
|
+
cursor: result.data.cursor ?? undefined,
|
|
1137
|
+
};
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
if (error instanceof NetworkError) throw error;
|
|
1140
|
+
throw new NetworkError(
|
|
1141
|
+
`Failed to list collections: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
1142
|
+
error,
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|