qa: 7라운드 발견 52건 처리·반영 — Store 재설계 시도와 철회, 타입 함수 원칙 신설
`qa-request/pre-implementation-handtrace-round7.md`의 `H-55`~`H-106`을
사용자와 대화형으로 처리하고 `base/`에 전량 반영했다. 결정의 소스는
`-followup.md`(개수·개별 항목은 여기서 세지 않는다).
## 처분
확정 39 / 무효·소멸 4(`H-73`~`H-76`) / 기각 1(`H-77`) / 범위 축소 2 /
다른 항목으로 흡수 6.
**부수로 `question.md` 최우선 절이 비었다** — 중간 State GC는 `_hold`
불변식(하류 → 상류 강함)으로, 동적 키 표면 위치는 `store:Of<<T>>(name)`
하나로 닫혔다. **M2 착수를 막는 항목이 없다.**
## 구조가 바뀐 것
- `Ref`가 `Epoch`를 만족(`.Revision` + `EpochBrand`) — 포탈 캐치업 비대칭과
같은 `Ref` 중복 dep이 같이 닫힘
- `Weak*` 등록 표면 신설(`Ref:WeakCallback` / `Observer:WeakSubscribe`) —
Weak 쪽이 프리미티브고 강한 쪽이 "GC 킵"을 얹은 것
- `Effect`: dep 등록이 생성자 한 곳으로, 강한 주인은 `_deps` 하나,
억제는 사적 `Blocker`, `bindLifetime`은 핸들 하나에만 적용.
`:Rerun()` 정의 신설(재진입은 지연 재실행), `_installed` 신설
- 전파 루프 의사코드 확정 — 구독자 집합의 원소는 Observer **값**이고
**자식 State 노드는 `canExecute`를 안 탄다**(그대로 짜면 파생 State
아래가 전부 침묵했다)
- `rawInvalid` → `cacheTargetCount`/`cacheCurrCount` 카운터 쌍
- `recompute` 재진입 차단 + `invalidAfter` 되감기, `gatedRecompute`가
인덱스를 캡처하지 않고 토큰으로 조회
- `emit(commit) -> boolean`, `EpochMap:Peek` — 정책이 흡수 집합을
버리지도 읽지도 못하던 것을 닫음
- error 계약(`level` 이분, 메시지는 영어)과 예외 계약(`pcall`로 안 감쌈)을
`architecture.md`에 신설
## Store — 같은 날 재설계하고 철회했다
`H-75`/`H-76`으로 `WrapStore`/`ProcessStoreType`이 폐기되자 그 자리를
"`store.key`는 값, `store:Of(k)`가 프리미티브"로 채웠다가 **같은 날
철회**했다(`archive/store-value-field-redesign-withdrawn.md`). 살아남은 건
**명시적 초기화** 하나다. 최종형은 타입 인자에 `Source<T>`를 직접 쓰고
`store.key`는 평범한 레코드 필드이며 **타입 함수를 안 쓴다**.
철회 이유 중 하나가 원칙으로 승격됐다 — **"타입 함수는 타입이 못 잡는
문제를 에러로 띄우는 정도 이상으로 가지 않는다"**(`typing-limits.md` §0).
`index<>`/`keyof<>`도 Luau가 predefine한 타입 함수라 같은 함정을 갖는다.
## 툴체인
**`luau` CLI가 심볼릭 링크를 못 탄다**(디렉토리·파일 둘 다)는 것이 최소
재현으로 밝혀졌다 — pesde 워크스페이스 링크가 전부 심볼릭이라 스모크 2개가
안 돌았고 `luau-analyze`는 **조용히 통과**했다("거짓 클린"). `scripts/relink.sh`
+ `scripts/test.sh` 신설, 이제 스모크 셋 전부 PASS.
## 검증
`/code-review high` 2회(12건 + 14건)와 감사 8패스(12/6/6/4/13/3/6/0건)를
각도를 바꿔가며 돌렸고 전부 반영했다 — 마지막 패스가 무발견으로 수렴.
각 패스의 각도와 대표 발견은 `-followup.md`의 검증 절이 소스.
`doc-check.py` ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG
This commit is contained in:
parent
8cc388cfab
commit
eb321e1747
52 changed files with 3837 additions and 450 deletions
File diff suppressed because one or more lines are too long
355
.claude/archive/store-value-field-redesign-withdrawn.md
Normal file
355
.claude/archive/store-value-field-redesign-withdrawn.md
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
# [검토 후 철회] Store를 "값 필드 + 타입 함수 합성"으로 바꾸려던 시도
|
||||
|
||||
**상태**: archive — **2026-08-25 오전에 도입했다가 같은 날 철회됨.**
|
||||
지금 유효한 설계는 `base/store-plan.md`가 소스. 이 문서는 그 시도의 원문과
|
||||
**왜 되돌렸는지**를 보존한다(히스토리 전용, **여기 적힌 것을 현재 설계로
|
||||
읽지 말 것**).
|
||||
|
||||
## 무엇을 시도했나
|
||||
|
||||
`WrapStore`/`ProcessStoreType` 타입 함수로 결과 타입을 **합성**하는 옛
|
||||
접근이 7라운드 `H-75`/`H-76`으로 무너지자, 그 자리를 다음으로 채우려 했다:
|
||||
|
||||
| 시도한 것 | 지금(철회 후) |
|
||||
|---|---|
|
||||
| 타입 인자가 **평범한 값 타입**(`Store<<{hp: number}>>`) | 타입 인자에 **`Source<T>`를 직접**(`Store<<{hp: Source<number>}>>`) |
|
||||
| `store.key` → **값**(`__index`가 `Of(k):Get()`) | `store.key` → **`Source<T>`**(평범한 레코드 필드) |
|
||||
| `store.key = v` **부활**(`__newindex`) | **폐기 유지** — `store.key:Set(v)` |
|
||||
| `store:Of(k)`가 **프리미티브** | `store:Of<<T>>(name)`은 **동적 키 전용**(옛 `GetDynamic` 흡수) |
|
||||
| 팬텀 필드 `__store` + `index<>`/`keyof<>` | **타입 함수 안 씀** — 평범한 레코드 |
|
||||
| 선언만 하고 값을 안 주는 키를 `?`로 표현 | **명시적 초기화**(이건 **살아남았다**) |
|
||||
|
||||
**살아남은 것은 명시적 초기화 하나**다 — eager/lazy 이중 모델 폐기,
|
||||
`store:Names()` 신설, `WrapStore` 폐기. 나머지는 전부 되돌렸다.
|
||||
|
||||
## 왜 철회했나
|
||||
|
||||
**사용자 지적**(2026-08-25): *"우리가 너무 '필드셋' 으로 처리하고싶다에
|
||||
너무 몰두해버린것 같네요. 필드의 read write 의 실질적 의미론이 어떤지도
|
||||
정의하지 못해버린채요. 이러면, `.Value = 1` 이 정적 쓰기처럼 보일텐데,
|
||||
여기서 error 터지는 trace 가 나오면 당황스럽기도 하고요, 약간 마법적
|
||||
동작이기도 해요. … 왜 우리가 `Source()` 를 직접 넣는걸 거부하고, 이렇게
|
||||
까지 하려 했죠? 단순히, `Store<{ a: Source }>` 로 두고, `store.a:Set,Get`
|
||||
하지 말아야할 이유가 있을까요?"*
|
||||
|
||||
되짚어보니 **비용이 이득보다 컸다**:
|
||||
|
||||
1. **읽기/쓰기 의미론을 정의하지 못한 채로 문법만 도입했다.**
|
||||
`store.key = v`가 정적 쓰기처럼 보이는데 실제로는 `Set`을 트리거하고,
|
||||
실패하면 그 자리에서 error trace가 난다 — 2026-08-06이 대입 문법을
|
||||
폐기한 논거 2번(*"`=`가 암시하는 즉시 커밋이 lazy와 안 맞는다"*)이
|
||||
그대로 되살아난 셈이다.
|
||||
2. **`index<>`/`keyof<>`도 타입 함수다.** **사용자 지적**: *"사실 keyof 와
|
||||
index 도 타입함수입니다(구현을 뜯어보면, 루아우에서 프리디파이닝한
|
||||
타입함수임). 타입함수가 가지는 고질적 문제를 그대로 가져요."*
|
||||
실제로 `H-76`이 지적한 부류의 문제가 새로 하나 생겼다 — `Of`의
|
||||
**`Self` 제네릭을 거치면 콜백 파라미터 추론이 깨져** `s`가 `unknown`으로
|
||||
떨어졌다(평범한 레코드 필드에선 정상 추론된다, 실측 대조).
|
||||
3. **`store:Names()`가 런타임에 구현 불가능했다.** Luau가 타입 인자를
|
||||
런타임에 지우므로 `Store<<{hp: number, name: string?}>>({hp = 100})`에서
|
||||
`name`이 선언됐다는 사실을 런타임이 알 방법이 없다. 그런데
|
||||
`attr:NameMap()`은 선언된 전체 키를 요구한다 — 즉 `H-79`가 "닫혔다"고
|
||||
한 문제가 옵셔널 미설정 키에 대해 그대로 재발한다(감사 4패스 발견).
|
||||
4. **`?`(nilable) 선언을 떠받칠 sentinel이 없었다.** `None`은 **핸들러
|
||||
배열의 nil hole** 때문에 생긴 물건이라 "값이 없다"가 아니고,
|
||||
`Source<None|T|nil>`을 정의한 적도 없어 `Compute`가 뭘 받을지부터
|
||||
안 정해진다(**사용자 분석**). 그렇다고 `nil`과 `None`을 가르는 새
|
||||
도구를 두기엔 의미론이 부딪힌다.
|
||||
5. **표면이 늘었다** — `Of` + `Names` + `GetDynamic` 셋이었는데, 철회 후엔
|
||||
`Of`가 `GetDynamic`을 흡수해 둘이 된다.
|
||||
|
||||
**여기서 나온 원칙 하나가 `base/typing-limits.md` §0으로 승격됐다** —
|
||||
*"타입 함수는 타입이 못 잡는 문제를 **에러로 띄우는** 정도 이상으로 가지
|
||||
않는다."* 이 시도가 정확히 그 선을 넘은 사례다.
|
||||
|
||||
경위 원문은 `qa-request/pre-implementation-handtrace-round7-followup.md`의
|
||||
🅖🅗 절, 발견 원문은 `qa-request/pre-implementation-handtrace-round7.md`의
|
||||
`H-73`~`H-76`.
|
||||
|
||||
---
|
||||
|
||||
## 철회된 시도의 원문 (보존)
|
||||
|
||||
아래는 **2026-08-25 오전에 `base/store-plan.md`에 실제로 들어갔던 네 절**을
|
||||
그대로 옮긴 것이다. 같은 날 철회돼 지금은 `base/store-plan.md`에 없다.
|
||||
**여기 적힌 것을 현재 설계로 읽지 말 것** — 특히 `store.key`가 값이라는
|
||||
서술, `__index`/`__newindex` 슈가, 팬텀 필드 `__store`,
|
||||
`index<>`/`keyof<>`, `?` nilable 선언, `store.key = v` 부활은 **전부 철회**됐다.
|
||||
|
||||
---
|
||||
|
||||
## Store = Source들의 이름 붙은 모음 (명시적 선언, 2026-08-25 재설계)
|
||||
|
||||
> **⭐⭐ [2026-08-25 재설계] 이 절은 통째로 다시 쓰였다.** 옛 모델
|
||||
> (`store.key`가 `Source<T>`를 직접 반환하는 레코드 필드 + eager/lazy
|
||||
> 이중 생성 + `WrapStore` 타입 함수)은 **역전**됐고 원문은
|
||||
> 이 파일(당시 이름은 store-source-record-model-reversed.md였다)에 있다. 경위는
|
||||
> `qa-request/pre-implementation-handtrace-round7-followup.md`.
|
||||
|
||||
**Store는 키를 타입 인자로 명시해서 만든다.** 타입 인자에 담는 것은
|
||||
`Source<T>`가 아니라 **평범한 값 타입**이다.
|
||||
|
||||
```lua
|
||||
local store = quad.Store<<{
|
||||
hp: number,
|
||||
name: string?, -- 안 넘겨도 되는 값은 `?`로 선언
|
||||
}>>({ hp = 100 })
|
||||
```
|
||||
|
||||
- **`store.key`는 값**이고 **`store.key = v`는 대입**이다 —
|
||||
`__index`/`__newindex`가 아래 `Of`를 대신 불러준다. 읽기에 계산이 끼지
|
||||
않는다(Store가 담는 건 `Source`뿐이므로 `Get`이 곧 현재 값이다).
|
||||
사용자: *"`__index` 는 이제 `:Get` 해주어도 될것 같다. (연산이 안
|
||||
일어난다. 진짜 현 값이 맞음)"*
|
||||
- **⭐ `store:Of(key)`가 프리미티브다** — 그 키의 `Source`를 돌려준다.
|
||||
`__index`/`__newindex`는 그 위의 슈가다(사용자: *"`__index` 를 통한
|
||||
get 에선 `Of()` 결과에서 `:Get` 하도록 합시다. `__newindex` 도
|
||||
유사하죠, 그 이후 `Set` 되는 식"*). 반응형으로 묶을 때는 이걸 쓴다.
|
||||
```lua
|
||||
store.hp -- number
|
||||
store.hp = 5 -- 대입
|
||||
store:Of("hp") -- Source<number>
|
||||
store:Of("name") -- Source<string?> ← `?` 선언이 그대로 드러남
|
||||
```
|
||||
- **이름이 `Peek`이 아니라 `Of`인 이유** — 사용자: *"Peek 자체가, 애초에
|
||||
여긴 다 확정된 값들의 무더기라서, Peek 아닌 다른게 좋긴 해보여요.
|
||||
`Of` 가 가장 좋아보입니다."* 다른 프레임워크에서 `Peek`은 관례적으로
|
||||
"구독 없이 값을 읽는다"인데 여기선 `store.key`가 이미 그 일을 하고
|
||||
이 메소드는 **핸들**을 준다.
|
||||
- **⭐ 선언은 필수, 값은 선택.** 안 넘겨도 되는 것은 타입에서 `?`로
|
||||
선언한다 — 그러면 `store:Of("name")`이 `Source<string?>`로 나와
|
||||
**타입이 정직해진다**. 옛 lazy 모델이 갖던 구멍(사용자: *"`Set` 을 안
|
||||
해주면, 초기 값이 타입에 어긋날 수 있거든요. 애초에,
|
||||
`Source<number>` 인데, nil을 조용히 가지고 있을수도 있고, 타입으로 못
|
||||
막네요"*)이 이걸로 닫힌다. 부모 컴포넌트가 기본값 있는 것까지 전부
|
||||
넘길 필요도 없어진다.
|
||||
- **`Source` 실체는 그림자 백킹 테이블에 있다** — `store.key`가 값이면
|
||||
`__index`/`__newindex`는 그 키가 store 테이블에 **없을 때만** 발동하므로
|
||||
실제 `Source`를 store 테이블에 raw로 넣을 수 없다. **[2026-08-25
|
||||
정정]** 옛 서술의 *"별도 `__values`류 그림자 실값 저장소도 불필요"*는
|
||||
이 재설계로 뒤집혔다.
|
||||
- **미선언 키의 방어선은 여전히 타입이다.** `Store<{field: T}>`로 선언된
|
||||
Store에 없는 이름을 쓰면 타입 에러가 난다 — `luau-analyze` 실측에서
|
||||
`store:Of("nope")`이 정확히 거부된다. 런타임에 이름이 정해지는 정당한
|
||||
용도는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
|
||||
- **`store:Names()`가 선언된 키 집합을 준다** — 타입은
|
||||
`{ keyof<...> }`로 정확히 나온다. 그룹 `Attribute(...)`/`attr:NameMap()`이
|
||||
이걸 요구한다(`base/attribute-plan.md`). **[2026-08-25 신설, 7라운드
|
||||
`H-79`]** 이 표면이 없어서 그룹 `Attribute`의 키 집합이 접근 이력에
|
||||
좌우되던 문제를 닫는다.
|
||||
- **구현 스케치**: 생성 시 `table.clone(defaults or {})` 후 그 결과를
|
||||
순회하며 각 슬롯을 `Source(v)`로 교체해 그림자 테이블로 삼는다
|
||||
(`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에
|
||||
키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로).
|
||||
**`or {}`가 필수다** — 무인자 `Store<<{}>>()`도 유효한데
|
||||
`table.clone(nil)`은 `table expected, got nil`로 죽는다(**[2026-08-25]**
|
||||
7라운드 `H-83` 실측).
|
||||
`Source()`(인자 없이 호출)는 `Source(nil)`과 동치.
|
||||
- **⭐ [2026-08-25 신설] 선언은 됐는데 `defaults`에 없는 키는 `Of`가 그
|
||||
자리에서 만든다.** "선언은 필수, 값은 선택"이 계약이므로
|
||||
`Store<<{hp: number, name: string?}>>({hp = 100})` 뒤
|
||||
`store:Of("name")`은 **정상 경로**인데, 위 스케치는 `defaults`에 있는
|
||||
키만 그림자 테이블에 넣으므로 그 자리가 비어 있다. `Of`가 비어 있으면
|
||||
`Source(nil)`을 만들어 저장하고 반환한다 — 타입이 `Source<string?>`라
|
||||
**정직하고**, 옛 lazy 모델이 갖던 구멍(`Source<number>`인데 조용히 `nil`)은
|
||||
`?` 선언 강제로 이미 닫혀 있다.
|
||||
```lua
|
||||
function Store:Of(key)
|
||||
local src = shadow[key]
|
||||
if src == nil then
|
||||
src = Source() -- == Source(nil)
|
||||
shadow[key] = src
|
||||
end
|
||||
return src
|
||||
end
|
||||
-- __index(t, k) == t:Of(k):Get()
|
||||
-- __newindex(t,k,v) == t:Of(k):Set(v)
|
||||
```
|
||||
**미선언 키는 여기 안 온다** — 타입에서 이미 거부되고(`store:Of("nope")`
|
||||
실측), 런타임에 이름이 정해지는 경로는 `:GetDynamic`이 정식 창구다.
|
||||
|
||||
v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게
|
||||
쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심
|
||||
제약이라 그 전제 자체가 더 이상 안 맞고, 2026-08-06 후속 세션의 정리로
|
||||
Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐. 값 하나만
|
||||
반응형으로 다루고 싶으면 Store를 통째로 만들지 말고 독립
|
||||
`Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립
|
||||
공개 프리미티브로 격상" 절).
|
||||
|
||||
## Store 값 설정 문법 — `store.key = v`와 `store:Of(k):Set(v)`는 같은 것 (2026-08-25 재역전)
|
||||
|
||||
> **⭐⭐ [2026-08-25 재역전] 2026-08-06에 확정했던 "`myStore.key = value`
|
||||
> 폐기, `source:Set(value)`로 전환"은 뒤집혔다.** 원문과 역전 이유는
|
||||
> 이 파일(당시 이름은 store-source-record-model-reversed.md였다). 여기 옛 결정을 남겨두면
|
||||
> 앞에서부터 읽는 구현자가 그걸 그대로 믿으므로 포인터만 남긴다.
|
||||
|
||||
**둘 다 정식 경로이고 같은 것이다.**
|
||||
|
||||
```lua
|
||||
store.hp = 5 -- __newindex → store:Of("hp"):Set(5)
|
||||
store:Of("hp"):Set(5) -- 프리미티브
|
||||
```
|
||||
|
||||
- **왜 되살아났나**: 옛 결정의 첫 근거는 *"읽기(`Source<T>`)/쓰기(`T`)
|
||||
타입이 갈려 mismatch가 남음"*이었는데, 위 재설계로 **읽기도 `T`가 되어
|
||||
대칭**이다. `luau-analyze` 실측에서 `store.hp = 5`는 통과하고
|
||||
`store.hp = "five"`는 정확히 걸린다.
|
||||
- 옛 결정의 둘째 근거(*"`=`가 암시하는 즉시 커밋이 quad의 lazy와 안
|
||||
맞음"*)도 이 자리에선 약하다 — lazy한 것은 **하류 전파**이지 Store 값
|
||||
자체가 아니다. 사용자: *"연산이 안 일어난다. 진짜 현 값이 맞음."*
|
||||
- 옛 결정의 셋째 근거(**"값을 바꾸는 연산엔 `:` 체이닝 허용"** 원칙,
|
||||
`base/architecture.md`)는 그대로 유지된다 — `store:Of(k):Set(v)`가
|
||||
그 사례다.
|
||||
- **`myStore "key"`(문자열 커링)는 여전히 기각**이다(2026-08-18 사용자
|
||||
판정) — *"저러면 "a" 가 string 으로 들어가서, Source<T> 의 타입을 모르기도
|
||||
하고, 우린 더이상 필요하지 않게 된 요소임."* 동적 키는 아래
|
||||
`:GetDynamic`으로 간다.
|
||||
|
||||
`base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께
|
||||
읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며
|
||||
"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 대입 문법 자체가
|
||||
아니었음.
|
||||
|
||||
## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드, 2026-08-25 재작성)
|
||||
|
||||
- `store "key"`(문자열 커링)로 `state<T>`를 오버로드 함수 타입으로 정확히
|
||||
추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**,
|
||||
위 절), **`store.key`(dot-access)를 1급 경로로 확정**. Store 타입을
|
||||
`index<>`/`keyof<>`로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고,
|
||||
문자열 리터럴 narrowing 문제 자체가 안 생김(구체적인 모양은 아래
|
||||
"`store.key` 레코드 필드 타이핑" 절).
|
||||
- **동적 키 경로는 명시적 메소드다** — `store:GetDynamic<<T>>(name): Source<T>`.
|
||||
런타임 동작 자체는 dot-access와 같고 문제는 **타입**뿐이었다: 선언되지
|
||||
않은 이름은 결과 타입에 없어서 타입 에러가 난다(그게 방어선이라는 게
|
||||
사용자 확정). 그래서 "런타임에 이름이 정해지는" 정당한 용도를 위해
|
||||
**타입을 호출자가 직접 주는 명시적 창구**를 둔다 — 사용자 판정:
|
||||
*"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐. 타입 애러가 난다는
|
||||
점인데, 이는 GetDynamic<T>(name): Source<T> 로 제공하는게 최선으로 보임."*
|
||||
- **⭐ [2026-08-25 실측 정정] `<<T>>`는 값 호출부에서 동작한다.**
|
||||
7라운드 `H-73`이 *"Luau엔 호출부 명시 타입 인자 문법이 없다"*고
|
||||
단정했으나 **틀렸다** — Luau의 **generic type instantiation**
|
||||
(`luau.org/types/generics/#generic-type-instantiation`)이 값 호출부에서도,
|
||||
콜론 메소드에서도 `T`를 실제로 묶는다. `luau-analyze` 실측:
|
||||
```lua
|
||||
local ok: Source<number> = store:GetDynamic<<number>>("x") -- 진단 없음
|
||||
local bad: Source<string> = store:GetDynamic<<number>>("y") -- 정확히 걸림
|
||||
local none: Source<number> = store:GetDynamic("z") -- Source<unknown>
|
||||
```
|
||||
원문은 **인스턴스화를 생략한 호출만** 돌려보고 단정했다. 따라서
|
||||
`base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다.
|
||||
- **⭐ [2026-08-25 확정] 표면은 전부 콜론 메소드이고, 예약 키 충돌은
|
||||
타입 함수가 잡는다.** 예약 이름은 `Of`/`Names`/`GetDynamic`/`__store`
|
||||
(팬텀 필드)다.
|
||||
- **안 잡으면 조용히 죽는다** — 실측에서 사용자 키가 예약 이름과 겹치면
|
||||
교집합이 뭉개져 **그 필드의 타입 검사가 통째로 꺼진다**(음성 대조군이
|
||||
진단 0건으로 통과했다). 시끄럽게 막히는 게 아니라 그냥 지나간다.
|
||||
- 그래서 `T`를 **검증만 하고 그대로 통과시키는** 작은 `type function`을
|
||||
둔다. 겹치면 사용 지점에
|
||||
`TypeError: quad.Store: "Of" is a reserved key`가 뜬다.
|
||||
```lua
|
||||
type function CheckReserved(t: type): type
|
||||
-- 겹치면 print(...) + return types.never, 아니면 t를 그대로 반환
|
||||
end
|
||||
type Store<T> = StoreOf<CheckReserved<T>>
|
||||
```
|
||||
- **`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼
|
||||
버려진다. `print(...)` + `return types.never` 조합만 된다
|
||||
(`luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`가
|
||||
기록해둔 사실이고 `type-version-check`가 이미 쓰는 패턴).
|
||||
- **[2026-08-25 해소] 옛 "탑레벨 함수로 옮길지" 미결은 닫혔다** —
|
||||
콜론 유지로 확정했고, 예약 키 문제는 위 타입 함수가 받는다. 팬텀
|
||||
필드(`__store`)의 사용은 UB로 둔다.
|
||||
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
|
||||
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
|
||||
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).
|
||||
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학"
|
||||
절이 최신 확정 내용.
|
||||
|
||||
### `store.key` 레코드 필드 타이핑 — `index<>`/`keyof<>`로 해결 (2026-08-25 재작성, `WrapStore` 폐기)
|
||||
|
||||
> **⭐⭐ [2026-08-25] 옛 `WrapStore`/`ProcessStoreType` 타입 함수 접근은
|
||||
> 폐기됐다.** 원문은 이 파일(당시 이름은 store-source-record-model-reversed.md였다).
|
||||
> 7라운드 `H-75`/`H-76`이 그 접근의 두 한계를 실측했는데(평평한 선언이면
|
||||
> `store.key:Compute(무주석 콜백)`이 깨짐 / `type function`이 바깥 타입
|
||||
> 별칭을 참조 못 해 구조를 통째로 중복 작성해야 하고 메소드 self
|
||||
> 파라미터가 불변이라 필드 하나만 어긋나도 대입 실패), 아래 모양은
|
||||
> **결과 타입을 합성하지 않으므로** 두 한계에 아예 안 닿는다.
|
||||
|
||||
**확정 형태** — 팬텀 필드에 선언 원형을 싣고, `index<>`/`keyof<>`로 꺼낸다.
|
||||
|
||||
```lua
|
||||
type StoreOf<T> = {
|
||||
__store: T, -- 팬텀. 사용은 UB.
|
||||
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
|
||||
-> Source<index<index<Self, "__store">, K>>,
|
||||
Names: <Self>(self: Self) -> { keyof<index<Self, "__store">> },
|
||||
GetDynamic: <T>(self: any, name: string) -> Source<T>,
|
||||
} & T
|
||||
type Store<T> = StoreOf<CheckReserved<T>>
|
||||
```
|
||||
|
||||
- `& T`가 dot-access(`store.hp`)를 그대로 만들어준다 — 타입 인자가
|
||||
**평범한 값 타입**이므로 `store.hp`가 `number`다.
|
||||
- `K & keyof<...>`가 **싱글톤을 보존**한다. 사용자가 실측으로 확인한
|
||||
성질이다: 타입 후보 중에 싱글톤이 있으면 `string`으로 뭉개지지 않고
|
||||
`"hp"` 그대로 넘어간다. 그래서 `index<T, K>`가 그 키의 실제 타입을
|
||||
뽑아낸다.
|
||||
- **`luau-analyze` 실측**(전부 `--!strict`, 양성 + 음성 대조군):
|
||||
|
||||
| 검사 | 결과 |
|
||||
|---|---|
|
||||
| `store.hp` | `number` ✅ (음성 대조군 걸림) |
|
||||
| `store.hp = 5` / `= "five"` | 통과 / **걸림** ✅ |
|
||||
| `store:Of("hp")` | `Source<number>` ✅ (음성 대조군 걸림) |
|
||||
| `store:Of("name")`(`string?` 선언) | `Source<string?>` ✅ |
|
||||
| `store:Of("nope")` | **거부** ✅ |
|
||||
| `store:Names()` | `{ "hp" \| "name" }` ✅ |
|
||||
| `store:Of("hp"):Set(3)` / `:Set("3")` | 통과 / **걸림** ✅ |
|
||||
| `Store<{ Of: number }>` | **`"Of" is a reserved key`** ✅ |
|
||||
| `store:Of("hp"):Compute(무주석 콜백)` | ⚠️ **파라미터 주석 필요**(아래 캐비엇) |
|
||||
|
||||
- **⚠️ [2026-08-25 정정] `store.key:Compute(…)`라고 적었던 표 행은 거짓이었다.**
|
||||
실측한 적이 없었고(`/code-review high`가 잡음), 애초에 새 모델에서
|
||||
`store.key`는 **값**이라 `:Compute`가 없다
|
||||
(`Type 'number' does not have key 'Compute'`). 파생은 프리미티브를
|
||||
거친다 — `store:Of("hp"):Compute(...)`.
|
||||
- **⭐ `store:Of(k)`의 반환값 추론은 무주석으로 정확하다 — Store 특유의
|
||||
타입 문제는 없다.** 실측(`audit/type-store-index-keyof/spikes/08`):
|
||||
```lua
|
||||
local hp = store:Of("hp") -- 무주석
|
||||
local a: number = hp:Get() -- ✅ (string 대조군은 정확히 걸림)
|
||||
hp:NoSuchMethod() -- ✅ 걸림
|
||||
hp:Set(5) -- ✅ (`:Set("5")`는 걸림)
|
||||
local nm = store:Of("name") -- Source<string?> ← `?` 선언이 그대로 드러남
|
||||
```
|
||||
- **걸리는 자리는 하나 — `:Compute`/`:With`의 콜백 파라미터 무주석 추론**이고,
|
||||
그건 Store가 만든 게 아니라 `base/typing-limits.md` §1의 **문제 A** 그
|
||||
자체다. 해법도 그 문서가 정한 그대로 — **파라미터에 주석을 단다**(실측
|
||||
통과):
|
||||
```lua
|
||||
hp:Compute(function(s) return s:Get() * 2 end) -- ❌ 진단
|
||||
hp:Compute(function(s: StateData<number>) return s:Get() * 2 end) -- ✅
|
||||
```
|
||||
§1②쪼개기가 평범한 `Source`에서 이 자리를 덮어주는 것과 달리 `Of`를
|
||||
거치면 안 걸린다(`Self` 제네릭 때문 — `spikes/07`). `& T` 형태에서
|
||||
`Self`를 빼는 건 불가능하고(`self: any`는 탐색 기록의 `Store4`처럼 통째로
|
||||
깨진다), §0의 *"Luau의 한계를 우회하려고 타입/API를 비틀지 않는다"*에
|
||||
따라 표면을 비틀지 않는다.
|
||||
- **파생 결과에 명시 주석을 다는 상시 규약은 그대로 유효하다** —
|
||||
`audit/type-recursion-issue/REPORT.md` 3-1절이 실측(`43`)으로 확인한
|
||||
대로, 명시 주석 이후 **다운스트림 전체는 정상 체크**되고 구멍은 그 한
|
||||
줄(RHS가 실제로 그 타입인지)뿐이다.
|
||||
- **`__call` 경로는 죽었다** — 타입 레벨 `__call`은 `self`를 못 받고,
|
||||
`typeof(f<<T>>)`로 타입 인자를 넘기는 것도 실패한다(사용자 실측).
|
||||
그래서 Store를 콜러블로 만드는 안은 성립하지 않는다. 같은 사실이
|
||||
`base/source-state-plan.md`의 `:Apply`에도 적용된다(7라운드 `H-94`).
|
||||
|
||||
---
|
||||
|
||||
## 이 시도 이전의 원문은 archive에 없다
|
||||
|
||||
옛 `WrapStore`/`ProcessStoreType` 접근(2026-08-12~2026-08-15)은 `H-75`/`H-76`
|
||||
실측으로 폐기됐고, 그 폐기는 **철회되지 않았다** — `base/typing-limits.md`
|
||||
§5의 폐기 배너와 `luau-test/STATUS.md`의 `16`/`21` 항목이 소스다.
|
||||
`store.key = value` 폐기(2026-08-06)와 dot-access 1급 경로도 **현행**이라
|
||||
`base/store-plan.md`에 그대로 있다.
|
||||
|
|
@ -124,7 +124,7 @@ relate4의 살아있는 엔트리 총 개수: 0 (기대 0)
|
|||
| `13-type-ref-preref-subtype` | ✅ 통과 | `PreRef<T>`가 `Ref<T>` 자리에 대입 가능 — 진단 0건 |
|
||||
| `14-type-nilable-default-overload` | ⚠️ 부분통과 | 의도한 오용은 정확히 막지만 **정상 nilable 사용례까지 같이 막아** 현 스케치로는 채택 불가 |
|
||||
| `15-type-compute-trailing-deps-typepack` | ❌ 검증불가 | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 파일 전체가 파싱 실패. 다만 파서가 복구 후 낸 진단에서 **아래 1번 이슈**가 드러남 |
|
||||
| `16-type-store-key-typefunction` | ❌ 실패(당시) → **[2026-08-15] ✅ 통과로 복구** | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음(레코드 대신 배열을 넘기고 있었음) — 설계 문제 아니라 API 버전 드리프트였음이 나중에 확인됨. 상세: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절, `luau-test/done/16-type-store-key-typefunction.luau` |
|
||||
| `16-type-store-key-typefunction` | ❌ 실패(당시) → **[2026-08-15] ✅ 통과로 복구** | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음(레코드 대신 배열을 넘기고 있었음) — 설계 문제 아니라 API 버전 드리프트였음이 나중에 확인됨. 상세: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절, `luau-test/rewrite-required/16-type-store-key-typefunction.luau` |
|
||||
|
||||
## ⚠️ 실측으로 드러난 진짜 설계 이슈 — `:Compute(fn)`의 lazy 핸들 계약이 Luau 추론과 충돌
|
||||
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ quad 설계 문제가 아니라 **`setmetatable`+`typeof(genericFn<<T>>())`
|
|||
|
||||
## 6. `type function` — `Store<T>` 필드 합성은 고쳐짐, 재귀 `Compute`는 막다른 길
|
||||
|
||||
### 6-1. `luau-test/done/16-type-store-key-typefunction.luau` 복구 성공(원래 `rewrite-required/`)
|
||||
### 6-1. `luau-test/rewrite-required/16-type-store-key-typefunction.luau` 복구 성공(원래 `rewrite-required/`)
|
||||
|
||||
`spikes/09-typefunction-store-key-FIXED.luau` — 원래 스파이크가 깨진
|
||||
이유는 설계 문제가 아니라 **API 버전 드리프트**: `types.newfunction`의
|
||||
|
|
|
|||
201
.claude/audit/type-store-index-keyof/REPORT.md
Normal file
201
.claude/audit/type-store-index-keyof/REPORT.md
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
# 실측 — Store 타이핑을 `index<>`/`keyof<>`로 다시 짜기 (2026-08-25)
|
||||
|
||||
> **⛔⛔ [2026-08-25, 같은 날] 이 실측이 뒷받침하던 설계는 철회됐다.**
|
||||
> 여기 기록된 **측정값 자체는 전부 유효**하다(재현 가능). 다만 그 위에
|
||||
> 세웠던 결론 — "`index<>`/`keyof<>` + 팬텀 필드로 Store를 짓는다" — 은
|
||||
> 같은 날 철회됐고, 지금 Store는 **타입 함수를 안 쓰고** 타입 인자에
|
||||
> `Source<T>`를 직접 써서 평범한 레코드로 짓는다(`base/store-plan.md`).
|
||||
> 철회 이유는 `archive/store-value-field-redesign-withdrawn.md`,
|
||||
> 원칙은 `base/typing-limits.md` §0(*"타입 함수는 진단까지만"*).
|
||||
>
|
||||
> **그래도 이 문서를 남기는 이유**: (1) `H-73` 반증(`f<<T>>()`가 값
|
||||
> 호출부에서 동작)은 지금도 유효한 확정 사실이고, (2) 실패한 formulation
|
||||
> 표는 "다시 시도하지 말 것" 목록으로 계속 값을 하며, (3) 싱글톤 보존
|
||||
> 규칙과 `__call`이 죽은 경로라는 것도 Luau 자체의 성질이라 그대로다.
|
||||
> (4) `spikes/07`이 잰 **`Self` 제네릭이 콜백 파라미터 추론을 깬다**는
|
||||
> 것이 철회의 직접 근거 중 하나였다.
|
||||
|
||||
**무엇인가**: `WrapStore`/`ProcessStoreType` 타입 함수로 결과 타입을
|
||||
**합성**하던 Store 타이핑을 폐기하고 `index<>`/`keyof<>` + 팬텀 필드로
|
||||
다시 지을 수 있는지를 `luau-analyze`로 판정한 기록. **계획이 아니라
|
||||
실측 결과**다 — 지금 유효한 설계는 `base/store-plan.md`가 소스이고,
|
||||
뒤집힌 옛 모델은 `archive/store-value-field-redesign-withdrawn.md`.
|
||||
|
||||
**발단**: 7라운드 손 트레이싱 `H-75`/`H-76`이 `WrapStore` 접근의 두 한계를
|
||||
실측했고(평평한 합성이면 `store.key:Compute(무주석 콜백)`이 깨짐 /
|
||||
`type function`이 바깥 타입 별칭을 참조 못 해 구조를 통째로 중복 작성해야
|
||||
하고 self 파라미터 불변성 때문에 필드 하나만 어긋나도 대입 실패),
|
||||
**사용자가 직접 `test.luau`로 대안을 탐색**했다.
|
||||
|
||||
**⚠️ 스크립트를 같이 두는 예외적 구성** — 판정이 "여러 formulation 대조"라
|
||||
개별 파일을 직접 돌려야 재현된다(`audit/type-recursion-issue/`와 같은 이유).
|
||||
전부 `luau-analyze <파일>`로 돌린다. **[2026-08-25 기준] `.luaurc`가
|
||||
`languageMode: strict`이므로 진단이 이 프로젝트에 그대로 적용된다.**
|
||||
|
||||
| 파일 | 무엇을 보나 |
|
||||
|---|---|
|
||||
| `spikes/01-exploration.luau` | **사용자 탐색 원본**(루트 `test.luau`). `const` → `local`만 고쳤다. 주석에 적힌 기대 진단이 곧 판정이고, 아래 "실패한 formulation" 절이 이 파일을 읽는 지도다 |
|
||||
| `spikes/02-toplevel-form.luau` | 조작 표면을 **탑레벨 함수**로 뺀 형태 — 예약 키가 팬텀 하나로 줄어드는지 |
|
||||
| `spikes/03-value-index-form.luau` | **확정된 형태** — 타입 인자가 평범한 값 타입이고 `Of`가 `Source<>`를 씌움 |
|
||||
| `spikes/04-reserved-key-silent.luau` | 예약 키 충돌 시 **타입 검사가 조용히 죽는지** |
|
||||
| `spikes/05-checkreserved.luau` | `CheckReserved` 타입 함수가 그 침묵을 **시끄럽게** 만드는지 |
|
||||
| `spikes/06-generic-instantiation.luau` | `f<<T>>()`가 값 호출부·콜론 메소드에서 `T`를 실제로 묶는지(`H-73` 반증) |
|
||||
| `spikes/07-self-generic-inference.luau` | **[2026-08-25 추가]** 무주석 콜백 추론이 어느 구성에서 깨지는지 — `Self` 제네릭만 걸리고 `index<>`/`keyof<>`/`K` 제네릭은 안 걸린다 |
|
||||
|
||||
---
|
||||
|
||||
## 결론
|
||||
|
||||
### ✅ 성립 — 마법 타입 함수 없이 Store 전체가 타이핑된다
|
||||
|
||||
```lua
|
||||
type StoreOf<T> = {
|
||||
__store: T, -- 팬텀. 사용은 UB.
|
||||
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
|
||||
-> Source<index<index<Self, "__store">, K>>,
|
||||
Names: <Self>(self: Self) -> { keyof<index<Self, "__store">> },
|
||||
} & T
|
||||
type Store<T> = StoreOf<CheckReserved<T>>
|
||||
```
|
||||
|
||||
`spikes/03`의 양성 6건이 전부 진단 0건이고 음성 대조군 5건이 전부 정확히
|
||||
걸렸다(**[2026-08-25 정정]** 아래 표의 마지막 두 행 — `:Names()`와
|
||||
`Compute` — 은 `03`이 아니라 각각 `02`/`07`이 근거다):
|
||||
|
||||
| 검사 | 결과 |
|
||||
|---|---|
|
||||
| `store.hp` | `number` ✅ (음성 대조군 걸림) |
|
||||
| `store.hp = 5` / `= "five"` | 통과 / **걸림** ✅ |
|
||||
| `store:Of("hp")` | `Source<number>` ✅ (음성 대조군 걸림) |
|
||||
| `store:Of("name")`(`string?` 선언) | `Source<string?>` ✅ |
|
||||
| `store:Of("nope")` | **거부** ✅ |
|
||||
| `store:Names()` | `{ "hp" \| "name" }` ✅ |
|
||||
| `store:Of("hp"):Set(3)` / `:Set("3")` | 통과 / **걸림** ✅ |
|
||||
| `store:Of("hp"):Compute(무주석 콜백)` | ⚠️ **파라미터 주석 필요**(아래 캐비엇) |
|
||||
|
||||
**⚠️ [2026-08-25 정정] 이 표에 있던
|
||||
*"`store.key:Compute(무주석 콜백)` 파라미터 추론 정상 ✅"* 행은 **실측한 적이
|
||||
없고 거짓이었다**(`/code-review high`가 잡음 — `spikes/03`엔 `Compute`가
|
||||
아예 없다). 새 모델에서 `store.key`는 **값**이라 `:Compute`가 없고
|
||||
(`Type 'number' does not have key 'Compute'`), 파생은
|
||||
`store:Of("hp"):Compute(...)`로 만든다.
|
||||
|
||||
**정본 선언으로 다시 재보니 두 축이 갈린다**(`spikes/08`):
|
||||
|
||||
| 검사 | 결과 |
|
||||
|---|---|
|
||||
| `local hp = store:Of("hp")` → `hp:Get()` | **`number`** ✅ (무주석, `string` 대조군 걸림) |
|
||||
| `hp:NoSuchMethod()` | ✅ 걸림 |
|
||||
| `hp:Set(5)` / `hp:Set("5")` | 통과 / **걸림** ✅ |
|
||||
| `store:Of("name")`(`string?` 선언) | `Source<string?>` ✅ (`number` 대조군 걸림) |
|
||||
| `hp:Compute(function(s) … end)` | ❌ **콜백 파라미터**만 안 잡힘 |
|
||||
| `hp:Compute(function(s: StateData<number>) … end)` | ✅ 통과 |
|
||||
|
||||
- **반환값 추론은 무주석으로 정확하다** — `store:Of(k)` 자체엔 Store 특유의
|
||||
타입 문제가 **없다**.
|
||||
- **걸리는 건 `:Compute`/`:With`의 콜백 파라미터 하나**이고, 그건 Store가
|
||||
만든 게 아니라 `base/typing-limits.md` §1의 **문제 A** 그 자체다.
|
||||
해법도 그 문서가 정한 대로 파라미터 주석이고, 실측으로 통과한다.
|
||||
- §1②쪼개기가 평범한 `Source`에서 이 자리를 덮어주는 것과 달리 `Of`를
|
||||
거치면 안 걸린다(`spikes/07` — `Self` 제네릭). `& T` 형태에서 `Self`를
|
||||
빼는 건 불가능하다(`self: any`는 `Store4`처럼 통째로 깨진다). §0의
|
||||
*"Luau의 한계를 우회하려고 타입/API를 비틀지 않는다"*에 따라 표면을
|
||||
비틀지 않는다.
|
||||
- 파생 결과에 명시 주석을 다는 상시 규약은 그대로다 —
|
||||
`audit/type-recursion-issue/REPORT.md` 3-1절(`43`)이 확인한 대로 명시
|
||||
주석 이후 다운스트림 전체는 정상 체크된다.
|
||||
|
||||
별도로 확인한 것 하나: `:Compute`의 **반환 타입** 음성 대조군은 평범한
|
||||
`Source`에서도 안 걸리는데, 그건 이 Store 설계와 무관한 `Compute` 시그니처
|
||||
쪽 성질이다(과잉 주장하지 말 것).
|
||||
|
||||
### ⭐ 싱글톤 보존 규칙 — 어떤 형태가 `string`으로 안 뭉개지는가
|
||||
|
||||
이 설계 전체가 **`K`가 `"hp"` 같은 싱글톤으로 넘어온다**는 데 기대고 있어서
|
||||
따로 실측했다(`spikes/01`의 `singletonTest1`~`4`).
|
||||
|
||||
| 선언 | `f("bbb")`의 `T` | 진단 |
|
||||
|---|---|---|
|
||||
| `f<T>(input: T): T` | `string` ❌ 뭉개짐 | 없음 |
|
||||
| `f<T>(input: T & string): T` | `unknown` ❌ | 없음 |
|
||||
| `f<T>(input: T & ""): T` | `"bbb"` ✅ | **에러**(`"bbb"`가 `""`의 서브타입이 아님) |
|
||||
| **`f<T>(input: T \| "" \| string): T`** | `"bbb"` ✅ | **없음** ✅ |
|
||||
| **`f<T>(input: T & ("A" \| "B")): T`** | `"B"` ✅ | 없음(범위 밖이면 정확히 걸림) ✅ |
|
||||
|
||||
**규칙**: 타입 후보 중에 **싱글톤이 있으면** 추론이 `string`으로 안 뭉개고
|
||||
있는 그대로 넘긴다. `K & keyof<...>`가 바로 그 형태라(키 이름들의 싱글톤
|
||||
유니온) 의도대로 동작하고, **범위 밖 키는 교집합이 비어 정확히 거부된다.**
|
||||
사용자 관찰: *"K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로
|
||||
뭉개지지 않고 전해진다."*
|
||||
|
||||
### ⛔ 실패한 formulation — 왜 다른 모양은 안 되는가
|
||||
|
||||
`spikes/01`이 순서대로 밟은 막다른 길들. 다시 시도하지 말 것:
|
||||
|
||||
| 형태 | 무엇이 막았나 |
|
||||
|---|---|
|
||||
| `Store2<T>`: `__call: typeof(getter2<<T>>)` | **완전 실패** — `type function` 밖에서도 타입 별칭 인자를 `typeof`에 실어 나를 수 없다. `H-76`이 관측한 것과 같은 벽 |
|
||||
| `Store<T>`: `__call: <K>(K & keyof<T>) -> index<T, K>` | `Argument count mismatch. Function expects 1 argument, but 1 is specified` — **타입 레벨 `__call`은 `self`를 못 받는다**(인자 목록만 남음) |
|
||||
| `Store3<T>`: `Set`/`Peek`을 **점 호출**로 | 동작은 하나 `Set`의 시그니처만 봐선 뭘 넣어야 할지 감이 안 온다(사용자 판정). 값 타입을 알기 어려워 직접 인덱스를 허용해야 함 |
|
||||
| `Store4<T>`: `{ Peek: <K>(self: any, ...) } & T` | `Property 'any' does not exist on type '{...}'` — `& T`가 `self: any` 자리에 끼어들어 깨진다 |
|
||||
| `Store5<T>`: `self: Store5<Inner>`로 self를 명시 | `Peek`의 인자가 **에러 타입**이 된다 |
|
||||
| `Store6<T>`: **`__realtype` 팬텀 + `index<index<Self,"__realtype">, K>`** | ✅ **성립** — 이게 확정 형태의 원형이다 |
|
||||
|
||||
**`__call` 경로는 죽었다**는 게 이 표의 요지이고, 그 사실이
|
||||
`base/source-state-plan.md`의 `:Apply`(7라운드 `H-94`)와
|
||||
`base/gate-plan.md` 2번에 반영됐다 — 애플리커티브 팩토리는 `__call`이
|
||||
아니라 **지정된 필드**로 자기를 노출한다.
|
||||
|
||||
### ⚠️ 예약 키 충돌은 **조용히** 타입 검사를 끈다
|
||||
|
||||
`spikes/04`에서 `Store<{ Peek: number, __store: boolean }>`로 걸었더니
|
||||
**진단 0건**인데, 그 안엔 걸려야 할 음성 대조군이 있다:
|
||||
|
||||
```lua
|
||||
local g3: string = clash.Peek -- Peek 은 number 인데 진단 없음 ❌
|
||||
```
|
||||
|
||||
즉 사용자 키가 예약 이름과 겹치면 교집합이 뭉개져 **그 필드에 대한 타입
|
||||
검사가 통째로 죽는다.** 시끄럽게 막히는 게 아니라 그냥 지나간다.
|
||||
|
||||
### ✅ `CheckReserved`가 그 침묵을 없앤다
|
||||
|
||||
`spikes/05`에서 확인:
|
||||
|
||||
```
|
||||
TypeError: quad.Store: "Of" is a reserved key
|
||||
```
|
||||
|
||||
**`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼 버려진다.
|
||||
`print(...)` + `return types.never` 조합만 된다
|
||||
(`luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`가 기록해둔
|
||||
사실이고 `type-version-check`가 이미 쓰는 패턴). 이 타입 함수는 `T`를
|
||||
**검증만 하고 그대로 통과**시키므로 `H-75`/`H-76`이 지적한 합성의 두 한계에
|
||||
아예 안 닿는다.
|
||||
|
||||
### ✅ `f<<T>>()`가 값 호출부에서 동작한다 — `H-73` 반증
|
||||
|
||||
`spikes/06`:
|
||||
|
||||
```lua
|
||||
local ok: Source<number> = store:GetDynamic<<number>>("x") -- 진단 없음 ✅
|
||||
local bad: Source<string> = store:GetDynamic<<number>>("y") -- 정확히 걸림 ✅
|
||||
local none: Source<number> = store:GetDynamic("z") -- Source<unknown>
|
||||
```
|
||||
|
||||
7라운드 `H-73`은 **인스턴스화를 생략한 호출만** 돌려보고 *"Luau엔 호출부
|
||||
명시 타입 인자 문법이 없다"*로 단정했다. 실제로는 Luau의 **generic type
|
||||
instantiation**(`luau.org/types/generics/#generic-type-instantiation`)이
|
||||
값 호출부에서도, **콜론 메소드에서도** `T`를 묶는다. 그래서
|
||||
`base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 이 실측이 바꾼 문서
|
||||
|
||||
- `base/store-plan.md` — "Store = Source들의 이름 붙은 모음" / "Store 값 설정 문법" /
|
||||
"타입 추론 문제" / "`store.key` 레코드 필드 타이핑" 네 절 재작성
|
||||
- `base/typing-limits.md` — §5가 폐기 배너를 달았고, §7에 싱글톤/인스턴스화 항목 추가
|
||||
- `base/quad-types-plan.md` — 이중 꺾쇠 관례가 값 호출부까지 확장
|
||||
- `archive/store-value-field-redesign-withdrawn.md` — 뒤집힌 옛 모델 원문
|
||||
- `luau-test/STATUS.md` — 스파이크 `16`/`21`이 `rewrite-required/`로
|
||||
155
.claude/audit/type-store-index-keyof/spikes/01-exploration.luau
Normal file
155
.claude/audit/type-store-index-keyof/spikes/01-exploration.luau
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
--!strict
|
||||
-- [보존] 사용자가 2026-08-25에 직접 돌린 탐색 원본(루트 `test.luau`).
|
||||
-- `const` -> `local`만 고쳤고 나머지는 그대로다. 주석의 기대 진단이 곧 판정.
|
||||
|
||||
local function test2<K>(k: K & ("A" | "B"), v: number): K
|
||||
return nil :: any
|
||||
end
|
||||
|
||||
local t: "B" = test2("B", 1)
|
||||
-- TypeError: Expected this to be '"A"', but got '"B"'
|
||||
local t: "A" = test2("B", 1) -- 에러 발생
|
||||
|
||||
local function test3<K>(k: K & ("A" | "B")): index<{ A: number }, K>
|
||||
return nil :: any
|
||||
end
|
||||
|
||||
-- number
|
||||
local a = test3("A")
|
||||
-- TypeError: Expected this to be 'string', but got 'number'Luau1000
|
||||
local a: string = test3("A")
|
||||
|
||||
local function test4<S, K>(s: S, k: K & ("A" | "B")): index<S, K>
|
||||
return nil :: any
|
||||
end
|
||||
|
||||
-- boolean
|
||||
local b = test4({ A = true}, "A")
|
||||
|
||||
|
||||
-- now we should test index<Self...>
|
||||
local function getter<Self, K>(self: Self, key: K & (keyof<index<Self, "__realtype">>)): index<index<Self, "__realtype">, K>
|
||||
return nil :: any
|
||||
end
|
||||
|
||||
local me = {
|
||||
__realtype = {
|
||||
name = true
|
||||
},
|
||||
getter = getter
|
||||
}
|
||||
-- name 이 자동완성됩니다. 게다가
|
||||
me:getter("name")
|
||||
local t = me:getter("name") -- boolean
|
||||
-- 음성 대조해보면. 이렇습니다
|
||||
local t: string = me:getter("name")
|
||||
|
||||
-- __call 에 대해서 시도해보죠
|
||||
local mock = (nil :: any) :: setmetatable<{
|
||||
__realtype: {
|
||||
name: boolean,
|
||||
}
|
||||
}, {
|
||||
__call: typeof(getter),
|
||||
}>
|
||||
-- mock() -- self 를 받지 못하네요. __call 은 타입에 인자만 남아야하나봅니다.
|
||||
|
||||
type Store<T> = setmetatable<{
|
||||
}, {
|
||||
__call: <K>(K & keyof<T>) -> index<T, K>
|
||||
}>
|
||||
local sinst = (nil :: any) :: Store<{
|
||||
a: boolean,
|
||||
b: number,
|
||||
}>
|
||||
-- TypeError: Argument count mismatch. Function expects 1 argument, but 1 is specifiedLuau1008
|
||||
-- 이해가 안가지만, 아마 어디선가 문제를 일으키나보네요
|
||||
local b = sinst("a")
|
||||
|
||||
-- 완전히 실패합니다. 이전에 확인한것과 같게, T는 넘어갈 수 없다는 논리
|
||||
local function getter2<Self, K>(key: K & (keyof<Self>)): index<Self, K>
|
||||
return nil :: any
|
||||
end
|
||||
type Store2<T> = setmetatable<{
|
||||
}, {
|
||||
__call: typeof(getter2<<T>>)
|
||||
}>
|
||||
local sinst2 = (nil::any)::Store2<{
|
||||
a: boolean, b: number
|
||||
}>
|
||||
sinst2()
|
||||
|
||||
-- 따라서 이것이 최선일 수 있어요
|
||||
type Store3<T> = {
|
||||
Set: <K>(key: K & keyof<T>, value: index<T, K>) -> Store3<T>,
|
||||
Peek: <K>(key: K & keyof<T>) -> index<T, K>,
|
||||
}
|
||||
local sinst3 = (nil::any)::Store3<{
|
||||
test: boolean
|
||||
}>
|
||||
-- 에러가 남. 다만, set 의 시그니처 보기로는 뭘 넣어야할지 바로 감이 안 잡힘.
|
||||
sinst3.Set("test", 1) -- test 가 자동완성됩니다
|
||||
-- Set 에 있어서는, 값이 뭔지 알기 힘들기에 직접 인덱스를 허용해야함.
|
||||
|
||||
|
||||
type Store4<T> = {
|
||||
Peek: <K>(self: any, key: K & keyof<T>) -> index<T, K>,
|
||||
} & T
|
||||
local sinst4 = (nil :: any) :: Store4<{
|
||||
test: boolean
|
||||
}>
|
||||
-- TypeError: Property 'any' does not exist on type '{ test: boolean }'Luau1038
|
||||
-- T 가 들어간 것이 self: any 자리에 들어가니, 에러가 납니다
|
||||
sinst4:Peek("test")
|
||||
|
||||
|
||||
type Store5<T> = {
|
||||
Peek: <Inner, K>(self: Store5<Inner>, key: K & keyof<Inner>) -> index<Inner, K>,
|
||||
} & T
|
||||
-- peek 의 인자가 에러타입이 됨
|
||||
local sinst5 = (nil :: any) :: Store5<{
|
||||
test: boolean
|
||||
}>
|
||||
|
||||
|
||||
type Store6<T> = {
|
||||
__realtype: T,
|
||||
Peek: <Self, K>(self: Self, key: K & keyof<index<Self, "__realtype">>) -> index<index<Self, "__realtype">, K>,
|
||||
} & T
|
||||
-- peek 의 인자가 에러타입이 됨
|
||||
local sinst6 = (nil :: any) :: Store6<{
|
||||
test: boolean
|
||||
}>
|
||||
-- TypeError: Expected this to be 'number', but got 'boolean'Luau1000
|
||||
local aaa: number = sinst6:Peek("test")
|
||||
sinst6:Peek("bbb") -- TypeError: Property '"bbb"' does not exist on type '{ test: boolean }'
|
||||
|
||||
-- 이것으로 남는 결론: K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로 뭉개지지 않고 전해진다.
|
||||
-- K & (...) 상태에서 bbb 를 넣을 순 있음. 그치만 잘 에러가 남. 보면 그대로 index 함수로 잘 싱글톤으로 들어간다는게 이상하게 보이는 부분.
|
||||
|
||||
local function singletonTest1<T>(input: T): T
|
||||
return input
|
||||
end
|
||||
local singletonTest1v = singletonTest1("bbb") -- string
|
||||
|
||||
local function singletonTest2<T>(input: T & string): T
|
||||
return input
|
||||
end
|
||||
local singletonTest2v = singletonTest2("bbb") -- unknown
|
||||
|
||||
local function singletonTest2<T>(input: T & ""): T
|
||||
return input
|
||||
end
|
||||
local singletonTest2v = singletonTest2("bbb") -- "bbb"
|
||||
-- TypeError: Expected this to be '"" & "bbb"', but got '"bbb"';
|
||||
-- the 2nd component of the intersection is `""`, and `"bbb"` is not a subtype of `""`
|
||||
-- **여기서 얻는점: 타입 후보 중에서 싱글톤이 있으면, 싱글톤으로 들어간다. 아마도
|
||||
-- 이것은 싱글톤 중에서 메치를 해야하니, 있는 그대로 string 으로 뭉개지 않고 bbb 로 넘기는듯
|
||||
-- 싱글톤으로 넘기는 조건이 있다는것이다
|
||||
|
||||
local function singletonTest3<T>(input: T | "" | string): T
|
||||
return input
|
||||
end
|
||||
local singletonTest3v = singletonTest3("bbb") -- "bbb", 에러도 없다.
|
||||
local singletonTest4v: "a" = singletonTest3("bbb") -- TypeError: Expected this to be '"a"', but got '"bbb"'
|
||||
-- 확실하다.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
--!strict
|
||||
type Source<T> = { Value: T, Set: (self: Source<T>, v: T) -> Source<T> }
|
||||
|
||||
-- 예약 키를 팬텀 하나로 줄인다. Peek/Set/Names 는 전부 탑레벨.
|
||||
type Store<T> = { __store: T } & T
|
||||
|
||||
local function peek<Self, K>(s: Self, key: K & keyof<index<Self, "__store">>)
|
||||
: index<index<index<Self, "__store">, K>, "Value">
|
||||
return nil :: any
|
||||
end
|
||||
local function names<Self>(s: Self): { keyof<index<Self, "__store">> }
|
||||
return nil :: any
|
||||
end
|
||||
|
||||
local store = (nil :: any) :: Store<{ hp: Source<number>, name: Source<string> }>
|
||||
|
||||
local f: Source<number> = store.hp -- 직접 접근
|
||||
local g: Source<string> = store.hp -- 음성 대조군
|
||||
|
||||
local a: number = peek(store, "hp")
|
||||
local b: string = peek(store, "hp") -- 음성 대조군
|
||||
local c: string = peek(store, "name")
|
||||
local d = peek(store, "nope") -- 음성 대조군
|
||||
local n: { "hp" | "name" } = names(store)
|
||||
|
||||
-- 사용자가 Peek / Set / Names 를 키로 써도 되는가
|
||||
local free = (nil :: any) :: Store<{ Peek: Source<number>, Set: Source<boolean>, Names: Source<string> }>
|
||||
local p: number = peek(free, "Peek")
|
||||
local q: boolean = peek(free, "Set")
|
||||
local w: string = peek(free, "Names")
|
||||
local z: number = peek(free, "Set") -- 음성 대조군
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
--!strict
|
||||
type Source<T> = { Value: T, Get: (self: Source<T>) -> T, Set: (self: Source<T>, v: T) -> Source<T> }
|
||||
|
||||
-- T 는 평범한 값 타입. __index/__newindex 가 Get/Set 을 대행하고,
|
||||
-- Peek 이 Source<> 를 씌워 준다.
|
||||
type Store<T> = {
|
||||
__store: T, -- 팬텀. 사용은 UB.
|
||||
Peek: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
|
||||
-> Source<index<index<Self, "__store">, K>>,
|
||||
} & T
|
||||
|
||||
local store = (nil :: any) :: Store<{ hp: number, name: string }>
|
||||
|
||||
-- (1) __index → 값
|
||||
local a: number = store.hp
|
||||
local b: string = store.hp -- 음성 대조군
|
||||
|
||||
-- (2) __newindex → 대입
|
||||
store.hp = 5
|
||||
store.hp = "five" -- 음성 대조군
|
||||
|
||||
-- (3) Peek → Source<값>
|
||||
local c: Source<number> = store:Peek("hp")
|
||||
local d: Source<string> = store:Peek("hp") -- 음성 대조군
|
||||
local e: Source<string> = store:Peek("name")
|
||||
local f = store:Peek("nope") -- 음성 대조군
|
||||
|
||||
-- (4) Peek 결과를 반응형으로 씀
|
||||
store:Peek("hp"):Set(3)
|
||||
store:Peek("hp"):Set("3") -- 음성 대조군
|
||||
|
||||
-- (5) 사용자가 Peek 을 자기 키로 쓰면
|
||||
local clash = (nil :: any) :: Store<{ Peek: number }>
|
||||
local g = clash:Peek("Peek")
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
--!strict
|
||||
type Source<T> = { Value: T, Get: (self: Source<T>) -> T, Set: (self: Source<T>, v: T) -> Source<T> }
|
||||
type Store<T> = {
|
||||
__store: T,
|
||||
Peek: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
|
||||
-> Source<index<index<Self, "__store">, K>>,
|
||||
} & T
|
||||
|
||||
-- 사용자 키 이름이 예약 이름과 겹칠 때
|
||||
local clash = (nil :: any) :: Store<{ Peek: number, __store: boolean }>
|
||||
local g1: Source<number> = clash:Peek("Peek") -- 메소드로 부를 수 있나 / 무엇이 나오나
|
||||
local g2: number = clash.Peek -- 필드로 읽으면
|
||||
local g3: string = clash.Peek -- 음성 대조군
|
||||
local g4 = clash:Peek("__store") -- 팬텀 이름을 키로
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
--!strict
|
||||
type Source<T> = { Value: T, Get: (self: Source<T>) -> T, Set: (self: Source<T>, v: T) -> Source<T> }
|
||||
|
||||
type function CheckReserved(t: type): type
|
||||
if t:is("table") then
|
||||
for k in t:properties() do
|
||||
if k:is("singleton") then
|
||||
local name = k:value()
|
||||
if name == "Of" or name == "__store" then
|
||||
print("quad.Store: \"" .. tostring(name) .. "\" is a reserved key")
|
||||
return types.never
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
type StoreOf<T> = {
|
||||
__store: T,
|
||||
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
|
||||
-> Source<index<index<Self, "__store">, K>>,
|
||||
} & T
|
||||
type Store<T> = StoreOf<CheckReserved<T>>
|
||||
|
||||
-- 정상
|
||||
local good = (nil :: any) :: Store<{ hp: number, name: string? }>
|
||||
local a: number = good.hp
|
||||
local b: string? = good.name
|
||||
local c: Source<number> = good:Of("hp")
|
||||
local d: Source<string?> = good:Of("name")
|
||||
local e: Source<number> = good:Of("name") -- 음성 대조군
|
||||
|
||||
-- 예약 키 충돌 → never 로 무너뜨림
|
||||
local bad = (nil :: any) :: Store<{ Of: number }>
|
||||
local f = bad.Of
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
--!strict
|
||||
type Source<T> = { Value: T, Revision: number }
|
||||
type Store = {
|
||||
GetDynamic: <T>(self: Store, name: string) -> Source<T>,
|
||||
[string]: any,
|
||||
}
|
||||
|
||||
local store = (nil :: any) :: Store
|
||||
|
||||
-- 양성: 명시 인스턴스화가 T를 묶는가
|
||||
local ok: Source<number> = store:GetDynamic<<number>>("x")
|
||||
-- 음성 대조군: 묶였다면 여기서 반드시 걸려야 한다
|
||||
local bad: Source<string> = store:GetDynamic<<number>>("y")
|
||||
-- 인스턴스화 생략하면?
|
||||
local none: Source<number> = store:GetDynamic("z")
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
--!strict
|
||||
type State<T> = { Value: T, Get: (self: State<T>) -> T,
|
||||
Compute: <U>(self: State<T>, fn: (v: T) -> U) -> State<U> }
|
||||
type Source<T> = State<T> & { Set: (self: Source<T>, v: T) -> Source<T> }
|
||||
type Rec = { hp: number }
|
||||
|
||||
-- 1) 제네릭 없이 그냥 Source<number> 를 주는 함수
|
||||
local function direct(): Source<number> return nil :: any end
|
||||
local r1 = direct():Compute(function(v) return v + 1 end)
|
||||
|
||||
-- 2) index<> 만 거친 것 (Self 없음, K 없음)
|
||||
local function viaIndex(): Source<index<Rec, "hp">> return nil :: any end
|
||||
local r2 = viaIndex():Compute(function(v) return v + 1 end)
|
||||
|
||||
-- 3) K 제네릭 + index<T,K> (Self 없음)
|
||||
local function viaK<K>(key: K & keyof<Rec>): Source<index<Rec, K>> return nil :: any end
|
||||
local r3 = viaK("hp"):Compute(function(v) return v + 1 end)
|
||||
|
||||
-- 4) Self 제네릭까지 (지금 확정 형태)
|
||||
type Store<T> = { __store: T,
|
||||
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
|
||||
-> Source<index<index<Self, "__store">, K>> } & T
|
||||
local store = (nil :: any) :: Store<Rec>
|
||||
local r4 = store:Of("hp"):Compute(function(v) return v + 1 end)
|
||||
|
||||
-- 5) 중간 변수에 명시 주석을 달면?
|
||||
local s5: Source<number> = store:Of("hp")
|
||||
local r5 = s5:Compute(function(v) return v + 1 end)
|
||||
|
||||
-- 6) 콜백 파라미터에 주석
|
||||
local r6 = store:Of("hp"):Compute(function(v: number) return v + 1 end)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
--!strict
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
export type State<T> = StateData<T> & {
|
||||
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||
}
|
||||
export type Source<T> = State<T> & { Value: T, Set: (self: Source<T>, v: T) -> Source<T> }
|
||||
type Rec = { hp: number, name: string? }
|
||||
type Store<T> = {
|
||||
__store: T,
|
||||
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
|
||||
-> Source<index<index<Self, "__store">, K>>,
|
||||
} & T
|
||||
local store = (nil :: any) :: Store<Rec>
|
||||
|
||||
-- (A) 반환값 무주석 추론 — 먹히는가?
|
||||
local hp = store:Of("hp")
|
||||
local a1: number = hp:Get() -- 통과해야 함
|
||||
local a2: string = hp:Get() -- 음성 대조군: 걸려야 함
|
||||
local a3 = hp:NoSuchMethod() -- 음성 대조군: 걸려야 함
|
||||
hp:Set(5)
|
||||
hp:Set("5") -- 음성 대조군: 걸려야 함
|
||||
|
||||
-- (B) name 은 string? 인가
|
||||
local nm = store:Of("name")
|
||||
local b1: string? = nm:Get()
|
||||
local b2: number = nm:Get() -- 음성 대조군
|
||||
|
||||
-- (C) Compute 콜백 파라미터 무주석 (문제 A)
|
||||
local c1 = hp:Compute(function(s) return s:Get() * 2 end)
|
||||
|
||||
-- (D) 콜백 파라미터에 주석을 달면
|
||||
local d1 = hp:Compute(function(s: StateData<number>) return s:Get() * 2 end)
|
||||
|
|
@ -262,8 +262,9 @@ quad/
|
|||
│ └── src/
|
||||
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
|
||||
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행)/`:Gate`(`GateNode`, `ComputeNode`와 같은 층위 — `base/gate-plan.md`) 전부 여기 소속
|
||||
│ ├── Observer.luau # ⭐ [2026-08-25 신설, 7라운드 `H-99`] `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()` 전역 레지스트리의 소유 모듈** — `EpochMap.luau`와 같은 이유로 `State.luau`에 묻지 않는다(`Effect`/`Gate`/leaf 핸들러가 전부 이 레지스트리를 본다)
|
||||
│ ├── EpochMap.luau # 재사용 가능한 Epoch 부기 객체(`:Update`/`:Refresh`/`:Sync`/`:TrackFrom`) — `State.luau`에 묻지 않고 별도 모듈, `GateNode`/`State`/`Effect`가 전부 씀(`base/state-epoch-plan.md`)
|
||||
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환
|
||||
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환(**평범한 레코드 필드** — 타입 함수 안 씀). **[2026-08-25]** 생성은 **명시적 초기화**(타입 인자에 `Source<T>` 직접, `defaults`에도 `Source(v)` 직접 — 옛 lazy `__index` 폐기), 동적 키는 `:Of<<T>>(name)` 하나(옛 `GetDynamic` 흡수), `:Names()`, 예약 키 진단용 `CheckReserved`(`base/store-plan.md`)
|
||||
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`) — 위 `state:Gate`의 `GateNode` 위에 얹히는 **정책**, 바닥부터 짜지 않음
|
||||
│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`)
|
||||
│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`)
|
||||
|
|
@ -316,6 +317,53 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로
|
|||
확정을 막던 항목도 아니었음(`purity-and-effects-plan.md`/`tween-plan.md`는
|
||||
이미 `base/`로 승격 완료).
|
||||
|
||||
## error 계약 — `level` 이분과 메시지 언어 (2026-08-25 확정, 7라운드 `H-104`/`H-105`)
|
||||
|
||||
quad가 던지는 error 자리는 약 29곳이고(`base/` 전수), **쓰기 전에 정해두면
|
||||
한 번에 맞고 나중에 바꾸면 전수를 다시 훑어야 한다.**
|
||||
|
||||
- **⭐ `level` 이분** — *즉시 error*가 quad의 주 방어선인데, 지금까지 **한
|
||||
군데도 `level` 인자가 없어** 전부 quad 내부 줄을 가리켰다. 사용자는 자기
|
||||
코드 어디서 틀렸는지를 볼 수 없었다.
|
||||
|
||||
| 종류 | `level` | 예 |
|
||||
|---|---|---|
|
||||
| **사용자 입력 검증** | **2**(호출부를 가리킴) | deps 타입/`nil`, 이중 바인드, 예약 키, 요소 타입 |
|
||||
| **내부 불변식 위반** | **1**(그 자리를 가리킴) | 부기가 깨짐(`lengthList[i] == nil` 등) |
|
||||
|
||||
```lua
|
||||
error("Effect: dep #3 is not a State/Source/Ref", 2)
|
||||
error("Dispatch.getOffsetAt: lengthList[3] is nil — bookkeeping is broken", 1)
|
||||
```
|
||||
|
||||
내부 불변식 위반은 곧 **quad 자신의 버그**라, 호출부가 아니라 터진 자리를
|
||||
가리켜야 리포트가 쓸모 있다.
|
||||
- **⭐ 메시지는 영어로 통일한다**(**사용자 확정**, 2026-08-25). 지금
|
||||
코퍼스는 영어 6 / 한국어 약 23으로 이미 갈려 있고, 공개 표면인데
|
||||
정해진 적이 없었다. `.claude/conventions.md`의 *"사용자가 보게 될 것은
|
||||
한국어"*는 **이 프로젝트의 대화·문서** 규칙이고 **quad 라이브러리
|
||||
사용자**에게까지 적용된다고 정해진 적이 없다 — 여기서 정한다. 이미
|
||||
영어인 6곳(동적 경로 가드 4형제, 모듈 초기화, attribute 이름 충돌)이
|
||||
핵심 경로라는 것도 같은 방향이다. `base/`의 예시 메시지도 영어로 쓴다.
|
||||
|
||||
## 예외 안전성 계약 — 감싸지 않는다 (2026-08-25 확정, 7라운드 🅒)
|
||||
|
||||
**예외가 나면 그 파동/그 자리의 부기는 복구되지 않는다.** `pcall`로
|
||||
감싸는 자리는 하나도 두지 않는다.
|
||||
|
||||
- 2026-08-21에 `base/slot-plan.md`의 `materializeSlotTree`에 대해 내린
|
||||
판단(*"마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 … 아직
|
||||
실제로 밟은 적 없는 경로다 … 실제로 물리면 그때 넣는다"*)을 **전 자리로
|
||||
확장**한 것이다. `.claude/conventions.md`의 *"드문 오용이나 가상의 미래
|
||||
요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다"* 원칙 그대로.
|
||||
- **적용 자리 넷**(7라운드가 찾은 것): State 전파 루프(구독자당 hot path),
|
||||
게이트 flush + `Blocker:Off()` 순회, `Dispatch.drive`의 배치 게이팅,
|
||||
Dispatch 체인 슬롯. 각각의 실패 모드는 그 문서들이 적는다.
|
||||
- **같은 원칙이 `EffectHandle:Rerun()`에도 적용된다** — `fn`이 던지면
|
||||
`_running`이 참으로 남는 것까지 포함해 복구하지 않는다(사용자: *"에러가
|
||||
난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작"*).
|
||||
- **yield 금지와 같은 톤으로 사용자 문서에 명시할 것.**
|
||||
|
||||
## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설)
|
||||
|
||||
지금까지 각 문서가 예시 코드를 쓰며 암묵적으로 따라온 패턴을 사용자가
|
||||
|
|
@ -442,15 +490,20 @@ CollectionService 태그 등)를 흉내낼 필요가 없고, quad-base 자체
|
|||
## Store/State/Source 온톨로지 — 확정됨 (요약)
|
||||
|
||||
Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로
|
||||
접근하면 이미 만들어져 있는 Source 객체를 그대로 반환하거나(defaults로
|
||||
Store 생성 시 미리 만들어둔 경우), 아직 없으면 그 자리에서 만들어 저장한
|
||||
뒤 반환한다(별도 wrapper 없음 — **[2026-08-06 후속 세션 정정, 2026-08-07
|
||||
추가 정정]** 원래 "매번 새 State를 감싸 반환"이었으나, `Source`가 구조적으로
|
||||
`State`를 만족하도록 재구성되며 wrapper 계층 자체가 불필요해짐. 이후
|
||||
"Store 생성 시 전부 eager하게만 만들어진다"로 한 차례 더 정리됐다가, Luau
|
||||
타입이 런타임에 강제되지 않아 defaults 없이 만든 키를 나중에 `:Set()`하면
|
||||
크래시난다는 점이 지적돼 lazy `__index`+저장 생성도 같이 필요함이 확인됨 —
|
||||
상세는 `base/source-state-plan.md` "Source가 State를 만족함" 절). 전파는
|
||||
접근하면 **그 키의 Source 객체를 그대로 반환한다** — 별도 wrapper도,
|
||||
프록시도, **타입 함수도** 없다(`Source`가 구조적으로 `State`를 만족하도록
|
||||
재구성되며 wrapper 계층 자체가 불필요해짐 — `base/source-state-plan.md`의
|
||||
"Source가 State를 만족함" 절).
|
||||
**⭐ [2026-08-25] 두 가지가 확정됐다** — (1) 생성은 **명시적 초기화**다:
|
||||
타입 인자에 `Source<T>`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접
|
||||
넣는다. 옛 lazy `__index`(없는 키를 그 자리에서 만들어 저장)는 폐기됐고,
|
||||
그래서 `defaults`가 곧 선언 키 집합이라 `store:Names()`가 성립한다.
|
||||
(2) 레코드 필드 타이핑에 **타입 함수를 안 쓴다**
|
||||
(`WrapStore`/`ProcessStoreType` 폐기). 쓰기는 `store.key = v`가 아니라
|
||||
**`store.key:Set(v)`**이고, 동적 키는 `store:Of<<T>>(name)` 하나다.
|
||||
상세는 `base/store-plan.md`가 소스 — 같은 날 "`store.key`를 값으로"
|
||||
재설계를 넣었다가 철회한 경위는
|
||||
`archive/store-value-field-redesign-withdrawn.md`. 전파는
|
||||
push-invalidate(신호만)/
|
||||
pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드
|
||||
의존성 중복 재계산 문제가 풀림(**[2026-08-14 보강]** 푸는 주체는
|
||||
|
|
|
|||
|
|
@ -260,6 +260,17 @@ end
|
|||
- **`nameClaims`와의 순서**: 위치 claim이 **먼저**다 — 같은 그룹의 두 번째
|
||||
위치는 이름 claim까지 갈 것 없이 그 자리에서 거부되어야 하고, 그래야
|
||||
`nameClaims`에 절반만 기록되는 중간 상태가 안 생긴다.
|
||||
- **⚠️⚠️ [2026-08-25 신설, `/code-review high`] GC 계약 — 그룹 값
|
||||
객체(`groupValue`)는 `inst`를 되참조하면 안 된다.** 이 값은
|
||||
`groupClaimKeys`의 **내부 키**로 쓰이는데, `Relate`의 내부 키는
|
||||
`SetStrong`/`SetWeak` **어느 쪽이든 항상 강하다**(Luau엔 ephemeron이
|
||||
없다 — `base/relate-plan.md`의 "위험한 패턴" 절 슬롯 표). 그래서 값이
|
||||
`inst`로 되돌아가면 `buckets`의 weak 키가 자기 버킷을 통해 살아남아
|
||||
**그 `inst`가 영원히 안 죽는다.** 실제로 밟기 어려운 모양은 아니다 —
|
||||
같은 트리의 `Ref`가 그 `inst`로 채워진 뒤 그 Store에 담기면 된다.
|
||||
**되참조가 필요해지면 내부 키를 값 객체가 아니라 이름/토큰으로
|
||||
바꿔야 한다**(값 객체는 그때 값 슬롯으로 내려가고, 그 슬롯은
|
||||
`SetWeak`으로 도망갈 수 있다). 지금은 그 요구가 없어 계약으로 둔다.
|
||||
- 이걸로 `Frame { a, a }` 갭(구 `AT-11`/5라운드 `AT-2`)도 **같이 닫힌다.**
|
||||
- **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게
|
||||
**정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`)
|
||||
|
|
@ -354,6 +365,12 @@ Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러
|
|||
Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error
|
||||
Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김
|
||||
attr:NameMap(): {[string]: Source<any>} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것)
|
||||
-- ⭐ [2026-08-25, 7라운드 `H-79`] `:NameMap()`이 Store를 평탄화하려면 **Store가
|
||||
-- 자기 키 집합을 알아야 한다** — 그 표면이 없어서 키 집합이 접근 이력에
|
||||
-- 좌우되고 있었다(lazy `__index` 시절엔 0개/1개/2개로 갈렸다). Store가
|
||||
-- **명시적 초기화**로 바뀌며 `defaults`가 곧 선언 키 집합이 됐고,
|
||||
-- `store:Names()`가 그걸 준다(`base/store-plan.md`). `:NameMap()`은 각
|
||||
-- Store에 대해 `store:Names()`를 돌며 `store[name]`을 모은다.
|
||||
-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 자신의 이름 겹침 정책:
|
||||
-- Attribute(a, b)에서 a와 b가 같은 이름을 가지면 **뒤에 온 인자가 이긴다**
|
||||
-- (= Overridden과 같은 정책, error 아님). Merged의 "겹치면 error"는 그 함수만의
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치
|
|||
`:Subscribe()`/`:Unsubscribe()`, 이중 바인딩 금지 게이트 →
|
||||
**`base/source-state-plan.md`**.
|
||||
- **Store** — 이름 붙은 Source 모음, `defaults`와 eager/lazy 생성, `store.key`
|
||||
dot-access 타이핑(+Luau `type function`), `store.key:Set(value)` 문법,
|
||||
dot-access 타이핑(**[2026-08-25]** `type function` 합성은 폐기 —
|
||||
타입 인자에 `Source<T>`를 직접 써서 **평범한 레코드**로 짓는다,
|
||||
`base/store-plan.md`), `store.key:Set(value)` 문법,
|
||||
"Store가 Store를 저장 가능한가", Store 부작용 정책 → **`base/store-plan.md`**.
|
||||
- **`Ref` / `PreRef`** — 용도 재정의, `.Value`/`:Set`/`:Callback`/`:Wait` API,
|
||||
`Ref`의 retract, PreRef 호이스팅/1회용 가드 → **`base/ref-plan.md`**.
|
||||
|
|
@ -239,10 +241,12 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근
|
|||
**Store 쪽 dot-access는 그대로 유지**: `store.key`는 실질적으로 Luau가
|
||||
타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source<number>, ...}`류
|
||||
평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 유지.
|
||||
**[2026-08-25]** 그 레코드를 **타입 함수로 합성하던 것만** 폐기됐다 —
|
||||
지금은 타입 인자에 `Source<T>`를 직접 쓴다.
|
||||
**[정정, 2026-08-18] `store "key"` 문자열 커링은 기각됐다** — 여기 폴백으로
|
||||
같이 적혀 있었으나 폐기됨(`"a"`가 그냥 `string`으로 들어가 `Source<T>`의
|
||||
`T`를 알 수 없고, dot-access + `type function` 타이핑이 자리잡아 더 이상
|
||||
필요 없어짐). 동적 키는 명시적 `store:GetDynamic<<T>>(name)`으로 간다 —
|
||||
필요 없어짐). 동적 키는 명시적 `store:Of<<T>>(name)`으로 간다(**[2026-08-25]** 옛 이름 `GetDynamic`) —
|
||||
`base/store-plan.md`가 소스.
|
||||
**이벤트가 이 관습의 예외인 성격도 바뀜** — "타입을 포기하는 예외"가 아니라
|
||||
**이름 지정 방식만 문자열 키인 예외**다(타입은 위 정정대로 생성기가 준다).
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를
|
|||
|
||||
state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에
|
||||
-- 처음 블록될 때가 아니라) onunblock 핸들을
|
||||
-- blocker의 weak 배열에 등록.
|
||||
-- blocker의 weak-키 셋에 등록(아래 "onunblock 핸들 보관").
|
||||
blocker:Policy(emit) -> onUpstreamEmit
|
||||
-- [2026-08-24 신설] 이 blocker의 게이트 정책을
|
||||
-- **값으로** 돌려준다. `state:Block(b)`가
|
||||
|
|
@ -98,6 +98,30 @@ gated state의 동작:
|
|||
`blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지
|
||||
정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다.
|
||||
|
||||
**⭐⭐ [2026-08-25 신설, 7라운드 `H-63`] onunblock 핸들 보관 — 세 자리를
|
||||
정한다.** 지금까지 *"blocker의 weak 배열"*이라고만 적혀 있었고 셋이
|
||||
비어 있었다.
|
||||
|
||||
1. **자료구조는 weak-키 해시맵 셋** `{[handle] = true}`(`__mode = "k"`)이다.
|
||||
값-weak **배열**이면 항목이 수거될 때 구멍이 생겨 `ipairs`가 첫 구멍에서
|
||||
멈추고(`#`도 border 미정) **뒤의 살아있는 게이트가 `Off()`를 못 받는다.**
|
||||
`H-7`이 `Ref.Callbacks`를 배열에서 해시맵 셋으로 바꾼 것과 같은 문제이고
|
||||
같은 처방이다.
|
||||
2. **⭐ 핸들을 강하게 드는 주체는 정책이 반환하는 `onUpstreamEmit` 클로저다** —
|
||||
그 클로저가 onunblock 핸들을 upvalue로 잡는다. 체인은
|
||||
`GateNode → onUpstreamEmit → onunblock 핸들`이고, Blocker 쪽은 weak이라
|
||||
**게이트 노드가 죽을 때만** 수거된다. 이게 안 정해져 있으면
|
||||
`Debounce`의 `setup`을 문서 그대로 짰을 때 핸들이 다음 GC에서 사라져
|
||||
`b:Off()`가 **조용히 아무것도 안 한다**(디바운스가 영영 안 나감) —
|
||||
실패가 GC 타이밍에 따라 간헐적이라 나중에 잡기 제일 어려운 종류다.
|
||||
- **Blocker가 강하게 드는 안은 기각** — 오래 사는 Blocker 하나가 거기
|
||||
걸렸던 **모든 gated state와 그 상류 체인을 영원히 살려둔다**(`:List`
|
||||
항목마다 게이트를 무는 패턴에서 직행 누수).
|
||||
3. **`Off()`/`OffWithoutEmit()`은 스냅샷을 뜬 뒤 순회한다** — 순회 중
|
||||
새 등록(핸들 → flush → 하류 Observer가 `state:Block(b)`를 새로 만듦)이
|
||||
`pairs`에서 미정의이기 때문. `Ref.Callbacks`/State 구독자 집합과 같은
|
||||
처방(`base/source-state-plan.md`의 `H-23` 확정).
|
||||
|
||||
**⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로
|
||||
꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고,
|
||||
`Blocker()` 생성자와 `state:Block`은 그대로다.
|
||||
|
|
@ -111,7 +135,15 @@ gated state의 동작:
|
|||
풀 때 그 `emit`이 정확히 1회 불린다.
|
||||
- **`Debounce`/`Throttle`은 Blocker를 사적으로 하나 갖는다** — 적용 핸들당
|
||||
하나(커링 결과가 여러 곳에 적용될 수 있으므로 `Apply` 시점 생성).
|
||||
`pending` 같은 상태는 별도로 안 들고 `HasBlockedEmit`으로 흡수한다.
|
||||
`pending` 같은 상태는 별도로 안 든다.
|
||||
**⭐⭐ [2026-08-25 정정, 7라운드 `H-86`] "`HasBlockedEmit`으로 흡수한다"는
|
||||
성립하지 않았다.** 아래 *"`HasBlockedEmit`은 게이트 흡수 집합의 특수형"*
|
||||
절이 그 값을 **게이트 노드의 `withheld`로** 흡수해버려, `Blocker` 객체
|
||||
쪽엔 **실체가 없다**. 두 흡수가 반대 방향이라 결과적으로 **아무도 안 들고
|
||||
있었다** — 정책이 "지금 쌓인 게 있나"를 읽을 통로가 0개였고,
|
||||
그래서 `Throttle`의 창이 idle로 못 돌아왔다. **통로는
|
||||
`emit(commit: boolean?) -> boolean`의 반환값**이다
|
||||
(`base/gate-plan.md` 2번).
|
||||
상세와 의사코드는 `base/gate-plan.md`의 5번 항목이 소스.
|
||||
|
||||
**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도
|
||||
|
|
|
|||
|
|
@ -57,18 +57,31 @@ State는 `:With`/`:Compute`로 만들어진 파생값일 수 있어 쓰기가
|
|||
의미 있음 — **사용자 확정**("맞음. 확실해"). 이 원칙 자체는 그대로 유지되고,
|
||||
아래 3번의 구체적 메커니즘만 2026-08-06 후속 세션에서 더 단순하게 갱신됨.
|
||||
|
||||
### 3. Store는 내부 Source를 그대로 반환 — Source가 State를 구조적으로 만족
|
||||
### 3. Store는 내부 Source를 `store:Of(key)`로 준다 — Source가 State를 구조적으로 만족
|
||||
|
||||
> **[2026-08-25] 이 절의 결론은 유지된다.** 같은 날 오전에
|
||||
> "`store.key`가 값이고 `store:Of(k)`가 프리미티브"라는 재설계를 넣었다가
|
||||
> **같은 날 철회**했다 — `archive/store-value-field-redesign-withdrawn.md`.
|
||||
> 바뀐 것은 **생성이 명시적 초기화가 됐다는 것 하나**다(아래 ⭐).
|
||||
|
||||
**확정**: `Source<T>`가 구조적으로 `State<T>`를 만족하므로(단방향 호환,
|
||||
Svelte `Writable<T> extends Readable<T>`와 같은 모양), `store.key`는 Store
|
||||
생성 시 이미 만들어둔 진짜 Source 객체를 그대로 반환한다 — 별도 프록시
|
||||
타입도, 별도 캐싱 계층도 없음(Source 자체가 이미 State의 읽기 계약을
|
||||
전부 만족하고 거기에 `:Set(value)`/`:Emit()`이 추가로 있을 뿐이라 "원본이라
|
||||
쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐). 쓰기 문법도 같이
|
||||
바뀜: `store.key = v`가 아니라 `store.key:Set(v)`(레코드 타입 읽기/쓰기
|
||||
대칭 + lazy 동작에 `=`가 암시하는 "즉시 커밋"이 안 맞는다는 논거). 상세
|
||||
근거·타입 설계·Luau 솔버 검증 필요 항목은 `base/source-state-plan.md`의
|
||||
"Source가 State를 만족함" 절이 최종 소스.
|
||||
Svelte `Writable<T> extends Readable<T>`와 같은 모양), **`store.key`는
|
||||
진짜 Source 객체를 그대로 반환하는 평범한 레코드 필드다** — 별도 프록시
|
||||
타입도, 별도 캐싱 계층도, **타입 함수도** 없다(Source 자체가 이미 State의
|
||||
읽기 계약을 전부 만족하고 거기에 `:Set(value)`/`:Emit()`이 추가로 있을
|
||||
뿐이라 "원본이라 쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐).
|
||||
쓰기는 `store.key = v`가 아니라 `store.key:Set(v)`
|
||||
(`base/store-plan.md`의 "Store 값 설정 문법" 절).
|
||||
|
||||
- **⭐ [2026-08-25] 생성은 명시적 초기화다** — 타입 인자에 `Source<T>`를
|
||||
직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index`
|
||||
(없는 키를 그 자리에서 만들어 저장)는 폐기됐고, 그래서 **선언 키 집합의
|
||||
런타임 소스가 하나**가 된다. 부모가 값을 다 안 넘겨도 되게 하려면
|
||||
**컴포넌트가 자기 `DEFAULTS`로 채워** 넘긴다.
|
||||
- 동적 키는 `store:Of<<T>>(name)` 하나다(옛 `GetDynamic` 흡수).
|
||||
|
||||
상세 근거·타입 설계는 `base/source-state-plan.md`의
|
||||
"Source가 State를 만족함" 절과 `base/store-plan.md`가 최종 소스.
|
||||
|
||||
**[이전에 확정했다가 폐기된 `StoreSource` 프록시 설계는 이 결론으로
|
||||
완전히 대체됨 — 원문·역전 이유·신구 비교표는
|
||||
|
|
@ -327,7 +340,8 @@ Ref를 받으면 그냥 전부 실행하면 됨 — Ref 콜백 리스트는 애
|
|||
(`Modifier.Overridden`은 2026-08-08 세션에서 이름 확정, 이 목록에서
|
||||
빠짐). (`GetSource` 계열 접근자는 위 3번 정정으로 아예
|
||||
불필요해짐 — `store.key`가 직접 Source를 반환하므로 별도 접근자 자체가
|
||||
없음.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/
|
||||
없음. **[2026-08-25]** 단 **동적 키** 전용 창구 `store:Of<<T>>(name)`은
|
||||
있다 — 옛 이름은 `GetDynamic`이었다.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/
|
||||
생성자 이름 미정)과 같은 급의 후순위 항목 — 구현 단계에서 다른
|
||||
이름들과 함께 확정.
|
||||
- **`quad2-try`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와
|
||||
|
|
|
|||
|
|
@ -833,14 +833,35 @@ function clearTimeout(timeout: Timeout) timeout._native() end
|
|||
>
|
||||
> - `gate:passThrough()` → **`b:Off()`**(보류분 1회 방출), 그 뒤 다시 `b:On()`.
|
||||
> - `Trailing = false` 경로 → **`b:OffWithoutEmit()`**.
|
||||
> - **`pending`은 없앤다** — "보류된 게 있는가"는 Blocker의 `HasBlockedEmit`이
|
||||
> - **`pending`은 없앤다** — "보류된 게 있는가"는 게이트 노드의 흡수 집합이
|
||||
> 이미 들고 있다(중복 상태를 안 만든다). 이게 `H-32`를 구조적으로 없앤다.
|
||||
> - **⭐⭐ [2026-08-25 정정, 7라운드 `H-86`] 읽는 통로는 `HasBlockedEmit`이
|
||||
> 아니라 `emit`의 반환값이다.** 여기 한때 *"Blocker의 `HasBlockedEmit`이
|
||||
> 이미 들고 있다"*고 적었는데, `base/blocker-plan.md`가 그 값을 **게이트
|
||||
> 노드의 `withheld`로** 흡수해 `Blocker` 객체 쪽엔 실체가 없다 — 정책이
|
||||
> 읽을 방법이 **0개**였다. 그대로 짜면 창을 닫을 조건을 못 읽어
|
||||
> **창이 영원히 열린 채**가 되고, `Throttle`의 정의(*"첫 신호는 즉시
|
||||
> 통과"*)가 사라져 사실상 "항상 trailing"이 되며 타이머 체인이 자기를
|
||||
> 무한 재무장해 아래 8절의 "유계 GC" 분석도 깨진다(실측 대조 확인).
|
||||
> - **확정된 통로**: `setup`이 받는 `emit`이
|
||||
> `emit(commit: boolean?) -> boolean`이 되어, 반환값이 **"실제로
|
||||
> 내보내거나 버릴 게 있었는가"**를 준다(`base/gate-plan.md` 2번).
|
||||
> `onWindowEnd`는 `if not emit() then window = nil else rearm() end`,
|
||||
> `MaxTime` 재무장 조건도 같은 반환값으로 판정한다.
|
||||
> - **버리는 경로도 같은 핸들이다** — `Trailing = false`/`Cancel`은
|
||||
> `b:OffWithoutEmit()`만으로는 집합이 안 비므로 `emit(false)`가 필요하다
|
||||
> (`H-55`).
|
||||
> - `Flush`/`Cancel` 핸들은 `setup` 클로저 안에서 `b`를 캡처해 만든다 —
|
||||
> 노드 객체 참조가 필요 없다(`Blocker`가 onunblock 핸들을 등록하는 방식과
|
||||
> 같은 우회).
|
||||
>
|
||||
> 아래 코드는 **창/타이머 정책 자체의 참고용**으로만 읽을 것 —
|
||||
> `openWindow`/`onWindowEnd`/`MaxTime`의 분기 구조는 그대로 유효하다.
|
||||
> **[2026-08-25 추가, 7라운드 `H-94`] 팩토리를 `setmetatable`+`__call`로
|
||||
> 만드는 표기도 이 배너 범위에 들어간다** — `__call` 테이블은 `:Apply`의
|
||||
> 함수 타입 자리에 **안 들어간다**(실측). 확정된 형태는 **지정된 필드**로
|
||||
> 자기를 노출하는 것이다(`base/source-state-plan.md`의
|
||||
> "`state:Apply(factory)`" 절).
|
||||
|
||||
**⭐ [2026-08-24 `H-32`] 같이 고쳐야 할 논리 결함 하나 — `Trailing = false`에서
|
||||
`pending`이 영구히 참으로 남는다.** 아래 코드는 창이 열려 있는 동안 오는 신호를
|
||||
|
|
@ -887,8 +908,20 @@ local function makeGate(reset: boolean, opts)
|
|||
for gate in instances do gate._cancel() end
|
||||
end
|
||||
|
||||
-- 팩토리 자체 — :Apply(factory)가 호출할 수 있는 함수이면서, 동시에
|
||||
-- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 콜러블 객체(5-4절)
|
||||
-- 팩토리 자체 — :Apply(factory)가 쓸 수 있으면서, 동시에
|
||||
-- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 객체(5-4절)
|
||||
--
|
||||
-- ⛔⛔ [2026-08-25 폐기, 7라운드 `H-94`] **`__call`은 안 쓴다.**
|
||||
-- `luau-analyze` 실측에서 `__call` 테이블은 `(State<T>) -> U` 함수 타입
|
||||
-- 자리에 **안 들어간다**(제네릭·비제네릭 양쪽) — 런타임은 멀쩡하고
|
||||
-- 타입만 막히므로 `--!nocheck` 스파이크에선 안 드러난다. 타입 레벨
|
||||
-- `__call`은 `self`도 못 받는다. **확정된 형태는 `__call`이 아니라
|
||||
-- 지정된 필드**로 자기를 노출하는 것이고, `Debounce`/`Throttle`/
|
||||
-- `Blocker`가 전부 같은 계약을 만족한다 —
|
||||
-- `base/source-state-plan.md`의 "`state:Apply(factory)`" 절이 소스
|
||||
-- (필드 이름과 정확한 시그니처는 구현 시 정한다).
|
||||
-- 아래 `__call` 표기는 **창/타이머 정책 본문을 읽기 위한 자리표시자**로만
|
||||
-- 볼 것 — 위 7절 배너와 같은 취급이다.
|
||||
local factory = setmetatable({}, {
|
||||
__call = function(_, self)
|
||||
local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드
|
||||
|
|
|
|||
|
|
@ -983,6 +983,18 @@ function Dispatch.process(inst, k, v, index)
|
|||
list[index] = { handler = h, retractor = retractor }
|
||||
end
|
||||
end
|
||||
--[[
|
||||
⚠️ [2026-08-25 신설, 7라운드 `H-103`] `h.process`가 던지면 그 자리에 `NOOP`
|
||||
마커가 **영구히 남는다** — 그 자리의 정리가 통째로 사라지고, 명시적 철거로도
|
||||
회수되지 않는다(`retractFrom`이 `NOOP`을 부르면 아무 일도 안 한다). 예컨대
|
||||
`AttributeKeyHandler.process`는 `nameClaims:SetStrong` **뒤에** `setAttribute`를
|
||||
부르므로, 주입 op가 미주입 에러 스텁이면 **이름 claim만 남고 해제 경로가
|
||||
없는** 상태가 된다.
|
||||
|
||||
**`pcall`로 감싸지 않는다** — `base/architecture.md`의 "예외 안전성 계약" 절이
|
||||
소스다. 자리당 hot path이고, 예외 이후의 부기 무결성을 quad가 보장하지
|
||||
않는다는 일반 계약을 여기에도 그대로 적용한다. **실제로 물리면 그때 넣는다.**
|
||||
]]
|
||||
|
||||
function Dispatch.retractFrom(inst, k, index)
|
||||
-- index부터(포함) 끝까지, 꼬리(가장 깊은 인덱스)부터 역순으로 정리.
|
||||
|
|
@ -1664,6 +1676,11 @@ function Dispatch.getOffsetAt(ownerKey, at)
|
|||
end
|
||||
local cur = bk.offsetCache[bk.invalidAfter]
|
||||
for i = bk.invalidAfter, at - 1 do
|
||||
-- ⭐ [2026-08-25, 7라운드 `H-106`] `nil` 가드 — `recompute`만 갖고 있던
|
||||
-- `C-6` 진단이 이 경로에선 우회돼 익명 산술 에러로 먼저 터졌다.
|
||||
if bk.lengthList[i] == nil then
|
||||
error("Dispatch.getOffsetAt: lengthList[" .. i .. "]가 nil — bookkeeping is broken", 1)
|
||||
end
|
||||
cur += contribution(bk, i) -- lengthList[i](State면 :Get())
|
||||
bk.offsetCache[i + 1] = cur -- **지금 자리의 길이가 다음 자리의 offset을 정한다**
|
||||
end
|
||||
|
|
@ -1729,7 +1746,19 @@ local function recompute(ownerKey, bk)
|
|||
-- 요소가 하나도 없는 Slot(`Slot()` 직후, 데이터가 빈 `:List` 등)은 `N`이 `nil`인
|
||||
-- 채로 `materializeSlotTree` 끝의 recompute에 도달한다 — `for i = 1, nil`은
|
||||
-- 그 자리에서 터진다. 빈 Slot은 완전히 정상적인 상태라 이건 방어가 아니라 계약.
|
||||
for i = 1, bk.N or 0 do
|
||||
-- ⭐⭐ [2026-08-25 재작성, 7라운드 `H-101`] 재진입 차단 + 되감기.
|
||||
-- (a) 자기 전용 `recomputeBlocker`를 켜 재진입 recompute를 막는다
|
||||
-- (배치 게이팅용 Blocker와 **별개 객체** — 합치면 배치 `Off()`의
|
||||
-- onunblock 순회 도중 같은 Blocker가 다시 꺼져 핸들이 재귀한다.
|
||||
-- `base/blocker-plan.md`가 네스팅을 의도적으로 미지원한다).
|
||||
-- (b) 상한 `bk.N`을 **매 반복 재평가**한다 — 진입 시 한 번만 평가하면
|
||||
-- 재진입이 끝에 붙인 자리를 바깥 루프가 아예 안 본다.
|
||||
-- (c) `bk.invalidAfter`가 낮아지면 **그 지점 다음부터 되감는다**.
|
||||
-- 접두합을 남겨두면 되감기 지점의 `sum`이 공짜로 복원된다.
|
||||
bk.recomputeBlocker:On()
|
||||
local prefix, i = {}, 1
|
||||
while i <= (bk.N or 0) do
|
||||
prefix[i] = sum
|
||||
local offset = bk.sourceList[i]
|
||||
-- offset은 실제 Source이거나 None(발행 채널 없음) — None은 truthy라
|
||||
-- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함.
|
||||
|
|
@ -1742,18 +1771,83 @@ local function recompute(ownerKey, bk)
|
|||
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
|
||||
end
|
||||
local abs = Dispatch.getOffsetAt(ownerKey, i) -- 절대 offset(캐시 경유)
|
||||
bk.invalidAfter = i -- 여기까지 유효해짐
|
||||
if offset ~= None and offset:Get() ~= abs then -- 실제로 다를 때만 Set
|
||||
offset:Set(abs)
|
||||
offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리
|
||||
end
|
||||
local v = bk.lengthList[i]
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
|
||||
if bk.invalidAfter < i then -- 누군가 낮췄다 → 되감기
|
||||
-- ⭐ [2026-08-25] `+1`이 아니라 **그 자리부터** 다시 돈다.
|
||||
-- `prefix[j+1]`은 **옛** `lengthList[j]`로 누적된 값이라, 길이가
|
||||
-- 바뀐 자리를 건너뛰면 `sum`이 낡은 채 `Length`에 실린다
|
||||
-- (재진입은 블로커에 막혀 자가치유도 안 된다). `j`를 다시 돌아도
|
||||
-- offset 쓰기는 바로 위 `~=` 가드가 막아 no-op다.
|
||||
i = bk.invalidAfter
|
||||
sum = prefix[i]
|
||||
else
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
-- ⭐ [2026-08-25] 캐시 리셋과 블로커 해제를 **`Length:Set` 앞에** 둔다.
|
||||
-- `Length:Set`은 상위 owner의 사용자 코드를 돌릴 수 있는데, 그 도중
|
||||
-- 낮춰진 `invalidAfter`를 뒤에서 무조건 덮으면 **캐시가 낡은 채로
|
||||
-- "유효"로 표시**되고, 그때 불린 `gatedRecompute`는 블로커가 아직
|
||||
-- 켜져 있어 조기 반환했으므로 아무도 다시 안 돈다.
|
||||
bk.invalidAfter = bk.N or 0
|
||||
bk.recomputeBlocker:OffWithoutEmit()
|
||||
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
|
||||
ownerKey.Length:Set(sum) -- **Length엔 base를 안 더한다** — 길이는 위치와 무관
|
||||
end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절)
|
||||
end
|
||||
```
|
||||
|
||||
**⭐⭐ [2026-08-25 신설, 7라운드 `H-101`/`H-102`] 재진입과 되감기.**
|
||||
|
||||
- **재진입 경로는 실재한다** — `offset:Set(abs)`가 **동기 전파**라 그
|
||||
offset을 관측하는 사용자 코드가 그 자리에서 같은 owner를 건드릴 수
|
||||
있다. 그러면 배치 게이팅 Blocker가 꺼진 정상 상태에서 **재진입
|
||||
`recompute`가 완주해 올바른 값을 써놓고, 바깥 루프의 꼬리가 자기
|
||||
옛 `sum`으로 그걸 덮는다.** 실제 재현에서는 `bk.N`이 자란 경우가
|
||||
걸렸다(바깥은 옛 상한까지만 돌아 `sum`이 모자랐다).
|
||||
- **원문이 든 트리거는 틀렸다** — `:List`의 `updateFn`은 offset 변경으로
|
||||
**재실행되지 않는다**(offset을 State로 넘겨 관측하게 할 뿐이다).
|
||||
남는 트리거는 그보다 좁은 것 하나 — **`slot.Offset` State를 관측하는
|
||||
사용자 코드**.
|
||||
- **`sum`이 낡는다는 서술도 대체로 틀렸다** — `contribution`을
|
||||
`offset:Set` **직후**에 읽으므로, 그 Set이 유발한 자식 길이 변경은
|
||||
이미 반영된 값이다(동기 계약 하에서).
|
||||
- **왜 되감아야 하나** — 앞자리가 당겨지는 splice가 도중에 나면 순차
|
||||
순회로는 복구가 안 된다. **사용자 예시**: *"`{a,a,a, b,b, c,c}` 여기서
|
||||
a,a 두개가 소멸했는데, 이미 c 에 왔다면, b,b 가 c,c 로 덮여지고 a,a 는
|
||||
달라지는게 없을 가능성이 생기죠. 따라서 recompute 도중 변경이 생긴다면,
|
||||
변경이 생긴 곳으로 위로 올라가야할것 같습니다."*
|
||||
- **되감기 신호는 `bk.invalidAfter` 하나로 통일한다** — 새 필드를 안
|
||||
만든다. 두 뜻("캐시가 여기까지 유효"와 "여기 다음부터 다시 해야 함")이
|
||||
실제로 같은 것이기 때문이고, `getOffsetAt`도 이미 `for i = bk.invalidAfter,
|
||||
at - 1`로 그렇게 읽는다.
|
||||
- **⭐ [2026-08-25 정정, `/code-review high`] 재개 지점은 `invalidAfter`
|
||||
자신이다(`+1` 아님).** 한때 *"길이가 바뀐 자리의 자기 offset은 여전히
|
||||
유효하다"*를 근거로 `+1`로 적었는데, **offset은 유효해도 `sum`이
|
||||
아니다** — `prefix[j+1]`은 **옛** `lengthList[j]`로 누적된 값이라 그
|
||||
자리를 건너뛰면 낡은 합계가 `ownerKey.Length`에 실리고, 재진입은
|
||||
블로커에 막혀 있어 **자가치유도 안 된다.** `j`를 다시 도는 비용은
|
||||
offset 쓰기 하나인데 그건 `offset:Get() ~= abs` 가드가 막아 no-op다.
|
||||
- **그래서 splice도 `j - 1`이 아니라 `j`로 낮춘다** — 재개가
|
||||
`invalidAfter`니까 `j`가 곧 "그 자리부터 다시"다. `getOffsetAt`의 캐시
|
||||
의미(`offsetCache[j]` = 1..j-1의 합)도 splice/길이변경 어느 쪽에서든
|
||||
`j` 이전은 안 바뀌므로 그대로 성립한다.
|
||||
- **`H-102`(splice가 observer를 옮겨도 클로저에 박힌 인덱스는 안 고쳐진다)가
|
||||
이걸로 같이 닫힌다** — 아래 `setLength`의 `gatedRecompute`가 **인덱스를
|
||||
캡처하지 않고 조회**한다. `slot._elemIndex`(물리 요소 → 인덱스 역방향
|
||||
맵, 6라운드 `H-39`)와 같은 개념을 **Dispatch 층위로 격상**해 `bk`가
|
||||
소유하고, splice가 배열을 당길 때 같이 갱신한다 — 그래서
|
||||
`base/slot-plan.md`의 splice 요구 목록에 항목이 늘지 않는다.
|
||||
(사용자: *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"*.)
|
||||
`bk.invalidAfter = 0`으로 뭉개는 안은 기각 — *"0 으로 두면, 모든 부분에
|
||||
있어 캐시가 무관해져요"*.
|
||||
|
||||
**`offset`/`sum`은 0-based *개수*이지 Lua 배열 인덱스가 아님(2026-08-11
|
||||
세션 명시화).** Luau/Lua 배열은 1-based 관례지만, 여기서 계산하는
|
||||
`offset[i]`는 "그 앞에 몇 개가 있는가"라는 순수 카디널 수라 자연스럽게
|
||||
|
|
@ -1804,15 +1898,37 @@ function Dispatch.setLength(ownerKey, i, len, anchor)
|
|||
|
||||
bk.lengthList[i] = len
|
||||
bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고
|
||||
-- ⭐ [2026-08-25] 이 자리의 유일한 토큰 — 아래 클로저가 인덱스 대신 이걸 캡처한다.
|
||||
local token = bk.tokens[i]
|
||||
if token == nil then
|
||||
token = {}
|
||||
bk.tokens[i] = token
|
||||
end
|
||||
bk.indexOfToken[token] = i
|
||||
-- [2026-08-24 `H-3`] 접두합 캐시를 여기까지 당긴다 — `i` 자리의 offset은
|
||||
-- `1..i-1`의 합이라 안 바뀌고, 바뀌는 건 그 **뒤**뿐(위 무효화 표).
|
||||
bk.invalidAfter = math.min(bk.invalidAfter, i)
|
||||
|
||||
local function gatedRecompute()
|
||||
bk.invalidAfter = math.min(bk.invalidAfter, i) -- 나중 emit도 같은 무효화가 필요
|
||||
if not blocker:IsOn() then
|
||||
recompute(ownerKey, bk)
|
||||
end
|
||||
-- ⭐ [2026-08-25, 7라운드 `H-102`] `i`를 **캡처하지 않는다** — splice가
|
||||
-- 자리를 당기면 박힌 인덱스가 낡는다. `bk`가 소유한 역방향 맵에서
|
||||
-- 현재 인덱스를 조회한다(`slot._elemIndex`와 같은 개념을 Dispatch로
|
||||
-- 격상한 것 — 위 `recompute` 절).
|
||||
--
|
||||
-- ⚠️ [2026-08-25 정정, `/code-review high`] 키는 **`len`이 아니라 이
|
||||
-- 자리의 토큰**이다. `len`은 자리마다 유일하지 않다 — 같은
|
||||
-- `Source`/`State`가 두 자리의 길이를 몰면 역방향 맵에서 **두
|
||||
-- 자리가 한 항목으로 접혀** 엉뚱한 인덱스를 무효화하고, 앞선
|
||||
-- 자리는 영영 다시 offset을 못 받는다. `token`은 등록 시점에
|
||||
-- 만드는 유일한 테이블로 `bk.tokens[i]`에 같이 저장되고 splice가
|
||||
-- 다른 배열과 **함께** 당긴다. (`slot._elemIndex`가 물리 요소를
|
||||
-- 키로 쓰는 건 요소가 자리마다 유일해서 성립하는 것 — 그 성질을
|
||||
-- Dispatch에선 토큰이 맡는다.)
|
||||
local cur = bk.indexOfToken[token]
|
||||
bk.invalidAfter = math.min(bk.invalidAfter, cur) -- 나중 emit도 같은 무효화가 필요
|
||||
if blocker:IsOn() then return end -- 배치 등록 중
|
||||
if bk.recomputeBlocker:IsOn() then return end -- ⭐ recompute 재진입 중
|
||||
recompute(ownerKey, bk)
|
||||
end
|
||||
|
||||
if isState(len) then
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ Effect는 내부적으로 각 dep에 구독을 거는 걸로 구현 — State/So
|
|||
`state:Observer(...)`, `Ref`면 `:Callback`.
|
||||
**⚠️ [표기 정정, 2026-08-24 6라운드 `H-14`] 여기 원래 *"`fn`은 포지셔널 인자로
|
||||
`state`를 받고(`fn(state)`)"*라고 적혀 있었는데 그건 폐기된 단수 시절
|
||||
표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고
|
||||
표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> ...(() -> ())`**이고
|
||||
(**[2026-08-25 `H-95`]** 가변 반환 팩 — 옛 `-> (() -> ())?`는 콜백이 "선언보다
|
||||
적게 반환"할 때 strict에서 막혀 `Effect(function() print("x") end, s)` 같은
|
||||
정상 용례가 안 통과했다)
|
||||
**deps는 `fn`에 안 넘어간다**(아래 "`Effect(fn, ...deps)`" 절이 소스,
|
||||
dep 값은 사용자가 클로저로 직접 읽는다).
|
||||
같이 붙어 있던 *"이 `fn(state)`가 lazy `State` 핸들을 받는다는 전제는 확정
|
||||
|
|
@ -130,37 +133,138 @@ gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그
|
|||
cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와
|
||||
`Rerun`이 같은 자리를 읽게 된다.
|
||||
|
||||
**의사코드 — `bindLifetime`이 부르는 두 훅**(**[2026-08-24 신설]** 감사 3라운드가
|
||||
*"호출부만 있고 정의가 없다"*고 지적해 보강. 다른 신설 헬퍼는 전부 바디가
|
||||
있는데 이 둘만 산문뿐이었다):
|
||||
**⭐⭐ [2026-08-25 재설계, 7라운드 `H-58`/`H-59`/`H-64`/`H-70`] dep 등록은
|
||||
`bindLifetime`이 아니라 생성자에서 한 번만 한다.** 옛 의사코드는 `Ref` 콜백을
|
||||
`_bindDestroying`에서 (재)등록했는데, `ref-plan.md`가 확정한 *"등록 즉시 그
|
||||
값으로 1회 호출됨"*과 겹쳐 **바인드마다 `Rerun`이 `Ref` dep 수만큼 돌았다**.
|
||||
같은 문제가 State dep에도 있다 — `source-state-plan.md`가 확정한
|
||||
*"`fn`은 등록 시점에 즉시 1회 실행된다"* 때문이다.
|
||||
|
||||
### 확정 구조 — 강한 주인은 항상 `Effect`, 발화 게이트는 `canExecute` 하나
|
||||
|
||||
```
|
||||
Effect ──강──▶ _deps = { [Ref | State] = fn | Observer } ← 강한 주인은 언제나 Effect
|
||||
Ref.Callbacks ──약──▶ fn (`ref:WeakCallback(fn)`)
|
||||
Observer 전역 레지스트리 ──약──▶ Observer (`observer:WeakSubscribe()`)
|
||||
발화 게이트: 전부 `canExecute(handle)` 하나로
|
||||
```
|
||||
|
||||
**사용자 확정(2026-08-25)**: *"그냥 간단하게 저 강한 map 을 Effect 가 가지고,
|
||||
WeakSub/WeakUnsub 를 WeakCallback 처럼 넣어줍시다. 의미론은 같습니다.
|
||||
callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지 않으면 gc 될
|
||||
수 있다. **그러면 bindLifetime 은 effect 하나 구현을 한 이후 canExecute 로
|
||||
모두 처리한다. 간단해집니다.**"*
|
||||
|
||||
- **dep마다 바인드/언바인드에서 등록·해제하던 춤이 통째로 사라진다.**
|
||||
`bindLifetime`/`unbindLifetime`은 **`Effect` 핸들 하나**에만 적용되고,
|
||||
내부 Observer와 `Ref` 콜백의 발화 여부는 `canExecute(handle)`이 전담한다.
|
||||
- 그래서 아래 `H-7` 절이 확정한 *"`unbindLifetime`과 `:Unsubscribe()`에서
|
||||
`:Uncallback`한다"*는 **더 이상 필요 없다** — `ref-plan.md`의
|
||||
*"해제는 누수를, 게이팅은 발화를 막는다"* 중 **앞쪽 절반을 `Weak*`가
|
||||
대신한다**.
|
||||
- **왜 `WeakRef`가 아니라 `WeakCallback`인가**(사용자 지적): *"Ref 안에
|
||||
항상 콜백이 쌓인다는것도 문제가 됨."* 클로저가 핸들을 약하게 잡는
|
||||
`WeakRef`는 "`Ref`가 `Effect`를 붙든다"만 풀고 "`Ref.Callbacks`에 죽은
|
||||
클로저가 쌓인다"는 못 푼다. `WeakCallback`은 둘 다 푼다 —
|
||||
`Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고 `Ref` 쪽
|
||||
항목도 같이 사라진다. **`WeakRef`는 만들지 않는다.**
|
||||
- 이건 `blocker-plan.md`의 onunblock 핸들 보관과 **같은 패턴**이다 —
|
||||
강한 주인은 소비자 쪽, 등록처는 weak.
|
||||
|
||||
### 의사코드 — 생성자 / `bindLifetime`이 부르는 두 훅 / `Rerun`
|
||||
|
||||
```lua
|
||||
-- quad-base, Effect.luau
|
||||
function Effect(fn, ...)
|
||||
local self = setmetatable({ fn = fn, _deps = {}, _epochs = EpochMap() }, EffectHandle)
|
||||
self._blocker = Blocker()
|
||||
|
||||
-- (0) deps 검증 — 생성자에서 한 번만 도는 검사라 hot path가 아니다.
|
||||
-- `select("#", ...)`로 순회해야 `nil` 구멍이 조용히 배열을 자르지 않는다.
|
||||
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
|
||||
if not seen[d] then seen[d] = true end -- 중복 dep은 조용히 무시(error 아님)
|
||||
end
|
||||
|
||||
-- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출은 Blocker로 억제한다.
|
||||
-- ⭐ 클로저는 **하나**로 통일한다 — 아래 "공통 상류를 공유해도 한
|
||||
-- 파동에 fn은 한 번만" 절의 그 클로저다. `from`을 받아
|
||||
-- `_epochs:Update(from)`가 참일 때만 `Rerun`해야 다이아몬드 dedup이
|
||||
-- 산다(안 하면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()`
|
||||
-- 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그).
|
||||
-- 시그니처가 `(self, from)`인 것은 `state:Observer(fn)`의 확정
|
||||
-- 계약이다(`base/source-state-plan.md`).
|
||||
self._blocker:On()
|
||||
local function onDepFire(_, from)
|
||||
if not canExecute(self) then return end -- 발화 게이트
|
||||
if self._blocker:IsOn() then return end -- 등록 구간 억제(Update보다 먼저)
|
||||
if self._epochs:Update(from) then
|
||||
self:Rerun()
|
||||
end
|
||||
end
|
||||
for d in seen do
|
||||
if isRef(d) then
|
||||
self._deps[d] = onDepFire -- ⭐ 강한 주인 = Effect
|
||||
d:WeakCallback(onDepFire) -- Ref 쪽은 약함
|
||||
else
|
||||
local o = d:Observer(onDepFire)
|
||||
self._deps[d] = o -- ⭐ 강한 주인 = Effect
|
||||
o:WeakSubscribe() -- 전역 레지스트리는 약함
|
||||
end
|
||||
-- ⭐ dep이 `Epoch`인지로 갈린다 — `state-epoch-plan.md` §4의 시딩 규칙
|
||||
-- 그대로다. **`Source`/`Ref`는 `Epoch`지만 `State`는 아니다**(§2·§8) —
|
||||
-- 무조건 `Sync`하면 State dep이 `.Revision` 없는 키로 들어가
|
||||
-- `Refresh()`가 영영 변화를 못 보고 포탈 캐치업이 죽는다.
|
||||
if isEpoch(d) then
|
||||
self._epochs:Sync(d)
|
||||
else
|
||||
self._epochs:TrackFrom(d.valueEpochMap)
|
||||
end
|
||||
end
|
||||
self._blocker:OffWithoutEmit()
|
||||
|
||||
-- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇).
|
||||
self:Rerun()
|
||||
return self
|
||||
end
|
||||
```
|
||||
|
||||
- **`_installing` 플래그는 폐기됐다** — 그건 생성자 구간만 덮어 바인드
|
||||
구간을 놓쳤다. 억제는 사적 `Blocker` 하나가 전담한다(**사용자 지적**:
|
||||
*"해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서 사용중임. 모든
|
||||
옵저버와 callback 등록에 있어서 이를 수행해야할 것임."*).
|
||||
- **⚠️ 생성 즉시 1회 실행은 바인드로 미룰 수 없다.** **사용자 판단**:
|
||||
*"Effect 가 바운딩 될 때 실행되는건 문제가 있습니다. 그 이팩트 실행
|
||||
결과를 바로 받아서 처리하는 아래쪽 요소가 있으면, 순차 처리가 전혀 안
|
||||
되거든요. 초기 값이 못 쓰게 되는거죠."* 그 따름정리로 **바운딩 없이
|
||||
버려지는 `Effect`는 UB**다 — cleanup이 안 불린다. `Observer`와 달리
|
||||
`Effect`는 "죽기 전에 처리해주겠다"가 계약이라 성격이 다르다.
|
||||
|
||||
```lua
|
||||
-- quad-base, Effect.luau — `base/lifecycle-pattern.md`의 bindLifetime에서만 불린다.
|
||||
-- 비공개(`_` 접두사): 사용자 표면이 아니라 배관이다.
|
||||
function EffectHandle:_bindDestroying(inst)
|
||||
-- 재바인드(포탈 재마운트)면 옛 연결부터 끊는다 — 멱등.
|
||||
self:_unbindDestroying()
|
||||
self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등
|
||||
|
||||
-- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점.
|
||||
self._destroyConn = onDestroying(inst, function()
|
||||
self:_unbindDestroying() -- 자기 연결/콜백을 먼저 정리(재진입 방지)
|
||||
local cleanup = self._cleanup
|
||||
self._cleanup = nil -- 두 번 안 불리게
|
||||
if cleanup then cleanup() end
|
||||
self:_unbindDestroying()
|
||||
self:_consumeCleanup()
|
||||
end)
|
||||
|
||||
-- (2) `Ref` dep 콜백 (재)등록 — State/Source dep의 `_observers` cascade와 대칭.
|
||||
-- `unbind`가 뗐던 걸 여기서 다시 건다(그래서 포탈이 성립한다).
|
||||
for _, ref in ipairs(self._refDeps) do
|
||||
local cb = function(value)
|
||||
-- ⭐ 발화 게이트 — State dep이 전파 루프에서 `canExecute(observer)`로
|
||||
-- 걸러지는 것과 같은 자리(아래 `H-7` 절). 해제가 늦거나 누락되는
|
||||
-- 창을 이게 덮는다.
|
||||
if not canExecute(self) then return end
|
||||
self:Rerun()
|
||||
end
|
||||
self._refCallbacks[ref] = cb -- 해제 때 정확히 이 클로저를 떼려면 보관해야 함
|
||||
ref:Callback(cb)
|
||||
-- (2) 캐치업 — **조건부 최대 1회**. dep 등록은 이미 생성자에서 끝났다.
|
||||
-- 설치돼 있지 않으면(파괴로 소진됐으면) 재설치, dep이 변했으면 재실행.
|
||||
-- ⚠️ `Refresh()`를 **먼저 부른다** — 그건 비교만 하는 게 아니라 자기
|
||||
-- 키를 라이브로 다시 읽어 **갱신**한다. `or` 단축평가에 걸면
|
||||
-- `not self._installed`가 참일 때(재설치 경로) 건너뛰어, 재설치 뒤에도
|
||||
-- `_epochs`가 파괴 전 리비전을 들고 있어 **다음 emit이 헛되이 한 번
|
||||
-- 더 돈다**(`Rerun`은 `_epochs`를 안 건드린다).
|
||||
local depsChanged = self._epochs:Refresh()
|
||||
if not self._installed or depsChanged then
|
||||
self:Rerun()
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -169,28 +273,116 @@ function EffectHandle:_unbindDestroying()
|
|||
self._destroyConn:Disconnect()
|
||||
self._destroyConn = nil
|
||||
end
|
||||
for ref, cb in pairs(self._refCallbacks) do
|
||||
ref:Uncallback(cb) -- `base/ref-plan.md`의 신설 표면(`H-7`)
|
||||
self._refCallbacks[ref] = nil
|
||||
end
|
||||
-- **`_cleanup`은 안 부른다** — 위 2번 계약. 여기서 부르면 `destroySlotTree`가
|
||||
-- `_detachCleanup`을 손으로 비운 뒤 unbind하는 경로에서 이중 호출이 된다.
|
||||
-- **`Ref` 콜백도 Observer도 안 뗀다** — `Weak*`로 걸려 있고 발화는
|
||||
-- `canExecute`가 막는다. **`_cleanup`도 안 부른다**(아래 2번 계약).
|
||||
end
|
||||
|
||||
-- cleanup 소진: 읽고 → 지우고 → 실행. 이 순서라야 이중 호출이 없고,
|
||||
-- `_cleanup`의 유무가 곧 "설치돼 있는가"가 된다.
|
||||
function EffectHandle:_consumeCleanup()
|
||||
local c = self._cleanup
|
||||
self._cleanup = nil
|
||||
self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다
|
||||
if c then c() end
|
||||
end
|
||||
```
|
||||
|
||||
**⚠️ [2026-08-25 `/code-review high` 정정] "설치돼 있는가"를 `_cleanup`의
|
||||
유무로 판정하면 안 된다 — 별도 `_installed` 플래그가 필요하다.** 여기 한때
|
||||
`if self._cleanup == nil or ...`라고 적어뒀는데, **`fn`의 cleanup 반환은
|
||||
선택**이라(`Effect(function() print("x") end, s)`처럼 아무것도 안 돌려주는
|
||||
게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면
|
||||
바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가
|
||||
없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.**
|
||||
`_installed`는 `Rerun`이 끝날 때 참, `_consumeCleanup`에서 거짓이 된다.
|
||||
|
||||
**⭐ [2026-08-25 신설, 7라운드 `H-60`] `EffectHandle:Rerun()` 정의.**
|
||||
지금까지 호출부만 다섯 곳이고 정의가 없었다.
|
||||
|
||||
```lua
|
||||
function EffectHandle:Rerun() -- 공개 메소드, 무인자
|
||||
if self._running then
|
||||
self._pending = true -- 실행 중 재진입 → 지연
|
||||
return
|
||||
end
|
||||
self._running = true
|
||||
repeat
|
||||
self._pending = false
|
||||
self:_consumeCleanup()
|
||||
self._cleanup = self.fn(self)
|
||||
self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨"
|
||||
until not self._pending -- 재요청이 또 오면 또 돈다
|
||||
self._running = false
|
||||
end
|
||||
```
|
||||
|
||||
- **재진입은 지연 재실행**이다. **사용자 판단**: *"Effect 의 실행 안에서
|
||||
뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행
|
||||
하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는
|
||||
상황은 react 등지에서 흔함."*
|
||||
- **`canExecute` 확인은 호출부가 한다** — `Ref` 콜백·전파 루프가 이미
|
||||
그렇게 한다. 사용자가 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다.
|
||||
- **error 시 UB** — 전파되고 복구하지 않는다(`_running`이 참으로 남는 것
|
||||
포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의
|
||||
일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다.
|
||||
|
||||
**⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리
|
||||
패턴.** 파괴로 cleanup이 소진된 `Effect`를 다시 바인드하면 위 (2)의
|
||||
`not self._installed`가 참이라 **재설치**된다. 죽음을 표시하는 별도 부기는
|
||||
만들지 않는다 — **사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에
|
||||
이벤트 바인딩인데 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도
|
||||
알아서 잘 풀린 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시
|
||||
실행해줘야한다는 것 이외엔 아무 문제가 없어요."*
|
||||
|
||||
같은 `fn`을 **여러 인스턴스로** 쓰고 싶으면 `Effect`를 넘기지 말고
|
||||
**팩토리를 넘긴다**:
|
||||
|
||||
```lua
|
||||
local function TimerEffectFactory(data: { timerSource: Source<number> }): Effect
|
||||
return Effect(function(self)
|
||||
...
|
||||
return function() ... end
|
||||
end, data.timerSource) -- 주입받은 것을 그대로 deps로도 쓸 수 있다
|
||||
end
|
||||
```
|
||||
|
||||
- **사용자 결론**: *"차라리 Effect 를 만들어내는 팩토리를 넘기는 패턴을
|
||||
권장해야할듯 해요 … Clone 도, Userdata 도, 템플릿도 필요하지 않다."*
|
||||
초기 1회 실행 문제가 자연히 해결되고, 템플릿이 실행되어 찌꺼기로 남는 걸
|
||||
막으려 따로 뭘 할 필요도 없다. 자식의 계약은 `({...}) -> Effect` 하나이고
|
||||
부모가 더 큰 타입을 넘겨도 **부분 성립**으로 해결된다. 무엇보다 **주입받은
|
||||
`Source`/`Ref`를 그대로 deps로 넣을 수 있다** — userdata로는 안 되던 것이다
|
||||
(*"이건 ud 가 deps 에 대해서는 아무 처리가 못 했던것과 비교해 더 간단하면서도,
|
||||
기능적임"*). modifier에서 이미 권해온 패턴이기도 하다.
|
||||
- 부수로 *"이팩트를 여러곳에 바인딩하면?"*도 자연히 해결된다(매번 새 인스턴스).
|
||||
- **검토 후 안 만들기로 한 것**: `Effect:Clone()`, `Effect<UD>:Userdata()` /
|
||||
`SetUserdata`·`GetUserdata`, `Effect.Template`, `WeakRef`.
|
||||
|
||||
- **`onDestroying(inst, fn)`은 백엔드 주입 op이다** — base는 `Instance`를
|
||||
모른다(`base/slot-plan.md`의 `native*` 절과 같은 이유). quad-roblox 구현은
|
||||
`inst.Destroying:Connect(fn)` 한 줄. **[2026-08-24 반영 완료]** 주입 op
|
||||
전체 목록의 단일 소스는 `base/architecture.md`의 `EngineOps.luau` 줄이고
|
||||
거기에 등재했다(`ROADMAP.md` M5 배너에도 같이 적었다) — 한때 여기
|
||||
*"`ROADMAP.md` M5에 추가할 것"*이라고만 적어 **소스를 잘못 지목**했었다.
|
||||
- **필드 셋이 새로 생긴다**: `_destroyConn`(연결 핸들), `_refDeps`(생성자가
|
||||
`...deps` 중 `isRef`인 것만 모아둔 배열), `_refCallbacks`(`ref → 내가 건
|
||||
클로저`). 앞의 둘은 `_observers`와 같은 층이고, 마지막 것은 **해제 시 정확히
|
||||
자기 클로저만 떼기 위해** 필요하다(`Ref.Callbacks`가 셋이라 값으로 떼야 한다).
|
||||
`inst.Destroying:Connect(fn)` 한 줄. 주입 op 전체 목록의 단일 소스는
|
||||
`base/architecture.md`의 `EngineOps.luau` 줄이다.
|
||||
- **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건
|
||||
`fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref`도 `Epoch`라 균일),
|
||||
`_blocker`(등록 구간 억제), `_cleanup`, **`_installed`**(설치 여부 —
|
||||
cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다),
|
||||
`_running`/`_pending`(재진입).
|
||||
**옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나와
|
||||
`_blocker`로 대체됐다.**
|
||||
|
||||
### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`)
|
||||
|
||||
> **⭐⭐ [2026-08-25 갱신, 7라운드 `H-58`/`H-59`] 해제 경로 대신 `Weak*`
|
||||
> 등록으로 바뀌었다.** 아래가 진단한 누수(*"leaf가 죽어도 `ref.Callbacks`에
|
||||
> 클로저가 영원히 남는다"*)는 그대로 유효하지만, **해법이 바뀌었다** —
|
||||
> `unbindLifetime`/`:Unsubscribe()`에서 `:Uncallback`하는 대신
|
||||
> `ref:WeakCallback(cb)`로 걸고 **강한 주인을 `Effect._deps`에 둔다**.
|
||||
> 그러면 `Effect`가 죽을 때 콜백도 같이 죽어 항목이 자연히 사라지고,
|
||||
> 바인드/언바인드마다 떼었다 붙이는 춤이 없어진다(그 춤이 `H-58`의
|
||||
> 중복 `Rerun`을 만들던 원인이다). 위 "확정 구조" 절이 소스.
|
||||
> **아래 `canExecute` 게이팅은 그대로 유효하다** — *"해제는 누수를,
|
||||
> 게이팅은 발화를 막는다"* 중 **뒤쪽 절반**이 여전히 이 절의 결론이다.
|
||||
|
||||
`Effect(fn, someRef)`의 leaf가 죽어도 **`ref.Callbacks`에 클로저가 영원히
|
||||
남았다** — `Ref`엔 콜백 해제 API가 없었고(`:Uncallback` 류 없음)
|
||||
`canExecute` 게이팅도 안 걸린다(그건 Observer 쪽 배관이다). 그 클로저가
|
||||
|
|
@ -236,7 +428,17 @@ named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순
|
|||
**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째
|
||||
세션, 재확인 후 명시화)**:
|
||||
|
||||
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`] 이 문단 전체가 아직 "Observer 하나"
|
||||
> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.**
|
||||
> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 —
|
||||
> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고**
|
||||
> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만**
|
||||
> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`이
|
||||
> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는
|
||||
> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록").
|
||||
> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`이
|
||||
> 되살아난다.**
|
||||
|
||||
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나"
|
||||
전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.**
|
||||
아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로
|
||||
구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려**
|
||||
|
|
@ -295,9 +497,20 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
`self` 반환(Observer와 동일한 fluent 대칭).
|
||||
|
||||
- **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에
|
||||
자신(또는 `state` 있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님,
|
||||
기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속
|
||||
살아있음(Observer와 동일 관용구).
|
||||
**핸들 자신**을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후
|
||||
로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구).
|
||||
- **⭐ [2026-08-25 확정, 7라운드 `H-59`] "핸들 자신이냐 내부 Observer냐"는
|
||||
더 이상 구현 세부가 아니다 — 핸들 자신이다.** 옛 서술은
|
||||
*"자신(또는 `state` 있는 경우 내부 Observer)"*, *"`handle._observers`만으로
|
||||
충분한지는 구현 세부"*였는데, `H-7`/`H-11`이 **핸들 자신의 생존 판정**에
|
||||
의존하는 배선을 추가하면서 그 선택이 계약이 됐다. 내부 Observer만
|
||||
등록하면 (a) `handle.Subscribed`가 안 세워져 `canExecute(handle)`이
|
||||
영원히 거짓이고, (b) deps 없는 `Effect(fn):Subscribe()`는 등록할 게
|
||||
아예 없어 핸들이 GC되고 cleanup이 유실된다.
|
||||
- **`:Subscribe()`가 하는 일은 그것 하나뿐이다** — 내부 Observer와 `Ref`
|
||||
콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절).
|
||||
`Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그
|
||||
경로들이 살아난다.
|
||||
- **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance
|
||||
생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`에
|
||||
묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지
|
||||
|
|
@ -348,12 +561,14 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
*"relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup
|
||||
이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면
|
||||
dedup 되어야하는듯."*
|
||||
- **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다**
|
||||
(5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라
|
||||
`handle._observers`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup
|
||||
분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다
|
||||
(handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시
|
||||
같은 `if` 안에 둘 것.
|
||||
- **⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] `EF-5`의 "내부 Observer
|
||||
cascade도 그 분기 안에" 요구는 사라졌다.** 원문은 *"`EffectHandle`은
|
||||
자기 자신뿐 아니라 `handle._observers`까지 같이 bind/unbind해야 하는데,
|
||||
그 cascade가 dedup 분기 밖에 있으면 handle과 내부 Observer의 바인딩
|
||||
상태가 갈린다"*였는데, **`bindLifetime`/`unbindLifetime`이 이제 핸들
|
||||
하나에만 적용되고 cascade 자체가 없다**(위 "확정 구조" 절,
|
||||
`_observers` 필드도 `_deps`로 통합돼 폐기). 갈릴 상태가 없으므로
|
||||
이 요구는 성립하지 않는다 — **그대로 구현하면 `nil`을 순회한다.**
|
||||
- **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린
|
||||
항목이 아니다.
|
||||
- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥
|
||||
|
|
@ -363,8 +578,13 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다"
|
||||
이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도
|
||||
동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨:
|
||||
1. `state`가 있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을
|
||||
끊고,
|
||||
1. **⚠️ [2026-08-25 정정, 7라운드 `H-58`/`H-59`]** 여기 원래 *"`state`가
|
||||
있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 끊고"*라
|
||||
적혀 있었는데, 내부 Observer는 생성자에서 `:WeakSubscribe()`로 걸리고
|
||||
**해제하지 않는다** — 발화는 `canExecute(handle)`이 막는다(위
|
||||
"확정 구조" 절). `:Unsubscribe()`가 `handle.Subscribed = false`로
|
||||
만들면 그 게이트가 곧바로 거짓이 되므로 **향후 재실행은 그것만으로
|
||||
끊긴다.** 단수 `state` 전제도 이미 `...deps`로 대체됐다.
|
||||
2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때
|
||||
하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것.
|
||||
3. **idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복
|
||||
|
|
@ -405,8 +625,28 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
|
|||
|
||||
- **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** —
|
||||
State/Source면 `Observer`, `Ref`면 `:Callback`. `:With`로 합치지 않는다.
|
||||
**[2026-08-25 정정]** 각각 `:WeakSubscribe()` / `:WeakCallback()`으로
|
||||
걸고, 강한 주인은 `Effect._deps`다(위 "확정 구조" 절).
|
||||
- **⭐ [2026-08-25 확정, 7라운드 `H-70`] deps 검증** — 지금까지
|
||||
비어 있던 세 자리를 정한다.
|
||||
- **`nil` dep은 error.** `{...}` + `ipairs`로 순회하면 `nil` 구멍 뒤가
|
||||
조용히 잘리므로 **`select("#", ...)`로 돌아야** 한다.
|
||||
- **State/Source/`Ref`가 아닌 값은 error.** `H-40`이 `:List`의 요소
|
||||
검증을 블랙리스트에서 화이트리스트로 뒤집은 것과 같은 성격이다 —
|
||||
이물 dep은 전파할 것이 없으므로 조용히 무시하면 "왜 안 발화하지"만
|
||||
남는다.
|
||||
- **중복 dep은 조용히 무시**(error 아님). **사용자 근거**: *":With 이나
|
||||
시소한 연산으로 다른 State 가 된다던가 하면 deps 가 겹쳐도, 근원
|
||||
source 가 겹쳐도 에러를 안 냄. Ref 도 유사한 부분."* `Ref`가 `Epoch`로
|
||||
승격돼(`base/ref-plan.md`) `EpochMap`이 키로 dedup하므로 **공짜로**
|
||||
처리된다 — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로 먼저 건 클로저가
|
||||
`Ref.Callbacks`에 남던 버그도 같이 사라진다.
|
||||
- 검증은 전부 **생성자에서 한 번만** 도므로 hot path가 아니다.
|
||||
error `level`은 **2**(사용자 입력 검증) — `base/architecture.md`의
|
||||
error 계약 절.
|
||||
- **⭐ [확정, 2026-08-24 6라운드 손 트레이싱 `H-14`] `fn`의 시그니처는
|
||||
`fn(self: EffectHandle) -> (() -> ())?` 이고, `...deps`는 의존성 선언일 뿐
|
||||
`fn(self: EffectHandle) -> ...(() -> ())` 이고(**[2026-08-25 `H-95`]** 가변
|
||||
반환 팩으로 정정 — 옛 `-> (() -> ())?`는 정상 용례를 막았다), `...deps`는 의존성 선언일 뿐
|
||||
`fn`에 넘어가지 않는다.**
|
||||
**사용자 확정**: *"`Effect( fn(self: Effect)->()->(), ...deps )` 가 맞는듯.
|
||||
Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게 맞아보이고,
|
||||
|
|
@ -431,14 +671,20 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
|
|||
- **`Ref` 의존성의 발화 시점은 `Set`될 때뿐**이다(Ref는 반복 재설정이
|
||||
가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다.
|
||||
- **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록
|
||||
즉시 1회 실행"이 N번 발화하므로, 설치 구간 동안 발화를 눌러뒀다가 마지막에
|
||||
한 번만 실행한다. **[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 —
|
||||
게이트도 `Blocker`도 안 쓴다.** 한때 *"`Blocker`의 "`state:Block()` 없이
|
||||
직접 쓰는" 용례를 그대로 재사용"*이라 적고 정확한 모양을 `Gate` 설계에
|
||||
걸어뒀는데, `Gate`가 **빈 배치일 땐 통지를 안 하는 것**으로 확정되면서
|
||||
성립하지 않는 게 확인됐다(`base/gate-plan.md`의 8번) — 설치 구간엔 어떤
|
||||
`Set`도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가
|
||||
없다. 설치 중 발화를 누르는 플래그 하나면 되고 새 메커니즘이 필요 없다.
|
||||
즉시 1회 실행"이 N번 발화하므로, 등록 구간 동안 발화를 눌러뒀다가 마지막에
|
||||
한 번만 실행한다.
|
||||
**⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] 그 억제는 `Effect` 내부 플래그
|
||||
(`_installing`)가 아니라 사적 `Blocker` 하나가 한다.** 여기 한때
|
||||
*"[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 — 게이트도 `Blocker`도
|
||||
안 쓴다"*고 적혀 있었는데, 그 플래그는 **생성자 구간만 덮어 바인드 구간을
|
||||
놓쳤다**(`Ref` 콜백을 바인드마다 재등록하던 옛 모델에서 `Rerun`이 dep 수만큼
|
||||
돌았다). 지금은 dep 등록이 **생성자 한 곳**으로 모였고 억제는
|
||||
`self._blocker:On()` … `:OffWithoutEmit()` 구간이 맡는다 —
|
||||
`materializeSlotTree`가 쓰는 관용구와 같은 모양이고, 위 "확정 구조" 절과
|
||||
생성자 의사코드가 소스다. **`_installing`은 폐기된 필드다.**
|
||||
(2026-08-21에 `Gate` 재사용을 접었던 근거 — *"설치 구간엔 어떤 `Set`도 안
|
||||
일어나 게이트에 쌓이는 소스가 없다"*, `base/gate-plan.md`의 8번 — 는 그대로
|
||||
유효하다. 게이트가 아니라 `Blocker`를 쓰는 이유이기도 하다.)
|
||||
- **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만
|
||||
돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`,
|
||||
`A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를,
|
||||
|
|
@ -453,21 +699,29 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
|
|||
```lua
|
||||
-- 각 dep의 내부 Observer가 공통으로 거는 클로저
|
||||
function(self, from)
|
||||
if handle._installing then return end -- 설치 구간 억제 (아래)
|
||||
if not canExecute(handle) then return end -- 발화 게이트
|
||||
if handle._blocker:IsOn() then return end -- 등록 구간 억제 (위 정정)
|
||||
if handle._epochs:Update(from) then
|
||||
handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행
|
||||
end
|
||||
end
|
||||
```
|
||||
**⚠️ 억제 플래그가 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회
|
||||
**⚠️ 억제 확인이 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회
|
||||
실행에는 `from`이 없어서(`nil`, `base/source-state-plan.md`의
|
||||
"`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면
|
||||
설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다
|
||||
(2026-08-21 커밋 전 `/code-review high` 발견).
|
||||
- **`Ref` 의존성은 이 맵에 안 낀다** — `Ref`는 `Epoch`가 아니고
|
||||
`:Callback`으로 발화하므로 `from`이 없다. `Ref` 쪽 발화는 그대로 매번
|
||||
`fn`을 돌린다(`Ref`는 반복 재설정마다 도는 게 계약이고, 공통 상류 문제
|
||||
자체가 없다).
|
||||
(2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가
|
||||
`_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로다.
|
||||
- **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** —
|
||||
여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from`이
|
||||
없다"*고 적혀 있었는데, **`Ref`가 `Epoch`로 승격**되며(`base/ref-plan.md`)
|
||||
공개 `.Revision`을 갖게 됐다. 그래서 `_epochs`가 State/Source/`Ref`를
|
||||
**같은 방식으로** 담고(**⚠️ [2026-08-25 정정]** 한때 여기 "State/Source/
|
||||
`Ref`를 균일하게"라 적었는데 **`State`는 `Epoch`가 아니다** — 등록이
|
||||
`isEpoch`로 갈려 `Source`/`Ref`는 `:Sync`, `State`는 `:TrackFrom`이다,
|
||||
`base/state-epoch-plan.md` §4·§8), 같은 `Ref`를 두 번 dep으로 넣어도 키 dedup으로 접힌다
|
||||
(`H-70`). `Ref`가 반복 재설정마다 도는 계약 자체는 안 바뀐다 — 리비전이
|
||||
매번 갱신되므로 `Update`가 매번 `true`다.
|
||||
- **검토했다 접은 대안**: deps를 하나의 파생 노드로 수렴시켜 다이아몬드
|
||||
dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 바꿔야 해서
|
||||
위 안보다 못하다. `useEffect`처럼 "N번 돌아도 무방"으로 계약을 느슨하게
|
||||
|
|
@ -475,14 +729,20 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
|
|||
- 근거 기록은 `reference/epoch-brand-composition.md`(이 갭이 `EpochMap`
|
||||
분리의 직접 발단이었다).
|
||||
- **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도
|
||||
N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를
|
||||
같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피
|
||||
모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."*
|
||||
N개다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 모든 옵져버들이
|
||||
내부에 들어가 있을것이므로 가능하다."*
|
||||
**⭐ [2026-08-25 정정, 7라운드 `H-58`/`H-59`] 다만 그 "전부"를 덮는 주체는
|
||||
bind/unbind cascade가 아니다** — `bindLifetime`/`unbindLifetime`은 이제
|
||||
**핸들 하나에만** 적용되고, N개 dep의 발화 여부는 `canExecute(handle)`
|
||||
하나가 전담한다(위 "확정 구조" 절). 등록도 생성자 한 곳에서 끝난다.
|
||||
|
||||
**우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M2의
|
||||
`Effect` 구현과 같이 간다. **[2026-08-21]** 여기 있던 "억제 장치 때문에
|
||||
`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Effect` 내부 플래그로
|
||||
확정돼 `Gate`에 안 걸린다.
|
||||
`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Gate`에 안 걸린다.
|
||||
**[2026-08-25 정정]** 억제 수단이 내부 플래그에서 **사적 `Blocker`**로
|
||||
바뀌었으므로 선행은 `Blocker`의 기본 메커니즘(`On`/`Off`/`IsOn`/
|
||||
`OffWithoutEmit`)이다 — 그건 `GateNode`/`:Policy`와 무관하게 독립 완결이라
|
||||
`Gate`보다 뒤일 필요는 여전히 없다(`ROADMAP.md` M2의 그 각주).
|
||||
|
||||
## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체)
|
||||
|
||||
|
|
|
|||
|
|
@ -91,10 +91,43 @@ end)
|
|||
**확정 형태**:
|
||||
|
||||
```lua
|
||||
-- setup: (emit: () -> ()) -> (onUpstreamEmit: () -> ())
|
||||
-- setup: (emit: (commit: boolean?) -> boolean) -> (onUpstreamEmit: () -> ())
|
||||
local gated = state:Gate(setup) -- ComputeNode처럼 GateNode를 하나 만든다
|
||||
```
|
||||
|
||||
**⭐⭐ [2026-08-25 확정, 7라운드 `H-55`/`H-86`] `emit`에 인자와 반환값이
|
||||
붙는다.** 인자 목록이 늘지 않으므로 `H-49`의 *"`setup` 시그니처는 안
|
||||
바뀐다"*를 최소로만 되짚는다.
|
||||
|
||||
| 호출 | 뜻 |
|
||||
|---|---|
|
||||
| `emit()` / `emit(true)` | 평소대로 흡수 집합을 flush하고 전파 |
|
||||
| `emit(false)` | **흡수 집합을 버리고** 전파하지 않는다 |
|
||||
| 반환값 | "실제로 내보내거나 버릴 게 있었는가"(= 집합이 비어 있지 않았는가) |
|
||||
|
||||
- **왜 필요한가 (1) — 버릴 수가 없었다(`H-55`).** 아래 4번은 *"emit 없이
|
||||
푸는 경로는 집합을 **버려야** 한다"*고 확정하는데, `Blocker`가
|
||||
`blocker:Policy(emit)` **값**으로 떨어져 나온 뒤로 정책이 손에 쥔 건
|
||||
`emit` 하나뿐이라 **집합에 닿는 통로가 없다.** 그래서
|
||||
`Trailing = false`/`Cancel`/`OffWithoutEmit`이 "버린다"가 아니라
|
||||
"미룬다"가 되어, 버리기로 했던 옛 원천들이 **다음 버스트에 실려
|
||||
나간다**(4번이 경고한 바로 그 모양이 정책 분리로 되살아난 것).
|
||||
- **왜 필요한가 (2) — 읽을 수가 없었다(`H-86`).** 5번이 *"`pending` 같은
|
||||
정책 상태는 `HasBlockedEmit`으로 흡수한다"*고 확정했는데, `blocker-plan.md`가
|
||||
`HasBlockedEmit`을 **게이트 노드의 `withheld`**로 흡수해버려 **양쪽 다
|
||||
안 들고 있다.** 그 결과 `Throttle`의 창이 idle로 못 돌아가
|
||||
**leading이 첫 버스트 이후 영구 소실**되고 타이머 체인이 안 끝나
|
||||
`base/debounce-throttle-plan.md`의 "8. 라이프사이클 / GC 분석" 절이 확정한 "유계 GC"가 깨진다(실측 확인).
|
||||
- **`Throttle`의 `onWindowEnd`가 이 한 줄로 닫힌다**:
|
||||
```lua
|
||||
if not emit() then window = nil -- 보류분 없었음 → 완전 idle 복귀
|
||||
else rearm() end
|
||||
```
|
||||
- **기각된 안**: `setup(emit, discard, hasWithheld)`처럼 핸들을 늘리는 것
|
||||
(인자 목록 자체가 바뀌어 `H-49`를 더 크게 되짚는다), 정책이 자기
|
||||
`pending`을 다시 드는 것(`H-32`를 손으로 다시 막아야 하고 버리기는
|
||||
여전히 안 닫힌다).
|
||||
|
||||
`Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Block(blocker)`가
|
||||
내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.**
|
||||
`:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로
|
||||
|
|
@ -120,8 +153,13 @@ end)
|
|||
- **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고,
|
||||
타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State<T>) -> U`
|
||||
함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력).
|
||||
`Apply`를 쓸 이유 자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시
|
||||
되살아나면 `luau-test` 스파이크 한 개로 판정할 것.
|
||||
**⭐ [2026-08-25 실측 확정, 7라운드 `H-94`] 안 들어간다.**
|
||||
`luau-analyze`로 재현했고(제네릭·비제네릭 양쪽), 타입 레벨 `__call`은
|
||||
`self`도 못 받는다. 여기서 *"확인할 필요도 없어졌다"*고 접었지만
|
||||
**`Debounce`/`Throttle` 쪽엔 그 불확실성이 그대로 남아 있었다** — 바로
|
||||
아래 항목이 *"`Debounce`/`Throttle`은 `:Apply` 그대로 둔다"*를 확정하기
|
||||
때문. 그래서 애플리커티브 팩토리는 `__call`이 아니라 **지정된 필드**로
|
||||
자기를 노출한다(`base/source-state-plan.md`의 "`state:Apply(factory)`" 절).
|
||||
- **2단 구조는 그대로 유효하다** — 사용자 관찰(*"Gate 의 callback 으로 얻어진
|
||||
emit과, 리턴해낸 클로저가 호출되는걸로 배선은 가능"*) 대로, 바깥 함수가
|
||||
**그 노드의 `emit`을 캡처**하고 반환 클로저가 상류 emit마다 정책을 태운다.
|
||||
|
|
@ -173,11 +211,23 @@ end)
|
|||
중첩 flush의 `clear`가 돌아 **바깥 전파의 남은 갈래가 빈 집합**을 받는다.
|
||||
**모델이 아니라 그 의사코드가 문제였다** — `emit()`이 flush인 이상 들어가는
|
||||
순간 떼어내는 게 원래 모양이고, 그러면 그 경로 자체가 없다:
|
||||
```lua
|
||||
-- ⭐ [2026-08-25 보강, 7라운드 `H-89`] 아래 8번과 `H-89`가 확정한
|
||||
-- **네 단계**를 그대로 적는다. 한때 이 스니펫이 가운데 둘만 갖고
|
||||
-- 있어서, 이걸 보고 짜면 "빈 배치도 통지" + "`emitEpochMap`을 언제
|
||||
-- `Sync`하는지 불명" 두 실수가 난다.
|
||||
function GateNode:_flush(commit: boolean?)
|
||||
if next(self._withheld) == nil then return false end -- (1) 빈 배치 얼리리턴
|
||||
local batch = self._withheld
|
||||
self._withheld = newWeakK() -- (2) 새 테이블. clear가 아니다
|
||||
if commit == false then return true end -- 버리기(H-55) — 전파도 Sync도 안 함
|
||||
self.emitEpochMap:Sync(batch) -- (3) 전파 **앞**에서 한꺼번에
|
||||
emitDownstream(self, batch) -- (4) 떼어낸 batch를 페이로드로
|
||||
return true
|
||||
end
|
||||
```
|
||||
local batch = self._withheld
|
||||
self._withheld = newWithheld() -- 새 테이블. clear가 아니다
|
||||
emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다
|
||||
```
|
||||
반환값이 `emit(commit) -> boolean`의 그 반환값이다 — **"실제로
|
||||
내보내거나 버릴 게 있었는가"**(위 2번, `H-55`/`H-86`).
|
||||
**⚠️ [정정, 2026-08-24 6라운드 손 트레이싱 `H-9`] 그 새 테이블도 weak여야
|
||||
한다.** 여기 원래 `self._withheld = {}`라고 적혀 있었는데, 그러면 위에서
|
||||
**weak key로 확정한 집합이 첫 flush 스왑에서 평범한 테이블로 바뀐다** —
|
||||
|
|
@ -212,7 +262,12 @@ end)
|
|||
게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이
|
||||
먼저 풀리든 정보가 안 샌다.
|
||||
- **게이트의 `emitEpochMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다
|
||||
(집합 전체에 대해 한꺼번에, `:Sync(batch)`). **이건 `state-epoch-plan.md`
|
||||
(집합 전체에 대해 한꺼번에, `:Sync(batch)`).
|
||||
**⭐ [2026-08-25 명시, 7라운드 `H-89`] `:Sync(batch)`는 전파 *앞*이다** —
|
||||
flush 순서는 **빈 배치 얼리리턴 → 스왑 → `:Sync(batch)` → 전파**다.
|
||||
지금까지 *"실제로 전파할 때"*라고만 적혀 앞/뒤가 안 정해져 있었다.
|
||||
그리고 **수신 시점의 판정에는 `:Peek`을 쓴다** — `:Update`는 덮으므로
|
||||
이 예외와 양립하지 않는다(`base/state-epoch-plan.md` §3). **이건 `state-epoch-plan.md`
|
||||
§4 의사코드의 유일한 예외이고, 그 문서에 예외로 기록돼 있다.** 그래야 "내가 하류로 던진
|
||||
리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중 같은 리비전이
|
||||
다른 경로로 또 오면 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에
|
||||
|
|
@ -224,14 +279,85 @@ end)
|
|||
게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이
|
||||
**단조 증가**하고, 나중에 아무 `Epoch`나 한 번 통과하는 순간 **버리기로
|
||||
했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다.
|
||||
- **⭐⭐ [2026-08-25 정정, 7라운드 `H-67`] 여기 근거로 든 용례가
|
||||
틀렸다.** `Dispatch.drive`의 배치 게이팅은 `base/blocker-plan.md`가
|
||||
*"이 용례는 `state:Block()`을 전혀 호출하지 않으므로 gated state도 …
|
||||
생기지 않는다"*고 명시한 경로라 **애초에 `withheld` 집합이 없다.**
|
||||
결론(비워야 한다)은 그대로 유효하고 **근거가 될 용례만 바꾼다** —
|
||||
`state:Block(b)`로 만든 gated state에 `b:OffWithoutEmit()`을 반복해
|
||||
거는 경우, 또는 `Throttle{Trailing = false}`가 창마다 버리는 경우가
|
||||
실제로 집합이 단조 증가하는 자리다.
|
||||
- **⭐⭐ [2026-08-25] 정책은 이걸 스스로 할 수 없다** — 위 2번의
|
||||
`emit(false)`가 그 통로다(`H-55`).
|
||||
- 그렇게 비우고 나면 하류의 `emitEpochMap`은 뒤에 남지만, 그 `Epoch`의
|
||||
다음 진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요.
|
||||
|
||||
**⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라
|
||||
**노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화).
|
||||
**⭐ 그래서 `setup`의 인자 목록은 안 바뀐다.** 집합을 **채우는** 건 정책이
|
||||
아니라 **노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위
|
||||
단순화). **[2026-08-25 정정]** 다만 집합을 **버리고/읽는** 것은 정책이
|
||||
해야 하는 일이었고 통로가 없었다 — 그래서 위 2번의
|
||||
`emit(commit: boolean?) -> boolean`이 생겼다. 인자 **목록**은 그대로다.
|
||||
정책은 소스를 몰라도 되고, `Throttle`처럼 나중에 타이머에서 `emit()`을
|
||||
부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다.
|
||||
|
||||
### ⭐⭐ `GateNode` 조립 — 필드 목록과 `:_receive`/`:_flush` (2026-08-25 신설)
|
||||
|
||||
**새 결정이 아니라 조립이다.** 판정 규칙은 `base/state-epoch-plan.md` §4,
|
||||
흡수 집합은 위 4번, `emit` 시그니처는 위 2번 — 지금까지 **세 문서에 나뉘어
|
||||
있어 한 곳에서 순서를 볼 수 없었다.** 구현자가 조립을 잘못할 여지를
|
||||
없애려고 여기 모은다.
|
||||
|
||||
```lua
|
||||
-- 필드 (ComputeNode와 같은 층위)
|
||||
GateNode = {
|
||||
_hold = { <상류 State/Source> }, -- 하류 → 상류 강참조(`source-state-plan.md`)
|
||||
_subs = <weak-키 구독자 집합>, -- 원소는 Observer 값 / 자식 State
|
||||
valueEpochMap = EpochMap(), -- §4 규칙: 값이 낡았는가
|
||||
emitEpochMap = EpochMap(), -- ⚠️ 수신 때가 아니라 **flush 때** 갱신
|
||||
_withheld = newWeakK(), -- 흡수 집합 { [Epoch] = true }
|
||||
onUpstreamEmit = <정책이 돌려준 클로저>, -- setup(emit)의 반환값
|
||||
}
|
||||
|
||||
function GateNode:_receive(from)
|
||||
-- (1) 판정은 §4 규칙 1~3을 **그대로** 돈다. 단 emit 쪽은 `Peek`이다 —
|
||||
-- `Update`는 덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻이 깨진다.
|
||||
local valueChanged = self.valueEpochMap:Update(from)
|
||||
local emitChanged = self.emitEpochMap:Peek(from)
|
||||
if valueChanged then self:_invalidate() end -- 캐시 카운터 갱신
|
||||
if not (valueChanged or emitChanged) then return end -- 규칙 3: 삼킨다
|
||||
-- (정책도 안 돌고 집합에도 안 넣는다)
|
||||
|
||||
-- (2) 통과한 것만 흡수 집합에 합친다. `from`이 집합이면 unfold해서 합친다
|
||||
-- — 게이트-게이트 중첩에서 중복이 저절로 접힌다(집합이라서).
|
||||
if isEpochSet(from) then
|
||||
for epoch in from do self._withheld[epoch] = true end
|
||||
else
|
||||
self._withheld[from] = true
|
||||
end
|
||||
|
||||
-- (3) 정책에 넘긴다. 정책은 손에 `emit` 하나만 쥐고 있고,
|
||||
-- 그걸로 flush(`emit()`)도 버리기(`emit(false)`)도 조회(반환값)도 한다.
|
||||
self.onUpstreamEmit()
|
||||
end
|
||||
|
||||
function GateNode:_flush(commit: boolean?): boolean -- 이게 정책이 받는 `emit`
|
||||
if next(self._withheld) == nil then return false end -- (1) 빈 배치면 아무것도 안 함
|
||||
local batch = self._withheld
|
||||
self._withheld = newWeakK() -- (2) 새 테이블. clear가 아니다
|
||||
if commit == false then return true end -- 버리기 — 전파도 Sync도 안 함
|
||||
self.emitEpochMap:Sync(batch) -- (3) 전파 **앞**에서 한꺼번에
|
||||
self:_emitDown(batch) -- (4) 떼어낸 batch가 페이로드
|
||||
return true
|
||||
end
|
||||
```
|
||||
|
||||
- **반환값이 곧 위 2번의 `emit(commit) -> boolean`**이다 — "실제로 내보내거나
|
||||
버릴 게 있었는가"(`H-55`/`H-86`).
|
||||
- **`valueEpochMap`도 있다는 걸 여기서 명시한다** — 지금까지 *"규칙 1~3을
|
||||
그대로 돈다"*는 문장에서 추론해야 했다.
|
||||
- **`_emitDown`은 State와 같은 것**이다(`base/source-state-plan.md`의
|
||||
"전파 루프 — 확정 의사코드" 절) — 게이트가 전파 루프를 따로 갖지 않는다.
|
||||
|
||||
5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 —
|
||||
지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,
|
||||
`debounce-throttle-plan.md` 5-4).
|
||||
|
|
|
|||
|
|
@ -335,15 +335,22 @@ function bindLifetime(inst, value)
|
|||
-- `slot._detachCleanup`(Detach 요소를 파괴하는 유일 경로)과 `OnDestroyed`가
|
||||
-- 통째로 무동작이었다(`base/effect-plan.md`).
|
||||
-- **왜 이 자리인가**(사용자 판단 2026-08-24): `Destroying`은 엔진이 아는
|
||||
-- 요소라 엔진을 다루는 자리에 있어야 하고, 이 함수는 이미 `Effect`의 내부
|
||||
-- Observer 목록으로 cascade하며 값을 들여다본다. `Effect`가 바인드되는
|
||||
-- 요소라 엔진을 다루는 자리에 있어야 하고, `Effect`가 바인드되는
|
||||
-- 경로가 둘(children 배열 leaf / `activateList`의 `_detachCleanup` 직접
|
||||
-- 바인드)이라 **호출부 쪽에 두면 반드시 한쪽이 샌다.**
|
||||
--
|
||||
-- ⭐⭐ [2026-08-25 정정, 7라운드 `H-58`/`H-59`] **내부 Observer로 cascade하지
|
||||
-- 않는다.** 여기 원래 `for _, observer in ipairs(value._observers) do
|
||||
-- bindLifetime(inst, observer) end`가 있었는데, 그 필드 자체가 폐기됐고
|
||||
-- (`_deps` 하나로 통합) dep 등록은 **생성자에서 한 번만** 일어난다
|
||||
-- (`WeakSubscribe`/`WeakCallback`). 그대로 두면 `_observers`가 `nil`이라
|
||||
-- 순회에서 죽고, 피해 가도 **바인드마다 `Rerun`이 도는 `H-58`이
|
||||
-- 되살아난다.** 발화 게이팅은 전부 `canExecute(handle)` 하나가 맡는다 —
|
||||
-- `base/effect-plan.md`의 "확정 구조" 절이 소스.
|
||||
if isEffect(value) then
|
||||
for _, observer in ipairs(value._observers) do -- N-deps cascade
|
||||
bindLifetime(inst, observer)
|
||||
end
|
||||
value:_bindDestroying(inst) -- Destroying 연결 + Ref dep 콜백 (재)등록
|
||||
value:_bindDestroying(inst) -- Destroying 연결 + **조건부 캐치업 1회**
|
||||
-- (`if not self._installed or
|
||||
-- self._epochs:Refresh() then self:Rerun() end`)
|
||||
-- 의사코드는 `base/effect-plan.md`가 소스.
|
||||
-- 그 안에서 주입 op `onDestroying(inst, fn)`을
|
||||
-- 부른다(base는 Instance를 모른다).
|
||||
|
|
@ -352,15 +359,16 @@ end
|
|||
|
||||
function unbindLifetime(value)
|
||||
-- [2026-08-24 `H-11`] bind의 대칭 — **cleanup은 부르지 않는다.**
|
||||
-- `Destroying` 연결을 끊고 `Ref` dep 콜백을 떼기만 한다. cleanup을 여기서
|
||||
-- 부르면 `destroySlotTree`가 `_detachCleanup`을 손으로 비운 뒤 unbind하는
|
||||
-- 경로에서 이중 호출이 된다(`base/effect-plan.md`의 그 항목이 소스).
|
||||
-- 이 대칭 덕에 포탈이 자연히 성립한다 — 언마운트가 떼고 재마운트가 다시 건다.
|
||||
-- cleanup을 여기서 부르면 `destroySlotTree`가 `_detachCleanup`을 손으로
|
||||
-- 비운 뒤 unbind하는 경로에서 이중 호출이 된다(`base/effect-plan.md`).
|
||||
--
|
||||
-- ⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] **`Ref` 콜백도 내부 Observer도
|
||||
-- 안 뗀다.** 둘 다 생성자에서 `Weak*`로 걸려 있고 발화는 `canExecute`가
|
||||
-- 막는다 — 떼었다 붙이는 그 춤이 `H-58`의 원인이었다. 포탈은 이제
|
||||
-- "떼고 다시 걸기"가 아니라 **재바인드 시 조건부 캐치업 1회**로
|
||||
-- 성립한다(`_bindDestroying`).
|
||||
if isEffect(value) then
|
||||
value:_unbindDestroying()
|
||||
for _, observer in ipairs(value._observers) do
|
||||
unbindLifetime(observer)
|
||||
end
|
||||
value:_unbindDestroying() -- Destroying 연결만 끊는다
|
||||
end
|
||||
|
||||
local gchold = BindData:GetWeak(value, "gchold")
|
||||
|
|
@ -512,12 +520,26 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라
|
|||
`canExecute`가 "어디서 불리는가"는 지금까지 어느 문서에도 코드로 없었음(위
|
||||
정정 배너 참고). 확정된 위치는 **State의 전파 루프**:
|
||||
|
||||
- State는 자기 구독자(Observer의 emit 클로저)를 **weak로** 담는다 — 살려두는
|
||||
책임은 State가 아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에
|
||||
있고, 어디에도 안 묶인 Observer는 그냥 GC되어 구독 목록에서 자연히 빠짐.
|
||||
- 발화 시 각 구독자에 대해 `canExecute(observer)`를 확인하고, 거짓이면
|
||||
**그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`를 건드리는 시도가
|
||||
일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 실제 구현 지점.
|
||||
- State는 자기 구독자를 **weak-키로** 담는다 — 살려두는 책임은 State가
|
||||
아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있고, 어디에도
|
||||
안 묶인 Observer는 그냥 GC되어 구독 목록에서 자연히 빠짐.
|
||||
**⭐ [2026-08-25 정정, 7라운드 `H-56`] 집합의 원소는 "Observer의 emit
|
||||
클로저"가 아니라 **Observer 값**이다.** `bindLifetime(inst, observer)`가
|
||||
Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를
|
||||
담으면 identity가 달라 `canExecute`가 **항상 거짓**이 된다.
|
||||
- 발화 시 **Observer/Effect 구독자에 대해서만** `canExecute(observer)`를
|
||||
확인하고, 거짓이면 **그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`를
|
||||
건드리는 시도가 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의
|
||||
실제 구현 지점.
|
||||
**⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 자식 State 노드는 이 게이트를
|
||||
안 탄다.** 여기 한때 *"각 구독자에 대해"*라고만 적혀 있었는데, 그대로
|
||||
짜면 `:With`/`:Compute`/`:Gate`가 만든 자식 노드가 **전부 걸러진다**
|
||||
(자식은 `bindLifetime`된 적도 `:Subscribe()`된 적도 없어
|
||||
`isBoundAlive`가 항상 거짓) — `A:Set()`이 파생 State에 한 번도 안 닿아
|
||||
그 아래 모든 Observer가 침묵한다. **확정 의사코드는
|
||||
`base/source-state-plan.md`의 "전파 루프 — 확정 의사코드" 절이 소스**이고,
|
||||
자식 노드의 생존은 `canExecute`가 아니라 같은 문서의 `_hold` 불변식이
|
||||
책임진다.
|
||||
|
||||
**`state:Observer(fn)`의 "등록 즉시 1회 실행"은 이 게이팅과 무관**하다 —
|
||||
그건 Observer 생성자 자체의 계약이라 `bindLifetime` 이전에 동기적으로
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ Modifier에는 없음).
|
|||
**[정정, 2026-08-18 구현 전 QA]** 여기 짝으로 적혀 있던 두 서술이 이번
|
||||
라운드에 바뀌었다 — (a) "동적인 건 **문자열 폴백**"의 그 폴백
|
||||
(`store "key"` 문자열 커링)은 **기각**됐고 동적 키는
|
||||
`store:GetDynamic<<T>>(name)`으로 감, (b) 정적 필드가 "**자주 쓰는 것만**"이
|
||||
`store:Of<<T>>(name)`으로 감(**[2026-08-25]** 옛 이름 `GetDynamic`), (b) 정적 필드가 "**자주 쓰는 것만**"이
|
||||
아니라 **생성기가 "GUI에 쓰이는 모든 인스턴스"를 전량 찍어냄**
|
||||
(`base/bind-system-plan.md`). Modifier 타입 생성(M7)이 재사용하는 건 그
|
||||
**패턴**(제네릭 + 생성된 정적 필드)이지 옛 범위 서술이 아님.
|
||||
|
|
|
|||
|
|
@ -188,6 +188,11 @@ export type function CheckVersion(actual: type, pattern: type): type
|
|||
**2개 이상**이면 단일 꺾쇠(`Foo<A, B>`)가 비교 연산자로 오파싱되니
|
||||
반드시 이중 꺾쇠(`Foo<<A, B>>`)를 써야 한다(코퍼스에 이미 있던
|
||||
`AttributeKey<<T>>` 관례와 같은 이유).
|
||||
**⭐ [2026-08-25 확장, 7라운드 `H-73`] 이 관례는 타입 자리 전용이
|
||||
아니다** — Luau의 generic type instantiation은 **값 호출부**에서도,
|
||||
**콜론 메소드**에서도 동작해 `T`를 실제로 묶는다
|
||||
(`store:Of<<number>>("x")`). `luau-analyze` 음성 대조군까지
|
||||
확인했다 — 상세는 `base/store-plan.md`의 "타입 추론 문제" 절.
|
||||
|
||||
`Version` 필드는 Luau 내장 `index<T, "Version">` type function으로 뽑는다
|
||||
(수동 `t:readproperty(...)`보다 간결 — **사용자 제안**으로 채택, 실측 확인
|
||||
|
|
|
|||
|
|
@ -167,11 +167,41 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
|
|||
`Ref(default):Callback(fn)`이 코퍼스 전반에 쓰인다) — 해제 핸들은
|
||||
**`:Uncallback(fn)`**으로 뗀다. 셋이라 `Callbacks[fn] = nil` 한 줄이고,
|
||||
중복 등록이 dedup되므로(위) "몇 번 떼야 하나"가 없다.
|
||||
- `EffectHandle`은 자기가 건 콜백을 들고 있다가 `unbindLifetime`과
|
||||
`:Unsubscribe()`에서 `:Uncallback`한다 — State/Source dep의
|
||||
`_observers`와 대칭(`base/effect-plan.md`).
|
||||
- **⛔ [2026-08-25 폐기, 7라운드 `H-58`] 여기 원래 *"`EffectHandle`은
|
||||
자기가 건 콜백을 들고 있다가 `unbindLifetime`과 `:Unsubscribe()`에서
|
||||
`:Uncallback`한다"*고 적혀 있었다.** 아래 `:WeakCallback` 항목이 그걸
|
||||
**대체한다** — `Effect`는 생성자에서 `ref:WeakCallback(cb)`로 한 번만
|
||||
걸고 강한 주인을 `_deps`에 두며, 바인드/언바인드는 `Ref`를 아예 안
|
||||
건드린다(떼었다 붙이는 그 춤이 `H-58`의 "바인드마다 `Rerun`"을 만들던
|
||||
원인이다). `:Uncallback`은 **사용자가 직접 건 콜백을 떼는** 공개
|
||||
표면으로 남는다. `base/effect-plan.md`의 "확정 구조" 절이 소스.
|
||||
- **`:Wait()`의 대기자는 이 표면의 대상이 아니다** — 발화 시 소진되므로
|
||||
뗄 일이 없다.
|
||||
- **⭐⭐ [2026-08-25 신설, 7라운드 `H-58`/`H-59`] `:WeakCallback(fn)` —
|
||||
약하게 등록하는 짝.** `Weak` 쪽이 **프리미티브**이고 평범한
|
||||
`:Callback(fn)`은 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다.
|
||||
**사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도
|
||||
동일하나 단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다.
|
||||
따라서 내부적으론 Weak 를 구현해 두고, Weak 아닌 곳에서 Weak 를
|
||||
수행하고 gc 처리만 두면 돼요."*
|
||||
- **자료구조**: 약한 등록은 **weak-키 테이블**(`__mode = "k"`)에
|
||||
들어간다 — `.Callbacks`(강함)와 별도 테이블이다. Lua의 weak 모드는
|
||||
테이블 단위라 항목별로 섞을 수 없다. 발화 순회는 두 테이블을 다
|
||||
훑고(각각 스냅샷), `:Uncallback(fn)`은 양쪽을 다 본다.
|
||||
- **왜 필요한가**: `Effect`가 자기 dep `Ref`에 콜백을 걸면
|
||||
`.Callbacks`가 강한 셋이라 **`Ref`가 그 `Effect`를 영원히 붙든다**.
|
||||
그렇다고 바인드/언바인드마다 떼었다 붙이면 *"등록 즉시 1회 호출"*에
|
||||
걸려 **바인드마다 `Rerun`이 돈다**(`H-58`). `:WeakCallback`으로 걸고
|
||||
**강한 주인을 `Effect._deps`에 두면** 둘 다 사라진다 —
|
||||
`Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고
|
||||
`Ref` 쪽 항목도 같이 없어진다(`base/effect-plan.md`의
|
||||
"확정 구조 — 강한 주인은 항상 `Effect`" 절).
|
||||
- **`WeakRef`(1-슬롯 약참조 박스)는 만들지 않는다** — 그건 "`Ref`가
|
||||
`Effect`를 붙든다"만 풀고 "죽은 클로저가 `.Callbacks`에 쌓인다"는
|
||||
못 푼다(사용자 지적). 아이디어만 기록:
|
||||
`WeakRef:Set(v)`/`:Get()`만 주고(`.Value`가 아닌 이유는 내부 값이
|
||||
항상 있다고 확정된 상태가 아니라서), 내부는
|
||||
`setmetatable({}, {__mode = "v"})`의 1-슬롯.
|
||||
- **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자
|
||||
제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인
|
||||
코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로
|
||||
|
|
@ -325,6 +355,41 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
|
|||
담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이
|
||||
없다는 결론, 용어 정리 대상에서 제외됨.
|
||||
|
||||
### ⭐⭐ `Ref`는 `Epoch`를 만족한다 — `.Revision` 신설 (2026-08-25 확정, 7라운드 `H-58`/`H-64`/`H-70`)
|
||||
|
||||
**사용자 제안**: *"혹은 Ref 까지도 Epoch 를 구현해줘도 좋다는 생각.
|
||||
'바뀌였나?' 보는건 source 에 대한 계약이라, 똑같이 실제 값을 가지는 Ref 도
|
||||
이를 구현해주는데 문제가 없음."*
|
||||
|
||||
`base/state-epoch-plan.md` §2가 **이미 예상해둔 확장**이다 — 그 절이
|
||||
`Epoch`를 `Source`가 아니라 최소 인터페이스로 일반화한 실익으로 든 게
|
||||
*"`Source`가 아닌 원천(외부 시계 등)이 특수 분기 없이 낀다 … 그건
|
||||
`EpochBrand:register(self)` 한 줄로 끝난다"*였다.
|
||||
|
||||
- **`Ref<T>`에 공개 필드 `Revision: number`가 생긴다.** `:Set()`이
|
||||
`Source`와 **같은 한 줄**로 갱신한다 — `self.Revision = bit32.bnot(-self.Revision)`
|
||||
(그 문서 §2의 랩어라운드 감소). 공개여야 구조적 만족이 타입 레벨에서
|
||||
성립한다.
|
||||
- **`EpochBrand:register(self)`** — `Source`가 `SourceBrand`이면서 동시에
|
||||
`EpochBrand`인 것과 같은 다중 태깅(`base/brand-plan.md`).
|
||||
- **`.Callbacks`(푸시 경로)는 그대로다** — `Epoch`는 **부기**일 뿐,
|
||||
전파 메커니즘을 대체하지 않는다.
|
||||
- **무엇이 닫히나**:
|
||||
- `Effect._epochs`(`EpochMap`)가 `Ref` dep을 **`Source`와 같은 방식으로**
|
||||
담는다(**⚠️ [2026-08-25 정정]** 한때 "State/Source/`Ref`를 균일하게"라
|
||||
적었는데 **`State`는 `Epoch`가 아니다** — 등록이 `isEpoch`로 갈려
|
||||
`Source`/`Ref`는 `:Sync`, `State`는 `:TrackFrom`, `base/state-epoch-plan.md`
|
||||
§4·§8. 균일해지는 건 **판정 쪽**이다).
|
||||
그래서 포탈 재마운트의 캐치업이 dep 종류에 따라 갈리던 것(`H-64`)이
|
||||
**대칭**이 되고, 판정이 `if self._epochs:Refresh() then self:Rerun() end`
|
||||
한 줄이 된다.
|
||||
- 같은 `Ref`를 deps에 두 번 넣어도 `EpochMap`이 키로 dedup하므로
|
||||
**공짜로** 처리된다(`H-70`) — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로
|
||||
먼저 건 클로저가 `.Callbacks`에 남던 버그도 같이 사라진다.
|
||||
- `Ref` dep 전용 스냅샷 배열 같은 별도 부기가 필요 없어진다.
|
||||
- **`Ref`는 여전히 `State`가 아니다** — emit(전파)이 없고 `Get`/`Compute`도
|
||||
없다. `Epoch`를 만족한다는 건 "직전과 구별되는 표식을 들고 있다"뿐이다.
|
||||
|
||||
### `Ref`의 retract — `State<Ref>` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용)
|
||||
|
||||
> **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료.**
|
||||
|
|
@ -380,13 +445,18 @@ function RefLeafHandler.process(inst, k, v, index)
|
|||
Dispatch.setOffsetSource(inst, k, None)
|
||||
Dispatch.setLength(inst, k, 0, inst)
|
||||
|
||||
local old = relate:GetStrong(inst, k)
|
||||
local old = relate:GetWeak(inst, k) -- ⭐ [2026-08-25 `H-71`] 쓰기가 SetWeak이므로 읽기도 Weak
|
||||
-- (안 맞추면 old가 항상 nil이라 dedup이 통째로 죽는다)
|
||||
if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip
|
||||
bindLifetime(inst, v) -- v가 이미 다른 자리에 살아있으면 여기서 즉시 error —
|
||||
-- 이중 배치 방지("이중 배치 방지" 절 참고), 별도 Relate 불필요
|
||||
v:Set(inst)
|
||||
end
|
||||
relate:SetStrong(inst, k, v)
|
||||
-- ⭐ [2026-08-25, 7라운드 `H-71`] `SetStrong` 아님 — `v:Set(inst)`로 값이
|
||||
-- 자기 바깥 키를 되참조하므로 `SetStrong`이면 **100% 샌다**(실측 50/50).
|
||||
-- dedup은 순수 성능 최적화라 weak로 낮춰도 최대 손해가 "한 번 놓침"이고,
|
||||
-- `v`는 gchold가 이미 강하게 잡는다(`base/relate-plan.md`의 슬롯 표).
|
||||
relate:SetWeak(inst, k, v)
|
||||
return function(nextValue)
|
||||
-- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는
|
||||
-- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요)
|
||||
|
|
@ -399,7 +469,7 @@ function RefLeafHandler.process(inst, k, v, index)
|
|||
-- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고
|
||||
-- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이
|
||||
-- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음.
|
||||
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
|
||||
if relate:GetWeak(inst, k) == v then relate:SetWeak(inst, k, nil) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -411,7 +481,7 @@ end
|
|||
불리게 함.
|
||||
- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우
|
||||
이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로)
|
||||
아예 없고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로
|
||||
아예 없고 `relate:GetWeak(inst,k)`도 `nil`이라 `process`가 바로
|
||||
`v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의
|
||||
구현으로 자연히 커버됨, 케이스 분기 불필요.
|
||||
- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째
|
||||
|
|
|
|||
|
|
@ -90,7 +90,66 @@ Destroy 전까지 userdata 동일성을 고정해주므로, 모든 `inst`-키 `R
|
|||
`slot._mountedInst`는 그 값(`slot`)이 자기 키(`physicalTarget`)를 다시
|
||||
참조하는 필드일 뿐이라 GC 안전 — `base/slot-plan.md` "재귀 메커니즘"
|
||||
절)는 **단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가
|
||||
테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문. 하지만
|
||||
테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문.
|
||||
|
||||
> **⛔⛔ [2026-08-25 전면 정정, 7라운드 `H-71`/`H-77`] 바로 위 "단일 `Relate`
|
||||
> 안에서는 안전함"은 **거짓이다.** 커밋된 `quad-base/src/Relate.luau`로
|
||||
> 실측했다.**
|
||||
>
|
||||
> 위험은 **값 기준 하나**가 아니라 **슬롯 셋**으로 갈린다:
|
||||
>
|
||||
> | 슬롯 | `SetStrong` | `SetWeak` |
|
||||
> |---|---|---|
|
||||
> | 바깥 키(`inst`) | weak(설계) | weak(설계) |
|
||||
> | **내부 키(`key`)** | **강함** | **강함** |
|
||||
> | 값(`value`) | **강함** | weak |
|
||||
>
|
||||
> ```lua
|
||||
> -- 커밋된 Relate.luau, 그대로
|
||||
> buckets[inst] = bucket -- buckets는 __mode = "k"
|
||||
> bucket.StrongMap[key] = value -- 평범한 테이블 → 키·값 모두 강함
|
||||
> bucket.WeakMap = setmetatable({}, { __mode = "v" }) -- **값만** weak
|
||||
> ```
|
||||
>
|
||||
> Luau엔 ephemeron이 없으므로 **버킷 안의 무엇이든 `inst`를 되참조하면
|
||||
> `buckets`의 weak 키가 자기 버킷을 통해 살아남는다.**
|
||||
>
|
||||
> - **`SetStrong`의 값이 `inst`를 되참조하면 100% 샌다**(`H-71`, 50/50
|
||||
> 실측). `SetWeak`으로 낮추면 0/50으로 안전해진다.
|
||||
> - **내부 키가 `inst`를 되참조하면 `SetStrong`/`SetWeak` 둘 다 샌다**
|
||||
> (`H-77`, 50/50 · 50/50 실측) — `WeakMap`도 **키는 강하게** 잡기 때문.
|
||||
> 이 슬롯은 위 규칙 문단에 **아예 등장하지 않았다.**
|
||||
>
|
||||
> **그래서 지금 규칙은 이렇다**:
|
||||
>
|
||||
> 1. **값이 바깥 키를 되참조하면 `SetWeak`을 쓴다.** dedup 기록처럼
|
||||
> "다른 곳에서 안전하게 유지되는 것"은 애초에 항상 `SetWeak`이라는
|
||||
> 아래 규칙과 같은 결이다. 실제로 물리던 자리는
|
||||
> `RefLeafHandler.process`와 `ObserverEffectLeafHandler.process`의
|
||||
> dedup 기록이고, 둘 다 `SetWeak`으로 정정됐다.
|
||||
> 2. **내부 키로 쓰는 객체는 바깥 키를 되참조하면 안 된다** — `SetWeak`으로
|
||||
> 도망갈 수 없는 슬롯이라 규칙으로 막는 수밖에 없다. 지금 내부 키가
|
||||
> 객체인 자리는 둘이다:
|
||||
> - **`runInitRelate`**(내부 키 = `initFn`) — **안전하다.** `RunInit`은
|
||||
> `initFn(self)`로 module을 **인자로** 받고, 멱등 가드가 성립하려면
|
||||
> 애초에 **하나의 고정 함수**여야 하므로(인라인 클로저면 매번 새
|
||||
> identity라 dedup 자체가 무의미) 바깥 변수를 캡처할 이유가 없다.
|
||||
> - **⚠️ `groupClaimKeys`**(내부 키 = 그룹 `Attribute`의 값 객체 `v`,
|
||||
> `base/attribute-plan.md`) — **자동으로 안전하지 않다.** 그 값은
|
||||
> 사용자가 만든 Store/Source이고, 거기 담긴 값이 `inst`로 되돌아갈 수
|
||||
> 있다(예: 같은 트리의 `Ref`가 그 `inst`로 채워진 뒤 Store에 담기는
|
||||
> 경우). **[2026-08-25 `/code-review high` 지적]** 여기 한때 *"둘 다
|
||||
> 안전하다"*고 단정했는데 `runInitRelate` 쪽 논거만 있었다.
|
||||
> **그래서 이건 증명이 아니라 계약이다** — 그룹 `Attribute`의 값
|
||||
> 객체는 `inst`를 되참조하면 안 되고, 그 제약을 `attribute-plan.md`가
|
||||
> 같이 적는다. 되참조가 필요해지면 내부 키를 값 객체가 아니라
|
||||
> **이름/토큰**으로 바꿔야 한다(값 객체는 그때 값 슬롯으로 내려가고,
|
||||
> 그 슬롯은 `SetWeak`으로 도망갈 수 있다).
|
||||
> 3. **`luau-test/done/07`에 되참조 음성 대조군을 추가할 것** — 그 파일은
|
||||
> "GC-native 아키텍처의 핵심 전제를 검증했다"고 여러 문서에 인용되는데
|
||||
> 실제로는 **안전한 모양만** 봤다.
|
||||
|
||||
하지만
|
||||
**서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 제공하는
|
||||
상호 순환**(예: `RelateA[inst]=value`(강)와 `RelateB[value]=inst`(강)가
|
||||
동시에 존재)은 완전히 다른, 더 위험한 모양 — `inst`의 reachability
|
||||
|
|
|
|||
|
|
@ -991,7 +991,22 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it
|
|||
위 "용어 주의" 참고.
|
||||
- **`updateFn<UD = any>(item, index: number, offset: Source<number>,
|
||||
prev: T?, userdata: UD?): (T | nil, UD?)` — 매 reconcile 사이클마다
|
||||
모든 key에 대해 호출됨.** `:List`는 더 이상 item을 위해 `Source`를
|
||||
모든 key에 대해 호출됨.**
|
||||
- **⭐⭐ [2026-08-25 정정, 7라운드 `H-95`] 이 반환 타입 그대로면 strict에서
|
||||
정상 용례가 전부 막힌다.** Luau는 콜백이 **선언된 것보다 적게
|
||||
반환**하면 에러다(`Expected 'El?, number?', but got 'El?'`) — 이 문서가
|
||||
드는 예시들이 `return frame` 한 값만 돌려주는데 그게 통과하지 않는다.
|
||||
**확정 형태는 함수 타입의 유니온**이다:
|
||||
```lua
|
||||
type UpdateFn<T, UD> = Fn2<T, UD> | Fn1<T> | Fn0
|
||||
-- Fn2: (...) -> (T?, UD?) Fn1: (...) -> T? Fn0: (...) -> ()
|
||||
```
|
||||
`luau-analyze` 실측에서 네 모양(2개/1개/`nil`/없음)이 전부 통과하고
|
||||
**엉뚱한 타입을 돌려주면 여전히 잡힌다**(음성 대조군 확인).
|
||||
*"항상 명시적으로 반환하라"*를 계약으로 두는 안은 기각 — 인체공학이
|
||||
나쁘고 `--!nocheck` 코드에선 조용히 지나간다. 단일 옵셔널 반환인
|
||||
`Effect`의 `fn`은 가변 반환 팩(`-> ...(() -> ())`)이 더 맞는다
|
||||
(`base/effect-plan.md`). `:List`는 더 이상 item을 위해 `Source`를
|
||||
대신 만들어주지 않음(아래 "왜 `Source`를 `:List`가 안 만드는가" 참고,
|
||||
2026-08-11 세션에 `index`도 같은 원칙으로 편입) — `item`/`index`는
|
||||
매번 그 사이클의 raw 현재값 그대로 넘어감, 반응형으로 쓸지는
|
||||
|
|
@ -2577,7 +2592,7 @@ function rawUnmount(self, index)
|
|||
nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
|
||||
end
|
||||
|
||||
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.N — 아래 참고
|
||||
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고
|
||||
recompute(self, bk) -- 자리가 없어지는 경로엔 setLength가 없으므로 여기서 명시 호출
|
||||
end
|
||||
|
||||
|
|
@ -2597,7 +2612,8 @@ function rawDetach(self, index)
|
|||
nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
|
||||
end
|
||||
|
||||
spliceArraysDown(self, index) -- `_elemIndex`에서도 이 요소가 빠진다(트리 밖이 됨)
|
||||
spliceArraysDown(self, index) -- `_elemIndex`/`bk.tokens`/`bk.indexOfToken`에서도
|
||||
-- 이 요소·자리가 빠진다(트리 밖이 됨) — 아래 splice 요구 목록
|
||||
recompute(self, bk)
|
||||
end
|
||||
|
||||
|
|
@ -2810,7 +2826,7 @@ function rawRemove(self, index)
|
|||
nativeDispose(element) -- 트리 밖이라 offset이 필요 없다
|
||||
end
|
||||
|
||||
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.N
|
||||
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고
|
||||
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
|
||||
end
|
||||
```
|
||||
|
|
@ -2836,9 +2852,34 @@ end
|
|||
그쪽 error 가드엔 안 걸린다). **동기 재진입이라 "체인 도중 yield 금지"
|
||||
불변식으로는 안 덮인다.** `sourceList`가 `None`으로 채워지는 것과 대칭을
|
||||
맞춰 창 자체를 없앤다.
|
||||
- **캐시를 앞으로 당긴다**(`H-3`) — `bk.invalidAfter = math.min(bk.invalidAfter, index)`.
|
||||
- **캐시를 앞으로 당긴다**(`H-3`) —
|
||||
`bk.invalidAfter = math.min(bk.invalidAfter, index)`.
|
||||
`base/dispatch-core-plan.md`의 무효화 표가 규정한 세 규칙 중 하나이고,
|
||||
지금까지 산문으로만 있고 코드 경로가 없었다.
|
||||
**[2026-08-25 정정]** 한때 여기 `index - 1`로 적었는데, `recompute`의
|
||||
되감기 재개 지점이 `invalidAfter + 1`에서 **`invalidAfter` 자신**으로
|
||||
고쳐지며 그 `-1`이 불필요해졌다(그 문서의 `recompute` 절).
|
||||
- **⭐ 이게 `recompute` 되감기의 신호이기도 하다** — recompute 도중
|
||||
splice가 나면 그 값이 낮아지고, 진행 중인 루프가 그 지점 다음부터
|
||||
되감는다(`base/dispatch-core-plan.md`의 `recompute` 절). 그래서
|
||||
`{a,a,a, b,b, c,c}`에서 앞의 `a,a`가 사라졌는데 커서가 이미 `c`에
|
||||
있어도 복구된다.
|
||||
- **⭐⭐ [2026-08-25 신설, 7라운드 `H-102`] `bk.tokens`와 `bk.indexOfToken`도
|
||||
같이 밀어야 한다.** `setLength`가 만드는 `gatedRecompute` 클로저는 인덱스를
|
||||
캡처하지 않고 **자리별 토큰**으로 `bk.indexOfToken[token]`을 조회한다
|
||||
(`slot._elemIndex`와 같은 개념을 Dispatch 층위로 격상 —
|
||||
`base/dispatch-core-plan.md`). 그래서 splice가 다음 둘을 반드시 해야 한다:
|
||||
1. **`bk.tokens`를 다른 배열과 같이 당긴다**(`lengthList`/`sourceList`/
|
||||
`observers`와 완전히 같은 처리).
|
||||
2. **밀린 자리 전부에 대해 `bk.indexOfToken[token]`을 새 인덱스로
|
||||
갱신하고, 사라진 자리의 토큰 항목은 지운다.** 강한 키 테이블이라
|
||||
안 지우면 **누수**다.
|
||||
|
||||
**안 하면 `H-102`가 그대로 남는다** — 토큰은 안 낡지만 그 토큰이 가리키는
|
||||
인덱스가 낡아, 제거 뒤 `gatedRecompute`가 **엉뚱한 위치로**
|
||||
`bk.invalidAfter`를 당긴다(앞선 자리가 영영 다시 offset을 못 받는다).
|
||||
이 목록에 *"옮겨진 클로저의 위치 추적"*이 셋 어디에도 없던 것이 원래
|
||||
결함이었다.
|
||||
|
||||
**[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는
|
||||
배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가
|
||||
|
|
@ -3298,7 +3339,12 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로
|
|||
-- `dispose`가 막으려던 바로 그 UB가 일어난다. **가드를 분기 밖으로 올린다.**
|
||||
function dispose(value)
|
||||
-- (1) 소유권 가드 — 값 종류와 무관하다(`elementOwner` 조회는 타입을 안 봄)
|
||||
if elementOwner:GetStrong(value) ~= nil then
|
||||
-- ⚠️ [2026-08-25 정정, `/code-review high`] `GetStrong(value)`(1-인자)는
|
||||
-- **항상 `nil`이라 가드가 죽은 코드였다** — `elementOwner`는 위에서
|
||||
-- 3-인자 `SetWeak(element, OWNER, ...)`로만 쓰이고 강한 맵엔 아무것도
|
||||
-- 안 들어간다(`Relate`는 항상 3-인자 `SetWeak`/2-인자 `GetWeak`, 409행).
|
||||
-- `H-71`로 dedup 기록까지 `SetWeak`이 되며 강/약 짝맞춤이 더 중요해졌다.
|
||||
if elementOwner:GetWeak(value, OWNER) ~= nil then
|
||||
error("dispose: 이 값은 아직 트리가 살아있길 요구 중임 — 먼저 Remove/Extract 할 것")
|
||||
end
|
||||
-- (2) [`H-43`] Slot도 Instance도 아닌 값이 백엔드로 그냥 흘러가지 않게
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
> | 갈라진 곳 | 담는 것 |
|
||||
> |---|---|
|
||||
> | **이 문서** | `Source`/`State` 온톨로지·서브타입, 전파 모델, `:With`/`:Compute`/`:Apply`, `previous`, `:Emit`, `Observer`, 구독/생명주기 게이트 |
|
||||
> | `base/store-plan.md` | Store = 이름 붙은 Source 모음 — `defaults`, eager/lazy 생성, `store.key` 타이핑, `:Set()` 문법 |
|
||||
> | `base/store-plan.md` | Store = 이름 붙은 Source 모음 — `defaults`, **명시적 초기화**(2026-08-25, 옛 eager/lazy 이중 모델 폐기), `store.key` 타이핑, `:Set()` 문법 |
|
||||
> | `base/dispatch-core-plan.md` | 디스패치 코어(핸들러 계약, `chains`, 하강 diff) |
|
||||
> | `base/bind-system-plan.md` | 인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인 |
|
||||
|
||||
|
|
@ -89,7 +89,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
|
|||
생기지 않음.
|
||||
- **`RefSource<T>` 같은 별도 타입은 불필요, `Store({defaults})`가
|
||||
내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨** — Store
|
||||
쪽 상세(eager/lazy 생성, `defaults` 템플릿의 성격, 구현 스케치)는
|
||||
쪽 상세(**[2026-08-25]** 명시적 초기화 — 옛 eager/lazy 이중 모델 폐기,
|
||||
`defaults`의 성격, 구현 스케치)는
|
||||
`base/store-plan.md`가 소스. 별도 `__values`류 그림자 실값 저장소도
|
||||
불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에
|
||||
검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/
|
||||
|
|
@ -112,7 +113,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
|
|||
구현 델리게이션 포함)이라 그 금지와 충돌하지 않음.
|
||||
- **동적 키 경로도 `State`가 아니라 `Source`를 반환**하는 것으로 자연히
|
||||
갱신됨 — **[정정, 2026-08-18]** 그 경로는 `store "key"` 문자열 커링이
|
||||
아니라 `store:GetDynamic<<T>>(name): Source<T>`다(문자열 커링은 기각,
|
||||
아니라 `store:Of<<T>>(name): Source<T>`다(**[2026-08-25]** 옛 이름
|
||||
`GetDynamic` — 문자열 커링은 기각,
|
||||
`base/store-plan.md`의 "타입 추론 문제" 절).
|
||||
|
||||
**[해소됨, 2026-08-13 첫 실측 라운드]** 핵심 질문(Source가 State를 구조적으로
|
||||
|
|
@ -205,8 +207,10 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
|
|||
전량은 **`base/state-epoch-plan.md`가 소스** — 여기선 이 절의 다른 서술과
|
||||
어긋나지 않게 요지만 적는다. 여기 있던 "emit은 *항상* 전파된다"는 무조건
|
||||
서술의 역전 원문은 `archive/always-propagate-no-dedup-superseded.md`.
|
||||
- **`invalid`(구현 이름 `rawInvalid`) 플래그의 역할은 "내 캐시가 낡았다"는
|
||||
표시 하나뿐** — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면
|
||||
- **"내 캐시가 낡았다"는 표시의 역할은 그것 하나뿐**(**[2026-08-25 정정]**
|
||||
한때 *"구현 이름 `rawInvalid`"*라 적었는데 그 필드는 **폐기**됐다 —
|
||||
지금은 `cacheTargetCount`/`cacheCurrCount` 카운터 쌍이고, 재계산 도중
|
||||
도착한 무효화와 `fn` 예외를 같이 덮는다. `base/state-epoch-plan.md`) — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면
|
||||
상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, 플래그를 끈다.
|
||||
- **⚠️ `invalid`로 전파를 접는 것은 지금도 금지다.** 2026-08-14에 그
|
||||
방식이 폐기된 이유는 `:Get()`을 호출하지 않는 `Observer`(아래
|
||||
|
|
@ -231,6 +235,52 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
|
|||
("emit 필요 여부" 열린 질문은 이걸로 해소). 전파가 잦아도 부담이 작은
|
||||
이유이기도 함 — 신호 하나가 트리를 훑는 비용이지 재계산 비용이 아님.
|
||||
|
||||
### ⭐⭐ 전파 루프 — 확정 의사코드 (2026-08-25 신설, 7라운드 `H-56`)
|
||||
|
||||
지금까지 이 루프는 **산문으로만** 있었고, 그 공백에서 실제 결함이 나왔다 —
|
||||
`base/lifecycle-pattern.md`의 *"발화 시 각 구독자에 대해 `canExecute(observer)`를
|
||||
확인하고, 거짓이면 그 구독자만 조용히 건너뜀"*을 글자 그대로 짜면
|
||||
**자식 State 노드가 전부 걸러진다**(자식은 `bindLifetime`된 적도
|
||||
`:Subscribe()`된 적도 없어 `canExecute`가 항상 거짓이다). 그러면
|
||||
`A:Set()`이 `A:With(...)`/`A:Compute(...)` 노드에 **한 번도 안 닿고**
|
||||
파생 State 아래의 모든 Observer가 침묵한다.
|
||||
|
||||
```lua
|
||||
function State:_emitDown(from)
|
||||
-- H-23 확정: pairs 순회 중 새 키 추가는 미정의라 먼저 배열로 스냅샷
|
||||
local snap = {}
|
||||
for sub in self._subs do snap[#snap + 1] = sub end
|
||||
for _, sub in ipairs(snap) do
|
||||
if isState(sub) then -- 자식 노드
|
||||
sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3
|
||||
elseif canExecute(sub) then -- Observer / Effect
|
||||
sub.fn(sub, from)
|
||||
end -- 거짓이면 조용히 건너뜀
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- **구독자 집합은 하나**(`self._subs`, weak-키)이고 **원소는 Observer
|
||||
값**이다 — emit 클로저가 아니다. `bindLifetime(inst, observer)`가 Observer
|
||||
**값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를 담으면
|
||||
`canExecute(클로저)`가 identity가 달라 **항상 거짓**이 된다.
|
||||
`base/lifecycle-pattern.md` (4)의 *"Observer의 emit 클로저"* 표현은 이
|
||||
결정에 맞춰 고쳤다.
|
||||
- **자식 State는 `canExecute`를 안 본다** — 그건 "이 값이 어떤 Instance에
|
||||
묶여 살아 있는가"를 묻는 판정이고, 자식 노드의 생존은 위
|
||||
"해소됨 — 중간 State는 `_hold`로 살아남는다" 절의 `_hold` 불변식이
|
||||
책임진다.
|
||||
- **두 집합으로 나누는 안은 기각** — emit마다 스냅샷이 두 번이 되고
|
||||
(아래 비용 참고) 등록/해제 경로도 둘로 갈린다.
|
||||
- **비용**(7라운드 `H-92`): 이 스냅샷은 **파동이 지나는 노드 수만큼** 배열을
|
||||
할당한다. `base/state-epoch-plan.md` §2가 테이블 리비전을 기각한 근거가
|
||||
*"`Set` 한 번마다 테이블 하나를 할당"*이었으므로, 그 문서 §7의 비용
|
||||
서술은 이 할당을 셈에 넣어 실제와 맞춰야 한다. **정확성 문제는
|
||||
아니다** — 순회 중 등록이 미정의인 이상 스냅샷은 필요하다.
|
||||
- **예외 안전성**: 구독자 콜백이 던지면 **그 파동의 나머지 구독자는 그
|
||||
변경에 대해 영구 침묵**한다(값만 자가치유). `pcall`로 감싸지 않는다 —
|
||||
`base/architecture.md`의 예외 계약 절이 소스.
|
||||
|
||||
### 다이아몬드 의존성은 무엇이 푸는가 (2026-08-14 명확화)
|
||||
|
||||
`a → b`, `a → c`, `(b, c) → d` 형태에서 `a`가 한 번 바뀌면 `d`는 두 경로로
|
||||
|
|
@ -300,10 +350,20 @@ State를 여러 갈래가 공유하는 다이아몬드 형태(`b`에서 `c1 = b:
|
|||
수만큼 중복 실행됨 — `previous` 메커니즘이 막으려던 문제를 반대로 다시
|
||||
만들어내는 셈이라 방향이 안 맞음.
|
||||
|
||||
**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야
|
||||
실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건
|
||||
실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는
|
||||
State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 재계산을 막는
|
||||
**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.**
|
||||
**⭐⭐ [2026-08-25 정정, 7라운드 `H-62`] 여기 한때 *"실제로 관측되는(`Get()`되는)
|
||||
State뿐 — 중간에 만들어놓고 아무도 안 보는 State는 구독 등록 자체가 안
|
||||
일어남"*이라 적혀 있었는데 **틀렸다. 구독 엣지는 생성 즉시(eager) 등록된다.**
|
||||
**사용자 확정**: *"생성 즉시 밖에 없다. 옵져버가 실행 안 된다면 get 자체가
|
||||
안 되므로, lazy 하면 아에 등록 될 기회가 없다."* — lazy가 성립하려면 "먼저
|
||||
`Get()`이 일어난다"가 전제인데 `Get()`을 부르는 주체가 바로 그 등록되지
|
||||
못한 Observer라 순환이다. `Get()`을 안 하는 Observer가 *"매 변경마다 정확히
|
||||
한 번 운다"*는 확정과도 양립하지 않았다. 나머지 코퍼스는 이미 eager였다 —
|
||||
`base/state-epoch-plan.md` §4의 생성 시점 시딩, `base/blocker-plan.md`의
|
||||
*"호출되는 즉시"* 등록, 아래 ":With도 새 State 노드로 확정" 절.
|
||||
관리 부담이 작다는 **결론 자체는 유지된다** — 근거가 "엣지가 적다"에서
|
||||
**"엣지가 노드당 자기 상류 수만큼으로 유계이고 전부 weak-키"**로 바뀔 뿐이다.
|
||||
다이아몬드에서 중복 재계산을 막는
|
||||
것도 **노드별 캐시**(위 "다이아몬드 의존성은 무엇이 푸는가" 절)라 체인
|
||||
전체가 링크드일 것을 요구하지 않고 각 노드가 자기 구독자 목록 + 자기
|
||||
캐시만 가지면 되는 것이라, 이 결정과 무관하게 그대로 유지됨. 구현은
|
||||
|
|
@ -314,7 +374,7 @@ Observer와 동일한 패턴(외부 weak table, `{[child] = true}` 류)으로
|
|||
유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더
|
||||
강해짐.
|
||||
|
||||
### ⚠️ 미해결 — 중간 State가 살아남는가(구독 엣지의 방향성) (2026-08-18 구현 전 QA에서 제기, **M2 착수 전 결론 필요**)
|
||||
### ⭐ 해소됨 — 중간 State는 `_hold`로 살아남는다(구독 엣지의 방향성) (2026-08-18 제기, **2026-08-25 확정**)
|
||||
|
||||
**사용자가 지목한 미검증 항목**: *"확인해봐야 하는게 State -> State ->
|
||||
State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지
|
||||
|
|
@ -340,11 +400,32 @@ With 등이 있는 경우 parent 와 연결된 상대를 자기 자신에 가지
|
|||
없어서** 그런 우연한 캡처가 없다 — 그래서 "우연"에 기대면 안 되고 방향성을
|
||||
불변식으로 못박아야 한다.
|
||||
|
||||
**해야 할 일**: (a) 이 방향성(상류 strong / 하류 weak)을 이 문서의
|
||||
불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 추가
|
||||
(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에).
|
||||
**미검증 상태로 M2에 착수하면 안 되는 항목** — 아래 "결론"의 "관리 부담은
|
||||
작음"은 이 항목이 닫히기 전까지는 잠정이다.
|
||||
**⭐⭐ [2026-08-25 확정] `_hold` 불변식으로 닫혔다.** **사용자 확정**:
|
||||
*"단순히, 각 state 들이 상위 State|Source 를 홀드하는 `_hold` 를 놓는것으로
|
||||
바로 해결된다. 당연히 후행은 선행 요소들이 있어야하기 때문. 선행 state 가
|
||||
후행 state 를 얻으려 하는건 UB이므로 가능한 일이다.(이건 릴레이션도 아니라
|
||||
gc되긴 하지만.)"*
|
||||
|
||||
| 방향 | 강도 |
|
||||
|---|---|
|
||||
| 하류 State → 상류 State/Source (`_hold`) | **강함** |
|
||||
| 상류 → 하류 (구독자 집합) | weak-키 |
|
||||
|
||||
- **모든 파생 노드**(`:With`/`:Compute`/`:Gate`/`:Block`)가 자기 상류를
|
||||
`_hold`에 강하게 담는다 — `:Compute`처럼 클로저가 **우연히** 캡처하는
|
||||
것에 기대지 않는다(`:With`의 pass-through 노드엔 그 우연이 없다).
|
||||
- 체인은 **말단(Observer/Effect/leaf)이 살아 있는 동안** 통째로 살아 있고,
|
||||
말단이 죽으면 통째로 수거된다. `Relate`가 아니므로 순환도 안 만든다.
|
||||
- **상류가 하류를 얻으려는 것은 UB**라 반대 방향 강참조가 필요 없다.
|
||||
- **따름정리 둘**(7라운드 `H-93`/`H-98`): 루트 `Epoch`(Source)가 하류보다
|
||||
먼저 수거될 수 없으므로 *"`:Refresh()`가 `false`를 줘 낡은 값을 최신이라고
|
||||
확신한다"*는 경로가 생기지 않고, `:Subscribe()`의 공개 계약 두 문장
|
||||
(*"참조를 아무 데도 안 담아도 정상"* / *"GC되지 않고 영원히 계속 실행됨"*)이
|
||||
서로 모순 없이 성립한다.
|
||||
- **남은 것은 실측 스파이크 하나** — `luau-test`에 "상류 strong / 하류 weak"
|
||||
불변식을 음성 대조군까지 확인하는 파일을 추가한다
|
||||
(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에).
|
||||
**M2 착수 게이트는 아니다**(`question.md` 최우선 절에서 내려갔다).
|
||||
|
||||
**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가
|
||||
플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는
|
||||
|
|
@ -431,7 +512,18 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(
|
|||
전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로
|
||||
대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는
|
||||
지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음.
|
||||
2. **공유 캐시를 못 타고 중복 계산이 생김. [2026-08-14 근거 재작성]**
|
||||
2. **엣지 수와 에포크 부기가 늘어남. [2026-08-25 근거 재작성, 7라운드 `H-82`]**
|
||||
**여기 한때 "공유 캐시를 못 타고 중복 계산이 생김"이라 적혀 있었는데
|
||||
`:With`엔 성립하지 않는다** — 같은 절이 `:With`를 *"계산 함수는 없고 값은
|
||||
`self`를 그대로 통과(pass-through)"*로 확정하므로 **공유될 계산 자체가
|
||||
없다**. 2026-08-14 재작성이 "근거가 더 강해졌다"면서 실제로는 정확도를
|
||||
낮춘 자리다. 유효한 근거는 이것이다 — 빌더로 누적하면 최종 소비자마다
|
||||
**자기 몫의 엣지 집합과 `EpochMap` 부기를 따로** 들게 되어, 실노드
|
||||
하나가 그걸 공유하는 것보다 등록/판정 비용이 소비자 수만큼 늘어난다.
|
||||
**결론(빌더 기각)은 안 바뀐다** — 근거 1·3이 그대로 유효하다.
|
||||
아래 옛 서술은 `:Compute`에 대해서는 여전히 맞는 이야기다:
|
||||
|
||||
[옛 근거 — `:Compute`엔 유효, `:With`엔 무효]
|
||||
원래 이 항목은 "invalid 플래그로 다이아몬드 중복 워크 방지" 장치를
|
||||
근거로 들었으나 **그 장치는 폐기됨**(위 "전파 모델 확정" 절 정정) —
|
||||
근거를 실제로 유효한 것으로 바꿔 적음. With가 진짜 노드면
|
||||
|
|
@ -766,7 +858,9 @@ someSource:Compute(computeFn)
|
|||
가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가
|
||||
없음).
|
||||
- **[정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 `store.key = value`
|
||||
(`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — 이유와
|
||||
(`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — **[2026-08-25]**
|
||||
같은 날 오전에 대입 문법을 되살렸다가 **철회**했다
|
||||
(`archive/store-value-field-redesign-withdrawn.md`). 이유와
|
||||
상세는 `base/store-plan.md`의 "Store 값 설정 문법" 절 참고(요지:
|
||||
Source가 State를 만족하는 구조로 바뀌며 레코드 타입 읽기/쓰기 대칭을
|
||||
맞추려면 대입 문법을 포기해야 함 + `=`가 암시하는 "즉시 커밋"이 실제
|
||||
|
|
@ -783,8 +877,10 @@ someSource:Compute(computeFn)
|
|||
`Ref()`로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만
|
||||
다루고 생성자를 명시 안 해서 생긴 공백), `architecture.md`의 "복사(clone)
|
||||
구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도
|
||||
같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도
|
||||
됨, 순수 편의용 초기값 템플릿).
|
||||
같은 스타일로 지원. **[2026-08-25 정정]** `defaults`는 더 이상 "선택 —
|
||||
순수 편의용 초기값 템플릿"이 아니다 — **명시적 초기화**가 확정되며
|
||||
`defaults`가 곧 **선언 키 집합**이 됐다(옛 lazy `__index` 폐기). 무인자
|
||||
`Store<<{}>>()`는 빈 타입일 때만 유효하다(`base/store-plan.md`).
|
||||
- **[보강, 2026-08-09 열한 번째 세션] `Source(default)`/`Ref(default)`의
|
||||
`default` 인자가 "선택"이라는 서술은 정확히는 `T`가 `nil`을 포함할 때만
|
||||
성립함 — 생략하면 실제로 `nil`이 그 자리를 채우기 때문.** `Source()`
|
||||
|
|
@ -801,6 +897,20 @@ someSource:Compute(computeFn)
|
|||
없이 생성하는 건 사용자 실수, 타입으로 막을 수 있으면 막고 안 되면
|
||||
UB로 문서 경고).
|
||||
|
||||
### ⭐ `Source:Set(v)`는 동일값이어도 항상 갱신하고 emit한다 (2026-08-25 확정, 7라운드 `H-68`)
|
||||
|
||||
지금까지 코퍼스 어디에도 이 경우가 안 적혀 있었다. **확정**: `v`가 현재
|
||||
값과 `==`여도 `Revision`을 갱신하고 정상적으로 emit한다.
|
||||
|
||||
- 판정이 **값 동등성이 아니라 리비전**이라는 `Epoch` 모델
|
||||
(`base/state-epoch-plan.md` §2)과 일관된다.
|
||||
- 더 중요한 건 **테이블 값**이다 — mutate한 뒤 같은 테이블을 다시
|
||||
`Set`하는 것이 `==`로 dedup되면 **변경이 조용히 증발**한다. 아래
|
||||
`:Emit()` 절이 다루는 것과 정확히 같은 상황인데, `Set`이 dedup하면
|
||||
사용자가 그 구분을 항상 의식해야 한다.
|
||||
- 불필요한 파동을 접는 일은 이미 **하류**가 한다 — `EpochMap` 판정과
|
||||
게이트(`base/gate-plan.md`).
|
||||
|
||||
## Source 값을 직접 mutate한 뒤 전파 — `:Emit()` (2026-08-06 후속 세션, 호출부 정정)
|
||||
|
||||
**결정**: Source가 들고 있는 값을 새 값으로 교체하지 않고 제자리에서
|
||||
|
|
@ -892,11 +1002,34 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
|
|||
방식이 완전히 통일됨. `:With`/`:Compute` 자체를 대신 호출해주는
|
||||
자동화가 아니므로, 여전히 팩토리 본문 안에서 `:With`/`:Compute`를
|
||||
직접 호출하는 건 팩토리 작성자 몫.
|
||||
- **⭐⭐ [2026-08-25 확정, 7라운드 `H-94`] 팩토리는 함수이거나 "지정된
|
||||
필드를 가진 객체"다 — `__call` 테이블은 안 받는다.** `Debounce{...}` /
|
||||
`Throttle{...}`가 `__call` 테이블을 돌려주면
|
||||
`state:Apply(Debounce{...})`가 **타입에러**다(`luau-analyze` 실측 —
|
||||
`__call` 테이블은 함수 타입 자리에 안 들어간다. 런타임은 멀쩡하고
|
||||
타입만 막히므로 `--!nocheck` 스파이크에선 안 드러난다). 같은 실측이
|
||||
타입 레벨 `__call`의 다른 한계도 보였다 — `self`를 못 받고,
|
||||
`typeof(f<<T>>)`로 타입 인자를 넘기는 것도 실패한다.
|
||||
- **확정**: 애플리커티브 팩토리는 **`__call`이 아니라 지정된 필드**로
|
||||
자기를 노출한다. **사용자 판단**: *"아에 어플리커티브 펑터로써,
|
||||
`__call` 이 아닌 다른 필드로 들어가는게 맞아보여요. 외부에서 직접
|
||||
`()` 호출하는건 의미 없게 둬야해요."*
|
||||
- **부수 효과**: `Blocker`를 슈가로 못 두던 이유도 같이 풀린다 —
|
||||
`Debounce`/`Throttle`/`Blocker`가 전부 같은 계약을 만족하게 된다.
|
||||
- **함수와 콜러블의 유니온으로 여는 안은 기각** — 필드로 받으면
|
||||
유니온도 캐스트도 필요 없다. 필드 이름과 정확한 시그니처는 구현 시
|
||||
정한다.
|
||||
- **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`로
|
||||
필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만
|
||||
존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌
|
||||
자체가 없음 — 그냥 고정 메소드 하나 추가하는 것.
|
||||
- **타입은 `factory: (State<T>) -> U): U`로 완전히 열어둠** — Modifier의
|
||||
- **타입은 반환 쪽을 완전히 열어둠** — **⚠️ [2026-08-25 정정, `H-94`]**
|
||||
여기 한때 `factory: (State<T>) -> U): U`라 적혀 있었는데, 그 시그니처는
|
||||
**함수만** 받으므로 위 `H-94` 항목이 확정한 "지정된 필드를 가진 객체"
|
||||
형태를 **거부한다** — `state:Apply(Debounce{...})`가 그대로 타입에러다.
|
||||
파라미터는 **함수 또는 그 필드를 가진 객체**를 받고 반환 `U`만 열어둔다
|
||||
(정확한 필드 이름과 시그니처는 구현 시 정한다). 아래 논거는 **반환 쪽**에
|
||||
대한 것이라 그대로 유효하다 — Modifier의
|
||||
`Apply`는 `factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이
|
||||
이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종
|
||||
요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도
|
||||
|
|
@ -1099,6 +1232,13 @@ override할 자리를 구조적으로 열어두는 것.)
|
|||
멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥
|
||||
이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히
|
||||
하면 별문제 없음(사용자 판단).
|
||||
- **⭐ [2026-08-25 정정, 7라운드 `H-61`] 내부 콜백은 no-op가 아니라
|
||||
`function(self) self:Get() end`이다.** 전파가 push-invalidate /
|
||||
pull-recompute라 `Get()`을 안 부르면 재계산이 아예 안 일어난다 —
|
||||
같은 절이 바로 위에서 *"값을 안 실어줌 — 반드시 `Get()`을 다시 해야
|
||||
함"*이라 못박고 있으므로, no-op 콜백이었다면 이 유틸은 자기 용도
|
||||
(`previous` 패턴의 mutate 로직을 계속 돌게 하기)를 **하나도 못
|
||||
한다**. 이름("항상 관측")과도 이쪽이 맞는다.
|
||||
|
||||
### Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정
|
||||
|
||||
|
|
@ -1125,6 +1265,24 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인
|
|||
`bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘
|
||||
두 개가 아니라 같은 것의 명명 전/후 표현.
|
||||
|
||||
**⭐⭐ [2026-08-25 신설, 7라운드 `H-58`/`H-59`] `:WeakSubscribe()` /
|
||||
`:WeakUnsubscribe()` — 약하게 등록하는 짝.** `Weak` 쪽이 **프리미티브**이고
|
||||
평범한 `:Subscribe()`는 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다
|
||||
(**사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나
|
||||
단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다"*). 즉
|
||||
`Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`이고 구현이 한 벌이다.
|
||||
|
||||
- **자료구조**: 전역 레지스트리에 **약하게** 들어간다. 살려두는 책임은
|
||||
**잡고 있는 쪽**에 있다 — `Effect`가 자기 내부 Observer를 `_deps`에
|
||||
강하게 들고 있는 게 그 예다(`base/effect-plan.md`의
|
||||
"확정 구조 — 강한 주인은 항상 `Effect`" 절).
|
||||
- **왜 필요한가**: `Effect`가 dep마다 Observer를 만들 때 평범한
|
||||
`:Subscribe()`를 쓰면 전역 레지스트리가 그 Observer를(따라서 `Effect`를)
|
||||
영원히 붙든다. 그렇다고 바인드/언바인드마다 등록·해제하면
|
||||
*"등록 시점에 즉시 1회 실행"*에 걸려 **바인드마다 `Rerun`이 dep 수만큼
|
||||
돈다**. `WeakSubscribe`면 둘 다 사라진다.
|
||||
- `Ref` 쪽의 짝은 `ref:WeakCallback(fn)`이다(`base/ref-plan.md`).
|
||||
|
||||
**해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새
|
||||
설계가 아니라 PA님 코드 교차검증(아래 라이프사이클 절)에서 이미 예고해둔
|
||||
확장 지점을 실제로 채우는 것 — "나중에 GC만으로 정말 부족한 케이스가
|
||||
|
|
@ -1383,23 +1541,49 @@ function ObserverEffectLeafHandler.process(inst, k, v, index)
|
|||
Dispatch.setOffsetSource(inst, k, None)
|
||||
Dispatch.setLength(inst, k, 0, inst)
|
||||
|
||||
local old = relate:GetStrong(inst, k)
|
||||
local old = relate:GetWeak(inst, k) -- ⭐ [2026-08-25 `H-71`] 쓰기가 SetWeak이므로 읽기도 Weak
|
||||
-- (안 맞추면 old가 항상 nil이라 dedup이 통째로 죽는다)
|
||||
if old ~= v then -- 이미 같은 값이 이 자리를 차지 중이면 재바인딩 skip
|
||||
bindLifetime(inst, v) -- Effect는 내부적으로 자기 Observer까지 cascade(`base/effect-plan.md`)
|
||||
bindLifetime(inst, v) -- [2026-08-25 정정] Effect도 **핸들 하나만** 바인드된다 —
|
||||
-- dep은 생성자에서 `Weak*`로 걸려 있고 발화는
|
||||
-- `canExecute(handle)`이 게이팅(`base/effect-plan.md`)
|
||||
end
|
||||
relate:SetStrong(inst, k, v)
|
||||
relate:SetWeak(inst, k, v) -- ⭐ [2026-08-25, 7라운드 `H-71`] Strong 아님 — 아래 참고
|
||||
return function(nextValue)
|
||||
if nextValue ~= v then
|
||||
unbindLifetime(v)
|
||||
-- ⭐ [2026-08-25, 7라운드 `H-57`] 값 교체는 파괴에 준한다 — 그 Effect는
|
||||
-- 다시 오지 않으므로 cleanup을 여기서 소진 호출한다.
|
||||
if isEffect(v) then v:_consumeCleanup() end
|
||||
-- [`RefLeafHandler`와 같은 주의] relate 정리는 반드시 이 분기 *안*에서만 —
|
||||
-- 밖에 두면 spurious 재발행(nextValue == v)에서도 기록이 지워져 곧바로
|
||||
-- 이어지는 process가 `old ~= v`를 항상 참으로 보고 dedup이 무력화됨.
|
||||
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
|
||||
if relate:GetWeak(inst, k) == v then relate:SetWeak(inst, k, nil) end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**⭐ [2026-08-25 신설, 7라운드 `H-71`] dedup 기록은 `SetWeak`이다.**
|
||||
`SetStrong`으로 두면 **값이 `inst`를 되참조할 때 100% 샌다** — 커밋된
|
||||
`Relate.luau`로 50/50 누수가 실측됐다(`base/relate-plan.md`의 슬롯별 강약
|
||||
표). dedup은 이 절이 스스로 밝히듯 **순수 성능 최적화**라, weak로 낮춰
|
||||
엔트리가 조기 소실돼도 "dedup을 한 번 놓친다"까지가 최대 손해다. `v`는
|
||||
`gchold`가 이미 강하게 잡고 있고, `relate-plan.md`의 **"다른 곳에서
|
||||
안전하게 유지되는 것은 항상 `SetWeak`"** 규칙에도 그대로 맞는다.
|
||||
`RefLeafHandler`도 같은 정정을 받는다(`base/ref-plan.md`).
|
||||
|
||||
**⭐ [2026-08-25 신설, 7라운드 `H-57`] 값 교체 retract는 cleanup을 부른다.**
|
||||
`base/effect-plan.md`가 확정한 *"`unbindLifetime`은 cleanup을 부르지
|
||||
않는다"*는 **포탈 언마운트**를 보고 정한 것인데, `unbindLifetime`의 호출부는
|
||||
셋이다 — 포탈 언마운트 / 파괴 직전 / **값 교체 retract**. 앞의 둘은
|
||||
cleanup을 안 불러도 되지만 셋째는 **파괴에 준한다**(그 `Effect`는 다시 안
|
||||
온다). 안 부르면 `Frame { effectState }`에서 `effectState:Set(E2)` 뒤에도
|
||||
`E1`의 타이머가 영원히 돈다 — React로 치면 `useEffect` 클로저가 바뀌었는데
|
||||
이전 cleanup을 안 부르는 것. `_consumeCleanup()`이 **읽고 → 지우고 →
|
||||
실행**이라 파괴 경로와 이중 호출이 없고, `unbindLifetime`의 계약 자체는
|
||||
안 건드린다.
|
||||
|
||||
## PA님 코드와의 교차검증(2026-08-04 4차 라운드) — 둘 다 기존 확정 유지
|
||||
|
||||
`.claude/initreq/artworks/EventDrivenProgramming/`(Connection/Event/
|
||||
|
|
|
|||
|
|
@ -164,12 +164,33 @@ type EpochSet = { [Epoch]: true } -- 배열이 아니라 집합이다 (아래
|
|||
|
||||
EpochMap() -> EpochMap
|
||||
|
||||
EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가"
|
||||
EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가" (읽고, 비교하고, 덮음)
|
||||
EpochMap:Peek(Epoch | EpochSet) -> boolean -- 같은 비교를 하되 **덮지 않음** (2026-08-25 신설)
|
||||
EpochMap:Refresh() -> boolean -- 자기 키 전부를 라이브로 다시 읽음
|
||||
EpochMap:Sync(Epoch | EpochSet) -- 읽지 않고 쓰기만 (반환값 없음)
|
||||
EpochMap:TrackFrom(other: EpochMap) -- other가 추적 중인 키를 넘겨받아 라이브 리비전으로 채움
|
||||
```
|
||||
|
||||
**⭐⭐ [2026-08-25 신설, 7라운드 `H-72`] `:Peek` — 갱신하지 않고 비교만 한다.**
|
||||
`GateNode`가 §4의 수신 규칙 1~3을 그대로 돌려면 `emitChanged`가 필요한데,
|
||||
그걸 얻는 유일한 통로였던 `:Update`는 **정의상 읽고 나서 덮는다**. 그런데
|
||||
같은 §4의 게이트 예외는 *"`emitEpochMap:Update`를 수신 시점에 부르지
|
||||
않는다"*(유보 중엔 아직 안 던졌다는 맵의 뜻을 지키려고)라, **두 요구를
|
||||
동시에 만족할 연산이 표면에 없었다.** 나머지 표면도 전부 쓰기를 한다 —
|
||||
`:Refresh`는 자기 키를 다시 읽어 **갱신**하고, `:Sync`는 **쓰기 전용**,
|
||||
`:TrackFrom`은 키를 넘겨받아 **채운다**.
|
||||
|
||||
```lua
|
||||
-- GateNode:_receive
|
||||
local valueChanged = self.valueEpochMap:Update(from)
|
||||
local emitChanged = self.emitEpochMap:Peek(from) -- 갱신 안 함
|
||||
```
|
||||
|
||||
`:Update`가 이미 `{읽기, 비교, 쓰기}`라 `:Peek`은 그 앞 두 개만 쓰는 것이고
|
||||
내부 코드 공유가 쉽다. 플래그(`Update(from, write)`)로 두는 안은 기각 —
|
||||
호출부에서 "안 쓴다"가 안 보이고 boolean 파라미터가 늘 그렇듯 읽기가
|
||||
나빠진다.
|
||||
|
||||
**⚠️ [2026-08-22 정정] 여러 개를 넘길 때는 `{Epoch}`(배열)가 아니라
|
||||
`{[Epoch]: true}`(집합)다.** 여기 한때 `{Epoch}`로 적혀 있었는데, Luau에서
|
||||
그건 `{[number]: Epoch}` 배열이라 **실제로 넘어오는 게이트 배치와 타입이
|
||||
|
|
@ -328,9 +349,68 @@ end
|
|||
폐기된 옛 dedup의 "영구 침묵"과 같은 계열이다
|
||||
(`archive/invalidate-dedup-propagation-reversed.md`).
|
||||
|
||||
### ⭐⭐ `rawInvalid` 불린은 **캐시 카운터 쌍**으로 교체됐다 (2026-08-25, 7라운드 `H-85`)
|
||||
|
||||
**문제**: 아래 "재계산이 끝나면"이 확정한 `rawInvalid = false`가 **재계산
|
||||
*도중* 도착한 무효화를 지운다.** `fn` 실행 중에 상류가 `Set`되면 규칙 1이
|
||||
`rawInvalid = true`를 세우는데, `fn`이 반환한 직후 꼬리가 그걸 무조건
|
||||
`false`로 덮는다 — 캐시가 **다음 `Set`까지 영구 stale**이고, 한 세대를
|
||||
조용히 건너뛰는 형태라 관측이 어렵다. 재계산 중 상류 쓰기는 흔하다(다른
|
||||
Store 필드에 결과를 적어두는 관용구, 헬퍼의 lazy 초기화, `fn`이 yield하는
|
||||
사이 타이머가 `Set`하는 경우 — yield 금지 불변식은 `Dispatch.process`/
|
||||
`attachSlot` 체인 **안**에만 적용된다).
|
||||
|
||||
**확정**: `rawInvalid: boolean`을 **`cacheTargetCount` / `cacheCurrCount`**
|
||||
두 필드로 바꾼다. **사용자 확정**: *"state 는 epoch 를 구현해선 안 돼.
|
||||
중간이지, 초기 값 컨테이너 계층은 아니거든. 따라서 cache count 를 넣을것을
|
||||
추천해. `cacheTargetCount`, `cacheCurrCount` 를 놓는게 맞지 않을까?"*
|
||||
|
||||
```lua
|
||||
-- 생성 시
|
||||
self.cacheTargetCount = 0
|
||||
self.cacheCurrCount = nil -- 아직 계산된 적 없음 → 항상 다름
|
||||
|
||||
-- 무효화 (아래 규칙 1의 `rawInvalid = true` 자리)
|
||||
if valueChanged then
|
||||
self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount)
|
||||
end
|
||||
|
||||
-- 재계산
|
||||
local gen = self.cacheTargetCount -- fn 직전 스냅샷
|
||||
self.cache = self.fn(self, self.cache, ...)
|
||||
for _, d in self.deps do d:_track(self.valueEpochMap) end
|
||||
self.cacheCurrCount = gen -- ⭐ 성공했을 때만
|
||||
|
||||
-- 재계산 판정 (아래 `rawInvalid == true` 자리)
|
||||
if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end
|
||||
```
|
||||
|
||||
- **재계산 도중 도착한 무효화**가 `cacheTargetCount`를 앞서게 만들어
|
||||
다음 `Get`이 반드시 재계산한다.
|
||||
- **`fn`이 던지면** `cacheCurrCount`가 안 갱신되므로 *계산된 적 없는
|
||||
캐시를 유효하다고 확신*하는 일이 없다 — **사용자 지적**: *"특히 에러가
|
||||
난다고 하면, 다시 계산 안하고 이전 결과를 다시 쓰겠네?"* (그래서
|
||||
"`rawInvalid = false`를 `fn` 앞으로 옮긴다"는 한 줄짜리 대안은
|
||||
**불충분하다**.)
|
||||
- 증가는 §2가 `Source.Revision`에 확정한 **`bit32.bnot(-n)` 랩** 그대로다 —
|
||||
비교가 `~=`뿐이라 랩이 무해한 것도 같고, uint32 안에 머무르므로 평이한
|
||||
`+1`이 갖는 2^53 포화 지점 자체가 없다.
|
||||
- **⚠️ 초기값은 `curr = nil`이어야 한다 — 두 숫자를 나란히 두면 안 된다.**
|
||||
이 갱신은 **감소**다(§2의 실측 표: `1 → 0 → 4294967295`). 그래서 한때
|
||||
적어뒀던 `target = 1, curr = 0`은 **첫 무효화에서 `target`이 `0`이 되어
|
||||
`curr`와 같아진다** — 계산된 적 없는 캐시를 "유효"로 판정해 다음 `Set`이
|
||||
올 때까지 그 값을 그대로 돌려준다(2026-08-25 `/code-review high` 발견).
|
||||
`curr = nil`이면 어떤 숫자와도 다르고 **갱신 방향이 바뀌어도 안 깨진다.**
|
||||
필드 타입은 `number?`이고, 첫 재계산 이후로는 계속 숫자다.
|
||||
- **State는 여전히 `Epoch`를 구현하지 않는다** — 이 카운터는 **자기 재계산
|
||||
부기**이지 남이 키로 삼는 리비전이 아니다(§4가 State dep에 대해
|
||||
`TrackFrom(dep.valueEpochMap)`을 쓰는 것과 일관).
|
||||
- 아래 두 절(`재계산 판정` / `재계산이 끝나면`)의 `rawInvalid`는 전부 이
|
||||
카운터 비교로 읽을 것.
|
||||
|
||||
### 재계산이 끝나면
|
||||
|
||||
- **`rawInvalid = false`**, 그리고 **`valueEpochMap`은 자기가 읽은 상류
|
||||
- **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`valueEpochMap`은 자기가 읽은 상류
|
||||
전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
|
||||
`Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해
|
||||
최신이다. 사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 전부
|
||||
|
|
@ -395,6 +475,20 @@ State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그
|
|||
재계산한다.
|
||||
- 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로
|
||||
컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.
|
||||
- **⭐ [2026-08-25 추가, 7라운드 `H-92`] 전파 루프의 구독자 스냅샷은 여기
|
||||
셈에 안 들어가 있었다.** `base/source-state-plan.md`의 "전파 루프 — 확정
|
||||
의사코드" 절이 확정한 대로, 순회 중 등록이 미정의라 매 발화마다 구독자
|
||||
집합을 배열로 복사한다 — 즉 **파동이 지나는 노드 수만큼 배열 하나씩**이
|
||||
할당된다. §2가 테이블 리비전 방식을 기각한 근거가 *"`Set` 한 번마다
|
||||
테이블 하나를 할당"*이었으므로 이 비용은 정직하게 적어둔다. **정확성
|
||||
문제는 아니고**(스냅샷 자체는 필수) 이 문서의 결정을 뒤집지도 않는다 —
|
||||
테이블 리비전은 **노드마다 상시** 새 테이블을 만드는 것이고 이건 **발화
|
||||
경로에만** 생기는 임시 배열이다.
|
||||
- **게이트 통과 모드의 할당**(7라운드 `H-69`): `base/gate-plan.md` 4번이
|
||||
확정한 대로 게이트는 통과시킬 때도 `withheld`에 넣었다가 flush에서
|
||||
새 weak 테이블로 스왑하므로 **emit마다 테이블 하나**를 쓴다. 역시
|
||||
정확성 문제가 아니라 구현 시 최적화 여지(통과 모드면 스왑 없이 넘기기)로
|
||||
적어둔다.
|
||||
|
||||
## 8. 구현 시 확인할 것
|
||||
|
||||
|
|
@ -409,6 +503,13 @@ State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그
|
|||
던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고
|
||||
생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 **옛 모델에서도 똑같이
|
||||
stale**이었고 이 변경이 악화시키는 게 없다.
|
||||
- **⚠️ [2026-08-25 범위 정정, 7라운드 `H-91`] 위 인용의 *"항상 state 는
|
||||
get 이 최신"*은 **선언한 의존성에 대해서**다.** 문장 그대로 읽으면
|
||||
코퍼스에 **의도적 반례**가 있다 — `base/tween-plan.md`의 `Animate`가
|
||||
*"`info.Style`이 State여도 이 내부 `:Compute`의 trailing deps로 안
|
||||
넘어가므로 구독 목록에 안 걸림"*이라며 **미선언 읽기를 설계로** 쓴다.
|
||||
바로 위 문장이 이미 "선언 안 한 것은 옛 모델에서도 stale"이라 말하고
|
||||
있으므로 결론은 안 바뀌고, 범위만 좁혀 적는다.
|
||||
- **동적 의존성**은 `valueEpochMap`이 보수적 상위집합이 된다 — 틀리진 않고
|
||||
재계산이 조금 더 잦아질 뿐이다.
|
||||
- **`Source:Emit()`**(값을 제자리에서 mutate하고 알리는 경로)은 `Revision`만
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@
|
|||
> **`base/source-state-plan.md`**.
|
||||
|
||||
**상태**: base — Store가 부작용을 허용한다는 핵심 결정, "이름 붙은 Source
|
||||
모음"이라는 정의, eager+lazy 생성, `store.key` dot-access 타이핑, `:Set()`
|
||||
문법 전환까지 전부 확정. 원본: `.claude/initreq/raw-userinput.md`
|
||||
모음"이라는 정의, `store.key` dot-access 타이핑까지 전부 확정.
|
||||
**⭐ [2026-08-25] 두 가지가 바뀌었다** — (1) 생성이 **명시적 초기화**로
|
||||
확정되고 옛 lazy `__index`가 폐기됐다, (2) 타입은 **타입 함수를 안 쓰고**
|
||||
평범한 레코드로 짓는다(`WrapStore`/`ProcessStoreType` 폐기). 같은 날
|
||||
"`store.key`를 값으로" 재설계를 넣었다가 철회한 경위는
|
||||
`archive/store-value-field-redesign-withdrawn.md`. 원본:
|
||||
`.claude/initreq/raw-userinput.md`
|
||||
"store는 부작용을 허용함" / "스토어는 스토어를 저장 가능한가" 절.
|
||||
|
||||
## Store는 부작용을 허용하는 게 기본 디자인
|
||||
|
|
@ -39,53 +44,74 @@ purity-and-effects-plan.md`와 연결됨).
|
|||
어떻게 되는가"는 별도 메커니즘 없이 `canExecute` 재사용으로 해결됨 —
|
||||
`base/source-state-plan.md`의 "Slot 생존 확인" 절이 소스.
|
||||
|
||||
## Store = Source들의 이름 붙은 모음 (eager + lazy 생성)
|
||||
## Store = Source들의 이름 붙은 모음 (명시적 초기화)
|
||||
|
||||
`store.a`처럼 키로 접근하면 **이미 만들어진 Source가 있으면 그대로 반환,
|
||||
없으면 그 자리에서 만들어 저장한 뒤 반환** — 더 이상 별도 State wrapper를
|
||||
매번 만들거나 따로 캐싱하지 않음(Source 자체가 이미 State를 만족하므로
|
||||
wrapper 계층 자체가 불필요해짐, `base/source-state-plan.md`의 "Source가
|
||||
State를 만족함" 절).
|
||||
> **⭐⭐ [2026-08-25] 같은 날 두 번 바뀌었다 — 최종은 이 절이다.**
|
||||
> 오전에 "`store.key`가 값이고 `store:Of(k)`가 프리미티브"라는 재설계를
|
||||
> 넣었다가 **같은 날 철회**했다. 철회된 시도의 원문과 이유는
|
||||
> `archive/store-value-field-redesign-withdrawn.md`. 그 시도가 남긴 것은
|
||||
> **명시적 초기화**(eager/lazy 이중 모델 폐기) 하나이고, 나머지(값 필드,
|
||||
> `__index`/`__newindex` 슈가, 팬텀 필드, `index<>`/`keyof<>`)는 전부
|
||||
> 되돌렸다.
|
||||
|
||||
- **`defaults`는 선택**(안 줘도 됨, 순수 편의용 초기값 템플릿) —
|
||||
`Store({defaults})`가 내부적으로 `{[key] = Source(default), ...}`나
|
||||
다름없게 됨.
|
||||
- **[정정, 2026-08-07] "Store 생성 시 전부 eager하게만 만들어진다"는 이전
|
||||
서술은 부정확 — eager와 lazy가 둘 다 필요하다.** Luau 타입은 런타임에
|
||||
강제되지 않으므로 `Store<<SomeType>>()`처럼 `defaults` 없이 만든 뒤
|
||||
`.Key:Set(v)`를 부르는 경우, `__index`가 "없으면 그 자리에서 만들어
|
||||
저장"까지 해주지 않으면 `.Key`가 `nil`이라 크래시남. 그래서 **Store
|
||||
생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과
|
||||
**`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어
|
||||
저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함.
|
||||
- **[2026-08-18 구현 전 QA, 2026-08-19 M0 실측으로 해소] lazy 생성이
|
||||
오타/동적 키로 Source를 무한정 누적하는 트레이드오프는 그대로 수용하고,
|
||||
방어선은 런타임이 아니라 타입에 둔다.** 사용자 판정: *"Store<{ field:
|
||||
type }> 상 없는 네임에는 타입 시간에 Source 가 없는것으로 나와 타입
|
||||
에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요"* — 즉
|
||||
`Store<{field: T}>`로 선언된 Store에 없는 이름을 쓰면 `type function`이
|
||||
합성한 결과 타입에 그 프로퍼티가 없어 **타입 에러**가 나야 한다.
|
||||
**[2026-08-19 실측 완료]** 맞았음 —
|
||||
`luau-test/done/21-type-store-undeclared-key-rejected.luau`가
|
||||
`ProcessStoreType`(`16`과 동일 type function)의 결과 타입에 미선언 키로
|
||||
접근하면 정확히 `TypeError` 2건(읽기·메소드 체이닝)이 나고, 선언된 키
|
||||
3개는 클린임을 확인. 런타임에 굳이 이름을 받아야 하는 경우는
|
||||
`:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
|
||||
- **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹
|
||||
스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값
|
||||
템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던
|
||||
"defaults 테이블 직접 mutate는 UB"라는 옛 서술은 2026-08-07에 정정됨).
|
||||
별도 `__values`류 그림자 실값 저장소도 불필요 — Source 객체 자체가
|
||||
저장소 역할을 함.
|
||||
- **구현 스케치(2026-08-07, 성능 근거): eager 생성은 `table.clone(defaults)`
|
||||
후 그 결과를 순회하며 각 슬롯을 `Source(v)`로 교체하는 모양이어야 함**
|
||||
(`local sources = table.clone(defaults); for k, v in sources do
|
||||
sources[k] = Source(v) end` 류) — 빈 테이블을 새로 만들어 키를 하나씩
|
||||
넣는 것보다, `table.clone`으로 원본의 해시/배열 슬롯 구조를 그대로
|
||||
재사용하는 쪽이 Luau VM 입장에서 더 쌈(직접 해시 슬롯을 처음부터
|
||||
구성하는 것보다 기존 슬롯을 복제하는 게 저렴). `Source()`(인자 없이
|
||||
호출)는 `Source(nil)`과 동치 — `defaults`에 값이 없는 키를 `store.key`
|
||||
접근 시점에 lazy 생성할 때 이 무인자 형태를 씀.
|
||||
**Store의 타입 인자에는 `Source<T>`를 직접 쓰고, `defaults`에도 `Source(v)`를
|
||||
직접 넣는다.**
|
||||
|
||||
```lua
|
||||
local store = quad.Store<<{
|
||||
hp: Source<number>,
|
||||
name: Source<string>,
|
||||
}>>({
|
||||
hp = Source(100),
|
||||
name = Source(""),
|
||||
})
|
||||
|
||||
store.hp:Get() -- 평범한 레코드 필드 접근 → Source<number>
|
||||
store.hp:Set(5)
|
||||
store.hp:Compute(function(s) ... end)
|
||||
```
|
||||
|
||||
- **`store.key`는 평범한 레코드 필드다** — `Store<T>`가 `T`(그 자체로
|
||||
`{hp: Source<number>, ...}`)를 그대로 포함하므로 **타입 함수가 하나도
|
||||
안 든다.** 마법이 없고, 읽기/쓰기 의미론이 `Source`의 기존 계약
|
||||
(`:Get()`/`:Set()`) 그대로다.
|
||||
- **⭐ 명시적 초기화가 기본** — 선언한 키는 생성 시 `Source`를 준다.
|
||||
`defaults`에 없는 키는 **필드 자체가 없어** 타입에서 걸리고, 런타임에도
|
||||
`nil` 역참조로 즉시 드러난다. 옛 lazy `__index`(없는 키를 그 자리에서
|
||||
만들어 저장)는 **폐기**됐다 — **사용자 근거**: *"lazy 로 만들어낸다는
|
||||
발상 자체가 약간 문제가 있어요. Set 을 안 해주면, 초기 값이 타입에
|
||||
어긋날 수 있거든요. 애초에, `Source<number>` 인데, nil을 조용히 가지고
|
||||
있을수도 있고, 타입으로 못 막네요."*
|
||||
- **부수 효과**: 선언 키 집합의 **런타임 소스가 `defaults` 하나**로
|
||||
확정된다. 그래서 `store:Names()`가 `pairs`로 성립한다 — 옛 lazy
|
||||
모델에선 키 집합이 접근 이력에 좌우돼 0개/1개/2개로 갈렸다
|
||||
(7라운드 `H-79`).
|
||||
- **부모가 값을 다 안 넘겨도 되게 하려면 컴포넌트가 자기 `DEFAULTS`로
|
||||
채운다** — 타입에 `?`를 다는 게 아니라 **호출 규약**으로 표현한다
|
||||
(**사용자 아이디어**: *"기본 값인 소스는 한 곳에 `Defaults = {}` 해두고
|
||||
쓰는거죠"*). 그래야 생성자가 항상 완전한 테이블을 받는다.
|
||||
- **외부에서 만든 `Source`를 나중에 끼워 넣는 표면은 없다** — 생성 시점에
|
||||
넣는 게 전부다(**사용자 판단**: *"다른곳에서 생성된 Source 를 다시
|
||||
넣는다는게, 가능하게 해야할 표면적 이유가 없습니다"*).
|
||||
- **값이 없는 상태가 필요하면 `None`이 이미 그 자리다** — attribute를
|
||||
실제로 지우는 것도 `Source<None> → None → nil`로 핸들러 계열을 타고
|
||||
말단에서 set nil 된다. Store 키를 nilable로 만들 이유가 아니다.
|
||||
- **구현 스케치**: 생성 시 `table.clone(defaults or {})`로 그림자 테이블을
|
||||
만든다(`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에
|
||||
키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로). 값이 이미
|
||||
`Source`이므로 **슬롯 교체 순회가 없다**. **`or {}`가 필수다** — 무인자
|
||||
`Store<<{}>>()`도 유효한데 `table.clone(nil)`은
|
||||
`table expected, got nil`로 죽는다(**[2026-08-25]** 7라운드 `H-83` 실측).
|
||||
- **`store:Names()`** — 그 시점의 키 집합을 준다(그림자 테이블의 키). 그룹
|
||||
`Attribute(...)`/`attr:NameMap()`이 이걸 요구한다
|
||||
(`base/attribute-plan.md`, 7라운드 `H-79`).
|
||||
**⚠️ "선언된 키"와 정확히 같지는 않다** — `defaults`의 키에 **동적 키
|
||||
창구 `store:Of(name)`이 만든 것**이 더해진다(아래 "타입 추론 문제" 절).
|
||||
둘의 차이는 `Of`를 쓴 Store에서만 생기고, `Of`는 "타입 보장을 포기했다"가
|
||||
호출부에 드러나는 명시적 자리다. **그룹 `Attribute`에 미치는 영향**:
|
||||
이미 배치된 `Attribute(store)` 바인딩은 그 시점의 `NameMap()` 스냅샷으로
|
||||
구성되므로, 나중에 `Of`로 늘어난 키는 **다음 재디스패치 때** 반영된다
|
||||
(`base/attribute-plan.md`의 그룹 절).
|
||||
|
||||
v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게
|
||||
쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심
|
||||
|
|
@ -95,38 +121,36 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
|
|||
`Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립
|
||||
공개 프리미티브로 격상" 절).
|
||||
|
||||
## Store 값 설정 문법 — `myStore.key = value` 폐기, `source:Set(value)`로 전환 (2026-08-06 후속 세션, 정정)
|
||||
## Store 값 설정 문법 — `myStore.key = value` 폐기, `store.key:Set(value)` (2026-08-06 후속 세션, 확정 유지)
|
||||
|
||||
**이전 버전("v1 인체공학 유지, `__newindex` 기반 `myStore.key = value`
|
||||
그대로")은 폐기됨.** `base/source-state-plan.md`의 "Source가 State를
|
||||
만족함" 절 타입 설계와 맞물려 재검토된 결과:
|
||||
> **[2026-08-25] 이 결정은 유지된다.** 같은 날 오전에 한 번 뒤집었다가
|
||||
> (`store.key`를 값으로 만들면서 대입이 되살아났었다) **같은 날 철회**했다 —
|
||||
> `archive/store-value-field-redesign-withdrawn.md`. `store.key`가 다시
|
||||
> `Source<T>`이므로 아래 근거 셋이 전부 그대로 성립한다.
|
||||
|
||||
1. **타입 대칭성**: `store.key`가 이제 `Source<T>`를 직접 반환하는
|
||||
평범한 레코드 필드(`{key: Source<number>}`)로 타이핑되는데, 레코드
|
||||
필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함.
|
||||
**`store.key = value`는 안 쓴다.** 값을 쓰는 경로는 `store.key:Set(value)`다.
|
||||
|
||||
1. **타입 대칭성**: `store.key`가 `Source<T>`를 직접 반환하는 평범한 레코드
|
||||
필드(`{key: Source<number>}`)로 타이핑되는데, 레코드 필드는 읽기/쓰기
|
||||
타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립한다.
|
||||
`store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source<T>`)/쓰기(`T`)
|
||||
타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드
|
||||
타입이 항상 `Source<T>`로 대칭적이라 문제 자체가 안 생김(사용자 지적).
|
||||
타입이 갈려 mismatch가 남는다 — `:Set(value)`로 통일하면 필드 타입이 항상
|
||||
`Source<T>`로 대칭적이라 문제 자체가 안 생긴다(사용자 지적).
|
||||
2. **의미론적 정직성**: `=` 대입 문법은 관례상 "그 자리에서 즉시 확정되는
|
||||
부작용 없는 값 쓰기"를 암시하는데, quad의 실제 동작은 **lazy** —
|
||||
`Set`은 무효화 신호만 쏘고, 실제 재계산은 나중에 누군가 관측(`Get()`)할
|
||||
때만 일어남("Emit으로 필요한 사람 있어? 하고 물어보고, 있어야 진짜
|
||||
계산 시작"). 이건 `=`가 암시하는 "즉시 커밋"과 정서가 안 맞고, 메소드
|
||||
호출(`:Set()`)이 "이건 프로세스를 트리거하는 연산"이라는 걸 더 정직하게
|
||||
신호함(사용자 확정 논거).
|
||||
3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙(`base/
|
||||
architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게
|
||||
아니라 기존 원칙의 정상적인 적용.
|
||||
때만 일어난다. 메소드 호출(`:Set()`)이 "이건 프로세스를 트리거하는
|
||||
연산"이라는 걸 더 정직하게 신호한다(사용자 확정 논거).
|
||||
**[2026-08-25 보강]** 철회된 재설계가 이 논거를 실제로 검증해줬다 —
|
||||
`store.key = v`를 되살리자 *"`.Value = 1`이 정적 쓰기처럼 보이는데
|
||||
거기서 error trace가 나오면 당황스럽고 약간 마법적"*(사용자)이라는
|
||||
문제가 바로 드러났다.
|
||||
3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙
|
||||
(`base/architecture.md`)에도 자연스럽게 들어맞는다.
|
||||
|
||||
**남는 것**: `:` 체이닝 원칙은 `:Set()` 자체가 그 사례라 유지.
|
||||
**[정정, 2026-08-18 구현 전 QA] `myStore "key"`(문자열 커링)는 기각됐다** —
|
||||
여기엔 "동적 키 전용 미타입 폴백으로 격하돼 그대로 유지"로 적혀 있었으나,
|
||||
사용자 판정은 폐기다: *"store "a" 식으로 문자열 호출하는것 또한 기각된
|
||||
바임. 저러면 "a" 가 string 으로 들어가서, Source<T> 의 타입을 모르기도
|
||||
하고, 우린 더이상 필요하지 않게 된 요소임."* 근거는 (a) `"a"`가 그냥
|
||||
`string`으로 들어가 `Source<T>`의 `T`를 알 수 없고, (b) dot-access +
|
||||
`type function` 타이핑이 자리잡아 더 이상 필요 없어졌다는 것. 동적 키는
|
||||
아래 `:GetDynamic` 항목으로 간다.
|
||||
**`myStore "key"`(문자열 커링)는 기각**이다(2026-08-18 사용자 판정) —
|
||||
*"저러면 "a" 가 string 으로 들어가서, Source<T> 의 타입을 모르기도 하고,
|
||||
우린 더이상 필요하지 않게 된 요소임."* 동적 키는 아래 `:Of`로 간다.
|
||||
|
||||
`base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께
|
||||
읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며
|
||||
|
|
@ -137,93 +161,119 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
|
|||
|
||||
- `store "key"`(문자열 커링)로 `state<T>`를 오버로드 함수 타입으로 정확히
|
||||
추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**,
|
||||
위 절), **`store.key`(dot-access)를 1급 경로로 확정**
|
||||
— Store 타입을 `{key: Source<number>, other: Source<string>}`류 평범한
|
||||
레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열
|
||||
리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State<T>`
|
||||
필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 `Source<T>`로
|
||||
갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가
|
||||
더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이
|
||||
해소됨, 위 "Store 값 설정 문법" 절 참고).
|
||||
**[정정, 2026-08-18] 동적 키 경로는 문자열 커링이 아니라 명시적 메소드다** —
|
||||
`store:GetDynamic<<T>>(name): Source<T>`. 런타임 동작 자체는 원래도
|
||||
dot-access와 같았고(lazy `__index`가 없는 이름을 만들면 그 자리에서
|
||||
Source를 만들어줌 — 아래 "없는 키" 항목) 문제는 **타입**뿐이었다:
|
||||
선언되지 않은 이름은 `type function`이 합성한 레코드 타입에 없어서 타입
|
||||
에러가 난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이
|
||||
정해지는" 정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를
|
||||
둔다 — 사용자 판정: *"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐.
|
||||
타입 애러가 난다는 점인데, 이는 GetDynamic<T>(name): Source<T> 로
|
||||
제공하는게 최선으로 보임."* 이름이 명시적이라 "여기서 타입 보장을
|
||||
포기했다"가 호출부에 드러나는 것도 문자열 커링보다 나은 점.
|
||||
- **⚠️ [구현 주의, 2026-08-18 감사에서 발견] 콜론 메소드는 Store의
|
||||
lazy `__index`와 정면으로 부딪힌다.** 위 "Store = Source들의 이름 붙은
|
||||
모음" 절이 확정한 대로 **없는 키를 인덱싱하면 그 자리에서 `Source`를
|
||||
만들어 저장**하므로, 아무 장치 없이 `store:GetDynamic("x")`를 부르면
|
||||
`store.GetDynamic`이 **`"GetDynamic"`이라는 이름의 새 `Source`를
|
||||
만들어 반환**하고 그걸 함수로 호출해 런타임 에러가 난다. 따라서
|
||||
**`__index`가 고정 메소드 테이블을 먼저 확인하고, 없을 때만 lazy
|
||||
`Source` 생성으로 폴백**해야 하며, 그 결과 **`GetDynamic`은 Store의
|
||||
예약 키 이름이 된다**(그 이름의 Source는 dot-access로 못 만듦).
|
||||
`Modifier`가 `Apply`/`Peek`/`Overridden`을 같은 이유로 예약하는 것과
|
||||
정확히 같은 구조(`base/modifier-plan.md`의 "구현 시 주의") — 다만
|
||||
Store의 키 이름은 **사용자 도메인 데이터 이름**이라 Modifier(스타일
|
||||
프로퍼티 이름)보다 충돌 확률이 높다는 게 차이.
|
||||
- **대안(미결, 사용자 판단 필요)**: 예약 키를 하나도 만들고 싶지 않으면
|
||||
**탑레벨 함수**(`getDynamic(store, name)`)로 두면 된다 — `isState`/
|
||||
`bindLifetime`처럼 "특정 프리미티브에 안 묶인 범용 유틸은 소문자
|
||||
탑레벨"이라는 기존 네이밍 규칙(`base/architecture.md`의 "코드 스타일 —
|
||||
네이밍 케이싱")에도 오히려 더 맞는다. 사용자가 지정한 표기는
|
||||
`GetDynamic<T>(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되,
|
||||
M2/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 최우선 절).
|
||||
위 절), **`store.key`(dot-access)를 1급 경로로 확정**. Store 타입을
|
||||
`{key: Source<number>, other: Source<string>}`류 **평범한 레코드 타입**으로
|
||||
지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 리터럴 narrowing
|
||||
문제 자체가 안 생긴다.
|
||||
- **⭐ [2026-08-25] 동적 키는 `store:Of<<T>>(name): Source<T>` 하나다** —
|
||||
옛 이름 `GetDynamic`을 **흡수**했다(표면 둘을 유지할 이유가 없다).
|
||||
선언되지 않은 이름은 레코드 타입에 없어서 dot-access는 타입 에러가
|
||||
난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이 정해지는"
|
||||
정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를 둔다 —
|
||||
사용자 판정:
|
||||
*"이는 GetDynamic<T>(name): Source<T> 로 제공하는게 최선으로 보임."*
|
||||
이름이 명시적이라 "여기서 타입 보장을 포기했다"가 호출부에 드러나는 것도
|
||||
문자열 커링보다 나은 점.
|
||||
- **⭐⭐ [2026-08-25 신설] `Of`는 없는 이름이면 그 자리에서 만들어 저장한다 —
|
||||
여기가 lazy 생성이 남는 유일한 자리다.** 명시적 초기화로 dot-access 쪽
|
||||
lazy `__index`는 폐기됐지만(위 절), **동적 키 창구는 그 위에 서 있었다**
|
||||
(`/code-review high` 발견) — `defaults`에 없는 이름을 `Of`가 그냥
|
||||
조회하면 `nil`을 `Source<U>` 타입으로 돌려주고 호출부가 `:Get()`에서
|
||||
타입 에러 없이 nil 역참조한다.
|
||||
```lua
|
||||
function Store:Of(name) -- 동적 키 전용
|
||||
local src = shadow[name]
|
||||
if src == nil then
|
||||
src = Source() -- == Source(nil)
|
||||
shadow[name] = src
|
||||
end
|
||||
return src
|
||||
end
|
||||
```
|
||||
- **`__index` lazy와 다른 점**: 그건 **암묵**이었고(오타가 조용히 새
|
||||
Source를 만들었다) 이건 **명시**다 — 이름을 문자열로 넘기고 타입을
|
||||
`<<T>>`로 직접 주는 자리라 "여기서 타입 보장을 포기했다"가 호출부에
|
||||
드러난다. 그게 애초에 이 창구를 둔 이유다.
|
||||
- **`store:Names()`는 그대로 성립한다** — 선언 키(`defaults`)에 `Of`가
|
||||
만든 동적 키가 더해진 것이 그 시점의 실제 키 집합이고, `Names()`가
|
||||
돌려주는 것도 그것이다.
|
||||
- **오타 방어는 여전히 타입이 한다** — dot-access는 레코드 타입에 없는
|
||||
이름을 거부한다. `Of`는 그 방어를 **의도적으로** 우회하는 창구다.
|
||||
- **⭐ [2026-08-25 실측] `<<T>>`는 값 호출부에서 동작한다.**
|
||||
7라운드 `H-73`이 *"Luau엔 호출부 명시 타입 인자 문법이 없다"*고
|
||||
단정했으나 **틀렸다** — Luau의 **generic type instantiation**
|
||||
(`luau.org/types/generics/#generic-type-instantiation`)이 값 호출부에서도,
|
||||
콜론 메소드에서도 `T`를 실제로 묶는다. `luau-analyze` 실측:
|
||||
```lua
|
||||
local ok: Source<number> = store:Of<<number>>("x") -- 진단 없음
|
||||
local bad: Source<string> = store:Of<<number>>("y") -- 정확히 걸림
|
||||
local none: Source<number> = store:Of("z") -- Source<unknown>
|
||||
```
|
||||
원문은 **인스턴스화를 생략한 호출만** 돌려보고 단정했다. 따라서
|
||||
`base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다.
|
||||
- **⭐ [2026-08-25] 예약 키는 `Of`/`Names` 둘뿐이고, 충돌은 조용히 죽는다.**
|
||||
실측: 사용자 키가 예약 이름과 겹치면 교집합이 뭉개져 **그 필드의 타입
|
||||
검사가 통째로 꺼진다**(음성 대조군이 진단 0건으로 통과했다). 시끄럽게
|
||||
막히는 게 아니라 그냥 지나간다.
|
||||
- **그래서 `T`를 검증만 하고 그대로 통과시키는 작은 `type function`을
|
||||
둔다.** 겹치면 사용 지점에
|
||||
`TypeError: quad.Store: "Of" is a reserved key`가 뜬다.
|
||||
**`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼
|
||||
버려진다. `print(...)` + `return types.never` 조합만 된다
|
||||
(`luau-test/rewrite-required/23-...`가 기록해둔 사실이고
|
||||
`type-version-check`가 이미 쓰는 패턴).
|
||||
- **이건 아래 §0 원칙의 허용 범위 안이다** — 타입 함수를 **진단을 띄우는
|
||||
데만** 쓰고 접근 타입을 합성하는 데는 안 쓴다
|
||||
(`base/typing-limits.md`).
|
||||
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
|
||||
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
|
||||
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).
|
||||
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학"
|
||||
절이 최신 확정 내용.
|
||||
|
||||
### `store.key` 레코드 필드 타이핑 — Luau 타입함수로 해결 확인 (2026-08-12 열일곱 번째 세션, `pre-implementation-audit.md` 1-10 해소)
|
||||
### `store.key` 레코드 필드 타이핑 — 타입 함수가 필요 없다 (2026-08-25 재확정)
|
||||
|
||||
위 절이 "`store.key`를 평범한 레코드 필드 타이핑으로 자동 해결"이라
|
||||
서술했지만, `Store<T>`가 입력 `T`(예: `{ty: string}`)를 받아
|
||||
`{ty: Source<string>}`류 결과 타입을 실제로 어떻게 합성하는지는 미검증으로
|
||||
남아있었음. **Luau의 `type function`**(컴파일타임에 타입을 인자로 받아 새
|
||||
타입을 조립하는 기능, https://luau.org/types/type-functions/ ,
|
||||
https://luau.org/types-library/ — tbox에서도 이미 쓰이는 검증된 패턴)으로
|
||||
정확히 풀림:
|
||||
> **⭐⭐ [2026-08-25] 타입 함수로 접근 타입을 짓는 접근은 **두 번** 시도됐고
|
||||
> **두 번 다 폐기**됐다.** (1) `WrapStore`/`ProcessStoreType`로 결과 타입을
|
||||
> **합성**하는 안(2026-08-12~2026-08-15) — 7라운드 `H-75`/`H-76`이 두
|
||||
> 한계를 실측해 폐기. (2) `index<>`/`keyof<>` + 팬텀 필드로 짓는 안
|
||||
> (2026-08-25 오전) — 같은 날 철회
|
||||
> (`archive/store-value-field-redesign-withdrawn.md`).
|
||||
|
||||
```luau
|
||||
type function WrapStore(ty: type): type
|
||||
-- Source<T> 형태를 그대로 조립(:Get/:Set/:Compute/:With 등)
|
||||
local result = types.newtable()
|
||||
result:setproperty(types.singleton("Get"), types.newfunction(...))
|
||||
return result
|
||||
end
|
||||
**지금은 타입 함수를 안 쓴다.** `Store<T>`의 `T`가 이미
|
||||
`{hp: Source<number>, ...}`이므로 `store.hp`는 **평범한 레코드 필드
|
||||
접근**이고, Luau가 그냥 해준다.
|
||||
|
||||
type function ProcessStoreType(ty: type): type
|
||||
local props = ty:properties() :: { [type]: { read: type?, write: type? } }
|
||||
local result = types.newtable()
|
||||
for i, v in props do
|
||||
-- i는 프로퍼티 이름을 담은 singleton 타입, i:value()로 실제 문자열
|
||||
result:setproperty(i, WrapStore(v))
|
||||
end
|
||||
return result
|
||||
end
|
||||
```lua
|
||||
type Store<T> = T & {
|
||||
Of: <U>(self: any, name: string) -> Source<U>, -- 동적 키 전용
|
||||
Names: (self: any) -> { string },
|
||||
}
|
||||
```
|
||||
|
||||
`ProcessStoreType<{ty: string}>` → `{ty: Source<string>}`가 나옴 — 결과는
|
||||
선언 시점에 이름 붙은 `Source<string>` 그 자체가 아니라 구조를 그대로 풀어낸
|
||||
(flatten) 익명 타입이지만, **Luau는 이름이 아니라 "만족하는가"로 구조적
|
||||
일치를 검사**하므로 문제없이 `Source<string>` 자리에 대입 가능 — 오히려 이
|
||||
방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이
|
||||
확인돼 M0/M2 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 —
|
||||
`ROADMAP.md`의 M0/M2 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증
|
||||
난이도 문제였던 것만 해소. **[2026-08-15] 이 `type function` 접근 자체의
|
||||
실측도 완료** — 스파이크(`luau-test/done/16-type-store-key-
|
||||
typefunction.luau`)는 원래 `types.newfunction` 시그니처 불일치로 깨져
|
||||
있었으나 원인이 설계 문제가 아니라 API 버전 드리프트였음이 드러나 수정
|
||||
후 통과(음성 대조군 4건 포함), `base/typing-limits.md` §5로 승격.
|
||||
상세는 `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절.
|
||||
**`luau-analyze` 실측**(양성 + 음성 대조군):
|
||||
|
||||
| 검사 | 결과 |
|
||||
|---|---|
|
||||
| `store.hp` | `Source<number>` ✅ (`Source<string>` 대조군 걸림) |
|
||||
| `store.hp:Get()` / `:Set(5)` | ✅ (틀린 타입 대조군 둘 다 걸림) |
|
||||
| `store.nope` | **거부** ✅ |
|
||||
| `store:Of<<boolean>>("dyn")` | `Source<boolean>` ✅ (대조군 걸림) |
|
||||
| 콜백 파라미터 추론(`store.hp:Compute(function(s) ... end)`) | ✅ `s`가 `StateData<number>`로 잡힘(`s:NoSuchField()`가 정확히 걸림) |
|
||||
|
||||
- **⚠️ `Compute`/`Apply`의 반환 타입은 여전히 명시 주석이 필요하다** —
|
||||
이건 Store와 무관한 `base/typing-limits.md` §1의 **문제 B**이고,
|
||||
`store.hp`든 독립 `Source`든 **똑같이** 조용히 안전성을 잃는다(실측:
|
||||
틀린 주석 `local x: State<string> = src:Compute(...)`이 양쪽 다 안 걸림).
|
||||
`audit/type-recursion-issue/REPORT.md` 3-1절이 확정한 대로 **명시 주석
|
||||
이후 다운스트림 전체는 정상 체크**되고 구멍은 그 한 줄뿐이다.
|
||||
살아나는 건 **콜백 파라미터** 쪽이다 — 그리고 철회된 재설계의
|
||||
`Self` 제네릭을 거치면 **그것마저 깨졌다**(`s`가 `unknown`으로 떨어짐).
|
||||
- **`__call` 경로는 죽었다** — 타입 레벨 `__call`은 `self`를 못 받고,
|
||||
`typeof(f<<T>>)`로 타입 인자를 넘기는 것도 실패한다(실측). 같은 사실이
|
||||
`base/source-state-plan.md`의 `:Apply`에도 적용된다(7라운드 `H-94`).
|
||||
|
||||
실측 전량은 `audit/type-store-index-keyof/`가 소스.
|
||||
|
||||
## Store가 Store를 저장 가능한가
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,28 @@
|
|||
사례 — 코드 생성 없이 타입 선언 두 개로 끝나고, 나중에 Luau가
|
||||
고쳐져도 손해가 없음).
|
||||
|
||||
### ⭐⭐ [2026-08-25 신설, 사용자] 타입 함수는 **진단을 띄우는 데까지만** 쓴다
|
||||
|
||||
**사용자 확정**: *"어쩌면 타입함수는 타입이 못 잡는 문제를 에러로 띄우기
|
||||
위한 정도 이상으로 가지 않는게 이로울 수도 있습니다."*
|
||||
|
||||
- **허용**: 타입이 못 잡는 오용을 **컴파일 타임 에러로 만드는** 용도.
|
||||
`type-version-check`의 `CheckVersion`(버전 불일치를 사람이 읽을
|
||||
메시지로), Store의 `CheckReserved`(예약 키 충돌)가 그 사례다. 둘 다
|
||||
**`T`를 검증만 하고 그대로 통과**시키고 결과 타입을 만들지 않는다.
|
||||
`error()`가 아니라 `print(...)` + `return types.never`를 쓴다.
|
||||
- **안 함**: **접근 타입/결과 타입을 합성**하는 용도.
|
||||
- **왜**: 그 선을 넘으면 §6이 실측한 함정(합성을 거친 값은 이후 제네릭
|
||||
self 체이닝이 조용히 깨짐)과 §5가 폐기한 한계(바깥 별칭 참조 불가,
|
||||
메소드 self 파라미터 불변)를 그대로 떠안는다.
|
||||
- **⚠️ `index<>`/`keyof<>`도 타입 함수다** — Luau가 predefine해둔 것일
|
||||
뿐 성질은 같다(**사용자 지적**: *"구현을 뜯어보면, 루아우에서
|
||||
프리디파이닝한 타입함수임. 타입함수가 가지는 고질적 문제를 그대로
|
||||
가져요."*). 실제로 2026-08-25에 `WrapStore`를 버리고 그쪽으로 갈아탔다가
|
||||
**같은 날 철회**했다 — 그 조합의 `Self` 제네릭을 거치면 **콜백 파라미터
|
||||
추론이 깨진다**(평범한 레코드 필드에선 정상, 실측 대조).
|
||||
경위는 `archive/store-value-field-redesign-withdrawn.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. ⭐ 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 조용히 사라짐
|
||||
|
|
@ -242,7 +264,7 @@ self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T
|
|||
| `state:With(...)` | 인라인 + 쪼개기 | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 |
|
||||
| `state:Apply(factory)` | 인라인 | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 |
|
||||
| `Effect(fn, ...deps)` | — | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) |
|
||||
| `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) |
|
||||
| `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`Observer` 반환) |
|
||||
| `tween:Mapped(fn)` | 인라인 제네릭 메소드 | — | ❌ **조용히 통과**(아래 `H-24`) |
|
||||
| `tween:Mapped(fn)` | `typeof(named fn)`(③) | 콜백 파라미터 명시 주석 필요 | ✅ 안전 |
|
||||
|
||||
|
|
@ -350,14 +372,34 @@ RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이
|
|||
|
||||
---
|
||||
|
||||
## 5. `store.key` 레코드 필드 타이핑(`type function`) — ✅ 검증 완료
|
||||
## 5. `store.key` 레코드 필드 타이핑 — ⛔ 이 접근은 폐기됨 (2026-08-25)
|
||||
|
||||
**[2026-08-15 확정, 근거: `luau-test/done/16-type-store-key-typefunction.luau`,
|
||||
`audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절]**
|
||||
> **⭐⭐ [2026-08-25 폐기] `WrapStore`/`ProcessStoreType`로 결과 타입을
|
||||
> **합성**하는 접근 자체가 사라졌습니다.** 지금 Store는 **타입 함수를 안
|
||||
> 쓰고** 타입 인자에 `Source<T>`를 직접 써서 **평범한 레코드**로
|
||||
> 타이핑합니다(같은 날 `index<>`/`keyof<>` + 팬텀 필드로 가는 안을
|
||||
> 넣었다가 위 §0 원칙에 따라 철회했습니다 —
|
||||
> `archive/store-value-field-redesign-withdrawn.md`) —
|
||||
> `base/store-plan.md`의 "`store.key` 레코드 필드 타이핑" 절이 소스,
|
||||
> 뒤집힌 원문은 `archive/store-value-field-redesign-withdrawn.md`.
|
||||
> 아래는 그 접근이 살아 있던 동안의 실측 기록이고, **`type function`
|
||||
> 자체의 성질**(API 시그니처, 재귀 호출 한계)은 여전히 유효합니다.
|
||||
>
|
||||
> **왜 폐기됐나**(7라운드 `H-75`/`H-76` 실측): (1) 합성 결과가 **평평하면**
|
||||
> `store.key:Compute(무주석 콜백)`이 깨진다 — 아래 1번의 ②쪼개기를
|
||||
> `type function` **안에서도** 해야 한다는 뜻인데 그게 문서 어디에도
|
||||
> 없었다. (2) `type function`은 **바깥 타입 별칭을 참조하지 못해**
|
||||
> `Source<T>` 전 표면을 구조적으로 중복 작성해야 하고, **메소드 self
|
||||
> 파라미터가 불변**이라 필드 하나만 어긋나도 `store.key`가 `State<T>`
|
||||
> 파라미터 자리에 안 들어간다. 스파이크 `16`은 그 대입을 한 번도
|
||||
> 해보지 않았다.
|
||||
|
||||
**[2026-08-15 확정, 근거: `luau-test/rewrite-required/16-type-store-key-typefunction.luau`,
|
||||
`audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 — 폐기 전 기록]**
|
||||
|
||||
`Store<T>` → `{[K]: Source<V>}` 합성을 Luau `type function`으로 하는
|
||||
설계(`pre-implementation-audit.md` 1-10)는 **설계와 실측 둘 다
|
||||
확정**입니다. 원래 스파이크가 깨졌던 이유는 설계 문제가 아니라
|
||||
설계(`pre-implementation-audit.md` 1-10)는 그 시점 **설계와 실측 둘 다
|
||||
확정**이었습니다. 원래 스파이크가 깨졌던 이유는 설계 문제가 아니라
|
||||
**`types.newfunction`의 API 버전 드리프트**였습니다 — 시그니처가
|
||||
`(parameters: {head: {type}?, tail: type?}, returns: {head: {type}?,
|
||||
tail: type?}?, generics: {type}?): type`로 parameters/returns 둘 다
|
||||
|
|
@ -466,7 +508,38 @@ local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T
|
|||
`PostRef`도 같이 커버할 것.
|
||||
- **Modifier의 제네릭 `__index` + `table.clone` 체이닝** — `luau-test/done/17`.
|
||||
- **콜백 파라미터/본문의 타입 체크**(1번의 쪼개기 적용 시) — 진짜
|
||||
살아있음.
|
||||
살아있음. **⚠️ [2026-08-25 경계 명시, 7라운드 `H-96`] 단 trailing deps가
|
||||
붙으면 dep 파라미터엔 주석이 필요하다** — deps가 0개면 콜백 파라미터가
|
||||
무주석으로 추론되지만, `:Compute(fn, a, b)`처럼 trailing deps가 붙는
|
||||
순간 그 dep 파라미터들은 해소되지 않아 `Consider annotating the return`이
|
||||
뜬다. ②쪼개기가 푸는 범위 **밖**이다.
|
||||
- **명시적 제네릭 인스턴스화 `f<<T>>(...)`가 값 호출부에서 동작**한다
|
||||
— 콜론 메소드에서도 된다(**[2026-08-25 실측]**, 7라운드 `H-73`이
|
||||
"문법이 없다"고 단정했던 것을 뒤집음). 자세한 건
|
||||
`base/store-plan.md`의 "타입 추론 문제" 절.
|
||||
- **⭐ [2026-08-25 실측] 싱글톤 보존 — 타입 후보 중에 싱글톤이 있으면
|
||||
`string`으로 안 뭉개진다.** `index<>`/`keyof<>` 기반 키 타이핑 전체가
|
||||
이 성질에 기대고 있어서 따로 확인했다:
|
||||
|
||||
| 선언 | `f("bbb")`의 `T` | 진단 |
|
||||
|---|---|---|
|
||||
| `f<T>(input: T): T` | `string` ❌ 뭉개짐 | 없음 |
|
||||
| `f<T>(input: T & string): T` | `unknown` ❌ | 없음 |
|
||||
| `f<T>(input: T & ""): T` | `"bbb"` ✅ | **에러**(교집합이 빔) |
|
||||
| **`f<T>(input: T \| "" \| string): T`** | `"bbb"` ✅ | **없음** ✅ |
|
||||
| **`f<T>(input: T & ("A" \| "B")): T`** | `"B"` ✅ | 범위 밖이면 정확히 걸림 ✅ |
|
||||
|
||||
**[2026-08-25] 다만 quad는 이 성질에 기대는 설계를 채택하지 않았다** —
|
||||
`K & keyof<...>` 기반 Store 타이핑은 같은 날 철회됐다(위 §0). 아래는
|
||||
Luau 자체의 성질로만 기록해둔다. 사용자 관찰:
|
||||
*"K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로
|
||||
뭉개지지 않고 전해진다."* 대조 전량은
|
||||
`audit/type-store-index-keyof/REPORT.md`.
|
||||
- **⛔ 타입 레벨 `__call`은 죽은 경로다**(**[2026-08-25 실측]**) — `self`를
|
||||
못 받고(`Argument count mismatch`), `typeof(f<<T>>)`로 타입 인자를
|
||||
실어 나르는 것도 실패한다. 그래서 콜러블 팩토리는 `__call`이 아니라
|
||||
**지정된 필드**로 자기를 노출한다(`base/source-state-plan.md`의
|
||||
"`state:Apply(factory)`" 절).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -88,12 +88,12 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
| `13-type-ref-preref-subtype.luau` (타입체크 전용) | **[2026-08-19 재작성]** `PreRef<T>`/`PostRef<T>`가 `Ref<T>`를 구조적으로 만족하는지 — 원래 이 파일에 있던 런타임(B) 부분은 A의 더미 스텁이 실행을 막아 도달 불가였던 문제라 `22`로 분리, PostRef까지 확장 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정), `ref-plan.md`의 "`PostRef`" 절 |
|
||||
| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 |
|
||||
| `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) |
|
||||
| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 |
|
||||
| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 **⛔ [2026-08-25 이동, `rewrite-required/`] 검증 대상이 폐기됐다** — `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는 접근 자체가 Store 재설계로 사라졌다(`base/store-plan.md`, 역전 원문은 `archive/store-value-field-redesign-withdrawn.md`). 재작성 지침은 `STATUS.md`가 소스 — **타입 함수 없는 평범한 레코드** 모양을 검증하고 음성 대조군에 **예약 키 충돌**(`CheckReserved`)과 없는 키 접근을 넣을 것 |
|
||||
| `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 |
|
||||
| `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 |
|
||||
| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" |
|
||||
| `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) |
|
||||
| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 |
|
||||
| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 **⚠️ [2026-08-25 이동, `rewrite-required/`]** `16`의 `ProcessStoreType`을 재사용하므로 같이 낡았다. **검증 대상(미선언 키는 타입 에러)은 그대로 유효**하고 새 모양에서도 성립함이 확인됐다(`store.nope` 거부) — 새 `Store<T>` 선언으로 바꿔 쓰기만 하면 된다 |
|
||||
| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지. **[2026-08-21] `rewrite-required/`로 이동** — 파일이 직접 구현해 쓰는 `Brand.set`/`Brand.get`이 인스턴스 브랜드 재작성으로 역전된 옛 API가 됐다(검증 대상 자체는 그대로 유효, 상태와 재작성 지침은 `STATUS.md`가 소스) | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 "⭐ 구현 — 인스턴스 브랜드" 절 |
|
||||
| `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 |
|
||||
|
||||
|
|
|
|||
|
|
@ -46,9 +46,17 @@
|
|||
| 폴더 | 뜻 | 개수 | 누가 처리 |
|
||||
|---|---|---|---|
|
||||
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 9 | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — |
|
||||
|
||||
**⚠️ [2026-08-25 신설] 타입 스파이크는 `./scripts/test.sh`가 하는 리링크를
|
||||
먼저 거쳐야 한다.** `luau` CLI가 심볼릭 링크를 못 타는데(디렉토리·파일 둘
|
||||
다) pesde의 워크스페이스 링크가 전부 심볼릭이라, 실제 패키지를 `require`하는
|
||||
스파이크(`23` 등)는 리링크 없이는 **의도한 진단 대신 링크 실패 진단**이
|
||||
뜬다 — 게다가 `luau-analyze`는 그걸 **조용히 통과**시키기도 한다(모듈을
|
||||
`any`로 떨어뜨림). `scripts/relink.sh`를 돌린 뒤 확인할 것. 경위는
|
||||
7라운드 `H-78`.
|
||||
|
||||
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
|
||||
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
|
||||
|
|
@ -95,6 +103,18 @@
|
|||
통과 상태로 `done/`에 두면 `01`은 구현이 안 하는 두 루프 순회를, `05`는
|
||||
**이제 접히는 중복 통지가 안 접힌다는 것**을 "검증됨"으로 오독하게 된다.
|
||||
|
||||
**[2026-08-25] `16`과 `21`이 합류** — Store 재설계로 **검증 대상 자체가
|
||||
폐기**됐다. `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는
|
||||
접근이 사라졌다 — 지금은 **타입 함수를 안 쓰고** 타입 인자에 `Source<T>`를
|
||||
직접 써서 평범한 레코드로 짓는다(`base/store-plan.md`). 같은 날
|
||||
`index<>`/`keyof<>` + 팬텀 필드 안을 넣었다가 `typing-limits.md` §0 원칙에
|
||||
따라 철회한 경위는 `archive/store-value-field-redesign-withdrawn.md`. 발단은 7라운드
|
||||
`H-75`/`H-76` 실측이다 — 평평한 합성이면 `store.key:Compute(무주석
|
||||
콜백)`이 깨지고, `type function`이 바깥 타입 별칭을 참조하지 못해 구조를
|
||||
통째로 중복 작성해야 하며 메소드 self 파라미터가 불변이라 필드 하나만
|
||||
어긋나도 대입이 실패한다. **`21`의 검증 대상(미선언 키는 타입 에러)은
|
||||
그대로 유효**하고 새 모양에서도 성립함이 확인됐다.
|
||||
|
||||
**[2026-08-21 후속] `22`도 같은 이유로 합류** — `Brand`가 **인스턴스
|
||||
브랜드**로 전면 재작성되면서(`base/brand-plan.md`) 이 스파이크가 직접 구현해
|
||||
쓰는 `Brand.set`/`Brand.get`/`XxxTag`가 **역전된 옛 API**가 됐다
|
||||
|
|
@ -112,6 +132,8 @@
|
|||
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
|
||||
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
|
||||
| `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) |
|
||||
| `16-type-store-key-typefunction.luau` | 옛 접근 기준으로는 ✅ 통과였음 | **[2026-08-25] 검증 대상이 폐기됨** — `WrapStore`/`ProcessStoreType` 합성 자체가 사라졌다. **재작성 지침**: 타입 함수 없는 평범한 레코드 모양(`base/store-plan.md`의 "`store.key` 레코드 필드 타이핑" 절)을 검증하고, 음성 대조군에 **예약 키 충돌**(`CheckReserved`가 `types.never`로 무너뜨리는지)과 **없는 키 접근**을 포함할 것 |
|
||||
| `21-type-store-undeclared-key-rejected.luau` | 옛 접근 기준으로는 ✅ 통과였음 | `16`의 `ProcessStoreType`을 재사용하므로 같이 낡음. **검증 대상(미선언 키가 타입 에러)은 그대로 유효**하다 — 새 `Store<T>` 선언으로 바꿔 쓰기만 하면 된다(`store:Of("nope")`이 거부되는 것도 같이 넣을 것) |
|
||||
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
|
||||
|
||||
## ⚪ `not-run/` — 이 환경에서 못 돌림
|
||||
|
|
@ -160,8 +182,6 @@
|
|||
| `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로). **[2026-08-24 `H-54`] 단 스파이크 자신의 주석이 실제 결과와 어긋난다** — *"이건 당연히 통과해야 함"*이라 적어둔 동질 대조군(line 41-43)이 실제로는 에러를 낸다(`AttributeKey<T>(name)`이 문맥에서 `T`를 못 추론해 `unknown`으로 남음). 오히려 "왜 진짜 테스트 대상이 조용히 통과하는지(= narrowing이 아예 안 일어남)"를 설명해주는 정합적 결과라 **이 총론은 그대로 유효**하고, 근거 라인만 다르다 — 재작성 시 참고 |
|
||||
| `13-type-ref-preref-subtype` | **[2026-08-19 재작성]** ✅ 통과 — `PreRef<T>`/`PostRef<T>` 둘 다 `Ref<T>`를 구조적으로 만족(음성 대조군도 정확히 에러). 런타임 B섹션은 `22`로 분리(A의 더미 스텁이 B 실행을 막던 문제 해결) |
|
||||
| `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 |
|
||||
| `16-type-store-key-typefunction` | **[2026-08-15]** ✅ 통과 — 원인은 설계 문제가 아니라 `types.newfunction` API 버전 드리프트(배열이 아니라 `{head=...}` 레코드). `ProcessStoreType<Input>`이 정확히 `{ty: Source<string>, count: Source<number>}` 구조를 만족, 음성 대조군 4건(틀린 Get/Set 타입 2건, 존재하지 않는 메소드) 전부 정확히 에러. 근거: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 |
|
||||
| `21-type-store-undeclared-key-rejected` | **[2026-08-19 신규]** ✅ 통과 — `16`의 `ProcessStoreType`을 재사용해 미선언 키 접근 2건(읽기, 메소드 체이닝)이 정확히 `TypeError`로 거부됨을 확인, 양성 경로(선언된 키 3개) 클린. `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측 확정 |
|
||||
| `23-type-quadtypes-checkversion-addplugin` | **[2026-08-19 신규, 같은 날 후속으로 재작성]** ✅ 통과 — 실제 `quad-types`/`quad-base`/`type-version-check`로 `CheckedQuad<T, Pattern>`+`AddPlugin<Self,P>` 통합 검증. 재작성 과정에서 `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견(`typing-limits.md` §6으로 승격), `export type function`/이중 꺾쇠 제네릭 인스턴스화 요구도 같이 실측 — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 |
|
||||
|
||||
### 특별히 중요한 통과 3건
|
||||
|
|
@ -207,5 +227,6 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치
|
|||
| 검증할 것 | 왜 | 출처 |
|
||||
|---|---|---|
|
||||
| ~~`table.insert`가 배열 중간의 구멍을 재사용하는가~~ **[2026-08-24 폐기]** | **전제 자체가 없어졌다** — 6라운드 `H-7`로 `Ref.Callbacks`가 배열에서 `{[callback\|thread] = true}` **해시맵 셋**으로 바뀌었고, 해시맵엔 border 개념도 구멍도 없다(`base/ref-plan.md`). 이 스파이크는 만들지 말 것 | QA 4라운드 `R-11`(폐기), 6라운드 `H-7` |
|
||||
| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M2 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 최우선 절 |
|
||||
| 중간 State가 `_hold`(하류 → 상류 강함) 불변식으로 실제로 살아남는가 | **[2026-08-25] 설계는 확정됐다** — 각 파생 노드가 자기 상류를 `_hold`로 강하게 든다(사용자 확정). 남은 건 실측뿐이고 **M2 착수 게이트는 아니다**(`question.md` 최우선 절에서 내려감). 음성 대조군으로 "`_hold` 없이 짜면 중간 노드가 수거돼 전파가 끊긴다"까지 볼 것 | `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`로 살아남는다" 절 |
|
||||
| `Relate` 값/내부 키가 바깥 키를 되참조하면 새는가 | **[2026-08-25 신설, 7라운드 `H-71`/`H-77`]** `done/07`은 **안전한 모양만** 봤는데 여러 문서가 그걸 "GC-native 아키텍처의 핵심 전제 검증"으로 인용한다. 실제로는 (a) `SetStrong`의 **값**이 되참조하면 100% 새고, (b) **내부 키**가 되참조하면 `SetStrong`/`SetWeak` **둘 다** 샌다. `07`에 음성 대조군으로 추가할 것 | `base/relate-plan.md`의 "위험한 패턴" 절 슬롯 표 |
|
||||
| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` |
|
||||
|
|
|
|||
|
|
@ -17,14 +17,21 @@ M3의 번호·순서가 맞바뀌었다** — 열려 있던 마일스톤 순서
|
|||
교체로 닫힌 결과다(경위는 `archive/question-resolved.md`의 "마일스톤 경계"
|
||||
절, 새 구성은 `ROADMAP.md`의 M2 배너). **2026-08-24 이전에 쓰인
|
||||
`session/`·`archive/`·`qa-request/`의 `M2`/`M3`는 옛 의미**(M2=디스패치,
|
||||
M3=반응형)다. 그 교체의 부작용으로 **M2 착수 전에 답이 필요한 항목이
|
||||
`question.md` 최우선 절로 올라왔다**(무엇이 몇 개인지는 그 절이 소스 —
|
||||
여기서 세지 않는다. 설계 게이트가 아니라 실측·표면 선택이라, 설계 게이트가
|
||||
없다는 아래와 `todos.md`의 서술과 모순되지 않음). 저장소 루트에
|
||||
M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선 절에 항목
|
||||
둘이 올라와 있었으나, **⭐ [2026-08-25] 둘 다 닫혀 그 절은 지금 비어 있다**
|
||||
(중간 State GC는 `_hold` 불변식으로, `store:GetDynamic` 위치는 콜론 유지 +
|
||||
`CheckReserved` 타입 함수로 — 7라운드 손 트레이싱 후속, 결정 전량의 소스는
|
||||
`qa-request/pre-implementation-handtrace-round7-followup.md`). **M2 착수를
|
||||
막는 항목은 설계든 실측이든 없다.** 저장소 루트에
|
||||
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
|
||||
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
|
||||
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`가
|
||||
소스. 핵심 아키텍처(Store 책임 분리, `process`/`retract`
|
||||
소스. **⚠️ [2026-08-25] 테스트는 `./scripts/test.sh`로 돌릴 것** —
|
||||
`luau` CLI가 **심볼릭 링크를 못 타는데**(디렉토리·파일 둘 다) pesde의
|
||||
워크스페이스 링크가 전부 심볼릭이라, 그냥 `luau`로 돌리면 스모크 2개가
|
||||
죽고 `luau-analyze`는 **조용히 통과**한다(모듈을 `any`로 떨어뜨림 —
|
||||
"거짓 클린"). 그 스크립트가 `scripts/relink.sh`를 먼저 돌려 심볼릭을 실제
|
||||
복사로 바꾼다. 핵심 아키텍처(Store 책임 분리, `process`/`retract`
|
||||
디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘,
|
||||
컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`에
|
||||
문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가
|
||||
|
|
|
|||
|
|
@ -1692,8 +1692,8 @@ nil value`, (b) 이름을 `self:Gate(...)`로 고쳐도 `setup`이 핸들러를
|
|||
## 🟢 `H-36` — `store-plan.md`가 미해결 항목을 확정처럼 근거로 인용한다
|
||||
|
||||
**어디**: `base/store-plan.md`(*"State/Source 그래프 구독이 전부 weak-keyed
|
||||
GC-native"*) vs `base/source-state-plan.md`의 "⚠️ 미해결 — 중간 State가
|
||||
살아남는가(구독 엣지의 방향성)" 절.
|
||||
GC-native"*) vs `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`로
|
||||
살아남는다" 절.
|
||||
|
||||
**무엇이 어긋나나**: `store-plan.md`는 "그래프 구독이 **전부** weak-keyed"라는
|
||||
명제를 기정사실로 써서 다른 결론(이중 해제 걱정 불필요)을 내리는데, 그 명제
|
||||
|
|
|
|||
|
|
@ -0,0 +1,899 @@
|
|||
# 7라운드 손 트레이싱 발견 — **사용자 결정과 반영 결과**
|
||||
|
||||
**무엇인가**: `.claude/qa-request/pre-implementation-handtrace-round7.md`의
|
||||
발견 52건(`H-55`~`H-106`)과 그 검증 패스
|
||||
(`qa-request/pre-implementation-handtrace-round7-verification.md`)를 사용자와 대화형으로 처리한 결과. **결정의 소스는
|
||||
이 문서**이고, 발견 원문은 위 두 파일이 소스다(여기서 다시 서술하지 않음).
|
||||
|
||||
**진행 방식**: 검증 패스가 재편성한 **결정 단위 12묶음(🅐~🅜)** 순서를
|
||||
따랐다. 같은 묶음 안의 항목은 결론이 서로를 규정하므로 같이 물었다.
|
||||
|
||||
**[2026-08-25] 결정·반영 전부 완료.** 12묶음을 순서대로 처리했고 `base/`
|
||||
반영까지 끝났다(`doc-check.py` ERROR 0). 처분 요약:
|
||||
|
||||
| 처분 | 건수 | 번호 |
|
||||
|---|---|---|
|
||||
| 확정 — `base/` 반영 | 39 | `H-55`~`H-72`, `H-79`~`H-82`, `H-84`~`H-92`, `H-94`~`H-99`, `H-101`~`H-106` |
|
||||
| **무효/소멸** | 4 | `H-73`(`<<T>>`가 값 호출부에서 동작함) · `H-74`·`H-75`·`H-76`(Store 재설계로 `WrapStore` 폐기) |
|
||||
| **기각** | 1 | `H-77`(`RunInit` 사례 — 성립 안 하는 사용법) |
|
||||
| 범위 축소 | 2 | `H-87`(🔴→🟡) · `H-105`(수치 정정) |
|
||||
| 다른 항목으로 흡수 | 6 | `H-64`/`H-70`(`Ref`=`Epoch`) · `H-102`(역참조 조회) · `H-93`/`H-98`(`_hold`) · `H-83` |
|
||||
|
||||
**부수로 닫힌 것 둘** — `question.md` 최우선의 **중간 State GC**(`_hold`
|
||||
불변식)와 **`GetDynamic` 위치**(콜론 + `CheckReserved`). **M2 착수를 막는
|
||||
항목이 하나도 남아 있지 않다.**
|
||||
|
||||
**새 표면**: `Ref.Revision`(=`Epoch`) · `Ref:WeakCallback` ·
|
||||
`Observer:WeakSubscribe`/`:WeakUnsubscribe` · `EpochMap:Peek` ·
|
||||
`store:Of`/`:Names` + `CheckReserved` · `emit(commit) -> boolean` ·
|
||||
`bk.recomputeBlocker` · `EffectHandle:Rerun`(정의) · `_consumeCleanup`
|
||||
**폐기**: `WrapStore`/`ProcessStoreType` · `_installing` · `rawInvalid` ·
|
||||
`_refDeps`/`_refCallbacks`/`_observers`(→ `_deps` 하나) · Store의 lazy 우선 모델
|
||||
**역전**: `store.key = value` 부활(`archive/store-value-field-redesign-withdrawn.md`)
|
||||
**툴체인**: `scripts/relink.sh` + `scripts/test.sh` 신설 — `luau` CLI가
|
||||
심볼릭 링크를 못 탄다는 것이 최소 재현으로 밝혀졌다(`H-78`).
|
||||
|
||||
---
|
||||
|
||||
## 🅐 게이트 정책의 상태 접근 통로 — `H-55` · `H-86` · `H-72` · `H-63`
|
||||
|
||||
### `H-55` + `H-86` — `emit`에 인자와 반환값을 준다 **(확정)**
|
||||
|
||||
`setup: (emit: (commit: boolean?) -> boolean) -> (onUpstreamEmit: () -> ())`
|
||||
|
||||
- `emit()` / `emit(true)` — 평소대로 흡수 집합을 flush하고 전파.
|
||||
- `emit(false)` — **흡수 집합을 버리고** 전파하지 않는다(`Trailing = false`,
|
||||
`Cancel`, `OffWithoutEmit`이 요구하던 "버리기").
|
||||
- **반환값** — "실제로 내보내거나 버릴 게 있었는가"(= 흡수 집합이
|
||||
비어 있지 않았는가). 정책이 `pending`을 따로 안 들고도 "지금 쌓인 게
|
||||
있나"를 읽는 유일한 통로.
|
||||
|
||||
`H-55`의 갈래 (b)와 `H-86`의 갈래 (a)를 그대로 합성한 것. `H-49`의
|
||||
*"`setup` 시그니처는 안 바뀐다"*는 **인자 목록은 유지한 채** 최소로만
|
||||
되짚는다(인자가 늘지 않고 기존 인자에 선택 파라미터와 반환값이 붙는다).
|
||||
|
||||
`Throttle`의 `onWindowEnd`가 이걸로 닫힌다:
|
||||
|
||||
```lua
|
||||
if not emit() then -- 보류분 없었음
|
||||
window = nil -- 완전 idle 복귀 → 타이머 체인 종료
|
||||
else
|
||||
rearm()
|
||||
end
|
||||
```
|
||||
|
||||
### `H-72` — `EpochMap:Peek(from) -> boolean` 추가 **(확정)**
|
||||
|
||||
갈래 (a). 읽기 전용 비교(저장된 리비전과 비교만 하고 덮지 않음).
|
||||
`Update`가 이미 `{읽기, 비교, 쓰기}`라 `Peek`은 그 앞 두 개만 쓰는 것이고
|
||||
내부 코드 공유가 쉽다. `GateNode:_receive`가 이걸 쓴다:
|
||||
|
||||
```lua
|
||||
local valueChanged = self.valueEpochMap:Update(from)
|
||||
local emitChanged = self.emitEpochMap:Peek(from) -- 갱신 안 함
|
||||
```
|
||||
|
||||
### `H-63` — 세 자리 모두 선례대로 **(확정)**
|
||||
|
||||
1. onunblock 핸들 보관은 **weak-키 해시맵 셋** `{[handle] = true}`
|
||||
(`__mode = "k"`) — `H-7`이 `Ref.Callbacks`에 한 것과 동일. 배열의
|
||||
구멍/`ipairs` 조기 종료 문제가 소멸한다.
|
||||
2. **강한 주인은 정책이 반환하는 `onUpstreamEmit` 클로저다** — 그 클로저가
|
||||
onunblock 핸들을 upvalue로 잡는다. 체인은
|
||||
`GateNode → onUpstreamEmit → onunblock 핸들`이고, Blocker 쪽은 weak이라
|
||||
**게이트 노드가 죽을 때만** 수거된다. Blocker가 핸들을 강하게 드는 안은
|
||||
기각 — 오래 사는 Blocker 하나가 거기 걸렸던 모든 gated state와 상류
|
||||
체인을 영원히 살려두므로(`:List` 항목마다 게이트를 무는 패턴에서 직행
|
||||
누수).
|
||||
3. `Off()`/`OffWithoutEmit()`은 **스냅샷을 뜬 뒤 순회**한다(`H-23`과 동일).
|
||||
|
||||
---
|
||||
|
||||
## 🅑 전파 루프를 코드로 확정 — `H-56` · `H-62` · `H-61`
|
||||
|
||||
### `H-56` — 한 집합 + 구독자 종류별 분기 **(확정)**
|
||||
|
||||
구독자 집합은 **하나**(`{[subscriber] = true}`, weak-키)이고, 원소는
|
||||
**Observer 값**이다 — emit 클로저가 아니다(`bindLifetime`이 그 identity를
|
||||
쓰므로 필수). `lifecycle-pattern.md` (4)의 *"Observer의 emit 클로저"* 표현을
|
||||
고친다.
|
||||
|
||||
```lua
|
||||
function State:_emitDown(from)
|
||||
local snap = {} -- H-23 스냅샷
|
||||
for sub in self._subs do snap[#snap+1] = sub end
|
||||
for _, sub in ipairs(snap) do
|
||||
if isState(sub) then -- 자식 노드
|
||||
sub:_receive(from) -- §4 규칙 1~3, canExecute 안 봄
|
||||
elseif canExecute(sub) then -- Observer / Effect
|
||||
sub.fn(sub, from)
|
||||
end -- 거짓이면 조용히 건너뜀
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
두 집합(`_childNodes`/`_observers`)으로 나누는 안은 기각 — emit마다 스냅샷이
|
||||
두 번이 되고(`H-92`의 할당 비용 두 배), 등록/해제 경로도 둘로 갈린다.
|
||||
|
||||
### `H-62` — eager(생성 즉시 등록) **(확정)**
|
||||
|
||||
**사용자 판단**: *"생성 즉시 밖에 없다. 옵져버가 실행 안 된다면 get 자체가
|
||||
안 되므로, lazy 하면 아예 등록 될 기회가 없다."* — lazy가 성립하려면
|
||||
"먼저 `Get()`이 일어난다"가 전제인데, `Get()`을 부르는 주체가 바로 그
|
||||
등록되지 못한 Observer라 순환이다.
|
||||
|
||||
`:With`/`:Compute`/`:Gate`/`:Block`은 **만들어지는 그 자리에서** 상위의
|
||||
구독자 집합에 들어간다. `state-epoch-plan.md` §4의 생성 시점 시딩,
|
||||
`blocker-plan.md`의 *"호출되는 즉시 … 등록"*, `source-state-plan.md`의
|
||||
":With도 새 State 노드로 확정" 절과 이미 일치한다. lazy를 논거로 쓰는
|
||||
"왜 State 체인을 Modifier처럼 플래튼하지 않는가" 절을 고친다.
|
||||
|
||||
⚠️ eager이므로 `question.md` 최우선의 **"중간 State GC"** 미해결이 더
|
||||
절실해진다(엣지가 있어도 상위가 하위를 weak로만 들면 노드가 사라진다).
|
||||
|
||||
### `H-61` — 무인자 `state:Observer()`의 내부 콜백은 `self:Get()` **(확정)**
|
||||
|
||||
`state:Observer() == state:Observer(function(self) self:Get() end)`.
|
||||
no-op 콜백은 `Get()`을 안 부르므로 문서가 적은 용도(`previous` 패턴의
|
||||
mutate 로직을 계속 돌게 하기)를 못 한다. 호출부 서술은 그대로 두고 내부
|
||||
콜백만 못박는다.
|
||||
|
||||
---
|
||||
|
||||
## 🅔 `Effect` 배선 — `H-57`~`H-60` · `H-64` · `H-65` · `H-70`
|
||||
|
||||
이 묶음은 대화 중에 **원문의 갈래 어느 것도 아닌 구조**로 수렴했다. 사용자가
|
||||
두 도구를 제안했고(`Ref`의 `Epoch` 승격, `Weak*` 등록 표면), 그 둘이
|
||||
`H-58`/`H-64`/`H-70`/`H-59`를 **한 번에** 닫았다.
|
||||
|
||||
### 확정된 구조 — 강한 주인은 항상 `Effect`, 발화 게이트는 `canExecute` 하나
|
||||
|
||||
```
|
||||
Effect ──강──▶ { [Ref | State] = fn | Observer } ← 강한 주인은 언제나 Effect
|
||||
Ref.Callbacks ──약──▶ fn (:WeakCallback)
|
||||
Observer 전역 레지스트리 ──약──▶ Observer (:WeakSubscribe)
|
||||
발화 게이트: 전부 canExecute(handle) 하나로
|
||||
```
|
||||
|
||||
**사용자 결론**: *"그냥 간단하게 저 강한 map 을 Effect 가 가지고,
|
||||
WeakSub/WeakUnsub 를 WeakCallback 처럼 넣어줍시다. 의미론은 같습니다.
|
||||
callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지 않으면 gc 될
|
||||
수 있다. **그러면 bindLifetime 은 effect 하나 구현을 한 이후 canExecute 로
|
||||
모두 처리한다. 간단해집니다.**"*
|
||||
|
||||
- dep마다 바인드/언바인드에서 등록·해제하던 춤이 **통째로 사라진다**.
|
||||
`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고,
|
||||
내부 Observer와 `Ref` 콜백의 발화 여부는 `canExecute(handle)`이 전담한다.
|
||||
- 그래서 `H-7`의 *"`unbindLifetime`과 `:Unsubscribe()`에서 `:Uncallback`한다"*는
|
||||
**필요 없어진다**(`ref-plan.md`의 *"해제는 누수를, 게이팅은 발화를 막는다"*
|
||||
중 앞쪽 절반을 `Weak*`가 대신한다).
|
||||
|
||||
### 새 표면 — `Ref:WeakCallback` / `Observer:WeakSubscribe`·`:WeakUnsubscribe`
|
||||
|
||||
- **`Weak*`가 프리미티브이고 강한 쪽이 그 위에 얹힌다.** **사용자 확정**:
|
||||
*"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나 단순히 gc 안
|
||||
되도록 킵 해주는 부분만 제거된 함수가 됩니다. 따라서 내부적으론 Weak 를
|
||||
구현해 두고, Weak 아닌 곳에서 Weak 를 수행하고 gc 처리만 두면 돼요."*
|
||||
→ `Callback(fn) = WeakCallback(fn) + 강한 셋에 킵`,
|
||||
`Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`. 구현이 한 벌.
|
||||
- 왜 `WeakRef`가 아니라 이것인가: **사용자 지적** — *"Ref 안에 항상 콜백이
|
||||
쌓인다는것도 문제가 됨."* `WeakRef`는 "`Ref`가 `Effect`를 붙든다"만 풀고
|
||||
"`Ref.Callbacks`에 죽은 클로저가 쌓인다"는 못 푼다. `WeakCallback`은 둘 다
|
||||
푼다 — `Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고,
|
||||
`Ref` 쪽 항목도 같이 사라진다.
|
||||
- **`WeakRef`는 만들지 않는다**(사용자: *"안 만들어야겠습니다. 아이디어만
|
||||
기록."*). 아이디어 원문: `WeakRef:Set(v)`/`:Get()`만 주고
|
||||
(`.Value`가 아닌 이유는 내부 값이 항상 있다고 확정된 상태가 아니라서),
|
||||
내부는 `setmetatable({}, {__mode = "v"})`의 1-슬롯.
|
||||
- 이건 🅐-3(`H-63`)에서 `Blocker`의 onunblock 핸들에 내린 결정과 **같은
|
||||
패턴**이다 — 강한 주인은 소비자 쪽, 등록처는 weak.
|
||||
|
||||
### `H-58` + `H-64` + `H-70`(중복) — `Ref`를 `Epoch`로 승격 **(확정)**
|
||||
|
||||
**사용자 제안**: *"혹은 Ref 까지도 Epoch 를 구현해줘도 좋다는 생각.
|
||||
'바뀌였나?' 보는건 source 에 대한 계약이라, 똑같이 실제 값을 가지는 Ref 도
|
||||
이를 구현해주는데 문제가 없음."*
|
||||
|
||||
`state-epoch-plan.md` §2가 이미 예상해둔 확장이다 — *"`Source`가 아닌
|
||||
원천(외부 시계 등)이 특수 분기 없이 낀다 … `EpochBrand:register(self)` 한
|
||||
줄로 끝난다."*
|
||||
|
||||
- `Ref`가 공개 필드 `.Revision`을 갖고 `EpochBrand`에 등록된다.
|
||||
`:Set()`이 `Source`와 같은 `bit32.bnot(-rev)`로 갱신.
|
||||
- `.Callbacks`(푸시 경로)는 그대로 — `Epoch`는 부기일 뿐이다.
|
||||
- 그래서 `EffectHandle._epochs`가 State/Source/`Ref`를 **균일하게** 담고,
|
||||
포탈 캐치업이 dep 종류에 따라 갈리던 `H-64`가 **대칭**이 된다.
|
||||
- `H-70`의 "같은 `Ref` 중복"도 `EpochMap` 키 dedup으로 **공짜로** 닫힌다
|
||||
(사용자: *"이미 dedup 해주는 도구는 Epoch 가 있고, 그걸 그대로 적용하는
|
||||
사안으로 공짜로 해결됨"*).
|
||||
|
||||
### `H-58` — 등록 구간 억제는 `Blocker` **(확정)**
|
||||
|
||||
**사용자 지적**: *"해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서
|
||||
사용중임. 모든 옵저버와 callback 등록에 있어서 이를 수행해야할 것임.
|
||||
**옵져버도 처음에 호출하고, 여러 state 를 넣을 수 있음에 유의할것.**"*
|
||||
|
||||
이 문제는 `Ref` dep 전용이 아니다 — `source-state-plan.md`가 확정한
|
||||
*"`fn`은 등록 시점에 즉시 1회 실행된다"* 때문에 **State dep이 여러 개인
|
||||
`Effect`도 똑같이 중복 실행**된다.
|
||||
|
||||
- `Effect`가 사적 `Blocker` 하나를 든다. 등록 구간 동안 `On()`, 끝나면
|
||||
`OffWithoutEmit()`. 즉시-1회 호출 경로가 전부 `blocker:IsOn()`이면 조기
|
||||
리턴. `materializeSlotTree`가 쓰는 관용구와 같은 모양이다.
|
||||
- **별도 `_installing` 플래그는 폐기** — 그건 생성자 구간만 덮어 바인드
|
||||
구간을 놓쳤다.
|
||||
|
||||
### `H-64` — 캐치업은 조건부, 그리고 **미설치면 재설치** (확정)
|
||||
|
||||
```lua
|
||||
if self._cleanup == nil or self._epochs:Refresh() then self:Rerun() end
|
||||
```
|
||||
|
||||
**사용자 판단**: *"cleanup 함수가 있냐 없냐를 보긴 해야겠네요. 기본적으로
|
||||
cleanup 의 실행은, cleanup 필드를 읽은 다음 그 필드를 제거해버리고 그 다음
|
||||
cleanup 을 실행해야할 것으로 보입니다. 그러면 cleanup 클로저가 있냐 없냐로
|
||||
클린 해야하는지 알 수 있어요. 그리고 cleanup 클로저를 지우는건, 이 역시
|
||||
gc 에 중요한 부분이겠네요."*
|
||||
|
||||
- "바인드 때 항상 실행"이 아니라 **"설치돼 있지 않으면 설치"**다. 그래서
|
||||
아래 `H-65`가 요구하는 재설치와 포탈 캐치업이 같은 한 줄로 닫힌다.
|
||||
|
||||
### `H-57` — retract가 직접 cleanup을 부른다 (확정)
|
||||
|
||||
갈래 (a). `Observer/Effect Leaf dedup`의 retract 클로저에서
|
||||
`nextValue ~= v` 분기가 `unbindLifetime(v)` 뒤에 `isEffect(v)`면 cleanup을
|
||||
**소진 호출**한다. 소진(필드 읽기 → 필드 제거 → 실행)이 있으므로 파괴
|
||||
경로와 이중 호출이 없고, `H-11`이 확정한 *"`unbindLifetime`은 cleanup을
|
||||
부르지 않는다"*는 안 건드린다.
|
||||
|
||||
### `H-60` — `Rerun()` 정의: 재진입은 **지연 재실행** (확정)
|
||||
|
||||
**사용자 판단**: *"Effect 의 실행 안에서 뭔가 수행되어 rerun 해야할 상황이
|
||||
발생하면, 지연해 두었다 나중에 재실행 하는건 어떤지(실행이 끝나고 나서).
|
||||
실제로 Effect 안에서 state 등을 바꾸는 상황은 react 등지에서 흔함. 유일한
|
||||
문제는 error 발생 시 어떻게 되느냐인데, 그냥 UB로 두는게 맞아보임. 에러가
|
||||
난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작이라서."*
|
||||
|
||||
```lua
|
||||
function EffectHandle:Rerun() -- 공개 메소드, 무인자
|
||||
if self._running then
|
||||
self._pending = true -- 실행 중 재진입 → 지연
|
||||
return
|
||||
end
|
||||
self._running = true
|
||||
repeat
|
||||
self._pending = false
|
||||
local c = self._cleanup
|
||||
self._cleanup = nil -- 읽고 → 지우고 → 실행 (소진)
|
||||
if c then c() end
|
||||
self._cleanup = self.fn(self)
|
||||
until not self._pending -- 재요청이 또 오면 또 돈다
|
||||
self._running = false
|
||||
end
|
||||
```
|
||||
|
||||
- `canExecute` 확인은 **호출부**가 한다(`Ref` 콜백·전파 루프가 이미 그렇게
|
||||
한다). 사용자가 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다.
|
||||
- **error 시 UB** — 전파되고 복구하지 않는다. 🅒(예외 안전성) 묶음의 원칙과
|
||||
같다.
|
||||
- 수렴 책임은 사용자 `fn`에 있다 — 무한 루프는 UB.
|
||||
|
||||
### `H-59` — `:Subscribe()`는 셋을 다 한다 (확정)
|
||||
|
||||
(a) `handle.Subscribed = true` + 전역 레지스트리에 **핸들 자신** 등록
|
||||
(`_observers`만 등록하는 해석은 폐기 — `Effect(fn)`이 GC돼 cleanup이
|
||||
유실된다), (b) 내부 Observer 각각 `:WeakSubscribe()`, (c) `Ref`
|
||||
`:WeakCallback()`. (b)(c)는 위 구조상 **생성자에서 이미 끝나 있고**,
|
||||
`:Subscribe()`가 새로 하는 일은 (a)뿐이다.
|
||||
|
||||
### `H-70` — deps 검증 (확정)
|
||||
|
||||
- `nil` dep → **error**. `select("#", ...)`로 순회해 구멍을 실제로 본다.
|
||||
- 중복 dep → **무시**(error 아님). **사용자 근거**: *":With 이나 시소한
|
||||
연산으로 다른 State 가 된다던가 하면 deps 가 겹쳐도, 근원 source 가 겹쳐도
|
||||
에러를 안 냄. Ref 도 유사한 부분."* — `EpochMap` 키 dedup이 처리한다.
|
||||
- State/Source/`Ref`가 아닌 값 → **error**. `H-40`이 요소 검증을
|
||||
화이트리스트로 뒤집은 것과 같은 성격.
|
||||
|
||||
### `H-65` — 재바인드는 재설치, 재사용은 **팩토리 패턴** (확정)
|
||||
|
||||
- **재바인드**: 위 `H-64`의 한 줄이 그대로 답이다 — `_cleanup`이 없으면
|
||||
`Rerun`으로 재설치한다. 죽음을 표시하는 별도 부기를 만들지 않는다
|
||||
(**사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에 이벤트 바인딩인데
|
||||
이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도 알아서 잘 풀린
|
||||
상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시 실행해줘야한다는
|
||||
것 이외엔 아무 문제가 없어요. 즉시 에러를 내려면 뭔가 다른 행동을
|
||||
해줘야합니다만. 그걸로 얻는게 있느냐? 에 대해서는 의문입니다."*).
|
||||
- **재사용/다중 인스턴스는 `Effect` 팩토리를 넘기는 패턴으로 안내한다.**
|
||||
검토 중에 `Effect:Clone()` / `Effect<UD>:Userdata()` / `Effect.Template`
|
||||
안이 차례로 나왔으나 **전부 기각**됐다 — **사용자 결론**: *"차라리 Effect
|
||||
를 만들어내는 팩토리를 넘기는 패턴을 권장해야할듯 해요 … Clone 도,
|
||||
Userdata 도, 템플릿도 필요하지 않다."*
|
||||
|
||||
```lua
|
||||
local function TimerEffectFactory(data: { timerSource: Source<...> }): Effect
|
||||
return Effect(function(self)
|
||||
...
|
||||
return function() ... end
|
||||
end, data.timerSource) -- 주입받은 것을 그대로 deps로도 쓸 수 있다
|
||||
end
|
||||
```
|
||||
|
||||
- **왜 이게 더 나은가**(사용자): 초기 1회 실행 문제가 자연히 해결되고,
|
||||
템플릿이 실행되어 찌꺼기로 남는 것을 막으려 따로 뭘 할 필요가 없다.
|
||||
자식의 계약은 `({...}) -> Effect` 하나이고, 부모가 더 큰 타입을 넘겨도
|
||||
**부분 성립**으로 해결된다. 무엇보다 **주입받은 `Source`/`Ref`를 그대로
|
||||
`deps`로 넣을 수 있다** — userdata로는 절대 안 되던 것이다
|
||||
(*"이건 ud 가 deps 에 대해서는 아무 처리가 못 했던것과 비교해 더
|
||||
간단하면서도, 기능적임"*).
|
||||
- **이미 있는 관례다** — modifier에서 같은 패턴을 권해왔다.
|
||||
- 부수로 *"이팩트를 여러곳에 바인딩하면?"*도 자연히 해결된다(매번 새
|
||||
인스턴스).
|
||||
|
||||
### 이 묶음에서 **안 만들기로 한 것**
|
||||
|
||||
`WeakRef`, `Effect:Clone()`, `Effect<UD>:Userdata()` / `SetUserdata`·
|
||||
`GetUserdata`, `Effect.Template`. 전부 검토 후 기각 — 위 각 절이 근거의
|
||||
소스다.
|
||||
|
||||
---
|
||||
|
||||
## 🅓 "긴 연산의 꼬리가 도중 변경을 덮어쓴다" — `H-85` · `H-101` (+ `H-102`)
|
||||
|
||||
### `H-85` — `rawInvalid` 불린을 **캐시 카운터 쌍**으로 교체 (확정)
|
||||
|
||||
원문의 갈래 (a)(`rawInvalid = false`를 `fn` 앞으로)는 **불충분하다** —
|
||||
**사용자 지적**: *"이러면 대신, 캐시가 언제 생성된 캐시인지 모르는 이슈가
|
||||
발생하지 않아? 특히 에러가 난다고 하면, 다시 계산 안하고 이전 결과를 다시
|
||||
쓰겠네?"* 그리고 *"state 는 epoch 를 구현해선 안 돼. 중간이지, 초기 값
|
||||
컨테이너 계층은 아니거든. 따라서 cache count 를 넣을것을 추천해."*
|
||||
|
||||
```lua
|
||||
-- 생성 시
|
||||
self.cacheTargetCount = 1
|
||||
self.cacheCurrCount = 0 -- 달라서 "재계산 필요"
|
||||
|
||||
-- 무효화(§4 규칙 1) — 옛 `rawInvalid = true` 자리
|
||||
if valueChanged then
|
||||
self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount)
|
||||
end
|
||||
|
||||
-- 재계산
|
||||
local gen = self.cacheTargetCount -- fn 직전 스냅샷
|
||||
self.cache = self.fn(self, self.cache, ...)
|
||||
for _, d in self.deps do d:_track(self.valueEpochMap) end
|
||||
self.cacheCurrCount = gen -- ← 성공했을 때만
|
||||
|
||||
-- 재계산 판정 — 옛 `rawInvalid == true` 자리
|
||||
if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end
|
||||
```
|
||||
|
||||
- **재계산 도중 도착한 무효화**는 `cacheTargetCount`를 앞서게 만들어
|
||||
다음 `Get`이 반드시 재계산한다(`H-85` 본체).
|
||||
- **`fn`이 던지면** `cacheCurrCount`가 안 갱신되므로 계산된 적 없는 캐시를
|
||||
유효하다고 확신하는 일이 없다(갈래 (a)가 못 막던 것).
|
||||
- 증가는 **`bit32.bnot(-n)` 랩** — §2가 `Source.Revision`에 확정한 그 한
|
||||
줄을 그대로 쓴다. 비교가 `~=`뿐이라 랩이 무해한 것도 똑같고, uint32 안에
|
||||
머무르므로 `+1`이 갖는 2^53 포화 지점 자체가 없다.
|
||||
- **State는 여전히 `Epoch`를 구현하지 않는다** — 이 카운터는 자기 재계산
|
||||
부기이지 남이 키로 삼는 리비전이 아니다(§4가 State dep에 대해
|
||||
`TrackFrom(dep.valueEpochMap)`을 쓰는 것과 일관).
|
||||
|
||||
### `H-101` + `H-102` — 재진입은 Blocker로 막고, 커서는 `invalidAfter`로 되감는다 (확정)
|
||||
|
||||
**먼저 원문의 두 서술을 정정한다.**
|
||||
|
||||
1. *"`:List`의 `updateFn`이 `offset`을 인자로 받는 사용자 코드라 거기서
|
||||
형제 Slot을 조작"* — **틀렸다**. **사용자 정정**: *"updateFn 자체를
|
||||
재실행 하는건 아니거든. offset 을 state 로 넘겨주어서 옵져빙 하게
|
||||
만들잖아."* 남는 트리거는 그보다 좁은 것 — `slot.Offset` State를
|
||||
**관측하는** 사용자 코드.
|
||||
2. *"바깥 루프의 남은 `sum`이 낡는다"* — **대체로 틀렸다**. **사용자 정정**:
|
||||
*"offset:Set 동작 자체가 무언가를 트리거 한다면, 그게 다 끝난 다음
|
||||
돌아옴 … sum 이 더해지는 시점, 그러니까 offset 설정 이후라면, 이미
|
||||
length 는 확정값임."* 실제로 재현된 오작동은 길이가 더럽혀진 게 아니라
|
||||
**`bk.N`이 자란 것**이었다(`for i = 1, bk.N`의 상한이 진입 시 한 번만
|
||||
평가됨).
|
||||
|
||||
**확정된 구조**:
|
||||
|
||||
- **Blocker 두 개.** 기존 배치 게이팅용(`drive`/`materializeSlotTree`가 켜는
|
||||
것)과 **재진입 차단용**을 따로 둔다(**사용자 확정**: *"그냥 blocker
|
||||
두개 쓰세요."*). 합치면 배치 `Off()`의 onunblock 순회 도중 같은 Blocker가
|
||||
다시 꺼져 핸들이 재귀한다 — `blocker-plan.md`가 네스팅을 의도적으로
|
||||
미지원한 것과 충돌.
|
||||
- **길이 변경 경로는 스킵으로 끝난다.** 바깥 루프가 `offset:Set(i)` **직후**에
|
||||
`contribution(i)`를 읽으므로 그 Set이 유발한 자식 길이 변경은 이미
|
||||
반영된 값이다 — 다시 도는 건 순수 낭비(사용자: *"안 돌아도 되는데"*).
|
||||
- **⭐ 구조 변경은 되감는다.** **사용자 판단**: *"정말 앞에서 슬롯이
|
||||
당겨졌다고 쳐요. 순차 순회로 처리가 안되는게 … `{a,a,a, b,b, c,c}`
|
||||
여기서 a,a 두개가 소멸했는데, 이미 c 에 왔다면, b,b 가 c,c 로 덮여지고
|
||||
a,a 는 달라지는게 없을 가능성이 생기죠. 따라서 recompute 도중 변경이
|
||||
생긴다면, 변경이 생긴 곳으로 위로 올라가야할것 같습니다. 따라서, 지금
|
||||
리컴퓨팅 중인 인덱스를 바꿀 방법을 제공하는게 나아보여요. 그러면 bk.N 에
|
||||
대한 문제도 알아서 해결될것 같아보입니다."*
|
||||
- **되감기 신호는 기존 `bk.invalidAfter` 하나로 통일한다** — 새 필드를 안
|
||||
만든다. 두 뜻("캐시가 여기까지 유효"와 "여기 다음부터 다시 해야 함")이
|
||||
실제로 같은 것이기 때문. **재개 지점은 `invalidAfter + 1`**이다
|
||||
(**사용자 지적**: *"length 가 바뀐 쪽에서, 본인의 offset 은 여전히
|
||||
유효하다는거죠."* — `getOffsetAt`의 `for i = bk.invalidAfter, at - 1`과
|
||||
같은 읽기). **splice는 `j`가 아니라 `j - 1`로 낮춘다** — 밀린 자리는
|
||||
자기 offset도 다시 `Set` 해야 하므로.
|
||||
|
||||
```lua
|
||||
function recompute(bk, ownerKey)
|
||||
bk.recomputeBlocker:On()
|
||||
local prefix, i, sum = {}, 1, 0
|
||||
while i <= (bk.N or 0) do -- 상한 매 반복 재평가 → N 증가 흡수
|
||||
prefix[i] = sum
|
||||
bk.offsetCache[i] = base + sum
|
||||
bk.invalidAfter = i -- 여기까지 유효해짐
|
||||
bk.offsets[i]:Set(base + sum) -- ← 사용자 코드가 돌 수 있는 자리
|
||||
sum += contribution(bk, i)
|
||||
if bk.invalidAfter < i then -- 누군가 낮췄다 → 되감기
|
||||
i = bk.invalidAfter + 1
|
||||
sum = prefix[i]
|
||||
else
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
ownerKey.Length:Set(sum)
|
||||
bk.invalidAfter = bk.N or 0
|
||||
bk.recomputeBlocker:OffWithoutEmit()
|
||||
end
|
||||
```
|
||||
|
||||
- **`H-102`가 이걸로 같이 닫힌다.** 원문은 "splice가 observer를 옮겨도
|
||||
클로저에 박힌 position 인덱스는 안 고쳐진다"였는데, **사용자 지적**대로
|
||||
*"이미 `slot._elemIndex`: realElem → index 를 관리중"*이므로
|
||||
`gatedRecompute`는 인덱스를 **캡처하지 않고 조회**한다. 그 역참조를
|
||||
**Dispatch 층위로 격상**해 `bk`가 소유한다(사용자: *"그것을 dispatch 로
|
||||
격상시키는게 더 나아보이는 지점"*) — splice가 배열을 당길 때 같이
|
||||
갱신되므로 `slot-plan.md`의 splice 요구 목록에 항목이 늘지 않는다.
|
||||
- `bk.invalidAfter = 0`으로 뭉개는 안은 **기각**(사용자: *"0 으로 두면,
|
||||
모든 부분에 있어 캐시가 무관해져요"*).
|
||||
- **참고 — `gatedRecompute`가 왜 게이트 앞에서 `invalidAfter`를 낮추나**:
|
||||
recompute가 스킵돼도 `getOffsetAt`의 lazy 접두합 캐시는 무효로 표시돼야
|
||||
다음 조회가 다시 계산한다(원문 주석 *"나중 emit도 같은 무효화가 필요"*).
|
||||
recompute와는 다른 축이다.
|
||||
|
||||
---
|
||||
|
||||
## 🅕 `Relate` 되참조 — `H-71` · `H-77`
|
||||
|
||||
### `H-71` — dedup 기록을 `SetWeak`으로 낮춘다 (확정)
|
||||
|
||||
갈래 (b). dedup은 순수 성능 최적화라(그 절이 스스로 *"correctness 문제는
|
||||
아님"*이라고 못박음) 엔트리가 조기 소실돼도 "dedup을 한 번 놓친다"까지가
|
||||
최대 손해다. `v`는 gchold가 이미 강하게 잡고 있고, `relate-plan.md`의
|
||||
**"다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`"** 규칙에도 그대로
|
||||
맞는다. 대상은 `RefLeafHandler.process`와
|
||||
`ObserverEffectLeafHandler.process`.
|
||||
|
||||
**같이 할 것**: `luau-test/done/07`에 되참조 케이스를 **음성 대조군**으로
|
||||
추가한다 — 지금 그 파일은 "GC-native 아키텍처의 핵심 전제를 검증했다"고
|
||||
여러 문서에 인용되는데 실제로는 안전한 모양만 봤다.
|
||||
|
||||
### `H-77` — `RunInit` 사례는 **기각**, `Relate` 규칙만 명문화 (확정)
|
||||
|
||||
**사용자 정정**: *"그런 경우 자체가 날 수 없음 … `initFn` 은 모듈을
|
||||
**인자로 받음**. 그건 클로저 캡쳐가 아님. 게다가 initFn 자체가 항상 하나야.
|
||||
그래야지 재진입이 방어되거든. 그 의미는, initFn 자체는 프로토에 불과하다는거임.
|
||||
게다가, 그 안에서 만들어낸 함수들이 module 을 레퍼런싱 해도, 리턴 값이
|
||||
quad자신에 flatten 되어 뮤테이션 되어 들어가는 구조 상, 그냥 module.fn
|
||||
수행하는거랑 다른게 없음."*
|
||||
|
||||
- 원문의 재현 코드(`q:RunInit(function() q.tagB = true end)`)는 **성립하는
|
||||
사용법이 아니다** — 인라인 클로저는 매번 새 identity라 멱등 가드 자체가
|
||||
무의미해진다. 발견이 자기 전제를 깨뜨린 경우.
|
||||
- `quad-base/src/init.luau`와 `runInitRelate` 설계는 **그대로 둔다**(최상위
|
||||
`Relate`, quad 인스턴스가 바깥 키, `initFn`이 내부 키). `:23` 주석도
|
||||
그대로.
|
||||
- **다만 `Relate`의 슬롯별 강/약 규칙 자체는 문서에 없으므로**, `H-71`이
|
||||
어차피 다시 쓰기로 한 "위험한 패턴" 절에 **세 슬롯 표**를 넣는다:
|
||||
|
||||
| 슬롯 | `SetStrong` | `SetWeak` |
|
||||
|---|---|---|
|
||||
| 바깥 키(`inst`) | weak | weak |
|
||||
| **내부 키(`key`)** | **강함** | **강함** |
|
||||
| 값(`value`) | 강함 | weak |
|
||||
|
||||
지금 그 절은 위험을 **값** 기준으로만 서술해 내부 키 슬롯이 아예
|
||||
등장하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 🅖🅗 Store 표면 · 확정 타입 vs 확정 관용구 — 진행 중
|
||||
|
||||
### ⛔ `H-73` — **무효**. `<<T>>`는 값 호출부에서 동작한다 (실측으로 뒤집힘)
|
||||
|
||||
**사용자 지적**으로 재실측했다 — Luau에는 **generic type instantiation**
|
||||
문법이 있고(`luau.org/types/generics/#generic-type-instantiation`), 값
|
||||
호출부에서도, **콜론 메소드에서도** 동작한다.
|
||||
|
||||
```lua
|
||||
--!strict
|
||||
type Source<T> = { Value: T, Revision: number }
|
||||
type Store = { GetDynamic: <T>(self: Store, name: string) -> Source<T>, [string]: any }
|
||||
local store = (nil :: any) :: Store
|
||||
|
||||
local ok: Source<number> = store:GetDynamic<<number>>("x") -- ✅ 진단 없음
|
||||
local bad: Source<string> = store:GetDynamic<<number>>("y") -- ✅ 정확히 걸림 (진짜 묶임)
|
||||
local none: Source<number> = store:GetDynamic("z") -- Source<unknown>
|
||||
```
|
||||
|
||||
원문과 검증 패스는 **인스턴스화를 생략한 호출만** 돌려보고
|
||||
*"Luau엔 호출부 명시 타입 인자 문법이 없다"*로 단정했다(`ident<number>(1)`이
|
||||
비교 연산자로 오파싱되는 건 맞지만 `<<>>`는 다른 문법이다). 확정 표기
|
||||
`store:GetDynamic<<T>>(name): Source<T>`는 **그대로 성립한다**.
|
||||
|
||||
**따라서**: (1) `question.md` 최우선 항목은 "타입을 묶을 수 있는가"가 아니라
|
||||
**원래의 예약 키 축**으로 되돌아온다. (2) `H-74`(eager `defaults` 경로가
|
||||
`__index`를 우회해 예약 키 방어를 무력화)는 **살아 있다**. (3)
|
||||
`quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다 — 그
|
||||
서술도 같이 넓혀야 한다.
|
||||
|
||||
### ⭐⭐ Store — `WrapStore` 폐기, **명시적 초기화**, 타입 함수 안 씀 (확정)
|
||||
|
||||
> **⚠️ 이 항목은 같은 날 두 번 바뀌었다.** 오전에
|
||||
> "`store.key`가 값이고 `store:Of(k)`가 프리미티브"라는 재설계를 넣었다가
|
||||
> **같은 날 철회**했다(감사 4패스가 `store:Names()`의 런타임 구현 불가를
|
||||
> 잡은 게 발단). 철회된 시도의 원문과 이유 다섯은
|
||||
> `archive/store-value-field-redesign-withdrawn.md`. 아래는 **최종**이다.
|
||||
|
||||
**발단**: 사용자가 `test.luau`로 직접 타입 실험을 돌려, `WrapStore`/
|
||||
`ProcessStoreType`로 결과 타입을 **합성**하는 접근이 `H-75`/`H-76`의 두
|
||||
한계에 걸린다는 걸 확인하고 대안을 탐색했다.
|
||||
|
||||
**최종 확정 형태** — 타입 함수를 **안 쓴다**:
|
||||
|
||||
```lua
|
||||
type Store<T> = T & {
|
||||
Of: <U>(self: any, name: string) -> Source<U>, -- 동적 키 전용(옛 GetDynamic)
|
||||
Names: (self: any) -> { string },
|
||||
}
|
||||
|
||||
local store = quad.Store<<{
|
||||
hp: Source<number>,
|
||||
name: Source<string>,
|
||||
}>>({
|
||||
hp = Source(100),
|
||||
name = Source(""),
|
||||
})
|
||||
|
||||
store.hp:Get() -- 평범한 레코드 필드 접근
|
||||
store.hp:Set(5)
|
||||
store.hp:Compute(function(s) ... end) -- 콜백 파라미터 추론 살아있음
|
||||
store:Of<<boolean>>("dynamicName") -- 동적 키
|
||||
```
|
||||
|
||||
- **타입 인자에 `Source<T>`를 직접 쓴다** — `store.key`가 평범한 레코드
|
||||
필드라 타입 함수가 하나도 안 들고, 읽기/쓰기 의미론이 `Source`의 기존
|
||||
계약 그대로다. **사용자 지적**: *"왜 우리가 `Source()` 를 직접 넣는걸
|
||||
거부하고, 이렇게 까지 하려 했죠? 단순히, `Store<{ a: Source }>` 로 두고,
|
||||
`store.a:Set,Get` 하지 말아야할 이유가 있을까요?"*
|
||||
- **⭐ 명시적 초기화** — 옛 lazy `__index`(없는 키를 그 자리에서 만들어
|
||||
저장) **폐기**. 그래서 `defaults`가 곧 선언 키 집합이 되고
|
||||
**`store:Names()`가 성립한다**(`H-79`). 부모가 값을 다 안 넘겨도 되게
|
||||
하려면 컴포넌트가 자기 `DEFAULTS`로 채워 넘긴다.
|
||||
- **`store.key = value`는 폐기 유지** — 2026-08-06 결정 그대로.
|
||||
오전 재설계에서 되살렸다가 같이 철회했다. **사용자 지적**이 그 논거를
|
||||
실측해줬다: *"`.Value = 1` 이 정적 쓰기처럼 보일텐데, 여기서 error
|
||||
터지는 trace 가 나오면 당황스럽기도 하고요, 약간 마법적 동작이기도
|
||||
해요."*
|
||||
- **동적 키는 `store:Of<<T>>(name)` 하나** — 옛 `GetDynamic`을 흡수했다
|
||||
(표면 둘을 유지할 이유가 없다).
|
||||
- **예약 키는 `Of`/`Names` 둘뿐**이고, 충돌하면 타입 검사가 **조용히
|
||||
꺼진다**(실측). `CheckReserved` 타입 함수가 **진단만** 띄운다 —
|
||||
`error()`가 아니라 `print(...)` + `return types.never`.
|
||||
- **⭐ 여기서 원칙 하나가 나왔다** — *"타입 함수는 타입이 못 잡는 문제를
|
||||
**에러로 띄우는** 정도 이상으로 가지 않는다"*(사용자). `index<>`/`keyof<>`도
|
||||
Luau가 predefine한 타입 함수라 같은 함정을 갖는다는 지적이 근거다.
|
||||
`base/typing-limits.md` §0으로 승격.
|
||||
- **`Compute`/`Apply`의 반환 타입은 여전히 명시 주석이 필요하다** — Store와
|
||||
무관한 §1의 문제 B이고 어느 모양에서든 조용히 unsound다(실측: 틀린
|
||||
주석이 `store.hp`든 독립 `Source`든 양쪽 다 안 걸림). 살아나는 건
|
||||
**콜백 파라미터** 쪽이고, 철회된 `Self` 제네릭을 거치면 그것마저 깨졌다.
|
||||
|
||||
실측 전량은 `audit/type-store-index-keyof/`(측정값은 유효, 결론만 철회됨).
|
||||
|
||||
### ⛔ `H-75` · `H-76` — **무효**. `WrapStore`/`ProcessStoreType`이 폐기된다
|
||||
|
||||
두 발견은 전부 *"정본 `Source<T>` 선언을 `type function` 안에서 어떻게
|
||||
합성하는가"*에 대한 것인데, 위 재설계로 **합성 자체를 안 한다**. 남는 타입
|
||||
함수는 `CheckReserved` 하나뿐이고 그건 `T`를 **검증만 하고 그대로 통과**
|
||||
시키므로 (a) 바깥 별칭을 반환할 일이 없고(`H-76`의 근거), (b) 메소드 self
|
||||
파라미터 불변성 문제도 없고(`H-76`), (c) ②쪼개기를 `type function` 안에서
|
||||
할 일도 없다(`H-75`). `typing-limits.md` §5의 *"✅ 검증 완료"* 서술과
|
||||
`store-plan.md`의 `WrapStore` 스케치는 **삭제 대상**이다.
|
||||
|
||||
### ⛔ `H-74` — **무효** (근거는 lazy `__index` 폐기)
|
||||
|
||||
원문은 *"eager `defaults` 경로가 `__index`를 통째로 우회하므로 '고정 메소드
|
||||
테이블을 먼저 확인'이라는 예약 키 방어가 성립하지 않는다"*였다. **명시적
|
||||
초기화가 확정되며 lazy `__index` 폴백 자체가 없어졌다** — 우회할 방어가
|
||||
아예 없으므로 발견이 소멸한다. 지금 예약 키(`Of`/`Names`) 방어는
|
||||
`__index`가 아니라 **타입 레벨 `CheckReserved`**가 한다.
|
||||
**[2026-08-25 문구 정정]** 한때 여기 근거로 "그림자 백킹 테이블"을 들었는데,
|
||||
그건 같은 날 철회된 재설계의 산물이다
|
||||
(`archive/store-value-field-redesign-withdrawn.md`). 판정(무효)은 안 바뀐다.
|
||||
|
||||
### `H-83` — `table.clone(defaults or {})` (확정)
|
||||
|
||||
한 글자. **명시적 초기화와 모순되지 않는다** — 선언된 키가 하나도 없는
|
||||
`Store<<{}>>()`는 여전히 유효하고(줄 키가 없으니 넘길 것도 없다), 그때
|
||||
`table.clone(nil)`이 `table expected, got nil`로 죽는 걸 막는다.
|
||||
"선언한 키는 값을 준다"와 "키가 0개면 인자도 없다"는 서로 다른 층위다.
|
||||
|
||||
### `H-79` — `store:Names()` 신설 (확정)
|
||||
|
||||
갈래 (c). `Tag:Names()`/`attr:NameMap()`과 같은 계열이고, 동적 키 표면
|
||||
(`store:Of<<T>>(name)`)과 **같은 자리**에 둔다(사용자: *"이것도 GetDynamic
|
||||
처럼 둡니다"* — 그 `GetDynamic`이 지금의 `Of`다). 그룹 `Attribute`가
|
||||
요구하던 열거가 이걸로 닫힌다.
|
||||
|
||||
**시그니처는 `Names: (self: any) -> { string }`이다.** **[2026-08-25 문구
|
||||
정정]** 한때 여기 `{ keyof<index<Self,"__store">> }`라 적었는데 그건 같은 날
|
||||
철회된 재설계의 타입이다. **런타임 성립 근거도 바뀌었다** — 팬텀 필드가
|
||||
아니라 **명시적 초기화**다: `defaults`가 곧 선언 키 집합이므로 그림자
|
||||
테이블의 키를 그대로 준다. (감사 4패스가 잡은 것 — 철회 전 모양에선
|
||||
Luau가 타입 인자를 런타임에 지워 `Names()`가 **구현 불가능**했다.)
|
||||
|
||||
### `H-95` — 두 콜백 시그니처를 실측 통과 형태로 고친다 (확정)
|
||||
|
||||
갈래 (a).
|
||||
|
||||
- **`Effect`의 `fn`** — 가변 반환 팩 `-> ...(() -> ())`. 단일 옵셔널
|
||||
반환에 맞고, `function(self) end`와 cleanup 반환이 둘 다 통과한다.
|
||||
- **`:List`의 `updateFn`** — 함수 타입의 유니온 `Fn2 | Fn1 | Fn0`. 네 모양
|
||||
(2개/1개/nil/없음)이 전부 통과하고 **엉뚱한 타입은 여전히 잡힌다**(음성
|
||||
대조군 확인).
|
||||
|
||||
"항상 명시적으로 반환하라"를 계약으로 두는 안은 기각 — `useEffect` 동형이라는
|
||||
확정 서술과 인체공학이 어긋나고, `--!nocheck` 코드에선 조용히 지나간다.
|
||||
|
||||
### `H-94` — `__call`이 아니라 **지정된 필드**로 받는다 (확정, 필드 이름 미정)
|
||||
|
||||
**사용자 판단**: *"그런데 이러면, Blocker 자체도 처음에 슈거로 두지 못했었던
|
||||
이유가 해결됩니다. 아에 어플리케이티브 펑터로써, `__call` 이 아닌 다른
|
||||
필드로 들어가는게 맞아보여요. 외부에서 직접 `()` 호출하는건 의미 없게
|
||||
둬야해요."*
|
||||
|
||||
- 원문의 갈래 (a)(함수와 콜러블의 유니온)는 **기각** — 유니온도 캐스트도
|
||||
필요 없어진다.
|
||||
- `:Apply`는 "함수" 또는 "그 필드를 가진 객체"를 받고, `Debounce{...}` /
|
||||
`Throttle{...}` / `Blocker`가 전부 같은 계약을 만족한다.
|
||||
- **실측 뒷받침**: `test.luau`가 `__call` 경로가 죽었음을 확인했다 — 타입
|
||||
레벨 `__call`은 `self`를 못 받고(`mock()`이 인자만 남는다),
|
||||
`typeof(getter2<<T>>)`로 `T`를 넘기는 것도 실패한다.
|
||||
- **남은 것**: 그 필드의 이름과 정확한 시그니처(구현 시 정하면 됨).
|
||||
|
||||
---
|
||||
|
||||
## 🅒 예외 안전성 — `H-88` · `H-89` · `H-87` · `H-103`
|
||||
|
||||
### 네 자리 전부 **UB로 명문화**, `pcall`로 감싸지 않는다 (확정)
|
||||
|
||||
2026-08-21에 `slot-plan.md`의 `materializeSlotTree`에 대해 내린 판단
|
||||
(*"마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 … 아직 실제로
|
||||
밟은 적 없는 경로다 — `conventions.md`의 "드문 오용이나 가상의 미래
|
||||
요구까지" 절이 세운 원칙 그대로. 실제로 물리면 그때 넣는다"*)를 **전
|
||||
자리로 확장**한다. 🅔의 `Rerun`에 대해 같은 원칙을 이미 확정했다
|
||||
(*"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반
|
||||
동작"*).
|
||||
|
||||
- 전파 루프(`H-88`, 구독자당 hot path), 게이트 flush + `Blocker:Off()`
|
||||
순회(`H-89`), 배치 게이팅(`H-87`), Dispatch 체인 슬롯(`H-103`, 자리당
|
||||
hot path) — 넷 다 감싸지 않는다.
|
||||
- **문서에 없던 것을 적는 게 이 항목의 산출물**이다 — 지금
|
||||
`state-epoch-plan.md`/`gate-plan.md`/`blocker-plan.md` 셋엔 `error`라는
|
||||
단어가 **0건**이다. yield 금지와 같은 톤으로 "예외가 나면 그 파동/그
|
||||
자리의 부기는 복구되지 않는다"를 명시한다.
|
||||
- **`H-87`의 심각도 정정**: 🔴 → 🟡. 원문의 *"코퍼스가 error 경로를 어느
|
||||
쪽도 다루지 않는다"*는 거짓이었다(위 선례가 있다). 새 결함 보고가 아니라
|
||||
**기존 판단의 적용 범위** 문제였다.
|
||||
- **`H-88`의 부정 주장 정정**: *"예외 안전성 계약이 코퍼스에 한 줄도
|
||||
없다"*는 과했다 — 위 선례가 유일한 선례이고, 그 선례가 고른 방향이 바로
|
||||
지금 채택한 쪽이다.
|
||||
|
||||
### `H-89`의 나머지 — `:Sync(batch)`는 **전파 전** (확정, 물을 것 없음)
|
||||
|
||||
`gate-plan.md` 4번이 *"실제로 전파할 때"*라고만 하고 앞/뒤를 안 정했는데,
|
||||
참조 구현의 `GateNode:_flush`가 이미 **빈 배치 얼리리턴 → 스왑 → `Sync` →
|
||||
전파** 순서이고 그게 4·8번과 일치한다. 그 순서를 문서에 명시한다.
|
||||
|
||||
---
|
||||
|
||||
## 🅙 error 계약 — `H-104` · `H-105`
|
||||
|
||||
**⚠️ 두 항목의 수치를 먼저 고칠 것** — 원문의 "42곳"은 산문 언급까지 센
|
||||
것이다. 실제 quad 자신의 error 코드 자리는 **약 29곳**, 한국어는 17이
|
||||
아니라 **약 23**, 영어는 6(동적 경로 가드 4형제 + 모듈 초기화 + attribute
|
||||
이름 충돌)이 맞다.
|
||||
|
||||
### `H-104` — 사용자 입력 검증은 `level 2`, 내부 불변식은 `level 1` (확정)
|
||||
|
||||
```lua
|
||||
error("Effect: dep #3 is not a State/Source/Ref", 2) -- 호출부를 가리킴
|
||||
error("Dispatch: bookkeeping is broken (bk.N=" .. n .. ")", 1) -- 그 자리를 가리킴
|
||||
```
|
||||
|
||||
*즉시 error*가 quad의 주 방어선인데 지금은 전부 quad 내부 줄을 가리켜
|
||||
사용자가 자기 코드 어디서 틀렸는지를 못 본다. 이분은 명확하다 — 사용자가
|
||||
잘못 넘긴 것(deps 타입, 이중 바인드, 예약 키…)은 호출부를, quad 자신의
|
||||
부기가 깨진 것은 그 자리를 가리킨다. **29곳을 쓰기 전에 정해야 한 번에
|
||||
맞는다.**
|
||||
|
||||
### `H-105` — error 메시지는 **영어로 통일** (확정)
|
||||
|
||||
**사용자 확정**. `conventions.md`의 *"사용자가 보게 될 것은 한국어"*는
|
||||
**이 프로젝트의 대화·문서** 규칙이고 quad 라이브러리 사용자에게까지
|
||||
적용된다고 정해진 적이 없다 — 이번에 정한다. 이미 영어인 6곳(동적 경로
|
||||
가드 4형제 등)이 핵심 경로라는 점도 같은 방향이다. 기존 한국어 메시지
|
||||
약 23곳은 구현 시 영어로 쓴다(문서의 예시 메시지도 같이).
|
||||
|
||||
---
|
||||
|
||||
## 그 밖의 개별 확정
|
||||
|
||||
### `H-68` — `Source:Set(v)`는 **동일값이어도 항상 갱신하고 emit** (확정)
|
||||
|
||||
판정이 값 동등성이 아니라 리비전이라는 `Epoch` 모델과 일관된다. 더
|
||||
중요한 건 **테이블 값**이다 — mutate한 뒤 같은 테이블을 다시 `Set`하는
|
||||
것이 `==`로 dedup되면 변경이 조용히 증발한다. dedup은 이미 하류
|
||||
(`EpochMap`, 게이트)에서 한다.
|
||||
|
||||
---
|
||||
|
||||
## 🅚 ⭐ "중간 State GC" 미해결이 **닫혔다** — `H-93` · `H-98`
|
||||
|
||||
**사용자 확정**: *"단순히, 각 state 들이 상위 State|Source 를 홀드하는
|
||||
`_hold` 를 놓는것으로 바로 해결된다. 당연히 후행은 선행 요소들이
|
||||
있어야하기 때문. 선행 state 가 후행 state 를 얻으려 하는건 UB이므로 가능한
|
||||
일이다.(이건 릴레이션도 아니라 gc되긴 하지만.)"*
|
||||
|
||||
불변식이 이걸로 명문화된다:
|
||||
|
||||
| 방향 | 강도 |
|
||||
|---|---|
|
||||
| 하류 State → 상류 State/Source (`_hold`) | **강함** |
|
||||
| 상류 → 하류 (구독자 집합) | weak-키 (🅑-1에서 확정) |
|
||||
|
||||
- 체인은 **말단(Observer/Effect/leaf)이 살아 있는 동안** 통째로 살아 있고,
|
||||
말단이 죽으면 통째로 수거된다. `Relate`가 아니므로 순환도 안 만든다.
|
||||
- **`H-93` 소멸** — 루트 `Epoch`(Source)가 하류보다 먼저 수거될 수 없으므로
|
||||
*"`:Refresh()`가 `false`를 줘 낡은 값을 최신이라고 확신한다"*는 경로가
|
||||
생기지 않는다.
|
||||
- **`H-98` 소멸** — `source-state-plan.md`의 두 문장(*"참조를 아무 데도 안
|
||||
담아도 정상"*, *"GC되지 않고 영원히 계속 실행됨"*)이 서로 모순 없이
|
||||
성립한다. `:Subscribe()`가 전역 강 레지스트리에 핸들을 넣고, 핸들이
|
||||
`_hold`로 상류를 잡는다.
|
||||
- **🅑-2(eager 등록)의 캐비엇도 해소** — 그 절에 적어둔 *"eager이므로 중간
|
||||
State GC 미해결이 더 절실해진다"*는 이제 해당 없음.
|
||||
- **`question.md` 최우선 절의 첫 항목이 닫힌다**(실측 스파이크는 여전히
|
||||
`luau-test`에 하나 두는 게 좋다 — 그 파일은 "상류 strong / 하류 weak
|
||||
불변식"을 음성 대조군까지 확인하는 형태로).
|
||||
|
||||
---
|
||||
|
||||
## 🅛 저장소·로드맵 — `H-78` · `H-97` · `H-80` · `H-81` · `H-99`
|
||||
|
||||
### ⭐ `H-78` — 근본 원인은 **`luau` CLI가 심볼릭 링크를 못 탄다**는 것 (확정)
|
||||
|
||||
원문은 *"안 돈다"*까지만 짚었는데, 이번에 최소 재현으로 원인을 특정했다:
|
||||
|
||||
```
|
||||
require("./real") → OK (진짜 디렉토리 + init.luau)
|
||||
require("./linked") → 실패 (같은 디렉토리를 가리키는 심볼릭 링크)
|
||||
could not resolve child component "linked"
|
||||
진짜 디렉토리 + init.luau 파일 심볼릭 → 실패 (no module present at resolved path)
|
||||
파일 심볼릭 직접 require → 실패
|
||||
```
|
||||
|
||||
pesde의 워크스페이스 링크가 전부 디렉토리 심볼릭
|
||||
(`.pesde/…/quad_types/src -> quad-types/src`)이라, `quad-base/src/init.luau`의
|
||||
`require("./roblox_packages/quad_types")`가 그 링크에 닿는 순간 죽는다.
|
||||
`luau` 0.735(현재 최신)에 관련 옵션도 없다.
|
||||
|
||||
**거짓 클린도 재현했다** — 같은 경로를 `luau-analyze`에 걸면 진단이 lint
|
||||
둘뿐이고 `local x: number = m.ok`(실제 `boolean`)를 **안 잡는다**. 모듈을
|
||||
`any`로 떨어뜨리고 조용히 통과한다.
|
||||
|
||||
**해법(확정)**: **개발용 리링크 스크립트** — `.pesde` 아래 디렉토리
|
||||
심볼릭을 실제 복사로 교체하고, 테스트/분석 스크립트가 그걸 먼저 돌린다.
|
||||
pesde 설정도 게시 경로도 안 건드린다. 워크스페이스 멤버를 고치면 다시
|
||||
돌려야 하므로 테스트 스크립트가 매번 선행 실행한다.
|
||||
|
||||
**같이 고칠 것**: `ROADMAP.md:198`의 *"전부 PASS"* — 날짜도 전제조건도 없다.
|
||||
지금 실제로는 `smoke.mock`만 PASS이고 `smoke.init`/`smoke.plugin`은 리링크
|
||||
없이는 안 돈다. 스파이크 `23`도 같은 이유로 의도한 음성 대조군이 안 뜬다
|
||||
(`luau-test/STATUS.md`에 반영).
|
||||
|
||||
### `H-97` — mock에 생명주기 4종을 M2에서 최소 구현 (확정)
|
||||
|
||||
`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`를 mock 백엔드용으로
|
||||
가장 단순하게 구현한다(커밋된 mock에 signal/Connection이 이미 있다).
|
||||
M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가는 것이
|
||||
이번 라운드가 찾은 종류의 결함을 그대로 낳는다.
|
||||
|
||||
### `H-80` · `H-81` · `H-99` — 체크박스·소스 트리 갱신 (결정 불필요)
|
||||
|
||||
- `H-80`: `ROADMAP.md` M2의 `Quad` 추가 목록이 `Source`/`State`/`Store`뿐 —
|
||||
`Effect`/`is*` 전량/`bindLifetime` 4종/`Blocker`/`Relate`를 더한다.
|
||||
`State`는 런타임 생성자가 없다는 것도 명시(파생으로만 생긴다).
|
||||
- `H-81`: `isModifier` 런타임 가드 체크박스가 M7에만 있다 — 적용 지점이
|
||||
전부 M2 파일(`Source:Set`/Store 생성/`:Compute` 캐싱)이므로 M2
|
||||
체크리스트에 넣고, `modifier-plan.md` 7번과 `source-state-plan.md`의
|
||||
적용 지점 목록을 일치시킨다(독립 `Source(someModifier)`가 한쪽에만 있다).
|
||||
- `H-99`: `architecture.md` 소스 트리에 `Observer.luau` 자리와
|
||||
`:Subscribe()` 전역 레지스트리의 소유 모듈을 명시(`EpochMap.luau`가
|
||||
*"`State.luau`에 묻지 않고 별도 모듈"*로 명시된 것과 대비를 맞춘다).
|
||||
|
||||
---
|
||||
|
||||
## 🅜 문서 정합 — 결정 불필요 (`H-66` · `H-67` · `H-82` · `H-91`)
|
||||
|
||||
- `H-66`: `typing-limits.md` 영향 범위 표의 `state:Observer(fn)` 행을
|
||||
`EffectHandle 반환` → **`Observer` 반환**.
|
||||
- `H-67`: `gate-plan.md` 4번이 `OffWithoutEmit` 비우기의 근거로 드는 용례가
|
||||
gated state를 안 쓴다 — gated state를 쓰는 용례로 교체.
|
||||
- `H-82`: `:With`를 실노드로 확정한 근거 2번("`w`의 캐시를 c1/c2가 공유")이
|
||||
pass-through 노드엔 성립하지 않는다 — **"엣지 수와 에포크 부기"**로 교체.
|
||||
결론은 안 바뀐다(근거 1·3이 유효).
|
||||
- `H-91`: `state-epoch-plan.md` §8의 *"항상 state 는 get 이 최신"*을
|
||||
**"선언한 의존성에 대해서는 항상 최신"**으로 좁히고, `Animate`가 미선언
|
||||
읽기를 **설계로** 쓰는 의도적 예외임을 가리킨다.
|
||||
|
||||
---
|
||||
|
||||
## 나머지 개별 — 구현 시 정하면 되는 것
|
||||
|
||||
- **`H-69`** 통과 모드 게이트가 emit마다 weak 테이블을 하나씩 할당 —
|
||||
정확성 문제 아님. 전파 루프를 코드로 쓸 때(🅑) 같은 자리에서 정한다.
|
||||
- **`H-92`** 구독자 스냅샷이 emit마다·노드마다 배열 하나를 할당 —
|
||||
`state-epoch-plan.md` §2가 테이블 리비전을 기각한 GC 근거, §7 비용 절과
|
||||
어긋난다. 정확성 문제 아님, **서술만 실제와 맞춘다**.
|
||||
- **`H-84`** `:With(...)`/`state:Block(b)`/`Source:Emit()`이 `ROADMAP.md` M2
|
||||
체크리스트에 개별 항목으로 없다 — 추가.
|
||||
- **`H-96`** trailing deps가 붙으면 콜백 파라미터 무주석 추론이 깨진다 —
|
||||
결정이 아니라 **`typing-limits.md` §7에 경계 한 줄**(*"단 trailing deps가
|
||||
붙으면 dep 파라미터엔 주석이 필요하다"*).
|
||||
- **`H-100`** `{[Source<T>]: true}`가 `{[Epoch]: true}` 자리에 안 들어간다
|
||||
(인덱서 키 불변) — `EpochSet` 리터럴에 캐스트가 필요하다는 것만 적는다.
|
||||
- **`H-106`** `getOffsetAt`의 `contribution(bk, i)`에 `nil` 가드가 없어
|
||||
`C-6`이 승격한 진단이 우회된다 — 한 줄 가드 추가.
|
||||
|
||||
---
|
||||
|
||||
## 반영 후 검증 — `/code-review high` 1회 + 감사 다패스 (2026-08-25)
|
||||
|
||||
`base/` 반영이 끝난 뒤 **각도를 바꿔가며** 검증을 돌렸다. 사용자 지침:
|
||||
*"여러 각으로 넣으면 다양한 문제들이 해결되거든"*, *"수렴 되기 까지 여러
|
||||
각으로 한번씩 넣어봐줘. 그게 stale 부채를 막는 길이라서."*
|
||||
|
||||
**각 패스가 낸 발견이 서로 거의 안 겹쳤다** — 이게 이 절을 남기는 이유다.
|
||||
다음에 큰 반영을 할 때도 한 각도로 여러 번이 아니라 **각도를 바꿔가며**
|
||||
돌릴 것.
|
||||
|
||||
| 패스 | 각도 | 발견 | 대표적으로 잡은 것 |
|
||||
|---|---|---|---|
|
||||
| `/code-review high` | diff 자체의 결함(사용자만 호출 가능) | 12 | 캐시 카운터 시드 충돌(갱신이 **감소**라 첫 무효화에서 두 값이 같아짐), `_cleanup == nil`로 "설치됨"을 판정해 `H-58` 부활, 되감기 `+1`이 바뀐 길이를 `sum`에서 누락, `bk.indexOf(len)`가 길이 값을 키로 써서 두 자리가 접힘, `relink.sh` 재실행 no-op |
|
||||
| 감사 1차 | diff 정합성 — 폐기된 이름·인덱스 레이어·색인 행 | 12 | **`ROADMAP.md` M2 전파 루프 체크리스트가 `H-56`이 뒤집은 것을 그대로 들고 있었다**(구현자가 보는 자리), `CLAUDE.md`/`project-context.md`가 닫힌 게이트를 열린 것으로 서술(매 세션 로드됨) |
|
||||
| 감사 2차 | **새 의사코드를 손으로 실행** | 6 | `lifecycle-pattern.md`의 `bindLifetime`이 폐기된 `_observers` cascade를 **정본 의사코드로** 들고 있었다, `ref-plan.md`가 같은 파일 안에서 `:Uncallback`과 `:WeakCallback` 두 계약을 동시 주장, gate flush 스니펫이 4단계 중 2단계만 |
|
||||
| 감사 3차 | **소비자 관점**(M2 표면을 쓰는 쪽) | 6 | **Store 재설계가 소비자 예시 12개 파일에 전혀 안 퍼짐** — 특히 `component-composition-plan.md` §3이 배너 없이 옛 모델을 "확정"으로 서술 |
|
||||
| 감사 4차 | **구현자 관점**(M2를 위에서부터 짜 내려간다면) | 4 | **`store:Names()`가 런타임에 구현 불가능**(Luau가 타입 인자를 지움) — 이게 오전 Store 재설계를 **철회**하게 만든 발단이다. 그 외 `Effect`↔`Blocker` 체크박스 순서, `Observer.luau` 파일명 누락, `GateNode` 조립 의사코드 부재 |
|
||||
| 감사 5차 | **철회가 완전한가** | 13 | **`architecture.md`를 철회에서 통째로 놓쳤다**(두 자리, 그중 하나는 같은 파일 안에서 모순), `source-state-plan.md`가 한 파일 안에서 대입 부활/폐기를 동시 주장, `GetDynamic`→`Of` 개명이 5곳 누락(**`ROADMAP` 체크박스 제목 포함**), `pre-implementation-audit.md`의 **"이미 고침" 목록**이 철회된 고침을 완료로 기록 |
|
||||
| 감사 6차 | **최종형 수렴 + `H-74`/`H-79`/`H-83` 재판정** | 3 | `effect-plan.md`가 **같은 문서 안에서** 폐기된 `_installing`을 "확정"으로 서술, `README.md`의 `effect-plan` 색인 행이 하루 전 상태, followup의 `H-74`/`H-79` 판정 문구가 철회된 모델을 근거로 인용. **`base/`는 전부 최종형으로 일관** — 잔재가 인덱스·기록 레이어에만 남았다 |
|
||||
|
||||
**교훈 둘** — (1) **각도를 바꾸면 계속 나온다.** 여섯 패스가 12/12/6/6/4/13/3건을
|
||||
냈고 겹치는 게 거의 없었다. 한 각도로 여러 번 돌리는 것보다 각도를 바꾸는
|
||||
쪽이 압도적으로 낫다. (2) **하루 안에 두 번 바뀐 것은 인덱스·기록 레이어에
|
||||
잔재가 남는다.** 6차에서 `base/`는 이미 깨끗했는데 `README.md` 색인 행과
|
||||
followup 판정 문구가 중간형을 들고 있었다 — "결정을 적는 곳"과 "결정을
|
||||
가리키는 곳"이 다르기 때문이다.
|
||||
|
||||
**교훈 셋** — 같은 결정을 **여러 문서가 각기 다른 역할로** 들고 있으면
|
||||
한 곳만 고치기 쉽다. `H-56`(전파 루프)은 `lifecycle-pattern.md`(산문) /
|
||||
`source-state-plan.md`(의사코드) / `ROADMAP.md`(구현 체크리스트) 셋에
|
||||
흩어져 있었고, 첫 반영은 앞의 둘만 고쳤다 — **구현자가 실제로 보는 건
|
||||
세 번째**다.
|
||||
|
|
@ -258,7 +258,7 @@ Observer 값을 키로 `BindData`에 gcconn을 복사하므로, 집합의 원소
|
|||
`H-23`이 스냅샷을 확정했지만 그 스냅샷 안에서 **무엇을 호출하는지**는
|
||||
여전히 산문뿐이다.
|
||||
|
||||
**이건 `question.md`의 "중간 State GC" 미해결과 별개다** — 그쪽은 자식
|
||||
**이건 `question.md` 최우선 절의 중간 State GC 미해결과 별개다** — 그쪽은 자식
|
||||
노드가 *살아남는가*, 이쪽은 살아있어도 *호출되는가*.
|
||||
|
||||
**필요한 것**: 전파 루프의 실제 의사코드 — 구독자가 State 노드면
|
||||
|
|
@ -909,7 +909,7 @@ store 테이블이 아니라 **내부 백킹 테이블**에 넣고 `__index`가
|
|||
## 🔴 `H-75` — 평평한 `WrapStore`면 `store.key:Compute(무주석 콜백)`이 깨진다 (실측)
|
||||
|
||||
**어디**: `base/store-plan.md`의 `WrapStore`/`ProcessStoreType` 스케치,
|
||||
`luau-test/done/16-type-store-key-typefunction.luau`,
|
||||
`luau-test/rewrite-required/16-type-store-key-typefunction.luau`,
|
||||
`base/typing-limits.md` §5(*"✅ 검증 완료"*)와 §1의 ②쪼개기.
|
||||
|
||||
**무엇이 어긋나나**: 확정된 `WrapStore`는 `Get`/`Set`만 있는 **평평한**
|
||||
|
|
@ -2094,8 +2094,8 @@ GC 근거를 그에 맞게 완화한다(그러면 테이블 리비전 기각 근
|
|||
## 🟢 `H-93` — 중간 State GC 미해결은 "전파 끊김"만이 아니라 "최신이라고 오판"으로도 나타난다
|
||||
|
||||
**어디**: `base/state-epoch-plan.md` §3의 *"키는 weak다. `epoch`가 죽으면
|
||||
항목이 사라진다"*, `base/source-state-plan.md`의 "미해결 — 중간 State가
|
||||
살아남는가" 절.
|
||||
항목이 사라진다"*, `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`로
|
||||
살아남는다" 절.
|
||||
|
||||
**미리 밝힘**: 이건 `question.md` 최우선 항목의 **파생**이라 이 라운드의
|
||||
검사 대상이 아니다(1차 패스가 명시적으로 뺐다). 그래도 적는 이유는 그
|
||||
|
|
@ -2436,7 +2436,7 @@ Connection까지 이미 구현해뒀다. 즉 gcconn 트릭을 흉내낼 재료
|
|||
|
||||
**어디**: `base/source-state-plan.md`의
|
||||
"Observer의 `:Subscribe()`/`:Unsubscribe()`" 절, 같은 문서의
|
||||
"미해결 — 중간 State가 살아남는가" 절.
|
||||
"해소됨 — 중간 State는 `_hold`로 살아남는다" 절.
|
||||
|
||||
**무엇이 어긋나나**: `:Subscribe()` 절은 두 가지를 **공개 계약으로**
|
||||
못박았다 — *"`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2
|
|||
오버로드 함수 타입으로 정확히 추론하려는 시도는 포기하고…"
|
||||
- `base/store-plan.md:128` — "`store "key"` 문자열 커링은 동적 키가 필요할
|
||||
때 쓰는 미타입(`Source<any>`) 폴백으로 격하."
|
||||
- `base/store-plan.md` "남는 것" 문단 — "`myStore "key"`(문자열 커링)는 …
|
||||
- `base/store-plan.md`의 값 설정 문법 절 — "`myStore "key"`(문자열 커링)는 …
|
||||
그대로 유지"
|
||||
- `base/bind-system-plan.md:157` — "`store "key"`(문자열 커링, 동적 키
|
||||
폴백)는 이벤트와 달리 …"
|
||||
|
|
|
|||
|
|
@ -658,7 +658,7 @@ function Dispatch.process(inst, k, v, index) -- ← 오케스트레이터
|
|||
|---|---|
|
||||
| `ML-5` | 사용자가 회신에서 직접 짚음(*"새로운 커밋에서 이것이 달라짐"*). 파일마다 `Relate()`+`INITED` 센티널을 두는 설계가 **`RunInit` 하나로 통합**됐다 — **함수 자기 자신을 릴레이션 키로** 쓰므로 센티널이 불필요. `quad-base/src/init.luau`에 실제 구현 + `test/smoke.init.luau`로 검증까지 끝. **문항 `ML-5`는 폐기.** |
|
||||
| `ML-8` | 위 변경에 맞춰 `RunInit`(함수 identity) vs `_initializedBy`(문자열 마커) 구분으로 재서술됨. **결론은 그대로**(둘은 다른 층위, 재사용 안 함)이라 문항 자체는 여전히 유효. |
|
||||
| `ST-3` | "M0에서 실측 확인할 것"이 **실측 완료**됨 — `luau-test/done/21-type-store-undeclared-key-rejected.luau`가 미선언 키 접근 2건이 정확히 `TypeError`로 거부됨을 확인. **⚠️ 열린 항목이 아니게 됨.** |
|
||||
| `ST-3` | "M0에서 실측 확인할 것"이 **실측 완료**됨 — `luau-test/rewrite-required/21-type-store-undeclared-key-rejected.luau`가 미선언 키 접근 2건이 정확히 `TypeError`로 거부됨을 확인. **⚠️ 열린 항목이 아니게 됨.** |
|
||||
| `D-10` | 위 B-2 — 스파이크 `01`이 이미 통과. |
|
||||
| `A-2` | 패키징이 **wally → pesde**로 바뀌었고 `mise`/`selene`가 도입됨. 문항의 "wally 패키지" 표현이 stale(모놀리식 모노레포라는 **결론 자체는 유지**). |
|
||||
| 전반 | `M0`/`M1`이 **완료**됐다 — 문항지 여기저기의 "M0 착수 전/M0에서 확인" 표현은 이제 "M2 착수 전"으로 읽어야 맞는 것들이 섞여 있다. |
|
||||
|
|
|
|||
|
|
@ -12,35 +12,30 @@
|
|||
|
||||
---
|
||||
|
||||
## ⭐ 최우선 — **M2(반응형 코어) 착수 전에 답이 필요한 것 둘** (2026-08-24 갱신)
|
||||
## ⭐ 최우선 — **비어 있음** (2026-08-25 갱신)
|
||||
|
||||
> **[2026-08-24] M2/M3 마일스톤 경계 문제는 닫혔습니다.** 그건 설계 결정이
|
||||
> 아니라 순서 문제라 이 절이 아니라 별도 항목으로 뒀었는데, 사용자가
|
||||
> **(a) 순서 교체**(반응형이 M2, 디스패치가 M3)를 선택해 같은 날 전량
|
||||
> 반영됐습니다 — 결정과 근거는 `archive/question-resolved.md`의
|
||||
> "마일스톤 경계" 절, 새 마일스톤 구성은 `ROADMAP.md`의 M2 배너.
|
||||
> **[2026-08-25] M2(반응형 코어) 착수를 막는 항목이 하나도 없습니다.**
|
||||
> 2026-08-24에 이 절로 올라왔던 둘이 7라운드 손 트레이싱 후속에서 같이
|
||||
> 닫혔습니다 — 결정과 근거는
|
||||
> `qa-request/pre-implementation-handtrace-round7-followup.md`가 소스.
|
||||
>
|
||||
> **⚠️ 다만 그 교체의 부작용으로 아래 둘이 "한 마일스톤 뒤"에서
|
||||
> "바로 다음"으로 올라왔습니다.** 둘 다 반응형(옛 M3, 지금 M2)의 게이트라
|
||||
> 예전엔 급하지 않았는데, 반응형이 먼저 지어지게 되면서 **지금 답이
|
||||
> 필요합니다.** 순수한 *설계* 결정 대기는 여전히 0건입니다 — 하나는 실측
|
||||
> 미완이고 하나는 표면 위치 선택입니다.
|
||||
|
||||
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
|
||||
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
|
||||
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
|
||||
지목했고, **명문화 여부 결정 + `luau-test` 실측이 M2 착수 전에 필요** —
|
||||
`base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절.
|
||||
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `store:GetDynamic`을
|
||||
콜론 메소드로 둘지, 탑레벨 함수로 둘지** — 콜론 메소드로 두면 Store의
|
||||
lazy `__index`(없는 키를 인덱싱하면 그 자리에서 `Source`를 만들어 저장)와
|
||||
부딪혀서, `__index`가 고정 메소드 테이블을 먼저 확인해야 하고 그 결과
|
||||
**`GetDynamic`이 모든 Store의 예약 키 이름**이 된다(그 이름의 Source는
|
||||
dot-access로 못 만듦). Store 키는 사용자 도메인 데이터 이름이라 충돌
|
||||
확률이 `Modifier`의 예약 이름들보다 높다. 대안은 탑레벨
|
||||
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
|
||||
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M2/M4 착수 전
|
||||
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
|
||||
> - **중간 State GC 미검증** → **닫힘.** 사용자 확정: *"단순히, 각 state
|
||||
> 들이 상위 State|Source 를 홀드하는 `_hold` 를 놓는것으로 바로
|
||||
> 해결된다. 당연히 후행은 선행 요소들이 있어야하기 때문."* 불변식은
|
||||
> **하류 → 상류 강함(`_hold`) / 상류 → 하류 weak(구독자 집합)**이고
|
||||
> `base/source-state-plan.md`에 명문화됐습니다. 실측 스파이크는 여전히
|
||||
> `luau-test`에 하나 두는 게 좋지만 **착수 게이트는 아닙니다**.
|
||||
> - **동적 키 표면(옛 `GetDynamic`) 위치** → **닫힘.** `store:Of<<T>>(name)`
|
||||
> 하나로 합쳤고(콜론 메소드 유지),
|
||||
> 예약 키 충돌은 `CheckReserved` 타입 함수가 잡습니다. 애초에 이 항목이
|
||||
> 섰던 근거(lazy `__index`와의 충돌)는 **lazy 생성 자체가 폐기**되며
|
||||
> 소멸했습니다(명시적 초기화). 이름도 `store:Of<<T>>(name)` 하나로
|
||||
> 합쳐졌습니다 — `base/store-plan.md`의 "타입 추론 문제" 절.
|
||||
>
|
||||
> **[2026-08-24] M2/M3 마일스톤 경계 문제도 닫혀 있습니다** — 사용자가
|
||||
> **(a) 순서 교체**(반응형이 M2, 디스패치가 M3)를 선택해 전량 반영됐습니다.
|
||||
> 결정과 근거는 `archive/question-resolved.md`의 "마일스톤 경계" 절, 새
|
||||
> 마일스톤 구성은 `ROADMAP.md`의 M2 배너.
|
||||
|
||||
> **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의
|
||||
> lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
1. **초기화** — `RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`)
|
||||
2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 생성자 `New` + 클래스별 정적 필드(**[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 전량 코드 생성)(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`)
|
||||
3. **속성 채우기** — `[Attribute "Name"]`, ~~`[Tag ""] = true`~~ **[2026-08-13 정정] 구모델(폐기, `archive/tag-hash-key-model-reversed.md`) — 실제로는 `Tag(...)` array-part 값 객체** 특수 바인드 키 (`architecture.md`)
|
||||
4. **반응형 기초** — `Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 바뀜)
|
||||
4. **반응형 기초** — `Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 확정. **[2026-08-25]** 같은 모델이 유지되고, **생성이 명시적 초기화로** 바뀌었다 — 타입 인자에 `Source<T>`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접. **초심자 트랙이 짚을 것**: 부모가 값을 다 안 넘겨도 되게 하려면 컴포넌트가 자기 `DEFAULTS`로 채워 넘긴다)
|
||||
5. **스타일링** — Modifier 기본 체이닝(`:FontSize(14)`), 배열/인라인 merge 우선순위 규칙 (`modifier-plan.md`)
|
||||
6. **자식 전달** — Slot 기본 개념(children 배열, add/remove/clear), 마운트된 slot 재마운트 시 throw (`slot-plan.md`)
|
||||
7. **컴포넌트 작성** — 컴포넌트 = 순수 함수, 리프 프로퍼티엔 State만 바인딩, 전역 store 직접 참조 금지(이식성) (`component-composition-plan.md`, `purity-and-effects-plan.md`)
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전.
|
|||
이름이 아니라 구조로 일치를 검사하므로 문제없음. 기술적으로 막힐 위험이
|
||||
없어졌으니 M0/M2 어느 시점에 검증해도 무방 — `ROADMAP.md` 배치를 억지로
|
||||
안 옮겨도 됨. 상세는 `base/typing-limits.md` "`store.key` 레코드 필드
|
||||
타이핑" 절, 실제 문법 실측은 `luau-test/done/16-type-store-key-
|
||||
타이핑" 절, 실제 문법 실측은 `luau-test/rewrite-required/16-type-store-key-
|
||||
typefunction.luau`(**[2026-08-15] 통과** — 원래 스파이크가 API 버전
|
||||
드리프트로 깨져있던 걸 고침, `audit/type-recursive-issue-with-typeof/
|
||||
REPORT.md` 6-1절). 아래는 원래 발견 당시 기록.
|
||||
|
|
@ -674,7 +674,14 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴
|
|||
|
||||
- `architecture.md`의 "Store/State/Source 온톨로지 — 확정됨 (요약)" 절이
|
||||
`source-state-plan.md`의 최신 재구성(Source가 State를 만족, `store.key`가
|
||||
Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 — 이 감사
|
||||
Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 —
|
||||
**⚠️ [2026-08-25 재오픈] 이 "고침"은 다시 낡았다가 다시 맞게 됐다.**
|
||||
같은 날 오전에 `architecture.md`가 "`store.key`는 값" 재설계로 고쳐졌고,
|
||||
오후에 그 재설계가 철회되며 원래 모델로 되돌아왔다
|
||||
(`archive/store-value-field-redesign-withdrawn.md`). **지금 맞는 서술은
|
||||
`base/store-plan.md`가 소스** — 이 목록의 "이미 고침"만 믿고 넘어가지
|
||||
말 것(실제로 그 철회에서 `architecture.md` 두 자리가 누락됐고 감사가
|
||||
잡았다). 이 감사
|
||||
세션 도중 발견해 직접 정정(커밋 `4b839b0`에서 별도로 이미 반영됨을 뒤늦게
|
||||
확인 — 같은 문제를 두 세션이 독립적으로 발견한 셈).
|
||||
- `architecture.md` 소스트리의 `Store.luau`/`Ref.luau` 주석이 각각 옛
|
||||
|
|
|
|||
|
|
@ -52,9 +52,11 @@ v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층
|
|||
CLAUDE.md에 그런 서술은 없었음. 실제 소스는
|
||||
`base/component-composition-plan.md`의 "1. 컴포넌트 = 그냥 함수, "자기
|
||||
store 자동 소유" 매직은 폐기" 절(자동 흡수/자동 store 생성 매직 폐기,
|
||||
사용자 확정)과 `base/store-plan.md`의 "Store 값 설정 문법 —
|
||||
`myStore.key = value` 폐기, `source:Set(value)`로 전환" 절(`__newindex`
|
||||
자동 위임 폐기)).
|
||||
사용자 확정)과 `base/store-plan.md`의 "Store 값 설정 문법" 절(`__newindex`
|
||||
자동 위임 폐기)). **[2026-08-25]** 여기 한때 *"`store.key = v`는
|
||||
되살아났다"*고 적었는데 그건 같은 날 **철회된** 재설계다 — 대입 문법
|
||||
폐기(2026-08-06)는 **유지**된다
|
||||
(`archive/store-value-field-redesign-withdrawn.md`).
|
||||
|
||||
## 3. 계층별 실현 가능성
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,38 @@
|
|||
(`.claude/question.md`, `luau-test/STATUS.md` 등).
|
||||
|
||||
|
||||
00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/`
|
||||
00. **⭐⭐⭐ [2026-08-25] 7라운드까지 전부 처리 완료 — `question.md` 최우선
|
||||
절이 비었고 M2 착수 게이트가 0이다.** 7라운드(손 트레이싱, 6패스,
|
||||
발견 52건 `H-55`~`H-106`)와 그 검증 패스를 사용자와 대화형으로 처리해
|
||||
`base/`에 전량 반영했다. **결정의 소스는
|
||||
`qa-request/pre-implementation-handtrace-round7-followup.md`**(발견 원문은
|
||||
`qa-request/pre-implementation-handtrace-round7.md`, 판정은
|
||||
`qa-request/pre-implementation-handtrace-round7-verification.md` — 개수·개별 항목은 여기서
|
||||
세지 않는다).
|
||||
- **구조가 바뀐 것**: Store — **명시적 초기화**(옛 eager/lazy 이중 모델
|
||||
폐기, `defaults`가 곧 선언 키 집합) + `WrapStore`/`ProcessStoreType`
|
||||
폐기(**타입 함수를 안 쓰고** 타입 인자에 `Source<T>`를 직접) +
|
||||
동적 키를 `store:Of<<T>>(name)` 하나로(옛 `GetDynamic` 흡수),
|
||||
`Ref`가 `Epoch`로 승격, `Weak*` 등록 표면 신설(`Ref:WeakCallback` /
|
||||
`Observer:WeakSubscribe`), `Effect`의 강한 dep 맵 + `Blocker` 억제,
|
||||
전파 루프 의사코드 확정, `rawInvalid` → 캐시 카운터 쌍,
|
||||
`recompute` 재진입 차단 + 되감기, `emit(commit) -> boolean`,
|
||||
`EpochMap:Peek`, `_hold` 불변식.
|
||||
- **⚠️ 같은 날 한 번 크게 되돌렸다**: 오전에 "`store.key`가 값,
|
||||
`store:Of(k)`가 프리미티브, `index<>`/`keyof<>` + 팬텀 필드,
|
||||
`store.key = v` 부활"이라는 Store 재설계를 넣었다가 **같은 날
|
||||
철회**했다 — 살아남은 것은 명시적 초기화 하나다. 철회 이유와 원문은
|
||||
`archive/store-value-field-redesign-withdrawn.md`, 거기서 나온 원칙
|
||||
(*"타입 함수는 진단까지만"*)이 `base/typing-limits.md` §0으로 승격됐다.
|
||||
- **역전 없음** — `store.key = value` 폐기(2026-08-06)는 **유지**된다.
|
||||
- **툴체인 하나**: `luau` CLI가 심볼릭 링크를 못 탄다는 게 밝혀져
|
||||
`scripts/relink.sh` + `scripts/test.sh` 신설 — 그 전엔 스모크 2개가
|
||||
안 돌고 `luau-analyze`가 **거짓 클린**을 줬다.
|
||||
- **새 error 계약**: `level` 이분(사용자 입력 2 / 내부 불변식 1),
|
||||
메시지는 **영어**, 예외는 `pcall`로 안 감싼다
|
||||
(`base/architecture.md`의 두 신설 절).
|
||||
|
||||
**[2026-08-21] 1~5라운드 전부 `base/`
|
||||
반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서
|
||||
**M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`는
|
||||
`state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의
|
||||
|
|
@ -166,7 +197,15 @@
|
|||
(**[2026-08-24]** 그때 열어뒀던 마일스톤 재편 여부가 순서 교체로
|
||||
닫혔다 — 위 00번 머리말).
|
||||
|
||||
**⚠️⚠️ [2026-08-24 승격] 아래 둘은 이제 *바로 다음* 마일스톤의
|
||||
**✅ [2026-08-25 해소] 아래 둘은 전부 닫혔다** — `question.md` 최우선
|
||||
절은 지금 **비어 있다**. 중간 State GC는 **`_hold` 불변식**(하류 → 상류
|
||||
강함, 상류 → 하류 weak)으로 사용자가 확정했고, 동적 키 표면(옛 `GetDynamic`) 위치는
|
||||
**콜론 유지 + `CheckReserved` 타입 함수**로 닫혔다(애초에 그 항목이 섰던
|
||||
근거인 lazy `__index` 충돌 자체가 Store 재설계로 소멸). 남은 건 실측
|
||||
스파이크 하나뿐이고 **착수 게이트가 아니다**(`luau-test/STATUS.md`의
|
||||
"만들어야 할 스파이크" 절). 아래는 해소 전 서술:
|
||||
|
||||
**⚠️⚠️ [2026-08-24 승격, 2026-08-25 해소됨] 아래 둘은 이제 *바로 다음* 마일스톤의
|
||||
게이트다.** 순서 교체 전엔 반응형이 M3라 "한 마일스톤 뒤"의 일이었는데,
|
||||
반응형이 M2가 되면서 **지금 착수 직전에 결론이 필요한 항목**이 됐다
|
||||
(위 00번이 "M2 착수를 막는 **설계** 항목은 없다"고 하는 것과 모순되지
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,3 +14,4 @@ lune_packages/
|
|||
# luau-lsp가 rojo 설치를 감지하면 백그라운드에서 자동 생성/watch함
|
||||
# (에디터 타입 링킹용 산출물, 커밋 대상 아님)
|
||||
sourcemap.json
|
||||
.relink-manifest
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의
|
|||
**2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의
|
||||
`M2`/`M3`는 옛 의미로 읽을 것**(라이브 문서는 전부 새 번호로 맞춰뒀음).
|
||||
경위는 `.claude/archive/question-resolved.md`의 "마일스톤 경계" 절,
|
||||
새 구성은 `ROADMAP.md`의 M2 배너. **⚠️ M2 착수 전에 답이 필요한 항목이
|
||||
남아 있다** — 무엇이 몇 개인지는 여기서 세지 말고 `.claude/question.md`의
|
||||
최우선 절을 볼 것. 같은 상태를 `.claude/project-context.md`도
|
||||
새 구성은 `ROADMAP.md`의 M2 배너. **⭐ [2026-08-25] M2 착수를 막는 항목은
|
||||
없다** — `.claude/question.md`의 최우선 절이 **비었다**(7라운드 손 트레이싱
|
||||
후속에서 중간 State GC와 `store:GetDynamic` 위치가 같이 닫혔다, 결정 전량의
|
||||
소스는 `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`). 같은 상태를 `.claude/project-context.md`도
|
||||
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
|
||||
항상 루트 `ROADMAP.md`.
|
||||
|
||||
|
|
|
|||
241
ROADMAP.md
241
ROADMAP.md
|
|
@ -13,10 +13,12 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
|
|||
> 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다.
|
||||
> **[2026-08-24] 착수를 막던 마일스톤 순서 문제는 해소됐습니다** — M2와
|
||||
> M3의 번호를 맞바꿔 반응형을 먼저 짓기로 확정했습니다(아래 M2 배너가
|
||||
> 소스). **⚠️ 다만 그 교체로 M2 착수 전에 답이 필요한 항목 둘이
|
||||
> `.claude/question.md` 최우선 절로 올라왔습니다** — 중간 State GC 실측과
|
||||
> `store:GetDynamic` 위치(원래 반응형의 게이트였는데 반응형이 앞으로
|
||||
> 오면서 같이 당겨짐). M1까지의 산출물은
|
||||
> 소스). **⭐ [2026-08-25] 그 교체로 올라왔던 항목 둘도 닫혔습니다** —
|
||||
> 중간 State GC는 `_hold` 불변식(하류 → 상류 강함)으로, 동적 키 표면
|
||||
> 위치는 `store:Of<<T>>(name)` 하나로(옛 `GetDynamic` 흡수) + 예약 키
|
||||
> 충돌은 `CheckReserved` 타입 함수로. `.claude/question.md`
|
||||
> 최우선 절은 지금 **비어 있습니다**. 결정 전량의 소스는
|
||||
> `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`. M1까지의 산출물은
|
||||
> quad-base/quad-roblox 폴더+pesde.toml, 루트
|
||||
> default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/
|
||||
> `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로
|
||||
|
|
@ -195,7 +197,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
"미정"이라 적어둔 걸 `H-48`이 닫았다
|
||||
- [x] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수
|
||||
`luau` CLI, `architecture.md` "테스트 전략" 절 참고) —
|
||||
`quad-base/test/mock.luau` + `smoke.*.luau`, 전부 PASS
|
||||
`quad-base/test/mock.luau` + `smoke.*.luau` — **[2026-08-25 정정,
|
||||
7라운드 `H-78`] "전부 PASS"엔 전제조건이 있었다.** `luau` CLI가
|
||||
**심볼릭 링크를 못 타는데**(디렉토리·파일 둘 다) pesde의 워크스페이스
|
||||
링크가 전부 심볼릭이라, 그대로는 `smoke.init`/`smoke.plugin`이
|
||||
`could not resolve child component "src"`로 죽고 `luau-analyze`는
|
||||
**조용히 통과**한다(모듈을 `any`로 떨어뜨림 — "거짓 클린").
|
||||
**`./scripts/test.sh`로 돌릴 것** — 그게 `scripts/relink.sh`를 먼저
|
||||
돌려 심볼릭을 실제 복사로 바꾼다. 그 뒤 셋 전부 PASS 확인
|
||||
- [x] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템
|
||||
Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별
|
||||
멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절
|
||||
|
|
@ -325,33 +334,68 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`:TrackFrom` — `EpochSet = {[Epoch]: true}`, **배열 아님**)
|
||||
으로 떼어내며, State가 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값
|
||||
유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고
|
||||
**출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만
|
||||
**출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만**(옛 `rawInvalid == false` 자리 — **[2026-08-25 `H-85`]** 불린이 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체됐다)
|
||||
돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복
|
||||
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
|
||||
(`luau-test/STATUS.md`).
|
||||
- [ ] `Source.luau`/`State.luau`/`Store.luau`
|
||||
- [ ] **[2026-08-18 신설]** `store:GetDynamic<<T>>(name): Source<T>` — 런타임에
|
||||
이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은
|
||||
기각). **⚠️ 콜론 메소드로 두면 `__index`가 고정 메소드 테이블을 먼저
|
||||
확인해야 하고 `GetDynamic`이 예약 키가 됨** — 탑레벨 함수로 둘지
|
||||
아직 미결(`base/store-plan.md`의 "타입 추론 문제" 절, `question.md` 최우선 절)
|
||||
- [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<<T>>(name): Source<T>` —
|
||||
런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열
|
||||
커링은 기각). **콜론 메소드로 확정**했고, 예약 키
|
||||
(`Of`/`Names`) 충돌은 `CheckReserved` 타입 함수가 사용 지점에서
|
||||
잡는다(`base/store-plan.md`의 "타입 추론 문제" 절).
|
||||
`<<T>>`가 값 호출부에서 실제로 `T`를 묶는 것도 실측 확인됨.
|
||||
**[2026-08-25] 옛 이름은 `GetDynamic`이었다 — `Of`가 흡수했다**
|
||||
- [ ] **[2026-08-25 신설]** `store:Names(): { string }` — 선언된 키 집합
|
||||
열거(그림자 테이블의 키). 그룹 `Attribute(...)`/`attr:NameMap()`이
|
||||
요구한다(`base/attribute-plan.md`)
|
||||
- [ ] **[2026-08-25 신설]** `CheckReserved` 타입 함수 — 예약 키를 **검증만**
|
||||
하고 `T`를 그대로 통과시킨다. `error()`가 아니라
|
||||
`print(...)` + `return types.never`를 써야 한다.
|
||||
**타입 함수는 이 용도(진단)까지만 쓴다** — `base/typing-limits.md` §0
|
||||
- [ ] **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source<T>`를
|
||||
직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index`
|
||||
(없는 키를 그 자리에서 만들어 저장)는 **폐기**. 그래서 `defaults`가
|
||||
곧 선언 키 집합이고 `Names()`가 성립한다
|
||||
- [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅**
|
||||
(2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제
|
||||
호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) —
|
||||
State는 구독자(Observer의 emit 클로저)를 **weak로만** 담고, 살려두는
|
||||
State는 구독자를 **weak-키로만** 담고, 살려두는
|
||||
책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음
|
||||
(어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). 발화 시
|
||||
각 구독자에 대해 `canExecute(observer)`가 거짓이면 **그 구독자만
|
||||
조용히 건너뜀**(no-op) — 이게 `canExecute`의 유일한 실제 호출부이고,
|
||||
`inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에
|
||||
걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은
|
||||
`bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관
|
||||
- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function`
|
||||
(`WrapStore`/`ProcessStoreType`)으로 `Store<T>`가 `T`의 각 필드를
|
||||
`Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱
|
||||
번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]**
|
||||
`luau-test/done/16-type-store-key-typefunction.luau` 통과(원인은
|
||||
설계 문제가 아니라 `types.newfunction` API 버전 드리프트였음)
|
||||
(어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐).
|
||||
**⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 확정 의사코드는
|
||||
`base/source-state-plan.md`의 "전파 루프 — 확정 의사코드" 절이
|
||||
소스다.** 여기 한때 적혀 있던 두 가지가 틀렸었다 —
|
||||
(a) 집합의 원소는 "Observer의 emit 클로저"가 아니라 **Observer
|
||||
값**이다(`bindLifetime`이 그 identity를 쓰므로 클로저를 담으면
|
||||
`canExecute`가 항상 거짓), (b) **`canExecute`는 Observer/Effect
|
||||
구독자에만 적용된다 — 자식 State 노드는 이 게이트를 안 탄다.**
|
||||
"각 구독자에 대해"로 짜면 `:With`/`:Compute`/`:Gate`가 만든 파생
|
||||
노드가 **전부 걸러져** 그 아래 모든 Observer가 침묵한다. 자식
|
||||
노드의 생존은 `canExecute`가 아니라 같은 문서의 **`_hold`
|
||||
불변식**(하류 → 상류 강함)이 책임진다.
|
||||
```lua
|
||||
if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음
|
||||
elseif canExecute(sub) then sub.fn(sub, from) -- Observer / Effect
|
||||
end
|
||||
```
|
||||
`canExecute`가 `inst`를 인자로 받을 수 없는 이유는 그대로다(State는
|
||||
자기가 어느 Instance에 걸렸는지 모름). `state:Observer(fn)`의
|
||||
"등록 즉시 1회 실행"은 `bindLifetime` 이전에 동기적으로 일어나므로
|
||||
이 게이팅과 무관.
|
||||
**순회 전 스냅샷 필수**(`H-23`) — `pairs` 순회 중 새 키 추가는
|
||||
미정의다
|
||||
- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 —
|
||||
**[2026-08-25 재작성]** 옛 `WrapStore`/`ProcessStoreType` 합성 접근은
|
||||
**폐기**됐고(`archive/store-value-field-redesign-withdrawn.md`), 지금은
|
||||
**타입 함수를 안 쓰고** 타입 인자에 `Source<T>`를 직접 써서 평범한
|
||||
레코드로 짓는다(같은 날 `index<>`/`keyof<>` + 팬텀 필드 안을 넣었다가
|
||||
§0 원칙에 따라 철회 —
|
||||
`archive/store-value-field-redesign-withdrawn.md`). `luau-analyze`로 양성 9건 +
|
||||
음성 대조군 전부 확인 — `base/store-plan.md`의
|
||||
"`store.key` 레코드 필드 타이핑" 절이 소스. 스파이크 `16`/`21`은
|
||||
폐기된 접근을 검증한 것이라 재작성 대기
|
||||
(`luau-test/STATUS.md`)
|
||||
- [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar
|
||||
(2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`"
|
||||
절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드
|
||||
|
|
@ -366,15 +410,28 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
|
||||
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
|
||||
기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함
|
||||
- [ ] **`Observer.luau`** — `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()`
|
||||
전역 레지스트리의 소유 모듈**. `EpochMap.luau`와 같은 이유로
|
||||
`State.luau`에 묻지 않는다(`Effect`/`GateNode`/leaf 핸들러가 전부 이
|
||||
레지스트리를 본다) — `base/architecture.md` 소스 트리, 7라운드 `H-99`
|
||||
- [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
|
||||
실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver`
|
||||
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()`. **동적
|
||||
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` +
|
||||
**`:WeakSubscribe()`/`:WeakUnsubscribe()`**(Weak 쪽이 프리미티브,
|
||||
7라운드 `H-58`/`H-59`). **무인자 `state:Observer()`의 내부 콜백은
|
||||
`function(self) self:Get() end`**(`H-61`). **동적
|
||||
경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v
|
||||
is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14
|
||||
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
|
||||
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
|
||||
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
|
||||
- [ ] `Effect(fn, ...deps)`(`base/effect-plan.md`, **[2026-08-21 5라운드
|
||||
- [ ] `Effect(fn, ...deps)` — **⚠️ 선행: `Blocker`의 기본 메커니즘**
|
||||
(`On`/`Off`/`IsOn`/`OffWithoutEmit`). 생성자가 등록 구간 억제에 사적
|
||||
`Blocker` 하나를 쓴다(`base/effect-plan.md`). 아래 `Blocker.luau`
|
||||
체크박스가 이 항목보다 뒤에 있지만 **그 기본 넷은 `GateNode`/`:Policy`와
|
||||
무관하게 독립 완결**이라(`base/blocker-plan.md`의 "메커니즘" 절) 그
|
||||
부분만 먼저 만들면 된다 — `:Policy`/`state:Block` 배선은 `GateNode`
|
||||
뒤에. (`base/effect-plan.md`, **[2026-08-21 5라운드
|
||||
`C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
|
||||
1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독**
|
||||
(State/Source는 `Observer`, `Ref`는 `:Callback`)을 걸어
|
||||
|
|
@ -389,17 +446,32 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
|
||||
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
|
||||
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
|
||||
- [ ] **⭐ [2026-08-24 신설, 6라운드] `Effect` 구현 시 같이 만들 것** —
|
||||
**`handle._observers`**(배열, 단수 `_observer`에서 바뀜 `H-8`) ·
|
||||
- [ ] **⭐ [2026-08-24 신설, 6라운드 / 2026-08-25 7라운드로 필드 재편]
|
||||
`Effect` 구현 시 같이 만들 것** —
|
||||
**`handle._deps`**(`{[Ref|State] = fn|Observer}`, **강참조** — 옛
|
||||
`_observers`/`_refDeps`/`_refCallbacks` 셋이 여기로 통합됐다) ·
|
||||
**`handle._epochs`**(`EpochMap` — `Ref`도 `Epoch`라 dep 종류가 균일) ·
|
||||
**`handle._blocker`**(등록 구간의 즉시-1회 호출 억제 — 옛 `_installing`
|
||||
플래그 폐기, 그건 생성자 구간만 덮어 바인드 구간을 놓쳤다) ·
|
||||
**`handle._cleanup`**(직전 cleanup 보관, `Rerun`과 `Destroying` 클로저가
|
||||
같은 자리를 읽는다) · **`handle._refDeps`/`_refCallbacks`**(`Ref` dep과
|
||||
거기 건 클로저 — 해제 시 값으로 떼야 해서 보관 필요) ·
|
||||
**`handle._destroyConn`** · **`:_bindDestroying(inst)`/`:_unbindDestroying()`**
|
||||
(`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`).
|
||||
`fn` 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고
|
||||
**`...deps`는 `fn`에 안 넘어간다**(`H-14`). `Ref` 콜백은 본문 맨 앞에서
|
||||
**`canExecute(handle)`를 확인**한다(`H-7`). 의사코드는
|
||||
`base/effect-plan.md`가 소스
|
||||
같은 자리를 읽는다) · **`handle._installed`**(설치 여부 — `fn`의 cleanup
|
||||
반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) ·
|
||||
**`handle._running`/`_pending`**(`Rerun` 재진입
|
||||
지연) · **`handle._destroyConn`** ·
|
||||
**`:_bindDestroying(inst)`/`:_unbindDestroying()`**
|
||||
(`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`) ·
|
||||
**`:Rerun()`**(정의는 `H-60`이 채웠다 — 실행 중 재진입은 **지연
|
||||
재실행**, error는 UB) · **`:_consumeCleanup()`**(읽고 → 지우고 → 실행).
|
||||
`fn` 시그니처는 **`fn(self: EffectHandle) -> ...(() -> ())`**이고
|
||||
(**[2026-08-25 `H-95`]** 가변 반환 팩 — 옛 `-> (() -> ())?`는 콜백이
|
||||
"선언보다 적게 반환"할 때 strict에서 막혀 정상 용례가 전부 안 통과했다)
|
||||
**`...deps`는 `fn`에 안 넘어간다**(`H-14`).
|
||||
**⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/
|
||||
`:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다
|
||||
(`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다
|
||||
(`H-7`). 캐치업은 바인드 직후 **조건부 최대 1회**
|
||||
(`if not self._installed or self._epochs:Refresh() then self:Rerun() end`
|
||||
— `H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스
|
||||
- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로
|
||||
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
|
||||
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
|
||||
|
|
@ -435,17 +507,40 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
한 번도 안 쓴다, `base/state-epoch-plan.md` §5). **빈 배치는
|
||||
아무것도 안 함**. `emitEpochMap`을 쓰므로 위 `EpochMap.luau`가 선행 —
|
||||
`base/gate-plan.md`. **"게이팅 먼저" 결정의 산물**
|
||||
(사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*)
|
||||
(사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*).
|
||||
**⭐⭐ [2026-08-25 갱신, 7라운드 `H-55`/`H-72`/`H-86`] `setup` 계약이
|
||||
바뀌었다** — `setup: (emit: (commit: boolean?) -> boolean) ->
|
||||
(onUpstreamEmit: () -> ())`. 인자 **목록**은 그대로지만
|
||||
`emit(false)`가 **흡수 집합을 버리고**(정책이 그걸 할 통로가 없었다),
|
||||
반환값이 **"실제로 내보내거나 버릴 게 있었는가"**를 준다(정책이
|
||||
`pending`을 읽을 통로가 없어 `Throttle`의 창이 idle로 못 돌아왔다).
|
||||
그리고 **수신 시점 판정은 `emitEpochMap:Peek`**을 쓴다 — `:Update`는
|
||||
덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻과 양립하지 않는다.
|
||||
flush 순서는 **빈 배치 얼리리턴 → 스왑 → `:Sync(batch)` → 전파**
|
||||
- [ ] **`Blocker.luau`** (**[2026-08-24]** 위 둘과 같이 되돌아옴) — 위 `GateNode` 위에
|
||||
얹히는 **정책**(다시 노드를 만들지 말 것). 여러 Source를 한꺼번에
|
||||
얹히는 **정책**(다시 노드를 만들지 말 것).
|
||||
**⭐ [2026-08-25 추가, 7라운드 `H-63`] onunblock 핸들 보관 세 자리**:
|
||||
(1) **weak-키 해시맵 셋**(`__mode = "k"`) — 값-weak 배열이면 구멍에서
|
||||
`ipairs`가 멈춰 뒤의 살아있는 게이트가 `Off()`를 못 받는다,
|
||||
(2) **강한 주인은 정책이 반환하는 `onUpstreamEmit` 클로저**(upvalue로
|
||||
잡는다) — Blocker가 강하게 들면 거기 걸렸던 모든 gated state와 상류
|
||||
체인이 영원히 산다, (3) `Off()`/`OffWithoutEmit()`은 **스냅샷 뒤 순회**. 여러 Source를 한꺼번에
|
||||
바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive
|
||||
(`base/blocker-plan.md`). **M3의** `Dispatch.setLength`/`setOffsetSource`의
|
||||
배치 등록이 `:On()`/`:IsOn()`/`:OffWithoutEmit()` **세 메서드**를
|
||||
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
|
||||
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
|
||||
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
|
||||
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Source`/`State`/
|
||||
`Store` 필드 추가 — **규칙 요지**: `New(): Quad`가 닫힌 레코드이고
|
||||
- [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
|
||||
`quad-types`의 `Quad`에 **이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
|
||||
`Source` / `Store` / `Effect` / `Blocker` / `Relate` /
|
||||
`is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/
|
||||
`isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`·
|
||||
`canBound`·`canExecute` 4종. **⚠️ `State`는 런타임 생성자가 없다** —
|
||||
파생(`:With`/`:Compute`/`:Gate`)으로만 생기므로 **타입만** 재수출한다.
|
||||
옛 목록(`Source`/`State`/`Store` 셋)은 M2가 실제로 얹는 것의 일부만
|
||||
담고 있었고, 빠진 것 전부가 `H-25`가 만든 바로 그 벽에 부딪힌다.
|
||||
**규칙 요지**: `New(): Quad`가 닫힌 레코드이고
|
||||
`RunInit`은 반환값이 없어 타입을 못 넓히므로, 서브시스템을 붙이는
|
||||
마일스톤마다 `quad-types`의 `Quad`에 그 필드와 타입 재수출을 같이
|
||||
추가한다. 안 하면 그 마일스톤 완료 후 `quad.Store` 접근이 런타임엔
|
||||
|
|
@ -464,7 +559,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로
|
||||
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
|
||||
되면 동종 타입 dep 1개로 한정)
|
||||
- [ ] mock 대상 테스트
|
||||
- [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Block(blocker)` /
|
||||
`Source:Emit()` — `:Compute`/`:Apply`/`:Observer`는 각각 체크박스가
|
||||
있는데 이 셋만 빠져 있었다
|
||||
- [ ] **[2026-08-25 신설, `H-81`]** `isModifier` 런타임 가드 — 적용 지점이
|
||||
**전부 이 마일스톤의 코드**다(`Source:Set` / Store 생성 시 eager
|
||||
`Source(default)` / State의 `:Compute` 결과 캐싱, 그리고
|
||||
독립 `Source(someModifier)`). 체크박스가 M7에만 있었다.
|
||||
`base/modifier-plan.md` 7번과 `base/source-state-plan.md`의 적용
|
||||
지점 목록을 서로 맞출 것(한쪽에만 있는 항목이 있다)
|
||||
- [ ] **[2026-08-25 신설, `H-97`]** mock 백엔드용 생명주기 4종 최소 구현 —
|
||||
`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`. 안 하면
|
||||
**아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다**(루프가 매
|
||||
발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은
|
||||
에러 스텁이다). 커밋된 `quad-base/test/mock.luau`에 signal/Connection이
|
||||
이미 있으므로 그 `Destroying`을 그대로 쓰면 된다
|
||||
- [ ] mock 대상 테스트 — **전파 루프를 실제로 돌릴 것**(위 항목이 선행).
|
||||
M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가면
|
||||
7라운드가 찾은 종류의 결함을 그대로 낳는다
|
||||
|
||||
## M3 — 디스패치 엔진
|
||||
|
||||
|
|
@ -1115,7 +1227,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Ref` 필드 추가
|
||||
(위 M3 항목의 "마일스톤마다" 규칙)
|
||||
- [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/
|
||||
`:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref
|
||||
`:Wait(thread?)`, 전부 self 반환).
|
||||
**⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-64`/`H-70`] `Ref`가 `Epoch`를
|
||||
만족하게 됐다** — 공개 필드 **`.Revision`**(`:Set()`이 `Source`와 같은
|
||||
`bit32.bnot(-rev)`로 갱신) + **`EpochBrand:register(self)`**, 그리고
|
||||
약하게 등록하는 **`:WeakCallback(fn)`**(weak-키 별도 테이블, `Weak` 쪽이
|
||||
프리미티브이고 `:Callback`이 그 위에 "GC 킵"을 얹은 것).
|
||||
**M2가 이미 이 표면을 전제한다** — `Effect` 생성자가 `Ref` dep을
|
||||
`self._epochs:Sync(d)`로 `EpochMap`에 태운다(`base/effect-plan.md`).
|
||||
`base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절이 소스 + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref
|
||||
런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단,
|
||||
위치 무관 호이스팅 pre-pass — `base/ref-plan.md` "`phase`
|
||||
옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절)
|
||||
|
|
@ -1181,17 +1301,30 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
(`pairs` 순회 중 새 키 추가가 Lua에서 미정의라 — `H-23`과 같은 처방)
|
||||
키 타입으로 분기: `type(k) == "thread"`면 `Callbacks[k] = nil`로 소진 후
|
||||
`coroutine.resume(k, self)`(값이 아니라 **Ref 자신**), 함수면 `k(value)`
|
||||
호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는 신설된
|
||||
호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는
|
||||
**`:Uncallback(fn)`**(`Callbacks[fn] = nil` 한 줄).
|
||||
**⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-59`] 약하게 등록하는 짝
|
||||
`:WeakCallback(fn)`이 신설됐고 그쪽이 프리미티브다** —
|
||||
`:Callback(fn)`은 거기에 "GC 안 되도록 킵" 하나를 더 얹은 것이다.
|
||||
약한 등록은 **weak-키 테이블**(`__mode = "k"`)로 `.Callbacks`와
|
||||
**별도**이고(Lua의 weak 모드는 테이블 단위), 발화 순회는 둘 다
|
||||
훑으며 `:Uncallback`은 양쪽을 본다.
|
||||
의사코드는 `base/ref-plan.md`가 소스.
|
||||
**⚠️ 옛 배열 설계(빈 슬롯 선형 탐색 등록, `[i] = nil` 소진, `#t` border
|
||||
실측)는 전부 폐기됐다** — 해시맵엔 구멍도 border도 없다.
|
||||
`:Wait(thread?)`는 그대로 — `thread`가 `nil`이면
|
||||
`coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self`
|
||||
반환(남의 thread를 여기서 대신 정지시킬 수 없어서)
|
||||
- [ ] **[2026-08-24 신설, 6라운드 `H-7`]** `Ref:Uncallback(fn)` — 해제 경로.
|
||||
`Effect`가 자기 `Ref` dep 콜백을 뗄 때 쓰고(`_refCallbacks`에 보관해둔
|
||||
바로 그 클로저를 값으로 뗀다), `unbindLifetime`/`:Unsubscribe()`가 부른다
|
||||
- [ ] **[2026-08-24 신설, 6라운드 `H-7` / 2026-08-25 범위 축소, 7라운드 `H-58`]**
|
||||
`Ref:Uncallback(fn)` — 해제 경로(강·약 두 테이블을 다 본다).
|
||||
**⚠️ `Effect`는 더 이상 이걸 안 부른다** — 여기 한때 *"`Effect`가 자기
|
||||
`Ref` dep 콜백을 뗄 때 쓰고(`_refCallbacks`에 보관해둔 바로 그 클로저를
|
||||
값으로 뗀다), `unbindLifetime`/`:Unsubscribe()`가 부른다"*고 적혀 있었는데
|
||||
그 계약은 `base/effect-plan.md`가 명시적으로 retract했다. 지금은
|
||||
`Effect`가 **생성자에서 `:WeakCallback`으로 한 번만** 걸고 강한 주인은
|
||||
`_deps`이며(`_refCallbacks`는 폐기된 필드), 바인드/언바인드는 `Ref`를
|
||||
아예 안 건드린다. `:Uncallback`은 **사용자가 직접 건 콜백을 떼는**
|
||||
공개 표면으로 남는다
|
||||
- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/
|
||||
`unbindLifetime`/`canBound`/`canExecute` 본체(인터페이스 자체는
|
||||
M2로 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현
|
||||
|
|
@ -1202,12 +1335,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`gchold[value]=true`(강참조로 생존 보장)와 `BindData:SetWeak(value,
|
||||
"gchold"/"gcconn", ...)`(값이 자기 생존 판정 근거를 직접 들고 있게)
|
||||
둘을 하고, `unbindLifetime(value)`은 그걸 되돌림.
|
||||
**⭐ [2026-08-24 6라운드 `H-11`] 그리고 `isEffect(value)`면 분기가 하나 더
|
||||
붙는다** — `handle._observers` 전부로 cascade하고
|
||||
`value:_bindDestroying(inst)` / `:_unbindDestroying()`를 부른다(전자는
|
||||
주입 op `onDestroying`으로 `Destroying`을 연결하고 `Ref` dep 콜백을
|
||||
(재)등록, 후자는 그 대칭). 여기 원래 *"둘만 하고"*라고 적혀 있었는데
|
||||
이 분기와 직접 모순이었다. **이게 `Effect`의 leaf 사망 cleanup을 실제로
|
||||
**⭐ [2026-08-24 6라운드 `H-11`, 2026-08-25 7라운드 `H-58`로 축소]
|
||||
그리고 `isEffect(value)`면 분기가 하나 더 붙는다** —
|
||||
`value:_bindDestroying(inst)` / `:_unbindDestroying()`를 부른다.
|
||||
여기 원래 *"둘만 하고"*라고 적혀 있었는데 이 분기와 직접 모순이었다.
|
||||
**⚠️ [2026-08-25 정정] `handle._observers` 전부로 cascade하지
|
||||
않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고
|
||||
`_bindDestroying`은 **`Ref` dep 콜백을 (재)등록하지 않는다** —
|
||||
dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과
|
||||
**조건부 캐치업 한 줄**(`if not self._installed or
|
||||
self._epochs:Refresh() then self:Rerun() end`)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로
|
||||
발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다
|
||||
(그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`와
|
||||
`base/effect-plan.md`가 소스. **[2026-08-14
|
||||
|
|
|
|||
91
scripts/relink.sh
Executable file
91
scripts/relink.sh
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
# pesde 워크스페이스 링크를 **실제 복사**로 교체한다. 여러 번 돌려도 최신을 따라간다.
|
||||
#
|
||||
# 왜 필요한가: **`luau` CLI가 심볼릭 링크를 못 탄다.** 디렉토리 링크도 파일
|
||||
# 링크도 안 되고, `luau-analyze`는 더 나쁘게 **조용히 통과**한다(모듈을 `any`로
|
||||
# 떨어뜨리고 진단 0건 — "거짓 클린"). pesde의 워크스페이스 링크는 전부 디렉토리
|
||||
# 심볼릭이라, `quad-base/src/init.luau`의 `require("./roblox_packages/quad_types")`가
|
||||
# 그 링크에 닿는 순간 죽는다. 근거와 최소 재현은
|
||||
# `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`의 🅛 절.
|
||||
#
|
||||
# ⚠️ 이 스크립트가 지켜야 하는 것 셋(전부 `/code-review high`가 실제로 잡은 것):
|
||||
# 1. **`cp -rL`** — 평범한 `cp -r`는 심볼릭을 **심볼릭 그대로** 복사한다.
|
||||
# 중첩 패키지(quad_types 안의 type_version_check 링크)가 그대로 살아남아
|
||||
# 같은 "거짓 클린"이 재발한다.
|
||||
# 2. **스킵은 치명적으로** — 원본이 없어 건너뛴 채 성공으로 끝나면 테스트가
|
||||
# 얼어붙은 스냅샷을 돈다. 이 스크립트가 막으려던 실패 모드 그 자체다.
|
||||
# 3. **깊이 고정 글롭** — `find -path '*/.pesde/...'`의 `*`는 `/`를 넘어가
|
||||
# 중첩 `.pesde`와 `src`까지 잡는다. 셸 글롭은 `/`를 안 넘으므로 그걸 쓴다.
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
cd "$(dirname "$0")/.."
|
||||
MANIFEST=".relink-manifest"
|
||||
|
||||
fail=0
|
||||
note_fail() { echo "relink: $*" >&2; fail=1; }
|
||||
|
||||
# 0) 매니페스트가 없는데 이미 복사돼 있으면(도입 전에 한 번 돌린 트리 등)
|
||||
# pesde 레이아웃에서 원본 매핑을 되살린다.
|
||||
# 구조: <member>/{luau,roblox}_packages/.pesde/<owner>+<pkg>/<ver>/<pkg>/<entry>
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
for d in */luau_packages/.pesde/*/*/* */roblox_packages/.pesde/*/*/*; do
|
||||
[ -d "$d" ] || continue
|
||||
pkg="$(basename "$d")"
|
||||
member="${pkg//_/-}"
|
||||
[ -d "$member" ] || continue
|
||||
for e in "$d"/*; do
|
||||
n="$(basename "$e")"
|
||||
[ -e "$member/$n" ] || continue
|
||||
printf '%s\t%s\n' "$e" "$(readlink -f "$member/$n")" >> "$MANIFEST"
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
replaced=0
|
||||
refreshed=0
|
||||
|
||||
# 1) 아직 심볼릭인 것 — 원본을 기록하고 복사로 교체. 중첩 링크가 남지 않도록
|
||||
# `cp -rL`로 역참조하고, 남은 게 없을 때까지 반복한다.
|
||||
while :; do
|
||||
found=0
|
||||
while IFS= read -r link; do
|
||||
found=1
|
||||
target="$(readlink -f "$link")"
|
||||
if [ ! -e "$target" ]; then note_fail "dangling symlink: $link"; continue; fi
|
||||
printf '%s\t%s\n' "$link" "$target" >> "$MANIFEST"
|
||||
rm "$link"
|
||||
cp -rL "$target" "$link"
|
||||
replaced=$((replaced + 1))
|
||||
done < <(find . -path ./.git -prune -o -type l -print | grep '/\.pesde/' || true)
|
||||
[ "$found" -eq 0 ] && break
|
||||
done
|
||||
|
||||
# 2) 이미 복사된 것 — 매니페스트를 보고 원본에서 다시 복사(워크스페이스 멤버를
|
||||
# 고쳤을 때 테스트가 옛 스냅샷을 돌지 않게).
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
sort -u "$MANIFEST" -o "$MANIFEST"
|
||||
while IFS=$'\t' read -r link target; do
|
||||
[ -n "$link" ] || continue
|
||||
[ -L "$link" ] && continue # 방금 1)에서 처리됨
|
||||
if [ ! -e "$target" ]; then note_fail "missing source: $target (for $link)"; continue; fi
|
||||
parent="$(dirname "$link")"
|
||||
if [ ! -d "$parent" ]; then note_fail "missing link parent: $parent — 매니페스트가 낡았다, $MANIFEST 를 지우고 pesde install 후 다시 돌릴 것"; continue; fi
|
||||
rm -rf "$link"
|
||||
cp -rL "$target" "$link"
|
||||
refreshed=$((refreshed + 1))
|
||||
done < "$MANIFEST"
|
||||
fi
|
||||
|
||||
echo "relink: $replaced symlink(s) replaced, $refreshed copy/copies refreshed"
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "relink: 위 스킵 때문에 트리가 최신이 아니다 — 테스트를 신뢰하지 말 것" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3) 최종 확인 — .pesde 아래에 심볼릭이 하나도 남으면 안 된다.
|
||||
leftover="$(find . -path ./.git -prune -o -type l -print | grep '/\.pesde/' || true)"
|
||||
if [ -n "$leftover" ]; then
|
||||
echo "relink: 심볼릭이 남았다 (luau가 못 탄다):" >&2
|
||||
echo "$leftover" >&2
|
||||
exit 1
|
||||
fi
|
||||
17
scripts/test.sh
Executable file
17
scripts/test.sh
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# 스모크 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고).
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
cd "$(dirname "$0")/.."
|
||||
./scripts/relink.sh
|
||||
files=(quad-base/test/smoke.*.luau)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "no smoke tests found (quad-base/test/smoke.*.luau)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fail=0
|
||||
for f in "${files[@]}"; do
|
||||
echo "=== $f"
|
||||
luau "$f" || fail=1
|
||||
done
|
||||
exit "$fail"
|
||||
Loading…
Reference in a new issue