Module stores
A store is just a list of ordinary modules:
- a module can be loaded on its own, from its
plugin.ts; - a store maps module names to URLs and lets you add one or all of them;
- a module never knows its store, and an app needs no store to add a single module.
So Qino, third parties and projects can each publish modules from their own location, and an app can combine several stores. No central service is needed.
API
app.modules.add(import.meta.resolve("./local/hello.world/plugin.ts")); // one module, by URL
const store = app.stores.add(import.meta.resolve("./vendor/store.json"));
store.add("hello.analytics"); // one module, by name
await app.stores.add(import.meta.resolve("./other/store.json")).addAll(); // the whole catalog
await app.stores.add(import.meta.resolve("./module/")).addAll(); // a plain folder is a store too
await app.init();
add() registers for one boot and is synchronous; app.init() imports, orders and links. The
module URL follows from the name, so store.add(name) reads nothing. Only addAll() reads the
catalog; it is names() + add(). These parts are public: store.names() reads the catalog,
store.moduleUrl(name) builds the URL, store.install(name) is the runtime version of add().
The backend store page uses them.
Folder or catalog
A store is a folder of module folders. A URL ending in / is read as a folder (its subfolders are
the modules); anything else is a store.json that lists them by name. In both cases
moduleUrl(name) is <base><name>/plugin.ts, so a folder can get a catalog later without any
module moving.
HTTP cannot list directories reliably, so folder stores are file: only and a remote store needs a
store.json. That file is also the place for per-module metadata if names are ever not enough —
e.g. a redirect. But then store.add(name) would have to read the catalog, and resolution would
move into init().
core needs no declaration; App adds it itself.
An app declares what it wants, not everything that needs. For missing dependencies init()
calls modules.locate(), which the StoreManager provides: declaring mail brings messaging
from whatever store has it. Installing does the same and stores each module, so each can be
uninstalled later. Dependencies no store offers are reported as missing.
Through this hook ModuleManager needs to know nothing about stores; otherwise the two managers
would depend on each other.
A store's listing is never cached: it may get new modules while the app runs.
Installing at runtime
add() registers for one boot; install() persists. Both managers have it:
await app.stores.install(catalogUrl); // stored in the `store` table
await store.install(name); // stored in the `module` table, then linked
await app.modules.install(pluginUrl, name); // the same, for a module without a store
await app.modules.uninstall(name); // unlink, plugin.uninstall(), row gone
await app.stores.uninstall(catalogUrl);
install creates what a module owns, uninstall removes it. link and unlink only attach the module to the running app. So linking requires an install, and unlinking keeps the data.
store.install(name) builds the URL like store.add(name). Use this form whenever request input
is involved: code that takes a name never handles an import URL. Importing a module runs server
code, so registering a store is a superuser decision, and installing from it relies on that.
Core does not enforce this; modules.install(url) stays open, since modules must work without
stores.
A module may install other modules in its install() hook.
During boot such an install only registers; init() links in passes, so the new modules get the
same ordering and schema merge. They are normal installed rows and can be uninstalled one by one —
a dependency could not.
Both tables belong to core, because they are needed before any module runs:
store: url
module: name | url | installed
A row means installed; installed is when plugin.install() ran; url is set only for modules
installed at runtime, which init() must import again. That also tells the two origins apart:
| Origin | removable through the UI | |
|---|---|---|
add() in server.ts |
the application itself | no (declared is true) |
row in store / module |
installed at runtime | yes |
plugin.install() runs once per app, not on every boot — otherwise uninstall() would make no
sense. An install() that used to repair things on each start no longer does.
A module row that fails to import must not stop the app. init() logs it, skips the row and
keeps it in modules.failures(); the store page lists these under Installed, not importable
with an uninstall button.
Both managers read their table in init() before migration, hence the listTables() check: a new
database has no tables yet.
cms.backend.superuser.module is the UI for all of this.
Minimal module
A module may contain only export function init(app) {}. All manifest fields are optional,
including name:
- a store supplies the name from its catalog;
- a directly added
<name>/plugin.tstakes the name of its folder; - a
namein the manifest wins, and a mismatch with the store's name fails the boot.
Duplicate names, one URL under two names, missing dependencies and cycles all fail with an error. Stores never override each other and there is no priority rule — a loud error can be relaxed later, silent shadowing cannot. Collisions between stores are avoided by the vendor segment; see module.md.
Catalog format
{
"modules": {
"hello.world": {},
"hello.analytics": {}
}
}
Paths are relative to the catalog: hello.world is at <catalog directory>/hello.world/plugin.ts.
The empty objects are reserved for metadata; today only "every value is an object" is checked and
unknown keys are ignored. The catalog is read with fetch, so file: and http(s): both work.
URL resolution
A function receiving "../module/plugin.ts" cannot know which file called it, so relative strings
are resolved against app.dir. To resolve relative to the calling file, do it at the call site:
app.modules.add(import.meta.resolve("../modules/example/plugin.ts"));
new URL("../modules/example/plugin.ts", import.meta.url) works too; add() accepts both.
Local source and JSR
Modules import Qino via @qino/qino and @qino/qino/cms. The repository root is a Deno workspace
with qino/ and the demos as members, so these resolve to the local package for demos,
test-modules/ and privat-module/. A standalone app maps them once to the published package,
which also makes copied modules work:
{
"imports": { "@qino/qino": "jsr:@qino/qino@^0.7" }
}
An app file can also use a full jsr: import without config, but module source files with bare
specifiers still need the mapping. To use a local checkout, make qino/ a workspace member. For a
local item.js checkout, add it to links in the root deno.json.
import.meta.resolve("jsr:…") returns an opaque specifier. It can be imported, but it is not a
normal URL, so a store needs a real file:, http: or https: URL.
One release, one graph
A store's files and the @qino/qino its modules import must be the same files. Otherwise both
are loaded twice, and module-level registries such as cmsInstances exist twice. A module
registered in one copy is missing in the other:
Error: module "cms" is not loaded
There is no warning; it just fails at runtime.
So point the store to where the specifier resolves. A local checkout is fine, since everything is
file:. For a release, use a registry with stable URLs: jsr:@qino/qino@^0.7 resolves to
https://jsr.io/@qino/qino/<version>/…, and a store at
https://jsr.io/@qino/qino/<version>/module/store.json serves modules from the same URLs.
Watch out: a local core plus modules from a remote store is exactly this broken case.
Public files of a remote module
For a module without a local directory, the pub/ part of its manifest.files is downloaded once
on import into cache/<name>/remote/, where the static route looks. So Module.modUrl is always an
address of this app — needed e.g. for SVG <use>, which does not work across origins.
A mirror is marked with its source only when complete; missing files are fetched again on the next start, and a health check lists incomplete modules.
Not there yet
- Integrity pinning. Installing a module from a URL runs remote code with full database
access — like WordPress or Drupal plugins. Registering the store is the superuser decision, but
nothing ensures that what gets installed is what was reviewed. The fix is a hash in the
modulerow, checked on re-import likedeno.lock. Deno's--allow-importdoes not help, since it applies to all tenants of the process. A second factor for install/uninstall would also help —authhas the factors andauth.webauthnthe ceremony; the step-up guard on the api verb is missing. - Locales of a remote module. Locales are read from
<module>/locale/in the localdir, which a remote module lacks — and HTTP cannot list directories.manifest.filesalready has the list (see Public files above).
Two more uses of that list are open: copying a remote module into the own store under a new name (fork — cms.backend.superuser.module.own refuses it today), and generating the list when publishing. A publishing app has the directory and needs no file; only a static host (CDN, pages) has to keep one.
- Module versions and rollback. The store page cannot offer updates without versions. But
rolling back also needs the host to keep old versions, and
<base><name>/plugin.tshas no version in it. Simpler: version the store via its URL (…/v2/). That suits modules developed together and needs no resolver — which could not work anyway, since a module name is global and two versions cannot coexist. - PostgreSQL demo. The
pgapp indemo/server.tsinstalls the default set, which happens to avoid what PostgreSQL cannot do yet. A general compatibility API is not worth it yet.