MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.
Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.
A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.
Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.
741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
83 lines
3.8 KiB
JavaScript
83 lines
3.8 KiB
JavaScript
// server.js's boot ORDER, which is a contract and not a style choice.
|
|
//
|
|
// Phase 4, slice 3 of MODULE_SYSTEM.md §2.7.2. Five steps have to happen in one
|
|
// order, and each arrow is a dependency that is invisible at the call site:
|
|
//
|
|
// core schema → the declared set (§2.7.2 decision 4) needs the settings row
|
|
// its seed writes, to know which hosts it may install from
|
|
// declared set → requiring app.js SCANS the volume (§1.12), so anything put
|
|
// there afterwards is not in this process
|
|
// require app → module schema fragments (§2.6) can only be replayed once the
|
|
// loader knows which modules there are
|
|
// fragments → onBoot runs against tables that exist
|
|
//
|
|
// This is a source-structure test, and it is worth being plain about what that
|
|
// does and does not prove: it cannot tell you the server boots, only that nobody
|
|
// has quietly moved one of these five lines past another. It exists because the
|
|
// defect it guards against has already happened once and was invisible to every
|
|
// other kind of test here. Deferring the app require — which slice 3 had to do —
|
|
// moved core's schema ahead of the scan, and `ensureSchema` then skipped the
|
|
// module fragments entirely. On this machine's dev database the tables already
|
|
// existed, so the module started; on a FRESH database it would have started
|
|
// against no tables at all. Every suite in this directory stubs either the
|
|
// loader or the pool, so none of them could see it. The browser smoke did, from
|
|
// one log line.
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
|
|
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const SERVER = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.js'), 'utf8')
|
|
|
|
/** Where a marker appears, asserted to appear exactly once. */
|
|
function at(marker) {
|
|
const first = SERVER.indexOf(marker)
|
|
assert.notEqual(first, -1, `server.js no longer contains ${JSON.stringify(marker)}`)
|
|
assert.equal(
|
|
SERVER.indexOf(marker, first + 1),
|
|
-1,
|
|
`${JSON.stringify(marker)} appears more than once in server.js — this test cannot tell which is the boot step`,
|
|
)
|
|
return first
|
|
}
|
|
|
|
test('boot runs core schema, then the declared set, then the scan, then fragments, then onBoot', () => {
|
|
const steps = [
|
|
['core schema', at('await ensureSchema({ replayModules: false })')],
|
|
['declared module set', at('await declaredModules.resolve(')],
|
|
['the volume scan', at("require('./app')")],
|
|
['module schema fragments', at('replayFragments()')],
|
|
['module onBoot', at('await moduleLifecycle.boot()')],
|
|
]
|
|
|
|
for (let i = 1; i < steps.length; i += 1) {
|
|
assert.ok(
|
|
steps[i][1] > steps[i - 1][1],
|
|
`${steps[i][0]} must come after ${steps[i - 1][0]} in server.js`,
|
|
)
|
|
}
|
|
})
|
|
|
|
test('app.js is required inside start(), never at the top of the file', () => {
|
|
// The whole mechanism depends on this. A top-level require runs when server.js
|
|
// is loaded — before a single line of start() — and the scan would then happen
|
|
// before the declared set had a chance to put anything on the volume, silently
|
|
// and with no error anywhere.
|
|
assert.ok(
|
|
at("require('./app')") > at('async function start()'),
|
|
'requiring ./app at the top of server.js scans the volume before the declared set is resolved',
|
|
)
|
|
})
|
|
|
|
test('ensureSchema is asked NOT to replay fragments, and something else does', () => {
|
|
// Both halves matter. Passing the flag without replaying elsewhere is the
|
|
// original defect with an explicit spelling; replaying without the flag runs
|
|
// the fragments twice, the second time being the one that matters.
|
|
assert.match(SERVER, /ensureSchema\(\{ replayModules: false \}\)/)
|
|
assert.match(SERVER, /modules\/schema'\)\.replayFragments\(\)/)
|
|
})
|