Calls and reads between apps

Call a function or read a query that another app offers, with no HTTP and no shared code.

Docs menu

An app can call a function another app offers (substrio.apps.<app>.<entry>(...args)) or read an offered query (substrio.apps.<app>.query.<name>(...args)). Both apps must declare the connection. The providing app offers the function or query, and the calling app declares its use.

The word

In substrio.toml, sent by substrio deploy (operation apps.calls.set, PUT /v1/_app_calls; read back with apps.calls.get, GET /v1/_app_calls):

# The owner (app "people")
[apps.offer]
peopleByEmail = ["shop", "@account"]        # the function, offered to app "shop" and every app of this account

[apps.query.byEmail]
collection = "users"
where = ["email"]                           # equality on each field, bound to argument i; each declared indexed
select = ["name", "email"]                  # the only fields a row answers
to = ["shop"]
limit = 100                                 # optional: at most this many rows (absent: every match)

# The user (app "shop")
[apps.use]
people = ["peopleByEmail"]

[apps.use_query]
people = ["byEmail"]                        # or { byEmail = ["name"] } to read only some selected fields

"@account" offers to every app in the provider's account. "@featured", on a featured app only (a jet, featured-apps.md), offers to every app of every account: the entry reads the calling app with substrio.auth.getCurrentUser() ({"user_id": null, "account": {"id"}, "app": {"id", "slug", "account"}}) and meters it with substrio.usage.consume on the caller's account, the app's own use counted beside. On an app that is not featured, it offers to no one. The entire declaration is validated. An invalid declaration is refused with the path of the invalid member, and the current declaration remains in force. A declaration is stored before the response is returned.

You can name an app that does not exist yet, so deployment order is flexible. The response includes "warnings": [{"code": "APP_USE_UNKNOWN_APP", "app": "<slug>"}].

Using it

// Held once at the top of the file: deploys and runs as fast as a call made inside the handler.
const byEmail = substrio.apps.people.query.byEmail;

module.exports = async function (request) {
  const rows = byEmail('[email protected]');                        // [{ name: 'Ann', email: '[email protected]' }]
  const person = substrio.apps.people.peopleByEmail('[email protected]');
  return Response.json({ rows, person });
};
  • A read runs directly on the provider's store as an index seek, without running the provider's code. Rows are plain objects in the reader's heap and contain only the selected fields.
  • A call runs the provider's function in its isolate, under its limits. Arguments and the result must be plain data.
  • An app that names no other app has no substrio.apps at all (typeof substrio.apps === 'undefined').
  • A sandbox reaches the other app's sandbox of the same name, never its live data. A test app reaches only test apps.
  • A change to either declaration or a deployment of either app applies to the next call or read, including one referenced by a variable.
  • Every call and read is counted on both apps (APP_CALLS).

One MCP for connected apps

The same declaration connects apps' MCP tools. An app named under [apps.use] or [apps.use_query] adds each tool it offers you to your /mcp, under the name <app>_<tool>. The tool's offer member controls the offer. An agent signed in to your app calls it as the same person. The call runs in the other app under its route gate and as that person's user there. See Connected apps for offer, the errors (MCP_LINK_NOT_LINKED, MCP_LINK_NOT_OFFERED) and the cost.

Keeping data in the calling app

An app stores what another app creates through it in that app's database, with substrio.jet.use(appId, (d) => ...). Its route or entry passes request.auth.app.id. When one app's entry calls another's, the data goes to the app the call started from. The data then counts toward that app's storage, and the app's backups, archives, and sandboxes carry it. See Where a jet keeps an app's data.

Reading the caller's users

An entry in a featured app (a jet offered to every app with "@featured"; see featured-apps.md) can read and add users in the calling app's _users collection. It requires no key or second store:

// AuthJet's entry, offered to every app: the people of whichever app called it.
module.exports = function (options) {
  const page = substrio.caller.users.list({ limit: 50, prefix: 'a' });   // { users, total, returned, limit, skip, next_cursor }
  const one = substrio.caller.users.get(page.users[0].user_id);          // user.get's members, has_password included
  const made = substrio.caller.users.invite('[email protected]', { name: 'Ada' });  // { user_id }
  return { page, one, made };
};
  • list({limit, skip, cursor, prefix}) runs user.list on the calling app with the same seek in U<email> order. next_cursor is the last email on a page, or null on the last page. limit defaults to 50.
  • get(id) takes the user_id returned by a list, as a number or decimal text, like user.get.
  • invite(email, {name}) is user.create with no password: a person with no credential, who signs up later and claims it. The same address, in any case, is the same person; nobody is added twice.
  • The calling app is set when the call is admitted. It is not an argument, and an app member in the options is ignored. Only a featured app's entry can read the users of the app that called it.
  • Cost per call over user.list: no HTTP parse, no request build and no admission or session check; one tenant record load (the entry's own app must be a featured jet) and one lock-free lease of the caller's database; the same seek and answer; no callback through the C API.
  • Within a jet-to-jet call, the caller app is the calling jet, never the end app: when SendJet's entry calls AuthJet's through substrio.apps, substrio.caller.users in AuthJet's entry reads SendJet's people (SendJet has none) and not those of the app that called SendJet. A jet that needs the end app's people reads them in its own entry, where the end app is its caller, and passes them on as plain data.
  • A jet's route reached with an app's key has that app as its caller app too; the jet's own code is trusted, and a route that reads the people answers only what the jet's author meant, so gate it (auth).

Refusals

A refused call or read throws an Error whose name and code are the code below, with a message naming the app.

code when
app_call_unknown_app / app_query_unknown_app no app has that slug (never made, or deleted)
app_call_unavailable / app_query_unavailable the app exists but is suspended, or its database could not be opened
app_call_not_offered / app_query_not_offered the owner offers no such entry or query
app_call_not_granted / app_query_not_granted offered, but not to this app, or this app no longer declares it
app_query_bad_argument not one argument per where field, or an argument that is not a string, number or boolean
app_query_not_servable the query's collection is gone, or a where field is no longer declared indexed
app_query_field_not_exposed the reader names a field the owner's offer does not select
app_call_value_not_copyable an argument or the answer is not plain data (a function, promise, class instance, cycle)
app_call_not_local the owner runs where this call cannot reach it
cpu_timeout / memory_exceeded the owner's own CPU or heap limit ended the call (the caller goes on)

substrio.caller.users throws an Error whose name and code are the error-table code:

code status when
CALLER_USERS_REFUSED (0xC088) 403 the entry's own app is not a featured jet: an app you merely use never reads your users
NO_CALLER_APP (0xC089) 400 the featured jet's entry was called by no app (a route, a schedule, a direct call), or the calling app is gone
USER_NOT_FOUND 404 get of an id the calling app does not hold
EMAIL_INVALID 400 invite of an address that cannot be stored
INVALID_ARGUMENT 400 list with a limit or skip that is not a whole number (a limit of 0), get with an id that is not a person's

Refusals at apps.calls.set (each answered with its error-table status):

code when
INVALID_ARGUMENT the document is malformed (the detail names the path)
COLLECTION_NOT_FOUND an offered query names a collection that does not exist
APP_QUERY_NOT_INDEXED a where field (other than _id) is not declared indexed or unique
APP_QUERY_FIELD_NOT_EXPOSED use_query names a field the owner's granted offer does not select