asobi_extensions (asobi v0.75.1)

View Source

The extension registry: a pure memoised function, not a process.

resolve/0 loads the host's application closure, discovers every application exporting an <app>_extension module, reads its manifest, validates the set and writes the result to persistent_term. Whoever calls first pays for it; everybody after reads a term.

It cannot be a process, and this is the constraint that decides the shape. nova is in asobi's applications list, so OTP starts Nova first, and nova_sup:init/1 calls setup_cowboy/1 -> nova_router:compile/1 -> asobi_router:routes/1 before asobi_app:start/2 ever runs. The router is therefore the first caller, and at that moment no asobi process exists. A registry gen_server, a supervised child or a step in an asobi boot sequence would all be populated after the route table was already compiled.

Its callers, in boot order:

  1. asobi_router:routes/1, inside Nova's boot.
  2. asobi_app:start/2, before migrations.
  3. asobi_repo:migration_apps/0, when kura discovers migrations.

Discovery walks the OTP application graph, not Nova's nova_apps. nova_apps only sees Nova apps, so a Lua-only or jobs-only extension would be invisible, and Nova's own resolve_nova_apps reverses its accumulator at every nesting level so its output is neither depth-first nor declaration order. application:get_key(App, applications) is the authoritative start order, and the closure walk emits each application after everything it depends on, so the discovered list is already topologically ordered with ties in the host's declaration order. Nothing sorts it afterwards.

Core loads the closure before discovering. In a release every application is loaded by the boot script before any start; under rebar3 shell and CT they load lazily, and without the sweep the discovered set would differ between dev and prod with no error.

rebar3 asobi check is the primary gate and runs check/0, the same function, without memoising. Boot validation is a backstop, and it is a backstop specifically because a failure raised from inside nova_sup:init/1 surfaces with Nova's crash context rather than a legible asobi error.

Summary

Types

One lua/0 binding with the namespace and function name it was declared under.

One resolved extension. sup/0 is deliberately absent; see sup_specs/1.

Functions

Discover, read and validate without memoising.

Renders validation problems as lines a human can act on.

Every error code the installed extensions mint, as Code => {Status, Message}.

Every game.<ns>.<fn> binding the installed extensions declare, flattened.

The operator action Extension declares as Action, or undefined.

The installed extensions, in dependency order. Memoised.

Every RPC method the installed extensions declare, as Method => {M, F, A}.

The child specs an extension wants asobi to supervise.

Namespace disjointness across the declared set, and against core's reserved names.

Types

binding()

-type binding() ::
          #{namespace := asobi_extension:lua_namespace(),
            function := binary(),
            mfa := mfa(),
            args := [asobi_extension:lua_arg()],
            effects := asobi_lua_surface:effect(),
            vms := [asobi_lua_surface:vm_kind()]}.

One lua/0 binding with the namespace and function name it was declared under.

extension()

-type extension() ::
          #{app := atom(),
            module := module(),
            name := asobi_extension:name(),
            extension_version := pos_integer(),
            rpc := asobi_extension:rpc(),
            ops := asobi_extension:ops(),
            lua := asobi_extension:lua(),
            owns := asobi_extension:owns(),
            codes := asobi_extension:codes()}.

One resolved extension. sup/0 is deliberately absent; see sup_specs/1.

problem()

-type problem() ::
          {bad_manifest, atom(), module(), term()} |
          {duplicate_name, asobi_extension:name(), atom(), atom()} |
          {namespace_conflict, asobi_extension_reserved:kind(), asobi_extension:token(), atom(), atom()} |
          {reserved_namespace, asobi_extension_reserved:kind(), asobi_extension:token(), atom()} |
          {undeclared_claim, asobi_extension_reserved:kind(), asobi_extension:token(), atom()}.

Functions

check()

-spec check() -> {ok, [extension()]} | {error, [problem(), ...]}.

Discover, read and validate without memoising.

The build-time gate (rebar3 asobi check) and the boot backstop run exactly this, so the two can never disagree.

describe(Problems)

-spec describe([problem()]) -> [binary()].

Renders validation problems as lines a human can act on.

error_codes()

-spec error_codes() -> #{asobi_error:code() => {pos_integer(), binary()}}.

Every error code the installed extensions mint, as Code => {Status, Message}.

asobi_error reads this rather than resolving, so building an error object never triggers discovery and never raises: before resolve/0 has run there are no extension codes, and resolve/0 runs inside Nova's boot, long before any request can fail.

lua_bindings()

-spec lua_bindings() -> [binding()].

Every game.<ns>.<fn> binding the installed extensions declare, flattened.

asobi_lua_api filters this by the VM kind it is installing into and installs what remains. Flat rather than nested because every consumer wants one pass over bindings, not a walk over namespaces.

ops_action(Extension, Action)

-spec ops_action(binary(), binary()) -> asobi_extension:ops_entry() | undefined.

The operator action Extension declares as Action, or undefined.

Read on two request paths - the capability check in asobi_ops_caps and the dispatch in asobi_ops_extension - so like rpc_methods/0 it reads the memoised table and never triggers discovery. undefined for an extension that is not installed and for an action it does not declare, which is one answer because they are one outcome: nothing to route to, and nothing to authorise.

resolve()

-spec resolve() -> [extension()].

The installed extensions, in dependency order. Memoised.

Raises {asobi_extensions, Problems} on an invalid set, after logging the same problems in prose. A node that cannot say which extension owns a namespace must not serve traffic under either.

rpc_methods()

-spec rpc_methods() -> #{asobi_extension:method() => mfa()}.

Every RPC method the installed extensions declare, as Method => {M, F, A}.

asobi_rpc reads this rather than resolving, for the same reason error_codes/0 exists: dispatch is on a request path and must never trigger discovery. Before resolve/0 has run the table is empty, and resolve/0 runs inside Nova's boot, long before a socket exists.

Methods cannot collide across extensions. A method's prefix is a derived rpc claim, and validate/1 refuses two extensions claiming one prefix, so flattening the declared maps together cannot lose an entry.

sup_specs(Module)

-spec sup_specs(module()) -> [supervisor:child_spec()].

The child specs an extension wants asobi to supervise.

Called from asobi_extension_child_sup:init/1 rather than cached, so the running code always decides.

validate/1

-spec validate([extension()]) -> ok | {error, [problem(), ...]}.

Namespace disjointness across the declared set, and against core's reserved names.

The claim set per namespace is owns/0 plus what the extension's own code already implies, and every kind derives:

KindDerived from
rpcthe prefixes in rpc/0 and the domains in codes/0
luathe namespaces in lua/0
tablestable/0 on the extension's kura_schema modules
queuesqueue/0 on the extension's shigoto_worker modules

So two extensions installing the same game.quests, or the same job queue, collide before either has bothered with owns/0.

The last two derive through asobi_extension_reserved, the same rule that finds core's own tables and queues, so a name core reserves and a name an extension claims can never be found two different ways.

That leaves owns/0 doing one job, and it is worth stating because it is no longer the source of anything: it is the closed-set assertion over what was derived. Naming a kind at all says "this is the complete set", and anything derived outside it is undeclared_claim - which is what turns a typo in owns.queues from an invisible no-op into a build failure. Nothing in owns/0 is load-bearing for collision detection any more.