asobi_extensions (asobi v0.75.1)
View SourceThe 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:
asobi_router:routes/1, inside Nova's boot.asobi_app:start/2, before migrations.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
-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.
-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.
-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
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.
Renders validation problems as lines a human can act on.
-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.
-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.
-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.
-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.
-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.
-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.
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:
| Kind | Derived from |
|---|---|
rpc | the prefixes in rpc/0 and the domains in codes/0 |
lua | the namespaces in lua/0 |
tables | table/0 on the extension's kura_schema modules |
queues | queue/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.