Files
Module-uo/server/core.js
wtclaude fe3251a543 feat(server): port the UO models, utils and schema fragment
The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.

server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.

Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.

The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.

One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:06:26 -05:00

113 lines
4.8 KiB
JavaScript

// ── Everything this module reaches in core ─────────────────────────────────
//
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
// code below it — models, utils, controllers — is ordinary Node that requires
// its dependencies at file scope, the way it did when it lived in core. This
// file is what lets both be true.
//
// **The shape is a lazy accessor, not a stored reference, and that is the whole
// point.** A ported file writes
//
// const { query } = require('../../core')
//
// at require time, which is before `register()` has been called and therefore
// before any `ctx` exists. Handing out `ctx.db.query` there would hand out
// `undefined`, permanently, and the failure would surface much later as a
// TypeError inside a model. So every export here is a stable function that
// resolves `ctx` when it is CALLED. Require order stops mattering, and the port
// stays a one-line import change per file rather than a signature change per
// function.
//
// The other half of the same rule: nothing here may be destructured off `ctx`
// at init time either, for the same reason in the other direction — core is
// free to hand over a getter (`ctx.site.baseUrl` is one), and a value captured
// once is a value that cannot change.
//
// If `ctx` is missing, every accessor throws with the same message. That is
// deliberate: the only way to reach one before `register()` is a require cycle
// or a test that forgot to call `init`, and both want naming, not `undefined`.
let ctx = null
function need() {
if (!ctx) {
throw new Error('module-uo: core accessed before register() — see server/core.js')
}
return ctx
}
/** Called once, first thing in `register()`. */
function init(value) {
ctx = value
}
/** Test seam. Nothing in the module calls this; there is no de-registration. */
function _reset() {
ctx = null
}
// A logger that can be taken at require time and used after `register()`.
//
// Ported files write `const log = require('../core').logger('shard-ingest')` at
// file scope — the same shape as core's `require('./logger')('…')` — so the
// object returned has to exist before `ctx` does. It is a façade whose four
// methods each resolve the real logger on call. Core namespaces it with the
// module id, so these come out as `[uo:shard-ingest]`.
function logger(namespace) {
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
}
module.exports = {
init,
_reset,
logger,
// Shared server dependencies. Core owns exactly one express, as it owns
// exactly one React on the client, and for the same reason: a second copy in
// the process is a second Router prototype and a second set of instanceof
// checks. A module lives outside core's `server/`, so it could not resolve
// these for itself even if it were allowed to (§7.2).
get express() { return need().express },
get validator() { return need().validator },
// Database. `query` is the one every `*.db.js` uses; `pool` is for the
// streamed atlas import, which needs a connection it can hold.
query: (...args) => need().db.query(...args),
get pool() { return need().db.pool },
// Core state a module may read or append to, each narrowed to what is
// actually used (§2.3).
settings: {
get: (...args) => need().settings.get(...args),
set: (...args) => need().settings.set(...args),
getInstanceName: (...args) => need().settings.getInstanceName(...args),
},
activity: { log: (...args) => need().activity.log(...args) },
users: { getById: (...args) => need().users.getById(...args) },
posts: {
listAll: (...args) => need().posts.listAll(...args),
getById: (...args) => need().posts.getById(...args),
linkAnnounceJob: (...args) => need().posts.linkAnnounceJob(...args),
markAnnounced: (...args) => need().posts.markAnnounced(...args),
},
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
push: { publish: (...args) => need().push.publish(...args) },
secretBox: {
encrypt: (...args) => need().secretBox.encrypt(...args),
decrypt: (...args) => need().secretBox.decrypt(...args),
},
get uploads() { return need().uploads },
// Middleware. Taken as values rather than wrapped, because express stores the
// function reference at mount time — a wrapper would be what ends up in the
// stack, and `requireRole('admin')` returns a middleware rather than being
// one. Routers are built inside `register()`, so `ctx` is set by then.
get middleware() { return need().middleware },
// Deployment facts.
get baseUrl() { return need().site.baseUrl },
get moduleRoot() { return need().paths.moduleRoot },
get moduleId() { return need().moduleId },
}