V Platform Features

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"
}
  • locator identifies the component the aspect is for (locator.namespace, locator.name, locator.id); declaration is what the component wrote under the extension’s key.
  • main names the entry file; without it only an index.js is found.
  • name is the key the aspect takes on the context. Two aspects with the same name in one component stop it from starting.
  • aspect may return several aspects as an array.
  • open and close are called as the component connects and disconnects. Do not touch the outside world before open: 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:

MethodProvides
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.

Search
Search the documentation