Fixed a silently-broken DI layer: the hand-rolled IoC container in src/core/ioc.ts keyed every service on t.prototype.name (always undefined), so all registrations collided and resolution returned the wrong instance or undefined.

Symptoms

  • The entire DI layer was non-functional, but nothing had run yet so it went unnoticed at write time.
  • ioc.get(Database) returned the wrong instance or undefined.
  • Every service registered via ioc.register(X) collided under a single key.

Root Cause

Both register() and get() in src/core/ioc.ts computed the map key as t.prototype.name.

For any class, SomeClass.prototype.name is undefined — only SomeClass.name returns the class name. Consequences:

  • Class registrations collided: every ioc.register(X) stored under the single key undefined, so the last registration won and overwrote all prior ones.
  • Exact registrations never matched: registerExact("Database", client) stored under the string key "Database", but ioc.get(Database) looked up key undefined — never finding it.

Fix

Changed both register() and get() to key on t.name instead of t.prototype.name.

This also makes registerExact("Database", ...) resolve correctly, because the exported Database is a named class expression:

export const Database = class Database {} as any;

so Database.name === "Database" — the class key now matches the string key used by registerExact.

Verification

  • pnpm exec tsc --noEmit — clean.
  • UsersService, DiscordService, and PermissionService now resolve by class name.

Gotcha: services are keyed by Class.name

Resolution depends on the class name being preserved at runtime. Production minification that mangles class names will break DI resolution — every service would resolve to a mangled key and get() would miss. Keep class names stable (disable name mangling for these classes), or switch to an explicit string-key registry if you intend to minify the backend.

For Agents

When registering or resolving in this IoC: the key is Class.name. Use a named class expression (const X = class X {}) — an anonymous class expression (const X = class {}) would produce an empty .name and silently reintroduce the collision. Prefer registerExact("name", instance) only when the string matches the class name.

This fix unblocked just-completed user-provisioning work:

  • UsersService.provision(discordId, permissions) fetches the Discord profile, upserts a User (idempotent on the unique username), and self-grants permissions via PermissionService.grantMany(user.id, permissions, user.id).
  • GET /users list endpoint is wired (paginated, 50/page).

Open design questions

  • Permissions are self-granted — no provisioner is tracked (grantor == grantee).
  • The upsert keys on username, not discordId (which is not @unique). Two Discord IDs sharing a username, or a username change, would collide/misbehave.