Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/_AI_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ This file is an index for AI agents. The `_` prefix keeps it out of Docusaurus.
| [cli.md](cli.md) | `nevermore` CLI command reference: every command and flag (`init`, `install`, `login`, `test`, `deploy`, `batch`, `tools`), global options, command tree |
| [deploy.md](deploy.md) | `nevermore deploy`: login, `deploy init`, `deploy run`, config schema, flag reference, common workflows |
| [architecture/](architecture/index.md) | Architecture: workspace layout, design philosophy, ServiceBag, dependency injection |
| [architecture/patterns.md](architecture/patterns.md) | Core patterns: Maid, BaseObject, Binder, Rx, Brio, Blend, AdorneeData, TieDefinition; Brio pipeline pitfalls |
| [architecture/patterns.md](architecture/patterns.md) | Core patterns: Maid, BaseObject, Binder, Rx, Brio, Blend, AdorneeData, TieDefinition, Remoting observables; Brio pipeline pitfalls |
| [build.md](build.md) | Contributing: local setup, tools, versioning, custom Rojo |
| [testing/](testing/index.md) | Testing: Jest3, deploy config, CLI commands, credentials, CI |
| [testing/integration-testing.md](testing/integration-testing.md) | Integration testing: full-game tests, base place merging, deploy pipeline |
Expand Down
52 changes: 52 additions & 0 deletions docs/architecture/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ The three adders return different things, which matters when you want to keep us

**When to use:** Any time you create connections, spawn threads, or instantiate objects that need cleanup. Almost every class uses one.

### Register with the parent maid before attaching `Finally`

A common shape is a short-lived maid, scoped to one async operation, parked on a long-lived one:

```lua
self._maid[opMaid] = opMaid
opMaid:GiveTask(function()
self._maid[opMaid] = nil
end)

promise:Finally(function()
self._maid[opMaid] = nil -- must come after the registration above
end)
```

Written the other way round it leaks, silently and only sometimes. Our promises settle *synchronously* when the work is already done — an `:Then` on a resolved promise runs inline — so a `Finally` attached first fires immediately, removes an entry that isn't there yet, and then the registration lands with nothing left to clear it. The entry survives for the lifetime of the owner. It looks correct in review and behaves correctly whenever the operation happens to be genuinely async, which is what makes it easy to miss.

`Maid:GivePromise` already does this correctly and is worth reading as the reference — it registers before attaching `Finally`, and early-outs entirely when the promise is already settled. Reach for it when the thing you're tracking *is* the promise. Hand-roll only when it isn't (tracking a sub-maid that owns the intermediate work, say), and then copy the ordering exactly.

The invariant is worth asserting directly: bind, release, and check the owner's task count is back where it started. Repeating the cycle ten times turns a slow leak into an obvious one.

## BaseObject

A lightweight base class that gives you a `_maid` and optional `_obj` reference for free. Nearly all Nevermore classes inherit from it.
Expand Down Expand Up @@ -275,6 +296,36 @@ end

**When to use:** When you need optional or pluggable interfaces — particularly across client/server boundaries or for plugin systems where the implementer shouldn't need to know about the consumer.

## Remoting observables

[Remoting](/api/Remoting) wraps RemoteEvents and RemoteFunctions so a service can declare its network surface as members rather than hand-managed Instances. Alongside the event and method members it can carry observable streams: the server binds a factory, the client subscribes, and values flow for as long as the client stays subscribed.

```lua
-- Server
remoting:BindObservable("Health", function(player, entityId)
return observeHealth(player, entityId)
end)

-- Client
maid:GiveTask(remoting:Observe("Health", entityId):Subscribe(print))

-- Or through member syntax, which reads better at the call site
remoting.Health:BindObservable(function(player, entityId) ... end)
maid:GiveTask(remoting.Health:Observe(entityId):Subscribe(print))
```

The factory runs once per subscription, so it can vary the stream by player and by the arguments the client passed. The server tears the stream down when the client unsubscribes, when the source completes or fails, when the player leaves, or when the remoting is destroyed — the client's subscription completes in that last case rather than hanging.

Under the hood each observable member reserves one extra remote event named `<Member>__Observe`. A RemoteEvent is full duplex, so that single instance carries subscribe and unsubscribe up and emissions down.

**Two ways this differs from a local observable:**

**It cannot emit synchronously on subscribe.** Most Nevermore observables fire an initial value during `:Subscribe()`; this one can't, because the first value is at least a round trip away. Anything that assumes a synchronous first emission — a Blend binding, a `Rx.combineLatest` that mixes local and remote sources — will sit empty until the value lands. Give it a starting value with `Rx.defaultsTo` when the consumer can't tolerate that gap.

**It is cold, and each subscription costs a stream.** Two `:Subscribe()` calls on the same observable open two server-side subscriptions and produce two streams of packets. That is correct Rx semantics but a real network bill, so pipe through `Rx.share()` when several consumers want the same values.

**When to use:** When the client needs a live view of server state rather than a one-shot answer. For a single value, `PromiseInvokeServer` is cheaper and simpler.

## How the patterns fit together

