quad/quad-base/src/Observer.luau
qwreey-agent-selene dca789ecca
feat(m2): 단위 3 — Observer(레지스트리 소유·네 진입점) / Effect(rawRerun·_rerunRequired 홀드·cleanup·네 진입점) + onDestroying 스텁·mock + spec 2개
- Observer.luau: Observer.Init(module) — 인스턴스별 임플 + Subscribed/WeakSubscribed(H-99),
  _receive(canExecute 게이팅, 홀드 H-159)/_catchUp(유일한 재생 자리, from=nil H-164),
  네 진입점 인라인(H-149, Weak 프리미티브 H-111, 관대 H-133, 엄격), 생성자 순서(fn → 플래그 → _subs).
- Effect.luau: Effect.Init(module) — deps 검증(H-70), dep 종류별 클로저(H-107), fire(from==nil
  가드, _epochs Update만 H-151, _blocker 없음 H-150), rawRerun(force)/Rerun(_pending 지연, 홀드,
  사망 계약), _bindDestroying/_unbindDestroying/_consumeCleanup(_cleanupRunning H-160),
  네 진입점 자기 본문(H-144) + isRunning 가드(H-147) + resubscribeTail.
- State:Observer → ObserverImpl.new. LifetimeHandle: onDestroying 에러 스텁. mock: onDestroying.
- quad-types: EpochSet/Observer/ObserverFn/EffectHandle/EffectFn, State.Observer, Quad.Effect·onDestroying.
- spec.observer(8절)·spec.effect(9절) ALL PASS, analyze 0. ROADMAP 단위 3 체크박스(mock 전파 루프
  테스트 포함), round11 §5 단위 3 확인 목록 + H-178(사적 필드 _ 접두), 세션 원문·요약.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-29 01:52:21 +09:00

161 lines
6.3 KiB
Text

