Skip to main content

Publishing a Library

The shop's queue module works, and the payments team wants one. Right now the only way they can have it is to copy the directory, which is the problem Octo exists to solve, transplanted one level up.

Publishing turns it into something they install. It also turns your anchors into an API, and that is the part worth thinking about before the first release rather than after it.

The package​

An Octo library is an ordinary npm package with one requirement and one strong convention.

package.json
{
"name": "@shop/octo-shop",
"type": "module",
"files": [
"dist",
"package.json"
],
"dependencies": {
"@quadnix/octo": "~x.y.z"
},
"exports": {
"./modules/service/shop-queue-service": "./dist/modules/service/shop-queue-service/index.js",
"./modules/service/shop-queue-service/schema": "./dist/modules/service/shop-queue-service/index.schema.js",
"./anchors/shop-queue/schema": "./dist/anchors/shop/shop-queue.anchor.schema.js",
"./resources/shop-queue/schema": "./dist/resources/shop-queue/index.schema.js"
}
}

"type": "module" is the requirement. Octo is ESM only.

The convention is the shape of the exports map: one entry per module, per anchor schema, and per resource schema, rather than one barrel export. @quadnix/octo-aws-cdk publishes eighty-one of them for twenty modules, and the reason is that a consumer's definition file names one module by path:

imports:
- className: ShopQueueServiceModule
importPath: "@shop/octo-shop/modules/service/shop-queue-service"

A single entry point would make every consumer load every module in your library at boot, and every decorator in it run.

What to export, and what not to​

ExportBecause
Module classesThe definition file names them.
Module input schemasConsumers driving Octo from TypeScript need the type.
Anchor schemasThis is how other modules find your models.
Resource schemasThis is how other modules find your resources.
Keep internalBecause
Model classesA consumer importing one is coupled to your implementation.
Resource classesSame, and it invites somebody to construct one directly.
ActionsRegistered by their decorators. Nobody calls them.
Anchor classesConsumers match on the schema; only you construct them.

The split is the same idea as anchors themselves. A schema is a shape you promise to keep; a class is how you happen to implement it this month.

Mark internals with @internal in their JSDoc so they stay out of generated API documentation.

Naming​

The string in every decorator is your package name inside Octo:

@Module<ShopQueueServiceModule>('@shop', ShopQueueServiceModuleSchema)
@Model<ShopQueueService>('@shop', 'service', ShopQueueServiceSchema)
@Resource<ShopQueue>('@shop', 'shop-queue', ShopQueueSchema)
@Anchor('@shop')

Use one for the whole library, and pick it once. It appears in every resource context your library produces, so @octo/s3-storage=bucket-shop-receipts would become @shop/shop-queue=queue-shop-orders for yours. Those strings are in your users' state files, and changing the package name later invalidates every one of them.

The npm package name and the Octo package name are independent, and there is no need for them to match.

Depending on Octo​

List @quadnix/octo as a normal dependency, through a tilde range on the major and minor you build against, which is what @quadnix/octo-aws-cdk does. Move it with each Octo release. A library that holds its consumers on an older release than the one their application installs is how a project ends up with two copies of the framework running at once, which Package Versions covers.

If you build on top of another Octo library, for example your modules take a region from @quadnix/octo-aws-cdk, depend on it only for the anchor schema. Importing @quadnix/octo-aws-cdk/anchors/aws-region/schema costs your consumers nothing beyond a type.

Breaking changes​

Three things in your library are public whether you meant them to be, and each breaks consumers differently.

A module's input schema. Removing an input, or making an optional one required, breaks every definition file that loads it. Adding an optional input is safe.

An anchor schema. Removing a property or changing its type breaks every module that matched on it, and the failure is a lookup returning nothing rather than a compile error, which is worse. Adding a property is safe. When you have to change one, schema translation is what keeps existing consumers working during the migration.

A resource ID. This one does not look like an API and is the most expensive. Resource IDs derive from module inputs, and Octo uses them as identity, so changing how one is built means every existing user's next run destroys and recreates that infrastructure. Treat the derivation as frozen from the first release.

Documenting it​

Octo's own API documentation is generated from JSDoc, and the same works for a published library. The comments that pay for themselves are on the module class and the input schema, because those are what a consumer reads before deciding to use your module:

/**
* `ShopQueueServiceModule` provides an SQS-backed implementation of the `Service` model.
*
* @example
* ```ts
* octo.loadModule(ShopQueueServiceModule, 'orders-queue-module', {
* queueName: 'shop-orders',
* region: myRegion,
* });
* ```

*
* @see {@link ShopQueueServiceModuleSchema} for the input schema.
*/

One example that actually runs is worth more than a paragraph describing the inputs, which the schema already does.