From dca789ecca71b25870f32497a33d2465d88d07f6 Mon Sep 17 00:00:00 2001 From: qwreey-agent-selene Date: Sat, 29 Aug 2026 01:52:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(m2):=20=EB=8B=A8=EC=9C=84=203=20=E2=80=94?= =?UTF-8?q?=20Observer(=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=C2=B7=EB=84=A4=20=EC=A7=84=EC=9E=85=EC=A0=90?= =?UTF-8?q?)=20/=20Effect(rawRerun=C2=B7=5FrerunRequired=20=ED=99=80?= =?UTF-8?q?=EB=93=9C=C2=B7cleanup=C2=B7=EB=84=A4=20=EC=A7=84=EC=9E=85?= =?UTF-8?q?=EC=A0=90)=20+=20onDestroying=20=EC=8A=A4=ED=85=81=C2=B7mock=20?= =?UTF-8?q?+=20spec=202=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../pre-implementation-handtrace-round11.md | 33 ++ .claude/session-summary.md | 3 + .../2026-08-28-03-m2-unit1-common-base.md | 18 +- ROADMAP.md | 14 +- quad-base/src/Effect.luau | 279 +++++++++++++++++ quad-base/src/LifetimeHandle.luau | 7 + quad-base/src/Observer.luau | 161 ++++++++++ quad-base/src/State.luau | 13 + quad-base/src/init.luau | 4 + quad-base/test/mock.luau | 10 + quad-base/test/spec.effect.luau | 281 ++++++++++++++++++ quad-base/test/spec.observer.luau | 210 +++++++++++++ quad-types/src/init.luau | 32 ++ 13 files changed, 1055 insertions(+), 10 deletions(-) create mode 100644 quad-base/src/Effect.luau create mode 100644 quad-base/src/Observer.luau create mode 100644 quad-base/test/spec.effect.luau create mode 100644 quad-base/test/spec.observer.luau diff --git a/.claude/qa-request/pre-implementation-handtrace-round11.md b/.claude/qa-request/pre-implementation-handtrace-round11.md index 9227333..6de9c25 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round11.md +++ b/.claude/qa-request/pre-implementation-handtrace-round11.md @@ -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/...`. diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 228fc8e..64d3d1c 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -2032,3 +2032,6 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs 놓음, 권고안 기각) / `H-168` `Ref<>()` / `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`(`_` 접두) 기록. diff --git a/.claude/session/2026-08-28-03-m2-unit1-common-base.md b/.claude/session/2026-08-28-03-m2-unit1-common-base.md index 392c678..c86aa93 100644 --- a/.claude/session/2026-08-28-03-m2-unit1-common-base.md +++ b/.claude/session/2026-08-28-03-m2-unit1-common-base.md @@ -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` + 탑레벨 마무리). diff --git a/ROADMAP.md b/ROADMAP.md index d7d9920..d36566c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -481,11 +481,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 부르는 순수 설탕, `factory: (State) -> 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라운드가 찾은 종류의 결함을 그대로 낳는다 diff --git a/quad-base/src/Effect.luau b/quad-base/src/Effect.luau new file mode 100644 index 0000000..e4ab321 --- /dev/null +++ b/quad-base/src/Effect.luau @@ -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, +} diff --git a/quad-base/src/LifetimeHandle.luau b/quad-base/src/LifetimeHandle.luau index 38160a5..683d6bc 100644 --- a/quad-base/src/LifetimeHandle.luau +++ b/quad-base/src/LifetimeHandle.luau @@ -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 diff --git a/quad-base/src/Observer.luau b/quad-base/src/Observer.luau new file mode 100644 index 0000000..286ca91 --- /dev/null +++ b/quad-base/src/Observer.luau @@ -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, +} diff --git a/quad-base/src/State.luau b/quad-base/src/State.luau index 4f2dffa..8330d7c 100644 --- a/quad-base/src/State.luau +++ b/quad-base/src/State.luau @@ -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 = QuadTypes.StateData @@ -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) diff --git a/quad-base/src/init.luau b/quad-base/src/init.luau index 4919e44..b8a9e0f 100644 --- a/quad-base/src/init.luau +++ b/quad-base/src/init.luau @@ -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 diff --git a/quad-base/test/mock.luau b/quad-base/test/mock.luau index b2b1bc3..feeeee5 100644 --- a/quad-base/test/mock.luau +++ b/quad-base/test/mock.luau @@ -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 { diff --git a/quad-base/test/spec.effect.luau b/quad-base/test/spec.effect.luau new file mode 100644 index 0000000..fb05529 --- /dev/null +++ b/quad-base/test/spec.effect.luau @@ -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 ===") diff --git a/quad-base/test/spec.observer.luau b/quad-base/test/spec.observer.luau new file mode 100644 index 0000000..13529ca --- /dev/null +++ b/quad-base/test/spec.observer.luau @@ -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 ===") diff --git a/quad-types/src/init.luau b/quad-types/src/init.luau index 3267b93..1744ef5 100644 --- a/quad-types/src/init.luau +++ b/quad-types/src/init.luau @@ -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 = { -- `fn(self, previous?, ...deps)` — 전부 lazy 핸들, 값은 `:Get()`으로만 -- (`.claude/base/source-state-plan.md` "self 인자도 lazy 핸들로 통일"). export type StateData = { Get: (self: StateData) -> 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 = (targetState: StateData, 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 = StateData & { -- deps는 `...any`다 — 타입팩 `D...`로 위치 인자를 좁히는 형태는 strict에서 콜백 dep -- 추론이 `{read Get: ...}`로 뒤틀려 정상 호출까지 막힌다(M2 단위 2 실측, 스파이크 15가 @@ -62,6 +88,8 @@ export type State = StateData & { With: (self: StateData, ...any) -> State, -- 애플리커티브 팩토리는 함수이거나 메소드형 `__apply`를 가진 객체(`H-94`/`H-158`). Apply: (self: StateData, factory: ((State) -> U) | { __apply: (self: any, state: State) -> U }) -> U, + -- 등록 즉시 1회 실행. 무인자면 "항상 관측" 유틸. + Observer: (self: StateData, fn: ObserverFn?) -> Observer, } -- `Source`는 `State`를 구조적으로 만족하고 동시에 `Epoch`다(다중 태깅). export type Source = State & { @@ -110,6 +138,8 @@ export type Quad = { -- 단위 2. `State`는 런타임 생성자가 없다(파생 전용) — 타입만 위에서 export. Source: (v: T) -> Source, Store: (defaults: T?) -> Store, + -- 단위 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`)는 그 타입의 마일스톤에서.