Write an Extension of Your Own
Several components of the shop need the warehouse’s inventory system, reached through a client library. Instead of each component creating and configuring a client, write an extension once: components declare it, and their operations find the client on the context. The same interface serves a storage for a database that is not supplied.
Most applications never need this. Why it is the place for such a concern is in the model article Extensions.
§Write the aspect
An extension is a package that exports a Factory. Its aspect method is asked once for each
component that declares the extension, and returns what goes on that component’s context:
// extensions/warehouse/index.ts
import { Connector } from '@toa.io/core'
import { connect } from 'warehouse-client'
import type { Locator } from '@toa.io/core'
import type { Client } from 'warehouse-client'
interface Declaration {
region: string
}
export class Factory {
aspect (locator: Locator, declaration: Declaration) {
return new Warehouse(declaration)
}
}
class Warehouse extends Connector {
name = 'warehouse'
#declaration: Declaration
#client!: Client
constructor (declaration: Declaration) {
super()
this.#declaration = declaration
}
async open () {
this.#client = await connect(process.env.WAREHOUSE_URL, this.#declaration)
}
async close () {
await this.#client.disconnect()
}
invoke (method: string, ...args: unknown[]) {
return this.#client[method](...args)
}
} Beside it, extensions/warehouse/package.json:
{
"name": "warehouse",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "index.ts"
} locatoridentifies the component the aspect is for (locator.namespace,locator.name,locator.id);declarationis what the component wrote under the extension’s key.mainnames the entry file; without it only anindex.jsis found.nameis the key the aspect takes on the context. Two aspects with the same name in one component stop it from starting.aspectmay return several aspects as an array.openandcloseare called as the component connects and disconnects. Do not touch the outside world beforeopen: no connections and no I/O in constructors.
§Declare it in a component
A component names the extension under extensions in its manifest, by a path relative to the manifest or by a
package reference. The value is the declaration handed to the factory:
# components/store.catalog/manifest.toa.yaml
name: catalog
namespace: store
extensions:
../../extensions/warehouse:
region: eu
operations:
stock:
input:
type: object
properties:
product: { type: string }
required: [product] §Use it in an operation
The aspect is called through context.aspects, under its name. The arguments are passed to invoke, and what invoke returns is returned:
// components/store.catalog/operations/stock.ts
import type { Context, StockInput } from '../types/index.d.ts'
export async function computation (input: StockInput, context: Context) {
return await context.aspects.warehouse('available', input.product)
} §Give it an address from the Context
Export a deployment function. It is given the components that declare the extension and the
annotation, and answers what a deployment needs, such as environment variables:
// extensions/warehouse/index.ts
import type { context } from '@toa.io/norm'
export function deployment (instances: context.Dependency[], annotation: string) {
return {
variables: {
global: [{ name: 'WAREHOUSE_URL', value: annotation }]
}
}
} The annotation is written in the Context under annotations, keyed
by the package’s name:
# context.toa.yaml
annotations:
warehouse: https://warehouse.internal
warehouse@local: http://localhost:9000 Variables declared this way are written by toa env for local runs and set on the workloads of a
deployment, which is how the aspect above finds WAREHOUSE_URL in its open.
§Try it
$ npm run restart
$ npx toa call store.catalog.stock "{ input: { product: 'a1' } }" §Check what components declare
Export a manifest function beside the Factory. It receives the declaration and the
component’s manifest, and returns the declaration to use:
// extensions/warehouse/index.ts
import type { Manifest } from '@toa.io/norm'
export function manifest (declaration: Partial<Declaration> | null, manifest: Manifest) {
if (declaration?.region === undefined)
throw new Error(`Component '${manifest.name}' declares no warehouse region`)
return declaration
} It runs when manifests are read, so a wrong declaration stops a deployment before anything is
built. It must return a value; returning undefined is an error:
Extension '../../extensions/warehouse' hasn't returned manifest §What else a factory can provide
Factory methods are all optional. Besides aspect:
| Method | Provides |
|---|---|
tenant(locator, declaration, manifest) | Something that runs beside each component that declares the extension, without being on its context. |
service() | A process of the extension’s own, run beside the application’s components. null where it is off. |
resident(host) | Something the extension keeps in every process, whatever that process runs. null where it keeps nothing. |
Whatever these return follows the same rule as an aspect: it connects in open, releases in close, and does nothing before open.
A service that listens picks a port that is not taken. Ports 8000 to 8005 are; see Ports that are taken.
What resident returns lives as long as the process: it connects before the rest of the process
is built and goes down after it. It may have a complete() method, called when everything the
process was built with has connected.
§Timers and polls
A deployment can be halted: every process keeps what it holds open and
stops what it does of its own accord. A part of an extension that acts on its own timing, a timer
or a poll, says how to stop and resume by overriding pause and unpause:
class Poller extends Connector {
pause () {
clearTimeout(this.timer)
}
unpause () {
this.arm()
}
} Whatever makes a call on its own timing belongs in pause. Connections stay open meanwhile; unpause alone undoes it, and nothing is rebuilt. A resident survives a halt.
§A storage of your own
A storage is a package too. A component names it in its entity declaration, by path or package
reference. Here it is store.orders, whose storage key is added:
# components/store.orders/manifest.toa.yaml
entity:
storage: ./storage
properties:
# … The package exports a Factory whose storage method returns the storage for a component, and
may export deployment exactly as an extension does:
// components/store.orders/storage/index.ts
import type { Locator } from '@toa.io/core'
import type { storages } from '@toa.io/core/types'
import type { context } from '@toa.io/norm'
export class Factory {
storage (locator: Locator, entity: storages.Entity) {
return new Storage(locator, entity)
}
}
export function deployment (instances: context.Dependency[], annotation: string) {
return {
variables: {
global: [{ name: 'ORDERS_DATABASE', value: annotation }]
}
}
} # context.toa.yaml
annotations:
orders-storage: postgres://db.example.com where orders-storage is the name in the storage’s package.json, whose main is index.ts.
Events, once, transitions over a set and collection streams work only where the storage can support them; see When a storage cannot do something.
Migrations are applied by the MongoDB storage only.
§Keep deployment commands light
Commands that only read declarations, such as a deployment or type generation, load an
extension’s package to call manifest and deployment. If the package’s entry pulls in heavy
dependencies, ship a definition.js beside it that exports those functions on their own; it is
read instead of the entry.