feat(m2): 단위 3 — Observer(레지스트리 소유·네 진입점) / Effect(rawRerun·_rerunRequired 홀드·cleanup·네 진입점) + onDestroying 스텁·mock + spec 2개
- Observer.luau: Observer.Init(module) — 인스턴스별 임플 + Subscribed/WeakSubscribed(H-99), _receive(canExecute 게이팅, 홀드 H-159)/_catchUp(유일한 재생 자리, from=nil H-164), 네 진입점 인라인(H-149, Weak 프리미티브 H-111, 관대 H-133, 엄격), 생성자 순서(fn → 플래그 → _subs). - Effect.luau: Effect.Init(module) — deps 검증(H-70), dep 종류별 클로저(H-107), fire(from==nil 가드, _epochs Update만 H-151, _blocker 없음 H-150), rawRerun(force)/Rerun(_pending 지연, 홀드, 사망 계약), _bindDestroying/_unbindDestroying/_consumeCleanup(_cleanupRunning H-160), 네 진입점 자기 본문(H-144) + isRunning 가드(H-147) + resubscribeTail. - State:Observer → ObserverImpl.new. LifetimeHandle: onDestroying 에러 스텁. mock: onDestroying. - quad-types: EpochSet/Observer/ObserverFn/EffectHandle/EffectFn, State.Observer, Quad.Effect·onDestroying. - spec.observer(8절)·spec.effect(9절) ALL PASS, analyze 0. ROADMAP 단위 3 체크박스(mock 전파 루프 테스트 포함), round11 §5 단위 3 확인 목록 + H-178(사적 필드 _ 접두), 세션 원문·요약. Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
67fb61e988
commit
dca789ecca
13 changed files with 1055 additions and 10 deletions
|
|
@ -26,6 +26,7 @@
|
|||
| `H-174` | **②** | 1→2 | 🔴 | 생명주기 4종은 **`New()` 인스턴스마다 다른 필드**(이 단위가 그렇게 만들었고 `spec.lifetime` 8이 고정)인데, 단위 2·3의 `base/` 의사코드(`Observer:_receive`의 `canExecute(self)`, `Subscribe` 넷의 `canBound(self)`, `EffectHandle.rawRerun`)는 **자유 함수**로 부른다 — `Observer.luau`/`Source.luau`가 자기 인스턴스의 필드에 어떻게 닿는지 어느 문서도 안 정했다. 결정 없이는 단위 2의 `_receive`를 쓸 수 없다 | ✅ (a) 사용자 확정 — 팩토리형, `module.canExecute(self)`를 발화 시점에 늦게 읽음(`lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체) |
|
||||
| `H-176` | ① | 2 | 🟡 | `:Compute`의 trailing deps를 타입팩 `D...`로 좁히는 선언은 strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힌다(스파이크 15가 "미검증"으로 남긴 자리) | ✅ `...any` + 콜백 주석으로 확정, `source-state-plan.md` 실측 기록 |
|
||||
| `H-177` | ① | 2 | 🟢 | `InitSource`/`InitStore`가 `State.implFor`만 불러 `New()`의 `RunInit` 순서에 의존했다 — `module-lifecycle-plan.md`는 "각 `InitXxx`가 `require`처럼 멱등하게 자기 의존성을 당겨온다"로 확정 | ✅ 각 Init이 `module:RunInit(dep)`를 직접 호출(감사 3라운드) |
|
||||
| `H-178` | ① | 3 | 🟢 | 코드의 사적 필드는 `_` 접두(`_valueEpochMap`·`_emitEpochMap`·`_subs`·`_hold`…)인데 `base/` 의사코드는 `valueEpochMap`처럼 접두 없이 쓴다 — 이름은 1:1이고 밑줄만 다르다 | ✅ 기록만(문서 무변경 — 코드 관례, `H-174` 조립 세부와 같은 급) |
|
||||
| `H-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 |
|
||||
|
||||
## 상세
|
||||
|
|
@ -166,6 +167,17 @@
|
|||
- **처리**: `Source.Init`이 `module:RunInit(State.Init)`, `Store.Init`이 `module:RunInit(InitSource)`를
|
||||
직접 호출. `init.luau` 순서 주석을 "무관"으로.
|
||||
|
||||
### 단위 3 — `Observer` → `Effect` (2026-08-29)
|
||||
|
||||
### `H-178` 🟢 — 사적 필드의 `_` 접두 (①)
|
||||
|
||||
- **어디서**: `effect-plan.md` 생성자 의사코드의 `d.valueEpochMap` / `state-epoch-plan.md` §4의
|
||||
`self.valueEpochMap` vs 코드 `_valueEpochMap`(단위 2부터).
|
||||
- **무엇이**: 단위 2가 State의 사적 필드를 전부 `_` 접두로 옮겼고(`_subs`/`_hold`/`_cache`/
|
||||
카운터 둘/맵 둘), 단위 3 `Effect`가 `d._valueEpochMap`로 읽는다. 문서는 접두 없음. 뜻·개수는
|
||||
1:1이라 문서를 고칠 이유가 없고(의사코드는 문서 안에서 자기완결), 코드 쪽 관례로 기록만.
|
||||
- **처리**: 없음 — 다음 단위(`GateNode`가 `valueEpochMap`을 컴포지션)도 같은 접두로.
|
||||
|
||||
### `H-175` 🟢 — 클로저 캐시 규칙의 범위 (①)
|
||||
|
||||
- **어디서**: 이 파일 §5 "툴링 사실 둘"의 둘째 항, `spec.ref.luau` 7번 주석.
|
||||
|
|
@ -241,6 +253,27 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
|
|||
추론이 안 된다** — `typing-limits.md` §1②가 이미 캐비엇으로 적어둔 자리(`:Apply`의 factory는
|
||||
주석 필요). 테스트는 주석으로.
|
||||
|
||||
**단위 3 (메인 세션, 2026-08-29)**:
|
||||
- `Observer` — 생성자 순서(fn 1회 → 플래그 내림 → `_subs`), `_receive`의 `canExecute` 게이팅과
|
||||
홀드, `_catchUp`이 유일한 재생 자리(출처 `nil`), 네 진입점(Weak 프리미티브·인라인 게이트·
|
||||
관대/엄격·양쪽 테이블 해제), 강/약 GC, 파동 중 구독자 스냅샷 — `spec.observer.luau` 8절.
|
||||
- `Effect` — deps 검증 셋(`H-70`), dep 종류별 클로저 둘(`H-107`), `fire`의 `from == nil` 가드,
|
||||
`_epochs` 갱신은 `fire`뿐(`H-151`), 다이아몬드 1회, cleanup 세 자리(루프 머리/`Unsubscribe`/
|
||||
`Destroying`), cleanup 없는 `fn`의 재바인드 무재실행(`H-58` 회귀), 재진입 지연(`_pending`),
|
||||
네 진입점의 `isRunning` 가드가 `fn`·cleanup 안 전부 막음(`H-147`), 에러 시 사망 계약, 강한
|
||||
주인 GC — `spec.effect.luau` 9절.
|
||||
- **조립 세부**: `Observer.luau`가 인스턴스별 레지스트리 둘(`_Subscribed`/`_WeakSubscribed`)을
|
||||
임플에 매달고 `Effect.luau`가 `implFor`로 받는다(`H-99`의 "모듈 내부 export, 이름은 구현 시").
|
||||
`State.Init`이 `Observer.Init`을 당겨 `state:Observer`를 `ObserverImpl.new`로 위임.
|
||||
- **`onDestroying` 스텁의 자리**: `LifetimeHandle.luau`의 `Init`이 생명주기 4종과 같이 에러
|
||||
스텁을 설치(주입 op 목록의 소스는 `architecture.md` EngineOps 줄 — 스텁 파일 배치는 코드
|
||||
배치). mock은 `installLifetime`이 `inst.Destroying:Connect(fn)`으로 채운다.
|
||||
- 색인이 짚은 문서 긴장(`ss:1197` "Effect의 내부 Observer가 설치 발화를 `from == nil`로
|
||||
거른다" vs `H-164`)은 코드에서 모순 없음 — Observer 계약은 `nil` = 출처 없음이고, Effect의
|
||||
`fire`가 그걸 `Update` 못 하는 값으로 **자기 사정**으로 거를 뿐이다.
|
||||
- 테스트 작성 함정: `table.insert(t, nil)`은 길이를 안 늘린다 — `emitFrom == nil` 발화를 셀 땐
|
||||
래퍼로 기록할 것(한 번 오진했다).
|
||||
|
||||
**툴링 사실 둘**(설계 아님, 다음 단위가 알아야 함):
|
||||
- `require("@self/X")`는 **`init.luau`에서만** 통한다 — 일반 파일에서 `@self`는 그 파일
|
||||
자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`.
|
||||
|
|
|
|||
|
|
@ -2032,3 +2032,6 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
|
|||
놓음, 권고안 기각) / `H-168` `Ref<<T?>>()` / `H-170` 즉시 실패만 re-raise. **이어서 단위 2
|
||||
구현** — `EpochMap`/`State`(`Init(module)`+`implFor`)/`Source`/`Store` + `quad-types`
|
||||
최종형 타입(`ty11`) + spec 4개, ALL PASS. `H-176` 타입팩 deps 선언 기각(`...any`).
|
||||
**2026-08-29 새벽(컨테이너 이사 뒤)**: 단위 2 감사 4·5라운드 반영(`H-177` 포함), **단위 3
|
||||
구현** — `Observer`(레지스트리 소유, 네 진입점)/`Effect`(`rawRerun`·홀드·cleanup 세 자리·
|
||||
네 진입점 자기 본문)/`onDestroying` 스텁·mock, spec 17절 ALL PASS. `H-178`(`_` 접두) 기록.
|
||||
|
|
|
|||
|
|
@ -81,7 +81,19 @@
|
|||
- 아직 안 한 것: 감사 4라운드(0건 확인) → `/code-review high` → fable 탐사자(규약 §5) →
|
||||
"`round11.md` §4를 보라". §4엔 지금 새 문항 없음(단위 2 발견은 전부 ①: `H-176`, `H-177`).
|
||||
|
||||
## 다음 (재개 지점)
|
||||
## 2026-08-29 새벽 — 컨테이너 이사 뒤 재개 (사용자: *"내가 자는동안 많은 작업을 수행해도 좋아"*)
|
||||
|
||||
`./scripts/test.sh` ALL PASS·doc-check ERROR 0인 상태. 감사 4라운드부터 이어서
|
||||
(범위 `4169d7b`..`8aa13ed`, 각도: diff 정합성 수렴 확인), 그다음 `/code-review high`, 탐사자.
|
||||
- 이사 검증: `upstream/main`에서 26커밋 fast-forward, `pesde install`, ALL PASS. Studio MCP
|
||||
연결(`Place1`, Edit) — M5 전까지 안 씀.
|
||||
- 단위 2 감사 4·5라운드 반영(`442d800`, `67fb61e`: `implFor` 호출자 정정, `EpochMap`도 공유 잎,
|
||||
`_hold` 본문 닫힘, `H-174` 잎 목록은 파일 헤더가 소스, `InitState` → `State.Init` 표기).
|
||||
- **단위 3 구현** — `Observer.luau`(인스턴스별 임플 + 레지스트리 둘, 네 진입점 인라인)/
|
||||
`Effect.luau`(`rawRerun(force)`·`_rerunRequired` 홀드·`_cleanupRunning`·네 진입점 자기 본문)/
|
||||
`State:Observer` 위임/`LifetimeHandle`에 `onDestroying` 스텁/mock `onDestroying`/`quad-types`
|
||||
`Observer`·`EffectHandle`·`Quad.Effect`. spec.observer 8절·spec.effect 9절 ALL PASS, analyze 0.
|
||||
발견 `H-178`(사적 필드 `_` 접두, 기록만).
|
||||
|
||||
## 다음
|
||||
|
||||
단위 2·3 합쳐 끝 절차: 감사 루프(6라운드 진행 중) → `/code-review high` → 탐사자 → §4.
|
||||
그다음 단위 4(`GateNode` → `Blocker` + 탑레벨 마무리).
|
||||
|
|
|
|||
14
ROADMAP.md
14
ROADMAP.md
|
|
@ -481,11 +481,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
|
||||
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
|
||||
기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함
|
||||
- [ ] **`Observer.luau`** — `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()`
|
||||
- [x] **[2026-08-29 완료 — 단위 3]** **`Observer.luau`** — `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()`
|
||||
전역 레지스트리의 소유 모듈**. `EpochMap.luau`와 같은 이유로
|
||||
`State.luau`에 묻지 않는다(`Effect`/`GateNode`/leaf 핸들러가 전부 이
|
||||
레지스트리를 본다) — `base/architecture.md` 소스 트리, 7라운드 `H-99`
|
||||
- [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
|
||||
- [x] **[2026-08-29 완료 — 단위 3]** `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회
|
||||
실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver`
|
||||
판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` +
|
||||
**`:WeakSubscribe()`/`:WeakUnsubscribe()`**(Weak 쪽이 프리미티브,
|
||||
|
|
@ -505,7 +505,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
|
||||
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
|
||||
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
|
||||
- [ ] `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~
|
||||
- [x] **[2026-08-29 완료 — 단위 3]** `Effect(fn, ...deps)` — ~~**⚠️ 선행: `Blocker`의 기본 메커니즘**~~
|
||||
(**[2026-08-28 10라운드 `H-150`]** 선행 요구 **해소** — 생성자의 사적
|
||||
`Blocker`는 `canExecute`(지금은 `rawRerun` 진입, `H-159`)가 이미 같은 억제를 해서 한 번도
|
||||
판정에 닿지 않는 죽은 부품이라 제거됐다. `Blocker.luau`는 이제 `GateNode`/
|
||||
|
|
@ -536,7 +536,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
"동적 경로 가드" 절, 2026-08-14 열한 번째 세션)
|
||||
**⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것
|
||||
자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목).
|
||||
- [ ] **⭐ [2026-08-24 신설, 6라운드 / 2026-08-25 7라운드로 필드 재편]
|
||||
- [x] **[2026-08-29 완료 — 단위 3]** **⭐ [2026-08-24 신설, 6라운드 / 2026-08-25 7라운드로 필드 재편]
|
||||
`Effect` 구현 시 같이 만들 것** —
|
||||
**`handle._deps`**(`{[Ref|State] = fn|Observer}`, **강참조** — 옛
|
||||
`_observers`/`_refDeps`/`_refCallbacks` 셋이 여기로 통합됐다) ·
|
||||
|
|
@ -576,7 +576,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
|
||||
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
|
||||
누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약
|
||||
- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(value)` 게이트로
|
||||
- [x] **[2026-08-29 완료 — 단위 3]** Observer/Effect 이중 바인딩 금지 — `canBound(value)` 게이트로
|
||||
`:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도
|
||||
내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/source-state-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째
|
||||
세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime
|
||||
|
|
@ -634,7 +634,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
|
||||
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
|
||||
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
|
||||
- [ ] **[2026-08-28 부분 — 단위 1·2분(`Relate`/`Void`/`Ref`/`is*`/생명주기 4종/`Source`/`Store` + `State`·`Source`·`Store` 타입)은 `quad-types` `Quad`에 추가됨, `Effect`/`Blocker`는 각 단위에서]** **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
|
||||
- [ ] **[2026-08-28 부분 — 단위 1·2·3분(`Relate`/`Void`/`Ref`/`is*`/생명주기 4종+`onDestroying`/`Source`/`Store`/`Effect` + `State`·`Source`·`Store`·`Observer`·`EffectHandle` 타입)은 `quad-types` `Quad`에 추가됨, `Blocker`는 단위 4에서]** **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
|
||||
`quad-types`의 `Quad`에 **이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
|
||||
`Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형,
|
||||
2026-08-27 `H-128`) /
|
||||
|
|
@ -688,7 +688,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은
|
||||
에러 스텁이다). 커밋된 `quad-base/test/mock.luau`에 signal/Connection이
|
||||
이미 있으므로 그 `Destroying`을 그대로 쓰면 된다
|
||||
- [ ] mock 대상 테스트 — **전파 루프를 실제로 돌릴 것**(위 항목이 선행).
|
||||
- [x] **[2026-08-29 완료 — 단위 3: `spec.observer.luau` 2·`spec.effect.luau` 2가 mock Instance에 묶은 채 전파 루프를 실제로 돌린다(홀드 → 바인드 캐치업 → 발화 → 파괴)]** mock 대상 테스트 — **전파 루프를 실제로 돌릴 것**(위 항목이 선행).
|
||||
M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가면
|
||||
7라운드가 찾은 종류의 결함을 그대로 낳는다
|
||||
|
||||
|
|
|
|||
279
quad-base/src/Effect.luau
Normal file
279
quad-base/src/Effect.luau
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
--[[
|
||||
Effect — a leaf consumer with several dependencies (`State`/`Source`/`Ref`)
|
||||
and a cleanup contract. `Effect(fn, ...deps)`; `fn(self) -> ...cleanup`.
|
||||
`.claude/base/effect-plan.md`, transcribed as-is:
|
||||
- "확정 구조": the strong owner is ALWAYS the Effect (`_deps` map);
|
||||
registrations at the deps are weak (`ref:WeakCallback`, internal
|
||||
`observer:WeakSubscribe()`); the single fire gate is `canExecute`.
|
||||
- "의사코드 — 생성자": deps validated once (`select("#")`, nil → error,
|
||||
non State/Source/Ref → error, duplicates ignored — `H-70`); one
|
||||
closure PER DEP KIND (`onRefFire(_, ref)` / `onStateFire(_, _, from)`,
|
||||
`H-107`); `fire`: `from == nil` (the internal Observer's install fire)
|
||||
is dropped, else `_epochs:Update(from)` → `Rerun` (`H-151`: the only
|
||||
place `_epochs` is written; no `_blocker`, `H-150`); `_epochs` seeded
|
||||
`Sync`/`TrackFrom` by `isEpoch`; install = `_rerunRequired = true` +
|
||||
`rawRerun(self, true)`.
|
||||
- `_bindDestroying(inst)` (guard `isRunning`; `_unbindDestroying` first;
|
||||
injected `onDestroying(inst, fn)`; replay once if `_rerunRequired`),
|
||||
`_unbindDestroying()` (idempotent), `_consumeCleanup()` (read → clear →
|
||||
`_rerunRequired = true` → run under `_cleanupRunning`, `H-160`).
|
||||
- `rawRerun(self, force)` + public `Rerun()`: re-entrancy is deferred
|
||||
(`_pending`), an unexecutable state HOLDS the request (`H-159`), the
|
||||
loop has no death judgement (`H-147`); errors leave `_running` /
|
||||
`_cleanupRunning` set — that Effect is dead, by contract.
|
||||
- "`EffectHandle:Subscribe()`": four entry points of its OWN (not
|
||||
Observer's bodies, `H-144`), first line `isRunning` guard (`H-147`),
|
||||
`resubscribeTail` replays a held change after registration,
|
||||
`Unsubscribe` consumes the cleanup.
|
||||
|
||||
Assembly (`H-174`): `Effect.Init(module)` — gates (`module.canExecute` /
|
||||
`module.canBound`) and the injected `module.onDestroying` are read at call
|
||||
time; the registries come from `Observer.luau` (`H-99`), pulled in with
|
||||
`module:RunInit(Observer.Init)` (idempotent).
|
||||
]]
|
||||
|
||||
local Brand = require("./Brand")
|
||||
local EpochMap = require("./EpochMap")
|
||||
local Observer = require("./Observer")
|
||||
|
||||
local EffectBrand = Brand.EffectBrand
|
||||
local isState = Brand.isState
|
||||
local isSource = Brand.isSource
|
||||
local isRef = Brand.isRef
|
||||
local isEpoch = Brand.isEpoch
|
||||
|
||||
local WEAK_KEY_MT = { __mode = "k" }
|
||||
|
||||
local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any }
|
||||
|
||||
local function createImpl(module: any)
|
||||
module:RunInit(Observer.Init) -- registries live there (`H-99`)
|
||||
local ObserverImpl = Observer.implFor(module)
|
||||
local Subscribed = ObserverImpl._Subscribed
|
||||
local WeakSubscribed = ObserverImpl._WeakSubscribed
|
||||
|
||||
local Impl = {}
|
||||
Impl.__index = Impl
|
||||
|
||||
-- ── local helpers, declared before their callers ─────────────────
|
||||
local function isRunning(self: any): boolean -- predicate only; `error` stays in each body (level 2)
|
||||
return self._running == true or self._cleanupRunning == true
|
||||
end
|
||||
|
||||
-- The body. `force` means exactly one thing: "may be called ignoring
|
||||
-- canExecute" — the install from the constructor, not yet bound.
|
||||
local function rawRerun(self: any, force: boolean)
|
||||
if self._running then
|
||||
self._pending = true -- re-entered while running → deferred re-run
|
||||
return
|
||||
end
|
||||
if self._cleanupRunning or (not force and not module.canExecute(self)) then
|
||||
self._rerunRequired = true -- `H-159`/`H-160`: unexecutable (cleanup running / unbound / dead)
|
||||
return -- → HOLD, replayed once on the next bind/subscribe
|
||||
end
|
||||
self._running = true
|
||||
repeat
|
||||
self._pending = false
|
||||
self:_consumeCleanup() -- sets `_rerunRequired = true`
|
||||
self._rerunRequired = false -- the ONLY place the flag goes down: fn really runs
|
||||
self._cleanup = self.fn(self)
|
||||
until not self._pending
|
||||
self._running = false
|
||||
end
|
||||
|
||||
-- After registration: replay a held change once (`H-144`; `Refresh` gone, `H-151`).
|
||||
local function resubscribeTail(self: any)
|
||||
if self._rerunRequired then
|
||||
self:Rerun() -- registered now, so the public gate passes
|
||||
end
|
||||
end
|
||||
|
||||
-- ── hooks called by `bindLifetime` / `unbindLifetime` ─────────────
|
||||
function Impl._bindDestroying(self: any, inst: any)
|
||||
if isRunning(self) then
|
||||
error("Effect: cannot bind an Effect from inside its own fn or cleanup", 2) -- `H-147` (A)
|
||||
end
|
||||
self:_unbindDestroying() -- rebind (portal remount): drop the old connection first — idempotent
|
||||
-- (1) leaf dies → cleanup exactly once. The one hook point (`LP-2`).
|
||||
self._destroyConn = module.onDestroying(inst, function() -- injected op, read at call time
|
||||
self:_unbindDestroying()
|
||||
self:_consumeCleanup()
|
||||
end)
|
||||
-- (2) catch-up: a change held while unexecutable is replayed once. gcconn is
|
||||
-- connected by now, so the public Rerun's gate passes.
|
||||
if self._rerunRequired then
|
||||
self:Rerun()
|
||||
end
|
||||
end
|
||||
|
||||
function Impl._unbindDestroying(self: any)
|
||||
if self._destroyConn then
|
||||
self._destroyConn:Disconnect()
|
||||
self._destroyConn = nil
|
||||
end
|
||||
-- Ref callbacks and the internal Observers stay (weakly registered; the gate
|
||||
-- silences them). `_cleanup` is NOT run here.
|
||||
end
|
||||
|
||||
-- read → clear → run. `_cleanup`'s presence is not "installed" — the flag is.
|
||||
function Impl._consumeCleanup(self: any)
|
||||
local c: any = self._cleanup
|
||||
self._cleanup = nil
|
||||
self._rerunRequired = true -- consumed = must install again at the next chance
|
||||
if c ~= nil then
|
||||
self._cleanupRunning = true -- `H-160`: cleanup runs outside `_running` in two of its three sites
|
||||
(c :: () -> ())()
|
||||
self._cleanupRunning = false
|
||||
end
|
||||
end
|
||||
|
||||
function Impl.Rerun(self: any): any -- public, no args — always gated
|
||||
rawRerun(self, false)
|
||||
return self
|
||||
end
|
||||
|
||||
-- ── four entry points — EffectHandle's OWN bodies (`H-144` (b)) ────
|
||||
function Impl.WeakSubscribe(self: any): any
|
||||
if isRunning(self) then
|
||||
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
|
||||
end
|
||||
if not module.canBound(self) then
|
||||
error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2)
|
||||
end
|
||||
self.Subscribed = true
|
||||
WeakSubscribed[self] = true
|
||||
resubscribeTail(self)
|
||||
return self
|
||||
end
|
||||
|
||||
function Impl.Subscribe(self: any): any
|
||||
if isRunning(self) then
|
||||
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
|
||||
end
|
||||
if not module.canBound(self) then
|
||||
error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2)
|
||||
end
|
||||
self.Subscribed = true
|
||||
WeakSubscribed[self] = true
|
||||
Subscribed[self] = true -- the strong keep is up BEFORE the tail runs
|
||||
resubscribeTail(self)
|
||||
return self
|
||||
end
|
||||
|
||||
function Impl.WeakUnsubscribe(self: any): any -- lenient (`H-133`) — does not touch the cleanup
|
||||
if isRunning(self) then
|
||||
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
|
||||
end
|
||||
if Subscribed[self] ~= nil then
|
||||
error("Effect: subscribed strongly; use :Unsubscribe()", 2)
|
||||
end
|
||||
WeakSubscribed[self] = nil
|
||||
self.Subscribed = false
|
||||
return self
|
||||
end
|
||||
|
||||
function Impl.Unsubscribe(self: any): any
|
||||
if isRunning(self) then
|
||||
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
|
||||
end
|
||||
if Subscribed[self] == nil then -- gate FIRST: never strongly subscribed → error, cleanup untouched
|
||||
error("Effect: not subscribed strongly; use :WeakUnsubscribe()", 2)
|
||||
end
|
||||
Subscribed[self] = nil
|
||||
WeakSubscribed[self] = nil
|
||||
self.Subscribed = false -- blocks future re-runs
|
||||
self:_consumeCleanup() -- only when the gate passed: the last cleanup exactly once
|
||||
return self
|
||||
end
|
||||
|
||||
-- ── constructor ──────────────────────────────────────────────────
|
||||
local function Effect(fn: any, ...: any): any
|
||||
if type(fn) ~= "function" then
|
||||
error("Effect: fn must be a function", 2)
|
||||
end
|
||||
local self = setmetatable({
|
||||
fn = fn,
|
||||
_deps = {}, -- strong owner of every registration
|
||||
_epochs = EpochMap(),
|
||||
_cleanup = nil,
|
||||
_rerunRequired = false,
|
||||
_running = false,
|
||||
_pending = false,
|
||||
_cleanupRunning = false,
|
||||
_destroyConn = nil,
|
||||
Subscribed = false,
|
||||
}, Impl)
|
||||
EffectBrand:register(self)
|
||||
|
||||
-- (0) deps validation — once, in the constructor. `select("#")` so a nil hole
|
||||
-- does not silently truncate the list.
|
||||
local seen = {}
|
||||
for i = 1, select("#", ...) do
|
||||
local d = (select(i, ...))
|
||||
if d == nil then
|
||||
error(`Effect: dep #{i} is nil`, 2)
|
||||
end
|
||||
if not (isState(d) or isSource(d) or isRef(d)) then
|
||||
error(`Effect: dep #{i} is not a State/Source/Ref`, 2)
|
||||
end
|
||||
seen[d] = true -- duplicates are silently ignored (dedup lives in `_deps`/`_epochs`)
|
||||
end
|
||||
|
||||
-- (1) register deps — HERE, ONCE. One closure per dep kind (`H-107`).
|
||||
local function fire(from: any)
|
||||
if from == nil then
|
||||
return -- the internal Observer's install fire (no source) cannot `Update(nil)`
|
||||
end
|
||||
if self._epochs:Update(from) then -- `H-151`: the only place `_epochs` is written
|
||||
self:Rerun() -- unexecutable → rawRerun holds it (`H-159`)
|
||||
end
|
||||
end
|
||||
local function onRefFire(_value: any, ref: any)
|
||||
fire(ref) -- Ref: the 2nd arg is the source
|
||||
end
|
||||
local function onStateFire(_targetState: any, _observer: any, from: any)
|
||||
fire(from) -- Observer: the 3rd arg is the source
|
||||
end
|
||||
for d in pairs(seen) do
|
||||
if isRef(d) then
|
||||
self._deps[d] = onRefFire -- strong owner = Effect
|
||||
d:WeakCallback(onRefFire) -- the Ref side is weak
|
||||
else
|
||||
local o = d:Observer(onStateFire)
|
||||
self._deps[d] = o -- strong owner = Effect
|
||||
o:WeakSubscribe() -- the global registry side is weak
|
||||
end
|
||||
-- seeding by "is it an Epoch" — Source/Ref are, State is not (§4 rule)
|
||||
if isEpoch(d) then
|
||||
self._epochs:Sync(d)
|
||||
else
|
||||
self._epochs:TrackFrom(d._valueEpochMap)
|
||||
end
|
||||
end
|
||||
|
||||
-- (2) install — once, immediately; cannot be deferred to bind.
|
||||
self._rerunRequired = true
|
||||
rawRerun(self, true)
|
||||
return self
|
||||
end
|
||||
|
||||
module.Effect = Effect
|
||||
return Impl
|
||||
end
|
||||
|
||||
local function Init(module: any)
|
||||
implByModule[module] = createImpl(module)
|
||||
end
|
||||
|
||||
local function implFor(module: any)
|
||||
local impl = implByModule[module]
|
||||
if impl == nil then
|
||||
error("Effect: Effect.Init(module) has not run for this quad instance", 1)
|
||||
end
|
||||
return impl
|
||||
end
|
||||
|
||||
return {
|
||||
Init = Init,
|
||||
implFor = implFor,
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@
|
|||
|
||||
They are flat top-level functions, not `LifetimeHandle.bind(...)` —
|
||||
first-class primitives handler authors call directly, like `isState`.
|
||||
The `onDestroying(inst, fn)` engine op stub is installed here too (M2 unit
|
||||
3) — it belongs to the same backend injection and `Effect` needs it.
|
||||
]]
|
||||
|
||||
local function notInstalled(name: string): (...any) -> ...any
|
||||
|
|
@ -39,6 +41,11 @@ local function Init(module: any)
|
|||
module.unbindLifetime = notInstalled("unbindLifetime")
|
||||
module.canBound = notInstalled("canBound")
|
||||
module.canExecute = notInstalled("canExecute")
|
||||
-- `onDestroying(inst, fn): Connection` — the injected engine op `Effect._bindDestroying`
|
||||
-- calls (`architecture.md` EngineOps line: a hook, not a manipulation, so no
|
||||
-- composition fallback — missing means a clear error). The same backend that
|
||||
-- fills the four above fills this one; until then, the same loud stub.
|
||||
module.onDestroying = notInstalled("onDestroying")
|
||||
end
|
||||
|
||||
return Init
|
||||
|
|
|
|||
161
quad-base/src/Observer.luau
Normal file
161
quad-base/src/Observer.luau
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
--[[
|
||||
Observer — the leaf subscriber a `State` fires, and the owner of the two
|
||||
global subscription registries (`Subscribed` strong / `WeakSubscribed`
|
||||
weak-key) that `Effect.luau` shares (`H-99`).
|
||||
|
||||
Assembly (`H-174`): `Observer.Init(module)` builds one implementation (and
|
||||
one registry pair) per quad instance; the lifetime gates are read as
|
||||
`module.canExecute(self)` / `module.canBound(self)` AT CALL TIME — never
|
||||
captured at init, the backend overwrites those fields after `New()`.
|
||||
`State.luau` reaches the constructor through `implFor(module)`.
|
||||
|
||||
Sources, transcribed as-is:
|
||||
- `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드":
|
||||
`Observer:_receive(from)` (`canExecute` → `fn(self._state, self, from)`,
|
||||
else hold `_rerunRequired = true`, `H-159`), `Observer:_catchUp()`
|
||||
(the ONLY place a held change is replayed — with `from = nil`),
|
||||
`State:Observer(fn)` constructor ORDER (fn once → flag down → join
|
||||
`_subs`; `H-159`/`H-164`), `observer._state` strong ref (`_hold`
|
||||
equivalent, `H-110`); "`state:Observer(fn)`" section: fires once on
|
||||
registration, `fn(targetState, self, emitFrom?)` — `nil` means
|
||||
"no source: install or catch-up, read the value" (`H-164`); no-arg
|
||||
`state:Observer()` = always-observe utility.
|
||||
- `.claude/base/lifecycle-pattern.md` "(2) 전역 경로": the four entry
|
||||
points — `WeakSubscribe` is the primitive (sets `.Subscribed = true`
|
||||
too, `H-111`), `Subscribe` is INLINED (no delegation, `H-149`),
|
||||
`WeakUnsubscribe` is lenient (`H-133`) except when a strong keep
|
||||
exists, `Unsubscribe` is strict and clears both tables. Each entry
|
||||
point runs its own `canBound` gate exactly once and throws with
|
||||
`level 2` from its own body (`H-104`).
|
||||
|
||||
An Observer is NOT an `Epoch` and carries no epoch bookkeeping — it only
|
||||
forwards the source it received. `EffectHandle` does NOT reuse these
|
||||
bodies (heterogeneous types; `conventions.md` 설계 원칙).
|
||||
]]
|
||||
|
||||
local Brand = require("./Brand")
|
||||
|
||||
local ObserverBrand = Brand.ObserverBrand
|
||||
|
||||
local WEAK_KEY_MT = { __mode = "k" }
|
||||
|
||||
local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any }
|
||||
|
||||
local function createImpl(module: any)
|
||||
local Impl = {}
|
||||
Impl.__index = Impl
|
||||
|
||||
-- Registries — owned here, shared with `Effect.luau` (`H-99`). Per quad
|
||||
-- instance because the gates that read `.Subscribed` are per instance.
|
||||
local Subscribed = {} :: { [any]: true } -- strong: keeps the handle alive
|
||||
local WeakSubscribed = setmetatable({}, WEAK_KEY_MT) :: { [any]: true } -- membership only
|
||||
Impl._Subscribed = Subscribed
|
||||
Impl._WeakSubscribed = WeakSubscribed
|
||||
|
||||
-- ── EmitReceive ──────────────────────────────────────────────────
|
||||
function Impl._receive(self: any, from: any)
|
||||
if module.canExecute(self) then -- read at fire time (`H-174`)
|
||||
self.fn(self._state, self, from) -- (receiver State, Observer itself, source)
|
||||
else
|
||||
self._rerunRequired = true -- `H-159`: change before binding is held — replayed once when bound
|
||||
end
|
||||
end
|
||||
|
||||
-- Catch-up: bind/subscribe replays a held change once, with no source.
|
||||
-- The only caller set: `bindLifetime`, `Subscribe`, `WeakSubscribe`.
|
||||
function Impl._catchUp(self: any)
|
||||
if self._rerunRequired then
|
||||
self._rerunRequired = false
|
||||
self.fn(self._state, self, nil)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── four entry points (`lifecycle-pattern.md` (2)) ───────────────
|
||||
function Impl.WeakSubscribe(self: any): any
|
||||
if not module.canBound(self) then -- same gate as bindLifetime (shared isBoundAlive)
|
||||
error(
|
||||
if self.Subscribed then "Observer: already subscribed" else "Observer: already bound to an Instance",
|
||||
2
|
||||
)
|
||||
end
|
||||
self.Subscribed = true -- `H-111`: the weak path raises the flag too
|
||||
WeakSubscribed[self] = true
|
||||
self:_catchUp() -- `H-159`: held change once, symmetric with bind
|
||||
return self
|
||||
end
|
||||
|
||||
function Impl.WeakUnsubscribe(self: any): any
|
||||
-- A strong keep left behind would make a half-released, never-GC'd handle: fail fast.
|
||||
if Subscribed[self] ~= nil then
|
||||
error("Observer: subscribed strongly; use :Unsubscribe()", 2)
|
||||
end
|
||||
-- `H-133`: that is the whole guard — never subscribed / already weakly released pass silently.
|
||||
WeakSubscribed[self] = nil
|
||||
self.Subscribed = false
|
||||
return self
|
||||
end
|
||||
|
||||
function Impl.Subscribe(self: any): any
|
||||
-- `H-149`: NOT delegated to WeakSubscribe — level 2 must point at the user's call,
|
||||
-- and colon delegation would resolve to a subtype's override.
|
||||
if not module.canBound(self) then
|
||||
error(
|
||||
if self.Subscribed then "Observer: already subscribed" else "Observer: already bound to an Instance",
|
||||
2
|
||||
)
|
||||
end
|
||||
self.Subscribed = true
|
||||
WeakSubscribed[self] = true
|
||||
Subscribed[self] = true -- just one more layer: the strong keep
|
||||
self:_catchUp()
|
||||
return self
|
||||
end
|
||||
|
||||
function Impl.Unsubscribe(self: any): any
|
||||
-- Symmetric with WeakUnsubscribe's guard: release through the path you subscribed by.
|
||||
if Subscribed[self] == nil then
|
||||
error("Observer: not subscribed strongly; use :WeakUnsubscribe()", 2)
|
||||
end
|
||||
Subscribed[self] = nil
|
||||
WeakSubscribed[self] = nil -- both tables, directly (`H-149`: no delegation)
|
||||
self.Subscribed = false
|
||||
return self
|
||||
end
|
||||
|
||||
-- ── constructor — called by `State:Observer(fn)`; ORDER IS THE CONTRACT ──
|
||||
local function alwaysObserve(targetState: any)
|
||||
targetState:Get() -- no-arg `state:Observer()` = "always observe" utility
|
||||
end
|
||||
|
||||
function Impl.new(state: any, fn: any): any
|
||||
local o: any = setmetatable({
|
||||
fn = fn or alwaysObserve,
|
||||
_state = state, -- strong: the handle holds its upstream (`_hold` equivalent, `H-110`)
|
||||
_rerunRequired = true,
|
||||
}, Impl)
|
||||
ObserverBrand:register(o)
|
||||
o.fn(state, o, nil) -- (1) fire once at registration — no source (`nil`)
|
||||
o._rerunRequired = false -- (2) the install fire lowers the flag
|
||||
state._subs[o] = true -- (3) only THEN join the subscriber set — reversed, (1) Setting its own
|
||||
return o -- State would land in this Observer's `_receive` and raise the flag
|
||||
end
|
||||
|
||||
return Impl
|
||||
end
|
||||
|
||||
local function Init(module: any)
|
||||
implByModule[module] = createImpl(module)
|
||||
end
|
||||
|
||||
local function implFor(module: any)
|
||||
local impl = implByModule[module]
|
||||
if impl == nil then
|
||||
error("Observer: Observer.Init(module) has not run for this quad instance", 1) -- invariant; callers pull it in first
|
||||
end
|
||||
return impl
|
||||
end
|
||||
|
||||
return {
|
||||
Init = Init,
|
||||
implFor = implFor,
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
|
||||
local Brand = require("./Brand")
|
||||
local EpochMap = require("./EpochMap")
|
||||
local Observer = require("./Observer")
|
||||
local QuadTypes = require("../roblox_packages/quad_types")
|
||||
|
||||
export type StateData<T> = QuadTypes.StateData<T>
|
||||
|
|
@ -56,6 +57,9 @@ local WEAK_KEY_MT = { __mode = "k" }
|
|||
local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any }
|
||||
|
||||
local function createImpl(module: any)
|
||||
module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`)
|
||||
local ObserverImpl = Observer.implFor(module)
|
||||
|
||||
local Impl = {}
|
||||
Impl.__index = Impl
|
||||
Impl._module = module -- read lazily by later units, never captured
|
||||
|
|
@ -176,6 +180,15 @@ local function createImpl(module: any)
|
|||
return newNode(deps, fn) -- one node: edges only, no combining node
|
||||
end
|
||||
|
||||
-- `state:Observer(fn?)` — leaf subscriber, fires once at registration
|
||||
-- (`source-state-plan.md` "`state:Observer(fn)`"). Body lives in `Observer.luau`.
|
||||
function Impl.Observer(self: any, fn: any?): any
|
||||
if fn ~= nil and type(fn) ~= "function" then
|
||||
error("State: Observer fn must be a function (or nil for the always-observe utility)", 2)
|
||||
end
|
||||
return ObserverImpl.new(self, fn)
|
||||
end
|
||||
|
||||
function Impl.Apply(self: any, factory: any): any
|
||||
if type(factory) == "function" then
|
||||
return (factory :: any)(self)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ local InitLifetimeHandle = require("@self/LifetimeHandle")
|
|||
local State = require("@self/State")
|
||||
local InitSource = require("@self/Source")
|
||||
local InitStore = require("@self/Store")
|
||||
local Observer = require("@self/Observer")
|
||||
local Effect = require("@self/Effect")
|
||||
|
||||
type Quad = QuadTypes.Quad
|
||||
|
||||
|
|
@ -75,6 +77,8 @@ local function New(): Quad
|
|||
module:RunInit(State.Init)
|
||||
module:RunInit(InitSource)
|
||||
module:RunInit(InitStore)
|
||||
module:RunInit(Observer.Init) -- 단위 3 — 레지스트리 둘의 소유 모듈(`H-99`)
|
||||
module:RunInit(Effect.Init)
|
||||
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
|
||||
|
||||
return module
|
||||
|
|
|
|||
|
|
@ -375,10 +375,20 @@ local function installLifetime(quad: any)
|
|||
BindData:SetWeak(value, "gcconn", nil)
|
||||
end
|
||||
|
||||
-- 주입 op `onDestroying(inst, fn): Connection` — quad-roblox는 `inst.Destroying:Connect(fn)`
|
||||
-- 한 줄(`architecture.md` EngineOps). mock은 그 시그널을 그대로 쓴다.
|
||||
local function onDestroying(inst: any, fn: () -> ()): any
|
||||
if not isMockInstance(inst) then
|
||||
error("onDestroying: inst is not a mock instance", 2)
|
||||
end
|
||||
return inst.Destroying:Connect(fn)
|
||||
end
|
||||
|
||||
quad.bindLifetime = bindLifetime
|
||||
quad.unbindLifetime = unbindLifetime
|
||||
quad.canBound = canBound
|
||||
quad.canExecute = canExecute
|
||||
quad.onDestroying = onDestroying
|
||||
end
|
||||
|
||||
return {
|
||||
|
|
|
|||
281
quad-base/test/spec.effect.luau
Normal file
281
quad-base/test/spec.effect.luau
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
--[[
|
||||
Effect 계약 — `.claude/base/effect-plan.md` "확정 구조" / "의사코드 — 생성자" / `_bindDestroying` /
|
||||
`_unbindDestroying` / `_consumeCleanup` / `rawRerun`+`Rerun` / "`EffectHandle:Subscribe()`" /
|
||||
"`Effect(fn, ...deps)`". `H-70`/`H-107`/`H-144`/`H-147`/`H-150`/`H-151`/`H-159`/`H-160`.
|
||||
]]
|
||||
|
||||
local Quad = require("../src")
|
||||
local mock = require("./mock")
|
||||
local QuadTypes = require("../roblox_packages/quad_types")
|
||||
|
||||
type EffectHandle = QuadTypes.EffectHandle
|
||||
|
||||
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
|
||||
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
|
||||
|
||||
local quad = Quad.New()
|
||||
mock.installLifetime(quad)
|
||||
local Source, Ref, Effect = quad.Source, quad.Ref, quad.Effect
|
||||
local Instance = mock.Instance
|
||||
|
||||
print("=== 1. 생성 — deps 검증(H-70), 설치 즉시 1회(force), _deps 강한 주인, 브랜드 ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local r = Ref(1)
|
||||
local runs = 0
|
||||
local e = Effect(function(self)
|
||||
runs += 1
|
||||
assert(quad.isEffect(self), "fn receives the handle")
|
||||
end, s, r, s) -- duplicate dep is ignored
|
||||
assert(runs == 1, "installed once on creation, before any binding (force)")
|
||||
assert(quad.isEffect(e) and not quad.isObserver(e), "brand")
|
||||
local depCount = 0
|
||||
for _ in pairs((e :: any)._deps) do
|
||||
depCount += 1
|
||||
end
|
||||
assert(depCount == 2, "duplicate dep collapsed; one entry per dep")
|
||||
local bads: { { value: any, message: string } } = {
|
||||
{ value = nil, message = "is nil" },
|
||||
{ value = {}, message = "not a State/Source/Ref" },
|
||||
{ value = 5, message = "not a State/Source/Ref" },
|
||||
}
|
||||
for _, bad in bads do
|
||||
local ok, err = pcall(function()
|
||||
Effect(function() end, s, bad.value)
|
||||
end)
|
||||
assert(not ok and string.find(tostring(err), bad.message, 1, true) ~= nil, "dep #2 " .. bad.message .. ": " .. tostring(err))
|
||||
assert(string.find(tostring(err), "spec.effect.luau", 1, true) ~= nil, "level 2")
|
||||
end
|
||||
assert(not pcall(function()
|
||||
Effect(5 :: any)
|
||||
end), "fn must be a function")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 2. 발화 — 안 묶이면 홀드(H-159), 묶이면 dep 변경마다 1회; Ref dep도 같은 경로 ===")
|
||||
do
|
||||
local s, r = Source(1), Ref(1)
|
||||
local runs = 0
|
||||
local e = Effect(function()
|
||||
runs += 1
|
||||
end, s, r)
|
||||
s:Set(2)
|
||||
r:Set(2)
|
||||
assert(runs == 1 and (e :: any)._rerunRequired == true, "unbound: changes held, not run")
|
||||
local inst = Instance.new("Frame")
|
||||
quad.bindLifetime(inst, e)
|
||||
assert(runs == 2 and (e :: any)._rerunRequired == false, "bind replays the hold exactly once (not per change)")
|
||||
s:Set(3)
|
||||
assert(runs == 3, "State dep fires")
|
||||
r:Set(3)
|
||||
assert(runs == 4, "Ref dep fires")
|
||||
quad.unbindLifetime(e)
|
||||
s:Set(4)
|
||||
assert(runs == 4, "unbound again: silent (held)")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 3. 다이아몬드 — 공통 상류가 한 번 바뀌면 fn은 한 번 (H-107: 클로저를 나눠도 dedup은 _epochs) ===")
|
||||
do
|
||||
local a = Source(1)
|
||||
local b = a:Compute(function(x): number
|
||||
return x:Get() + 1
|
||||
end)
|
||||
local c = a:Compute(function(x): number
|
||||
return x:Get() + 2
|
||||
end)
|
||||
local runs = 0
|
||||
local e = Effect(function()
|
||||
runs += 1
|
||||
end, b, c):Subscribe()
|
||||
runs = 0
|
||||
a:Set(5)
|
||||
assert(runs == 1, "one Set → one run, got " .. runs)
|
||||
e:Unsubscribe()
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 4. cleanup — fn의 반환이 cleanup, 다음 fn 전에 1회, Unsubscribe가 소진, 파괴가 소진 ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local log: { string } = {}
|
||||
local e = Effect(function()
|
||||
table.insert(log, "run")
|
||||
return function()
|
||||
table.insert(log, "clean")
|
||||
end
|
||||
end, s):Subscribe()
|
||||
s:Set(2)
|
||||
assert(table.concat(log, ",") == "run,clean,run", "cleanup runs right before the next fn")
|
||||
e:Unsubscribe()
|
||||
assert(table.concat(log, ",") == "run,clean,run,clean", "Unsubscribe consumes the cleanup exactly once")
|
||||
assert((e :: any)._rerunRequired == true, "consumed = must reinstall next time")
|
||||
-- 재구독 → 꼬리가 1회 재실행(H-144)
|
||||
e:Subscribe()
|
||||
assert(table.concat(log, ",") == "run,clean,run,clean,run", "resubscribe replays once")
|
||||
e:Unsubscribe()
|
||||
-- leaf 파괴 → Destroying 콜백이 cleanup 소진
|
||||
log = {}
|
||||
local inst = Instance.new("Frame")
|
||||
local e2 = Effect(function()
|
||||
table.insert(log, "run")
|
||||
return function()
|
||||
table.insert(log, "clean")
|
||||
end
|
||||
end, s)
|
||||
quad.bindLifetime(inst, e2)
|
||||
assert(table.concat(log, ",") == "run", "bound: no replay (nothing held)")
|
||||
inst:Destroy()
|
||||
assert(table.concat(log, ",") == "run,clean", "Destroying consumed the cleanup")
|
||||
assert((e2 :: any)._destroyConn == nil, "connection dropped")
|
||||
quad.unbindLifetime(e2) -- 죽은 뒤에도 안전(no-op)
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 5. cleanup 없는 fn — 바인드/재마운트마다 재실행되지 않는다 (_rerunRequired 판정, H-58 회귀 방지) ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local runs = 0
|
||||
local e = Effect(function()
|
||||
runs += 1
|
||||
end, s)
|
||||
local a, b = Instance.new("Frame"), Instance.new("Frame")
|
||||
quad.bindLifetime(a, e)
|
||||
quad.unbindLifetime(e)
|
||||
quad.bindLifetime(b, e)
|
||||
assert(runs == 1, "no held change → no re-run on (re)bind, got " .. runs)
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 6. 재진입 — fn 안의 dep:Set은 지연 재실행(_pending); fn/cleanup은 자기 구독을 못 바꾼다 (H-147) ===")
|
||||
do
|
||||
local s = Source(0)
|
||||
local runs = 0
|
||||
local e: any
|
||||
e = Effect(function()
|
||||
runs += 1
|
||||
if s:Get() == 0 then
|
||||
s:Set(1) -- re-entrant change during fn
|
||||
end
|
||||
end, s):Subscribe()
|
||||
assert(runs == 2 and s:Get() == 1, "deferred re-run happened once after fn returned");
|
||||
(e :: any):Unsubscribe()
|
||||
|
||||
local caught: { string } = {}
|
||||
local e2: any
|
||||
e2 = Effect(function(self)
|
||||
for _, name in { "Subscribe", "WeakSubscribe", "Unsubscribe", "WeakUnsubscribe" } do
|
||||
local ok = pcall(function()
|
||||
(self :: any)[name](self)
|
||||
end)
|
||||
if not ok then
|
||||
table.insert(caught, name)
|
||||
end
|
||||
end
|
||||
return function()
|
||||
local ok = pcall(function()
|
||||
e2:Subscribe()
|
||||
end)
|
||||
if not ok then
|
||||
table.insert(caught, "cleanup:Subscribe")
|
||||
end
|
||||
end
|
||||
end, s)
|
||||
assert(#caught == 4, "all four entry points refuse inside fn, got " .. table.concat(caught, ","))
|
||||
e2:Subscribe()
|
||||
e2:Unsubscribe() -- runs the cleanup → its Subscribe attempt must error too
|
||||
assert(caught[#caught] == "cleanup:Subscribe", "entry points refuse inside cleanup too")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 7. 네 진입점 — 게이트·관대/엄격·강한 킵 뒤 꼬리, canExecute 연동 ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local runs = 0
|
||||
local e = Effect(function()
|
||||
runs += 1
|
||||
end, s)
|
||||
assert(quad.canExecute(e) == false, "unbound")
|
||||
s:Set(2) -- held
|
||||
assert(e:WeakSubscribe() == e and e.Subscribed == true and quad.canExecute(e) == true, "weak primitive")
|
||||
assert(runs == 2, "tail replayed the hold")
|
||||
assert(not pcall(function()
|
||||
e:Subscribe()
|
||||
end), "already subscribed")
|
||||
e:WeakUnsubscribe()
|
||||
assert((e :: any).Subscribed == false, "lenient release")
|
||||
e:WeakUnsubscribe() -- silent
|
||||
e:Subscribe()
|
||||
assert(not pcall(function()
|
||||
e:WeakUnsubscribe()
|
||||
end), "strong keep present → error")
|
||||
e:Unsubscribe()
|
||||
assert(not pcall(function()
|
||||
e:Unsubscribe()
|
||||
end), "strict")
|
||||
local ok, err = pcall(function()
|
||||
quad.bindLifetime(Instance.new("Frame"), Effect(function() end, s):Subscribe())
|
||||
end)
|
||||
assert(not ok and string.find(tostring(err), "already subscribed", 1, true) ~= nil, "bindLifetime shares the gate: " .. tostring(err))
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 8. error — fn이 던지면 그 Effect는 죽는다(_running 남음), 이후 재진입 전부 차단 ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local e = Effect(function()
|
||||
if s:Get() == 2 then
|
||||
error("boom")
|
||||
end
|
||||
end, s):Subscribe()
|
||||
local ok = pcall(function()
|
||||
s:Set(2)
|
||||
end)
|
||||
assert(not ok, "the error propagated out of Set (no pcall inside quad)")
|
||||
assert((e :: any)._running == true, "dead: _running stays set")
|
||||
s:Set(1) -- 발화는 _pending으로만 기록되고 fn은 안 돈다
|
||||
assert(not pcall(function()
|
||||
e:Unsubscribe()
|
||||
end), "entry points are blocked on a dead handle")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 9. GC — Effect가 강한 주인: 핸들을 놓으면 Ref 콜백·내부 Observer도 같이 사라진다 ===")
|
||||
do
|
||||
local s, r = Source(1), Ref(1)
|
||||
local weak = setmetatable({}, { __mode = "v" }) :: { any }
|
||||
local function make()
|
||||
weak[1] = Effect(function() end, s, r)
|
||||
end
|
||||
make()
|
||||
collectgarbage()
|
||||
collectgarbage()
|
||||
assert(weak[1] == nil, "an unowned Effect is collectable")
|
||||
assert(next(r.WeakCallbacks) == nil, "its Ref callback went with it")
|
||||
assert(next((s :: any)._subs) == nil, "and its internal Observer left the State's subscriber set")
|
||||
-- 강한 구독은 살려둔다
|
||||
local calls = 0
|
||||
local function makeStrong()
|
||||
Effect(function()
|
||||
calls += 1
|
||||
end, s):Subscribe()
|
||||
end
|
||||
makeStrong()
|
||||
collectgarbage()
|
||||
collectgarbage()
|
||||
calls = 0
|
||||
s:Set(9)
|
||||
assert(calls == 1, "strongly subscribed Effect survives with no references")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== ALL PASS ===")
|
||||
210
quad-base/test/spec.observer.luau
Normal file
210
quad-base/test/spec.observer.luau
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
--[[
|
||||
Observer 계약 — `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드"(`_receive`/`_catchUp`/
|
||||
생성자 순서) / "`state:Observer(fn)`" 절(등록 즉시 1회, `fn(targetState, self, emitFrom?)`, `nil` 계약),
|
||||
`.claude/base/lifecycle-pattern.md` "(2) 전역 경로"(네 진입점) / "(4) 실제 호출부"(`canExecute` 게이팅).
|
||||
]]
|
||||
|
||||
local Quad = require("../src")
|
||||
local mock = require("./mock")
|
||||
local QuadTypes = require("../roblox_packages/quad_types")
|
||||
|
||||
type Observer = QuadTypes.Observer
|
||||
|
||||
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
|
||||
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
|
||||
|
||||
local quad = Quad.New()
|
||||
mock.installLifetime(quad)
|
||||
local Source = quad.Source
|
||||
local Instance = mock.Instance
|
||||
|
||||
print("=== 1. 생성 — 등록 즉시 1회(emitFrom = nil), _state 강참조, 브랜드, 순서(fn → _subs) ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local calls: { any } = {}
|
||||
local o = s:Observer(function(target, self, from)
|
||||
table.insert(calls, { target = target, self = self, from = from })
|
||||
end)
|
||||
assert(#calls == 1 and calls[1].target == s and calls[1].self == o and calls[1].from == nil, "install fire: (state, observer, nil)")
|
||||
assert(quad.isObserver(o) and not quad.isState(o) and not quad.isEpoch(o), "brand")
|
||||
assert((o :: any)._state == s, "observer holds its State strongly")
|
||||
assert((s :: any)._subs[o] == true, "joined the subscriber set after the install fire")
|
||||
assert((o :: any)._rerunRequired == false, "install fire lowered the hold flag")
|
||||
-- 설치 발화가 자기 State를 Set해도 플래그가 서지 않는다(순서 계약)
|
||||
local s2 = Source(0)
|
||||
local o2 = s2:Observer(function(target, _, from)
|
||||
if from == nil and target:Get() == 0 then
|
||||
(target :: any):Set(1)
|
||||
end
|
||||
end)
|
||||
assert((o2 :: any)._rerunRequired == false, "fn → _subs order: the install fire's own Set did not raise the flag")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 2. _receive — canExecute 게이팅: 안 묶이면 홀드, 묶이면 fn(state, self, from) ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local calls: { { from: any } } = {} -- `table.insert(t, nil)`은 길이를 안 늘리므로 래퍼로 기록
|
||||
local o = s:Observer(function(_, _, from)
|
||||
table.insert(calls, { from = from })
|
||||
end)
|
||||
s:Set(2)
|
||||
assert(#calls == 1, "unbound observer does not fire")
|
||||
assert((o :: any)._rerunRequired == true, "…the change is held (H-159)")
|
||||
local inst = Instance.new("Frame")
|
||||
quad.bindLifetime(inst, o)
|
||||
assert(#calls == 2 and calls[2].from == nil, "bind replays the held change once, with no source (H-164)")
|
||||
assert((o :: any)._rerunRequired == false, "flag lowered")
|
||||
s:Set(3)
|
||||
assert(#calls == 3 and calls[3].from == s, "bound: fires with the source epoch")
|
||||
inst:Destroy()
|
||||
s:Set(4)
|
||||
assert(#calls == 3 and (o :: any)._rerunRequired == true, "dead inst: held again, not fired")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 3. 무인자 state:Observer() — 항상 관측 유틸(호출 즉시 Get) ===")
|
||||
do
|
||||
local runs = 0
|
||||
local s = Source(1)
|
||||
local d = s:Compute(function(x): number
|
||||
runs += 1
|
||||
return x:Get()
|
||||
end)
|
||||
local o = d:Observer()
|
||||
assert(runs == 1, "install fire observed (computed) once")
|
||||
o:Subscribe()
|
||||
s:Set(2)
|
||||
assert(runs == 2, "each change re-observes")
|
||||
o:Unsubscribe()
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 4. WeakSubscribe(프리미티브) — .Subscribed=true, 약한 등록, 캐치업; canExecute true ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local calls = 0
|
||||
local o = s:Observer(function()
|
||||
calls += 1
|
||||
end)
|
||||
s:Set(2) -- held
|
||||
assert(o:WeakSubscribe() == o, "returns self")
|
||||
assert(o.Subscribed == true and quad.canExecute(o) == true, "flag + gate")
|
||||
assert(calls == 2, "WeakSubscribe replayed the held change once")
|
||||
s:Set(3)
|
||||
assert(calls == 3, "fires while weakly subscribed")
|
||||
local ok, err = pcall(function()
|
||||
o:WeakSubscribe()
|
||||
end)
|
||||
assert(not ok and string.find(tostring(err), "already subscribed", 1, true) ~= nil, "double subscribe: " .. tostring(err))
|
||||
assert(string.find(tostring(err), "spec.observer.luau", 1, true) ~= nil, "level 2")
|
||||
local ok2, err2 = pcall(function()
|
||||
quad.bindLifetime(Instance.new("Frame"), o)
|
||||
end)
|
||||
assert(not ok2 and string.find(tostring(err2), "already subscribed", 1, true) ~= nil, "bindLifetime shares the gate: " .. tostring(err2))
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 5. WeakUnsubscribe — 관대(H-133), 단 강한 킵이 있으면 error ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local o = s:Observer(function() end)
|
||||
o:WeakUnsubscribe() -- never subscribed: silent
|
||||
o:WeakSubscribe():WeakUnsubscribe()
|
||||
assert((o :: any).Subscribed == false and quad.canExecute(o) == false, "released")
|
||||
o:WeakUnsubscribe() -- already released: silent
|
||||
o:Subscribe()
|
||||
local ok, err = pcall(function()
|
||||
o:WeakUnsubscribe()
|
||||
end)
|
||||
assert(not ok and string.find(tostring(err), "use :Unsubscribe()", 1, true) ~= nil, "strong keep present: " .. tostring(err))
|
||||
assert(o.Subscribed == true, "…and nothing was changed")
|
||||
o:Unsubscribe()
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 6. Subscribe/Unsubscribe — 인라인 게이트(H-149), 강한 킵, 엄격 해제 ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local calls = 0
|
||||
local o = s:Observer(function()
|
||||
calls += 1
|
||||
end)
|
||||
s:Set(2)
|
||||
assert(o:Subscribe() == o and calls == 2, "Subscribe replays the held change once")
|
||||
local ok, err = pcall(function()
|
||||
o:Subscribe()
|
||||
end)
|
||||
assert(not ok and string.find(tostring(err), "spec.observer.luau", 1, true) ~= nil, "double subscribe points at the caller: " .. tostring(err))
|
||||
local w = s:Observer(function() end):WeakSubscribe()
|
||||
local ok2, err2 = pcall(function()
|
||||
w:Unsubscribe()
|
||||
end)
|
||||
assert(not ok2 and string.find(tostring(err2), "use :WeakUnsubscribe()", 1, true) ~= nil, "strict: weakly subscribed → error: " .. tostring(err2))
|
||||
assert(w.Subscribed == true, "…untouched")
|
||||
assert(o:Unsubscribe() == o and o.Subscribed == false and quad.canExecute(o) == false, "released both tables")
|
||||
local ok3 = pcall(function()
|
||||
o:Unsubscribe()
|
||||
end)
|
||||
assert(not ok3, "second Unsubscribe errors (strict)")
|
||||
s:Set(3)
|
||||
assert(calls == 2, "released observer is silent")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 7. GC — 강한 구독은 참조를 안 들어도 산다, 약한 구독은 놓으면 수거 ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local strongCalls, weakCalls = 0, 0
|
||||
local function make()
|
||||
s:Observer(function()
|
||||
strongCalls += 1
|
||||
end):Subscribe()
|
||||
s:Observer(function()
|
||||
weakCalls += 1
|
||||
end):WeakSubscribe()
|
||||
end
|
||||
make()
|
||||
collectgarbage()
|
||||
collectgarbage()
|
||||
strongCalls, weakCalls = 0, 0
|
||||
s:Set(2)
|
||||
assert(strongCalls == 1, "strongly subscribed observer survives with no references")
|
||||
assert(weakCalls == 0, "weakly subscribed observer was collected")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 8. 파동 중 새 구독자 — 스냅샷 계약(다음 파동부터) + 인스턴스별 레지스트리 ===")
|
||||
do
|
||||
local s = Source(1)
|
||||
local late: any = nil
|
||||
local lateCalls = 0
|
||||
s:Observer(function(_, _, from)
|
||||
if from ~= nil and late == nil then
|
||||
late = s:Observer(function(_, _, f)
|
||||
if f ~= nil then
|
||||
lateCalls += 1
|
||||
end
|
||||
end):Subscribe()
|
||||
end
|
||||
end):Subscribe()
|
||||
s:Set(2)
|
||||
assert(late ~= nil and lateCalls == 0, "subscriber added mid-wave is not visited in that wave")
|
||||
s:Set(3)
|
||||
assert(lateCalls == 1, "…but from the next wave")
|
||||
local other = Quad.New()
|
||||
mock.installLifetime(other)
|
||||
local oo = other.Source(0):Observer(function() end)
|
||||
assert(getmetatable(oo :: any) ~= getmetatable(late :: any), "each quad instance has its own Observer impl (H-174)")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== ALL PASS ===")
|
||||
|
|
@ -20,6 +20,7 @@ local TypeVersionCheck = require("./luau_packages/type_version_check")
|
|||
-- `Epoch` — 최소 인터페이스(`.claude/base/state-epoch-plan.md` §2). 판정은
|
||||
-- identity + "직전과 다른 Revision"뿐, 순서 비교 없음. 런타임 판별은 `isEpoch`.
|
||||
export type Epoch = { Revision: number }
|
||||
export type EpochSet = { [Epoch]: true } -- 집합이지 배열이 아니다(게이트 배치가 그대로 넘어온다)
|
||||
|
||||
-- `Relate` — inst를 weak 키로 하는 릴레이션(`.claude/base/relate-plan.md` "API").
|
||||
-- `inst`는 항상 weak, `Weak`/`Strong`은 value의 보관 방식만 가리킨다.
|
||||
|
|
@ -54,6 +55,31 @@ export type Ref<T> = {
|
|||
-- `fn(self, previous?, ...deps)` — 전부 lazy 핸들, 값은 `:Get()`으로만
|
||||
-- (`.claude/base/source-state-plan.md` "self 인자도 lazy 핸들로 통일").
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
|
||||
-- `Observer` — 값을 안 실어주는 leaf 구독(`source-state-plan.md` "`state:Observer(fn)`").
|
||||
-- `fn(targetState, self, emitFrom?)`: 3번째가 출처(`Epoch` | 집합), 설치·캐치업 발화는 `nil`.
|
||||
-- 네 진입점은 `lifecycle-pattern.md` (2) — Weak가 프리미티브, `.Subscribed`는 강·약 공용.
|
||||
export type Observer = {
|
||||
Subscribed: boolean,
|
||||
Subscribe: (self: Observer) -> Observer,
|
||||
WeakSubscribe: (self: Observer) -> Observer,
|
||||
Unsubscribe: (self: Observer) -> Observer,
|
||||
WeakUnsubscribe: (self: Observer) -> Observer,
|
||||
}
|
||||
export type ObserverFn<T> = (targetState: StateData<T>, self: Observer, emitFrom: (Epoch | EpochSet)?) -> ()
|
||||
|
||||
-- `EffectHandle` — `Effect(fn, ...deps)`의 핸들(`effect-plan.md`). `fn(self) -> ...cleanup`.
|
||||
-- 네 진입점은 Observer와 같은 이름·같은 게이트이되 **본문은 자기 것**(`H-144`).
|
||||
export type EffectHandle = {
|
||||
Subscribed: boolean,
|
||||
Rerun: (self: EffectHandle) -> EffectHandle,
|
||||
Subscribe: (self: EffectHandle) -> EffectHandle,
|
||||
WeakSubscribe: (self: EffectHandle) -> EffectHandle,
|
||||
Unsubscribe: (self: EffectHandle) -> EffectHandle,
|
||||
WeakUnsubscribe: (self: EffectHandle) -> EffectHandle,
|
||||
}
|
||||
export type EffectFn = (self: EffectHandle) -> ...(() -> ())
|
||||
|
||||
export type State<T> = StateData<T> & {
|
||||
-- deps는 `...any`다 — 타입팩 `D...`로 위치 인자를 좁히는 형태는 strict에서 콜백 dep
|
||||
-- 추론이 `{read Get: ...}`로 뒤틀려 정상 호출까지 막힌다(M2 단위 2 실측, 스파이크 15가
|
||||
|
|
@ -62,6 +88,8 @@ export type State<T> = StateData<T> & {
|
|||
With: (self: StateData<T>, ...any) -> State<T>,
|
||||
-- 애플리커티브 팩토리는 함수이거나 메소드형 `__apply`를 가진 객체(`H-94`/`H-158`).
|
||||
Apply: <U>(self: StateData<T>, factory: ((State<T>) -> U) | { __apply: (self: any, state: State<T>) -> U }) -> U,
|
||||
-- 등록 즉시 1회 실행. 무인자면 "항상 관측" 유틸.
|
||||
Observer: (self: StateData<T>, fn: ObserverFn<T>?) -> Observer,
|
||||
}
|
||||
-- `Source`는 `State`를 구조적으로 만족하고 동시에 `Epoch`다(다중 태깅).
|
||||
export type Source<T> = State<T> & {
|
||||
|
|
@ -110,6 +138,8 @@ export type Quad = {
|
|||
-- 단위 2. `State`는 런타임 생성자가 없다(파생 전용) — 타입만 위에서 export.
|
||||
Source: <T>(v: T) -> Source<T>,
|
||||
Store: <T>(defaults: T?) -> Store<T>,
|
||||
-- 단위 3. deps는 State/Source/Ref(`H-70`), `fn`엔 안 넘어간다.
|
||||
Effect: (fn: EffectFn, ...any) -> EffectHandle,
|
||||
|
||||
-- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입
|
||||
-- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`.
|
||||
|
|
@ -117,6 +147,8 @@ export type Quad = {
|
|||
unbindLifetime: (value: any) -> (),
|
||||
canBound: (value: any) -> boolean,
|
||||
canExecute: (value: any) -> boolean,
|
||||
-- 주입 엔진 op(`architecture.md` EngineOps) — `Effect._bindDestroying`이 부른다. 미주입이면 에러 스텁.
|
||||
onDestroying: (inst: any, fn: () -> ()) -> { Connected: boolean, Disconnect: (self: any) -> () },
|
||||
|
||||
-- 브랜드 술어(`.claude/base/brand-plan.md`). 나머지(`isTag`/`isAttribute*`/
|
||||
-- `isTween`/`isSlot`)는 그 타입의 마일스톤에서.
|
||||
|
|
|
|||
Loading…
Reference in a new issue