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`)๋Š” ๊ทธ ํƒ€์ž…์˜ ๋งˆ์ผ์Šคํ†ค์—์„œ.