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>
This commit is contained in:
qwreey-agent-selene 2026-08-29 01:52:21 +09:00
parent 67fb61e988
commit dca789ecca
No known key found for this signature in database
13 changed files with 1055 additions and 10 deletions

View file

@ -26,6 +26,7 @@
| `H-174` | **②** | 1→2 | 🔴 | 생명주기 4종은 **`New()` 인스턴스마다 다른 필드**(이 단위가 그렇게 만들었고 `spec.lifetime` 8이 고정)인데, 단위 2·3의 `base/` 의사코드(`Observer:_receive`의 `canExecute(self)`, `Subscribe` 넷의 `canBound(self)`, `EffectHandle.rawRerun`)는 **자유 함수**로 부른다 — `Observer.luau`/`Source.luau`가 자기 인스턴스의 필드에 어떻게 닿는지 어느 문서도 안 정했다. 결정 없이는 단위 2의 `_receive`를 쓸 수 없다 | ✅ (a) 사용자 확정 — 팩토리형, `module.canExecute(self)`를 발화 시점에 늦게 읽음(`lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체) |
| `H-176` | ① | 2 | 🟡 | `:Compute`의 trailing deps를 타입팩 `D...`로 좁히는 선언은 strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힌다(스파이크 15가 "미검증"으로 남긴 자리) | ✅ `...any` + 콜백 주석으로 확정, `source-state-plan.md` 실측 기록 |
| `H-177` | ① | 2 | 🟢 | `InitSource`/`InitStore`가 `State.implFor`만 불러 `New()``RunInit` 순서에 의존했다 — `module-lifecycle-plan.md`는 "각 `InitXxx``require`처럼 멱등하게 자기 의존성을 당겨온다"로 확정 | ✅ 각 Init이 `module:RunInit(dep)`를 직접 호출(감사 3라운드) |
| `H-178` | ① | 3 | 🟢 | 코드의 사적 필드는 `_` 접두(`_valueEpochMap`·`_emitEpochMap`·`_subs`·`_hold`…)인데 `base/` 의사코드는 `valueEpochMap`처럼 접두 없이 쓴다 — 이름은 1:1이고 밑줄만 다르다 | ✅ 기록만(문서 무변경 — 코드 관례, `H-174` 조립 세부와 같은 급) |
| `H-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 |
## 상세
@ -166,6 +167,17 @@
- **처리**: `Source.Init``module:RunInit(State.Init)`, `Store.Init``module:RunInit(InitSource)`
직접 호출. `init.luau` 순서 주석을 "무관"으로.
### 단위 3 — `Observer``Effect` (2026-08-29)
### `H-178` 🟢 — 사적 필드의 `_` 접두 (①)
- **어디서**: `effect-plan.md` 생성자 의사코드의 `d.valueEpochMap` / `state-epoch-plan.md` §4의
`self.valueEpochMap` vs 코드 `_valueEpochMap`(단위 2부터).
- **무엇이**: 단위 2가 State의 사적 필드를 전부 `_` 접두로 옮겼고(`_subs`/`_hold`/`_cache`/
카운터 둘/맵 둘), 단위 3 `Effect``d._valueEpochMap`로 읽는다. 문서는 접두 없음. 뜻·개수는
1:1이라 문서를 고칠 이유가 없고(의사코드는 문서 안에서 자기완결), 코드 쪽 관례로 기록만.
- **처리**: 없음 — 다음 단위(`GateNode`가 `valueEpochMap`을 컴포지션)도 같은 접두로.
### `H-175` 🟢 — 클로저 캐시 규칙의 범위 (①)
- **어디서**: 이 파일 §5 "툴링 사실 둘"의 둘째 항, `spec.ref.luau` 7번 주석.
@ -241,6 +253,27 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
추론이 안 된다** — `typing-limits.md` §1②가 이미 캐비엇으로 적어둔 자리(`:Apply`의 factory는
주석 필요). 테스트는 주석으로.
**단위 3 (메인 세션, 2026-08-29)**:
- `Observer` — 생성자 순서(fn 1회 → 플래그 내림 → `_subs`), `_receive``canExecute` 게이팅과
홀드, `_catchUp`이 유일한 재생 자리(출처 `nil`), 네 진입점(Weak 프리미티브·인라인 게이트·
관대/엄격·양쪽 테이블 해제), 강/약 GC, 파동 중 구독자 스냅샷 — `spec.observer.luau` 8절.
- `Effect` — deps 검증 셋(`H-70`), dep 종류별 클로저 둘(`H-107`), `fire``from == nil` 가드,
`_epochs` 갱신은 `fire`뿐(`H-151`), 다이아몬드 1회, cleanup 세 자리(루프 머리/`Unsubscribe`/
`Destroying`), cleanup 없는 `fn`의 재바인드 무재실행(`H-58` 회귀), 재진입 지연(`_pending`),
네 진입점의 `isRunning` 가드가 `fn`·cleanup 안 전부 막음(`H-147`), 에러 시 사망 계약, 강한
주인 GC — `spec.effect.luau` 9절.
- **조립 세부**: `Observer.luau`가 인스턴스별 레지스트리 둘(`_Subscribed`/`_WeakSubscribed`)을
임플에 매달고 `Effect.luau``implFor`로 받는다(`H-99`의 "모듈 내부 export, 이름은 구현 시").
`State.Init``Observer.Init`을 당겨 `state:Observer``ObserverImpl.new`로 위임.
- **`onDestroying` 스텁의 자리**: `LifetimeHandle.luau``Init`이 생명주기 4종과 같이 에러
스텁을 설치(주입 op 목록의 소스는 `architecture.md` EngineOps 줄 — 스텁 파일 배치는 코드
배치). mock은 `installLifetime``inst.Destroying:Connect(fn)`으로 채운다.
- 색인이 짚은 문서 긴장(`ss:1197` "Effect의 내부 Observer가 설치 발화를 `from == nil`
거른다" vs `H-164`)은 코드에서 모순 없음 — Observer 계약은 `nil` = 출처 없음이고, Effect의
`fire`가 그걸 `Update` 못 하는 값으로 **자기 사정**으로 거를 뿐이다.
- 테스트 작성 함정: `table.insert(t, nil)`은 길이를 안 늘린다 — `emitFrom == nil` 발화를 셀 땐
래퍼로 기록할 것(한 번 오진했다).
**툴링 사실 둘**(설계 아님, 다음 단위가 알아야 함):
- `require("@self/X")`**`init.luau`에서만** 통한다 — 일반 파일에서 `@self`는 그 파일
자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`.

View file

@ -2032,3 +2032,6 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
놓음, 권고안 기각) / `H-168` `Ref<<T?>>()` / `H-170` 즉시 실패만 re-raise. **이어서 단위 2
구현** — `EpochMap`/`State`(`Init(module)`+`implFor`)/`Source`/`Store` + `quad-types`
최종형 타입(`ty11`) + spec 4개, ALL PASS. `H-176` 타입팩 deps 선언 기각(`...any`).
**2026-08-29 새벽(컨테이너 이사 뒤)**: 단위 2 감사 4·5라운드 반영(`H-177` 포함), **단위 3
구현** — `Observer`(레지스트리 소유, 네 진입점)/`Effect`(`rawRerun`·홀드·cleanup 세 자리·
네 진입점 자기 본문)/`onDestroying` 스텁·mock, spec 17절 ALL PASS. `H-178`(`_` 접두) 기록.

View file

@ -81,7 +81,19 @@
- 아직 안 한 것: 감사 4라운드(0건 확인) → `/code-review high` → fable 탐사자(규약 §5) →
"`round11.md` §4를 보라". §4엔 지금 새 문항 없음(단위 2 발견은 전부 ①: `H-176`, `H-177`).
## 다음 (재개 지점)
## 2026-08-29 새벽 — 컨테이너 이사 뒤 재개 (사용자: *"내가 자는동안 많은 작업을 수행해도 좋아"*)
`./scripts/test.sh` ALL PASS·doc-check ERROR 0인 상태. 감사 4라운드부터 이어서
(범위 `4169d7b`..`8aa13ed`, 각도: diff 정합성 수렴 확인), 그다음 `/code-review high`, 탐사자.
- 이사 검증: `upstream/main`에서 26커밋 fast-forward, `pesde install`, ALL PASS. Studio MCP
연결(`Place1`, Edit) — M5 전까지 안 씀.
- 단위 2 감사 4·5라운드 반영(`442d800`, `67fb61e`: `implFor` 호출자 정정, `EpochMap`도 공유 잎,
`_hold` 본문 닫힘, `H-174` 잎 목록은 파일 헤더가 소스, `InitState``State.Init` 표기).
- **단위 3 구현**`Observer.luau`(인스턴스별 임플 + 레지스트리 둘, 네 진입점 인라인)/
`Effect.luau`(`rawRerun(force)`·`_rerunRequired` 홀드·`_cleanupRunning`·네 진입점 자기 본문)/
`State:Observer` 위임/`LifetimeHandle`에 `onDestroying` 스텁/mock `onDestroying`/`quad-types`
`Observer`·`EffectHandle`·`Quad.Effect`. spec.observer 8절·spec.effect 9절 ALL PASS, analyze 0.
발견 `H-178`(사적 필드 `_` 접두, 기록만).
## 다음
단위 2·3 합쳐 끝 절차: 감사 루프(6라운드 진행 중) → `/code-review high` → 탐사자 → §4.
그다음 단위 4(`GateNode` → `Blocker` + 탑레벨 마무리).

View file

@ -481,11 +481,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함
- [ ] **`Observer.luau`** — `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()`
- [x] **[2026-08-29 완료 — 단위 3]** **`Observer.luau`** — `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()`
전역 레지스트리의 소유 모듈**. `EpochMap.luau`와 같은 이유로
`State.luau`에 묻지 않는다(`Effect`/`GateNode`/leaf 핸들러가 전부 이
레지스트리를 본다) — `base/architecture.md` 소스 트리, 7라운드 `H-99`
- [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
- [x] **[2026-08-29 완료 — 단위 3]** `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver`
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` +
**`:WeakSubscribe()`/`:WeakUnsubscribe()`**(Weak 쪽이 프리미티브,
@ -505,7 +505,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
- [ ] `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~
- [x] **[2026-08-29 완료 — 단위 3]** `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~
(**[2026-08-28 10라운드 `H-150`]** 선행 요구 **해소** — 생성자의 사적
`Blocker``canExecute`(지금은 `rawRerun` 진입, `H-159`)가 이미 같은 억제를 해서 한 번도
판정에 닿지 않는 죽은 부품이라 제거됐다. `Blocker.luau`는 이제 `GateNode`/
@ -536,7 +536,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
- [ ] **⭐ [2026-08-24 신설, 6라운드 / 2026-08-25 7라운드로 필드 재편]
- [x] **[2026-08-29 완료 — 단위 3]** **⭐ [2026-08-24 신설, 6라운드 / 2026-08-25 7라운드로 필드 재편]
`Effect` 구현 시 같이 만들 것** —
**`handle._deps`**(`{[Ref|State] = fn|Observer}`, **강참조** — 옛
`_observers`/`_refDeps`/`_refCallbacks` 셋이 여기로 통합됐다) ·
@ -576,7 +576,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약
- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(value)` 게이트로
- [x] **[2026-08-29 완료 — 단위 3]** Observer/Effect 이중 바인딩 금지 — `canBound(value)` 게이트로
`:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도
내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/source-state-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째
세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime
@ -634,7 +634,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
- [ ] **[2026-08-28 부분 — 단위 1·2분(`Relate`/`Void`/`Ref`/`is*`/생명주기 4종/`Source`/`Store` + `State`·`Source`·`Store` 타입)은 `quad-types` `Quad`에 추가됨, `Effect`/`Blocker`는 단위에서]** **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
- [ ] **[2026-08-28 부분 — 단위 1·2·3분(`Relate`/`Void`/`Ref`/`is*`/생명주기 4종+`onDestroying`/`Source`/`Store`/`Effect` + `State`·`Source`·`Store`·`Observer`·`EffectHandle` 타입)은 `quad-types` `Quad`에 추가됨, `Blocker`는 단위 4에서]** **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
`quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
`Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형,
2026-08-27 `H-128`) /
@ -688,7 +688,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은
에러 스텁이다). 커밋된 `quad-base/test/mock.luau`에 signal/Connection이
이미 있으므로 그 `Destroying`을 그대로 쓰면 된다
- [ ] mock 대상 테스트 — **전파 루프를 실제로 돌릴 것**(위 항목이 선행).
- [x] **[2026-08-29 완료 — 단위 3: `spec.observer.luau` 2·`spec.effect.luau` 2가 mock Instance에 묶은 채 전파 루프를 실제로 돌린다(홀드 → 바인드 캐치업 → 발화 → 파괴)]** mock 대상 테스트 — **전파 루프를 실제로 돌릴 것**(위 항목이 선행).
M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가면
7라운드가 찾은 종류의 결함을 그대로 낳는다

279
quad-base/src/Effect.luau Normal file
View file

@ -0,0 +1,279 @@
--[[
Effect — a leaf consumer with several dependencies (`State`/`Source`/`Ref`)
and a cleanup contract. `Effect(fn, ...deps)`; `fn(self) -> ...cleanup`.
`.claude/base/effect-plan.md`, transcribed as-is:
- "확정 구조": the strong owner is ALWAYS the Effect (`_deps` map);
registrations at the deps are weak (`ref:WeakCallback`, internal
`observer:WeakSubscribe()`); the single fire gate is `canExecute`.
- "의사코드 — 생성자": deps validated once (`select("#")`, nil → error,
non State/Source/Ref → error, duplicates ignored — `H-70`); one
closure PER DEP KIND (`onRefFire(_, ref)` / `onStateFire(_, _, from)`,
`H-107`); `fire`: `from == nil` (the internal Observer's install fire)
is dropped, else `_epochs:Update(from)` → `Rerun` (`H-151`: the only
place `_epochs` is written; no `_blocker`, `H-150`); `_epochs` seeded
`Sync`/`TrackFrom` by `isEpoch`; install = `_rerunRequired = true` +
`rawRerun(self, true)`.
- `_bindDestroying(inst)` (guard `isRunning`; `_unbindDestroying` first;
injected `onDestroying(inst, fn)`; replay once if `_rerunRequired`),
`_unbindDestroying()` (idempotent), `_consumeCleanup()` (read → clear →
`_rerunRequired = true` → run under `_cleanupRunning`, `H-160`).
- `rawRerun(self, force)` + public `Rerun()`: re-entrancy is deferred
(`_pending`), an unexecutable state HOLDS the request (`H-159`), the
loop has no death judgement (`H-147`); errors leave `_running` /
`_cleanupRunning` set — that Effect is dead, by contract.
- "`EffectHandle:Subscribe()`": four entry points of its OWN (not
Observer's bodies, `H-144`), first line `isRunning` guard (`H-147`),
`resubscribeTail` replays a held change after registration,
`Unsubscribe` consumes the cleanup.
Assembly (`H-174`): `Effect.Init(module)` — gates (`module.canExecute` /
`module.canBound`) and the injected `module.onDestroying` are read at call
time; the registries come from `Observer.luau` (`H-99`), pulled in with
`module:RunInit(Observer.Init)` (idempotent).
]]
local Brand = require("./Brand")
local EpochMap = require("./EpochMap")
local Observer = require("./Observer")
local EffectBrand = Brand.EffectBrand
local isState = Brand.isState
local isSource = Brand.isSource
local isRef = Brand.isRef
local isEpoch = Brand.isEpoch
local WEAK_KEY_MT = { __mode = "k" }
local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any }
local function createImpl(module: any)
module:RunInit(Observer.Init) -- registries live there (`H-99`)
local ObserverImpl = Observer.implFor(module)
local Subscribed = ObserverImpl._Subscribed
local WeakSubscribed = ObserverImpl._WeakSubscribed
local Impl = {}
Impl.__index = Impl
-- ── local helpers, declared before their callers ─────────────────
local function isRunning(self: any): boolean -- predicate only; `error` stays in each body (level 2)
return self._running == true or self._cleanupRunning == true
end
-- The body. `force` means exactly one thing: "may be called ignoring
-- canExecute" — the install from the constructor, not yet bound.
local function rawRerun(self: any, force: boolean)
if self._running then
self._pending = true -- re-entered while running → deferred re-run
return
end
if self._cleanupRunning or (not force and not module.canExecute(self)) then
self._rerunRequired = true -- `H-159`/`H-160`: unexecutable (cleanup running / unbound / dead)
return -- → HOLD, replayed once on the next bind/subscribe
end
self._running = true
repeat
self._pending = false
self:_consumeCleanup() -- sets `_rerunRequired = true`
self._rerunRequired = false -- the ONLY place the flag goes down: fn really runs
self._cleanup = self.fn(self)
until not self._pending
self._running = false
end
-- After registration: replay a held change once (`H-144`; `Refresh` gone, `H-151`).
local function resubscribeTail(self: any)
if self._rerunRequired then
self:Rerun() -- registered now, so the public gate passes
end
end
-- ── hooks called by `bindLifetime` / `unbindLifetime` ─────────────
function Impl._bindDestroying(self: any, inst: any)
if isRunning(self) then
error("Effect: cannot bind an Effect from inside its own fn or cleanup", 2) -- `H-147` (A)
end
self:_unbindDestroying() -- rebind (portal remount): drop the old connection first — idempotent
-- (1) leaf dies → cleanup exactly once. The one hook point (`LP-2`).
self._destroyConn = module.onDestroying(inst, function() -- injected op, read at call time
self:_unbindDestroying()
self:_consumeCleanup()
end)
-- (2) catch-up: a change held while unexecutable is replayed once. gcconn is
-- connected by now, so the public Rerun's gate passes.
if self._rerunRequired then
self:Rerun()
end
end
function Impl._unbindDestroying(self: any)
if self._destroyConn then
self._destroyConn:Disconnect()
self._destroyConn = nil
end
-- Ref callbacks and the internal Observers stay (weakly registered; the gate
-- silences them). `_cleanup` is NOT run here.
end
-- read → clear → run. `_cleanup`'s presence is not "installed" — the flag is.
function Impl._consumeCleanup(self: any)
local c: any = self._cleanup
self._cleanup = nil
self._rerunRequired = true -- consumed = must install again at the next chance
if c ~= nil then
self._cleanupRunning = true -- `H-160`: cleanup runs outside `_running` in two of its three sites
(c :: () -> ())()
self._cleanupRunning = false
end
end
function Impl.Rerun(self: any): any -- public, no args — always gated
rawRerun(self, false)
return self
end
-- ── four entry points — EffectHandle's OWN bodies (`H-144` (b)) ────
function Impl.WeakSubscribe(self: any): any
if isRunning(self) then
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
end
if not module.canBound(self) then
error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2)
end
self.Subscribed = true
WeakSubscribed[self] = true
resubscribeTail(self)
return self
end
function Impl.Subscribe(self: any): any
if isRunning(self) then
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
end
if not module.canBound(self) then
error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2)
end
self.Subscribed = true
WeakSubscribed[self] = true
Subscribed[self] = true -- the strong keep is up BEFORE the tail runs
resubscribeTail(self)
return self
end
function Impl.WeakUnsubscribe(self: any): any -- lenient (`H-133`) — does not touch the cleanup
if isRunning(self) then
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
end
if Subscribed[self] ~= nil then
error("Effect: subscribed strongly; use :Unsubscribe()", 2)
end
WeakSubscribed[self] = nil
self.Subscribed = false
return self
end
function Impl.Unsubscribe(self: any): any
if isRunning(self) then
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
end
if Subscribed[self] == nil then -- gate FIRST: never strongly subscribed → error, cleanup untouched
error("Effect: not subscribed strongly; use :WeakUnsubscribe()", 2)
end
Subscribed[self] = nil
WeakSubscribed[self] = nil
self.Subscribed = false -- blocks future re-runs
self:_consumeCleanup() -- only when the gate passed: the last cleanup exactly once
return self
end
-- ── constructor ──────────────────────────────────────────────────
local function Effect(fn: any, ...: any): any
if type(fn) ~= "function" then
error("Effect: fn must be a function", 2)
end
local self = setmetatable({
fn = fn,
_deps = {}, -- strong owner of every registration
_epochs = EpochMap(),
_cleanup = nil,
_rerunRequired = false,
_running = false,
_pending = false,
_cleanupRunning = false,
_destroyConn = nil,
Subscribed = false,
}, Impl)
EffectBrand:register(self)
-- (0) deps validation — once, in the constructor. `select("#")` so a nil hole
-- does not silently truncate the list.
local seen = {}
for i = 1, select("#", ...) do
local d = (select(i, ...))
if d == nil then
error(`Effect: dep #{i} is nil`, 2)
end
if not (isState(d) or isSource(d) or isRef(d)) then
error(`Effect: dep #{i} is not a State/Source/Ref`, 2)
end
seen[d] = true -- duplicates are silently ignored (dedup lives in `_deps`/`_epochs`)
end
-- (1) register deps — HERE, ONCE. One closure per dep kind (`H-107`).
local function fire(from: any)
if from == nil then
return -- the internal Observer's install fire (no source) cannot `Update(nil)`
end
if self._epochs:Update(from) then -- `H-151`: the only place `_epochs` is written
self:Rerun() -- unexecutable → rawRerun holds it (`H-159`)
end
end
local function onRefFire(_value: any, ref: any)
fire(ref) -- Ref: the 2nd arg is the source
end
local function onStateFire(_targetState: any, _observer: any, from: any)
fire(from) -- Observer: the 3rd arg is the source
end
for d in pairs(seen) do
if isRef(d) then
self._deps[d] = onRefFire -- strong owner = Effect
d:WeakCallback(onRefFire) -- the Ref side is weak
else
local o = d:Observer(onStateFire)
self._deps[d] = o -- strong owner = Effect
o:WeakSubscribe() -- the global registry side is weak
end
-- seeding by "is it an Epoch" — Source/Ref are, State is not (§4 rule)
if isEpoch(d) then
self._epochs:Sync(d)
else
self._epochs:TrackFrom(d._valueEpochMap)
end
end
-- (2) install — once, immediately; cannot be deferred to bind.
self._rerunRequired = true
rawRerun(self, true)
return self
end
module.Effect = Effect
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("Effect: Effect.Init(module) has not run for this quad instance", 1)
end
return impl
end
return {
Init = Init,
implFor = implFor,
}

View file

@ -22,6 +22,8 @@
They are flat top-level functions, not `LifetimeHandle.bind(...)` —
first-class primitives handler authors call directly, like `isState`.
The `onDestroying(inst, fn)` engine op stub is installed here too (M2 unit
3) — it belongs to the same backend injection and `Effect` needs it.
]]
local function notInstalled(name: string): (...any) -> ...any
@ -39,6 +41,11 @@ local function Init(module: any)
module.unbindLifetime = notInstalled("unbindLifetime")
module.canBound = notInstalled("canBound")
module.canExecute = notInstalled("canExecute")
-- `onDestroying(inst, fn): Connection` — the injected engine op `Effect._bindDestroying`
-- calls (`architecture.md` EngineOps line: a hook, not a manipulation, so no
-- composition fallback — missing means a clear error). The same backend that
-- fills the four above fills this one; until then, the same loud stub.
module.onDestroying = notInstalled("onDestroying")
end
return Init

