Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .agents/skills/hypercerts-org-sdk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: hypercerts-org-sdk
description: "Write Hypercerts and Certified records (org.hypercerts.*, app.certified.*) to a PDS from an app with @hypercerts-org/sdk: setting up the SDK context from an authenticated @atproto/lex Client, choosing the right operation and subpath, uploading blobs, and handling write errors without duplicates. Use when app code imports @hypercerts-org/sdk, or the user wants to create, link or write hypercert-related records from an app. Not for changing the SDK's own source."
metadata:
author: hypercerts-org
version: "3"
version: "4"
---

# @hypercerts-org/sdk
Expand Down Expand Up @@ -52,7 +52,7 @@ Make a new context when the signed-in account changes. Records are always writte
| `@hypercerts-org/sdk/feature` | `createFeature`, `RecordAlreadyExistsError`, the same `Write*` errors, `FeatureInput`, `CreateFeatureResult`, `LeafletDocument` |
| `@hypercerts-org/sdk/organization` | `createOrganization`, `OrganizationAlreadyExistsError`, `RecordAlreadyExistsError`, the same `Write*` errors, `OrganizationInput`, `CreateOrganizationInput`, `CreateOrganizationResult`, `LeafletDocument` |
| `@hypercerts-org/sdk/blob` | `uploadBlob` |
| `@hypercerts-org/sdk/collection` | `createActivityInCollection`, `WriteError`, `WriteRejectedError`, `WriteOutcomeUnknownError`, `WriteResultMismatchError`, input/result types |
| `@hypercerts-org/sdk/collection` | `createCollection`, `createActivityInCollection`, `RecordAlreadyExistsError`, `WriteError`, `WriteRejectedError`, `WriteOutcomeUnknownError`, `WriteResultMismatchError`, input/result types |

The root entry does not re-export operations. `import { createActivity } from "@hypercerts-org/sdk"` fails.

Expand All @@ -67,7 +67,7 @@ Operations are grouped by domain, each under its own subpath. Read the domain's
| Location (a standalone `app.certified.location`, shared by several activities) | `@hypercerts-org/sdk/location` | [references/location.md](references/location.md) |
| Tag (`org.hypercerts.vocab.tag`, `org.hypercerts.workscope.tag`) | `@hypercerts-org/sdk/tag` | [references/tag.md](references/tag.md) |
| Feature (`org.hypercerts.entity.feature`, with its tags and locations) | `@hypercerts-org/sdk/feature` | [references/feature.md](references/feature.md) |
| Collection (add a new Activity to an existing Collection) | `@hypercerts-org/sdk/collection` | [references/collection.md](references/collection.md) |
| Collection (create a Collection with items; add a new Activity to an existing Collection) | `@hypercerts-org/sdk/collection` | [references/collection.md](references/collection.md) |
| Organization (`app.certified.actor.organization`, with its profile and locations) | `@hypercerts-org/sdk/organization` | [references/organization.md](references/organization.md) |

Three things to know before opening a reference:
Expand Down Expand Up @@ -107,7 +107,7 @@ Uploads happen before the write and are not rolled back, so a call that throws c

Order of work: validate input → read `{ uri }` records → upload blobs → validate against lexicons → one write. The SDK never retries.

The three write errors are exported from `@hypercerts-org/sdk/activity`, `/actor`, `/location`, `/tag`, `/feature`, `/collection` and `/organization` and extend `WriteError`. Every `WriteError` has `attempted`: the URI and CID of every record in the request, in request order: for `createActivity` the Activity is last; for `createActivityInCollection` the Collection is last and the Activity is just before it; for `createOrganization` the organization is last. `instanceof WriteError` catches all three.
The three write errors are exported from `@hypercerts-org/sdk/activity`, `/actor`, `/location`, `/tag`, `/feature`, `/collection` and `/organization` and extend `WriteError`. Every `WriteError` has `attempted`: the URI and CID of every record in the request, in request order: for `createActivity` the Activity is last; for `createActivityInCollection` the Collection is last and the Activity is just before it; for `createCollection` the Collection is last, after the records it points at; for `createOrganization` the organization is last. `instanceof WriteError` catches all three.

