What package code may do
A provider package may act on its own API, but never on your app's data. That way a package can carry what someone who knows the API has learned, written once and tested, without reaching into the apps that install it.
- No state and no database. A package owns no entities or tables and never reads or writes the app's data. Data a package fetches reaches the app as a return value or an event, and the app's own action writes it.
- Package actions call only their own API. A provider package may
hold
public actions inactions/. They call the package's own operations in any order, pass answers between them, use its pure functions, and return a result or fail. They can't emit app events, send email, call any other connection, or read configuration or secrets. The app calls one asconnection.action(input)inside its own action. One with several changing calls follows Several changing calls in one action. Inside a package, operations are named without the connection:customers.create. - Package subscribers on its own webhooks. A package may subscribe to
its own webhook events, call its own API to enrich them, and emit its
own declared events, such as
Stripe.SubscriptionRenewed. - Events only about the outside world. A package emits events for
webhooks, enriched webhooks and polls, never as a side effect of the app
calling it. Where the app wants an event after a call, the app's own
action emits it, where a reviewer sees it. So an action that emits
can't be
public: only the package's subscribers and polls run it. Package events travel through the app's outbox like any other. - Package code runs only when started: by the app calling it, or by the outside world through a webhook or a poll the app subscribes to.
In this sketch, Stripe's package holds an action that makes several calls
in the order Stripe requires. Its subscriber renewal hears the raw
InvoicePaid webhook event, which isn't public, and runs
announce_renewal, which emits the public SubscriptionRenewed. The app
calls the action through its stripe connection:
// packages/stripe/v2024_06_20/actions/start_subscription.jadpo/// Creates the customer, attaches the card, starts the subscription and/// finalises its first invoice, in the order Stripe requires.public action start_subscription(input: StartSubscription) fails CardDeclined -> StartedSubscription { ... }// packages/stripe/v2024_06_20/subscribers/renewal.jadposubscriber renewal { on: InvoicePaid run: announce_renewal(event)}// domains/billing/actions/subscribe.jadpovar started: Stripe.StartedSubscription = attempt stripe.start_subscription(request) { card_declined: PaymentDeclined}
A package action, compiled
Here a package's action calls its own operations, one of which the package keeps to itself, and passes each answer to the next. The app calls the action through its connection and maps its failure at the call. A package action's changing calls are steps of the action that calls it (Several changing calls in one action). The choices are in "Package actions and recipes", further down this page.
The card can be declined after the customer was created. So the
package's surface lists after_effect DeclinedCard for
start_subscription, and the app's call of it gets the warning
step.late_failure: a point that can end subscribe for good once
something took effect (Jobs and events). Here
the empty customer left behind is harmless, so the warning is declined
with that reason.
/// Creates the customer, then attaches the card to it.public action start_subscription(input: PayRequest) fails DeclinedCard -> PayReply { var customer: PayReply = customers.create(input) return attempt cards.attach( PayRequest { reference: customer.id } )}
use app { pay }use billing/failures { PaymentRefused }use shared/providers/acme_pay/// Starts a subscription: two steps, run by a subscriber.decline step.late_failure: "A declined card leaves an empty customer at Acme Pay, which costs nothing"action subscribe(tier: AcmePay.Reference) fails PaymentRefused -> AcmePay.PayReply { return attempt pay.start_subscription( AcmePay.PayRequest { reference: tier } ) { declined_card: PaymentRefused }}
A package subscriber, compiled
Here a package's subscriber hears an event its webhooks bring, which the
package keeps to itself. It runs the package's action, which looks the
subscription up through the connection the webhook came in on and emits
the package's own public event. The app's subscriber keeps that event.
The choices are under "Package subscribers and recipes in sample/",
further down this page. The event the action emits gives no wire_name:,
because wire_name: marks an event the provider sends.
/// An invoice was paid: all Acme Pay's webhook says, kept to the package.type InvoicePaid = Event { wire_name: "invoice.paid" occurred_at: Instant subscription: Reference}
/// A subscription was renewed, with its tier: the package's own event.public type SubscriptionRenewed = Event { subscription: Reference tier: Reference paid_at: Instant}
/// Looks up the subscription an invoice paid for, and announces it.action announce_renewal(event: InvoicePaid) -> Unit { var renewed_subscription: Subscription = subscriptions.retrieve( SubscriptionAsked { id: event.subscription } ) emit SubscriptionRenewed { subscription: renewed_subscription.id tier: renewed_subscription.tier paid_at: event.occurred_at }}
/// Turns a paid invoice into a renewal.subscriber renewal { on: InvoicePaid run: announce_renewal(event)}
use billing/actions { record_renewal }use shared/providers/acme_pay/// Keeps each renewal Acme Pay announces.subscriber renewed { on: AcmePay.SubscriptionRenewed run: record_renewal(event)}
A signed invoice.paid reaches renewal once, and its lookup is sent
through payments with that connection's credential. The renewal it
emits reaches renewed through the app's outbox.
Polls
Polling turns an API without webhooks into events. polls: in
provider.jadpo names an operation, a default interval and the event
emitted when the answer changes. The runtime calls the operation on that
interval and keeps what it last saw, as runtime state, like webhook
de-duplication. It emits an event only when the answer changes. A poll
runs only while something subscribes to its event, and the connection can
change the interval: polls: { latest: 6h }.
// packages/exchange_rates/v1/provider.jadpoprovider { protocol: Protocol.json_http hosts: { live: "https://api.rates.example" } credential: CredentialScheme.bearer polls: { latest: { call: rates.latest every: 1d emits: RatesPublished } }}
Polls are for feeds that don't depend on your app's data, such as exchange rates or a price list. To check each of your own records, such as each parcel still in transit, write a schedule in your app that calls the package, one event per record. A package can give that as a recipe.
Here a poll of Acme Fuel's price list emits the package's own public
PricesPublished, made from its answer. The operation takes nothing and
changes nothing. The connection polls it every six hours instead of once
a day, and the app keeps each change. fetched_at, which the event
doesn't carry, never counts as a change. The choices are under "How polls
and max_concurrent: run", further down this page.
application { connections: { main: { driver: Driver.postgres } fuel: { package: FuelPrices.v1 domains: [pricing] credential: config.fuel_key environment: config.fuel_environment max_concurrent: 2 polls: { latest: 6h } } } events: { store: main }}
/// Acme Fuel's price list, which sends no webhooks.provider { protocol: Protocol.json_http hosts: { live: "https://api.fuel-prices.example" sandbox: "https://sandbox.fuel-prices.example" } credential: CredentialScheme.bearer max_concurrent: 4 polls: { latest: { call: pump_prices.latest every: 1d emits: PricesPublished } }}
/// The day's pump prices, published.public type PricesPublished = Event { region: Text diesel_pence: Int petrol_pence: Int}
use pricing/actions { keep_prices }use shared/providers/fuel_prices/// Keeps each change of price.subscriber prices_published { on: FuelPrices.PricesPublished run: keep_prices(event)}
The tick, the runtime's regular check for background work that is due,
calls pump_prices.latest through fuel with its credential once the
poll is due. It keeps PricesPublished and its deliveries only when the
prices changed, and tries a failed call again a minute later. Without the
subscriber the poll never runs. Under max_concurrent: 1 a call waits for
the connection's slot, and a delivery that would call through it waits in
the outbox.
Services
A package's own API may be a container Jadpo runs, declared with runs:.
The package's actions call it. A service can't call another package or
connection.
// packages/gotenberg/v8/provider.jadpo/// Gotenberg's HTTP API, run by Jadpo as a container beside the app.provider { protocol: Protocol.multipart_http runs: { image: "gotenberg/gotenberg:8@sha256:…" port: 3000 asks: ["fonts.googleapis.com"] }}
- Only stateless services. A service holds no data and has no volume, and each app instance runs its own copy, so many servers need nothing shared. Anything that holds data (PostgreSQL, Redis) is infrastructure the operator provides and the app reaches through a driver connection.
- A service never starts until approved. Installing shows the image, its
digest and the hosts it asks for; the app names it on a connection, and a
new or changed image needs a person's acceptance in the approval report
before it runs, so an upgrade cannot swap in a different image unseen. The
image's digest is written in the package source, which the package's own
digest pins; an own integration in
shared/providers/has no lock entry, and its image is listed the same way. - Outbound network needs the app's permission. A package only asks
(
asks:); the service gets none of those hosts until the connection grants them (outbound: [...]), and a new grant appears in the approval report. Until thenjadpo checknames the hosts asked for and the line that grants them. - Three settings Jadpo always applies when it creates a container: no outbound network unless granted, never privileged, and no access to the host's disk. Jadpo creates the containers itself, so there is no configuration to audit.
- Reaching a service. A package says only the port its service listens on inside its container, so two packages can both use 3000. Jadpo gives each app a private network that only it and its services are on, and hands each connection the address: the service's name on that network when the app runs in a container, or a loopback port the engine picked when it runs as a plain process. Nobody writes an address.
- Jadpo is a client, never a container runtime. It talks to whatever
engine the machine has through the Docker-compatible API, which Docker and
Podman both serve, and leaves pulling, isolation and restart policies to
it. An app with no service never needs an engine; when one is needed and
none is found,
jadpo devand the deployable say so and how to install one. - Docker stays behind the scenes.
jadpo serviceslists each service (package, image, state, health, CPU and memory, restarts, uptime);jadpo services start,stopandrestart <name>,logs <name>(--follow,--since) andshell <name>(development only) manage them, each with--json. In production the commands talk to the running app, which supervises its services. A service's output joins the app's OpenTelemetry logs under its name, and a call to it appears in the request's trace as a provider call. - Where services run.
jadpo devstarts the declared services, and tests keep using fakes.jadpo buildwrites the configuration that runs each one beside the app, Compose first. The native deployable supervises its services itself. On Workers, which runs no containers, a service is hosted elsewhere and the connection giveshost:.
Recipes
A package may ship recipes/: complete wiring examples (schedules, actions,
entities) that jadpo package recipe <package> <recipe> --into <domain>
copies into a domain, after which they are the app's own code. Each is
compiled and tested against the package's sample/ app at publish and
listed in the surface. Library packages may ship recipes too.
jadpo package check'srecipesrow compiles each recipe on its own, copied into the one domain of the package'ssample/app asjadpo package recipecopies it, the package placed as the sample's own integration; a recipe that doesn't compile is shown at its own path,recipes/<recipe>/..., and a package with recipes and no such sample ispublish.sample_missing. The choices are under "Package subscribers and recipes insample/".
Not built yet: services run by Jadpo (runs:), and running a recipe's
tests in sample/.
Markdown for agents: /docs/what-package-code-may-do.md.Built from Jadpo main at a707efb.