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
4 changes: 2 additions & 2 deletions .agents/skills/writing-changesets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Do not add one for documentation-only or test-only changes, changes to the separ

## Package and version bump

The bundle is versioned as one unit. The repository's other released package, the `@hypercerts-org/api` client, is not released yet and is in the Changesets `ignore` list (see `packages/api/README.md`), so until that changes the Changeset frontmatter must target only `@hypercerts-org/happyview-modules`:
The bundle is versioned as one unit. The repository's other released package, the `@hypercerts-org/api` client (`packages/api/`), has its own version and is published to npm; target it when a change alters its query definitions, types or `@hypercerts-org/lexicon` version, and give each package its own entry when a change touches both. For the bundle:

```md
---
Expand Down Expand Up @@ -59,7 +59,7 @@ Use bullets only when there are several distinct actions or changes to scan. Omi
## Procedure

1. Check the actual diff and read `.changeset/README.md` plus `happyview-modules/README.md` for the current scope and release behavior.
2. Run `pnpm changeset` and select `@hypercerts-org/happyview-modules`, or create a descriptive Markdown file under `.changeset/` with the required frontmatter.
2. Run `pnpm changeset` and select the affected packages, or create a descriptive Markdown file under `.changeset/` with the required frontmatter.
3. Write and review the summary using the plain-language guidance above. Make any adaptation steps concrete and accurate.
4. Run `pnpm changeset status` to check that the Changeset is valid and targets the intended package.

Expand Down
2 changes: 1 addition & 1 deletion .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This repository releases two packages, each with its own version:

- `@hypercerts-org/happyview-modules`: the installable API bundle (Lua modules, Lexicons and installer). Released as a Git tag and GitHub Release, not published to npm; operators retrieve the tagged source from this public repository and install it into their own HappyView instance.
- `@hypercerts-org/api`: the TypeScript query definitions and types for clients. Published to npm, with a Git tag and GitHub Release. Not released yet; see [packages/api/README.md](../packages/api/README.md#versioning-and-releases).
- `@hypercerts-org/api`: the TypeScript query definitions and types for clients. Published to npm, with a Git tag and GitHub Release.

Add a Changeset when a pull request changes what either package ships. Run:

Expand Down
5 changes: 5 additions & 0 deletions .changeset/api-first-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hypercerts-org/api': minor
---

First release of `@hypercerts-org/api`: TypeScript query definitions and types for the Hypercerts API, for use with the [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex) client. Every query is at its NSID path (`api.call(org.hypercerts.claim.getActivity, { uri })`), with typed parameters and views. The records in those views are the schemas of `@hypercerts-org/lexicon` 2.0, so a record type is the same in both packages. Install it with its peer dependencies, `@hypercerts-org/lexicon` ^2.0.0 and `@atproto/lex` ^0.4.2.
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["hypercerts-api-endpoints", "@hypercerts-org/api"],
"ignore": ["hypercerts-api-endpoints"],
"privatePackages": {
"version": true,
"tag": true
Expand Down
2 changes: 1 addition & 1 deletion docs/coverage.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"source": {
"apiManifest": "happyview-modules/manifest.json",
"pinnedLexiconPackage": "1.6.0"
"pinnedLexiconPackage": "2.0.0"
},
"runtimeValidation": "not-assessed",
"deploymentValidation": "not-assessed",
Expand Down
2 changes: 1 addition & 1 deletion docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8498,6 +8498,6 @@
}
}
},
"x-hypercerts-source": "happyview-modules/manifest.json; @hypercerts-org/lexicon@1.6.0",
"x-hypercerts-source": "happyview-modules/manifest.json; @hypercerts-org/lexicon@2.0.0",
"x-hypercerts-unresolved-references": []
}
2 changes: 1 addition & 1 deletion docs/sources/index.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"apiManifest": "happyview-modules/manifest.json",
"pinnedLexiconPackage": "1.6.0",
"pinnedLexiconPackage": "2.0.0",
"sourcePolicy": "Explorer operations are the query/procedure Lexicons declared by modules in happyview-modules/manifest.json. Referenced schemas are resolved from that manifest and its pinned Lexicon package; no remote refs are fetched.",
"runtimeValidation": "not-assessed",
"deploymentValidation": "not-assessed",
Expand Down
2 changes: 1 addition & 1 deletion happyview-modules/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"@atcute/cid": "2.4.1",
"@atproto/lexicon": "0.6.2",
"@atproto/syntax": "0.5.4",
"@hypercerts-org/lexicon": "1.6.0"
"@hypercerts-org/lexicon": "2.0.0"
},
"scripts": {
"lint:js": "eslint --max-warnings=0 --report-unused-disable-directives .",
Expand Down
21 changes: 10 additions & 11 deletions packages/api/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# @hypercerts-org/api

TypeScript query definitions and types for the Hypercerts API, for use with [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex). Not published to npm yet (see [Versioning and releases](#versioning-and-releases)); use it from this workspace, or from a tarball made with `pnpm pack` (which builds first). A git dependency does not work: `dist/` is not committed.
TypeScript query definitions and types for the Hypercerts API, for use with [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex). Install it with its peer dependencies:

```sh
npm install @hypercerts-org/api @hypercerts-org/lexicon @atproto/lex
```

```ts
import { Client } from "@atproto/lex";
Expand All @@ -19,10 +23,10 @@ type Activity = org.hypercerts.claim.activity.Main; // the record, from @hyperce