These patterns compose naturally:
Expand All @@ -286,3 +337,4 @@ These patterns compose naturally:
5. **Blend + Rx** — Blend properties accept observables directly, making UI reactive.
6. **ServiceBag + Binder** — Services create and manage binders; binders receive ServiceBag for dependency injection.
7. **AdorneeData + Binder** — Binder creates a class per tagged Instance; AdorneeData reads/observes configuration attributes on that Instance.
8. **Remoting + Rx** — Remoting carries observables across the client/server boundary, so a server-side Rx pipeline can drive client UI directly.
8 changes: 8 additions & 0 deletions docs/conventions/luau.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,11 @@ These are the types you'll use most often:
- `Signal.new() :: any` — when the signal type would be too complex to annotate inline

**Prefer fixing upstream types** over casting. If a type is wrong, fix it in the source package.

## Type system limits worth knowing

Two limits show up often enough to cost real time if you rediscover them.

**Singleton types are strings and booleans only.** `"server" :: "server"` is how the enum packages pin a literal, so `OPCODE_FIRE = 3 :: 3` looks like it should work too. It doesn't — numeric singletons don't exist, and the failure is a *parse* error rather than a type error, so stylua and selene report a cascade of confusing syntax complaints starting at the enclosing call rather than the offending line. Write the number plain.

**A class stored behind a table index type loses its generic methods.** Our classes are `typeof(setmetatable(...))` types whose methods are generic (`Maid.Add` is `<T>(self, T) -> T`). Put one in a field or index type — `_subscriptions: { [string]: Maid.Maid }` — and calling a method on a value read back out fails with the memorable `Expected this to be 'Maid', but got 'Maid'`, or refuses `==` between two of them because "they do not have the same metatable". Type the stored value `any` and note why; the annotation was never buying much, since it stops checking at exactly the point you'd use it.
28 changes: 28 additions & 0 deletions docs/gotchas/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,34 @@ instrumentation that still times out tells you nothing. Two distinct causes to r
step detached (`task.spawn`) so the test body returns, and have a later test in the same suite
sample and print the detached thread's `coroutine.status`/`debug.traceback`.

### A dual-realm spec hangs on a tie/binder that resolves fine on the other realm

Symptom: one realm's `TieDefinition:Promise(...)` never resolves (jest reports a 5s timeout) while the
identical call on the other realm passes, and the only warning is a downstream "failed to find X for
player". The cause is usually **not** the package under test — it's a stale `node_modules`.

`node_modules` is untracked, so `git stash -u` and a clean checkout both leave it stale; the failure
looks pre-existing and reproducible when it is purely local. When a workspace dependency was added to
some transitive package's `package.json` since your last install, the symlink is missing and the
loader throws `[Loader] - "SomeModule" is not available` from inside that package's `Init`. ServiceBag
does not abort the boot on a failed service `Init`, so the bag comes up **half-registered**: services
the aborted `Init` had not reached yet are never added. Anything that later calls
`serviceBag:GetService(...)` for one of them (a binder constructor, for instance) throws
`Cannot initialize service "..." after start`, the bind silently fails, and the tie for that realm
never gets an implementation.

Fix: run `pnpm install` from the repo root. To confirm before you start editing source, run the boot
with `--logs` and read the *first* error in the log, not the last — the loader failure appears many
lines above the symptom.

### `--script-text` only runs the first line on Windows

Multi-line strings passed to `nevermore test --cloud --script-text` are truncated at the first newline
when the CLI is invoked through PowerShell or Git Bash, and the run reports `Tests passed!` having
executed one line. Join the script into a single line before passing it
(`(Get-Content diag.lua) -join ' '`), and drop any `--` comments and the `--!strict`/`--!nonstrict`
header first — the header is otherwise parsed as a CLI flag.

### Test fails with `loader is not a valid member of ModuleScript "..."`

The standard file header `require(script.Parent.loader).load(script)` deliberately does not
Expand Down
22 changes: 22 additions & 0 deletions docs/testing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,28 @@ Known limits — these are gaps in `player-mock`'s contract to fix there, not pa
- Per-player data with no lookup domain yet: add a domain to `LOOKUPS` rather than stubbing per call
site.

### Both realms share a thread, so replication no longer hides creation races

In a live game a server-created remote reaches the client a network step after the server finished
wiring it up. Dummy mode collapses that: the server's `Instance.new` and the client's reaction to it
run on one thread, so a client watching for a remote to appear can act on it *while the server is
still mid-setup* — after the instance was parented, before the handler was attached. A message sent
in that window goes nowhere and is never retried, which reads as a stream that silently never starts.

The fix is to make the instance discoverable only once it is fully wired: create it detached, attach
the handler, then parent it. `Remoting._getOrCreateRemoteEvent` and `_getOrCreateRemoteFunction` take
an `attachHandler` callback for exactly this, so `Connect` and `Bind` are live the instant anything
can see them. Parenting last is worth doing on reflex whenever another realm — or another observer —
keys off an instance appearing.

Resist the urge to paper over it with `task.defer` on the reacting side. It passes the test for the
same reason production passes without it, which is precisely why it hides the defect rather than
fixing it: the window is still there, just harder to hit.

The general shape: when a spec covers one realm reacting to another realm creating an Instance, ask
whether the reaction can observe a half-built state. If it can, the bug is real — production just has
a network hop papering over it — and the ordering fix belongs in the package, not the spec.

### jest.config.lua

Every testable package needs a `jest.config.lua` in its `src/` directory. This tells the test runner to discover `.spec` files:
Expand Down
1 change: 1 addition & 0 deletions src/remoting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ npm install @quenty/remoting --save
## Features

* Promise implementation available
* Observable streams across the client/server boundary via `BindObservable`/`Observe`
* Always safe-to-use remotes if acquired
Loading
Loading