161
quad-base/src/Observer.luau Normal file
View file

@ -0,0 +1,161 @@
--[[
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,
}

View file

@ -39,6 +39,7 @@
local Brand = require("./Brand")
local EpochMap = require("./EpochMap")
local Observer = require("./Observer")
local QuadTypes = require("../roblox_packages/quad_types")
export type StateData<T> = QuadTypes.StateData<T>
@ -56,6 +57,9 @@ local WEAK_KEY_MT = { __mode = "k" }
local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any }
local function createImpl(module: any)
module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`)
local ObserverImpl = Observer.implFor(module)
local Impl = {}
Impl.__index = Impl
Impl._module = module -- read lazily by later units, never captured
@ -176,6 +180,15 @@ local function createImpl(module: any)
return newNode(deps, fn) -- one node: edges only, no combining node
end
-- `state:Observer(fn?)` — leaf subscriber, fires once at registration
-- (`source-state-plan.md` "`state:Observer(fn)`"). Body lives in `Observer.luau`.
function Impl.Observer(self: any, fn: any?): any
if fn ~= nil and type(fn) ~= "function" then
error("State: Observer fn must be a function (or nil for the always-observe utility)", 2)
end
return ObserverImpl.new(self, fn)
end
function Impl.Apply(self: any, factory: any): any
if type(factory) == "function" then
return (factory :: any)(self)

View file

@ -23,6 +23,8 @@ local InitLifetimeHandle = require("@self/LifetimeHandle")
local State = require("@self/State")
local InitSource = require("@self/Source")
local InitStore = require("@self/Store")
local Observer = require("@self/Observer")
local Effect = require("@self/Effect")
type Quad = QuadTypes.Quad
@ -75,6 +77,8 @@ local function New(): Quad
module:RunInit(State.Init)
module:RunInit(InitSource)
module:RunInit(InitStore)
module:RunInit(Observer.Init) -- 단위 3 — 레지스트리 둘의 소유 모듈(`H-99`)
module:RunInit(Effect.Init)
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
return module

View file

@ -375,10 +375,20 @@ local function installLifetime(quad: any)
BindData:SetWeak(value, "gcconn", nil)
end
-- 주입 op `onDestroying(inst, fn): Connection` — quad-roblox는 `inst.Destroying:Connect(fn)`
-- 한 줄(`architecture.md` EngineOps). mock은 그 시그널을 그대로 쓴다.
local function onDestroying(inst: any, fn: () -> ()): any
if not isMockInstance(inst) then
error("onDestroying: inst is not a mock instance", 2)
end
return inst.Destroying:Connect(fn)
end
quad.bindLifetime = bindLifetime
quad.unbindLifetime = unbindLifetime
quad.canBound = canBound
quad.canExecute = canExecute
quad.onDestroying = onDestroying
end
return {

View file

@ -0,0 +1,281 @@
--[[
Effect 계약 — `.claude/base/effect-plan.md` "확정 구조" / "의사코드 — 생성자" / `_bindDestroying` /
`_unbindDestroying` / `_consumeCleanup` / `rawRerun`+`Rerun` / "`EffectHandle:Subscribe()`" /
"`Effect(fn, ...deps)`". `H-70`/`H-107`/`H-144`/`H-147`/`H-150`/`H-151`/`H-159`/`H-160`.
]]
local Quad = require("../src")
local mock = require("./mock")
local QuadTypes = require("../roblox_packages/quad_types")
type EffectHandle = QuadTypes.EffectHandle
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
local quad = Quad.New()
mock.installLifetime(quad)
local Source, Ref, Effect = quad.Source, quad.Ref, quad.Effect
local Instance = mock.Instance
print("=== 1. 생성 — deps 검증(H-70), 설치 즉시 1회(force), _deps 강한 주인, 브랜드 ===")
do
local s = Source(1)
local r = Ref(1)
local runs = 0
local e = Effect(function(self)
runs += 1
assert(quad.isEffect(self), "fn receives the handle")
end, s, r, s) -- duplicate dep is ignored
assert(runs == 1, "installed once on creation, before any binding (force)")
assert(quad.isEffect(e) and not quad.isObserver(e), "brand")
local depCount = 0
for _ in pairs((e :: any)._deps) do
depCount += 1
end
assert(depCount == 2, "duplicate dep collapsed; one entry per dep")
local bads: { { value: any, message: string } } = {
{ value = nil, message = "is nil" },
{ value = {}, message = "not a State/Source/Ref" },
{ value = 5, message = "not a State/Source/Ref" },
}
for _, bad in bads do
local ok, err = pcall(function()
Effect(function() end, s, bad.value)
end)
assert(not ok and string.find(tostring(err), bad.message, 1, true) ~= nil, "dep #2 " .. bad.message .. ": " .. tostring(err))
assert(string.find(tostring(err), "spec.effect.luau", 1, true) ~= nil, "level 2")
end
assert(not pcall(function()
Effect(5 :: any)
end), "fn must be a function")
print("PASS")
end
print()
print("=== 2. 발화 — 안 묶이면 홀드(H-159), 묶이면 dep 변경마다 1회; Ref dep도 같은 경로 ===")
do
local s, r = Source(1), Ref(1)
local runs = 0
local e = Effect(function()
runs += 1
end, s, r)
s:Set(2)
r:Set(2)
assert(runs == 1 and (e :: any)._rerunRequired == true, "unbound: changes held, not run")
local inst = Instance.new("Frame")
quad.bindLifetime(inst, e)
assert(runs == 2 and (e :: any)._rerunRequired == false, "bind replays the hold exactly once (not per change)")
s:Set(3)
assert(runs == 3, "State dep fires")
r:Set(3)
assert(runs == 4, "Ref dep fires")
quad.unbindLifetime(e)
s:Set(4)
assert(runs == 4, "unbound again: silent (held)")
print("PASS")
end
print()
print("=== 3. 다이아몬드 — 공통 상류가 한 번 바뀌면 fn은 한 번 (H-107: 클로저를 나눠도 dedup은 _epochs) ===")
do
local a = Source(1)
local b = a:Compute(function(x): number
return x:Get() + 1
end)
local c = a:Compute(function(x): number
return x:Get() + 2
end)
local runs = 0
local e = Effect(function()
runs += 1
end, b, c):Subscribe()
runs = 0
a:Set(5)
assert(runs == 1, "one Set → one run, got " .. runs)
e:Unsubscribe()
print("PASS")
end
print()
print("=== 4. cleanup — fn의 반환이 cleanup, 다음 fn 전에 1회, Unsubscribe가 소진, 파괴가 소진 ===")
do
local s = Source(1)
local log: { string } = {}
local e = Effect(function()
table.insert(log, "run")
return function()
table.insert(log, "clean")
end
end, s):Subscribe()
s:Set(2)
assert(table.concat(log, ",") == "run,clean,run", "cleanup runs right before the next fn")
e:Unsubscribe()
assert(table.concat(log, ",") == "run,clean,run,clean", "Unsubscribe consumes the cleanup exactly once")
assert((e :: any)._rerunRequired == true, "consumed = must reinstall next time")
-- 재구독 → 꼬리가 1회 재실행(H-144)
e:Subscribe()
assert(table.concat(log, ",") == "run,clean,run,clean,run", "resubscribe replays once")
e:Unsubscribe()
-- leaf 파괴 → Destroying 콜백이 cleanup 소진
log = {}
local inst = Instance.new("Frame")
local e2 = Effect(function()
table.insert(log, "run")
return function()
table.insert(log, "clean")
end
end, s)
quad.bindLifetime(inst, e2)
assert(table.concat(log, ",") == "run", "bound: no replay (nothing held)")
inst:Destroy()
assert(table.concat(log, ",") == "run,clean", "Destroying consumed the cleanup")
assert((e2 :: any)._destroyConn == nil, "connection dropped")
quad.unbindLifetime(e2) -- 죽은 뒤에도 안전(no-op)
print("PASS")
end
print()
print("=== 5. cleanup 없는 fn — 바인드/재마운트마다 재실행되지 않는다 (_rerunRequired 판정, H-58 회귀 방지) ===")
do
local s = Source(1)
local runs = 0
local e = Effect(function()
runs += 1
end, s)
local a, b = Instance.new("Frame"), Instance.new("Frame")
quad.bindLifetime(a, e)
quad.unbindLifetime(e)
quad.bindLifetime(b, e)
assert(runs == 1, "no held change → no re-run on (re)bind, got " .. runs)
print("PASS")
end
print()
print("=== 6. 재진입 — fn 안의 dep:Set은 지연 재실행(_pending); fn/cleanup은 자기 구독을 못 바꾼다 (H-147) ===")
do
local s = Source(0)
local runs = 0
local e: any
e = Effect(function()
runs += 1
if s:Get() == 0 then
s:Set(1) -- re-entrant change during fn
end
end, s):Subscribe()
assert(runs == 2 and s:Get() == 1, "deferred re-run happened once after fn returned");
(e :: any):Unsubscribe()
local caught: { string } = {}
local e2: any
e2 = Effect(function(self)
for _, name in { "Subscribe", "WeakSubscribe", "Unsubscribe", "WeakUnsubscribe" } do
local ok = pcall(function()
(self :: any)[name](self)
end)
if not ok then
table.insert(caught, name)
end
end
return function()
local ok = pcall(function()
e2:Subscribe()
end)
if not ok then
table.insert(caught, "cleanup:Subscribe")
end
end
end, s)
assert(#caught == 4, "all four entry points refuse inside fn, got " .. table.concat(caught, ","))
e2:Subscribe()
e2:Unsubscribe() -- runs the cleanup → its Subscribe attempt must error too
assert(caught[#caught] == "cleanup:Subscribe", "entry points refuse inside cleanup too")
print("PASS")
end
print()
print("=== 7. 네 진입점 — 게이트·관대/엄격·강한 킵 뒤 꼬리, canExecute 연동 ===")
do
local s = Source(1)
local runs = 0
local e = Effect(function()
runs += 1
end, s)
assert(quad.canExecute(e) == false, "unbound")
s:Set(2) -- held
assert(e:WeakSubscribe() == e and e.Subscribed == true and quad.canExecute(e) == true, "weak primitive")
assert(runs == 2, "tail replayed the hold")
assert(not pcall(function()
e:Subscribe()
end), "already subscribed")
e:WeakUnsubscribe()
assert((e :: any).Subscribed == false, "lenient release")
e:WeakUnsubscribe() -- silent
e:Subscribe()
assert(not pcall(function()
e:WeakUnsubscribe()
end), "strong keep present → error")
e:Unsubscribe()
assert(not pcall(function()
e:Unsubscribe()
end), "strict")
local ok, err = pcall(function()
quad.bindLifetime(Instance.new("Frame"), Effect(function() end, s):Subscribe())
end)
assert(not ok and string.find(tostring(err), "already subscribed", 1, true) ~= nil, "bindLifetime shares the gate: " .. tostring(err))
print("PASS")
end
print()
print("=== 8. error — fn이 던지면 그 Effect는 죽는다(_running 남음), 이후 재진입 전부 차단 ===")
do
local s = Source(1)
local e = Effect(function()
if s:Get() == 2 then
error("boom")
end
end, s):Subscribe()
local ok = pcall(function()
s:Set(2)
end)
assert(not ok, "the error propagated out of Set (no pcall inside quad)")
assert((e :: any)._running == true, "dead: _running stays set")
s:Set(1) -- 발화는 _pending으로만 기록되고 fn은 안 돈다
assert(not pcall(function()
e:Unsubscribe()
end), "entry points are blocked on a dead handle")
print("PASS")
end
print()
print("=== 9. GC — Effect가 강한 주인: 핸들을 놓으면 Ref 콜백·내부 Observer도 같이 사라진다 ===")
do
local s, r = Source(1), Ref(1)
local weak = setmetatable({}, { __mode = "v" }) :: { any }
local function make()
weak[1] = Effect(function() end, s, r)
end
make()
collectgarbage()
collectgarbage()
assert(weak[1] == nil, "an unowned Effect is collectable")
assert(next(r.WeakCallbacks) == nil, "its Ref callback went with it")
assert(next((s :: any)._subs) == nil, "and its internal Observer left the State's subscriber set")
-- 강한 구독은 살려둔다
local calls = 0
local function makeStrong()
Effect(function()
calls += 1
end, s):Subscribe()
end
makeStrong()
collectgarbage()
collectgarbage()
calls = 0
s:Set(9)
assert(calls == 1, "strongly subscribed Effect survives with no references")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,210 @@
--[[
Observer 계약 — `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드"(`_receive`/`_catchUp`/
생성자 순서) / "`state:Observer(fn)`" 절(등록 즉시 1회, `fn(targetState, self, emitFrom?)`, `nil` 계약),
`.claude/base/lifecycle-pattern.md` "(2) 전역 경로"(네 진입점) / "(4) 실제 호출부"(`canExecute` 게이팅).
]]
local Quad = require("../src")
local mock = require("./mock")
local QuadTypes = require("../roblox_packages/quad_types")
type Observer = QuadTypes.Observer
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
local quad = Quad.New()
mock.installLifetime(quad)
local Source = quad.Source
local Instance = mock.Instance
print("=== 1. 생성 — 등록 즉시 1회(emitFrom = nil), _state 강참조, 브랜드, 순서(fn → _subs) ===")
do
local s = Source(1)
local calls: { any } = {}
local o = s:Observer(function(target, self, from)
table.insert(calls, { target = target, self = self, from = from })
end)
assert(#calls == 1 and calls[1].target == s and calls[1].self == o and calls[1].from == nil, "install fire: (state, observer, nil)")
assert(quad.isObserver(o) and not quad.isState(o) and not quad.isEpoch(o), "brand")
assert((o :: any)._state == s, "observer holds its State strongly")
assert((s :: any)._subs[o] == true, "joined the subscriber set after the install fire")
assert((o :: any)._rerunRequired == false, "install fire lowered the hold flag")
-- 설치 발화가 자기 State를 Set해도 플래그가 서지 않는다(순서 계약)
local s2 = Source(0)
local o2 = s2:Observer(function(target, _, from)
if from == nil and target:Get() == 0 then
(target :: any):Set(1)
end
end)
assert((o2 :: any)._rerunRequired == false, "fn → _subs order: the install fire's own Set did not raise the flag")
print("PASS")
end
print()
print("=== 2. _receive — canExecute 게이팅: 안 묶이면 홀드, 묶이면 fn(state, self, from) ===")
do
local s = Source(1)
local calls: { { from: any } } = {} -- `table.insert(t, nil)`은 길이를 안 늘리므로 래퍼로 기록
local o = s:Observer(function(_, _, from)
table.insert(calls, { from = from })
end)
s:Set(2)
assert(#calls == 1, "unbound observer does not fire")
assert((o :: any)._rerunRequired == true, "…the change is held (H-159)")
local inst = Instance.new("Frame")
quad.bindLifetime(inst, o)
assert(#calls == 2 and calls[2].from == nil, "bind replays the held change once, with no source (H-164)")
assert((o :: any)._rerunRequired == false, "flag lowered")
s:Set(3)
assert(#calls == 3 and calls[3].from == s, "bound: fires with the source epoch")
inst:Destroy()
s:Set(4)
assert(#calls == 3 and (o :: any)._rerunRequired == true, "dead inst: held again, not fired")
print("PASS")
end
print()
print("=== 3. 무인자 state:Observer() — 항상 관측 유틸(호출 즉시 Get) ===")
do
local runs = 0
local s = Source(1)
local d = s:Compute(function(x): number
runs += 1
return x:Get()
end)
local o = d:Observer()
assert(runs == 1, "install fire observed (computed) once")
o:Subscribe()
s:Set(2)
assert(runs == 2, "each change re-observes")
o:Unsubscribe()
print("PASS")
end
print()
print("=== 4. WeakSubscribe(프리미티브) — .Subscribed=true, 약한 등록, 캐치업; canExecute true ===")
do
local s = Source(1)
local calls = 0
local o = s:Observer(function()
calls += 1
end)
s:Set(2) -- held
assert(o:WeakSubscribe() == o, "returns self")
assert(o.Subscribed == true and quad.canExecute(o) == true, "flag + gate")
assert(calls == 2, "WeakSubscribe replayed the held change once")
s:Set(3)
assert(calls == 3, "fires while weakly subscribed")
local ok, err = pcall(function()
o:WeakSubscribe()
end)
assert(not ok and string.find(tostring(err), "already subscribed", 1, true) ~= nil, "double subscribe: " .. tostring(err))
assert(string.find(tostring(err), "spec.observer.luau", 1, true) ~= nil, "level 2")
local ok2, err2 = pcall(function()
quad.bindLifetime(Instance.new("Frame"), o)
end)
assert(not ok2 and string.find(tostring(err2), "already subscribed", 1, true) ~= nil, "bindLifetime shares the gate: " .. tostring(err2))
print("PASS")
end
print()
print("=== 5. WeakUnsubscribe — 관대(H-133), 단 강한 킵이 있으면 error ===")
do
local s = Source(1)
local o = s:Observer(function() end)
o:WeakUnsubscribe() -- never subscribed: silent
o:WeakSubscribe():WeakUnsubscribe()
assert((o :: any).Subscribed == false and quad.canExecute(o) == false, "released")
o:WeakUnsubscribe() -- already released: silent
o:Subscribe()
local ok, err = pcall(function()
o:WeakUnsubscribe()
end)
assert(not ok and string.find(tostring(err), "use :Unsubscribe()", 1, true) ~= nil, "strong keep present: " .. tostring(err))
assert(o.Subscribed == true, "…and nothing was changed")
o:Unsubscribe()
print("PASS")
end
print()
print("=== 6. Subscribe/Unsubscribe — 인라인 게이트(H-149), 강한 킵, 엄격 해제 ===")
do
local s = Source(1)
local calls = 0
local o = s:Observer(function()
calls += 1
end)
s:Set(2)
assert(o:Subscribe() == o and calls == 2, "Subscribe replays the held change once")
local ok, err = pcall(function()
o:Subscribe()
end)
assert(not ok and string.find(tostring(err), "spec.observer.luau", 1, true) ~= nil, "double subscribe points at the caller: " .. tostring(err))
local w = s:Observer(function() end):WeakSubscribe()
local ok2, err2 = pcall(function()
w:Unsubscribe()
end)
assert(not ok2 and string.find(tostring(err2), "use :WeakUnsubscribe()", 1, true) ~= nil, "strict: weakly subscribed → error: " .. tostring(err2))
assert(w.Subscribed == true, "…untouched")
assert(o:Unsubscribe() == o and o.Subscribed == false and quad.canExecute(o) == false, "released both tables")
local ok3 = pcall(function()
o:Unsubscribe()
end)
assert(not ok3, "second Unsubscribe errors (strict)")
s:Set(3)
assert(calls == 2, "released observer is silent")
print("PASS")
end
print()
print("=== 7. GC — 강한 구독은 참조를 안 들어도 산다, 약한 구독은 놓으면 수거 ===")
do
local s = Source(1)
local strongCalls, weakCalls = 0, 0
local function make()
s:Observer(function()
strongCalls += 1
end):Subscribe()
s:Observer(function()
weakCalls += 1
end):WeakSubscribe()
end
make()
collectgarbage()
collectgarbage()
strongCalls, weakCalls = 0, 0
s:Set(2)
assert(strongCalls == 1, "strongly subscribed observer survives with no references")
assert(weakCalls == 0, "weakly subscribed observer was collected")
print("PASS")
end
print()
print("=== 8. 파동 중 새 구독자 — 스냅샷 계약(다음 파동부터) + 인스턴스별 레지스트리 ===")
do
local s = Source(1)
local late: any = nil
local lateCalls = 0
s:Observer(function(_, _, from)
if from ~= nil and late == nil then
late = s:Observer(function(_, _, f)
if f ~= nil then
lateCalls += 1
end
end):Subscribe()
end
end):Subscribe()
s:Set(2)
assert(late ~= nil and lateCalls == 0, "subscriber added mid-wave is not visited in that wave")
s:Set(3)
assert(lateCalls == 1, "…but from the next wave")
local other = Quad.New()
mock.installLifetime(other)
local oo = other.Source(0):Observer(function() end)
assert(getmetatable(oo :: any) ~= getmetatable(late :: any), "each quad instance has its own Observer impl (H-174)")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -20,6 +20,7 @@ local TypeVersionCheck = require("./luau_packages/type_version_check")
-- `Epoch` — 최소 인터페이스(`.claude/base/state-epoch-plan.md` §2). 판정은
-- identity + "직전과 다른 Revision"뿐, 순서 비교 없음. 런타임 판별은 `isEpoch`.
export type Epoch = { Revision: number }
export type EpochSet = { [Epoch]: true } -- 집합이지 배열이 아니다(게이트 배치가 그대로 넘어온다)
-- `Relate` — inst를 weak 키로 하는 릴레이션(`.claude/base/relate-plan.md` "API").
-- `inst`는 항상 weak, `Weak`/`Strong`은 value의 보관 방식만 가리킨다.
@ -54,6 +55,31 @@ export type Ref<T> = {
-- `fn(self, previous?, ...deps)` — 전부 lazy 핸들, 값은 `:Get()`으로만
-- (`.claude/base/source-state-plan.md` "self 인자도 lazy 핸들로 통일").
export type StateData<T> = { Get: (self: StateData<T>) -> T }
-- `Observer` — 값을 안 실어주는 leaf 구독(`source-state-plan.md` "`state:Observer(fn)`").
-- `fn(targetState, self, emitFrom?)`: 3번째가 출처(`Epoch` | 집합), 설치·캐치업 발화는 `nil`.
-- 네 진입점은 `lifecycle-pattern.md` (2) — Weak가 프리미티브, `.Subscribed`는 강·약 공용.
export type Observer = {
Subscribed: boolean,
Subscribe: (self: Observer) -> Observer,
WeakSubscribe: (self: Observer) -> Observer,
Unsubscribe: (self: Observer) -> Observer,
WeakUnsubscribe: (self: Observer) -> Observer,
}
export type ObserverFn<T> = (targetState: StateData<T>, self: Observer, emitFrom: (Epoch | EpochSet)?) -> ()
-- `EffectHandle` — `Effect(fn, ...deps)`의 핸들(`effect-plan.md`). `fn(self) -> ...cleanup`.
-- 네 진입점은 Observer와 같은 이름·같은 게이트이되 **본문은 자기 것**(`H-144`).
export type EffectHandle = {
Subscribed: boolean,
Rerun: (self: EffectHandle) -> EffectHandle,
Subscribe: (self: EffectHandle) -> EffectHandle,
WeakSubscribe: (self: EffectHandle) -> EffectHandle,
Unsubscribe: (self: EffectHandle) -> EffectHandle,
WeakUnsubscribe: (self: EffectHandle) -> EffectHandle,
}
export type EffectFn = (self: EffectHandle) -> ...(() -> ())
export type State<T> = StateData<T> & {
-- deps는 `...any`다 — 타입팩 `D...`로 위치 인자를 좁히는 형태는 strict에서 콜백 dep
-- 추론이 `{read Get: ...}`로 뒤틀려 정상 호출까지 막힌다(M2 단위 2 실측, 스파이크 15가
@ -62,6 +88,8 @@ export type State<T> = StateData<T> & {
With: (self: StateData<T>, ...any) -> State<T>,
-- 애플리커티브 팩토리는 함수이거나 메소드형 `__apply`를 가진 객체(`H-94`/`H-158`).
Apply: <U>(self: StateData<T>, factory: ((State<T>) -> U) | { __apply: (self: any, state: State<T>) -> U }) -> U,
-- 등록 즉시 1회 실행. 무인자면 "항상 관측" 유틸.
Observer: (self: StateData<T>, fn: ObserverFn<T>?) -> Observer,
}
-- `Source`는 `State`를 구조적으로 만족하고 동시에 `Epoch`다(다중 태깅).
export type Source<T> = State<T> & {
@ -110,6 +138,8 @@ export type Quad = {
-- 단위 2. `State`는 런타임 생성자가 없다(파생 전용) — 타입만 위에서 export.
Source: <T>(v: T) -> Source<T>,
Store: <T>(defaults: T?) -> Store<T>,
-- 단위 3. deps는 State/Source/Ref(`H-70`), `fn`엔 안 넘어간다.
Effect: (fn: EffectFn, ...any) -> EffectHandle,
-- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입
-- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`.
@ -117,6 +147,8 @@ export type Quad = {
unbindLifetime: (value: any) -> (),
canBound: (value: any) -> boolean,
canExecute: (value: any) -> boolean,
-- 주입 엔진 op(`architecture.md` EngineOps) — `Effect._bindDestroying`이 부른다. 미주입이면 에러 스텁.
onDestroying: (inst: any, fn: () -> ()) -> { Connected: boolean, Disconnect: (self: any) -> () },
-- 브랜드 술어(`.claude/base/brand-plan.md`). 나머지(`isTag`/`isAttribute*`/
-- `isTween`/`isSlot`)는 그 타입의 마일스톤에서.