## What it contains

The package exports [lex's NSID namespace tree](https://www.npmjs.com/package/@atproto/lex) (`app`, `com`, `org`, `pub`), the same layout as `@hypercerts-org/lexicon/records`, so every name is an NSID:
The package exports [lex's NSID namespace tree](https://www.npmjs.com/package/@atproto/lex) (`app`, `com`, `org`, `pub`), the same layout as [`@hypercerts-org/lexicon`](https://www.npmjs.com/package/@hypercerts-org/lexicon), so every name is an NSID:

- Each query is at its NSID path (`org.hypercerts.claim.getActivity`), for `Client.call`, with its parameter and view types on the same path (`.$Params`, `.ActivityView`). Shared views are in `org.hypercerts.api.defs` and `org.hypercerts.entity.defs`.
- The records and other definitions the queries reference are in the same tree, but they are the modules of [`@hypercerts-org/lexicon/records`](https://github.com/hypercerts-org/hypercerts-lexicon), re-exported, not copied: `org.hypercerts.claim.activity` is the same module in both packages, and `ActivityView["record"]` is `org.hypercerts.claim.activity.Main`. A record no query returns, such as `org.hyperboards.board`, is not in this tree; import it from `@hypercerts-org/lexicon/records`. Where a record has queries under its NSID (`org.hypercerts.collection`), the namespace here adds the query modules to the record's exports.
- The records and other definitions the queries reference are in the same tree, but they are the modules of [`@hypercerts-org/lexicon`](https://github.com/hypercerts-org/hypercerts-lexicon), re-exported, not copied: `org.hypercerts.claim.activity` is the same module in both packages, and `ActivityView["record"]` is `org.hypercerts.claim.activity.Main`. A record no query returns, such as `org.hyperboards.board`, is not in this tree; import it from `@hypercerts-org/lexicon`. Where a record has queries under its NSID (`org.hypercerts.collection`), the namespace here adds the query modules to the record's exports.
- `LEXICON_VERSION`: the `@hypercerts-org/lexicon` version the definitions were generated from, also in `package.json` as `hypercerts.lexiconVersion`.

ESM only. `@atproto/lex` and `@hypercerts-org/lexicon` are peer dependencies; install versions in the declared ranges.
Expand All @@ -33,18 +37,14 @@ ESM only. `@atproto/lex` and `@hypercerts-org/lexicon` are peer dependencies; in

## How it is generated

`pnpm generate` (or `pnpm client:generate` from the repository root) runs `lex build --index-file` over the Lexicons declared in `happyview-modules/manifest.json` `validationLexicons`: the API's own Lexicons from `happyview-modules/lexicons/`, and the record Lexicons they reference, read from the installed `@hypercerts-org/lexicon`. It keeps the modules of the API's own Lexicons and the namespace modules on the path to them, points every other import or re-export at `@hypercerts-org/lexicon/records/<nsid path>` (and fails if a module is missing there), writes `src/lexicons/` and `src/index.ts`, and records the Lexicon version in `package.json`.
`pnpm generate` (or `pnpm client:generate` from the repository root) runs `lex build --index-file` over the Lexicons declared in `happyview-modules/manifest.json` `validationLexicons`: the API's own Lexicons from `happyview-modules/lexicons/`, and the record Lexicons they reference, read from the installed `@hypercerts-org/lexicon`. It keeps the modules of the API's own Lexicons and the namespace modules on the path to them, points every other import or re-export at `@hypercerts-org/lexicon/<nsid path>`, or at the package root for a top-level namespace (`com`, `pub`), and fails if the lexicon package does not export it, writes `src/lexicons/` and `src/index.ts`, and records the Lexicon version in `package.json`.

Generated files start with a banner naming `scripts/generate.js`, which rewrites lex's output. They are committed, unlike in the lexicon repository: `src/` is what reviewers see change when an API Lexicon changes, and `pnpm check:generated` fails when it is stale. Do not edit them.

Regenerate whenever a query Lexicon, `happyview-modules/manifest.json`, the `@hypercerts-org/lexicon` version or the `@atproto/lex` version (including a lockfile change to its generator dependencies) changes.

The generator works around [bluesky-social/atproto#5738](https://github.com/bluesky-social/atproto/issues/5738), broken imports for queries under a record NSID. Remove the workaround once it is fixed upstream.

## Unreleased lexicon package

`@hypercerts-org/lexicon/records` is not released yet ([hypercerts-lexicon#270](https://github.com/hypercerts-org/hypercerts-lexicon/pull/270)). Until it is, the package builds against `vendor/hypercerts-org-lexicon-1.7.0.tgz`, a packed build of that pull request. Replace it with the published version (`"@hypercerts-org/lexicon": "1.7.0"`) and delete `vendor/` once it is on npm.

## Commands

Run from `packages/api/`:
Expand All @@ -65,10 +65,9 @@ Tooling follows the SDK repository (TECH-748): Biome, TypeScript 7, ESM, Node 24

The package is versioned with Changesets, independently of the API modules. The release workflow publishes it to npm with [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) and provenance, and creates an `@hypercerts-org/api@X.Y.Z` tag and GitHub Release, when a Release PR that bumps it is merged.

It is not released yet: it is `private` and listed in the Changesets `ignore` list until it builds against a published lexicon package. To start releasing it:
The first release needs a Trusted Publisher for `@hypercerts-org/api` on npmjs.com (repository `hypercerts-org/api`, workflow `release.yml`). If npm requires the package to exist before one can be configured, an owner of the `@hypercerts-org` npm scope publishes the first version by hand.

1. Configure a Trusted Publisher for `@hypercerts-org/api` on npmjs.com: repository `hypercerts-org/api`, workflow `release.yml`. If npm requires the package to exist first, an owner of the `@hypercerts-org` npm scope publishes the first version by hand.
2. In one pull request: remove `"private": true`, remove the package from `ignore` in `.changeset/config.json`, and add a changeset for it.
`@hypercerts-org/lexicon` is a peer dependency: the record types come from it. The version the package is generated from is pinned in `devDependencies` and must match the one `happyview-modules/` validates against (a test checks this), so the client's types describe what the server accepts. When it changes, regenerate `src/` (see [How it is generated](#how-it-is-generated)).

## License

Expand Down
7 changes: 3 additions & 4 deletions packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "@hypercerts-org/api",
"version": "0.0.0",
"private": true,
"description": "TypeScript query definitions and types for the Hypercerts API, for use with @atproto/lex",
"license": "MIT OR Apache-2.0",
"repository": {
Expand Down Expand Up @@ -31,7 +30,7 @@
"access": "public"
},
"hypercerts": {
"lexiconVersion": "1.7.0"
"lexiconVersion": "2.0.0"
},
"scripts": {
"generate": "node scripts/generate.js",
Expand All @@ -48,12 +47,12 @@
},
"peerDependencies": {
"@atproto/lex": "^0.4.2",
"@hypercerts-org/lexicon": "^1.7.0"
"@hypercerts-org/lexicon": "^2.0.0"
},
"devDependencies": {
"@atproto/lex": "0.4.2",
"@biomejs/biome": "2.6.3",
"@hypercerts-org/lexicon": "file:vendor/hypercerts-org-lexicon-1.7.0.tgz",
"@hypercerts-org/lexicon": "2.0.0",
"@types/node": "24.19.2",
"typescript": "7.0.2",
"vite": "8.3.5"
Expand Down
6 changes: 3 additions & 3 deletions packages/api/scripts/check-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const packageJson = readJson(path.join(packageRoot, "package.json"));
const pinned = (name) => {
const version = packageJson.devDependencies?.[name];
if (!version) throw new Error(`check-package: ${name} must be a devDependency`);
return version.replace(/^file:/, `file:${packageRoot}/`);
return version;
};

// Child processes must not inherit the parent's npm and pnpm settings.
Expand Down Expand Up @@ -94,7 +94,7 @@ function createConsumer(dir, tarball) {
/** @param {string} consumer */
function checkNode(consumer) {
const script = `import { LEXICON_VERSION, org } from "@hypercerts-org/api";
import { org as records } from "@hypercerts-org/lexicon/records";
import { org as records } from "@hypercerts-org/lexicon";
if (org.hypercerts.claim.getActivity.main.nsid !== "org.hypercerts.claim.getActivity") throw new Error("query");
if (org.hypercerts.claim.activity.main !== records.hypercerts.claim.activity.main) throw new Error("records are not shared");
if (LEXICON_VERSION !== ${JSON.stringify(packageJson.hypercerts.lexiconVersion)}) throw new Error("LEXICON_VERSION");`;
Expand All @@ -108,7 +108,7 @@ function checkTypes(consumer) {
path.join(consumer, "src", "types.ts"),
`import { Client } from "@atproto/lex";
import { org } from "@hypercerts-org/api";
import { org as records } from "@hypercerts-org/lexicon/records";
import { org as records } from "@hypercerts-org/lexicon";
const api = new Client("https://api.example");
const uri = "at://did:plc:abc123/org.hypercerts.claim.activity/3k2abc" as const;
export async function record(): Promise<records.hypercerts.claim.activity.Main> {
Expand Down
68 changes: 43 additions & 25 deletions packages/api/scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// happyview-modules/manifest.json, in lex's own layout: the package exports lex's NSID
// namespace tree (app, com, org, pub), as in the @atproto/lex documentation.
// Modules for the API's own Lexicons (happyview-modules/lexicons/) are generated here;
// everything else in the tree (records and their definitions) points at
// @hypercerts-org/lexicon/records, so no record code is copied. Run with
// --check to fail when the committed output is stale.
// everything else in the tree (records and their definitions) points at the
// modules of @hypercerts-org/lexicon at the same NSID path, so no record code is
// copied. Run with --check to fail when the committed output is stale.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
Expand All @@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const modulesRoot = path.resolve(packageRoot, "../../happyview-modules");
const lexEntry = path.join(packageRoot, "node_modules/@atproto/lex/bin/lex");
const recordsSpecifier = "@hypercerts-org/lexicon/records";
const recordsSpecifier = "@hypercerts-org/lexicon";
const header =
'/*\n * THIS FILE WAS GENERATED BY scripts/generate.js WITH "@atproto/lex". DO NOT EDIT.\n */';
const lexHeader = '/*\n * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.\n */';
Expand Down Expand Up @@ -91,40 +91,58 @@ function keptFiles(files, localIds) {

/**
* Keeps only this package's modules and points every import or re-export of
* another module at @hypercerts-org/lexicon/records.
* another module at the same NSID path in @hypercerts-org/lexicon. Paths are
* resolved through the lexicon package's exports, so a module it does not
* export fails here rather than in a consumer.
* @param {string} lexiconsDir generated output
* @param {string[]} localIds
* @param {string} lexiconRoot installed @hypercerts-org/lexicon
*/
async function linkRecords(lexiconsDir, localIds, lexiconRoot) {
async function linkRecords(lexiconsDir, localIds) {
const files = await listFiles(lexiconsDir);
const keep = keptFiles(files, localIds);
await Promise.all(
files.filter((file) => !keep.has(file)).map((file) => rm(path.join(lexiconsDir, file))),
);
await removeEmptyDirectories(lexiconsDir);

const require = createRequire(path.join(packageRoot, "package.json"));
const rootExports = new Set(Object.keys(await import(recordsSpecifier)));

for (const file of keep) {
const absolute = path.join(lexiconsDir, file);
const source = await readFile(absolute, "utf8");
const directory = path.posix.dirname(file);
const linked = source.replaceAll(/from "(\.\.?\/[^"]+)"/g, (match, specifier) => {
// Workaround for bluesky-social/atproto#5738: @atproto/lex 0.4.2 emits
// "./.defs.js" for a query whose namespace is a record NSID
// (org.hypercerts.collection.getCollection imports
// org.hypercerts.collection). Remove once fixed upstream.
const fixed =
specifier === "./.defs.js" ? `../${path.posix.basename(directory)}.defs.js` : specifier;
const target = path.posix.normalize(path.posix.join(directory, fixed));
if (keep.has(target.replace(/\.js$/, ".ts"))) {
return fixed === specifier ? match : `from "${fixed}"`;
}
const modulePath = target.slice(0, -".js".length);
if (!existsSync(path.join(lexiconRoot, "dist/records", `${modulePath}.js`))) {
throw new Error(`${file} needs ${modulePath}, which ${recordsSpecifier} does not export`);
}
return `from "${recordsSpecifier}/${modulePath}"`;
});
const linked = source.replaceAll(
/(export \* as (\w+) )?from "(\.\.?\/[^"]+)"/g,
(match, reexport, name, specifier) => {
// Workaround for bluesky-social/atproto#5738: @atproto/lex 0.4.2 emits
// "./.defs.js" for a query whose namespace is a record NSID
// (org.hypercerts.collection.getCollection imports
// org.hypercerts.collection). Remove once fixed upstream.
const fixed =
specifier === "./.defs.js" ? `../${path.posix.basename(directory)}.defs.js` : specifier;
const target = path.posix.normalize(path.posix.join(directory, fixed));
if (keep.has(target.replace(/\.js$/, ".ts"))) {
return fixed === specifier ? match : `${reexport ?? ""}from "${fixed}"`;
}
const modulePath = target.slice(0, -".js".length);
// A top-level namespace (app, com, org, pub) is a root export, not a subpath.
if (!modulePath.includes("/")) {
if (name !== modulePath || !rootExports.has(name)) {
throw new Error(
`${file} needs ${modulePath}, which ${recordsSpecifier} does not export`,
);
}
return `export { ${name} } from "${recordsSpecifier}"`;
}
try {
require.resolve(`${recordsSpecifier}/${modulePath}`);
} catch {
throw new Error(`${file} needs ${modulePath}, which ${recordsSpecifier} does not export`);
}
return `${reexport ?? ""}from "${recordsSpecifier}/${modulePath}"`;
},
);
if (linked !== source) await writeFile(absolute, linked);
}
}
Expand Down Expand Up @@ -234,7 +252,7 @@ async function generate(srcDir, lexiconPkg) {
await rm(inputDir, { recursive: true, force: true });
}
const localIds = lexicons.filter((entry) => entry.local).map((entry) => entry.id);
await linkRecords(lexiconsDir, localIds, lexiconPkg.root);
await linkRecords(lexiconsDir, localIds);
await normalizeOutput(lexiconsDir);
await writeIndex(srcDir, lexiconPkg.version);
}
Expand Down
Loading
Loading