| Error | Meaning | What to do |
| -------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------- |
Expand Down Expand Up @@ -179,7 +179,8 @@ try {

- `createSDKContext({ client: session })` or passing an `@atproto/api` `Agent` throws. Wrap it: `new Client(session)`.
- Passing an `uploadBlob` result as `image` throws. Pass the `Blob`/`File`.
- Retrying blindly after `WriteOutcomeUnknownError` duplicates every record under new keys. The exception is a call whose records all have a fixed key and are only created if absent: `createProfile` and `createVocabularyTag`. `createOrganization` is also safe to repeat: the organization exists at most once, so a second call creates it or throws `OrganizationAlreadyExistsError`. `createFeature` and `createWorkScopeTag` are not safe to repeat.
- Retrying blindly after `WriteOutcomeUnknownError` duplicates every record under new keys. The exception is a call whose records all have a fixed key and are only created if absent: `createProfile` and `createVocabularyTag`. `createOrganization` is also safe to repeat: the organization exists at most once, so a second call creates it or throws `OrganizationAlreadyExistsError`. `createFeature`, `createCollection` and `createWorkScopeTag` are not safe to repeat.
- An item of `createCollection` is `{ activity }`, `{ feature }`, `{ collection }` or `{ uri }`, never a bare Activity input; `{ uri }` must be an Activity, Feature or Collection.
- A vocabulary tag's key is `<category>.<key>` exactly as you pass it. A dot in either part throws a `TypeError`; the SDK does not normalise the parts.
- A work-scope tag URI is not a vocabulary tag URI: each field accepts one collection, and a `{ uri }` of another throws a `TypeError`.
- One write holds at most 200 records on the reference PDS. A larger call throws a `TypeError` before anything is written; the SDK does not split it. Create some records first and pass their URIs.
Expand Down
71 changes: 66 additions & 5 deletions .agents/skills/hypercerts-org-sdk/references/collection.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,67 @@
# Collection domain (`@hypercerts-org/sdk/collection`)

Read [../SKILL.md](../SKILL.md) first for setup and error handling, and [activity.md](activity.md) for the `activity` input. Every example assumes `context` from `createSDKContext`.
Read [../SKILL.md](../SKILL.md) first for setup and error handling, [activity.md](activity.md) for an `activity` item, [feature.md](feature.md) for a `feature` item and [tag.md](tag.md) / [location.md](location.md) for tags and locations. Every example assumes `context` from `createSDKContext`.

Two operations: `createCollection` creates a **new** Collection with its items; `createActivityInCollection` adds a new Activity to a Collection that **already exists**.

## createCollection

`createCollection(context, input)` creates an `org.hypercerts.collection` (TID key) and every new record in it, in one `applyWrites` commit: the Collection, new Activities (with their rights, locations, contributors, ...), Features, child Collections, locations and tags. All of it exists afterwards, or none does.

```ts
import { createCollection } from "@hypercerts-org/sdk/collection";

const collection = await createCollection(context, {
type: "portfolio",
title: "Phobjikha valley restoration",
description: "Everything we do in the valley.", // string | Leaflet document | { uri } of any record
avatar: "https://example.org/valley.png", // URL string, or an image Blob/File to upload
location: { uri: "at://did:plc:abc123/app.certified.location/3k2a4b5c6d7e8" }, // reused
tags: [{ category: "theme", key: "forest", name: "Forest", status: "accepted" }], // created
items: [
{ activity: { title: "Reforestation", shortDescription: "Planting 5,000 trees." }, weight: "2" }, // created
{ feature: { type: "zone", title: "Valley floor zone" } }, // created
{ collection: { title: "Phase 2", items: [] } }, // created, can hold items too
{ uri: "at://did:plc:abc123/org.hypercerts.claim.activity/3k2a4b5c6d7e8", weight: "0.5" }, // reused
],
});
collection.items[0]; // { uri, cid, type: "activity", created: true, locations: [], contributors: [] }
collection.items[3]; // { uri, cid, type: "activity", created: false }
```

- An item is **exactly one** of `{ activity }`, `{ feature }`, `{ collection }` (the data of a new record, same input as `createActivity`, `createFeature` and `createCollection`) or `{ uri }`, plus an optional `weight`. The SDK never guesses from the fields: a bare Activity (`{ title, shortDescription }`) is a `TypeError`.
- `{ uri }` must be an Activity, Feature or Collection with a DID (`at://<did>/<collection>/<rkey>`). It is read for its CID and reused unchanged; it may belong to another account if public. Another record type, or a handle, is a `TypeError` naming the item.
- `weight` is a positive decimal string (`"2"`, `"0.50"`), stored exactly as you pass it. Weights need not add up to anything.
- Order is kept. Nothing is deduplicated or searched for: the same `{ uri }` twice gives two items, the same data twice gives two records.
- `location` and `tags` take new records or `{ uri }` like in `createFeature`. A new tag's key is `<category>.<key>`: a taken key throws `RecordAlreadyExistsError` before anything is uploaded or written, and two new tags with the same key anywhere in the call are a `TypeError` (create it first and pass its `uri`).
- `description`: a string and a Leaflet document (it needs `$type: "pub.leaflet.pages.linearDocument"`) stay inline in the Collection; `{ uri }` becomes a strong reference. The SDK never creates a document record.
- `avatar` (image up to 5 MB) and `banner` (image up to 10 MB): a URL string is stored as it is, a `Blob`/`File` is uploaded.
- The result mirrors the input: `result.items[i]`, `result.tags[i]` and `result.location` say what was created (`created: true`) or reused (`created: false`). Narrow an item on `created` and `type` to reach the nested results (`item.locations`, `item.items`, ...).
- One write holds at most 200 records, the Collection and every nested Activity, location and tag included. A bigger call throws a `TypeError` before anything is read or uploaded; it is never split. Create some records first and pass their `uri`.
- The session needs `create` on every collection the call writes to.

### Order of work and failures

Validate all input and count the records, read every `{ uri }` and every new tag's key, upload files, build the records, one write. A bad item, a wrong record type, too many records or an unreadable `{ uri }` fails (`TypeError` or `Error`) before any upload. A file the image limits reject, or a record the lexicon rejects (a title that is too long), is only found when that record is built, so files of other records may already be uploaded. Uploads are not rolled back if the write fails; see [Uploads are not part of the atomic write](../SKILL.md#uploads-are-not-part-of-the-atomic-write).

Errors are as in [Handle failures](../SKILL.md#6-handle-failures). After `WriteOutcomeUnknownError`, `attempted` lists every new record, children before the records that point at them, the Collection **last**. The write is all-or-nothing, so check the Collection with `getRecord`; if it exists, everything does. **Do not retry blindly**: it would create every record again under new keys.

### `CollectionInput`

| Field | Type | Notes |
| ------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------- |
| `title` | `string` | Required. At most 800 characters and 80 graphemes. |
| `type` | `string` | `favorites`, `project`, `portfolio`, `program`; other values accepted. |
| `shortDescription` | `string` | At most 300 graphemes. `shortDescriptionFacets` holds its rich-text facets. |
| `description` | `string \| LeafletDocument \| { uri }` | Inline text or document, or a reference to a record of any type. |
| `langs` | `string[]` | Up to three BCP-47 tags. |
| `avatar` / `banner` | `string \| Blob` | URL stored as is, or an image uploaded. |
| `location` | `LocationInput \| { uri }` | New or existing `app.certified.location`. |
| `tags` | `(VocabularyTagInput \| { uri })[]` | At most 20; vocabulary tags only (a work-scope tag URI throws). |
| `items` | `CollectionItemInput[]` | At most 1000. `{ activity \| feature \| collection \| uri, weight? }`. |
| `createdAt` | `string` | ISO 8601; defaults to the time of the call, for every new record. |

Not supported: `signatures`, adding items to a Collection that exists (see below), changing an existing record.

## createActivityInCollection

Expand All @@ -25,19 +86,19 @@ const { activity, collection } = await createActivityInCollection(context, {
- The item is appended last. Everything else in the Collection (`createdAt`, existing items, unknown fields, `signatures`) is kept as it was. `signatures` is not re-signed: after the append, an earlier signature no longer matches the content.
- A Collection that holds 1000 items is full: `Error`, nothing written. A Collection that does not exist or is not a valid `org.hypercerts.collection` record: `Error`, nothing written.
- Nothing is deduplicated: a second call creates a second Activity and a second item.
- Not supported: creating or editing Collections, adding an existing Activity, adding to another account's Collection, nested Collections.
- Not supported: adding an existing Activity, a Feature or a child Collection to an existing Collection, and adding to another account's Collection. To create a whole new Collection, use `createCollection`.

## Concurrent writes (`InvalidSwap`)
### Concurrent writes (`InvalidSwap`)

The write is conditional on the account not having changed since the Collection was read, so a concurrent change to its `items` cannot be overwritten with a stale list. When it did change you get `WriteRejectedError` with `code === "InvalidSwap"`: see [Concurrent changes](../SKILL.md#concurrent-changes-invalidswap) for the rule and the retry example.

Any other `WriteRejectedError` (4xx) is not fixed by calling again. After `WriteOutcomeUnknownError`, `attempted` lists the new records and, last, the Collection: check the Activity and whether the Collection lists it before retrying, or you create a second Activity.

## Uploads are outside the commit
### Uploads are outside the commit

`Blob`/`File` values are uploaded before the repository is read and are not rolled back if the write fails; see [Uploads are not part of the atomic write](../SKILL.md#uploads-are-not-part-of-the-atomic-write). A bad URI, another account's Collection, a missing, invalid or full Collection, or an invalid `activity` fails before any upload (the Collection is read once up front, then again after the latest commit for the guarded write).

## `CreateActivityInCollectionInput`
### `CreateActivityInCollectionInput`

| Field | Type | Notes |
| --------------- | --------------- | ------------------------------------------------------------------------------------------------------- |
Expand Down
7 changes: 7 additions & 0 deletions .changeset/create-collection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hypercerts-org/sdk": minor
---

- Add `createCollection` at `@hypercerts-org/sdk/collection`: creates a Collection with its items, location and tags in one `applyWrites` commit.
- An item is `{ activity }`, `{ feature }` or `{ collection }` (new, nested to any depth) or `{ uri }` (an existing Activity, Feature or Collection, reused unchanged), with an optional `weight` kept as written.
- Failed writes throw `WriteRejectedError` (nothing created) or `WriteOutcomeUnknownError` (lists every attempted record, the Collection last); the SDK never retries.
18 changes: 0 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,30 +61,12 @@ jobs:
# Exact and renamed copies, near-misses with up to 3 differing lines, and functions with the
# same structure. Test files are skipped: their setup repeats on purpose.
- run: pnpm check:duplicates --reporters console,sarif --output jscpd-report
# The semantic check embeds functions with a 548 MB local model; cache it so only the first
# run downloads it. The key changes when the jscpd version (and so its default model) does.
- if: ${{ !cancelled() }}
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/jscpd/models
key: jscpd-models-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: jscpd-models-
# Functions that do the same thing written differently, which the check above misses.
# Report-only for now: it flags too many false matches to block merges.
- if: ${{ !cancelled() }}
continue-on-error: true
run: pnpm check:duplicates:semantic --reporters console,sarif --output jscpd-semantic-report
# Upload even when clones fail the step above, which is when the annotations are useful.
- if: ${{ !cancelled() && hashFiles('jscpd-report/jscpd-report.sarif') != '' }}
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: jscpd-report/jscpd-report.sarif
category: jscpd
- if: ${{ !cancelled() && hashFiles('jscpd-semantic-report/jscpd-report.sarif') != '' }}
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: jscpd-semantic-report/jscpd-report.sarif
category: jscpd-semantic

package:
name: Package consumer check (Node ${{ matrix.node }})
Expand Down
Loading
Loading