--[[
Observer — the leaf subscriber a `State` fires, and the owner of the two
global subscription registries (`Subscribed` strong / `WeakSubscribed`
weak-key) that `Effect.luau` shares (`H-99`).
Assembly (`H-174`): `Observer.Init(module)` builds one implementation (and
one registry pair) per quad instance; the lifetime gates are read as
`module.canExecute(self)` / `module.canBound(self)` AT CALL TIME — never
captured at init, the backend overwrites those fields after `New()`.
`State.luau` reaches the constructor through `implFor(module)`.
Sources, transcribed as-is:
- `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드":
`Observer:_receive(from)` (`canExecute` → `fn(self._state, self, from)`,
else hold `_rerunRequired = true`, `H-159`), `Observer:_catchUp()`
(the ONLY place a held change is replayed — with `from = nil`),
`State:Observer(fn)` constructor ORDER (fn once → flag down → join
`_subs`; `H-159`/`H-164`), `observer._state` strong ref (`_hold`
equivalent, `H-110`); "`state:Observer(fn)`" section: fires once on
registration, `fn(targetState, self, emitFrom?)` — `nil` means
"no source: install or catch-up, read the value" (`H-164`); no-arg
`state:Observer()` = always-observe utility.
- `.claude/base/lifecycle-pattern.md` "(2) 전역 경로": the four entry
points — `WeakSubscribe` is the primitive (sets `.Subscribed = true`
too, `H-111`), `Subscribe` is INLINED (no delegation, `H-149`),
`WeakUnsubscribe` is lenient (`H-133`) except when a strong keep
exists, `Unsubscribe` is strict and clears both tables. Each entry
point runs its own `canBound` gate exactly once and throws with
`level 2` from its own body (`H-104`).
An Observer is NOT an `Epoch` and carries no epoch bookkeeping — it only
forwards the source it received. `EffectHandle` does NOT reuse these
bodies (heterogeneous types; `conventions.md` 설계 원칙).
]]
local Brand = require("./Brand")
local ObserverBrand = Brand.ObserverBrand
local WEAK_KEY_MT = { __mode = "k" }
local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any }
local function createImpl(module: any)
local Impl = {}
Impl.__index = Impl
-- Registries — owned here, shared with `Effect.luau` (`H-99`). Per quad
-- instance because the gates that read `.Subscribed` are per instance.
local Subscribed = {} :: { [any]: true } -- strong: keeps the handle alive
local WeakSubscribed = setmetatable({}, WEAK_KEY_MT) :: { [any]: true } -- membership only
Impl._Subscribed = Subscribed
Impl._WeakSubscribed = WeakSubscribed
-- ── EmitReceive ──────────────────────────────────────────────────
function Impl._receive(self: any, from: any)
if module.canExecute(self) then -- read at fire time (`H-174`)
self.fn(self._state, self, from) -- (receiver State, Observer itself, source)
else
self._rerunRequired = true -- `H-159`: change before binding is held — replayed once when bound
end
end
-- Catch-up: bind/subscribe replays a held change once, with no source.
-- The only caller set: `bindLifetime`, `Subscribe`, `WeakSubscribe`.
function Impl._catchUp(self: any)
if self._rerunRequired then
self._rerunRequired = false
self.fn(self._state, self, nil)
end
end
-- ── four entry points (`lifecycle-pattern.md` (2)) ───────────────
function Impl.WeakSubscribe(self: any): any
if not module.canBound(self) then -- same gate as bindLifetime (shared isBoundAlive)
error(
if self.Subscribed then "Observer: already subscribed" else "Observer: already bound to an Instance",
2
)
end
self.Subscribed = true -- `H-111`: the weak path raises the flag too
WeakSubscribed[self] = true
self:_catchUp() -- `H-159`: held change once, symmetric with bind
return self
end
function Impl.WeakUnsubscribe(self: any): any
-- A strong keep left behind would make a half-released, never-GC'd handle: fail fast.
if Subscribed[self] ~= nil then
error("Observer: subscribed strongly; use :Unsubscribe()", 2)
end
-- `H-133`: that is the whole guard — never subscribed / already weakly released pass silently.
WeakSubscribed[self] = nil
self.Subscribed = false
return self
end
function Impl.Subscribe(self: any): any
-- `H-149`: NOT delegated to WeakSubscribe — level 2 must point at the user's call,
-- and colon delegation would resolve to a subtype's override.
if not module.canBound(self) then
error(
if self.Subscribed then "Observer: already subscribed" else "Observer: already bound to an Instance",
2
)
end
self.Subscribed = true
WeakSubscribed[self] = true
Subscribed[self] = true -- just one more layer: the strong keep
self:_catchUp()
return self
end
function Impl.Unsubscribe(self: any): any
-- Symmetric with WeakUnsubscribe's guard: release through the path you subscribed by.
if Subscribed[self] == nil then
error("Observer: not subscribed strongly; use :WeakUnsubscribe()", 2)
end
Subscribed[self] = nil
WeakSubscribed[self] = nil -- both tables, directly (`H-149`: no delegation)
self.Subscribed = false
return self
end
-- ── constructor — called by `State:Observer(fn)`; ORDER IS THE CONTRACT ──
local function alwaysObserve(targetState: any)
targetState:Get() -- no-arg `state:Observer()` = "always observe" utility
end
function Impl.new(state: any, fn: any): any
local o: any = setmetatable({
fn = fn or alwaysObserve,
_state = state, -- strong: the handle holds its upstream (`_hold` equivalent, `H-110`)
_rerunRequired = true,
}, Impl)
ObserverBrand:register(o)
o.fn(state, o, nil) -- (1) fire once at registration — no source (`nil`)
o._rerunRequired = false -- (2) the install fire lowers the flag
state._subs[o] = true -- (3) only THEN join the subscriber set — reversed, (1) Setting its own
return o -- State would land in this Observer's `_receive` and raise the flag
end
return Impl
end
local function Init(module: any)
implByModule[module] = createImpl(module)
end
local function implFor(module: any)
local impl = implByModule[module]
if impl == nil then
error("Observer: Observer.Init(module) has not run for this quad instance", 1) -- invariant; callers pull it in first
end
return impl
end
return {
Init = Init,
implFor = implFor,
}